Cost & P&L

Cost & P&L

The admin Costs & P&L tab (/admin) shows real-time generation cost and profit. Backend: GET /api/admin/costs (admin-only). On each request it first lazily costs any newly-completed jobs, then reads and returns range, config, measuredSpend, falUsage, authoritativeProviderSpend, storeProfitability, vercelInfraSpend, staticMonthlyTotal.

Date range — Costs, Overview, and Generations accept a custom range via ?from=&to= (YYYY-MM-DD, to exclusive; parseAdminRange defaults to month-to-date). It threads through the ranged RPCs (generation_spend_summary(p_from, p_to), store_generation_spend(p_from, p_to)) and every cost helper. Caveat: base-plan MRR is not historized — a past month reuses the store’s current plan price, so only provider spend, overage, and infra are truly historical.

Test stores are out of the P&L by default (#384)

Every number on the tab — the P&L tiles, unit economics, provider counts, the routing/model breakdown and the month series, the profit-by-merchant table and its CSV — is the business: generations on stores whose CURRENT is_test is true are left out (the prod smoke store makes real, paid kie/fal calls on every smoke). GET /api/admin/costs?includeTest=1 brings them back; the tab’s Hide test stores box (the same excludeTest switch as the merchants list, on by default) drives it, and the Overview’s P&L line reads the same payload, so the two can never disagree.

  • The spend RPCs group jobs without a store, so instead of changing them (a migration) the test stores’ own jobs are read once per range — readTestStoreJobs (jobs → tryon_sessions → stores!inner filtered on is_test, paged, shared in flight and cached 60 s) — and subtracted with the RPCs’ exact keys (lib/admin/test-spend.ts: provider + model; month + provider + model
    • routing + status). Rows that held only test jobs disappear.
  • fal’s invoice cannot be split by store: the test stores’ MEASURED fal spend is taken off it in authoritativeProviderSpend, and the fal panel shows that line (“of which test stores”).
  • The payload carries includeTest and testSpend (jobs, spend, per provider) — the tab states what it left out. A store whose flag flips is classified by its current flag for the whole range.
  • getStoreProfitability(range, opts) defaults to including test stores (merchant tuning looks up one store’s row); only the Costs route asks for the business view.

The profit formula

Net profit = Revenue − Provider spend − Infra
Revenue        = Est. MRR (base plans of Paying stores only, #724) + Usage overage (pending)
Provider spend = real fal billing (usage API) + kie actual credits   [authoritative]
Infra          = static monthly costs + live Vercel spend

Per-merchant: Margin = (plan MRR + overage) − that store's generation cost.

Per-generation cost — tryon_provider_jobs.cost_usd

Captured from data already on the job row — no change to the generation hot path. Pure function computeGenerationCost in src/lib/billing/generation-cost.ts.

  • kie.ai — actual metered credits, read from response_payload.taskRecord.creditsConsumed × $0.005/credit. Exact when kie reports them. It does not report them for every model: nano-banana-2 callbacks carry no creditsConsumed, so that model is priced from the table below at $0.04 (8 credits) — verified with kie recordInfo on 15 jobs spread over every week since 2026-07-29: constant 8 credits. Until #423 added that price, all 731 such jobs stayed cost_usd = null and the P&L undercounted kie by ~$29 (2026-08-22).
  • fal.ai — fal bills by different unit types (confirmed via GET api.fal.ai/v1/models/pricing?endpoint_id=X):
    • nano-banana-2/edit → unit images, $0.08 — exact, per image.
    • nano-banana-lite/edit and gpt-image → unit units, $1.00 — token-based, not per image. Per-request unit consumption is not derivable per job, so token-billed models use an invoice-derived estimate at the per-job level (see the authoritative fal usage API below for the real total).
  • Config — getGenerationCostConfig (lib/server/supabase-admin.ts) reads app_config keys cost_kie_credit_usd, cost_model_prices (JSON map provider:model → $), and cost_fallback_usd. Owner overrides merge over the code defaults (DEFAULT_MODEL_PRICES, which lives in the pure unit lib/billing/generation-cost.ts and is unit-tested). An unknown model falls back to cost_fallback_usd — never guessed, and the Costs tab names it (see the uncosted warning below).
  • refreshGenerationCosts() costs newly-completed null-cost_usd jobs. Since #380 it runs from the hourly cron /api/cron/generation-costs (35 * * * *, behind a cron lock) — not from the costs GET, which only reads. Measured kie spend therefore lags by at most an hour; fal’s authoritative spend comes from its billing API and does not lag. Never touches the generation path.
  • refreshGenerationCosts() groups its writes by identical cost and updates them with in(id, …) batches — the #423 backfill costed 731 jobs in a handful of statements instead of 731 sequential UPDATEs inside an admin request.
  • getGenerationSpend() → SQL function generation_spend_summary → spend by provider:model (month + all-time).
  • getGenerationBreakdown(range) / getGenerationMonthly() → SQL function generation_cost_breakdown(p_from, p_to) → one row per month × provider × model × routing bucket × status with jobs, jobs_costed and spend. Everything the Costs tab draws (provider share, provider×model, tier-combined average, month series) is shaped from that one result set by the pure unit lib/billing/generation-breakdown.ts. The read is stampede-guarded: concurrent callers for the same window share one in-flight query and only a successful result is cached (60 s, keyed by range — a failure is never cached).

Migrations: 20260707_provider_job_cost_usd.sql (adds cost_usd numeric), 20260707_generation_spend_summary.sql, 20260822_generation_cost_breakdown.sql.

Why generation_model / smart_routing_reason are stored generated columns. They mirror request_payload->>'generationModel' / ->>'smartRoutingReason', which cannot drift because Postgres recomputes them on every write. They exist for speed: request_payload averages 2.5 KB and is TOASTed, so extracting those two strings dominated every cost aggregate. Measured on prod (13 887 jobs, 2026-06-01 → 2026-09-01): the month/provider/model/routing aggregate took 2.4–3.7 s reading the JSONB and 16–21 ms reading the same rows without it. No index can fix that — the values have to leave the TOAST.

⚠️

fal’s unit field matters: images is exact per-image pricing; units is token-based. Never multiply the $1.00 unit price by generation count for token-billed models — it will be wrong.

Authoritative provider spend

The per-job cost_usd estimate is good enough for the model-spend breakdown, but for token-billed fal models it is not the real invoice number. The authoritative total comes from each provider’s own billing API:

  • fal — getFalUsageSpendThisMonth() calls the admin-scope fal usage API GET api.fal.ai/v1/models/usage (params start/end as ISO dates — not start_date; expand=time_series; cursor pagination) and sums the real billed quantity × cost per endpoint. Pure function sumFalUsage in src/lib/billing/fal-usage.ts. Gated on env FAL_ADMIN_KEY. This is the only accurate cost source for token-billed models — it exposed that the per-job estimate was 12× low on nano-banana-lite ($8.69 real vs $0.68 estimated), while nano-banana-2 matched the real invoice exactly ($189.44).
  • kie — its cost_usd (actual credits) is already exact, no separate call needed.

authoritativeProviderMonth = falUsage.total + kie month cost_usd, falling back to the per-job estimate (measuredSpend.monthSpend) when FAL_ADMIN_KEY is absent.

⚠️

The fal usage API requires an admin-scope key — an API-scope key gets a 403. Query params are start/end, not start_date/end_date.

Per-merchant P&L — getStoreProfitability()

SQL function store_generation_spend joins each costed job to its store via tryon_provider_jobs.session_id → tryon_sessions.id → store_id. Per store:

  • Revenue = plan MRR (custom_price or getPlan(planId).price, counted for the Paying segment only (#724) — an in-trial approval has no base fee yet and an uninstalled store’s stale active is not revenue) + usage overage (below).
  • Margin = revenue − that store’s summed cost_usd.

This is contribution margin only — shared/fixed infra is not allocated per store.

Migration: 20260708_store_generation_spend.sql.

Usage overage in revenue

Revenue counts usage overage, not just base plan MRR. It has two parts:

overageRevenue = settled_this_month + (overage_pending × getOverageRate(store))
  • Settled — overage already billed to Shopify this month, summed from billing_events (event_type = 'overage_charged', success = true, amount). settlePendingOverage (lib/server/billing-overage.ts, overage cron) writes one such event each time it charges.
  • Pending — overage_pending extra try-ons accrued since the last settlement, × the rate (custom_overage_rate or plan.overage). Not yet billed; the cron resets it to 0 on settle.

Both are counted in per-merchant revenue and the app-wide P&L. Using settled + pending (rather than pending alone) is essential — a store settling daily can bill most of its month’s overage before you look, leaving overage_pending tiny. See Billing Pipeline for the full overage lifecycle.

Order-fee revenue (#439 / #441)

A revenue line beside plan MRR and overage: a percentage of qualifying orders. The published rate per plan is PLANS[x].orderFeeUpsellPercent (1% on all four today); the charged rate is resolved at runtime by #441 (per-store override → app_config per plan → app_config global → the published value) and stamped onto every fee row along with the source that won, so the P&L can always explain the number it billed.

Same shape as overage, and for the same reason — a store that settles daily bills most of the month before you look, so counting only what is still accrued understates it:

orderFeeRevenue = settled_this_month + accrued_not_yet_settled

Settled comes from billing_events (event_type = 'order_fee_charged', success = true, amount); accrued comes from order_fees rows still in accrued. Rows in excluded, skipped_cap, skipped_no_subscription or skipped_terms_not_approved are not revenue — they are the audit trail for money we chose not to charge, and the admin view reports them separately so that a silent cap, or a fleet still on the old usage terms, can never look like a lack of demand.

Margin note: an order fee carries no provider cost. Unlike a try-on it is pure gross margin, so folding it into the per-generation cost figures would flatter them. It stays its own revenue row.

Infra costs

  • Vercel — getVercelInfraSpendThisMonth() calls GET api.vercel.com/v1/billing/charges (params from/to — not start/end; FOCUS v1.3 JSONL format) and sums BilledCost via the pure function sumFocusBilledCost in src/lib/billing/infra-cost.ts. Gated on env VERCEL_BILLING_TOKEN + VERCEL_TEAM_ID. Best-effort — returns null on failure/missing config. (The account currently runs on Vercel Hobby, so this is typically ~$0/month.)
  • Supabase — no clean spend API exists, so it’s a flat static cost, part of staticMonthlyCosts in the cost config.
⚠️

The Vercel billing-charges API takes from/to, not start/end — the opposite convention from the fal usage API above. Easy to mix up.

Admin UI (Costs & P&L tab)

KPI cards: Est. MRR (base plans, Paying stores only), Usage overage (pending), Provider spend (real billing), Infra + static, Net profit.

Tables:

  • fal — real billing — real quantity/unit/cost per endpoint, straight from the fal usage API.
  • Provider routing (#423) — jobs, share of all jobs, completed/failed per provider, plus the smart-routing reason buckets (primary_kie, fallback_fal, disabled_store, (none) — the prefix of the recorded reason, so the ~40 raw strings such as primary_kie(kie_healthy(2/10)) collapse to four readable buckets).
  • Generation spend by model (#423) — per-provider rows with jobs / share / avg per generation / spend, and for a model served by BOTH providers a combined row: that is the price of the tier rather than of one vendor (nano-banana-2 = $0.08 on fal, $0.04 on kie → ≈ $0.0539 combined on prod for 2026-07-27 → 2026-08-22).
  • By month (#423) — one row per calendar month since 2026-06 with each provider’s share, jobs, spend and the combined average of any two-provider model. A month with no generations is still a row, rendered with —.
  • Profit by merchant — store, plan, MRR, overage, generation count, generation cost, margin.

Rules the tab obeys:

  • Every average divides by COSTED jobs, never by all jobs; the uncosted count is shown next to the average instead of being averaged away.
  • Jobs we cannot price are named: a banner reads N uncosted jobs — no price for <provider>:<model>, so a new unpriced model is visible immediately instead of quietly undercounting spend.
  • The range control also has a calendar-month picker, and the range lives in the URL (?from=&to=), so a cost view is shareable and bookmarkable.
  • If the breakdown RPC fails, only that block degrades — an inline error with Retry — while the rest of the tab keeps its numbers.

Cost config (cost_kie_credit_usd, cost_model_prices, cost_fallback_usd, static monthly costs) is editable from the same tab. A per-generation cost column also appears in the admin generations log and on the store-detail recent-generations list.

Env vars

See Env Vars (Cost & P&L section) for FAL_ADMIN_KEY, VERCEL_BILLING_TOKEN, and VERCEL_TEAM_ID.