userCredit) plus an append-only log (creditTransaction)
with exactly 4 transaction types. Every payment provider funnels into the
same addCredits / consumeCredits / refundCredits primitives, so credit
behavior is identical whether the user paid via Stripe or WeChat.
The 4 transaction types
That’s it. Resist adding more — encode nuance insourceType instead.
| Type | When | Example sourceType |
|---|---|---|
GRANT | Credits added | register_gift, subscription, credit_pack, manual |
CONSUME | Credits spent | ai_call, image_generation |
EXPIRE | Credits expired (cron) | expiration_cron |
REFUND | Credits returned | failed_call, manual_reversal |
src/db/app.schema.ts; primitives in src/credits/server.ts;
business wrappers in src/credits/index.ts.
Atomic primitives
All three primitives wrap their UPDATE + INSERT in a single transaction so the balance and the log can never drift:consumeCredits checks balance inside the transaction and returns
{ ok: false, reason: 'INSUFFICIENT' } if the user is short — never throws.
refundCredits mirrors addCredits with type: 'REFUND'.
Business wrappers
Most callers don’t talk toaddCredits directly. They use the higher-level
helpers in src/credits/index.ts:
siteConfig.credits — so editing the config alone
re-shapes the gift size, monthly free amount, and per-plan grants.
How payments grant credits
The webhook handlersrc/payment/handlers/core.ts has a single dispatch
function that maps payload.type + payload.scene to the right helper:
type + scene fields you set when creating the
checkout. Add a new pricing mode by adding a branch here.
Multi-currency
payment.amount is stored in the smallest unit (cents, fen, …) and
payment.currency records the ISO code. USD and CNY are both first-class.
The credits ledger itself is currency-agnostic — credits are pure integers,
and pricing-to-credits mapping happens at grant time using
siteConfig.creditPacks / siteConfig.credits.subscriptionMonthly.
Expiration
creditTransaction.expirationDate is set on GRANT rows that should expire
(e.g. register_gift expires after 30 days per siteConfig.credits.registerGift).
A cron job sweeps these and inserts matching EXPIRE rows; if you don’t
deploy the cron, expiration just doesn’t happen — credits never go negative
either way.
Customer-facing UI
The credits dashboard renders at/settings/credits:
- Current balance (
userCredit.currentCredits) - Transaction history (paginated
creditTransactionlog) - Buy more / upgrade plan CTAs
/settings/billing.
Verify it works
- Sign up a new user — registration triggers
addRegisterGiftCredits. - Open Postgres:
You should see
GRANT 50 register_gift. - Buy a credit pack (test mode) → expect a second
GRANTrow withsource_type = 'credit_pack'and the pack’s credit count. - Trigger an AI call (or call
consumeCreditsfrom a test script) — you should see aCONSUMErow and the balance drop.
Common pitfalls
- Adding new transaction types — don’t. Use
sourceTypefor taxonomy. New types break analytics and admin UIs that enumerate the four. - Bypassing the transaction — never UPDATE
userCredit.currentCreditsoutside the helpers. The only safe path isaddCredits/consumeCredits/refundCreditsbecause they wrap UPDATE + INSERT in one DB transaction. - Granting credits before inserting the payment row — the webhook handler
inserts payment first, then dispatches credits. Reversing the order makes
retries grant duplicates because the idempotency check is on
payment.invoiceId. - Stale
siteConfig.creditPacksreferences —addCreditPackCreditslooks up the pack by ID; if you delete a pack from config but a paid order still references it, the helper silently returns. Audit before removing packs. - Forgetting the cron for expiration —
expirationDateis data, not a trigger. No cron means noEXPIRErows ever get inserted.
Official docs
- Drizzle ORM transactions: orm.drizzle.team/docs/transactions
- PostgreSQL
SELECT … FOR UPDATE(used implicitly via Drizzle): postgresql.org/docs/current/explicit-locking.html - vibestrap source:
src/credits/server.ts,src/credits/index.ts,src/db/app.schema.ts