<!-- OpenSmartRoute: osr-financial-control-plane. https://opensmartroute.ai/docs/skills/osr-financial-control-plane -->

# Financial control plane for the hosted platform

The platform already sells flat plans through Stripe (`billing.py`), meters every request (`usage` rows), knows
what each model costs upstream (`models.py`, `RouteTarget.cost`) and has an operator console. What it does
**not** do yet is connect those into one money flow:

```text
Providers -> Models -> Price books -> Routing -> Usage -> Customer charge -> Ledger -> Invoice
                                                       -> Provider cost   -> Provider ledger -> Settlement
                                                                                -> Margin -> Analytics
```

The design principle is **two independent price books per model**: what the provider charges us
(*provider cost*) and what we charge the customer (*customer price*), with the pricing version pinned on every
metered row so `gross margin = customer revenue - provider cost - fees` is reproducible months later. Money
movement stays in Stripe; the platform owns metering, pricing, the ledger and the analytics.

Read `.claude/skills/osr-platform/SKILL.md` first for the platform conventions (routers per domain,
`openapi.json` regeneration, docs-claims gates, Postgres portability). This skill is the finance-specific
playbook on top of it.

## What exists today (build on it, do not duplicate it)

| Concern | Where it lives now | What it gives you |
|---|---|---|
| Metering | `db.record_usage(...)` called from `PlatformAPI.metered(...)` (api.py) for route / chat / embeddings / audio / images; table `usage` (`account_id, key_id, ts, day, endpoint, target_id, ok, status, latency_ms, tokens, cost_usd, request_id, model, domain, complexity, user_id, tenant`), `usage_daily` (requests per day, the plan quota) | one row per metered request; `tokens` is the total, `cost_usd` is the **provider cost estimate** (`ExecutionResult.outcomes[*].cost_usd`, falling back to `RouteTarget.estimated_cost(tokens)` = `usd_per_call + usd_per_1k_tokens * tokens / 1000`) |
| Budgets | `PlatformAPI.budgets(p, tenant)` / `check_budget` (policy `daily_budget_usd`, `monthly_budget_usd`), `db.spend`, `db.spend_by_tenant`, `alerts.budget_alerts` | spend caps per workspace and tenant, 429 + `X-Budget-*` headers |
| Provider prices | `models.LLM.input_usd_per_1m / output_usd_per_1m / cache_read_usd_per_1m` (OpenRouter snapshot `data/models.json`, refreshed by `rankings.py`); SDK `RouteTarget.cost` (`usd_per_1k_tokens`, `usd_per_call`) in `targets.yaml` | list prices per model, the router's cost objective |
| Providers | `providers.Provider` (deployment-wide, `PRESETS`, `probe()` health check, `ModelMapping` target -> upstream model), `workspace_providers` (BYOK, AES-256-GCM credentials) | who executes which target, credentials, health |
| Plans and subscriptions | `plans.Plan` (`price_usd_month/year`, `requests_per_month`, `overage_usd_per_100k`, `trial_days`), `billing.Billing` (Checkout, in-place plan change, portal, invoices, cancel, webhook, `sync_overage` -> Stripe Billing Meter events, `admin_router` console API), `coupons.py` | recurring revenue, overage per 100k requests, coupon trials, the operator Billing page |
| One-time payments | `Billing.listing_checkout_url` (`mode=payment`, `metadata[listing]`) and `Billing.on_purchase` | the pattern for credit purchases |
| Analytics | `admin_console.py` `/analytics` (cost per day, per target, per workspace, per plan), `reports.py` digests, `telemetry.py` series, dashboard `/savings` (`baseline_cost - routed_cost`) | cost views without a revenue side |
| Audit and roles | `db.record_audit(action, actor_type=, actor_id=, actor_label=, account_id=, **details)`, `OPERATOR_ROLES = ("operator", "superadmin")`, `PlatformAPI.require_superadmin` | who did what; only two operator roles |

Two gaps drive everything below: `usage.cost_usd` has no customer-price twin, and nothing is versioned.

## Target model

```text
                     ┌── model_prices (provider book, versioned) ──┐
usage event ──> pricing engine ──┬── charge_usd  (customer)  ──> ledger_entries (usage) ──> invoice / wallet
  tokens in/out, cached          └── cost_usd    (provider)  ──> provider_spend ──> provider_invoices -> settlement
  provider, model, price_version                                   ▲
                     └── price_rules (markup, tiers, per-customer) ┘
```

Every table is additive and portable (`db.SCHEMA` + `Database._migrate` `ALTER TABLE ... ADD COLUMN`; SQLite and
PostgreSQL through the `?` placeholders `to_postgres()` rewrites). Money is stored in **integer minor units**
(`amount_cents INTEGER`, `currency TEXT`) - never floats on the ledger; `usage.cost_usd` stays a float estimate.

