AIAI SaaS

Credits System

How batches, expiration, and FEFO deduction work together.

Two tables, one truth

  • credit_grant — every issuance is a batch with amount, remaining, and optional expiresAt
  • credit_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.