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!innerfiltered onis_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
includeTestandtestSpend(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 spendPer-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-2callbacks carry nocreditsConsumed, so that model is priced from the table below at$0.04(8 credits) — verified with kierecordInfoon 15 jobs spread over every week since 2026-07-29: constant 8 credits. Until #423 added that price, all 731 such jobs stayedcost_usd = nulland 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→ unitimages,$0.08— exact, per image.nano-banana-lite/editand gpt-image → unitunits,$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) readsapp_configkeyscost_kie_credit_usd,cost_model_prices(JSON mapprovider:model→$), andcost_fallback_usd. Owner overrides merge over the code defaults (DEFAULT_MODEL_PRICES, which lives in the pure unitlib/billing/generation-cost.tsand is unit-tested). An unknown model falls back tocost_fallback_usd— never guessed, and the Costs tab names it (see the uncosted warning below). refreshGenerationCosts()costs newly-completed null-cost_usdjobs. Since #380 it runs from the hourly cron/api/cron/generation-costs(35 * * * *, behind a cron lock) — not from the costsGET, 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 within(id, …)batches — the #423 backfill costed 731 jobs in a handful of statements instead of 731 sequentialUPDATEs inside an admin request.getGenerationSpend()→ SQL functiongeneration_spend_summary→ spend byprovider:model(month + all-time).getGenerationBreakdown(range)/getGenerationMonthly()→ SQL functiongeneration_cost_breakdown(p_from, p_to)→ one row per month × provider × model × routing bucket × status withjobs,jobs_costedandspend. Everything the Costs tab draws (provider share, provider×model, tier-combined average, month series) is shaped from that one result set by the pure unitlib/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 APIGET api.fal.ai/v1/models/usage(paramsstart/endas ISO dates — notstart_date;expand=time_series;cursorpagination) and sums the real billedquantity×costper endpoint. Pure functionsumFalUsageinsrc/lib/billing/fal-usage.ts. Gated on envFAL_ADMIN_KEY. This is the only accurate cost source for token-billed models — it exposed that the per-job estimate was 12× low onnano-banana-lite($8.69 real vs $0.68 estimated), whilenano-banana-2matched 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_priceorgetPlan(planId).price, counted for the Paying segment only (#724) — an in-trial approval has no base fee yet and an uninstalled store’s staleactiveis 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_pendingextra try-ons accrued since the last settlement, × the rate (custom_overage_rateorplan.overage). Not yet billed; the cron resets it to0on 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_settledSettled 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()callsGET api.vercel.com/v1/billing/charges(paramsfrom/to— notstart/end; FOCUS v1.3 JSONL format) and sumsBilledCostvia the pure functionsumFocusBilledCostinsrc/lib/billing/infra-cost.ts. Gated on envVERCEL_BILLING_TOKEN+VERCEL_TEAM_ID. Best-effort — returnsnullon 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
staticMonthlyCostsin 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 asprimary_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.
Related
- Billing Pipeline — plan MRR, overage accrual/settlement, usage accounting.
- AI Providers — kie.ai and fal.ai client details.
- Data Model —
tryon_provider_jobs,stores.