## Phase 0 - metering fidelity (prerequisite for everything)

Extend the `usage` row, keeping the old columns:

| Column | Source | Why |
|---|---|---|
| `prompt_tokens`, `completion_tokens`, `cached_tokens` | `ExecutionResult.response` usage (`_cost_tokens` reads `total_tokens` today; `/v1` handlers already expose `prompt_tokens` / `completion_tokens` in trace attributes) | input and output are priced differently |
| `provider_id`, `upstream_model` | `PlatformAPI._executed_on(result)` + the provider/BYOK mapping already put in `X-OSR-Provider` | provider cost and settlement are per provider |
| `charge_usd` | pricing engine (Phase 1) | the customer side of the row |
| `price_version` | pricing engine | reproducible margin |
| `billed_at`, `ledger_id` | Phase 2/3 | which invoice / ledger entry consumed the row |

Set them in `metered()` where `cost_usd` is set; the `fn()` callables return `(payload, target_id, tokens, cost)` -
widen that tuple to a small dataclass (`MeteredResult`) rather than growing the tuple. Feature-flag nothing:
unknown values stay `NULL`. Keep `/savings` and `/analytics` reading `cost_usd` until Phase 5 switches them.

A usage event is the contract; write it once in `db.record_usage` and never derive billing from the UI or from
`telemetry_events`.

## Phase 1 - price books and the pricing engine

Tables:

```text
model_prices      id, provider_id (NULL = list price), model, unit (token_in|token_out|token_cached|call|
                  image|second), usd_per_million REAL, currency, effective_from, effective_to, source
                  (catalogue|manual|contract), created_by, created_at
price_rules       id, scope (deployment|plan|account), scope_id, kind (markup_pct|markup_fixed|per_million|
                  tier|min_charge|promo), model (NULL = all), provider_id, params JSON, effective_from,
                  effective_to, priority, created_by, created_at
price_versions    id, created_at, created_by, note, snapshot JSON   (hash of the active books + rules)
```

Seed `model_prices` from `models.LLM` (`input_usd_per_1m` -> `token_in`, `output_usd_per_1m` -> `token_out`,
`cache_read_usd_per_1m` -> `token_cached`) in the `rankings` refresh so list prices track the catalogue; manual
and contract rows override by `provider_id` + `effective_from`.

The engine is a **pure function** in a new module `pricing.py`:

```python
@dataclass(frozen=True)
class Quote:
    cost_cents: int        # provider side
    charge_cents: int      # customer side
    currency: str
    version: str           # price_versions.id the books/rules were frozen under
    lines: tuple[QuoteLine, ...]  # unit, quantity, provider rate, customer rate, rule ids applied

def quote(event: UsageEvent, books: PriceBooks, rules: RuleSet, *, at: float) -> Quote: ...
```

Rules resolve most-specific first (`account` > `plan` > `deployment`), then by `priority`; a `min_charge` rule
applies last. Percentage and fixed markups are the common case (`$2.00 / 1M in -> 20 % -> $2.40 / 1M`);
tiers take `params = {"tiers": [{"upto_tokens": 1e9, "markup_pct": 20}, ...]}` evaluated against the month's
volume from `usage_daily`. Currency conversion is a rule of kind `fx` with a pinned rate, applied before rounding.
Round once, at the end, half-up to cents; store both sides.

Cache `PriceBooks` / `RuleSet` in the `Billing`-style service object and invalidate on write (the
`RegistryStore.facets()` TTL + `invalidate()` pattern). Every write to `model_prices` / `price_rules` creates a
`price_versions` row and an audit entry with `before` and `after` (see RBAC below).

Router integration: keep routing on **provider cost** (`RouteTarget.cost`), because that is what the
cost objective optimises; expose the customer side to the caller as `X-OSR-Charge-USD` next to the existing
`X-OSR-Provider` header (add it to `EXPOSED_HEADERS` in `app.py` and `FORWARD_RESPONSE_HEADERS` in
`platform/web/src/lib/api/proxy.ts`). When a provider's balance (Phase 4) is exhausted, feed that into the
health breakers rather than the price - a provider we cannot pay is unhealthy, not expensive.

## Phase 2 - credits wallet and the ledger

```text
ledger_entries    id, account_id, ts, kind (purchase|usage|promo|adjustment|refund|invoice_settlement),
                  amount_cents (signed), currency, reference (stripe payment intent / invoice / usage day /
                  coupon), actor_type, actor_id, note, price_version, idempotency_key UNIQUE
```

Rules that keep the ledger trustworthy:

