:::info Executable tutorial
This page is generated from a tested Jupyter notebook. Open it in Google Colab or view the source notebook.
:::
Consume credit lifecycle events
CreditEventEmitter publishes in-process success and failure events from the credits service. This tutorial subscribes to lifecycle events, performs representative mutations, and verifies that success events occur only after the store transaction commits.
Learning objectives
After completing this tutorial, you can:
- Register handlers for typed credit events
- Observe plan, purchase, usage, refund, and lease events
- Handle business-failure events without changing accounting state
- Explain the emitter's post-commit delivery semantics
Prerequisites
- Complete the credit lifecycle and lease tutorials
- Start the notebook server from
samples/python/notebooks/
Setup
The publisher config comes from the catalog, so the events facade is built over the same store with Bursar(credit_store=store, emitter=emitter). The handler records every event it sees.
import atexit
from shared import start_postgres_store, cleanup, base_config, publish_config, USER_ADA
from decimal import Decimal
from bursar import Bursar
from bursar.credits.events import CreditEventEmitter
from bursar.credits.service_types import ReserveOptions
from bursar.metrics import UsageMetrics
store, pgdata = start_postgres_store()
atexit.register(cleanup, pgdata)
publish_config(store, base_config())
emitter = CreditEventEmitter()
seen = []
def record(event):
seen.append((event.type, event.data or {}))
print(event.type, "-", event.data or {})
for event_type in [
"credits.plan_changed",
"credits.added",
"credits.deducted",
"credits.deduct_failed",
"credits.refunded",
"credits.refund_failed",
"credits.reserved",
"credits.reservation_released",
"credits.quota_threshold",
]:
emitter.on(event_type, record)
bursar = Bursar(credit_store=store, emitter=emitter)
credits = bursar.credits
bursar.accounts.on_account_created(USER_ADA, "acct_ada")
print("events facade ready")
Plan, top-up, and usage
Assigning the pro plan emits credits.plan_changed; adding credits emits credits.added. The deduction below crosses 80% of the pro plan's daily_tokens quota (500,000 output tokens/day), which fires credits.quota_threshold alongside credits.deducted.
credits.set_user_plan(USER_ADA, "pro")
credits.add_credits(
USER_ADA, Decimal(200), idempotency_key="events:purchase:1"
)
usage = credits.deduct(USER_ADA, UsageMetrics(
operation="completion",
measures={"input_tokens": Decimal(400000), "output_tokens": Decimal(400000)},
dimensions={"model": "gpt-4o"},
), idempotency_key="events:usage:1")
print("deducted", usage.amount, "entry:", usage.entry_id[:8])
Refunds
refund_credits(entry_id, amount) reverses part or all of a previous deduction and emits credits.refunded. Over-refunding is rejected: the store reports a refusal row and the service raises RefundError (its credits.refund_failed event path currently surfaces as a row-validation error in the Postgres backend).
refunded = credits.refund_credits(
usage.entry_id,
amount=Decimal("0.001"),
reason="correction",
idempotency_key="events:refund:partial:1",
)
print("refunded", refunded.amount, "entry:", refunded.refund_entry_id[:8])
try:
credits.refund_credits(
usage.entry_id,
amount=Decimal("10"),
reason="over-refund",
idempotency_key="events:refund:over:1",
)
except Exception as exc:
print(type(exc).__name__, str(exc)[:60])
Reservations
reserve holds credits against anticipated work and emits credits.reserved; release returns the hold unused and emits credits.reservation_released. Settling a lease converts the hold into a charge.
lease = credits.reserve(USER_ADA, UsageMetrics(
operation="completion",
measures={"input_tokens": Decimal(1000), "output_tokens": Decimal(100)},
dimensions={"model": "gpt-4o"},
), ReserveOptions(idempotency_key="events:lease:1"))
print("lease:", lease.lease_id)
credits.release(USER_ADA, lease.lease_id)
print("released")
Failure events
A deduction that would breach the balance floor raises InsufficientCreditsError and emits credits.deduct_failed, so downstream systems observe the denial even though nothing was charged.
try:
credits.deduct(USER_ADA, UsageMetrics(
operation="completion",
measures={"input_tokens": Decimal(500000000), "output_tokens": Decimal(0)},
dimensions={"model": "gpt-4o"},
), idempotency_key="events:usage:insufficient")
except Exception as exc:
print(type(exc).__name__, str(exc)[:60])
Post-commit semantics
Success events are emitted after their transaction commits, so a handler always observes durable state (a refund handler can read the new balance). The same ordering makes the emitter a clean building block for an outbox: a webhook endpoint can collect credit events and fan them out to external systems without risking notification of rolled-back work.
print("events seen:", [event_type for event_type, _ in seen])
print("all done")