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, one_time, 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.registerGift. Plan grants
come from each entry’s creditsGranted in siteConfig.demoPlans — editing
the array shapes the entire grant matrix.
How payments grant credits
The webhook handlersrc/payment/handlers/core.ts has a single dispatch:
grantPlanCredits looks up siteConfig.demoPlans by id and grants
creditsGranted on every successful invoice. For yearly subs that means 12×
the monthly amount up front; for monthly that means 1× per cycle; for
one-time tiers that means once forever.
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.demoPlans[*].creditsGranted.
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 (demo by default — see next section)
/settings/billing.
Going from demo to real checkout
Vibestrap ships/settings/credits as a starter showing what a credits-and-plans
purchase page can look like. Out of the box, the Buy buttons display a toast
(“Demo only”) instead of triggering Stripe — so vibestrap.dev itself can’t
accidentally charge users for meaningless subscriptions, and so buyers exploring
the scaffold can click around safely before configuring their providers.
Replacing it with real checkout is a code change, not a config switch:
- Configure your provider’s price IDs in
siteConfig.demoPlans(and the matchingSTRIPE_PRICE_*env vars). - Open
src/app/[locale]/(app)/settings/credits/buy-plan-button.tsxand replace the body with aCheckoutButtonthat callscreateCheckoutAction— the/pricingpage’s<CheckoutButton>(insrc/components/payment/checkout-button.tsx) shows the same pattern wired up to real Stripe. - Remove the demo banner at the top of
src/app/[locale]/(app)/settings/credits/page.tsx. - Delete the demo i18n keys:
Settings.credits.demoNotice,Settings.credits.demoBanner.title,Settings.credits.demoBanner.body(in bothmessages/en.jsonandmessages/zh.json).
Verify it works
- Sign up a new user — registration triggers
addRegisterGiftCredits. - Open Postgres:
You should see
GRANT 50 register_gift. - Buy a
demoPlanstier (test mode) → expect a secondGRANTrow withsource_type = 'subscription'(or'one_time') and the tier’screditsGranted. - 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.demoPlansreferences —grantPlanCreditslooks up the tier by id; if you delete a tier from config but a paid order still references it, the helper silently returns. Audit before removing tiers. - 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