- **Append-only.** No `UPDATE` on `ledger_entries`; corrections are new `adjustment` rows with a `note` and the
  operator's id. The balance is `SUM(amount_cents)`; cache it per account in `accounts.balance_cents` only as a
  denormalised read model rebuilt from the ledger, never written by hand.
- **Idempotent writes.** `idempotency_key` = Stripe event id for purchases (the `billing_events` table already
  de-duplicates webhook ids), `usage:<account>:<day>` for the daily usage debit (one debit per workspace per
  UTC day, summed from `usage.charge_usd`; the compare-and-set style of `db.claim_overage` is the model).
- **Purchases** reuse `Billing.listing_checkout_url`: `mode=payment`, `metadata[credits_cents]`, success URL back
  to `/platform/dashboard/billing?credited=<amount>`; `Billing.handle_event` routes `checkout.session.completed`
  with that metadata to a `credit()` method the way `metadata[listing]` reaches `on_purchase`.
- **Promotional / enterprise credits** are `promo` / `adjustment` entries created from the console with a reason;
  coupons (`coupons.py`) stay the tool for plan trials, credits are money.
- **Low balance** is an alert rule next to `alerts.quota_alert` (`wallet` rule: 20 %, exhausted), delivered
  through the existing notification channels and the owner-fallback e-mail; `PlatformAPI.metered` refuses
  spending calls (`meta["spends"]`) with `402` when the balance is exhausted and the plan is wallet-billed.

Per plan, add `billing_mode` (`subscription` = today's flat plan + overage, `wallet` = prepaid usage,
`invoice` = enterprise net terms). Do not mix overage meter events and wallet debits for the same request: the
mode decides which path `sync_overage` / the daily usage debit takes.

## Phase 3 - invoices and tax

- **Subscriptions** keep Stripe as the system of record (`GET /api/v1/billing/invoices` already lists them).
- **Usage charges** for `subscription` plans go to Stripe as a *dollar* meter (`payload[value]` = cents of
  `charge_usd`, a second Billing Meter next to `osr_overage_requests`) so they land on the same invoice;
  `sync_overage` becomes `sync_usage_charges` with the same CAS bookkeeping.
