Skip to main content
Version: 2.x

:::info Executable tutorial

This page is generated from a tested Jupyter notebook. Open it in Google Colab or view the source notebook.

:::

Set up Bursar and create an account

This tutorial introduces the Bursar facade through one complete local workflow. You will start an isolated PostgreSQL environment, publish the shared tutorial configuration, create an account, grant prepaid credits, and record a metered charge.

Learning objectives

After completing this tutorial, you can:

  • Explain which responsibilities belong to the Bursar facade
  • Publish and activate a validated configuration
  • Create an account and post replay-safe credit mutations
  • Distinguish plan allowance consumption from balance deductions

Prerequisites

  • Python 3.12 or 3.13 with the Bursar development dependencies installed
  • The notebook server started from samples/python/notebooks/ so shared.py is importable
  • Permission to create the disposable local PostgreSQL environment used by the shared helpers

The problem: AI usage is money, and money needs a ledger

The example application bills per token, so usage arrives as input tokens, output tokens, cached tokens, and jobs. Three problems appear when those measurements are handled ad hoc:

  • Metering drifts. Hand-rolled counters get rounded, truncated, or double-counted. Billing disputes are won by whoever has the better ledger.
  • Balances are invented twice. Prepaid credits live in your database and in your accounting system, and the two copies disagree until someone reconciles them.
  • Pricing is code. Changing a rate means a deploy, a rollout, and a midnight rollback when the decimal places were wrong.

The fix is a single source of truth: an append-only ledger of every credit movement, priced against a versioned configuration document that lives outside your code.

What bursar is

Bursar is one facade over everything a SaaS needs to charge for usage. The Bursar object exposes four services:

  • credits — balances, an append-only ledger, and atomic deductions priced by your config;
  • catalog — versioned configuration: validate, publish, activate, roll back;
  • accounts — account lifecycle (on_account_created assigns a plan and runs grants);
  • billing and commerce — optional Stripe subscriptions, credit top-ups, and auto-recharge, covered in later chapters.

Underneath it is deliberately boring: one Postgres schema, one tenant per store, one validated configuration document. Every notebook in this series runs against a throwaway Postgres cluster started by the shared helpers, so you can run it again and again without touching a real database.

# Everything in this series runs against a throwaway Postgres cluster.
# start_postgres_store() launches one, runs the bursar schema migrations,
# and provisions a tenant; the second return value is the data directory
# that must be cleaned up at the end of the notebook.
from decimal import Decimal

from shared import start_postgres_store, cleanup, base_config, publish_config, USER_ADA

store, pgdata = start_postgres_store()
print("store type:", type(store).__name__)
print("tenant id:", store.tenant_id)

The canonical configuration

base_config() returns the configuration this series uses everywhere: two billable operations (completion priced per million tokens, execution priced per job), one rate card (standard), two credit buckets (promotional, purchased), two plans (free, pro), entitlements, admission control, and Stripe commerce offers. Publishing it validates the whole document, stores it as catalog version 1, and activates it — every deduction from now on prices against it.

# publish_config validates, publishes, and activates the config in one step.
bursar = publish_config(store, base_config(), label="notebooks")
print("active catalog version:", bursar.catalog.get_active().version)
print("default plan:", bursar.catalog.public_view()["default_plan"])

Ada signs up

on_account_created is the hook you call when a user registers. It reads the active catalog, assigns the account its default plan (free), and runs any account_created grant programs. The returned dict tells you what happened: here plan_assigned is True and grants is empty, because the base config defines no signup grants — the free plan's 10,000-credit monthly allowance is granted implicitly by the plan itself, not by a grant program.

result = bursar.accounts.on_account_created(USER_ADA, event_key="signup")
print("account:", result["account_id"])
print("plan:", result["plan_key"], "| assigned now:", result["plan_assigned"])
print("grants:", result["grants"])

Credits: a prepaid balance

Ada's free plan carries a 10,000-credit monthly allowance that deductions consume first. Credits you sell land in her balance as ledger entries. We add a 50-credit purchase and read it back: get_balance reports the balance and lifetime purchases, and the ledger keeps the full history of how it got there. Then we run one real completion — the engine math behind the 0.000008-credit cost is chapter 02 — and watch the free allowance absorb it while the purchased balance stays untouched.

added = bursar.credits.add_credits(
USER_ADA, Decimal("50"), entry_type="purchase", idempotency_key="setup:purchase:ada"
)
print("added:", added.amount, "-> balance", added.new_balance)

balance = bursar.credits.get_balance(USER_ADA)
print("balance:", balance.balance, "| lifetime purchased:", balance.lifetime_purchased)

# One gpt-4o completion: 1,000 input + 500 output + 200 cached tokens.
from bursar.metrics import UsageMetrics

deduction = bursar.credits.deduct(
USER_ADA,
UsageMetrics(
operation="completion",
measures={
"input_tokens": Decimal(1000),
"output_tokens": Decimal(500),
"cache_read_tokens": Decimal(200),
},
dimensions={"model": "gpt-4o"},
),
idempotency_key="setup-001",
)
print("charged:", deduction.amount, "| from allowance:", deduction.allowance_consumed)
print("balance after:", deduction.balance_after)

Guardrails fail loudly

The ledger refuses to invent money, and it tells you why. Two errors teach the boundary:

  • Ada's free plan allows only completion, so charging an execution raises OperationNotAllowedError.
  • Nothing can be spent that does not exist. Ada's ceiling is the 10,000-credit monthly allowance plus her 50-credit balance. At 0.0025 credits per million input tokens, one absurd 4.02-trillion-token completion costs 10,050.000010 credits — 0.000010 over the ceiling — and deduct raises InsufficientCreditsError, leaving the balance exactly where it was.

Both are caught below: errors are teaching moments, and they must never crash the notebook.

from bursar.errors import OperationNotAllowedError, InsufficientCreditsError

try:
bursar.credits.deduct(
USER_ADA,
UsageMetrics(
operation="execution",
measures={"jobs": Decimal(1)},
dimensions={"model": "gpt-4o"},
),
idempotency_key="setup-002",
)
except OperationNotAllowedError as error:
print("plan gate ->", type(error).__name__)

try:
bursar.credits.deduct(
USER_ADA,
UsageMetrics(
operation="completion",
measures={"input_tokens": Decimal("4020000000000"), "output_tokens": Decimal(1000)},
dimensions={"model": "gpt-4o"},
),
idempotency_key="setup-003",
)
except InsufficientCreditsError as error:
print("spend gate ->", type(error).__name__)

print("balance untouched:", bursar.credits.get_balance(USER_ADA).balance)

What is next in the series

The stage is set: a live store, an active catalog, and an account. Each chapter builds on it:

01 — Your first pricing config: the document behind the product 02 — The pricing engine: how a rate card becomes a credit cost 03 — The expression language: formulas in config, safety built in 04 — The credit lifecycle: add, deduct, refund, idempotency 05 — Plans and allowances: free tiers and monthly grants 06 — Quotas and spend caps 07 — Credit tiers and expiry 08 — Leases and financial safety 09 — Teams: shared pools of credits 10 — Analytics 11 — Events 12 — Subscriptions and auto-recharge 13 — The CLI and deployment 14 — Custom stores 15 — The full pricing config schema

The last cell tears down the temporary Postgres cluster so every run of this notebook starts clean.

cleanup(pgdata)