Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
5.5 KiB
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
-
Billing UI vs API mismatch (critical)
/api/billing/usagepreviously preferred the latestbilling_cyclesrow bystart_date, including ended cycles. Afterseed-demo/AssignPlanreset the wallet to 1M/0, usage still showed e.g. 22 credits used while remaining showed 1,000,000.
Fix:UsageSummaryalways reports walletused/total/remaining; range filters products/tokens only.AssignPlancloses open cycles and inserts a new open cycle.ConsumeCreditsupdates onlyend_date > now()cycles (creates one if missing). -
Campaign silent free AI
ConsumeCreditserrors were ignored (_ = ...), and AI could run when paid entitlements saidCanUseAIbut wallet was empty.
Fix: requireRemainingCredits ≥ 1; always debit after AI; mapinsufficient_credits→ HTTP 402. -
SEO AI empty wallet
Only checkedCanUseAI(true on paid with 0 remaining).
Fix: requireRemainingCredits ≥ 1before AI. -
Missing
campaign_copycost seed
Fell back to 1 vialookupCost, but row was absent.
Fix: seed inEnsureDefaultCosts. -
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.
- Fake date-range labels (did not call API with
-
Negative remaining
Clampremaining/credits_remainingto ≥ 0 in overview and usage.
Smoke results (demo@descrybe.local / DemoPass123!)
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:
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_processingwhenCanUseAIand 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
- demo-user.md
- Sibling v1
PRICING-AND-USER-GROWTH.md(packaging; Free AI pack overridden here to 0)