- **Wallet** customers get a monthly statement rendered from `ledger_entries` (PDF through the mail
  templates' `_shell`, stored under `data_dir / "statements"`), not a Stripe invoice.
- **Enterprise** (`invoice` mode) uses Stripe Invoicing (`collection_method=send_invoice`, `days_until_due`).
- **Tax**: enable Stripe Tax on Checkout (`automatic_tax[enabled]=true`) and collect tax ids in the portal
  (`customer_update.allowed_updates` already includes `tax_id` in the configuration `stripe_setup.py`
  creates). Store the customer's tax status on the account for the wallet statement.
- **Refunds** are Stripe refunds first, then a `refund` ledger entry keyed by the refund id from the
  `charge.refunded` webhook (add it to `WEBHOOK_EVENTS` in `stripe_setup.py` and `Billing.handle_event`).

## Phase 4 - provider finance and settlement

```text
provider_accounts  provider_id, currency, balance_cents (prepaid providers), credit_limit_cents,
                   contract_terms TEXT, effective_from, low_balance_cents
provider_invoices  id, provider_id, period_start, period_end, amount_cents, currency, invoice_ref,
                   received_at, paid_at, status (expected|received|disputed|paid), note
```

Expected spend for a period is `SUM(usage.cost_usd)` grouped by `provider_id` (Phase 0 column); variance =
invoice - expected, shown per provider with the top models behind it. Reconciliation is a console screen,
settlement is recording `paid_at` - the platform does not pay providers itself. Provider balance decrements
are a projection of the same `usage.cost_usd` sum; when it crosses `low_balance_cents` raise a deployment alert
(`alerts.deployment_alerts`) and mark the provider `healthy=False` so the router fails over.

The onboarding wizard the console needs is mostly there: `PRESETS` (details), credentials (`providers.py`
encrypts), **Test connection** (`probe()` -> `record_check`), **Discover models** (`upstream_models`),
**Model mapping** (`ModelMapping`). Add two steps - *Provider pricing* (rows into `model_prices` with
`provider_id`) and *Reseller pricing* (a `price_rules` row) - and refuse `enabled=True` until `probe()` succeeded
and every mapped model has a price.

## Phase 5 - revenue, cost and margin analytics

Extend `admin_console.py` `/analytics` (same `days` window, same `daily` series shape) with a `finance` block
and give the operator Billing page (`app/platform/admin/(console)/billing/page.tsx`) a *Revenue* tab:

```text
revenue_cents      subscriptions (MRR from live plans, the estimate `Billing.mrr_usd` makes today)
                 + usage charges (SUM charge_usd)  + credits consumed (ledger usage debits)
provider_cost      SUM cost_usd (by provider, model, workspace)
gross_margin       revenue - provider_cost - stripe fees (from invoice `balance_transaction` when expanded)
outstanding        Stripe invoices open/past_due (already in the webhook log) + wallet balances
by dimension       model, provider, workspace, plan, day
```

Render with the existing `StatCard` / `DataTable` kit; reuse the Billing page's `run()` helper for actions.
Customer side: the dashboard billing page gets a *Usage* table (`day, model, provider, requests, tokens in/out,
charge`) from a new workspace route that reads `usage` with the Phase 0 columns - filters by key, tenant, model
and day; CSV through `csv_response`.

## Phase 6 - RBAC and audit for money

Extend `OPERATOR_ROLES` to `("operator", "finance", "provider_manager", "superadmin")` and add
`PlatformAPI.require_role(op, *roles)` next to `require_superadmin` (superadmin passes everything). Guard:

| Action | Roles |
|---|---|
| price books, price rules, refunds, adjustments, settlements | `finance`, `superadmin` |
| providers, credentials, model mapping, provider pricing | `provider_manager`, `superadmin` |
| read revenue / margin | any operator |

Every financial mutation records `record_audit(action, actor_type="operator", ..., before=<old>, after=<new>,
reason=<free text the console requires>)` - `pricing.rule_changed`, `pricing.model_price_changed`,
`ledger.adjustment`, `billing.refund`, `provider.invoice_recorded`, `provider.settled`. The console shows
"Sathish changed GPT model X token_in from 2.00 to 2.20 USD/1M" from `before` / `after`; never overwrite or
delete audit rows (retention purges by age only).

## Conventions and gates (the ones this work trips)

- New tables go into `db.SCHEMA` with `CREATE TABLE IF NOT EXISTS`; new columns on existing tables through
  `Database._migrate`; qualify columns in `ON CONFLICT ... DO UPDATE SET x=table.x+1` (PostgreSQL). Run the suite
  on PostgreSQL too (`OSR_TEST_DATABASE_URL`).
- Routes: workspace routes in the `billing` domain (`Billing.router`), operator routes in the `admin` domain
  (`Billing.admin_router` / a new `pricing_admin_router` mounted in `PlatformContext.routers("admin")`), prefixes
  registered in `services.SERVICES`; regenerate `platform/api/openapi.json` **and** `platform/sdk-ts/src/generated`
  (`npm run generate` in `platform/sdk-ts`) or the SDK TypeScript pipeline fails.
- Settings: new knobs are `PlatformSettings` fields mapped in `from_env` (the `OSR_PLATFORM_` prefix rule); add
  secret-looking names to `admin_console._SECRET_MARKERS`, the group to `_GROUPS`. Stripe's existing knobs are
  `OSR_PLATFORM_STRIPE_SECRET`, `OSR_PLATFORM_STRIPE_WEBHOOK_SECRET`, `OSR_PLATFORM_STRIPE_PRICES`,
  `OSR_PLATFORM_STRIPE_METER_EVENT`, `OSR_PLATFORM_OVERAGE_SYNC_S`.
- Tests replace `Billing._post` / `_get` with `FakeStripe` (`platform/api/tests/test_billing.py`); extend that fake
  for payment-mode sessions, refunds and the dollar meter rather than mocking HTTP. Pricing engine tests are
  table-driven on `quote()`; ledger tests assert `SUM(amount_cents)` and idempotent replays.
- Docs: each console page needs a row in the operator-console table of `docs/PLATFORM.md`, each dashboard page a
  row in the dashboard table (`test_platform_docs_claims` checks both); user-facing billing behaviour goes into
  the "Billing" section; CHANGELOG under `[Unreleased]`.
- Prices quoted in Markdown must equal a `Plan.price_usd_month` (`test_documented_prices_match_the_plan_catalogue`).

## Pitfalls

- Never compute the customer price from the provider price at read time - pin `price_version` on the row.
- Never store a mutable balance as the source of truth; rebuild `balance_cents` from the ledger.
- One request must hit exactly one billing path: overage meter (subscription plans) **or** wallet debit **or**
  invoice line - decide by `Plan.billing_mode`, not by what data happens to exist.
- Stripe refuses mixed intervals on one subscription - the dollar usage meter must be a monthly metered price
  on monthly subscriptions and a yearly one on yearly (`<plan>:overage:year` shows the pattern).
- Month boundaries are UTC (`month_of()`); quotas, overage, usage debits and statements must agree on it.
- Provider list prices from the catalogue are *list*; a contract row with `provider_id` must win, and a
  catalogue refresh must never overwrite manual or contract rows.
