Skip to main content
Vibestrap ships a credits ledger that’s intentionally small: a single mutable balance per user (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 in sourceType instead.
TypeWhenExample sourceType
GRANTCredits addedregister_gift, subscription, one_time, manual
CONSUMECredits spentai_call, image_generation
EXPIRECredits expired (cron)expiration_cron
REFUNDCredits returnedfailed_call, manual_reversal
Schema lives in 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:
export async function addCredits(input: AddCreditsInput): Promise<void> {
  await ensureUserCredit(input.userId);
  await db.transaction(async (tx) => {
    await tx
      .update(userCredit)
      .set({ currentCredits: sql`${userCredit.currentCredits} + ${input.amount}` })
      .where(eq(userCredit.userId, input.userId));
    await tx.insert(creditTransaction).values({
      type: 'GRANT', amount: input.amount, ...
    });
  });
}
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 to addCredits directly. They use the higher-level helpers in src/credits/index.ts:
addRegisterGiftCredits(userId);                       // signup gift
grantPlanCredits({ userId, planId, paymentId });      // any demoPlans tier
The signup gift comes from 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 handler src/payment/handlers/core.ts has a single dispatch:
if (p.scene === siteConfig.product.id) return; // Vibestrap itself — license, not credits
await grantPlanCredits({ userId: p.userId, planId: p.scene, paymentId });
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 creditTransaction log)
  • Buy more / upgrade plan CTAs (demo by default — see next section)
Billing for subscriptions / portal access lives at /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:
  1. Configure your provider’s price IDs in siteConfig.demoPlans (and the matching STRIPE_PRICE_* env vars).
  2. Open src/app/[locale]/(app)/settings/credits/buy-plan-button.tsx and replace the body with a CheckoutButton that calls createCheckoutAction — the /pricing page’s <CheckoutButton> (in src/components/payment/checkout-button.tsx) shows the same pattern wired up to real Stripe.
  3. Remove the demo banner at the top of src/app/[locale]/(app)/settings/credits/page.tsx.
  4. Delete the demo i18n keys: Settings.credits.demoNotice, Settings.credits.demoBanner.title, Settings.credits.demoBanner.body (in both messages/en.json and messages/zh.json).
That’s it — about ten minutes of mechanical changes. We deliberately don’t ship a “demo mode” environment variable because that would be one more switch buyers need to remember to flip; deleting the demo code on the way to production is clearer.

Verify it works

  1. Sign up a new user — registration triggers addRegisterGiftCredits.
  2. Open Postgres:
    select type, amount, source_type from credit_transaction
    where user_id = '<new-user-id>';
    
    You should see GRANT 50 register_gift.
  3. Buy a demoPlans tier (test mode) → expect a second GRANT row with source_type = 'subscription' (or 'one_time') and the tier’s creditsGranted.
  4. Trigger an AI call (or call consumeCredits from a test script) — you should see a CONSUME row and the balance drop.

Common pitfalls

  • Adding new transaction types — don’t. Use sourceType for taxonomy. New types break analytics and admin UIs that enumerate the four.
  • Bypassing the transaction — never UPDATE userCredit.currentCredits outside the helpers. The only safe path is addCredits / consumeCredits / refundCredits because 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.demoPlans referencesgrantPlanCredits looks 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 expirationexpirationDate is data, not a trigger. No cron means no EXPIRE rows ever get inserted.

Official docs