> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibestrap.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Credits

> An immutable ledger with exactly 4 transaction types — wired into every payment provider.

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.

| 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`                      |

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:

```ts theme={null}
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`:

```ts theme={null}
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:

```ts theme={null}
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:
   ```sql theme={null}
   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` references** — `grantPlanCredits` 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 expiration** — `expirationDate` is data, not a
  trigger. No cron means no `EXPIRE` rows ever get inserted.

## Official docs

* Drizzle ORM transactions: [orm.drizzle.team/docs/transactions](https://orm.drizzle.team/docs/transactions)
* PostgreSQL `SELECT … FOR UPDATE` (used implicitly via Drizzle): [postgresql.org/docs/current/explicit-locking.html](https://www.postgresql.org/docs/current/explicit-locking.html)
* Vibestrap source: `src/credits/server.ts`, `src/credits/index.ts`,
  `src/db/app.schema.ts`
