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,
provider_environment="test",
),
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:

from decimal import Decimal

grant = bursar.credits.add_credits(
user_id,
Decimal("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=Decimal("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. Bulk revocation​

revoke_credits_by_entry_type and revokeCreditsByEntryType are broad lifecycle operations. They remove every remaining credit lot for the account whose source ledger operation matches the supplied value, then return a typed RevokeCreditsResult (revoked and balance_after in Python; revoked and balanceAfter in TypeScript).

Do not pass a shared value such as purchase to resolve one disputed order; that can revoke unrelated purchases. Use this operation only when your grant path assigned a dedicated operation to the entire cohort you intend to remove. Provider refund webhooks and bounded administrative deductions handle transaction-specific clawbacks.

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 requested and charged amounts, and how much the allowance covered (allowance_requested, 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.