# Billing & credits audit (Descrybe v2) Date: 2026-08-04 Scope: credit grant, debit (SEO / campaign / enhance), remaining display, Free vs Enterprise, Billing UI vs API. ## Verdict Demo **Enterprise** wallet and Billing UI now agree on **1,000,000 remaining / 0 used**. **Free** grants **0** AI credits (`can_use_ai=false`). Stale `billing_cycles` rows no longer inflate “credits used” on `/api/billing/usage` or the Billing page. ## Trace (source of truth) | Concern | Path | Behavior | |--------|------|----------| | Grant on signup | `billing.ProvisionFreePlan` → `AssignPlan` | Free plan → wallet `total=0`, `used=0` | | Grant on assign / renew | `AssignPlan`, `RunDueBillingCycles` | Sets `credit_balances.total_credits` from `plans.monthly_credits` (or trial credits), resets `used=0`; opens a fresh open `billing_cycles` row | | Enterprise pack | `billing.EnterpriseUnlimitedCredits` | `1_000_000` (marketing “Unlimited”; wallet is finite so debit works) | | Demo grant | `cmd/seed-demo` | Assigns Enterprise to Local Demo Co | | Overview / me | `CreditsOverview` → `/api/billing/credits`, `/api/auth/me` | `remaining` = `max(0, total−used)`; entitlements `can_use_ai` / Free vs paid | | Usage | `UsageSummary(range)` → `/api/billing/usage?range=` | **Credits from live wallet**; product/token series filtered by range | | Enhance / process debit | `processing.Pipeline` → `ConsumeCredits(..., "product_processing")` | Skipped entirely when **BYOK**; Free + 0 tokens = no debit | | SEO AI debit | `seo.Service.Apply` mode=`ai` | Requires `CanUseAI` **and** `RemainingCredits≥1`, then `ConsumeCredits(..., "seo_meta_ai")` | | Campaign AI debit | `campaigns.Generate` mode=`ai` | Same gates; **errors propagated** (no silent swallow); `campaign_copy` cost row seeded | | Gates at job start | `AssertCanStartProcessing` | SKU cap always; credit wallet only when `RequiresAI` / `RequiresEPREL` | ### Debit formula `debit = cost(feature) + ceil(tokens/1000) * cost(openai_token_k)` (minimum 1 when charging). Default costs: `product_processing`, `openai_token_k`, `seo_meta_ai`, `campaign_copy` (all 1). ### Entitlements `ComputeEntitlements`: Free ⇒ no AI/EPREL unless leftover wallet credits; paid ⇒ `can_use_ai` even at 0 remaining (processing/SEO/campaign still require remaining ≥ 1 before AI work). ## Bugs found & fixed 1. **Billing UI vs API mismatch (critical)** `/api/billing/usage` previously preferred the latest `billing_cycles` row by `start_date`, including **ended** cycles. After `seed-demo` / `AssignPlan` reset the wallet to 1M/0, usage still showed e.g. **22 credits used** while remaining showed **1,000,000**. **Fix:** `UsageSummary` always reports wallet `used`/`total`/`remaining`; range filters products/tokens only. `AssignPlan` closes open cycles and inserts a new open cycle. `ConsumeCredits` updates only `end_date > now()` cycles (creates one if missing). 2. **Campaign silent free AI** `ConsumeCredits` errors were ignored (`_ = ...`), and AI could run when paid entitlements said `CanUseAI` but wallet was empty. **Fix:** require `RemainingCredits ≥ 1`; always debit after AI; map `insufficient_credits` → HTTP 402. 3. **SEO AI empty wallet** Only checked `CanUseAI` (true on paid with 0 remaining). **Fix:** require `RemainingCredits ≥ 1` before AI. 4. **Missing `campaign_copy` cost seed** Fell back to 1 via `lookupCost`, but row was absent. **Fix:** seed in `EnsureDefaultCosts`. 5. **Billing page** - Fake date-range labels (did not call API with `range`). - “Out of credits” banner on Free (0 is expected). - Only showed remaining, not used/total / plan grant. **Fix:** wired `?range=`; Free info banner; show used/total + Enterprise “Unlimited” grant label; usage copy clarifies wallet vs product range. 6. **Negative remaining** Clamp `remaining` / `credits_remaining` to ≥ 0 in overview and usage. ## Smoke results (`demo@descrybe.local` / `DemoPass123!`) ```text CREDITS plan=Enterprise total=1000000 used=0 rem=1000000 monthly=1000000 free=False USAGE used=0 total=1000000 rem=1000000 (matches /auth/me) PLANS Free=0, Starter=300, Growth=2000, Business=10000, Enterprise=1000000 AFTER_FREE assign → total=0 rem=0 free=True ai=False AFTER_ENT assign → total=1000000 rem=1000000 Open billing_cycles row created on AssignPlan (credits_used=0) ``` Commands: ```powershell cd apps/api go build ./... go test ./internal/billing/... ./internal/campaigns/... ./internal/seo/... # restart bin/api.exe, then: # login + curl /api/billing/credits and /api/billing/usage?range=30d ``` ## Known gaps (not changed) - **No daily credit ledger** — range filters cannot reconstruct historical credit burn; only products/tokens. Notes on usage response document this. - **Paid normalize still debits** base `product_processing` when `CanUseAI` and tokens=0 (Free normalize does not). Intentional metering unless product asks to make normalize free on paid too. - **Campaign/SEO charge after LLM call** — empty wallet is gated first; mid-flight race can still burn tokens then fail debit (acceptable without a reservation ledger). - **Migrated tenants** without a public plan may still hold legacy wallet balances; entitlements treat empty plan name as Free with leftover-credit AI unlock. - **Plan `nou` / Merkur trial** remain admin/DB-only; hidden from `/api/billing/plans`. ## Related docs - [free-tier.md](free-tier.md) - [demo-user.md](demo-user.md) - Sibling v1 `PRICING-AND-USER-GROWTH.md` (packaging; Free AI pack overridden here to **0**)