Billing and credits
Stripe subscriptions, one-off payments and the credit ledger.
Subscription state comes from webhooks only
Nothing writes to the subscription table except src/app/api/stripe/webhook/route.ts.
If a server action also wrote it, the redirect back from Checkout would race the
webhook and the faster one would win.
Idempotency
Stripe retries deliveries. Every credit grant carries an idempotencyKey, and the
unique index on that column turns a replayed event into a no-op:
await grantCredits({
organizationId,
amount: plan.monthlyCredits,
reason: 'plan_grant',
idempotencyKey: `invoice:${invoice.id}`,
});Balances are derived
credit_ledger stores deltas. The balance is SUM(amount). Spending runs inside a
transaction that takes a row lock on the organisation first, so two concurrent
generations cannot both pass the same balance check.
Adding a plan
Add it to PLANS in src/lib/plans.ts with its Stripe price IDs. planFromPriceId()
is what the webhook uses to map a price back to a plan, so it works automatically.