Files
descrybe/docs/billing-credits-audit.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

5.5 KiB
Raw Blame History

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.ProvisionFreePlanAssignPlan 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, totalused); 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.PipelineConsumeCredits(..., "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!)

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_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.