Skip to main content

03 - Credit Lifecycle

Credits flow through a lifecycle: they are added, charged, and sometimes refunded. Understanding this lifecycle is critical to building reliable credit systems.

bursar's core operations are simple:

  • add_credits() deposits credits into a user's account.
  • deduct_with_allowance() charges atomically — calculates against a fixed cost you already know, then debits in one transaction.
  • refund_credits() reverses a completed deduction.

Everything in this notebook assumes you already know (or can easily compute) the cost before charging — the common case for most operations. When the cost isn't known until the work finishes (a chat call with unpredictable token counts) or several concurrent operations need to see each other's in-flight holds, bursar provides a separate lease lifecycle (reservesettle/release) — that's substantial enough to get its own notebook: see Notebook 06 - Financial Safety.

from datetime import datetime, timedelta
from bursar.interface.postgres import PostgresStore
from bursar.manager import CreditManager
from bursar.engine import PricingEngine
from bursar.metrics import UsageMetrics, ToolCall
from bursar.interface.models import (
PricingConfigData, PlanDefinition,
CreditMetadata,
)
from shared import start_postgres_store, cleanup

store, pgdata = start_postgres_store()
import uuid
print("✔ PostgresStore ready.")

Add credits

The first operation in any credit lifecycle is adding credits to a user's account. PostgresStore.add_credits() creates a new credit entry with a unique transaction ID and returns the user's updated balance.

Each deposit has a type label - such as "signup_bonus", "purchase", or "adjustment" - which serves as an audit category. This makes it possible to later query how many credits came from signups versus purchases versus manual adjustments.

# Generate a unique user ID for this demonstration.
# In production, this would be the user's UUID from your auth system.
user = str(uuid.uuid4())

# Deposit 10,000 credits as a signup bonus into the user's account.
# The add_credits() method returns an AddCreditsResult object containing:
# - transaction_id: a unique identifier for this deposit, used for audit trails and future refunds
# - new_balance: the user's total credit balance after the deposit completes
r = store.add_credits(user, 10_000, type="signup_bonus")
print(f" Tx: {r.transaction_id}") # Unique reference for this deposit
print(f" Balance: {r.new_balance}") # User now has 10,000 credits available to spend

Creating a user record without funding it

Sometimes you need a user to exist in bursar's ledger before they have any credits of their own — for example, before adding them to a team (Notebook 08), which requires every member to already have a user record. The idiom is to add zero credits with a descriptive type:

store.add_credits(user_id, 0, type="adjustment")

This creates the user's row with a balance of 0 without implying they received a real deposit. type is an arbitrary string — use whatever your own audit taxonomy calls a no-op initialization.

new_user = str(uuid.uuid4())
r0 = store.add_credits(new_user, 0, type="adjustment")
print(f" User initialized with balance: {r0.new_balance}")
assert r0.new_balance == 0

Deduct credits (atomic charge)

Most operations simply charge credits directly with store.deduct_with_allowance() — bursar's atomic "calculate cost, then charge" primitive. It locks the user's row, applies free allowance, enforces the balance floor, and debits, all in one server-side transaction — no separate reservation step needed for a single-shot charge.

This is the same call CreditManager.deduct() makes internally after computing the cost from UsageMetrics. Calling it directly here keeps this section store-only, with no pricing engine required. It also accepts an optional model= keyword — see the next section.

from decimal import Decimal

# Deduct 2,000 credits directly. deduct_with_allowance() is the atomic
# "calculate cost, then charge" primitive — it locks the row, applies free
# allowance, enforces the balance floor, and debits, all in one transaction.
# Returns a DeductionResult object with:
# - transaction_id: a unique reference for this completed deduction, used for audits and refunds
# - balance_after: the user's total balance after the deduction completes
ded = store.deduct_with_allowance(user, Decimal("2_000"))
print(f" Deduction: {ded.transaction_id}") # Unique reference for this spend
print(f" Balance aft: {ded.balance_after}") # 10,000 - 2,000 = 8,000
assert ded.balance_after == Decimal("8000")

Attributing a deduction to a model (model=)

deduct_with_allowance() accepts an optional model= keyword. Passing the model name that produced this charge doesn't change the amount debited — it only tags the transaction so later analytics queries can break spend down by model. Notebook 09's spend_by_model() query is built entirely on this tag, so it's worth attaching whenever you know which model handled the request.

ded_m = store.deduct_with_allowance(user, Decimal("500"), model="gpt-4o")
print(f" Deduction attributed to gpt-4o, balance after: {ded_m.balance_after}")
assert ded_m.balance_after == Decimal("7500")

Refund a deduction

Sometimes a completed deduction needs to be reversed. For example, if a credit purchase fails after the initial authorization, or if a customer requests a refund for a faulty model response.

refund_credits() restores the deducted amount to the user's balance. Critically, it requires the original deduction's transaction_id as a reference. This ensures a proper audit trail: the refund is linked to the original spend, and the same transaction cannot be refunded twice. The original deduction's transaction record remains in the database unchanged - it is not deleted or modified - preserving a complete history of the spend-and-refund cycle for auditing purposes.

# Refund the deduction we just completed, referencing its original transaction_id.
# Passing the deduction's transaction_id allows the system to:
# 1. Validate that the referenced transaction exists and has not already been refunded
# 2. Create an audit trail linking the refund to the original spend
# 3. Prevent double-reversal (deduplication) — the same transaction cannot be refunded twice
ref = store.refund_credits(ded.transaction_id, amount=2_000, reason="test")
print(f" Refund tx: {ref.refund_transaction_id}") # New unique ID for this refund operation
print(f" New balance: {ref.new_balance}") # 7,500 + 2,000 refunded = 9,500
assert ref.new_balance == 9_500 # the earlier 500-credit gpt-4o deduction is still in effect

Refunding the same transaction twice

refund_credits() never raises on a business-rule failure — it sets result.error instead, so you can always safely inspect the result. Attempting to refund a transaction that was already fully refunded returns error="already_refunded" and moves no credits. Always check .error before treating a refund as successful.

dup = store.refund_credits(ded.transaction_id, amount=2_000, reason="test")
print(f" Second refund attempt: error='{dup.error}'")
assert dup.error == "already_refunded"
assert store.get_balance(user).balance == 9_500 # unchanged by the failed duplicate
cleanup(pgdata)