Credits System
How batches, expiration, and FEFO deduction work together.
Two tables, one truth
credit_grant— every issuance is a batch withamount,remaining, and optionalexpiresAtcredit_transaction— an append-only ledger shown to users
The balance is always SUM(credit_grant.remaining). No cached counter to drift.
Expiration
Different sources can have different lifetimes: register gifts might expire in 30 days, purchased packages never. A daily job (GitHub Actions cron by default) zeroes expired batches and writes expiration transactions.
FEFO deduction
Consumption deducts from the batch that expires first (First-Expired, First-Out), so users never lose credits they could have spent. Concurrent deductions are protected by optimistic locking with retries.
Wiring it into your feature
import { consumeCredits, InsufficientCreditsError } from "@/credits/service";
try {
await consumeCredits({
userId,
amount: 1,
description: "My AI feature",
});
} catch (e) {
if (e instanceof InsufficientCreditsError) {
// return 402 and prompt upgrade
}
}
Manual adjustments
Admins can add or deduct credits from the user table — every adjustment lands in the transaction history with a note.