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

97 lines
5.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, 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.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**)