Skip to main content
Version: 2.x

Manage the credit lifecycle

Prerequisites

Outcome

  • A facade you can run end to end: signup grants, a purchased lot, priced usage, a refund, an expiry sweep, and a revocation — every step auditable in the ledger.

Every credit that enters or leaves an account posts a canonical ledger entry: grants, purchases, usage charges, refunds, expiries, and revocations are all the same append-only history. Apply the procedures below at the application boundaries that own signup, payments, usage, refunds, and maintenance jobs.

Setup first: publish a validated configuration and bind a facade.

from bursar import Bursar, PostgresStore
from shared import USER_ADA, base_config, publish_config

bursar = publish_config(
PostgresStore(database_url, tenant_id=tenant_id),
base_config(),
)
user_id = USER_ADA

1. Signup

Call the account-created boundary from the durable signup handler. It assigns the catalog's default plan and runs eligible account_created grant programs in one operation:

result = bursar.accounts.on_account_created(
user_id, event_key="signup", region="us-east-1"
)
print(result["plan_key"], result["plan_assigned"], result["grants"])

The result reports the assigned plan and any grant-program awards. The call is idempotent: a second signup event with the same key does not re-anchor the plan or post the grant twice.

2. Buying credits

Post purchased credits only after the payment adapter verifies and normalizes a successful provider event. Credit the account as a purchase in the configured bucket, and derive the idempotency key from the provider event identifier so redelivery replays the same entry:

grant = bursar.credits.add_credits(
user_id,
50000,
entry_type="purchase",
idempotency_key="checkout:cs_123456",
)
print(grant.entry_id, grant.new_balance, grant.bucket, grant.idempotent)

In the database this creates a credit lot: the purchase ledger entry plus a credit_lots row that tracks granted - consumed. Pass expires_at (or expiresAt) to give the lot an expiry date — expiring lots are handled by the sweep in step 5. TypeScript amounts are decimal.js Decimal values.

3. Spending

Call deduct when the final usage measurement is known. It selects the account's rate card, evaluates the metrics, applies plan policy, and posts the charge in one transaction:

from bursar.metrics import UsageMetrics

charge = bursar.credits.deduct(
user_id,
UsageMetrics(
operation="completion",
measures={
"input_tokens": 4000,
"output_tokens": 1200,
"cache_read_tokens": 6000,
},
dimensions={"model": "gpt-4o-mini"},
),
idempotency_key="chat:turn:42",
)
print(charge.amount, charge.allowance_consumed, charge.balance_after)

The charge costs 0.000030 credits (the 6dp ROUND_HALF_UP total), and the free plan's allowance absorbs it: allowance_consumed is 0.000030 and the balance stays at 50000. Deductions draw allowance first, then debits from the balance in bucket priority order — promotional (1) before purchased (10) — so promotional credits burn first. On the Pro plan the 500k output_tokens/day quota is checked in the same transaction. If the account cannot cover the charge, deduct raises InsufficientCreditsError, which projects to HTTP 402 (payment_required).

4. Refunds

Refund a charge by its entry identifier. Pass an amount only for a partial refund:

refund = bursar.credits.refund_credits(
charge.entry_id,
amount=0.000030,
reason="user_reported_bad_output",
idempotency_key="refund:chat:turn:42:partial",
)
print(refund.refund_entry_id, refund.new_balance)

A refund never rewrites the original entry. It posts a new refund ledger entry that restores the balance, linked back to the original via reference_entry_id / originalEntryId. The store rejects over-refunds, duplicates, and refunds of the wrong entry type with RefundError.

5. Expiry

Promotional credits can carry an expiry. First inspect what a sweep would expire, then run it:

dry = bursar.credits.sweep_expired_credits(dry_run=True)
print(dry.expired_count, dry.expired_amount)

result = bursar.credits.sweep_expired_credits()
print(result.expired_count, result.expired_amount)

Each expired lot posts an expiry ledger entry and the amount leaves the balance — expiry is an accounting event, not a read filter. Run the sweep on a schedule (for example hourly) or enable lazy expiry so a user's own next operation clears their due lots first.

6. Revocation

If a purchase is fraudulent or violates your ToS, revoke all credits of an entry type rather than adjusting the balance by hand:

revoked = bursar.credits.revoke_credits_by_entry_type(user_id, "purchase")
print(revoked["amount"], revoked["new_balance"])

Revocation walks the purchase lots LIFO across tiers and posts revocation ledger entries, so the audit trail shows exactly what was taken back and why.

7. Reading the ledger

The ledger is the pricing evidence. Walk it with the cursor loop — pages are stable, ordered by (created_at, entry_id):

page = bursar.credits.list_ledger_entries(user_id, limit=50)
while True:
for entry in page.items:
print(entry.entry_id, entry.entry_type, entry.amount, entry.created_at)
if not page.next_cursor:
break
page = bursar.credits.list_ledger_entries(
user_id, limit=50, cursor=page.next_cursor
)

list_usage_charges is the same loop against metered usage — each row shows the operation, the measures, the charged amount, and how much the allowance covered. Filter by entry type or date range in either call; offset pagination is not supported.

After these operations, get_balance and the ledger must agree: the balance equals the sum of its entries.