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, credit_pack, 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
addSubscriptionCredits({ userId, plan, paymentId }); // subscription renewal
addCreditPackCredits({ userId, packId, paymentId }); // credit-pack purchase
The amounts come from 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 handler src/payment/handlers/core.ts has a single dispatch function that maps payload.type + payload.scene to the right helper:
if (p.type === 'credit_pack') {
  const packId = p.scene.replace(/^credits_/, '');
  await addCreditPackCredits({ userId: p.userId, packId, paymentId });
}
if (p.type === 'subscription' || p.type === 'one_time') {
  if (p.scene === 'pro_monthly' || p.scene === 'pro_yearly') {
    await addSubscriptionCredits({ userId: p.userId, plan: 'pro', paymentId });
  } else if (p.scene === 'lifetime' || p.scene === 'vibestrap-lifetime') {
    await addSubscriptionCredits({ userId: p.userId, plan: 'lifetime', paymentId });
  }
}
All three pricing modes — subscription, one-time lifetime, credit pack — are auto-detected from the 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 creditTransaction log)
  • Buy more / upgrade plan CTAs
Billing for subscriptions / portal access lives at /settings/billing.

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 credit pack (test mode) → expect a second GRANT row with source_type = 'credit_pack' and the pack’s credit count.
  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.creditPacks referencesaddCreditPackCredits looks 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 expirationexpirationDate is data, not a trigger. No cron means no EXPIRE rows ever get inserted.

Official docs