97 lines
5.5 KiB
Markdown
97 lines
5.5 KiB
Markdown
# 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**)
|