Skip to content

Token Expiration

Credits don’t last forever. Promotional tokens, trial balances, and time-limited top-ups all need expiration dates. Murai handles this with a FIFO bucket-based expiration system that integrates seamlessly with the append-only ledger.

How it works

Buckets

Every call to topUp creates a bucket — a record of how many tokens were added, how many remain, and when they expire. Each bucket tracks:

FieldDescription
userIdOwner of the bucket
amountOriginal credit amount
remainingUnspent tokens in this bucket
expiresAtWhen the bucket expires (null = never expires)
createdAtWhen the bucket was created
// Non-expiring top-up (no expiresAt)
await wallet.topUp('user_123', 5_000, 'purchase-001');
// Expiring top-up (30 days)
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await wallet.topUp('user_123', 10_000, 'promo-002', {
expiresAt: thirtyDays,
});

FIFO spend ordering

When a user spends tokens, the system consumes from buckets in order:

  1. Earliest-expiring buckets first — tokens that expire soonest are used first
  2. Non-expiring buckets last — buckets with expiresAt: null are used only after all expiring buckets are exhausted (NULLS LAST ordering)
  3. Within the same expiration date — buckets are consumed in creation order

This ensures that expiring tokens are always used before permanent ones, minimizing waste for the user.

Bucket A: 3,000 remaining, expires Jan 15 <- consumed first
Bucket B: 5,000 remaining, expires Feb 20 <- consumed second
Bucket C: 2,000 remaining, never expires <- consumed last

Expiring tokens

The expireTokens function

Expired buckets are not automatically removed from the balance. Instead, you call expireTokens(userId) to atomically debit any remaining tokens from expired buckets.

const result = await wallet.expireTokens('user_123');
// result.expiredCount — number of buckets expired
// result.expiredAmount — total tokens debited

This function:

  1. Finds all buckets where expiresAt < now and remaining > 0
  2. Creates a debit ledger entry for each bucket’s remaining balance
  3. Sets remaining = 0 on each expired bucket
  4. Returns an ExpireResult with the totals

All steps happen inside a single transaction with SELECT FOR UPDATE locking, so it is safe to call concurrently.

Balance semantics

getBalance() returns the materialized balance — the sum of all ledger entries. Expired-but-not-yet-processed buckets are still counted in the balance until expireTokens runs and creates the corresponding debit entries.

Running via cron

In production, run expireTokens on a schedule for all users with expiring buckets. Here is a minimal example:

// cron-expire.ts — run via cron (e.g., daily at midnight)
import { createDrizzleStorage, createWallet } from '@murai-wallet/murai';
const storage = createDrizzleStorage(db);
const wallet = createWallet({ storage });
// Get all user IDs with expiring buckets (your own query)
const userIds = await getUsersWithExpiringBuckets();
for (const userId of userIds) {
const result = await wallet.expireTokens(userId);
if (result.expiredAmount > 0) {
console.log(
`${userId}: expired ${result.expiredCount} buckets,` +
` ${result.expiredAmount} tokens`
);
}
}

Full example

lib/expiration.ts
import { createDrizzleStorage, createWallet } from '@murai-wallet/murai';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
// biome-ignore lint/style/noNonNullAssertion: env vars validated at startup
const sql = postgres(process.env.DATABASE_URL!);
const storage = createDrizzleStorage(drizzle(sql));
const wallet = createWallet({ storage });
// Top up with 30-day expiration
const thirtyDays = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await wallet.topUp('user_123', 10_000, 'purchase-abc', {
expiresAt: thirtyDays,
});
// Spend consumes earliest-expiring bucket first
await wallet.spend('user_123', 2_000, 'usage-xyz');
// Run this via cron (e.g., daily) to expire old credits
const result = await wallet.expireTokens('user_123');
// biome-ignore lint/suspicious/noConsole: example script
console.log(`Expired ${result.expiredCount} buckets, ${result.expiredAmount} tokens`);

What’s next?