Skip to content
Documentation
Agent skills

osr-financial-control-plane

Turn the hosted platform's billing into a financial control plane - usage metering with provider cost and customer charge side by side, a versioned pricing engine (provider price book, reseller markup, customer-specific and tiered pricing), an immutable credits wallet ledger, Stripe invoices and usage charges, provider finance (expected vs invoiced spend, reconciliation, settlement), revenue / cost / gross-margin analytics, finance and provider-manager operator roles and before/after audit. Use when adding reseller pricing, credits or prepaid balances, provider cost tracking, margin reports, provider settlement or finance RBAC to platform/api and the operator console, or when reasoning about how money flows through the platform.

Package
.claude/skills/osr-financial-control-plane
Compatibility
OpenSmartRoute repository, Python >= 3.11, Node >= 22, Stripe account (test mode first)
License
Apache-2.0
Domains
finance coding general
Quality prior
0.85
Tags
opensmartroute platform billing pricing ledger credits margin provider settlement stripe finance

Install by copying .claude/skills/osr-financial-control-plane/ into the skills folder of your coding assistant. To load every package as a routing target: osr --skills .claude/skills route "..." --plan.

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:

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)

ConcernWhere it lives nowWhat it gives you
Meteringdb.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)
BudgetsPlatformAPI.budgets(p, tenant) / check_budget (policy daily_budget_usd, monthly_budget_usd), db.spend, db.spend_by_tenant, alerts.budget_alertsspend caps per workspace and tenant, 429 + X-Budget-* headers
Provider pricesmodels.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.yamllist prices per model, the router's cost objective
Providersproviders.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 subscriptionsplans.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.pyrecurring revenue, overage per 100k requests, coupon trials, the operator Billing page
One-time paymentsBilling.listing_checkout_url (mode=payment, metadata[listing]) and Billing.on_purchasethe pattern for credit purchases
Analyticsadmin_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 rolesdb.record_audit(action, actor_type=, actor_id=, actor_label=, account_id=, **details), OPERATOR_ROLES = ("operator", "superadmin"), PlatformAPI.require_superadminwho 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

                     ┌── 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:

ColumnSourceWhy
prompt_tokens, completion_tokens, cached_tokensExecutionResult.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_modelPlatformAPI._executed_on(result) + the provider/BYOK mapping already put in X-OSR-Providerprovider cost and settlement are per provider
charge_usdpricing engine (Phase 1)the customer side of the row
price_versionpricing enginereproducible margin
billed_at, ledger_idPhase 2/3which 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:

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:

@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

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

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:

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:

ActionRoles
price books, price rules, refunds, adjustments, settlementsfinance, superadmin
providers, credentials, model mapping, provider pricingprovider_manager, superadmin
read revenue / marginany 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.