# Bursar — Open-source AI credits and usage billing > PostgreSQL-native usage metering, prepaid credits, plans, and reserve-settle billing for AI SaaS, available as Python, TypeScript, and Go SDKs. Version: 2.x This file contains all documentation content in a single document following the llmstxt.org standard. ## What Bursar manages Bursar is an open-source usage metering, credit ledger, and billing library for AI products. It prices operations, enforces account policy, and records each monetary change in PostgreSQL through matching Python, TypeScript, and Go software development kits (SDKs). ## The boundary Bursar owns AI products need to decide whether work may start before they know its final cost. Bursar puts pricing, admission, and accounting behind one application boundary so those decisions use the same state. ```mermaid flowchart LR A[Application usage] --> B[Bursar facade] C[Versioned configuration] --> B D[Payment provider events] --> B B --> E[(PostgreSQL)] E --> F[Balance and ledger] E --> G[Plans and allowances] E --> H[Billing state] ``` Your application calls one `Bursar` facade. The facade exposes these capabilities: | Capability | Responsibility | | ---------- | --------------------------------------------------------------------------------- | | `credits` | Balances, ledger entries, metered charges, leases, refunds, quotas, and analytics | | `catalog` | Validation, publication, activation, and rollback of configuration versions | | `accounts` | Default plan assignment and account-created grant programs | | `billing` | Normalized provider events, subscriptions, invoices, payments, and disputes | | `commerce` | Checkout, plan changes, top-ups, and auto-recharge | `billing` and `commerce` are optional. Configure them only when Bursar should coordinate a payment provider. ## The guarantees Bursar enforces Bursar concentrates the rules that are difficult to maintain across application handlers and background workers: - **Exact money**: decimal values use fixed precision and half-up rounding. Configuration stores exact values as strings - **Append-only accounting**: grants, purchases, charges, refunds, expiry, and revocation create ledger entries instead of rewriting history - **Replay-safe writes**: stable idempotency keys prevent retries and webhook redelivery from posting a second mutation - **Atomic admission**: plans, entitlements, quotas, allowances, balance floors, and lease capacity are checked in the transaction that admits work - **Tenant isolation**: mandatory tenant identifiers, composite foreign keys, and forced row-level security keep tenant data separate - **Cross-SDK parity**: Python, TypeScript, and Go share configuration fixtures, SQL migrations, expression cases, error categories, and rounding behavior Read [Financial safety](./guides/financial-safety.mdx) before integrating any path that moves money or admits long-running work. ## What remains outside Bursar Bursar does not replace your product database, identity provider, tax system, general ledger, or payment processor. Your application still owns account identity, product workflows, and the user interface. Bursar owns the metered-credit boundary and can project payment-provider events into that boundary. Use Bursar when your product needs one or more of these controls: - Prepaid balances or promotional credit grants - Per-operation pricing based on token, model, job, or compute measures - Plans with allowances, entitlements, quotas, and spend caps - Reservations for work whose final cost is unknown at admission - Replay-safe subscription, top-up, refund, and auto-recharge workflows - An auditable account ledger shared across Python, TypeScript, and Go services ## Supported platforms | Surface | Supported version | Package | | ------------------------------------- | ---------------------- | ----------------------------------------- | | Python SDK and command-line interface | Python 3.12 and 3.13 | `bursar` | | TypeScript SDK | Node.js 22 or newer | `@zonastery/bursar` | | Go SDK | Go 1.25 or newer | `github.com/Zonastery/bursar/golang/v2` | | Database | PostgreSQL 16 or newer | `pg_partman` 5.x and `pg_jsonschema` 0.3+ | The current documentation tracks Bursar 2.x. Docusaurus version snapshots will be added only when a future major release changes user-facing behavior. ## Choose the next document - Follow [Create your first metered charge](./quickstart.mdx) to install the schema and post one usage charge - Run the [executable tutorials](/docs/tutorials) to learn each workflow against an isolated PostgreSQL environment - Follow the [concept reading path](./concepts/index.mdx) to understand the architecture, credit accounting, pricing, access, and billing models - Open the [Python API](./python-api/index.mdx), [TypeScript API](./javascript-api/index.mdx), or [Go API](./go-api/index.mdx) for exact call signatures --- ## Create your first metered charge This tutorial creates an isolated Bursar tenant, publishes one pricing rule, grants prepaid credits, and records a usage charge. The final ledger entry proves which account was charged, why it was charged, and which balance resulted. ## Prerequisites Prepare these dependencies before continuing: - Python 3.12 or 3.13 for the Bursar command-line interface (CLI) - PostgreSQL 16 or newer with `pg_partman` 5.x and `pg_jsonschema` 0.3+ available to the migration role - A PostgreSQL migration-owner connection and permission to create two least-privilege login roles - Node.js 22 or newer if you will use the TypeScript example - Go 1.25 or newer if you will use the Go example :::caution Use a development database `bursar migrate` creates and secures the `bursar` schema. Run this tutorial against a development database, not an existing production tenant. ::: ## 1. Install the package and schema Install the Python package with PostgreSQL support. The package supplies the CLI used by both SDK integrations. ```bash title="Terminal" python -m pip install "bursar[postgres]" export BURSAR_MIGRATION_DATABASE_URL="postgresql://bursar_migrator:password@localhost:5432/bursar" bursar migrate ``` As the migration owner, create separate operator and application logins. Set real passwords through your database provider or secret tooling rather than placing them in shell history: ```sql title="PostgreSQL" CREATE ROLE bursar_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; GRANT bursar_client TO bursar_app WITH INHERIT FALSE, SET TRUE; CREATE ROLE bursar_ops LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; GRANT bursar_operator TO bursar_ops WITH INHERIT FALSE, SET TRUE; ``` Give each login only the role shown above, then supply its connection through your normal secret manager. These illustrative local URLs keep the two trust boundaries explicit: ```bash title="Terminal" export BURSAR_OPERATOR_DATABASE_URL="postgresql://bursar_ops:password@localhost:5432/bursar" export DATABASE_URL="postgresql://bursar_app:password@localhost:5432/bursar" export BURSAR_PROVIDER_ENVIRONMENT="test" ``` `BURSAR_PROVIDER_ENVIRONMENT` selects an isolated financial namespace. Use `test` for this tutorial and provider test credentials; production uses `live`. TypeScript applications also install the Node.js package: ```bash title="Terminal" npm install @zonastery/bursar pg ``` Go applications install the versioned module. The Python package still owns the migration CLI, so every SDK uses the same ordered SQL baseline: ```bash title="Terminal" go get github.com/Zonastery/bursar/golang/v2 ``` `bursar migrate` applies the ordered SQL baseline and records each file checksum. Re-running the command is a no-op. The command fails if an applied migration file has changed. ## 2. Create a tenant Create a tenant with an explicit universally unique identifier (UUID). Every store binds to one tenant before it reads or writes business data. ```bash title="Terminal" bursar tenant create acme \ --id 018f7f5f-7b4a-7000-8000-000000000001 \ --display-name "Acme" export BURSAR_TENANT_ID="018f7f5f-7b4a-7000-8000-000000000001" ``` ## 3. Define and publish pricing Save this minimal configuration as `pricing.yaml`. It defines one metered operation, one rate card, one credit bucket, and one plan. ```yaml title="pricing.yaml" version: 1 catalog: default_plan: standard pricing: operations: completion: measures: input_tokens: { unit: token } dimensions: model: { type: string } rate_cards: standard: operations: completion: unmatched: action: charge charge: type: per_unit measure: input_tokens rate: "0.25" unit_size: "1000" credits: buckets: purchased: { priority: 10 } default_bucket: purchased plans: standard: rate_card: standard allowed_operations: [completion] ``` Validate the file before publishing it: ```bash title="Terminal" bursar config validate pricing.yaml bursar config set pricing.yaml --label "quickstart" bursar config list ``` The schema rejects unknown fields. Exact decimal values such as `rate` and `unit_size` remain strings so parsing does not introduce floating-point values. ## 4. Construct the facade Read the connection and tenant identifiers from the environment, then bind one store to the tenant. ```python import os from bursar import Bursar, PostgresStore database_url = os.environ["DATABASE_URL"] tenant_id = os.environ["BURSAR_TENANT_ID"] provider_environment = os.environ["BURSAR_PROVIDER_ENVIRONMENT"] store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment=provider_environment, ) bursar = Bursar(credit_store=store) ``` ```typescript import { Bursar, PostgresStore, type ProviderEnvironment, } from "@zonastery/bursar"; const databaseUrl = process.env.DATABASE_URL!; const tenantId = process.env.BURSAR_TENANT_ID!; const providerEnvironment = process.env .BURSAR_PROVIDER_ENVIRONMENT! as ProviderEnvironment; const store = new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment, }); const bursar = new Bursar({ creditStore: store }); ``` ```go package main import ( "context" "log" "os" bursar "github.com/Zonastery/bursar/golang/v2" ) func main() { ctx := context.Background() store, err := bursar.NewPostgresStore(ctx, os.Getenv("DATABASE_URL"), bursar.PostgresStoreOptions{ TenantID: os.Getenv("BURSAR_TENANT_ID"), ProviderEnvironment: bursar.ProviderEnvironmentTest, }) if err != nil { log.Fatal(err) } defer store.Close() sdk, err := bursar.New(bursar.Options{CreditStore: store}) if err != nil { log.Fatal(err) } if err := sdk.LoadCatalog(ctx); err != nil { log.Fatal(err) } } ``` Keep the store tenant-bound for its full lifetime. Never set a session-scoped tenant value on a shared connection pool. ## 5. Create and fund an account Create a stable account identifier in your application database. Notify Bursar after signup, then post a purchase with a replay-safe idempotency key. ```python from decimal import Decimal account_id = "11111111-1111-4111-8111-111111111111" bursar.accounts.on_account_created( account_id, event_key="signup:11111111", ) grant = bursar.credits.add_credits( account_id, Decimal("100.000000"), entry_type="purchase", idempotency_key="purchase:quickstart:11111111", ) print(grant.entry_id, grant.new_balance) ``` ```typescript const accountId = "11111111-1111-4111-8111-111111111111"; await bursar.accounts.onAccountCreated({ accountId, eventKey: "signup:11111111", }); const grant = await bursar.credits.addCredits(accountId, "100.000000", { type: "purchase", idempotencyKey: "purchase:quickstart:11111111", }); console.log(grant.entryId, grant.newBalance); ``` ```go accountID := "11111111-1111-4111-8111-111111111111" if _, err := sdk.Accounts.OnAccountCreated(ctx, bursar.AccountCreatedInput{ AccountID: accountID, EventKey: "signup:11111111", }); err != nil { log.Fatal(err) } grant, err := sdk.Credits.AddCredits(ctx, accountID, bursar.MustAmount("100.000000"), bursar.AddCreditsOptions{ Type: "purchase", IdempotencyKey: "purchase:quickstart:11111111", }) if err != nil { log.Fatal(err) } log.Println(grant.EntryID, grant.NewBalance) ``` The signup event assigns the `standard` plan from `catalog.default_plan`. The purchase appends one ledger entry and creates spendable credit in the `purchased` bucket. ## 6. Meter and charge usage Record 800 input tokens. The rate card prices 1,000 tokens at 0.25 credits, so this call charges 0.20 credits. ```python from bursar.metrics import UsageMetrics usage = UsageMetrics( operation="completion", measures={"input_tokens": Decimal("800")}, dimensions={"model": "example-model"}, ) charge = bursar.credits.deduct( account_id, usage, idempotency_key="request:quickstart:0195", ) print(charge.amount, charge.balance_after, charge.entry_id) ``` ```typescript const usage = { operation: "completion", measures: { input_tokens: "800" }, dimensions: { model: "example-model" }, }; const charge = await bursar.credits.deduct(accountId, usage, { idempotencyKey: "request:quickstart:0195", }); console.log(charge.amount, charge.balanceAfter, charge.entryId); ``` ```go engine, err := sdk.Catalog.Engine() if err != nil { log.Fatal(err) } quote, err := engine.Calculate(bursar.UsageMetrics{ Operation: "completion", Measures: map[string]bursar.Amount{"input_tokens": bursar.MustAmount("800")}, Dimensions: map[string]any{"model": "example-model"}, }) if err != nil { log.Fatal(err) } charge, err := sdk.Credits.Deduct(ctx, accountID, quote.Total, bursar.DeductWithAllowanceOptions{ Operation: "completion", IdempotencyKey: "request:quickstart:0195", OperationUsageOptions: bursar.OperationUsageOptions{ Measures: map[string]bursar.Amount{"input_tokens": bursar.MustAmount("800")}, Dimensions: map[string]any{"model": "example-model"}, }, }) if err != nil { log.Fatal(err) } log.Println(charge.Amount, charge.EntryID) ``` Call `deduct` again with the same idempotency key and payload. Bursar returns the original result without adding another ledger entry. Reusing the key with a different payload returns a conflict. ## 7. Verify the ledger Read the canonical balance and the latest entries. The account should hold 99.800000 credits after the 0.200000 charge. ```python balance = bursar.credits.get_balance(account_id) page = bursar.credits.list_ledger_entries(account_id, limit=10) print(balance.balance) for entry in page.items: print(entry.entry_type, entry.amount, entry.created_at) ``` ```typescript const balance = await bursar.credits.getBalance(accountId); const page = await bursar.credits.listLedgerEntries(accountId, { limit: 10 }); console.log(balance.balance); for (const entry of page.items) { console.log(entry.entryType, entry.amount, entry.createdAt); } ``` ```go balance, err := sdk.Credits.GetBalance(ctx, accountID) if err != nil { log.Fatal(err) } page, err := sdk.Credits.ListLedgerEntries(ctx, accountID, bursar.ListLedgerEntriesOptions{Limit: 10}) if err != nil { log.Fatal(err) } log.Println(balance.Balance) for _, entry := range page.Items { log.Println(entry.EntryType, entry.Amount) } ``` The ledger is the accounting history. Do not maintain a second application-level balance counter. ## Continue with a production workflow - Follow [Manage the credit lifecycle](./guides/credit-lifecycle.mdx) for refunds, expiry, revocation, and cursor-based ledger reads - Follow [Protect long-running work](./guides/financial-safety.mdx) for reserve, renew, settle, and release workflows - Read [Configuration and catalog revisions](./concepts/configuration.mdx) before adding plans, quotas, entitlements, or payment offers - Run the [executable tutorial collection](/docs/tutorials) to explore the same APIs against disposable PostgreSQL environments --- ## Set up Bursar and create an account {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/00_why_bursar_and_setup.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/00_why_bursar_and_setup.ipynb). ::: # 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. ```python # 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. ```python # 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. ```python 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. ```python 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. ```python 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. ```python cleanup(pgdata) ``` --- ## Publish your first pricing configuration {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/01_first_pricing_config.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/01_first_pricing_config.ipynb). ::: # Publish your first pricing configuration Bursar uses one validated document for operations, rates, credit buckets, plans, and commerce policy. This tutorial examines the shared example, publishes an immutable revision, activates it, and verifies that invalid fields cannot enter the catalog. ## Learning objectives After completing this tutorial, you can: - Identify each top-level configuration section - Validate, publish, and activate a configuration revision - Explain how canonicalization and digests prevent duplicate revisions - Diagnose strict-schema validation errors ## Prerequisites - Complete the setup tutorial or install the Bursar Python development environment - Start the notebook server from `samples/python/notebooks/` ## The document at a glance Every bursar document is a strict dict with a fixed top-level shape: `catalog` (which plan is the default), `pricing` (operations and rate cards), `credits` (buckets and the default bucket), `entitlements` (feature definitions), `admission` (concurrency policies), `plans`, and `commerce` (providers, offers, auto-recharge). The cell below lists the sections the canonical demo config declares. ```python from shared import base_config, start_postgres_store, cleanup, publish_config from bursar.config import load_config_from_dict, ConfigError config = base_config() print("top-level sections:", sorted(config.keys())) ``` ## pricing.operations: what you sell An operation declares the **measures** it bills on, each with a unit, and the **dimensions** that select a price. The shared tutorial configuration declares two operations: - `completion` — measures `input_tokens`, `output_tokens`, `cache_read_tokens` (unit `token`), dimension `model`; - `execution` — measures `jobs` (unit `job`) and `compute_seconds` (unit `second`), dimension `model`. Measures are quantities, dimensions are selectors. Later the engine charges any combination of measures — one you do not report counts as zero. ```python for name, operation in config["pricing"]["operations"].items(): measures = ", ".join(f"{m} ({unit['unit']})" for m, unit in operation["measures"].items()) dimensions = ", ".join(operation["dimensions"]) print(f"{name}: measures=[{measures}] dimensions=[{dimensions}]") ``` ## pricing.rate_cards: what it costs A rate card prices every operation with an ordered list of **rules**. Each rule has a `when` clause that matches on dimensions — here `model in ["gpt-4o", "gpt-4o-mini"]` — and a `charge` built from building blocks. The `completion` charge is a `sum` of three `per_unit` components: each multiplies a measure by a `rate` and divides by a `unit_size`, which is how "per 1M tokens" is expressed. Rates are decimal strings, never floats. The `unmatched` policy decides what happens when no rule matches: `charge` applies a fallback (here an expression over the operation's measures), while `reject` refuses the request. The base config rejects unknown models for `execution` — an unpriced job is a bug, not a discount. ```python card = config["pricing"]["rate_cards"]["standard"] completion = card["operations"]["completion"] rule = completion["rules"][0] print("when:", rule["when"]) print("charge type:", rule["charge"]["type"]) for component in rule["charge"]["components"]: print("component:", component) print("unmatched completion:", completion["unmatched"]["action"]) print("unmatched execution:", card["operations"]["execution"]["unmatched"]["action"]) ``` ## credits, plans, entitlements, admission, commerce The rest of the document answers product questions: - `credits.buckets` — where money sits. `promotional` (priority 1) spends first; `purchased` (priority 10) is where purchases land and is the default bucket. - `plans` — `free` (rank 0, completion only, 10,000-credit monthly calendar allowance) and `pro` (rank 1, completion + execution, `voice_mode` and `max_context` features, a 500,000 output-token/day blocking quota, and admission policy `default`). - `entitlements.features` — the catalogue of features plans can turn on (`voice_mode` boolean, `max_context` integer). - `admission.policies` — the `default` policy caps concurrent in-flight operations at 4. - `commerce` — the `pro_monthly` subscription (50,000-credit cycle grant) and `credits_10k` top-up, plus auto-recharge guardrails: recharge when the balance drops to 2,000 credits, at most 5 purchases per day, max 5,000 minor units per charge. ```python print( "buckets:", {k: bucket["priority"] for k, bucket in config["credits"]["buckets"].items()}, "| default:", config["credits"]["default_bucket"], ) for key, plan in config["plans"].items(): summary = { "rank": plan["rank"], "rate_card": plan["rate_card"], "operations": plan["allowed_operations"], "features": plan.get("features", {}), "allowance": plan.get("credit_allowance", {}).get("amount"), "quotas": list(plan.get("quotas", {}).keys()), "admission_policy": plan.get("admission_policy"), } print(f"plan {key}:", summary) print("offers:", sorted(config["commerce"]["offers"].keys())) print("admission policies:", sorted(config["admission"]["policies"].keys())) ``` ```python # load_config_from_dict is the gatekeeper: it parses the document into # typed models and validates every cross-reference — rate cards exist, # plans reference real operations, quotas reference real measures, offer # buckets exist, and every pricing expression is safe. Publishing runs # the same validation, so a document that fails here never reaches the # live catalog. parsed = load_config_from_dict(base_config()) print("parsed plans:", list(parsed.plans.keys())) print("parsed operations:", list(parsed.pricing.operations.keys())) print("parsed rate cards:", list(parsed.pricing.rate_cards.keys())) print("free plan operations:", parsed.plans["free"].allowed_operations) print("pro plan features:", parsed.plans["pro"].features) ``` ## Publish, activate, and advance Publishing writes the validated document as a new immutable catalog version and activates it; `catalog.get_active()` returns the live version. Then we exercise the loop that matters: raise the gpt-4o output rate from 0.0100 to 0.0200 credits per million tokens, publish again, and the catalog advances to version 2. Version 1 is not overwritten — it stays in history for audit and rollback — and the active document read back through the facade shows the new rate. ```python store, pgdata = start_postgres_store() bursar = publish_config(store, base_config(), label="notebooks") print("active version:", bursar.catalog.get_active().version) raised = base_config() raised["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["rules"][0]["charge"]["components"][1]["rate"] = "0.0200" bursar.catalog.publish_and_activate(raised, label="raise gpt-4o output rate") print("active version after change:", bursar.catalog.get_active().version) active = bursar.catalog.get_config() components = active.pricing.rate_cards["standard"].operations["completion"].rules[0].charge.components print("active gpt-4o output rate:", components[1].rate) ``` ## Bad configs are rejected Validation is strict by design. A typo in a section name hits `extra_forbidden` instead of being silently dropped, and a malformed rate fails type validation. Both surface as `ConfigError`. The try/excepts below let the notebook keep running; in production they stop a broken configuration before it ever reaches users. ```python bogus = dict(base_config()) bogus["bogus_top_level_key"] = True try: load_config_from_dict(bogus) except ConfigError as error: print("unknown key ->", type(error).__name__) malformed = base_config() malformed["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["rules"][0]["charge"]["components"][0]["rate"] = "not-a-number" try: load_config_from_dict(malformed) except ConfigError as error: print("bad rate ->", type(error).__name__, "|", str(error).splitlines()[0]) cleanup(pgdata) ``` --- ## Calculate usage costs with the pricing engine {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/02_pricing_engine.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/02_pricing_engine.ipynb). ::: # Calculate usage costs with the pricing engine `PricingEngine` evaluates a validated configuration without a database or network connection. This tutorial prices several `UsageMetrics` values and inspects the exact-decimal breakdown returned by the same engine used during `deduct`. ## Learning objectives After completing this tutorial, you can: - Construct a pricing engine from a validated configuration - Price metered operations and batches with exact decimals - Read a cost breakdown and compare rate cards - Handle usage that has no matching price rule ## Prerequisites - Read the pricing configuration tutorial - Start the notebook server from `samples/python/notebooks/` ## Build the engine from the canonical config `PricingEngine.from_dict` validates the config and builds the engine in one call — the same validation path `publish_config` uses. The engine is cheap and stateless; you can build as many as you like, or reuse the facade's one. ```python from decimal import Decimal from bursar.engine import PricingEngine from bursar.metrics import UsageMetrics from shared import base_config engine = PricingEngine.from_dict(base_config()) print("engine built from canonical config") ``` ## A gpt-4o completion: the math A completion reports 1,000 input tokens, 500 output tokens, and 200 cached tokens on `gpt-4o`. The rule matches on `model`, and its `sum` charge adds three `per_unit` components, each computed as `measure / unit_size * rate`: `0.0025 * 1000 / 1e6 + 0.0100 * 500 / 1e6 + 0.00125 * 200 / 1e6` `= 0.0000025 + 0.000005 + 0.00000025 = 0.00000775` credits The engine quantizes every result to 6 decimal places with `ROUND_HALF_UP`, so the total you see is `0.000008` — never truncated, never silently rounded down. ```python completion = UsageMetrics( operation="completion", measures={ "input_tokens": Decimal(1000), "output_tokens": Decimal(500), "cache_read_tokens": Decimal(200), }, dimensions={"model": "gpt-4o"}, ) cost = engine.calculate(completion, rate_card="standard") print("gpt-4o completion total:", cost.total) ``` ## A second model, and the expression fallback `gpt-4o-mini` matches the same rule, so it is priced at the same per-1M rates: 2,000 input + 800 output tokens costs `0.0025 * 2000 / 1e6 + 0.0100 * 800 / 1e6 = 0.000005 + 0.000008 = 0.000013` credits. A model with no rule — here `mistral-large` — falls through to the operation's `unmatched` policy, which charges the expression `input_tokens * 0.005 + output_tokens * 0.015`. For 1,000 + 500 tokens that is `5 + 7.5 = 12.5` credits. This is your "any new model is priced, even before we publish a rule for it" backstop. ```python mini = UsageMetrics( operation="completion", measures={"input_tokens": Decimal(2000), "output_tokens": Decimal(800)}, dimensions={"model": "gpt-4o-mini"}, ) print("gpt-4o-mini total:", engine.calculate(mini, rate_card="standard").total) unknown = UsageMetrics( operation="completion", measures={"input_tokens": Decimal(1000), "output_tokens": Decimal(500)}, dimensions={"model": "mistral-large"}, ) print("unknown model total:", engine.calculate(unknown, rate_card="standard").total) ``` ## Execution jobs, and a batch The `execution` operation bills `jobs` at a flat 0.04 credits per job when `model` is `gpt-4o` — `compute_seconds` is metered but not priced, a measure the operation accepts and the rule ignores. Three jobs cost `3 * 0.04 = 0.12` credits. `calculate_batch` prices many events in one call and returns one `CostBreakdown` each — the pattern for a nightly re-pricing job or a usage report. ```python execution = UsageMetrics( operation="execution", measures={"jobs": Decimal(3), "compute_seconds": Decimal(120)}, dimensions={"model": "gpt-4o"}, ) print("3 jobs total:", engine.calculate(execution, rate_card="standard").total) batch = engine.calculate_batch([completion, mini, execution], rate_card="standard") print("batch totals:", [str(item.total) for item in batch]) ``` ## The breakdown, and rate cards per plan Every result carries a `breakdown` dict describing how the total was produced: the operation, the rate card that priced it, the charge type that won, and the exact measures and dimensions that were used. This is the audit trail for "why did this cost what it did?". Rate cards are bound to plans, not to users: `get_rate_card_for_plan("pro")` resolves the plan's configured card. Both `free` and `pro` use `standard`; an unknown plan resolves to `None`, meaning the caller must decide how to price it. ```python cost = engine.calculate(completion, rate_card="standard") print("total:", cost.total) print("breakdown keys:", sorted(cost.breakdown.keys())) print("charge type:", cost.breakdown["charge_type"]) print("rate card:", cost.breakdown["rate_card"]) print("measures:", cost.breakdown["measures"]) print("dimensions:", cost.breakdown["dimensions"]) print("rate card for 'pro':", engine.get_rate_card_for_plan("pro")) print("rate card for 'free':", engine.get_rate_card_for_plan("free")) print("rate card for unknown plan:", engine.get_rate_card_for_plan("enterprise")) ``` ## Errors: unpriced work is refused The engine is strict where it counts. `execution` has `unmatched: reject`, so a job on a model without a rule is a `ConfigError`, not a silent free ride. And an operation that does not exist in the config at all — say `transcription` — fails before any pricing logic runs. Both are caught below. ```python from bursar.config import ConfigError try: engine.calculate( UsageMetrics( operation="execution", measures={"jobs": Decimal(1)}, dimensions={"model": "llama-3"}, ), rate_card="standard", ) except ConfigError as error: print("unpriced execution ->", type(error).__name__, "|", error) try: engine.calculate( UsageMetrics(operation="transcription", measures={"seconds": Decimal(60)}), rate_card="standard", ) except ConfigError as error: print("unknown operation ->", type(error).__name__, "|", error) ``` --- ## Write safe pricing expressions {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/03_expression_language.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/03_expression_language.ipynb). ::: # Write safe pricing expressions Bursar's expression language supports minimum charges, caps, conditional pricing, and volume tiers without executing application code. This tutorial evaluates formulas with exact decimals and verifies the sandbox boundary. ## Learning objectives After completing this tutorial, you can: - Evaluate arithmetic, conditional, tier, and rounding expressions - Supply usage measures as expression variables - Validate a formula before publishing configuration - Explain which syntax and operations the sandbox rejects ## Prerequisites - Read the pricing engine tutorial - Start the notebook server from `samples/python/notebooks/` ## Evaluate the basic operators `evaluate_expression(formula, variables)` parses, validates, and evaluates a formula in exact `Decimal` arithmetic. Variables come from `UsageMetrics.measures`. Standard arithmetic precedence applies. ```python from decimal import Decimal from bursar.expr import evaluate_expression, validate_expression, ExpressionError # The rate card fallback, computed by hand: 2 * 5 + 3 * 15 = 55. print("line rate:", evaluate_expression("input_tokens * 5 + output_tokens * 15", {"input_tokens": 2, "output_tokens": 3})) # Parentheses: (2 + 3) * 2 = 10. print("parens: ", evaluate_expression("(input_tokens + output_tokens) * 2", {"input_tokens": 2, "output_tokens": 3})) # Division: 10 / 4 = 2.5, exact. print("division:", evaluate_expression("input_tokens / 4", {"input_tokens": 10})) ``` ## Floors and caps: max() and min() `max()` enforces a minimum charge floor — a 50-credit floor means tiny completions still cost 50. `min()` caps a charge at a ceiling, the inverse guardrail. ```python # max(a, b, ...) returns the largest argument: the floor wins at 50. print("floor:", evaluate_expression("max(50, input_tokens * 2)", {"input_tokens": 10})) print("above floor:", evaluate_expression("max(50, input_tokens * 2)", {"input_tokens": 40})) # min(a, b, ...) returns the smallest argument: the cap wins at 100. print("cap:", evaluate_expression("min(100, input_tokens * 2)", {"input_tokens": 70})) print("below cap:", evaluate_expression("min(100, input_tokens * 2)", {"input_tokens": 10})) ``` ## Conditionals: if() `if(condition, then, else)` is the language's branch. This example makes completions under 100 input tokens free and charges 1.5 credits for other completions. Conditions support comparisons and `and`/`or`; the `if(...)` spelling is rewritten internally to a safe function call. ```python print("under 100 tokens:", evaluate_expression("if(input_tokens < 100, 0, 1.5)", {"input_tokens": 50})) print("over 100 tokens:", evaluate_expression("if(input_tokens < 100, 0, 1.5)", {"input_tokens": 150})) # Conditions nest: free only when both measures are small. print( "nested:", evaluate_expression( "if(input_tokens < 100, if(output_tokens < 10, 0, 0.5), 1.5)", {"input_tokens": 50, "output_tokens": 5}, ), ) ``` ## Volume discounts: tier() `tier(value, t1, r1, t2, r2, ..., default)` selects the first rate whose upper bound exceeds the value and uses the last argument as the default. The example output rate is 0.01 under 1,000 tokens, 0.008 under 10,000, and 0.006 beyond. ```python curve = "tier(output_tokens, 1000, 0.01, 10000, 0.008, 0.006)" print("small batch:", evaluate_expression(curve, {"output_tokens": 500})) print("mid batch: ", evaluate_expression(curve, {"output_tokens": 5000})) print("big batch: ", evaluate_expression(curve, {"output_tokens": 50000})) ``` ## Hard bounds: clamp() `clamp(x, lo, hi)` pins a value into a closed range — a minimum charge that is also capped, in one call. ```python print("clamp low: ", evaluate_expression("clamp(input_tokens * 2, 10, 100)", {"input_tokens": 4})) print("clamp mid: ", evaluate_expression("clamp(input_tokens * 2, 10, 100)", {"input_tokens": 30})) print("clamp high:", evaluate_expression("clamp(input_tokens * 2, 10, 100)", {"input_tokens": 60})) ``` ## Rounding: ceil(), floor(), round() `ceil` and `floor` round whole units — handy for pricing by started units (an execution billed per started minute). `round(x, 2)` rounds to decimal places with `ROUND_HALF_UP`, the same convention the engine uses for credit amounts. ```python print("ceil: ", evaluate_expression("ceil(input_tokens / 3)", {"input_tokens": 10})) print("floor:", evaluate_expression("floor(input_tokens / 3)", {"input_tokens": 10})) print("round:", evaluate_expression("round(input_tokens * 3.14159, 2)", {"input_tokens": 1})) ``` ## Percentiles, and a realistic formula `percentile(p, v1, v2, ...)` interpolates the p-th percentile of its arguments. The language requires at least one measure reference, so the first sample is the current metric's value. Then a realistic combined formula: volume-discounted input rate, flat output rate, 4-decimal rounding, and a hard clamp — for 5,000 + 1,000 tokens the tier picks 0.004, so `0.004 * 5000 + 0.015 * 1000 = 35.0000`. ```python print("median:", evaluate_expression("percentile(50, input_tokens, 2, 3, 4)", {"input_tokens": 1})) print("p90: ", evaluate_expression("percentile(90, input_tokens, 2, 3, 4)", {"input_tokens": 1})) combined = ( "clamp(round(" "tier(input_tokens, 1000, 0.005, 100000, 0.004, 0.003) * input_tokens" " + output_tokens * 0.015, 4), 0, 500)" ) print("combined:", evaluate_expression(combined, {"input_tokens": 5000, "output_tokens": 1000})) ``` ## The sandbox: what the language refuses Expressions are stored in config and evaluated by the engine — code from a config file is an attack surface, so the language is a locked-down subset of arithmetic and functions. There is no way to reach Python: - `**` exponentiation is rejected outright. - Builtins like `__import__` are not in the language — a formula can never touch `os`, files, or the network. (`__import__('os')` below is exactly the kind of string that would be dangerous in `eval`, and exactly what the parser refuses.) - Division by zero raises a clear `ExpressionError`. - An undefined variable raises, rather than silently evaluating to something. The same validation runs at config-load time against the operation's declared measures: a formula referencing an undeclared measure fails `load_config_from_dict`, and a formula with no measure reference at all is rejected too. ```python try: evaluate_expression("input_tokens ** 2", {"input_tokens": 2}) except ExpressionError as error: print("pow ->", type(error).__name__, "|", error) try: evaluate_expression("__import__('os')", {"input_tokens": 2}) except ExpressionError as error: print("builtin ->", type(error).__name__, "|", error) try: evaluate_expression("input_tokens / 0", {"input_tokens": 2}) except ExpressionError as error: print("div by zero ->", type(error).__name__, "|", error) try: evaluate_expression("bogus * 2", {"input_tokens": 2}) except ExpressionError as error: print("undefined var ->", type(error).__name__, "|", error) # The same rules apply at config-load time, scoped to declared measures. try: validate_expression("input_tokens * 2 + widgets", {"input_tokens"}) except ExpressionError as error: print("validate ->", type(error).__name__, "|", error) from shared import base_config from bursar.config import load_config_from_dict, ConfigError config = base_config() config["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["rules"][0]["charge"] = { "type": "expression", "formula": "input_tokens * 0.0025 + widgets * 5", } try: load_config_from_dict(config) except ConfigError as error: print("config undeclared measure ->", type(error).__name__, "|", error) constant_only = base_config() constant_only["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["rules"][0]["charge"] = { "type": "expression", "formula": "5 + 3", } try: load_config_from_dict(constant_only) except ConfigError as error: print("config constant-only ->", type(error).__name__, "|", error) ``` --- ## Manage the credit lifecycle {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/04_credit_lifecycle.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/04_credit_lifecycle.ipynb). ::: # Manage the credit lifecycle This tutorial follows credits from purchase through metered spending, bucket allocation, refund, and ledger inspection. Every mutation runs against an isolated PostgreSQL store and uses the same transactional paths as a production integration. ## Learning objectives After completing this tutorial, you can: - Post a purchase with a stable idempotency key - Charge measured usage and handle insufficient credit - Explain how bucket priority controls consumption - Refund a charge and verify the append-only ledger ## Prerequisites - Complete the foundation tutorials - Start the notebook server from `samples/python/notebooks/` - Permit the shared helper to create a disposable PostgreSQL environment ## Setup We start a temporary Postgres store, publish the standard pricing config, and create `USER_ADA` on the **pro** plan (unmetered usage billed from a prepaid balance — the free-plan allowance path is covered in notebook 05). ```python from decimal import Decimal from bursar.metrics import UsageMetrics from shared import cleanup, base_config, publish_config, start_postgres_store, USER_ADA def completion(output=Decimal(500)): return UsageMetrics( operation="completion", measures={ "input_tokens": Decimal(1000), "output_tokens": output, "cache_read_tokens": Decimal(200), }, dimensions={"model": "gpt-4o"}, ) def execution(jobs=Decimal(1)): return UsageMetrics( operation="execution", measures={"jobs": jobs, "compute_seconds": Decimal(30)}, dimensions={"model": "gpt-4o"}, ) store, pgdata = start_postgres_store() bursar = publish_config(store, base_config()) bursar.accounts.on_account_created(USER_ADA, "signup") bursar.credits.set_user_plan(USER_ADA, "pro") print("temporary postgres:", pgdata) ``` ## Buying credits `add_credits` lands money in the default **purchased** bucket and returns the resulting balance. The purchase also posts a `purchase` entry to the ledger and tracks the lifetime total. Expect `new_balance = 100.000000` and `lifetime_purchased = 100.000000`. ```python result = bursar.credits.add_credits( USER_ADA, Decimal("100.00"), entry_type="purchase", idempotency_key="buy-topup-0001", ) print("entry_id:", result.entry_id) print("new balance:", result.new_balance) print("lifetime purchased:", result.lifetime_purchased) print("bucket:", result.bucket) print("idempotent replay?:", result.idempotent) ``` ## Idempotent top-ups Retrying the same purchase with the same `idempotency_key` is a no-op: the store returns the *original* entry id and marks the call `idempotent=True`. Network retries can never double-charge a user. ```python replay = bursar.credits.add_credits( USER_ADA, Decimal("100.00"), entry_type="purchase", idempotency_key="buy-topup-0001", ) print("same entry_id:", replay.entry_id == result.entry_id) print("balance unchanged:", replay.new_balance) print("idempotent replay?:", replay.idempotent) ``` ## Spending on usage A small chat completion is priced at `0.000008` credits (1000 input + 500 output + 200 cached tokens at the standard rate card). Metered `deduct` debits the balance and records a `usage` ledger entry. Replaying the same idempotency key later is a no-op that returns the stored result of the original charge — note its balance snapshot is from the *original* charge, not the current balance. ```python first = bursar.credits.deduct(USER_ADA, completion(), idempotency_key="chat-1") print("entry:", first.entry_id[:12], "| amount:", first.amount) print("balance after:", first.balance_after) print("bucket breakdown:", first.bucket_breakdown) bigger = bursar.credits.deduct(USER_ADA, completion(output=Decimal(20000)), idempotency_key="chat-2") print("20k-output chat | amount:", bigger.amount, "| balance after:", bigger.balance_after) replay = bursar.credits.deduct(USER_ADA, completion(), idempotency_key="chat-1") print("replayed chat-1:", replay.idempotent, "| same entry:", replay.entry_id == first.entry_id) print("replay result balance snapshot:", replay.balance_after) print("actual balance now:", bursar.credits.get_balance(USER_ADA).balance) ``` ## When the money runs out A metered charge that would push the balance negative raises `InsufficientCreditsError` (`INSUFFICIENT_CREDITS`) and charges nothing — 2500 execution jobs cost exactly 100.00 credits, but 2501 cost 100.04. The direct balance API, `deduct_credits`, surfaces the same situation as a `StoreError` from the store RPC. In both cases the balance is untouched. ```python try: bursar.credits.deduct(USER_ADA, execution(jobs=Decimal(2501)), idempotency_key="run-over") except Exception as exc: print("metered deduct:", type(exc).__name__, getattr(exc, "code", None)) try: bursar.credits.deduct_credits( USER_ADA, Decimal("1000000"), idempotency_key="run-over:raw" ) except Exception as exc: print("direct deduct_credits:", type(exc).__name__, getattr(exc, "code", None)) print("balance untouched:", bursar.credits.get_balance(USER_ADA).balance) ``` ## Buckets and spillover Credits live in **buckets** with a spending priority: promotional credits (priority 1) are spent before purchased credits (priority 10). A single charge may draw from several buckets — here a 0.08 execution charge drains the 0.05 promotional grant and spills 0.03 into purchased. Each bucket reports `expires`, the expiry-enabled flag of its definition in the published config (per-lot expiry is covered in notebook 07). ```python grant = bursar.credits.add_credits( USER_ADA, Decimal("0.05"), entry_type="grant", bucket="promotional", idempotency_key="promo-jul", ) print("promo grant | new balance:", grant.new_balance) spill = bursar.credits.deduct(USER_ADA, execution(jobs=Decimal(2)), idempotency_key="run-spill") print("0.08 charge | amount:", spill.amount, "| breakdown:", spill.bucket_breakdown) print("balance after:", spill.balance_after) balances = bursar.credits.get_bucket_balances(USER_ADA) for b in balances.buckets: print(f"bucket={b.bucket_key!r} priority={b.priority} expires={b.expires} balance={b.balance}") print("total:", balances.total_balance) ``` ## Refunds `refund_credits` reverses a charge and returns money to the bucket it came from. Refunds can be partial via `amount=`; a full refund (the default) returns everything. Refunding the same entry twice is idempotent — the store returns the original refund entry rather than refunding again. Refunding a *purchase* entry — money that was never spent — is rejected by the store with a validation error. ```python refunded = bursar.credits.refund_credits( bigger.entry_id, idempotency_key="refund:chat-2:full" ) print("refund:", refunded.amount, "| original:", refunded.original_entry_id[:12], "| new balance:", refunded.new_balance) partial = bursar.credits.refund_credits( spill.entry_id, amount=Decimal("0.03"), idempotency_key="refund:run-spill:partial" ) print("partial refund:", partial.amount, "| new balance:", partial.new_balance) again = bursar.credits.refund_credits( bigger.entry_id, idempotency_key="refund:chat-2:full" ) print("second refund idempotent:", again.refund_entry_id == refunded.refund_entry_id, "| balance:", again.new_balance) try: bursar.credits.refund_credits( result.entry_id, idempotency_key="refund:purchase:rejected" ) except Exception as exc: print("refund a purchase:", type(exc).__name__) ``` ## The ledger Every credit event — purchases, grants, usage, refunds, expiries — is an append-only ledger entry. The most recent entries come first; note the `refund` entries referencing their original charges. ```python page = bursar.credits.list_ledger_entries(USER_ADA, limit=50) print(f"{len(page.items)} entries, newest first:") for e in page.items: print(f" {e.entry_type:8s} {e.amount:>12} {e.created_at[:19]}") ``` --- ## Configure plans and allowances {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/05_plans_and_allowances.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/05_plans_and_allowances.ipynb). ::: # Configure plans and allowances Plans combine a rate card, allowed operations, entitlements, and optional recurring credit allowances. This tutorial moves one account from a free plan to a paid plan and back while observing admission and balance behavior. ## Learning objectives After completing this tutorial, you can: - Assign and change an account plan - Distinguish allowance consumption from balance deductions - Enforce operation and feature entitlements - Explain what happens when a plan changes mid-cycle ## Prerequisites - Complete the credit lifecycle tutorial - Start the notebook server from `samples/python/notebooks/` ## Setup Start a temporary Postgres store with the standard config. `on_account_created` lands `USER_ADA` on the free plan by default. ```python from decimal import Decimal from bursar.metrics import UsageMetrics from shared import cleanup, base_config, publish_config, start_postgres_store, USER_ADA def completion(output=Decimal(500)): return UsageMetrics( operation="completion", measures={ "input_tokens": Decimal(1000), "output_tokens": output, "cache_read_tokens": Decimal(200), }, dimensions={"model": "gpt-4o"}, ) def execution(jobs=Decimal(1)): return UsageMetrics( operation="execution", measures={"jobs": jobs, "compute_seconds": Decimal(30)}, dimensions={"model": "gpt-4o"}, ) store, pgdata = start_postgres_store() bursar = publish_config(store, base_config()) bursar.accounts.on_account_created(USER_ADA, "signup") print("created on plan:", bursar.credits.get_user_plan(USER_ADA).plan_key) ``` ## The free allowance The free plan grants **10,000 credits per month**. `check_allowance` reports the plan's allowance window: how much is left, and when the period runs. Nothing has been spent yet, so the full `10000.000000` remains. ```python allowance = bursar.credits.check_allowance(USER_ADA) print("plan:", allowance.plan_id) print("allowance remaining:", allowance.allowance_remaining) print("period:", allowance.period_start, "->", allowance.period_end) ``` ## Spending the allowance Free completions draw down the allowance, not a wallet: `amount` charged to the balance is `0.000000`, the allowance consumed is `0.000008` per small chat, and no ledger entry is written (`entry_id` is empty). After 12 chats the allowance shows `9999.999904` remaining — 12 × 0.000008 consumed. ```python for n in range(12): r = bursar.credits.deduct(USER_ADA, completion(), idempotency_key=f"free-{n}") print(f"chat {n + 1:2d}: amount={r.amount} allowance_consumed={r.allowance_consumed} entry_id={r.entry_id!r}") allowance = bursar.credits.check_allowance(USER_ADA) print("allowance remaining after 12 chats:", allowance.allowance_remaining) ``` ## Entitlements: features behind the plan `get_user_plan` exposes the entitlement map. On the free plan, `voice_mode` is `False` and `max_context` is 128000. Use `check_feature` as the runtime gate before offering a protected feature. ```python plan = bursar.credits.get_user_plan(USER_ADA) print("plan key:", plan.plan_key) print("entitlements:", plan.entitlements) print("voice_mode:", plan.entitlements["voice_mode"].value, "| max_context:", plan.entitlements["max_context"].value) print("check_feature(voice_mode):", bursar.credits.check_feature(USER_ADA, "voice_mode").has_feature) ``` ## Upgrading to pro `set_user_plan` swaps the account to **pro**: `voice_mode` flips to `True`, `max_context` grows to 200000, and the allowance is gone — pro is billed from a prepaid balance instead, so `check_allowance` returns `None` — a plan with no credit allowance has no window at all. ```python bursar.credits.set_user_plan(USER_ADA, "pro") plan = bursar.credits.get_user_plan(USER_ADA) print("plan key:", plan.plan_key) print("voice_mode:", plan.entitlements["voice_mode"].value, "| max_context:", plan.entitlements["max_context"].value) print("allowance policy:", plan.allowance) allowance = bursar.credits.check_allowance(USER_ADA) print("check_allowance on pro:", repr(allowance)) ``` ## Billing from the balance The same completion that drew from the allowance on free now hits the wallet: `amount=0.000008` plus a `usage` ledger entry. Pro also unlocks operations — an execution job is now billable at 0.040000. ```python bursar.credits.add_credits(USER_ADA, Decimal("100.00"), entry_type="purchase", idempotency_key="pro-topup") chat = bursar.credits.deduct(USER_ADA, completion(), idempotency_key="pro-chat-1") print("pro chat: amount", chat.amount, "| balance after", chat.balance_after, "| entry", chat.entry_id[:12]) run = bursar.credits.deduct(USER_ADA, execution(), idempotency_key="pro-run-1") print("execution job: amount", run.amount, "| balance after", run.balance_after) ``` ## Downgrading mid-cycle Back to free: the allowance policy is restored and picks up where it left off (`9999.999904` — pro usage never touched it), `voice_mode` is gated again, and execution — an operation the free plan does not allow — is refused with `OperationNotAllowedError`. ```python bursar.credits.set_user_plan(USER_ADA, "free") plan = bursar.credits.get_user_plan(USER_ADA) print("plan key:", plan.plan_key) print("allowance remaining:", bursar.credits.check_allowance(USER_ADA).allowance_remaining) print("voice_mode:", bursar.credits.check_feature(USER_ADA, "voice_mode").has_feature) try: bursar.credits.deduct(USER_ADA, execution(), idempotency_key="downgraded-run") except Exception as exc: print("execution on free:", type(exc).__name__) ``` --- ## Enforce quotas and spend caps {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/06_quotas_and_spend_caps.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/06_quotas_and_spend_caps.ipynb). ::: # Enforce quotas and spend caps Per-request pricing does not limit cumulative usage. This tutorial configures a daily output-token quota, observes alert thresholds, verifies that reservations count toward the limit, and confirms that an over-limit request is rejected atomically. ## Learning objectives After completing this tutorial, you can: - Read quota state for an account and plan - Interpret alert and block thresholds - Account for active reservations in quota usage - Verify that rejected work does not change the balance ## Prerequisites - Complete the plans and allowances tutorial - Start the notebook server from `samples/python/notebooks/` ## Setup Same sandbox: a temporary Postgres store with the standard config. `USER_ADA` is put on **pro** and loaded with 200 credits. Metered deductions below the cap are cheap, so most of the action happens in quota state, not the wallet. ```python from decimal import Decimal from bursar.metrics import UsageMetrics from shared import cleanup, base_config, publish_config, start_postgres_store, USER_ADA def completion(output=Decimal(500)): return UsageMetrics( operation="completion", measures={ "input_tokens": Decimal(1000), "output_tokens": output, "cache_read_tokens": Decimal(200), }, dimensions={"model": "gpt-4o"}, ) store, pgdata = start_postgres_store() bursar = publish_config(store, base_config()) bursar.accounts.on_account_created(USER_ADA, "signup") bursar.credits.set_user_plan(USER_ADA, "pro") bursar.credits.add_credits(USER_ADA, Decimal("200.00"), entry_type="purchase", idempotency_key="topup") print("ready") ``` ## Reading the quota state The pro plan defines one quota: `daily_tokens`, 500,000 `output_tokens` per calendar day, enforced with `block`. `emit_at_percent` lists the alert thresholds (80% and 100%). **Known issue:** the SDK's typed `get_quota_state` currently crashes with a pydantic `ValidationError` (the RPC returns a datetime where the model expects a string), so this notebook reads the raw RPC through the store and constructs `QuotaState` by hand. The shape is exactly what the typed call would return. ```python from bursar.credits.types import QuotaState def quota_state(user_id=USER_ADA): try: return bursar.credits.get_quota_state(user_id) except Exception as exc: print("typed get_quota_state:", type(exc).__name__, "- using raw RPC") rows = store._callproc("get_subject_quota_state", [user_id, None]) return [ QuotaState( user_id=r["user_id"], quota_key=r["quota_key"], operation=r["operation_key"], measure=r["measure_key"], limit=Decimal(r["quota_limit"]), consumed=Decimal(r["consumed"]), reserved=Decimal(r["reserved"]), remaining=Decimal(r["remaining"]), overage=Decimal(r["overage"]), enforcement=r["enforcement"], window_start=r["window_start"].isoformat(), window_end=r["window_end"].isoformat(), emit_at_percent=[float(p) for p in r["emit_at_percent"]], ) for r in rows ] q = quota_state()[0] print(f"quota_key={q.quota_key} operation={q.operation} measure={q.measure}") print(f"limit={q.limit} consumed={q.consumed} remaining={q.remaining}") print(f"enforcement={q.enforcement} emit_at_percent={q.emit_at_percent}") print(f"window: {q.window_start} -> {q.window_end}") ``` ## Reservations count too Admission checks the quota *before* work starts: two 200,000-token holds fit (400,000 ≤ 500,000), the third would cross 600,000 and is refused. Held tokens appear in `quota_state.reserved`; releasing returns them. The refused attempt is itself recorded as a `blocked` quota event (the event list below will show it alongside the later threshold event). ```python from bursar.credits.service_types import ReserveOptions held = [] for i in range(3): try: lease = bursar.credits.reserve( USER_ADA, completion(output=Decimal("200000")), ReserveOptions(ttl=60, idempotency_key=f"quota:reserve:{i}"), ) held.append(lease) print(f"reserve {i + 1}: ok hold={lease.amount} available={lease.available}") except Exception as exc: print(f"reserve {i + 1}: {type(exc).__name__} (RPC refused: would exceed quota)") q = quota_state()[0] print(f"quota after holds: consumed={q.consumed} reserved={q.reserved} remaining={q.remaining}") for lease in held: bursar.credits.release(USER_ADA, lease.lease_id) q = quota_state()[0] print(f"quota after release: reserved={q.reserved} remaining={q.remaining}") ``` ## Streaming up to the cap Eleven metered completions stream 420,000 output tokens in 40,000-token steps. At 80% (400,000) the quota emits a `threshold` event carrying the idempotency key of the charge that crossed the line. ```python total = Decimal(0) while total < Decimal("420000"): step = min(Decimal("40000"), Decimal("420000") - total) bursar.credits.deduct(USER_ADA, completion(output=step), idempotency_key=f"stream-{int(total)}") total += step if int(total) % 200000 == 0: q = quota_state()[0] print(f"consumed={q.consumed} remaining={q.remaining}") for e in bursar.credits.list_quota_events(USER_ADA): print(f"event: type={e.event_type} threshold={e.threshold_percent} key={e.idempotency_key}") ``` ## Hitting the wall A single 600,000-token request is blocked outright: `QuotaExceededError` with code `QUOTA_EXCEEDED`, nothing is charged, and the quota records a `blocked` event. The counter stays at 420,000 — overage is refused, not accumulated. ```python balance_before = bursar.credits.get_balance(USER_ADA).balance try: bursar.credits.deduct(USER_ADA, completion(output=Decimal("600000")), idempotency_key="big-run") except Exception as exc: print("blocked:", type(exc).__name__, getattr(exc, "code", None)) print("balance unchanged:", bursar.credits.get_balance(USER_ADA).balance == balance_before) q = quota_state()[0] print(f"consumed={q.consumed} remaining={q.remaining}") for e in bursar.credits.list_quota_events(USER_ADA): print(f"event: type={e.event_type} threshold={e.threshold_percent} key={e.idempotency_key}") ``` ## Windows and plan differences The daily window rolls at midnight UTC; `window_start` / `window_end` mark the current one. Quotas are defined per plan: the free plan has no quota rows at all, so a free account reads an empty list. ```python q = quota_state(USER_ADA)[0] print("current window:", q.window_start, "->", q.window_end) from shared import USER_ALEX bursar.accounts.on_account_created(USER_ALEX, "signup") print("free-plan quota rows:", quota_state(USER_ALEX)) ``` --- ## Manage credit priority and expiry {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/07_credit_tiers_and_expiry.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/07_credit_tiers_and_expiry.ipynb). ::: # Manage credit priority and expiry Credit buckets let promotional and purchased value follow different consumption and expiry policies. This tutorial grants expiring promotional credits, consumes them before purchased credits, runs the expiry sweep, and verifies every change in the ledger. ## Learning objectives After completing this tutorial, you can: - Configure and inspect bucket priority - Grant credits with an expiry timestamp - Preview and execute an expiry sweep - Confirm that expiry posts an accounting entry ## Prerequisites - Complete the credit lifecycle tutorial - Start the notebook server from `samples/python/notebooks/` ## Setup Same sandbox: a temporary Postgres store, `USER_ADA` on **pro** with 100 purchased credits in the `purchased` bucket. ```python from decimal import Decimal from datetime import UTC, datetime, timedelta from time import sleep from bursar.metrics import UsageMetrics from shared import cleanup, base_config, publish_config, start_postgres_store, USER_ADA def completion(output=Decimal(500)): return UsageMetrics( operation="completion", measures={ "input_tokens": Decimal(1000), "output_tokens": output, "cache_read_tokens": Decimal(200), }, dimensions={"model": "gpt-4o"}, ) store, pgdata = start_postgres_store() bursar = publish_config(store, base_config()) bursar.accounts.on_account_created(USER_ADA, "signup") bursar.credits.set_user_plan(USER_ADA, "pro") bursar.credits.add_credits(USER_ADA, Decimal("100.00"), entry_type="purchase", idempotency_key="buy") print("ready") ``` ## Grants with a shelf life A 20.00 promotional grant is issued with `expires_at` a few seconds in the future, landing in the `promotional` tier while the purchase sits in `purchased`. Balance is 120.000000. The bucket view reports `expires=False` for both tiers: that flag mirrors the bucket *definition* in the published config (whether the bucket is expiry-enabled), and the standard config leaves it off. Expiry here is per lot, set at grant time with `expires_at` — the sweep honors it regardless of the flag. ```python grant = bursar.credits.add_credits( USER_ADA, Decimal("20.00"), entry_type="grant", bucket="promotional", expires_at=datetime.now(UTC) + timedelta(seconds=5), idempotency_key="trial-grant", ) print("grant entry:", grant.entry_id[:12], "| new balance:", grant.new_balance) for b in bursar.credits.get_bucket_balances(USER_ADA).buckets: print(f"bucket={b.bucket_key!r} expires={b.expires} balance={b.balance}") ``` ## While it lives Promotional credits sit at priority 1, so usage spends from them first. A small chat costs 0.000008, all of it from the promotional lot. ```python r = bursar.credits.deduct(USER_ADA, completion(), idempotency_key="promo-chat") print("amount:", r.amount, "| breakdown:", r.bucket_breakdown) promo = bursar.credits.get_bucket_balances(USER_ADA).buckets[0] print("promotional remaining:", promo.balance) ``` ## Expiry and the sweep After the expiry moment passes, the balance still *shows* the credits — expiry is only acted on by a sweep, and the sweep can be dry-run first. Expect the dry run to report 1 lot / 19.999992 credits (20.00 minus the chat), then the real sweep to remove it, leaving exactly the purchased 100.000000. ```python sleep(6) print("balance before sweep:", bursar.credits.get_balance(USER_ADA).balance) dry = bursar.credits.sweep_expired_credits(dry_run=True) print("dry run:", dry.expired_count, "lots,", dry.expired_amount, "credits, by bucket:", dry.expired_by_bucket) real = bursar.credits.sweep_expired_credits(dry_run=False) print("real sweep:", real.expired_count, "lots,", real.expired_amount, "credits, by bucket:", real.expired_by_bucket) print("balance after sweep:", bursar.credits.get_balance(USER_ADA).balance) ``` ## Past expiry is rejected Grants are validated on the way in as well: an `expires_at` in the past is refused by the store with `StoreError` — the API refuses to create a lot that is already dead. ```python try: bursar.credits.add_credits( USER_ADA, Decimal("10.00"), entry_type="grant", bucket="promotional", expires_at=datetime.now(UTC) - timedelta(seconds=1), idempotency_key="trial-grant-expired", ) except Exception as exc: print("past expiry:", type(exc).__name__, getattr(exc, "code", None), "|", str(exc)[:60]) ``` ## Tiers in the ledger The ledger tells the whole story: the 100.00 purchase, the 20.00 grant, the small usage that ate into it, and the negative `expiry` entry that retired the rest. ```python for e in bursar.credits.list_ledger_entries(USER_ADA, limit=6).items: print(f" {e.entry_type:8s} {e.amount:>12} {e.created_at[:19]}") ``` --- ## Protect long-running work with leases {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/08_leases_and_financial_safety.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/08_leases_and_financial_safety.ipynb). ::: # Protect long-running work with leases A lease reserves a worst-case cost before long-running work begins, then settles the measured cost or releases the hold. This tutorial exercises reservation, settlement, release, renewal, expiry, concurrency limits, and policy snapshots. ## Learning objectives After completing this tutorial, you can: - Reserve capacity before starting uncertain-cost work - Settle actual usage or release an unused hold - Renew and expire leases safely - Explain how plan changes and concurrency limits affect active leases ## Prerequisites - Complete the plans and allowances tutorial - Read the financial safety guide - Start the notebook server from `samples/python/notebooks/` ## Setup Same sandbox: a temporary Postgres store, `USER_ADA` on **pro** with 100 credits. The pricing config prices execution at 0.04 credits per job, so a single job's worst case is exactly 0.040000. ```python from decimal import Decimal from time import sleep from bursar.metrics import UsageMetrics from shared import cleanup, base_config, publish_config, start_postgres_store, USER_ADA def execution(jobs=Decimal(1)): return UsageMetrics( operation="execution", measures={"jobs": jobs, "compute_seconds": Decimal(30)}, dimensions={"model": "gpt-4o"}, ) store, pgdata = start_postgres_store() bursar = publish_config(store, base_config()) bursar.accounts.on_account_created(USER_ADA, "signup") bursar.credits.set_user_plan(USER_ADA, "pro") bursar.credits.add_credits(USER_ADA, Decimal("100.00"), entry_type="purchase", idempotency_key="buy") print("ready") ``` ## Reserving before work `reserve` admits the request and holds the worst-case 0.040000. The balance is untouched, but `get_available` shows the money is now spoken for: reserved 0.040000, available 99.960000. `billing_mode=strict` means the hold is a hard floor — settlement can never take the balance negative. ```python from bursar.credits.service_types import ReserveOptions, SettleOptions lease = bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=120, idempotency_key="lease:initial") ) print("lease_id:", lease.lease_id) print("hold:", lease.amount, "| billing_mode:", lease.billing_mode) print("expires_at:", lease.expires_at[:19]) avail = bursar.credits.get_available(USER_ADA) print(f"balance={avail.balance} reserved={avail.reserved} available={avail.available}") ``` ## Settling bills the actual The run completes and reports the *actual* metrics: 240 jobs instead of the 250 that were estimated. Settlement debits the actual cost (9.600000) and releases the rest of the hold. Settling the same lease again is idempotent and returns the same entry. ```python actual = execution(jobs=Decimal(240)) settled = bursar.credits.settle( USER_ADA, lease.lease_id, actual, SettleOptions(idempotency_key="lease:initial:settle") ) print("entry:", settled.entry_id[:12], "| amount:", settled.amount, "| balance after:", settled.balance_after) replay = bursar.credits.settle( USER_ADA, lease.lease_id, actual, SettleOptions(idempotency_key="lease:initial:settle") ) print("replay idempotent:", replay.idempotent, "| same entry:", replay.entry_id == settled.entry_id) avail = bursar.credits.get_available(USER_ADA) print("available after settle:", avail.available) ``` ## One call: run_billed `run_billed` is the whole lifecycle in one call: reserve with the estimate, run `do_work`, settle with whatever it returns. Here the estimate is 250 jobs (10.00) but the work really used 240 (9.60) — the hold is sized at worst case and the charge follows reality. ```python from bursar.credits.service_types import RunBilledOptions run = bursar.credits.run_billed( USER_ADA, RunBilledOptions( estimate=execution(jobs=Decimal(250)), do_work=lambda: ("ok", execution(jobs=Decimal(240))), operation_type="execution", operation_key="voice-1", ), ) print("result:", run.result) print("charged:", run.deduction.amount, "| balance after:", run.deduction.balance_after) ``` ## Release when things fail When work fails or is cancelled, `release` returns the hold without charging anything. ```python lease = bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=120, idempotency_key="lease:release") ) print("reserved:", bursar.credits.get_available(USER_ADA).reserved) rel = bursar.credits.release(USER_ADA, lease.lease_id) print("released:", rel.released, "| reason:", rel.reason) print("available after release:", bursar.credits.get_available(USER_ADA).available) ``` ## Leases expire A lease is only valid for its TTL. After a 2-second TTL passes, settling raises `LeaseExpiredError` — the hold is already gone. `renew` extends a live lease: same lease id, later `expires_at`. ```python lease = bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=2, idempotency_key="lease:expiry") ) sleep(3) try: bursar.credits.settle( USER_ADA, lease.lease_id, execution(jobs=Decimal(1)), SettleOptions(idempotency_key="lease:expiry:settle"), ) except Exception as exc: print("settle after expiry:", type(exc).__name__) lease = bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=120, idempotency_key="lease:renew") ) renewed = bursar.credits.renew(USER_ADA, lease.lease_id, ttl=300) print("same lease id:", renewed.lease_id == lease.lease_id, "| expires_at:", renewed.expires_at[:19]) bursar.credits.settle( USER_ADA, lease.lease_id, execution(jobs=Decimal(1)), SettleOptions(idempotency_key="lease:renew:settle"), ) print("settled after renew | available:", bursar.credits.get_available(USER_ADA).available) ``` ## The in-flight cap The pro admission policy allows **4 concurrent execution leases**. Holds 1–4 pass; the 5th is refused without touching the balance — reserved stays 0.160000 and available stays put. **Known issue:** a refused reservation currently surfaces as a pydantic `ValidationError` (the RPC returns no lease row), not the typed `ConcurrencyLimitError`; the underlying RPC reports `max_concurrent_reached`. ```python held = [] for i in range(4): held.append(bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=120, idempotency_key=f"lease:concurrency:{i}"), )) before = bursar.credits.get_available(USER_ADA) print(f"4 leases held: reserved={before.reserved} available={before.available}") try: bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=120, idempotency_key="lease:concurrency:limit"), ) except Exception as exc: print("5th lease:", type(exc).__name__) after = bursar.credits.get_available(USER_ADA) print(f"still reserved={after.reserved} available={after.available} (unchanged)") for l in held: bursar.credits.release(USER_ADA, l.lease_id) print("after releasing all:", bursar.credits.get_available(USER_ADA).available) ``` ## Settling after a downgrade Financial safety means the hold is honored even if the world changes mid-flight: a lease reserved on pro settles cleanly after the account is downgraded to free — the minimum balance was captured at reservation time, and settlement is de-clamped, billing the actual 0.400000 for 10 jobs. ```python lease = bursar.credits.reserve( USER_ADA, execution(), ReserveOptions(ttl=120, idempotency_key="lease:plan-change") ) print("reserved on pro:", lease.billing_mode, lease.amount) bursar.credits.set_user_plan(USER_ADA, "free") settled = bursar.credits.settle( USER_ADA, lease.lease_id, execution(jobs=Decimal(10)), SettleOptions(idempotency_key="lease:plan-change:settle"), ) print("settled on free:", settled.amount, "| balance after:", settled.balance_after) ``` --- ## Share credits across a team {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/09_teams.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/09_teams.ipynb). ::: # Share credits across a team A Bursar team owns a shared credit pool while each member retains an independently enforced spend cap. This tutorial creates a team, manages membership, charges the shared pool, and verifies that membership changes do not reset spend history. ## Learning objectives After completing this tutorial, you can: - Create and fund a team account - Add members with individual spend caps - Charge usage against the shared pool - Remove and restore membership without losing audit history ## Prerequisites - Complete the credit lifecycle tutorial - Start the notebook server from `samples/python/notebooks/` ## Setup Each notebook starts a throwaway Postgres cluster, runs the bursar schema, and publishes the demo configuration. `publish_config` returns a `Bursar` facade bound to the store; team operations live on the store and on `bursar.credits`. ```python import atexit from shared import start_postgres_store, cleanup, base_config, publish_config, USER_ADA, USER_ALEX, USER_JAMAL from decimal import Decimal from bursar.metrics import UsageMetrics store, pgdata = start_postgres_store() atexit.register(cleanup, pgdata) bursar = publish_config(store, base_config()) credits = bursar.credits print("config published") ``` ## Creating a team `create_team(owner_subject_id, name, initial_balance)` provisions the pool and makes the owner its first member with an uncapped role. `get_team_balance` reports the pool balance and the number of members. ```python team = store.create_team( owner_subject_id=USER_ADA, name="Acme", initial_balance=Decimal("1000"), idempotency_key="team:acme:create", ) print("team_id:", team.team_id) balance = store.get_team_balance(team.team_id) print("balance:", balance.balance) print("members:", balance.member_count) ``` ## Adding members with spend caps `add_team_member(team_id, subject_id, role, spend_cap)` adds a member with a cap measured in credits. `get_team_members` returns each member's role, cap, and cumulative spend. ```python store.add_team_member(team.team_id, USER_ALEX, role="member", spend_cap=Decimal("200")) store.add_team_member(team.team_id, USER_JAMAL, role="admin", spend_cap=Decimal("300")) for member in store.get_team_members(team.team_id): print(member.user_id[:8], member.role, "cap:", member.spend_cap, "spent:", member.total_spent) ``` ## Spending against the team pool `credits.deduct_team(team_id, user_id, metrics, idempotency_key=...)` prices the usage with the member's plan rate card, debits the **team** pool (not the member's personal balance), and attributes the charge to the member. The caller-stable key makes retries replay-safe. The result carries the pool balance after the charge. ```python bursar.accounts.on_account_created(USER_ALEX, "acct_alex") result = credits.deduct_team( team.team_id, USER_ALEX, UsageMetrics( operation="completion", measures={"input_tokens": Decimal(40000), "output_tokens": Decimal(10000)}, dimensions={"model": "gpt-4o"}, ), idempotency_key="team:alex:completion:1", ) print("charged:", result.amount) print("team balance after:", result.team_balance_after) print("entry_id:", result.entry_id) for member in store.get_team_members(team.team_id): print(member.user_id[:8], "spent:", member.total_spent) ``` ## Enforcing a member spend cap A member whose cumulative spend plus the requested charge would exceed their cap is rejected with `CapReachedError`; the team pool is untouched. ```python try: credits.deduct_team( team.team_id, USER_ALEX, UsageMetrics( operation="completion", measures={"input_tokens": Decimal(1000000), "output_tokens": Decimal(100000)}, dimensions={"model": "gpt-4o"}, ), idempotency_key="team:alex:cap-check:1", ) except Exception as exc: print(type(exc).__name__, str(exc)[:80]) print("balance unchanged:", store.get_team_balance(team.team_id).balance) ``` ## Membership lifecycle `remove_team_member` revokes access; the member's historical spend is preserved, so re-adding them with a new cap starts from their existing cumulative spend. The final cell always stops the cluster. ```python print("removed:", store.remove_team_member(team.team_id, USER_JAMAL)) store.add_team_member(team.team_id, USER_JAMAL, role="member", spend_cap=Decimal("500")) print("members after re-add:", len(store.get_team_members(team.team_id))) print("all done") ``` --- ## Query usage analytics {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/10_analytics.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/10_analytics.ipynb). ::: # Query usage analytics Bursar records metered charges in PostgreSQL usage rollups so applications can query spend without scanning the ledger. This tutorial seeds usage, groups it by account and model, inspects daily totals, and reads aggregate statistics. ## Learning objectives After completing this tutorial, you can: - Query spend by account, model, and day - Rank high-usage accounts - Read aggregate usage statistics - Distinguish usage rollups from canonical ledger entries ## Prerequisites - Complete the credit lifecycle tutorial - Start the notebook server from `samples/python/notebooks/` ## Setup Three demo users start on the **pro** plan, which has no free allowance, so every deduction becomes real spend against their purchased credits. (The free plan's monthly allowance would otherwise cover small deducts and they would not show up as charged spend.) ```python import atexit from shared import start_postgres_store, cleanup, base_config, publish_config, USER_ADA, USER_ALEX, USER_JAMAL from datetime import UTC, datetime, timedelta from decimal import Decimal from bursar.metrics import UsageMetrics store, pgdata = start_postgres_store() atexit.register(cleanup, pgdata) bursar = publish_config(store, base_config()) credits = bursar.credits for user, account in ( (USER_ADA, "acct_ada"), (USER_ALEX, "acct_alex"), (USER_JAMAL, "acct_jamal"), ): bursar.accounts.on_account_created(user, account) credits.set_user_plan(user, "pro") credits.add_credits( user, Decimal(5000), idempotency_key=f"analytics:seed:{account}" ) print("users on pro plan") ``` ## Seeding spend Each deduction is priced by the standard rate card: input tokens at 0.0025 per 1M, output at 0.0100 per 1M, cached reads at 0.00125 per 1M, and one `execution` job at 0.04 per job. ```python credits.deduct(USER_ADA, UsageMetrics( operation="completion", measures={"input_tokens": Decimal(100000), "output_tokens": Decimal(20000)}, dimensions={"model": "gpt-4o"}, ), idempotency_key="analytics:ada:completion:1") credits.deduct(USER_ADA, UsageMetrics( operation="completion", measures={ "input_tokens": Decimal(200000), "output_tokens": Decimal(50000), "cache_read_tokens": Decimal(100000), }, dimensions={"model": "gpt-4o-mini"}, ), idempotency_key="analytics:ada:completion:2") credits.deduct(USER_ALEX, UsageMetrics( operation="completion", measures={"input_tokens": Decimal(50000), "output_tokens": Decimal(10000)}, dimensions={"model": "gpt-4o"}, ), idempotency_key="analytics:alex:completion:1") credits.deduct(USER_JAMAL, UsageMetrics( operation="execution", measures={"jobs": Decimal(5), "compute_seconds": Decimal(30)}, dimensions={"model": "gpt-4o"}, ), idempotency_key="analytics:jamal:execution:1") start = datetime.now(UTC) - timedelta(days=1) end = datetime.now(UTC) + timedelta(days=1) print("seeded") ``` ## Spend by user and by model `spend_by_user` and `spend_by_model` aggregate charged spend (never allowance-covered usage) over a time window. ```python for row in credits.spend_by_user(start, end): print(row.user_id[:8], row.total_spend, "entries:", row.entry_count) ``` ```python for row in credits.spend_by_model(start, end): print(row.model, row.total_spend, "entries:", row.entry_count) ``` ## Top users and daily spend `top_users` ranks accounts by spend in the window. `daily_spend` projects the same data per UTC day — currently the Postgres backend surfaces rollup days as `date` objects while the typed row expects `str`, so validation raises; the other analytics methods are unaffected. ```python for row in credits.top_users(5, start, end): print(row.user_id[:8], row.total_spend) try: for row in credits.daily_spend(start, end): print(row.date, row.total_spend, row.entry_count) except Exception as exc: print(type(exc).__name__, str(exc)[:60]) ``` ## Aggregate statistics `aggregate_stats` collapses the window into one row: total credits consumed, active users, average daily spend, and the top model and user. ```python stats = credits.aggregate_stats(start, end) print("total consumed:", stats.total_credits_consumed) print("active users:", stats.active_users) print("avg daily spend:", stats.avg_daily_spend) print("top model:", stats.top_model) print("top user:", stats.top_user[:8]) ``` ## The metered charges `list_usage_charges` returns the raw usage-charge rows behind the rollups, including `allowance_covered` (how much of the charge was covered by free allowance) and the idempotency key that makes webhook redelivery safe. ```python page = credits.list_usage_charges(USER_ADA, limit=10) print("charges:", len(page.items)) for charge in page.items: print(charge.model, charge.operation, "requested:", charge.requested, "charged:", charge.charged, "allowance covered:", charge.allowance_covered, "idem:", charge.idempotency_key[:24]) print("all done") ``` --- ## Consume credit lifecycle events {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/11_events.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/11_events.ipynb). ::: # 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. ```python 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`. ```python 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). ```python 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. ```python 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. ```python 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. ```python print("events seen:", [event_type for event_type, _ in seen]) print("all done") ``` --- ## Integrate subscriptions and auto-recharge {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/12_subscriptions_and_auto_recharge.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/12_subscriptions_and_auto_recharge.ipynb). ::: # Integrate subscriptions and auto-recharge Bursar's optional commerce layer maps configured offers and normalized payment events to subscription, top-up, plan-change, and auto-recharge workflows. This tutorial uses the mock provider so every state transition remains local and deterministic. ## Learning objectives After completing this tutorial, you can: - Configure subscription and top-up offers - Resolve provider product and price identifiers - Create checkout intents and process normalized webhooks - Grant subscription cycles and enforce auto-recharge guardrails ## Prerequisites - Complete the plans, events, and financial safety tutorials - Start the notebook server from `samples/python/notebooks/` - Use the included mock provider; no external payment credentials are required ## A mock payment provider The `MockPaymentProvider` simulates a real payment provider: checkouts return the caller's return URL and webhooks are driven by hand. Its event mapper normalizes **dodo**-style webhook payloads and stamps every event with the `dodo` provider, so the demo registers the provider under that key. The subclass adds a default payment method so auto-recharge has something to charge. ```python import atexit from shared import start_postgres_store, cleanup, base_config, publish_config, USER_ADA import json from datetime import UTC, datetime from decimal import Decimal from bursar import Bursar from bursar.metrics import UsageMetrics from bursar.billing.postgres.store import PostgresBillingStore from bursar.commerce.types import AutoRechargeInput, CommerceOptions, CreateCheckoutInput from bursar.credits.service_types import GrantSubscriptionCycleOptions from bursar.providers.mock.provider import MockPaymentProvider from bursar.providers.types import PaymentMethodInfo class DemoMockPaymentProvider(MockPaymentProvider): provider = "dodo" async def list_payment_methods(self, customer_id): return [ PaymentMethodInfo( id="dodo_pm_1", last4="4242", brand="visa", expiry_month=12, expiry_year=2030, is_default=True, ) ] ``` ## Config: declaring the provider The demo config references Stripe, so it is re-keyed to the mock: declare a `dodo` provider, give each offer a `dodo_product` reference carrying the `product_id` the webhook will echo back, and drop Stripe. The catalog is published, then the billing store and commerce options are wired into a full `Bursar` facade. ```python store, pgdata = start_postgres_store() atexit.register(cleanup, pgdata) config = base_config() config["commerce"]["providers"]["dodo"] = {"type": "dodo"} config["commerce"]["offers"]["pro_monthly"]["providers"]["dodo"] = { "type": "dodo_product", "product_id": "prod_pro_monthly", } config["commerce"]["offers"]["credits_10k"]["providers"]["dodo"] = { "type": "dodo_product", "product_id": "prod_credits_10k", } del config["commerce"]["providers"]["stripe"] for offer in config["commerce"]["offers"].values(): del offer["providers"]["stripe"] publish_config(store, config, label="billing") billing_store = PostgresBillingStore( store.database_url, tenant_id=store.tenant_id, provider_environment="test" ) def mock_factory(ctx): return DemoMockPaymentProvider(event_sink=ctx.event_sink) commerce_options = CommerceOptions( tenant_id=store.tenant_id, provider_environment="test", providers={"dodo": mock_factory}, default_provider="dodo", preference_defaults={ "auto_recharge": False, "overage_protection": True, "email_notifications": True, "usage_alerts": True, "invoice_reminders": False, }, ) bursar = Bursar( credit_store=store, billing_store=billing_store, commerce_options=commerce_options, ) billing = bursar.require_billing() commerce = bursar.require_commerce() print("billing and commerce ready") bursar.accounts.on_account_created(USER_ADA, "acct_ada") ``` ## Resolving offers and top-ups `billing.resolve_offer` / `resolve_topup` resolve catalog rows by provider and product reference — the same lookup the webhook path uses. The pro offer carries its cycle grant (50,000 credits, `replace_previous` renewal); the top-up is 10,000 credits for $5.00. ```python offer = billing.resolve_offer("dodo", product_id="prod_pro_monthly") print("offer:", offer.offer_key, "| plan:", offer.plan) print("grant:", offer.grant.credits, offer.grant.bucket, "| replace prior:", offer.grant.replace_prior) topup = billing.resolve_topup("dodo", product_id="prod_credits_10k") print("topup:", topup.topup_key, "| credits:", topup.credits_per_unit) print("price:", topup.amount_minor, topup.currency) ``` ## Checkout `commerce.create_checkout` opens a checkout intent and delegates to the provider. The mock provider returns the `return_url` immediately; a real provider would return a hosted payment page. `get_checkout_status` reports the intent state until the webhook completes it. ```python checkout = await commerce.create_checkout(CreateCheckoutInput( subject_id=USER_ADA, account_id=USER_ADA, offer_key="credits_10k", return_url="https://app.example.com/checkout/return/{intentId}", cancel_url="https://app.example.com/checkout/cancel/{intentId}", operation_key="op_checkout_topup_1", quantity=1, provider="dodo", type="credit_pack", )) print("intent:", checkout.intent_id) print("url:", checkout.url) print("status:", commerce.get_checkout_status(checkout.intent_id, USER_ADA).status) ``` ## The payment webhook The provider posts a `payment.succeeded` webhook; the mock provider validates it directly as a `BillingEvent` and the billing service settles the payment, completes the checkout intent, and **grants 10,000 credits** into the purchased bucket. The event's `account_id` and the trusted `metadata.checkout_intent_id` field are how the event is attributed. ```python body = { "event_id": "evt_topup_1", "event_type": "payment.succeeded", "occurred_at": datetime.now(UTC).isoformat(), "account_id": USER_ADA, "customer": {"provider_customer_id": "dodo_cus_ada"}, "payment": { "provider_payment_id": "pay_dodo_topup_1", "amount_minor": 500, "tax_minor": 0, "currency": "USD", "refs": {"product_id": "prod_credits_10k"}, "purpose": "credit_topup", "status": "succeeded", }, "metadata": {"checkout_intent_id": checkout.intent_id}, } webhook = await commerce.handle_webhook( raw_body=json.dumps(body), headers={"content-type": "application/json"}, provider="dodo", ) print("webhook:", webhook.received, webhook.event_type) print("balance:", bursar.credits.get_balance(USER_ADA).balance) print("status:", commerce.get_checkout_status(checkout.intent_id, USER_ADA).status) ``` ## Subscription cycle grants `grant_subscription_cycle` is the safe idempotent grant for renewal webhooks: the provider event id becomes the idempotency key. `replace_prior` means the renewal **replaces** any leftover purchased-bucket balance from the previous cycle instead of stacking on it, and the user is placed on the pro plan. ```python grant = bursar.credits.grant_subscription_cycle( USER_ADA, Decimal("50000"), GrantSubscriptionCycleOptions(bucket="purchased", plan_key="pro", idempotency_key="evt_cycle_1"), ) print("cycle balance:", grant.new_balance) renewal = bursar.credits.grant_subscription_cycle( USER_ADA, Decimal("50000"), GrantSubscriptionCycleOptions(bucket="purchased", plan_key="pro", idempotency_key="evt_renewal_1"), ) print("renewal balance (leftover replaced):", renewal.new_balance) print("plan:", bursar.credits.get_user_plan(USER_ADA).plan_key) ``` ## Auto-recharge With a customer and saved payment method on file, `auto_recharge.enable` arms a profile: charge the `credits_10k` top-up whenever the balance falls below the configured 2,000-credit threshold. Every deduction runs a post-deduction hook that calls `process_if_needed`, so the top-up is purchased automatically. ```python billing.upsert_customer("dodo", "dodo_cus_ada", USER_ADA, "ada@example.com") enabled = await commerce.auto_recharge.enable(AutoRechargeInput( account_id=USER_ADA, return_url="https://app.example.com/checkout/return/{intentId}", )) print("enabled:", enabled.enabled, "| state:", enabled.state, "| threshold:", enabled.threshold_credits) result = await commerce.auto_recharge.process_if_needed(AutoRechargeInput(account_id=USER_ADA)) print("process (balance above threshold):", result.outcome) ``` ```python # Drain the balance below the 2,000-credit threshold with input-only usage, # keeping clear of the pro plan's 500k output-token daily quota. # At 0.0025 per 1M input tokens: 19,200,400,000,000 tokens -> 48,001 credits. bursar.credits.deduct(USER_ADA, UsageMetrics( operation="completion", measures={"input_tokens": Decimal("19200400000000"), "output_tokens": Decimal(0)}, dimensions={"model": "gpt-4o"}, ), idempotency_key="auto-recharge:drain:1") print("balance after drain:", bursar.credits.get_balance(USER_ADA).balance) status = await commerce.auto_recharge.get_status(USER_ADA) print("recharges in window:", status.recharges_in_window) print("charged:", status.payment_method_brand, "...", status.payment_method_last4) commerce.auto_recharge.disable(USER_ADA) status = await commerce.auto_recharge.get_status(USER_ADA) print("after disable:", status.enabled, status.state) ``` ## Without a billing store A facade bound to a shared store without a billing store or commerce options has no billing or commerce capability — good to remember when sharing one store between services. Constructing it directly avoids activating a new catalog revision. ```python plain = Bursar(credit_store=store) print("billing:", plain.billing) print("commerce:", plain.commerce) print("all done") ``` --- ## Manage pricing configuration with the CLI {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/13_cli_and_deployment.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/13_cli_and_deployment.ipynb). ::: # Manage pricing configuration with the CLI The Bursar command-line interface (CLI) applies schema migrations, manages tenants, validates candidate configuration, publishes immutable revisions, and activates a prior revision during rollback. This tutorial models those operator actions without exposing credentials in command arguments. ## Learning objectives After completing this tutorial, you can: - Provision and inspect a tenant from the CLI - Validate configuration in continuous integration - Publish, compare, export, and activate revisions - Define a repeatable publication and rollback sequence ## Prerequisites Install the SDK with the Postgres extra, which also provides the `bursar` entry point: ```bash pip install "bursar[postgres]" ``` Use a dedicated migration-owner connection to install the schema: ```bash export BURSAR_MIGRATION_DATABASE_URL="postgresql://bursar_migrator@host:5432/bursar" bursar migrate # -> Migrations applied successfully. ``` The CLI reads credentials only from explicit environment variables; it does not auto-load `.env`. After migration, provision separate SET-only `bursar_operator` and `bursar_client` login members as shown in the CLI guide. Supply those connections independently: ```bash export BURSAR_OPERATOR_DATABASE_URL="postgresql://bursar_ops@host:5432/bursar" export DATABASE_URL="postgresql://bursar_app@host:5432/bursar" export BURSAR_TENANT_ID="00000000-0000-0000-0000-000000000001" export BURSAR_PROVIDER_ENVIRONMENT="test" ``` Migrations are **checksummed and idempotent**: the runner records the filename and SHA-256 of every applied script, skips scripts that already ran, and refuses to proceed on a checksum mismatch. Re-running `bursar migrate` in a pipeline is always safe. Never give the application migration-owner, superuser, `BYPASSRLS`, or Supabase `service_role` credentials. Run application-owned SQL through the application's migration tool with its own ledger and transaction boundary. ## Provisioning the tenant A tenant must exist before any config or store operation. `tenant create` provisions one and prints the tenant UUID (`--id` pins a specific one; `--display-name` is optional): ```bash bursar tenant create acme --display-name "Acme Production" # -> 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c ``` `tenant bootstrap` does two steps in one: provision the tenant and publish its first config. It validates the file *before* provisioning, so a malformed document can never leave a tenant behind that cannot start: ```bash bursar tenant bootstrap promo pricing.prod.yaml --label "initial: standard rate card" # -> Tenant 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c bootstrapped successfully (config applied). ``` Tenant lifecycle is explicit - activate, suspend, or close an account. A suspended tenant stops admitting usage immediately: ```bash bursar tenant status 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c suspended # -> 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c ``` ## Shipping a pricing change The core loop is `config set`. It reads a JSON or YAML document (or `-` for stdin), validates it against the `BursarConfig` model - types, cross-references, and every pricing expression - and publishes it as a new **immutable version**. Previous versions are never overwritten; the audit trail is append-only. ```bash bursar config set pricing.prod.yaml --label "change-42: pro quota 500k -> 600k" # -> Bursar config set successfully. ``` Setting a document identical to the active version is a no-op, so pipelines are safe to re-run: ```bash bursar config set pricing.prod.yaml --label "change-42 (retry)" # -> No changes - config is identical to the active version. ``` Inspect what is live: ```bash bursar config get ``` `config get` prints the canonical JSON of the active revision: the revision `id`, the `version` number, and the full `config` document (money as decimal strings, defaults filled in). Truncated for readability: ```json { "id": "019fbb6d-...", "config": { "version": 1, "catalog": {"default_plan": "free"}, "pricing": {"operations": {"completion": {...}, "execution": {...}}, "rate_cards": {"standard": {...}}}, "credits": {"buckets": {"promotional": {"priority": 1}, "purchased": {"priority": 10}}, "default_bucket": "purchased"}, "plans": {"free": {...}, "pro": {...}}, "commerce": {"providers": {"stripe": {"type": "stripe"}}, "offers": {...}} }, "version": 3 } ``` Version history, newest first, the active version starred: ```bash bursar config list # * v3 (id=019fbb6d...) change-42 2026-08-01T10:30:00 # v2 (id=019fbb6e...) change-37 2026-07-28T09:12:00 # v1 (id=019fbb6f...) initial 2026-07-25T08:00:00 ``` Each row is one immutable revision: active marker, version, truncated revision id, label, timestamp. ## Rolling back Every revision stays available, so the catalog rollback path is a single command with no schema migration or cache flush: ```bash # Something is wrong with change-42. Activate the previous revision. bursar config activate 2 # -> Pricing v2 activated. ``` Activation deactivates all other revisions and marks the requested one active inside a single transaction. Go back (rollback) or forward (restore) at will; activation never creates a new version, so the history stays clean. `tenant status` is the complementary control: it suspends the whole tenant when an incident needs to stop the meter outright: ```bash bursar tenant status 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c suspended # -> 3f6a2b7e-8c1d-4e9a-9f2b-7c0d5e1a3b4c # (set it back to 'active' once the new pricing has been verified) ``` ## The CI gate: `config validate` `config validate` parses and validates a file **without touching the database** - no `DATABASE_URL`, tenant, provider environment, or store. That makes it the natural first step of every pipeline: catch a bad document before it is ever published. ```bash bursar config validate pricing.new.yaml # -> Bursar config is valid. ``` Human-readable errors go to stderr and the exit code is 1. For CI, ask for the machine-readable form: ```bash bursar config validate pricing.new.yaml --json # { # "valid": true, # "errors": [] # } ``` On an invalid document, `--json` reports the structured validator errors - the same shapes `ConfigError.errors()` produces in Python: ```json { "valid": false, "errors": [ { "type": "decimal_string", "loc": ["pricing", "rate_cards", "standard", "operations", "completion", "rules", 0, "charge", "sum", "components", 0, "per_unit", "rate"], "msg": "must be a base-10 decimal string", "input": 0.0025 } ] } ``` For editor autocompletion and linting, the CLI also emits the full JSON Schema: ```bash bursar config schema > pricing.schema.json ``` (Notebook 15 covers the schema end to end.) ## Comparing and exporting versions Before activating a rollback, see exactly what changed between two revisions. `config diff` produces a unified diff over the canonical JSON of each revision: ```bash bursar config diff 2 3 # --- v2 # +++ v3 # @@ ... @@ # - "display_name": "Pro", # + "display_name": "Pro Tier", ``` `config export` dumps one revision's document as JSON - the starting point for the safe edit-publish loop: ```bash # 1. Export the live revision bursar config export 3 > current.json # 2. Edit it in your editor (or a script) # 3. Validate the edit - no database required bursar config validate current.json # 4. Publish the new revision with an audit label bursar config set current.json --label "change-43: sonnet-4.1 rates" ``` Export to edit to validate to set is the recommended way to make surgical changes: you always start from a document that was valid before you touched it. ## The CLI in action, from this notebook Everything above is shell work, and this notebook cannot run those commands against your database. What it *can* do is prove the one property that makes the whole workflow safe: **`config validate` is pure**. It parses the document and validates it in memory - no database, no environment variables, no tenant. The cell below serializes the shared `base_config()` to a temporary JSON file, then invokes `python -m bursar config validate `, the same code path used by the installed CLI. It also requests the `--json` continuous-integration form and runs `config list` without `DATABASE_URL` to verify that store commands fail with a clear error. Expected output: `exit code: 0` with `stdout: 'Bursar config is valid.'`; the `--json` run prints `{"valid": true, "errors": []}`; and the store command exits `1` with `DATABASE_URL is required` on stderr. ```python import json import os import subprocess import sys import tempfile from shared import base_config with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: json.dump(base_config(), handle) pricing_file = handle.name def run_cli(*args, env=None): merged = dict(os.environ) if env is None else dict(env) return subprocess.run( [sys.executable, "-m", "bursar", *args], capture_output=True, text=True, env=merged, ) try: ok = run_cli("config", "validate", pricing_file) print(f"exit code: {ok.returncode}") print(f"stdout: {ok.stdout.strip()!r}") json_gate = run_cli("config", "validate", pricing_file, "--json") print(f"exit code (--json): {json_gate.returncode}") print(json_gate.stdout.strip()) no_db = { k: v for k, v in os.environ.items() if k not in ("DATABASE_URL", "BURSAR_TENANT_ID", "BURSAR_PROVIDER_ENVIRONMENT") } denied = run_cli("config", "list", env=no_db) print(f"exit code without DATABASE_URL: {denied.returncode}") print(f"stderr: {denied.stderr.strip()}") finally: os.unlink(pricing_file) ``` ## The catalog publication workflow A complete, numbered host-controlled workflow for pricing changes: ```bash # 0. One-time setup (a provisioning job, not the pipeline): export BURSAR_MIGRATION_DATABASE_URL="" bursar migrate # Provision separate bursar_ops and bursar_app logins as documented. export BURSAR_OPERATOR_DATABASE_URL="" export DATABASE_URL="" export BURSAR_PROVIDER_ENVIRONMENT="test" # use live with production provider credentials bursar tenant create acme --display-name "Acme Production" # retain the printed UUID export BURSAR_TENANT_ID="" # 1. Validate the candidate - pure, runs anywhere, fails fast bursar config validate pricing.new.yaml --json # 2. Publish as a new immutable revision, labeled with the change id bursar config set pricing.new.yaml --label "$(git rev-parse --short HEAD): pro quota bump" # 3. Confirm what is live bursar config get # 4. Smoke-test the new pricing (a priced request against the test tenant) # 5. If anything misbehaves, roll back in one command bursar config activate ``` The rules of the road: - **Everything is versioned** - publishing never overwrites; `config list` is the audit trail. - **Labels carry context** - git hashes and change ids turn the history into a readable changelog. - **Validate before you set** - `config validate` needs no database, so it gates every pipeline. - **Rollback is activation** - one atomic catalog command. - **Sensitive values stay out of argv** - migration, operator, application, tenant, and provider-environment values are read from the environment. - **The host owns execution** - Bursar exposes these commands but does not prescribe CI/CD, process supervision, or cloud deployment. That is the operator loop: validate, publish, verify, roll back - all versioned, all auditable. --- ## Implement a custom credit store {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/14_custom_stores.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/14_custom_stores.ipynb). ::: # Implement a custom credit store `PostgresStore` is the supported production backend, while `CreditStore` defines the contract for specialized environments. This tutorial inspects that contract, separates required capabilities from optional ones, and shows why a custom backend must reproduce Bursar's transactional guarantees. ## Learning objectives After completing this tutorial, you can: - Inspect the abstract store contract from the installed package - Distinguish required methods from optional capabilities - Identify the atomicity, idempotency, and pagination guarantees a backend must preserve - Decide when a custom store is appropriate ## Prerequisites - Complete the credit lifecycle and financial safety tutorials - Understand your target database's transaction and locking model ## The contract `CreditStore` (defined in `bursar.credits.store`) is an abstract base class whose docstrings pin down the invariants every backend must honor. Reading them, five guarantees stand out: 1. **Atomic mutations.** `add_credits` must "atomically add credits and log a transaction"; `deduct_with_allowance` performs the lock, idempotency check, allowance consumption, quota enforcement, and debit "within one transaction" - all-or-nothing, with any failure rolling back allowance consumption and the balance change. 2. **Idempotency.** Every mutation takes a user-scoped `idempotency_key`: "A retried grant with the same key ... returns the original entry's result rather than granting a second time - no double-mutation, no second ledger row." The same idiom covers deductions, lease settlement, and team deductions. 3. **Append-only ledger.** Credits never move silently. Every grant posts a transaction; refunds reference the original `entry_id`; usage charges are recorded even when allowance covered them. 4. **Cursor-stable pagination.** Ledger reads are ordered by a `(created_at, entry_id)` tuple cursor - a timestamp-plus-entry key - so pages never skip or duplicate rows while new entries arrive. 5. **Store-owned state.** Balance rows, buckets and lot allocation (`expires_at`, priorities), plan assignments, quota windows, allowance windows, and lease rows are database-owned; the SDK services only read the results. Anything the contract does not state is your freedom - but these five are the compatibility surface. ```python from datetime import datetime, timezone from bursar import CreditStore, PostgresStore from bursar.errors import CapabilityNotSupportedError # The 29 abstract methods are the full minimum surface of the contract. required = sorted(CreditStore.__abstractmethods__) print(f"{len(required)} required methods:") print(required) # PostgresStore is the reference implementation of this exact contract. print("PostgresStore is a CreditStore:", issubclass(PostgresStore, CreditStore)) ``` ## Required vs. optional capabilities The 27 abstract methods above are the *minimum* surface. A second group of methods ships with default implementations that raise `CapabilityNotSupportedError` (from `bursar.errors`) until you override them - so a minimal custom store does not implement them: - **Analytics** - `spend_by_user`, `spend_by_model`, `top_users`, `daily_spend`, `aggregate_stats` - **Ledger history** - `list_ledger_entries`, `list_usage_entries`, `list_usage_charges`, `get_ledger_entry` - **Teams** - `create_team`, `get_team_balance`, `add_team_member`, `get_team_members`, `remove_team_member`, `deduct_team` - **Maintenance** - `expire_leases`, `execute_grant_program` Calling an unimplemented capability raises `CapabilityNotSupportedError` with a message naming the capability, and the SDK surfaces it as a typed error - so callers can feature-detect their backend instead of crashing blindly. (`check_feature` is the inverse: a *concrete* method with a default implementation that works on any store, since it derives from `get_user_plan`.) ```python # A store is only a store when it implements the whole abstract surface. # Here we implement only three core methods; the other 26 stay undefined. class PartialStore(CreditStore): def get_balance(self, user_id): raise NotImplementedError("get_balance") def add_credits( self, user_id, amount, type="adjustment", metadata=None, expires_at=None, bucket=None, idempotency_key=None, ): raise NotImplementedError("add_credits") def deduct_with_allowance(self, user_id, amount, **kwargs): raise NotImplementedError("deduct_with_allowance") try: PartialStore() except TypeError as exc: print(str(exc)[:220]) print("...") ``` ```python # A minimal *complete* store: every abstract method is bound to a stub that # raises NotImplementedError until this backend gains a real implementation # (real stores replace the stubs with atomic SQL or RPC calls). def _stub(name): def method(self, *args, **kwargs): raise NotImplementedError(f"{name}() is not implemented by MinimalStore") method.__name__ = name return method MinimalStore = type( "MinimalStore", (CreditStore,), {name: _stub(name) for name in CreditStore.__abstractmethods__}, ) store = MinimalStore() now = datetime.now(timezone.utc) try: store.daily_spend(now, now) # optional capability: default raises except CapabilityNotSupportedError as exc: print("optional:", exc) try: store.get_balance("user-ada") # core contract: up to the store author except NotImplementedError as exc: print("core: ", exc) ``` ## Migrations stay with `bursar migrate` A store implements the runtime interface - it never installs schema. The Postgres backend's tables, RPCs, and the embedded catalog-validation functions are owned by the bundled migrations, applied through the CLI: ```bash export BURSAR_MIGRATION_DATABASE_URL="postgresql://bursar_migrator@host:5432/bursar" bursar migrate # bundled schema, checksummed and idempotent ``` If your custom store targets the same database as the reference backend, you can still run `bursar migrate` and pair the standard tables with your own RPCs. Run host-owned SQL through the host application's migration tool. If the store targets a different system, the schema story is yours - but the *contract* story (the five guarantees above) is not. ## Parity guarantees - and when *not* to roll your own The SDK's services sit on top of your store and normalize everything they can. What a custom store must preserve is the *observable* contract: - **Same configuration** - pricing, plans, quotas, and commerce all come from the same validated `BursarConfig` document; the store only persists revisions and their activation state. - **Same arithmetic** - costs are computed by the pricing engine, never by the store, so a `completion` on the `standard` rate card costs the same regardless of backend. Money is `Decimal` everywhere, quantized to 6 decimal places. - **Same cross-language vectors** - the contract test suites shared between the Python and TypeScript SDKs pin down idempotent replays, cursor boundaries, lot ordering, and refund postings. A production custom store is expected to pass the same vectors. Two questions decide whether rolling your own is the right call. If you need what the reference backend already ships - the Supabase-flavored Postgres schema, RLS-friendly design, the migration toolchain, and a full contract test suite - reimplementing 27 methods is real money spent to lose features. And if you have no concrete constraint (existing schema, exotic runtime, test double), the default answer is `PostgresStore`. If you do build one, keep the ledger append-only and the mutations atomic; the SDK will do the rest. --- ## Validate the complete configuration schema {/* Generated by scripts/gen-notebook-docs.py; edit the source notebook. */} :::info Executable tutorial This page is generated from a tested Jupyter notebook. [Open it in Google Colab](https://colab.research.google.com/github/zonastery/bursar/blob/main/samples/python/notebooks/15_pricing_config_schema.ipynb) or [view the source notebook](https://github.com/zonastery/bursar/blob/main/samples/python/notebooks/15_pricing_config_schema.ipynb). ::: # Validate the complete configuration schema The Bursar configuration connects pricing, credit buckets, plans, quotas, admission policies, offers, and auto-recharge in one validated document. This tutorial inspects the complete shape, canonicalizes it, publishes a revision, reads the public projection, and locates the generated JSON Schema. ## Learning objectives After completing this tutorial, you can: - Validate every top-level configuration section - Explain canonicalization and immutable catalog revisions - Distinguish the internal document from its public projection - Use the generated JSON Schema in editors and continuous integration ## Prerequisites - Complete the pricing configuration and CLI tutorials - Start the notebook server from `samples/python/notebooks/` ## The document at a glance The shared `base_config()` drives every tutorial in this collection: - `version` - the schema version of the document itself. - `catalog` - defaults such as `default_plan: "free"`. - `pricing` - the `completion` and `execution` operations and the `standard` rate card (per-1M-token rates, the $0.04/job `gpt-4o` rule, the fallback expression, the `reject` unmatched policy). - `credits` - the `promotional` (priority 1) and `purchased` (priority 10) buckets, `default_bucket: "purchased"`. - `entitlements` and `admission` - the `voice_mode` / `max_context` features and the `max_in_flight: 4` policy. - `plans` - `free` (10,000-credit monthly allowance) and `pro` (500k `output_tokens`/day block quota, admission policy). - `commerce` - the Stripe provider, the `pro_monthly` subscription and `credits_10k` top-up offers, and auto-recharge guardrails. `load_config_from_dict` parses and validates the whole thing in one call. ```python from shared import base_config from bursar.config import load_config_from_dict config = load_config_from_dict(base_config()) print("top-level sections:", sorted(config.model_dump().keys())) print("plans:", list(config.plans.keys())) print("offers:", list(config.commerce.offers.keys())) print("credit buckets:", {k: b.priority for k, b in config.credits.buckets.items()}) print("rate cards:", list(config.pricing.rate_cards.keys())) ``` ## Validation - what the document refuses Every invalid document is rejected with a `ConfigError` (from `bursar.config`) that carries a structured `errors()` payload. Five representative failures, each a mistake a real operator would make: 1. An **unknown top-level key** - extra inputs are forbidden, so a typo like `billing_hook` fails instead of silently doing nothing. 2. A **float money value** - money is always a base-10 decimal string (`"0.0025"`), never a JSON float, because floats lose precision. 3. A **missing `credits` section** - the document requires credits; a pricing-only file is rejected. 4. An **expression referencing an undeclared measure** - expression safety is validated at config time, so a formula typo can never reach production. 5. A **plan referencing an unknown operation** - `allowed_operations` and rate-card resolution are checked across the whole document. Each case below prints the head of the `ConfigError` message plus the first structured error. ```python from bursar.config import ConfigError def reject(label, mutate): doc = base_config() mutate(doc) try: load_config_from_dict(doc) print(f"{label}: ACCEPTED (unexpected)") except ConfigError as exc: head = str(exc).splitlines()[:3] print(f"{label}:") for line in head: print(" ", line) err = exc.errors()[0] print(" loc:", err["loc"], "| type:", err["type"]) reject("unknown top-level key", lambda d: d.update({"billing_hook": True})) reject( "float money value", lambda d: d["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["rules"][0] ["charge"]["components"][0].update({"rate": 0.0025}), ) reject("missing credits section", lambda d: d.pop("credits")) reject( "undeclared measure in formula", lambda d: d["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["unmatched"] ["charge"].update({"formula": "bogus_measure * 0.005 + output_tokens * 0.015"}), ) reject( "unknown operation in plan", lambda d: d["plans"]["free"]["allowed_operations"].append("streaming"), ) ``` ## Canonicalization - one shape to publish `canonical_bursar_config_dict` validates the document and returns the **canonical JSON dict**: defaults filled in, `None` values dropped, money emitted as base-10 decimal strings. This is exactly the shape that gets persisted - the catalog publishes canonical output, never the raw input - so revision diffs are stable and meaningful. Inside the typed `BursarConfig` model, amounts are `Decimal`; in the canonical dict they are strings. ```python from bursar.config import canonical_bursar_config_dict canonical = canonical_bursar_config_dict(base_config()) print("canonical sections:", sorted(canonical.keys())) allowance = canonical["plans"]["free"]["credit_allowance"]["amount"] quota = canonical["plans"]["pro"]["quotas"]["daily_tokens"]["limit"] grant = canonical["commerce"]["offers"]["pro_monthly"]["cycle_grant"]["amount"] print("free allowance amount:", repr(allowance), type(allowance).__name__) print("pro quota limit: ", repr(quota), type(quota).__name__) print("cycle grant amount: ", repr(grant), type(grant).__name__) roundtrip = canonical_bursar_config_dict(canonical) print("canonical is idempotent:", roundtrip == canonical) ``` ## Versioning - publish, draft, activate The store persists every publish as an immutable revision with a monotonically increasing version number and a creation timestamp; exactly one revision is active at any time. The Python API mirrors the CLI: - `bursar.catalog.publish_and_activate(config, label)` - validate, publish, and activate in one call (what `config set` does). - `bursar.catalog.publish_draft(config, label)` - publish an **inactive** draft; the live catalog is untouched. - `bursar.catalog.activate(version)` - make a published revision active; the rollback path. Below we publish v1 and v2, then stage a draft (which becomes v3, but is *not* active) and activate it only after inspecting the history. ```python from shared import start_postgres_store, cleanup from bursar import Bursar store, pgdata = start_postgres_store() bursar = Bursar(credit_store=store) bursar.catalog.publish_and_activate(base_config(), label="initial") print("v1 active:", bursar.catalog.get_active().version) v2 = base_config() v2["plans"]["pro"]["quotas"]["daily_tokens"]["limit"] = "600000" bursar.catalog.publish_and_activate(v2, label="deploy-42: pro quota 500k -> 600k") print("v2 active:", bursar.catalog.get_active().version) v3 = base_config() v3["pricing"]["rate_cards"]["standard"]["operations"]["completion"]["unmatched"]["charge"][ "formula" ] = "input_tokens * 0.006 + output_tokens * 0.016" draft_id = bursar.catalog.publish_draft(v3, label="staged: fallback rate bump") print("draft id:", draft_id) print("active unchanged by draft:", bursar.catalog.get_active().version) history = store.get_catalog_history() for item in history: print(f" {'*' if item.active else ' '} v{item.version} ({item.label}) active={item.active}") draft_version = next(item.version for item in history if item.id == draft_id) bursar.catalog.activate(draft_version) print("activated:", bursar.catalog.get_active().version, "| id matches draft:", bursar.catalog.get_active().id == draft_id) ``` ## The public view - what clients may see Clients never receive the full document: it contains provider product identifiers (Stripe `price_id`s) that belong in your backend, not your storefront. `bursar.catalog.public_view()` projects a **provider-secret-free** catalog - plans sorted by rank, their offers, and top-ups, amounts as strings - ready to render on a pricing page or power a purchase flow. The projection runs against the same live revision the versioning cell left active (v3, the staged fallback-rate bump). ```python try: view = bursar.catalog.public_view() print("view sections:", list(view.keys())) for plan in view["plans"]: offers = ", ".join(o["key"] for o in plan["offers"]) print(f"plan {plan['key']!r}: rank={plan['rank']} display_name={plan['display_name']!r} offers=[{offers}]") topup = view["topups"][0] print("topup:", topup["key"], "| credits_per_unit:", topup["credits_per_unit"], "| quantity:", topup["quantity"]) print("offer fields:", sorted(view["plans"][1]["offers"][0].keys())) print("provider secrets present:", "providers" in view["plans"][1]["offers"][0]) finally: cleanup(pgdata) ``` ## The JSON Schema - at every layer The same schema that validates documents in Python is available everywhere: - **Editor support.** `bursar config schema` prints the JSON Schema (a ~64 kB document) for autocompletion and validation in your editor or a lint script: ```bash bursar config schema > pricing.schema.json ``` - **The repository copy.** The identical schema ships in the repo as `docs/pricing-config.schema.json` - the reference artifact for code review. - **Publish-time enforcement in Postgres.** The migrations install `bursar.require_catalog_document_shape`, which validates every document against the embedded schema *inside the database* before a revision is written. Even a caller that bypasses the SDK (raw SQL, a rogue script) cannot publish a malformed catalog. - **CI gates.** `bursar config validate --json` (notebook 13) gives pipelines machine-readable errors without a database. One schema, four layers: the typed `BursarConfig` model in Python, the JSON Schema artifact, the in-database validator, and the CLI gate. ## Summary The configuration document is the whole product surface: - **Validation** rejects unknown keys, non-decimal money, missing sections, unsafe expressions, and dangling references - with structured `ConfigError.errors()`. - **Canonicalization** produces one stable JSON shape (money as strings, defaults filled, `None` dropped) that is exactly what gets persisted, keeping revision diffs meaningful. - **Versioning** publishes immutable revisions; `publish_and_activate`, `publish_draft`, and `activate` give you staged rollouts and one-command rollbacks. - **The public view** projects plans and offers without provider secrets. - **The JSON Schema** backs editors, the CLI, and the database itself. From `load_config_from_dict` to `public_view`, it is one document - validated once, versioned forever. --- ## How-to guides # Complete an embedded integration These guides assume you understand the Bursar boundary and have completed the quickstart. Each page addresses one production task and links to reference material for exact software development kit (SDK) signatures. If you are choosing the architecture, begin with [Build a prepaid credit system for AI SaaS](./ai-saas-credits.mdx) to separate application ownership from the transactional metering and ledger boundary. ## Follow the integration sequence For a new integration, complete these tasks in order: 1. [Provision and isolate tenants](./multitenancy.mdx) before constructing tenant-bound stores. 2. [Configure storage backends](./storage-backends.mdx) and verify the migrated PostgreSQL connection. 3. [Protect monetary operations](./financial-safety.mdx) with stable idempotency keys and atomic admission. 4. [Manage credits](./credit-lifecycle.mdx) through grants, purchases, charges, refunds, expiry, and revocation. 5. [Integrate subscriptions and payments](./subscription-integration.mdx) after the credit boundary is stable. 6. [Instrument Bursar with OpenTelemetry](./opentelemetry.mdx) when the host has selected its telemetry providers and exporters. Add [Google ADK metering](./google-adk.mdx) or the [Bursar coding-agent skill](../agent-skills.mdx) only when the host application uses those surfaces. ## Browse by task area ## Learn or look up exact behavior Use the [executable tutorials](/docs/tutorials) for guided learning. Use the [reference section](/docs/reference) for commands, configuration, expressions, database schema, and SDK signatures. --- ## Isolate Bursar tenants ## Prerequisites - Install the Bursar CLI and prepare separate migration, operator, and application runtime PostgreSQL credentials — see the [CLI reference](../cli.mdx). - Read [credit accounting](../concepts/data-model.mdx) for the tenant-scoped accounting entities. - Have a [validated configuration](../concepts/configuration.mdx) if you want to bootstrap pricing with the tenant. ## Outcome - A provisioned tenant that every store and runtime is bound to by `tenant_id`. - Database-level isolation: tenant-prefixed unique constraints, forced row-level security, and composite foreign keys. Bursar uses one PostgreSQL schema with shared tables. Every catalog, credit, usage, quota, team, and billing row carries a mandatory `tenant_id`. Tenant-prefixed unique constraints let different tenants reuse subject, provider, and idempotency identifiers. Composite foreign keys prevent a row from referencing another tenant's rows even if privileged code has a bug. ## Provision and bind a tenant 1. Run migrations with the dedicated migration owner in `BURSAR_MIGRATION_DATABASE_URL` (see the [CLI reference](../cli.mdx)). 2. Provision the separate operator and application logins described in the CLI guide, then create the tenant with the operator connection: ```bash title="Terminal" export BURSAR_OPERATOR_DATABASE_URL=postgresql://bursar_ops@db.example.com/bursar bursar tenant create acme \ --id 018f7f5f-7b4a-7000-8000-000000000001 \ --display-name "Acme" ``` 3. Applications that also need to publish initial pricing can use the idempotent bootstrap boundary, which validates the config before provisioning: ```bash title="Terminal" export DATABASE_URL=postgresql://bursar_app@db.example.com/bursar export BURSAR_TENANT_ID=018f7f5f-7b4a-7000-8000-000000000001 export BURSAR_PROVIDER_ENVIRONMENT=test bursar tenant bootstrap acme ./pricing.yaml \ --display-name "Acme" ``` :::warning Do not insert into `bursar.tenants` from host migrations or seed SQL. Tenant storage and lifecycle are Bursar implementation details behind the operator CLI. ::: Pass the tenant UUID and the application runtime connection when constructing a store. The runtime principal must not be a superuser, have `BYPASSRLS`, or use Supabase's `service_role`. The store comes from the package top level; the optional runtime composition root lives in `bursar.storage` (Python) and the `@zonastery/bursar/node` subpath (TypeScript): ```python from bursar import PostgresStore from bursar.storage import BursarRuntimeOptions, create_bursar_runtime store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment="test", ) runtime = create_bursar_runtime( BursarRuntimeOptions( postgres=database_url, operator_postgres=operator_database_url, tenant_id=tenant_id, provider_environment="test", ) ) ``` ```ts import { PostgresStore } from "@zonastery/bursar"; import { createBursarRuntime } from "@zonastery/bursar/node"; const store = new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment: "test", }); const runtime = await createBursarRuntime({ postgres: databaseUrl, operatorPostgres: operatorDatabaseUrl, tenantId, providerEnvironment: "test", }); ``` Each SDK checks out one pooled connection, starts a transaction, sets `bursar.tenant_id` with transaction-local scope, performs the RPC, then commits or rolls back before releasing the connection. Never set a session-scoped tenant value on a pooled connection — it would leak into the next caller. ## Database enforcement Business tables use forced row-level security. Server SDKs supply the tenant only through transaction-local `bursar.tenant_id` on the same checked-out connection as the RPC. Bursar does not derive the tenant from PostgREST JWT metadata, and a privileged connection is not an isolation boundary. Missing tenant context fails closed on writes. Suspended and closed tenants cannot read or mutate business rows. Operators change lifecycle state: ```bash title="Terminal" bursar tenant status 018f7f5f-7b4a-7000-8000-000000000001 suspended bursar tenant status 018f7f5f-7b4a-7000-8000-000000000001 active ``` ## Host triggers and external storage A host application attaches Bursar's tenant-aware trigger API to its principal table and passes its provisioned tenant slug: ```sql CREATE TRIGGER bursar_account_created AFTER INSERT ON app.users FOR EACH ROW EXECUTE FUNCTION bursar.provision_subject_account_on_insert('acme'); ``` Bursar resolves the active tenant, binds transaction-local context, assigns the active default plan, and runs eligible `account_created` grants. Host SQL must not read Bursar tables or implement those steps itself. The operator API can claim the global outbox, while SDK runtimes use the tenant-filtered claim overload so one runtime cannot take another tenant's work. Claimed events and exported payloads include `tenant_id`. S3 keys use `/tenants//billing-events/...` (the prefix defaults to `bursar`), ClickHouse rows and analytics queries include a tenant filter, and archive exports embed the tenant id. Keep this field in any custom outbox handler or projection. --- ## Configure storage backends ## Prerequisites - Run migrations and provision a tenant before constructing a store — see the [CLI reference](../cli.mdx) and [multi-tenancy](./multitenancy.mdx). - For the optional adapters in Python, install the `postgres` extra (and the `s3` extra for the native S3 adapter); in TypeScript, import them from the Node-only `@zonastery/bursar/node` subpath. ## Outcome - A `PostgresStore` credit ledger that owns all account state. - Optional ClickHouse usage history and S3 billing archives delivered through a transactional outbox on the same connection pool. `PostgresStore` is the canonical credit store. There is no `bursar.stores` package, and the former in-memory and Supabase HTTP stores have been removed. All account state lives in PostgreSQL. Import `PostgresStore` and the `CreditStore` abstract base from the package top level: ```python from bursar import CreditStore, PostgresStore ``` ```ts import { CreditStore, PostgresStore } from "@zonastery/bursar"; ``` ## PostgresStore The store is tenant-bound: pass the provisioned tenant UUID when constructing it. Python accepts `tenant_id` as a keyword argument. TypeScript accepts `tenantId` in the constructor options object. ```python store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment="test", ) ``` ```ts const store = new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment: "test", }); ``` No store performs installation. Run `bursar migrate` with `BURSAR_MIGRATION_DATABASE_URL` and provision the tenant before constructing a store — see [CLI reference](../cli.mdx) and [multi-tenancy](./multitenancy.mdx). Bursar owns the schema, migrations, and tenant lifecycle; host migrations must not create, alter, or seed Bursar tables. Custom stores subclass `CreditStore`. They must preserve idempotency, account locking semantics, append-only ledger history, cursor ordering by `(created_at, entry_id)`, and atomic lot allocation, because the financial invariants in [financial safety](./financial-safety.mdx) depend on them. ## Optional analytics and archive adapters For high-volume applications, Bursar can route high-cardinality data into external systems through optional adapters. PostgreSQL remains the canonical source for balances, compact usage receipts, billing claims, and idempotency. In Python they live under `bursar.storage` (importing requires the `postgres` extra; the native S3 adapter also needs the `s3` extra); in TypeScript they are exported from the Node-only `@zonastery/bursar/node` subpath. | Adapter | Purpose | | ---------------------- | ---------------------------------------------------------------------------------- | | `ClickHouseUsageStore` | Usage history and analytics — skips PostgreSQL detail and rollup rows when enabled | | `S3BillingArchive` | Billing payload archive — skips PostgreSQL raw envelope rows when enabled | High-cardinality usage details follow the usage backend: monthly PostgreSQL partitions with retention cleanup by default, or the ClickHouse usage projection when configured. Canonical billable usage receipts remain permanent; expired record-only receipts and PostgreSQL detail payloads are cleaned with the configured usage-retention horizon. S3 continues to own unbounded billing webhook envelopes, independently of the usage backend. A transactional outbox carries the complete external payload in the same transaction as the canonical receipt or billing claim. An `OutboxWorker` delivers `usage.charge_recorded` to ClickHouse and `billing.webhook_received` to S3. This avoids a second permanent PostgreSQL copy while preserving retries during external outages. Exported rows and archive keys always carry `tenant_id` (S3 keys use `/tenants//billing-events/...`, with the prefix defaulting to `bursar`). External detail is eventually consistent: the PostgreSQL receipt or billing claim commits immediately, while ClickHouse history and S3 objects become visible after the outbox worker delivers them. The worker renews each active claim, acknowledges only while it still owns that claim, and retries with bounded exponential backoff. Custom `OutboxStore` implementations must therefore implement claim renewal as well as claim, complete, and fail. Handlers must remain idempotent because an external write can succeed immediately before the acknowledgement is lost. After Bursar's configured retention cleanup removes the PostgreSQL detail, the selected external adapter owns that detail: ClickHouse owns usage dimensions and metadata, while S3 owns raw billing envelopes. PostgreSQL continues to own balances, ledger entries, compact billable usage receipts, billing claims, and idempotency records. Configure an external adapter only when the host accepts this data-ownership contract. Bursar does not configure bucket policy, replication, backups, exporters, or service topology. ### Adapter initialization and ownership `ClickHouseUsageStore` does not create tables by default. Either create the projection through the host's schema workflow and call `checkSchemaCompatibility()` / `check_schema_compatibility()`, or explicitly set `createTable: true` / `create_table=True` and call `initializeSchema()` / `initialize_schema()`. The adapter exposes `writeUsageBatch()` / `write_usage_batch()` for multi-row inserts; the single-row method delegates to the same projection path. `retentionDays` / `retention_days` only changes DDL generated by `initializeSchema()` / `initialize_schema()`. It does not alter a table owned by the host when `createTable` / `create_table` is false; configure that table's TTL in the host's schema workflow instead. `S3BillingArchive` uses the AWS SDK's normal credential and region provider chains when explicit values are omitted. A host can inject a configured client or lazy client factory and choose client ownership. Per-object encryption and checksum fields are available through `putObject` / `put_object`; bucket policy, versioning, lifecycle, and object-lock policy remain host-owned. The runtime composition root wires the Postgres stores, the optional adapters, and the outbox worker onto separate tenant and operator connection pools: ```python from bursar.storage import BursarRuntimeOptions, BursarRuntimeStartOptions, create_bursar_runtime runtime = create_bursar_runtime( BursarRuntimeOptions( postgres=database_url, operator_postgres=operator_database_url, tenant_id=tenant_id, provider_environment="test", clickhouse=clickhouse_options, s3=s3_options, outbox=outbox_options, ) ) runtime.start(BursarRuntimeStartOptions(load_catalog=True)) ``` ```ts import { createBursarRuntime } from "@zonastery/bursar/node"; const runtime = await createBursarRuntime({ postgres: databaseUrl, operatorPostgres: operatorDatabaseUrl, tenantId, providerEnvironment: "test", clickhouse: clickhouseOptions, s3: s3Options, outbox: outboxOptions, }); await runtime.start({ loadCatalog: true }); ``` The runtime exposes the composed `bursar` facade plus the underlying `creditStore` and `billingStore`. SDK runtimes claim outbox events through the tenant-filtered overload, so one runtime can never take another tenant's work. When ClickHouse is configured, analytics and usage-history methods route to ClickHouse; when S3 is configured, billing envelopes route to S3. Without either adapter, the runtime uses PostgreSQL for those methods and payloads. On startup, the built-in ClickHouse adapter performs a non-mutating schema compatibility check before the outbox worker starts, including after optional SDK-owned table creation. ## Invoke maintenance and inspect dependencies Bursar never starts a maintenance scheduler. The host calls bounded passes at the cadence it chooses: ```python from bursar.storage import MaintenanceRunOptions, OperatorMaintenanceRunOptions tenant_result = runtime.maintenance.run_once(MaintenanceRunOptions(limit=100)) storage_result = runtime.operator_maintenance.run_once( OperatorMaintenanceRunOptions(mode="if_due") ) diagnostics = runtime.check_dependencies() ``` ```ts const tenantResult = await runtime.maintenance.runOnce({ limit: 100 }); const storageResult = await runtime.operatorMaintenance.runOnce({ mode: "ifDue", }); const diagnostics = await runtime.checkDependencies(); ``` `state()` is local and side-effect free. `checkDependencies()` / `check_dependencies()` actively checks PostgreSQL, the catalog, and outbox status, and returns safe diagnostic codes rather than raw exception messages. Both surfaces distinguish `financialReady` / `financial_ready` from `projectionReady` / `projection_ready`. A failed outbox check, stopped local worker, or any dead letter makes the optional projection degraded without hiding whether PostgreSQL-backed financial operations remain available. The maintenance results report counts and `hasMore` so the host can continue bounded work without Bursar owning a timer or process lifecycle. ## Recover dead letters The runtime exposes its tenant-bound recovery port as `outboxRecovery` in TypeScript and `outbox_recovery` in Python. It supports aggregate `stats`, bounded keyset `listDeadLetters()` / `list_dead_letters()`, and explicit `requeue()` of one dead letter. The listing deliberately excludes payloads and claim tokens. Requeue only after correcting the sink or event problem; delivery remains at least once and handlers remain idempotent. --- ## Build a prepaid credit system for AI SaaS An AI product usually knows whether a request may start before it knows the request's final token, model, tool, or compute cost. A production credit system must therefore do more than subtract a number from a balance: it must admit work atomically, measure the result, survive retries, and leave an auditable history. Bursar provides that boundary as an embedded, open-source Python and TypeScript SDK backed by PostgreSQL. ## Outcome This architecture gives an AI SaaS application: - Prepaid, promotional, and subscription-funded credit lots - Usage pricing based on tokens, models, tool calls, jobs, or compute measures - Atomic enforcement of balance floors, quotas, entitlements, allowances, and concurrency - Reservations for work whose final cost is not known at admission - Idempotent charges, refunds, provider events, and job retries - One tenant-isolated, append-only PostgreSQL ledger shared by Python and TypeScript services ## Put accounting behind one boundary Keep identity and product workflows in the application. Route every operation that changes financial state through one tenant-bound Bursar facade. ```mermaid flowchart LR A["Application request"] --> B["Bursar admission"] C["Versioned pricing and plan configuration"] --> B B --> D["Run AI work"] D --> E["Measure actual usage"] E --> F["Bursar settlement"] G["Verified payment event"] --> F F --> H[("Tenant-isolated PostgreSQL ledger")] ``` | Your application owns | Bursar owns | | ------------------------------------------- | -------------------------------------------------------------- | | Authentication and durable account identity | Credit accounts, lots, balances, and ledger entries | | AI request execution and usage measurement | Pricing and atomic admission policy | | Product user interface | Plans, allowances, entitlements, quotas, and spend caps | | Payment-provider catalog and tax | Verified-event projection, subscriptions, top-ups, and refunds | ## Avoid balance-check-then-deduct A separate `can afford` check followed by a debit is unsafe. Two concurrent requests can both observe the same available balance and both begin work before either debit commits. A process-local mutex only moves the race to another worker and can also serialize unrelated tenants. Use one of Bursar's transactional admission paths instead: | Workload | Admission path | Why | | --------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------- | | Final measurement is already known | `deduct` | Prices, checks policy, and posts the charge atomically | | Final cost is unknown or work is long-running | `reserve`, then `settle` or `release` | Holds worst-case capacity before work starts and charges actual usage afterward | | A callback can own the complete lifecycle | `run_billed` / `runBilled` | Wraps reservation, work, settlement retries, and release-on-failure | The PostgreSQL transaction locks only the relevant tenant account while it checks policy and posts accounting entries. It does not take one global ledger lock for every tenant. ## Implementation path 1. **Install and migrate.** Install `bursar[postgres]` for Python or `@zonastery/bursar` plus `pg` for TypeScript. Apply the SQL baseline with a dedicated migration principal. 2. **Provision tenants.** Give every store an explicit tenant identifier and connect the application with a least-privilege runtime principal. Follow the [multi-tenancy guide](./multitenancy.mdx). 3. **Publish one configuration.** Define operations, measures, rate cards, plans, allowances, entitlements, quotas, credit buckets, and commerce offers in the [versioned configuration](../concepts/configuration.mdx). 4. **Create accounts from durable events.** Connect account creation and signup grants to an event that can be replayed safely. 5. **Map product usage to metrics.** Use stable operation names and explicit dimensions such as model or region. Keep monetary values as exact decimals. 6. **Choose atomic debit or reserve-settle.** Use `deduct` for known usage and a [lease](./financial-safety.mdx#leases-the-safe-path-for-long-running-ai-work) for uncertain usage. 7. **Connect payments only after verification.** Derive idempotency keys from verified provider event identifiers and pass normalized events through the [subscription and payment integration](./subscription-integration.mdx). 8. **Test retries and concurrency.** Replay successful requests, reuse a key with a changed payload, run simultaneous admissions, and verify that each tenant remains isolated. ## Preserve the accounting invariants Treat these as release-blocking requirements: - The account balance equals the sum of its relevant ledger entries. - Every replayable monetary mutation has a stable idempotency key. - The same key cannot represent two different requests. - A strict-prepaid account never crosses its configured minimum balance. - A lease can settle at most once and is released when work fails. - Refunds reference and cannot exceed the original charge. - Tenant context is transaction-local on shared database pools. - Application code never updates Bursar-owned balances or ledger rows directly. See [Protect financial invariants](./financial-safety.mdx) for executable Python and TypeScript examples, and [Manage the credit lifecycle](./credit-lifecycle.mdx) for signup grants, purchases, measured usage, refunds, expiry, and revocation. --- ## Protect financial invariants ## Prerequisites - Provision a tenant and a store first — see [multi-tenancy](./multitenancy.mdx) and [storage backends](./storage-backends.mdx). - Read [credit accounting](../concepts/data-model.mdx) for the entries and balances these invariants protect. - Read [billing concepts](../concepts/billing.mdx) if you plan to use provider webhook ids as idempotency keys. ## Outcome - A billing path you can retry: the same idempotency key always replays the same ledger entry. - Leases that admit long-running work without charging it twice. - Refunds and expiry posted as auditable reversal entries, never silent write-offs. Bursar enforces financial invariants at the canonical account boundary: - Each mutation locks the account before calculating a new balance. - An idempotency key maps to one ledger entry per account. - The account balance must equal the sum of its ledger entries. - Available lot amounts must equal derived bucket totals. - A strict-prepaid operation cannot cross its minimum balance. - Leases reserve capacity and can settle only once. - Refunds and expiry are reversal entries, never projection rewrites. ## Idempotency keys Every monetary mutation accepts an idempotency key. The store indexes `(account, idempotency_key)` uniquely, so a retry with the same key and the same request replays the original entry — the result is returned with `idempotent=True` and no second ledger row is created: ```python from decimal import Decimal first = bursar.credits.add_credits( user_id, Decimal("100"), entry_type="purchase", idempotency_key="checkout:42" ) replay = bursar.credits.add_credits( user_id, Decimal("100"), entry_type="purchase", idempotency_key="checkout:42" ) assert first.entry_id == replay.entry_id and replay.idempotent ``` ```ts const first = await bursar.credits.addCredits(userId, "100", { type: "purchase", idempotencyKey: "checkout:42", }); const replay = await bursar.credits.addCredits(userId, "100", { type: "purchase", idempotencyKey: "checkout:42", }); console.assert(first.entryId === replay.entryId && replay.idempotent); ``` Reusing a key with a different request is a conflict, not a silent pass: the store rejects it, because two different payloads cannot share one ledger row. Use provider event ids as keys in webhook handlers so redelivery is a no-op — never a double charge. Keys are scoped per account. ## Leases: the safe path for long-running AI work When the final cost is only known after the work runs, charge through a lease instead of guessing at admission: 1. `reserve` prices a worst-case estimate, enforces entitlement, quota, and allowance, and atomically creates the lease — the only admission gate. 2. Do the work. Call `renew` before the TTL elapses on long jobs. 3. `settle` bills the actual cost and finalizes the lease; `release` returns an unused hold on failure. ```python from bursar.credits.service_types import ReserveOptions, SettleOptions lease = bursar.credits.reserve( user_id, estimate_metrics, ReserveOptions(idempotency_key="job:42:reserve"), ) try: actual = run_completion() result = bursar.credits.settle( user_id, lease.lease_id, actual, SettleOptions(idempotency_key="job:42:settle"), ) except Exception: bursar.credits.release(user_id, lease.lease_id) raise ``` ```ts const lease = await bursar.credits.reserve(userId, estimateMetrics, { idempotencyKey: "job:42:reserve", }); try { const actual = await runCompletion(); const result = await bursar.credits.settle(userId, lease.leaseId, actual, { idempotencyKey: "job:42:settle", }); } catch (err) { await bursar.credits.release(userId, lease.leaseId); throw err; } ``` The lease captures its `minimum_balance` and pricing snapshot at admission. Settlement honors that captured policy even if the user changes plans while the work is in flight, so a plan change can never strand approved work — or let it bypass the policy it was admitted under. A lease settles exactly once; a settled or released lease cannot be charged again. `run_billed` wraps reserve → work → settle in one call: on a `do_work` exception the lease is released automatically, and settlement is retried with a bounded attempt count because the outcome of a failed settle may be unknown: ```python outcome = bursar.credits.run_billed( user_id, RunBilledOptions( operation_key="job:42", estimate=estimate_metrics, do_work=run_completion, ), ) ``` ```ts const outcome = await bursar.credits.runBilled(userId, { operationKey: "job:42", estimate: estimateMetrics, doWork: runCompletion, }); ``` A crashed worker leaves the lease to expire: the TTL plus the store's `expire_leases` reaper reclaims the hold without ever charging it. ## Refund bounds `refund_credits(entry_id, amount=...)` refunds against the original entry, not the running balance. The store enforces the bounds: never more than the remaining original amount, no duplicate refunds, and only refundable entry kinds. Sources are preserved — the refund entry carries the original entry id — so the audit trail always shows which charge a refund reverses. ```python charge = bursar.credits.deduct_credits( user_id, Decimal("10"), idempotency_key="operation:42", ) refund = bursar.credits.refund_credits( charge.entry_id, amount=Decimal("5"), idempotency_key="refund:operation:42:partial", ) print(refund.amount, refund.new_balance) ``` ```ts const charge = await bursar.credits.deductCredits(userId, "10", { idempotencyKey: "operation:42", }); const refund = await bursar.credits.refundCredits(charge.entryId, { amount: "5", idempotencyKey: "refund:operation:42:partial", }); console.log(refund.amount, refund.newBalance); ``` ## Invariant checklist | Invariant | Enforcement | | ------------------------------------------ | ------------------------------------------------ | | Balance equals the ledger sum | Account row lock + transactional postings | | One entry per (account, idempotency key) | Unique index, checked under the row lock | | Same key, different request | Rejected as a conflict | | No balance below minimum in strict-prepaid | Floor checked at admission and settlement | | Lease settles at most once | Lease status machine (`settling` → `settled`) | | Plan changes cannot strand leased work | Minimum balance captured at reserve | | Refund never exceeds the original | Refunded-amount bounds on the source entry | | Expiry is an accounting event | `expiry` ledger entries, never silent write-offs | Team charges use the same posting path: `deduct_team` debits the team pool with the member stored as the ledger actor, and member spend caps are enforced in the same transaction. Spend caps, allowance windows, usage quotas, and maximum concurrency are account-plan policy — they never duplicate the monetary balance. --- ## Manage the credit lifecycle(Guides) ## Prerequisites - Publish a [validated configuration](../concepts/configuration.mdx) and provision a tenant — see [multi-tenancy](./multitenancy.mdx) and [storage backends](./storage-backends.mdx). - Read [credit accounting](../concepts/data-model.mdx) for buckets, lots, allowances, and ledger entries. ## 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](../concepts/configuration.mdx) and bind a facade. ```python 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 ``` ```ts import { Bursar, PostgresStore } from "@zonastery/bursar"; import { USER_ADA, baseConfig, publishConfig } from "./shared"; const bursar = await publishConfig( new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment: "test", }), baseConfig(), ); const userId = 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: ```python result = bursar.accounts.on_account_created( user_id, event_key="signup", region="us-east-1" ) print(result.plan_key, result.plan_assigned, result.grants) ``` ```ts const result = await bursar.accounts.onAccountCreated({ accountId: userId, eventKey: "signup", region: "us-east-1", }); console.log(result.planKey, result.planAssigned, 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: ```python 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) ``` ```ts const grant = await bursar.credits.addCredits(userId, "50000", { type: "purchase", idempotencyKey: "checkout:cs_123456", }); console.log(grant.entryId, grant.newBalance, 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: ```python 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) ``` ```ts const charge = await bursar.credits.deduct( userId, { operation: "completion", measures: { input_tokens: 4000, output_tokens: 1200, cache_read_tokens: 6000, }, dimensions: { model: "gpt-4o-mini" }, }, { idempotencyKey: "chat:turn:42" }, ); console.log(charge.amount, charge.allowanceConsumed, charge.balanceAfter); ``` 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: ```python 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) ``` ```ts const refund = await bursar.credits.refundCredits(charge.entryId, { amount: "0.000030", reason: "user_reported_bad_output", idempotencyKey: "refund:chat:turn:42:partial", }); console.log(refund.refundEntryId, refund.newBalance); ``` 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: ```python 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) ``` ```ts const dry = await bursar.credits.sweepExpiredCredits(true); console.log(dry.expiredCount, dry.expiredAmount); const result = await bursar.credits.sweepExpiredCredits(); console.log(result.expiredCount, result.expiredAmount); ``` 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)`: ```python 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 ) ``` ```ts let page = await bursar.credits.listLedgerEntries(userId, { limit: 50 }); while (true) { for (const entry of page.items) { console.log(entry.entryId, entry.entryType, entry.amount, entry.createdAt); } if (!page.nextCursor) break; page = await bursar.credits.listLedgerEntries(userId, { limit: 50, cursor: page.nextCursor, }); } ``` `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. --- ## Integrate subscriptions and payments This guide wires one payment provider into Bursar, creates a hosted checkout, and sends the provider's raw webhook request through signature verification. ## Prerequisites - Follow [multi-tenancy](./multitenancy.mdx) to provision a tenant and separate migration and runtime database credentials. - Publish a [validated configuration](../concepts/configuration.mdx) whose `commerce` section declares your provider and offers. - Read [billing concepts](../concepts/billing.mdx) for offer and subscription semantics. - Create provider products and prices, register a webhook endpoint, and store the API and webhook secrets on the server. Install the provider SDK alongside Bursar: ```bash title="Terminal" python -m pip install "bursar[postgres,stripe]" ``` ```bash title="Terminal" npm install @zonastery/bursar pg stripe ``` :::important Provider catalog ownership Bursar does not create or synchronize products and prices in Stripe, Dodo, or another provider. Your embedding application owns that catalog. Every provider identifier in the active Bursar config must already exist in the same provider environment as the API key. ::: ## 1. Construct billing and commerce Create both tenant-bound stores, register a lazy provider factory, then require the commerce capability once. `commerce` is nullable on an unconfigured facade; `require_commerce()` and `requireCommerce()` turn a configuration mistake into a typed error at startup. ```python import os import stripe from bursar import Bursar, CommerceOptions, PostgresBillingStore, PostgresStore from bursar.providers import StripeProvider stripe_client = stripe.StripeClient(os.environ["STRIPE_SECRET_KEY"]) provider_environment = "test" credit_store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment=provider_environment, ) billing_store = PostgresBillingStore( database_url, tenant_id=tenant_id, provider_environment=provider_environment, ) bursar = Bursar( credit_store=credit_store, billing_store=billing_store, commerce_options=CommerceOptions( tenant_id=tenant_id, provider_environment=provider_environment, default_provider="stripe", providers={ "stripe": lambda context: StripeProvider( get_client=lambda: stripe_client, webhook_secret=os.environ["STRIPE_WEBHOOK_SECRET"], event_sink=context.event_sink, ), }, ), ) commerce = bursar.require_commerce() ``` ```ts import { Bursar, PostgresBillingStore, PostgresStore } from "@zonastery/bursar"; import { StripeProvider } from "@zonastery/bursar/providers/stripe"; import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const providerEnvironment = "test" as const; const creditStore = new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment, }); const billingStore = new PostgresBillingStore({ postgres: databaseUrl, tenantId, providerEnvironment, }); const bursar = new Bursar({ creditStore, billingStore, commerceOptions: { tenantId, providerEnvironment, defaultProvider: "stripe", providers: { stripe: (context) => new StripeProvider({ getClient: () => stripe, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, eventSink: context.eventSink, }), }, }, }); const commerce = bursar.requireCommerce(); ``` The provider factory is lazy and receives Bursar's event sink. Bursar-created checkouts attach the trusted financial account as `bursar_account_id`; later events that do not repeat that metadata are reconciled through persisted customer, subscription, and payment references. Use `test` for test-mode provider credentials, `live` for production credentials, and `sandbox` for a provider sandbox distinct from test mode. Credit, billing, and commerce objects in one facade must use the same value. ### Use Dodo instead Install `dodopayments` in TypeScript or use `python -m pip install "bursar[postgres,dodo]"`, then replace the Stripe client and factory: ```python from typing import Literal from dodopayments import AsyncDodoPayments from bursar.providers import DodoProvider dodo_environment: Literal["live_mode", "test_mode"] = ( "live_mode" if os.environ.get("DODO_PAYMENTS_ENVIRONMENT") == "live_mode" else "test_mode" ) dodo = AsyncDodoPayments( bearer_token=os.environ["DODO_PAYMENTS_API_KEY"], environment=dodo_environment, ) def dodo_factory(context): return DodoProvider( get_client=lambda: dodo, webhook_key=os.environ["DODO_PAYMENTS_WEBHOOK_KEY"], setup_product_id=os.environ["DODO_SETUP_PRODUCT_ID"], event_sink=context.event_sink, ) ``` ```ts import type { CommerceProviderFactory } from "@zonastery/bursar"; import { DodoProvider } from "@zonastery/bursar/providers/dodo"; import DodoPayments from "dodopayments"; const dodo = new DodoPayments({ bearerToken: process.env.DODO_PAYMENTS_API_KEY!, environment: process.env.DODO_PAYMENTS_ENVIRONMENT === "live_mode" ? "live_mode" : "test_mode", }); const dodoFactory: CommerceProviderFactory = (context) => new DodoProvider({ getClient: () => dodo, webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_KEY!, setupProductId: process.env.DODO_SETUP_PRODUCT_ID!, eventSink: context.eventSink, }); ``` Register that factory under `dodo`, set `default_provider="dodo"` or `defaultProvider: "dodo"`, and install `dodopayments` instead of `stripe`. `DODO_SETUP_PRODUCT_ID` is the Dodo subscription product your application chooses for mandate-only payment-method setup when an account has no active subscription. Product creation and synchronization remain application-owned. ## 2. Create checkout from authenticated server code `subject_id`/`subjectId` is the authenticated member or actor authorizing the checkout. `account_id`/`accountId` is the financial subject receiving the subscription or credits, so it may identify that member's team. The two values may be equal for a personal account, but they are not aliases. Derive the actor from the verified server session or token, resolve and authorize the account through your trusted application mapping, and do not trust either identifier directly from the browser. ```python from bursar import CreateCheckoutInput checkout = await commerce.create_checkout( CreateCheckoutInput( subject_id=actor_id, account_id=account_id, offer_key="pro_monthly", return_url="https://app.example.com/billing/success", cancel_url="https://app.example.com/billing", operation_key=f"checkout:{request_id}", ) ) return {"checkout_url": checkout.url} ``` ```ts const checkout = await commerce.createCheckout({ subjectId: actorId, accountId, offerKey: "pro_monthly", returnUrl: "https://app.example.com/billing/success", cancelUrl: "https://app.example.com/billing", operationKey: `checkout:${requestId}`, }); return Response.json({ checkoutUrl: checkout.url }); ``` Treat the return URL as navigation only. Credits and subscription access change after a verified provider webhook, not when a browser reaches that URL. ## 3. Verify and ingest raw webhooks Pass the exact raw body and request headers to commerce before parsing JSON. The selected provider adapter verifies the signature, normalizes the event, and submits it through Bursar's idempotent billing event sink. ```python from fastapi import FastAPI, HTTPException, Request app = FastAPI() @app.post("/webhooks/stripe") async def stripe_webhook(request: Request): result = await commerce.handle_webhook( provider="stripe", raw_body=(await request.body()).decode("utf-8"), headers=dict(request.headers), ) if not result.received: status = 503 if result.retryable else 400 raise HTTPException(status_code=status, detail="Webhook rejected") return {"received": True} ``` ```ts // app/api/webhooks/stripe/route.ts export async function POST(request: Request) { const result = await commerce.handleWebhook({ provider: "stripe", rawBody: await request.text(), headers: Object.fromEntries(request.headers.entries()), }); const status = result.received ? 200 : result.retryable ? 503 : 400; return Response.json({ received: result.received }, { status }); } ``` Never call `ingest_billing_event` or `ingestBillingEvent` directly from a public webhook route. Those methods accept an already trusted normalized event and exist for verified custom adapters. For Dodo on Next.js, use `createDodoNextWebhookHandler` from `@zonastery/bursar/providers/dodo/nextjs`; it composes Bursar with Dodo's official `@dodopayments/nextjs` webhook adapter. For Stripe, the provider calls the official SDK's `constructEvent`/`construct_event` API. Stripe does not ship a separate first-party Next.js webhook adapter, so preserving the raw request body is the framework integration boundary—do not reimplement signature verification in application code. The normalized constants use each language's public naming convention: Python uses `BillingEventType.subscription_created` and `BillingEventType.subscription_renewed`; TypeScript uses `BillingEventType.SUBSCRIPTION_CREATED` and `BillingEventType.SUBSCRIPTION_RENEWED`. ## Subscription lifecycle A subscription-created, activated, or plan-changed event in a positive state assigns the offer's plan. Cancellation, expiry, pause, and customer deletion remove that assignment or move the account to the configured terminal plan. A renewal or successful subscription invoice grants the offer's cycle credits. The provider event identifier makes the grant replay-safe. A `replace_previous` renewal expires the previous cycle's remainder before the new grant lands. ## Auto-recharge after deduction When commerce is configured, every deduction runs the configured auto-recharge check. Users opt in through `commerce.auto_recharge` in Python or `commerce.autoRecharge` in TypeScript. The active config controls eligible top-ups, thresholds, cooldowns, limits, and failure behavior; the verified successful-payment webhook posts the resulting credits. ## Plan changes Preview first, show the provider quote to the customer, then confirm with the returned fingerprint. Bursar re-quotes and raises `QuoteChangedError` if the amount or effective time moved. ```python preview = await commerce.preview_plan_change(account_id, offer_key="pro_monthly") confirmed = await commerce.confirm_plan_change( account_id, "plan-change:0195", offer_key="pro_monthly", quote_fingerprint=preview.quote_fingerprint, ) ``` ```ts const preview = await commerce.previewPlanChange({ accountId, offerKey: "pro_monthly", }); const confirmed = await commerce.confirmPlanChange({ accountId, operationKey: "plan-change:0195", offerKey: "pro_monthly", quoteFingerprint: preview.quoteFingerprint, }); ``` --- ## Instrument Bursar with OpenTelemetry Bursar provides an optional OpenTelemetry API adapter. The adapter creates spans and records metrics through the providers already selected by the embedding application. It does not install or configure an OpenTelemetry SDK, processor, reader, collector, or exporter. Without the optional adapter, Bursar uses its vendor-neutral no-op instrumentation. Installing only the OpenTelemetry API is also safe: when the host has not registered an SDK provider, the API implementations remain no-op. ## JavaScript Install the optional peer alongside Bursar: ```bash npm install @zonastery/bursar @opentelemetry/api ``` Create one instrumentation instance and inject it into both the credit service and the PostgreSQL store: ```ts import { Bursar, PostgresStore } from "@zonastery/bursar"; import { createOpenTelemetryInstrumentation } from "@zonastery/bursar/opentelemetry"; const instrumentation = createOpenTelemetryInstrumentation(); const creditStore = new PostgresStore({ postgres: process.env.DATABASE_URL!, tenantId, providerEnvironment: "live", instrumentation, }); const bursar = new Bursar({ creditStore, creditsOptions: { instrumentation }, }); ``` `enableOpenTelemetry()` is a convenience alternative when every subsequently constructed Bursar service should use the adapter: ```ts import { enableOpenTelemetry } from "@zonastery/bursar/opentelemetry"; const restoreInstrumentation = enableOpenTelemetry(); ``` Call the restore function only after the Bursar instances using that default have stopped. Passing `creditsOptions.instrumentation` is preferred when an application needs explicit per-service isolation. Passing `instrumentation` directly to each PostgreSQL store is likewise preferred over relying on the process-wide default. ## Python Install Bursar's API-only extra: ```bash pip install "bursar[opentelemetry]" ``` Inject the same instrumentation into the credit service and PostgreSQL client options: ```python from bursar import ( Bursar, CreditsServiceOptions, PostgresConnectionOptions, PostgresStore, ) from bursar.telemetry.opentelemetry import ( create_opentelemetry_instrumentation, ) instrumentation = create_opentelemetry_instrumentation() credit_store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment="live", postgres_options=PostgresConnectionOptions( instrumentation=instrumentation, ), ) bursar = Bursar( credit_store=credit_store, credits_options=CreditsServiceOptions( instrumentation=instrumentation, ), ) ``` `enable_opentelemetry()` selects the adapter as Bursar's fallback for services constructed afterward and returns a restore callback. Explicit options remain the clearer choice when several independently configured Bursar instances share a process. ## Emitted operations Bursar instruments the following bounded operation names: - Credit grants, grant programs, reserve, settle, release, deduct, and refund. - PostgreSQL query and RPC boundaries. The adapter emits: - `bursar.operation.count`, a completed-operation counter. - `bursar.operation.duration`, a duration histogram in seconds. - One active span named `bursar.` around each boundary. The instrumentation scope is the Bursar package name and package version. Spans preserve the host's currently active context. ## Attribute and data-safety contract Only these bounded attributes can reach the adapter: - `bursar.operation` - `bursar.outcome` - `bursar.backend` - `bursar.provider` - `error.type` - `error.code` Unknown attributes and non-scalar values are discarded. Error messages and exception events are deliberately not recorded. Bursar never adds tenant, user, account, lease, or event identifiers; idempotency keys; SQL text or parameters; database URLs; prompts; webhook envelopes; or arbitrary metadata. The host application owns any additional span enrichment and is responsible for ensuring that its own attributes remain bounded and non-sensitive. ## Ownership boundary Bursar owns only the vendor-neutral instrumentation contract, safe operation boundaries, and the optional OpenTelemetry API adapter. The embedding application owns provider registration, sampling, processing, export, and all global OpenTelemetry configuration. --- ## Meter Google ADK model calls Use Bursar's Google ADK plugin when each model call is a dynamically priced operation. The plugin reserves an estimated amount before provider transport, settles the lease from final usage, and releases the hold when a call fails before the provider completes it. Operational observability stays in ADK's OpenTelemetry pipeline. Export those spans to Langfuse or another tracing backend for latency, retries, prompts, responses, and errors. Bursar stores only the financial usage, price-selection dimensions, provider request ID, and trace correlation needed for accounting. ## Install ```bash pip install "bursar[google-adk,postgres]" ``` The integration is tested against Google ADK 2.6. It is an optional extra, so applications that do not use ADK do not install ADK transitively. ## Register the plugin Declare the largest usage you are willing to admit for one model call. The measure and dimension names also form an allow-list: undeclared provider telemetry is not copied into financial records. ```python from bursar import UsageMetrics from bursar.integrations.google_adk import BursarPlugin from google.adk.apps import App estimate = UsageMetrics( operation="completion", measures={ "calls": 1, "input_tokens": 8_000, "output_tokens": 4_096, "total_tokens": 12_096, "cache_read_tokens": 0, "reasoning_tokens": 0, "tool_calls": 0, }, dimensions={"model": "configured-model", "provider": "openrouter"}, ) app = App( name="support_agent", root_agent=root_agent, plugins=[ BursarPlugin( bursar.credits, estimate=estimate, operation_type="completion", feature="agent_chat", provider="openrouter", reference_type="chat", operation_key_prefix="chat", state_namespace="support", ) ], ) ``` Export `app` from the ADK agent module (for example, `support_agent/agent.py`). ADK's loader prefers it over a bare `root_agent`, which keeps the financial lifecycle at the application boundary instead of duplicating it in every agent. Register the Bursar plugin before plugins that may short-circuit a model call. This makes credit admission the first gate. Its run and agent error hooks clean up a reservation if a later plugin or agent callback exits early. ## Subject and invocation attribution By default, the account is ADK's `user_id`. Override `subject_resolver` when your Bursar account identifier comes from another trusted context field. ADK supplies the invocation ID; callers do not need to create a job ID. Bursar uses that invocation as the financial reference and creates a distinct, idempotent operation key for every model call. A tool-using turn with several model calls therefore produces several independently replayable usage charges under one ADK invocation. The plugin keeps unresolved lease state in ADK's durable session state. Its keys start with `_bursar_model_leases:` and should remain server-managed rather than being accepted from or returned to an untrusted client. ## Authoritative provider receipts ADK's normalized `usage_metadata` is the default source for token counts. Some routers expose additional accounting fields—such as authoritative cost, cache-write tokens, or a generation ID—only on the provider SDK response. Pass a framework-neutral `ProviderReceiptSource` when those fields affect pricing: ```python from bursar.integrations import ProviderReceipt, ProviderReceiptSource class RouterReceiptSource(ProviderReceiptSource): def begin(self) -> None: # Start request-local capture through the provider SDK's supported hook. ... def finish(self) -> ProviderReceipt | None: # Return normalized UsageMetrics plus optional CreditMetadata. ... plugin = BursarPlugin( bursar.credits, estimate=estimate, receipt_source=RouterReceiptSource(), ) ``` Use the provider SDK's official callback or middleware surface. Do not parse logs or put unbounded response payloads in Bursar metadata. Fields absent from the estimate are intentionally discarded; they belong in OpenTelemetry. ## Fixed-price parent jobs Do not attach child model charges merely because a fixed-price batch workflow uses ADK internally. Bill the parent job explicitly with `run_billed_async`/`begin_billed_operation`, and let ADK telemetry describe its internal calls. Enable this plugin for those child calls only when your product actually prices both the parent and the inferences. ## Other agent frameworks The financial lifecycle is not coupled to Google ADK. `ProviderReceipt` and `ProviderReceiptSource` live in `bursar.integrations`, while reservation, settlement, and replay live in `CreditsService`. An adapter for another agent framework only needs to map that framework's before/after/error hooks onto the same contracts. Installing `bursar[google-adk]` is therefore an adapter choice, not a requirement for Bursar itself. --- ## Install the Bursar agent skill The Bursar repository publishes an Agent Skill for coding agents that integrate metering, credits, plans, leases, and billing. The skill contains agent-specific procedure and safety policy; this site remains the canonical source for tutorials, concepts, and API reference. ## Install the skill Install the `bursar` skill at project scope with the Skills command-line interface (CLI): ```bash title="Terminal" npx skills add zonastery/bursar@bursar ``` Project scope lets your team review and version the installed skill with the application that uses it. Pass `--global` only when every project on the workstation should use the skill. ```bash title="Terminal" npx skills add zonastery/bursar@bursar --global ``` Update project-scoped skills after a Bursar release: ```bash title="Terminal" npx skills update --project ``` See the [Skills CLI repository](https://github.com/vercel-labs/skills) for supported coding agents and installation options. ## What the skill contains The package uses progressive disclosure so a coding agent loads only the context needed for the task: | File | Purpose | | ------------------------------------ | ------------------------------------------------------------------------------------------ | | `SKILL.md` | Trigger description, integration workflow, source lookup order, and financial safety gates | | `agents/openai.yaml` | User-facing metadata for clients that support skill catalogs | | `assets/pricing.config.example.yaml` | A complete configuration template that an agent can copy and adapt | The skill does not copy the quickstart, API tables, billing guide, or configuration reference. It links to the maintained documentation and tells an agent to inspect the installed SDK version before writing code. ## Invoke the skill Refer to `$bursar` when you want an agent to apply the integration workflow explicitly. Example requests include: ```text Use $bursar to add replay-safe token usage charges to this API route. ``` ```text Use $bursar to review this checkout webhook for double-credit risks. ``` ```text Use $bursar to add a reserve and settle flow around this background job. ``` The skill should cause the agent to identify the installed Bursar version, inspect the relevant local types, preserve tenant isolation, and verify idempotency before changing code. ## Source ownership Each subject has one maintained owner: | Subject | Canonical source | | ----------------------------------------------------- | --------------------------------------------------- | | Integration procedure and non-negotiable agent checks | `skills/bursar/SKILL.md` | | Tutorials, how-to guides, and concepts | This documentation site | | Python and TypeScript signatures | Generated API reference and installed package types | | PostgreSQL schema | Ordered migrations in `python/src/bursar/sql/` | | Configuration shape | Generated JSON Schema | | Executable learning material | `samples/python/notebooks/` | This separation prevents the skill and documentation from drifting while keeping the skill useful when an agent works inside a repository checkout. ## Related resources - Read [Create your first metered charge](./quickstart.mdx) for the supported setup path - Read [Financial safety](./guides/financial-safety.mdx) for the invariants the skill enforces - Review the [Agent Skills specification](https://agentskills.io/specification) for the portable folder format - Open the [Bursar skill source](https://github.com/zonastery/bursar/tree/main/skills/bursar) to review every instruction and bundled asset --- ## Concepts # Understand the Bursar system These pages explain the boundaries and domain model behind Bursar. Read them in order when you are evaluating the system or designing an integration; use the how-to guides when you need implementation steps. ```mermaid flowchart LR U[Usage event] --> P[Pricing model] P --> A[Plan policy and admission] A --> C[Charge or lease] C --> L[Append-only ledger] E[Provider event] --> B[Billing and commerce] B --> A B --> L ``` ## Read the concepts in order 1. [Architecture](./architecture.mdx) defines the application boundary, capabilities, transaction model, and extension points. 2. [Credit accounting](./data-model.mdx) explains accounts, lots, leases, allowances, and the append-only ledger. 3. [Pricing model](./pricing.mdx) shows how operations, measures, rate cards, and rules produce an exact charge. 4. [Plans and access](./plans.mdx) explains entitlements, quotas, allowances, credit policies, and admission controls. 5. [Billing and commerce](./billing.mdx) connects provider events to offers, subscriptions, plan assignments, and credit grants. 6. [Configuration and catalog revisions](./configuration.mdx) brings those domains together in one validated, versioned document. ## Browse concepts ## Continue with an implementation task Open the [how-to guides](../guides/index.mdx) to provision tenants, protect monetary operations, manage credits, connect subscriptions, or integrate agent tooling. --- ## Architecture `Bursar` is the application boundary. Integrations construct one facade and use its capabilities; they never wire credit and billing services together independently. ## Facade ```mermaid flowchart TB B["Bursar"] --> C1["credits — CreditStore (PostgresStore)"] B --> C2["catalog — active BursarConfig"] B --> C3["accounts — account lifecycle (default plan + signup grants)"] B --> C4["billing — BillingStore + payment provider (optional)"] B --> C5["commerce — checkout, subscriptions, auto-recharge (optional)"] ``` `billing` and `commerce` are present only when you supply a `billing_store` (and, for `commerce`, `commerce_options`) at construction. With commerce enabled, the facade registers an auto-recharge hook that runs after every deduction. ## Capabilities - **credits** — balances, the append-only ledger, lots, leases, allowances, quotas, and analytics. - **catalog** — publishes, activates, and pins versioned configurations; billing never owns configuration writes. - **accounts** — assigns the default plan and executes `account_created` grant programs on signup. - **billing** — provider-agnostic lifecycle: subscription events, invoices, and subscription changes. - **commerce** — checkout intents, offers (subscriptions and topups), and auto-recharge guardrails. See [Credit accounting](./data-model.mdx) for the accounting entities, and [Configuration and catalog revisions](./configuration.mdx) for the document that drives them. ## Database model PostgreSQL stores one balance per row in `credit_accounts`. Every purchase, grant, deduction, refund, expiry, lease settlement, and team charge passes through one locked ledger-posting function and appends to `credit_ledger_entries`. `credit_lots` and `credit_lot_allocations` derive bucket availability and expiry. `credit_leases` hold temporary reservations with their policy snapshot. Plan membership is non-monetary state in `account_plan_assignments`; usage windows and allowance consumption are keyed by account. All rows carry a mandatory `tenant_id`; tenant-prefixed unique constraints and composite foreign keys keep tenants isolated even under buggy code. See [Multi-tenancy](../guides/multitenancy.mdx) for provisioning and [Storage backends](../guides/storage-backends.mdx) for the store interface. ## Migration ownership The Bursar command-line interface applies ordered, append-only SQL migrations from the SDK package. Recorded checksums protect applied migrations, and `bursar migrate` fails when an installed migration no longer matches its recorded checksum. Stores and facades never create database objects. Use the [CLI reference](../cli.mdx) for migration commands and the [database schema](./database-schema.md) for generated table relationships. ## Concurrency and safety The hot path is one atomic store transaction: allowance consumption, entitlement, quota enforcement, and the debit commit or roll back together. Lease admission (`reserve`) enforces policy in the same transaction as the hold, so availability checks and the actual bill cannot disagree. Every mutation is idempotency-keyed per account, and the ledger is append-only, so retries and webhook redeliveries replay instead of double-posting. ## Optional storage `PostgresStore` is the only credit store; `PostgresBillingStore` persists billing state. Both are constructed tenant-bound and run over the same migrated schema — see [Storage backends](../guides/storage-backends.mdx). ## Related - [Credit accounting](./data-model.mdx): accounts, the ledger, lots, and leases on one schema - [Configuration and catalog revisions](./configuration.mdx): the document the catalog publishes and activates - [Provision and isolate tenants](../guides/multitenancy.mdx): tenant context and isolation - [Configure storage backends](../guides/storage-backends.mdx): the store interface and the PostgreSQL implementation --- ## Credit accounting model Bursar records monetary state in one tenant-isolated PostgreSQL schema. This page explains the accounting entities and invariants; use the database schema and SDK references for exact tables and method signatures. ## Accounts A subject (typically one user) has one personal `credit_accounts` row, and optionally one `account_kind = 'team'` row per team. Each account carries a single locked canonical balance and a `version` that every mutation checks — the hot path never re-derives balance from history. ## The append-only ledger Every monetary event is one row in `credit_ledger_entries`: a signed `amount`, the exact `balance_after` after posting, an idempotency key, and a `reference_entry_id` for reversals. Kinds are `grant`, `purchase`, `usage`, `expiry`, `revocation`, `refund`, `adjustment`, `reservation`, `release`, and `refund_clawback`. Positive kinds post credits; `usage`, `expiry`, and `reservation` post debits; `adjustment` can go either way. A refund reverses its source: the refund entry restores the spent balance, and a `refund_clawback` entry reverses the original debits. ## Lots and allocations Every positive entry becomes a `credit_lots` row: the amount, its bucket, priority, and expiry. Debits consume lots in priority order — bucket tiering is `credit_lot_allocations` rows fanning out of the debit entry. When credits expire or are revoked, the remaining lot balance is allocated as `expiry` or `revocation` and the account balance moves by exactly that amount. Refunds restore the source lots that the original debit consumed, not the account globally, so expiry semantics survive refunds. ## Leases `credit_leases` are the atomic admission gate for long-running work: `reserve` captures a worst-case hold, a policy snapshot, and the `minimum_balance` at reservation; `settle` charges the actual cost and finalizes; `release` returns the hold. Entitlement, quota, and `max_in_flight` checks all happen in the same transaction as the reserve, so nothing drifts between "can I afford this?" and "you are billed". ## Plans as state Plans are non-monetary. `account_plan_assignments` records which plan an account holds against which catalog revision; `allowance_windows` and `quota_windows` track usage windows and consumption keyed by account. The allowance is real money _available_ to the account, but it never enters the balance — `DeductionResult.allowance_consumed` tells you the split. ## Usage charges Each metered charge writes one compact `credit_usage_charges` row: operation, `requested`, `charged`, `allowance_requested`, `allowance_covered`, and the catalog revision, plan, and rate card in effect. The measures, model, dimensions, and the full pricing snapshot live in the joined `usage_charge_payloads` row — payload storage is separate so the charged amount never bloats with reconciliation receipts. The `charged + allowance_covered = requested` check is part of the schema. Zero-cost usage is still recorded, so quotas and analytics cannot be bypassed by a free rate. ## Core tables | Table | Purpose | | -------------------------- | -------------------------------------------------------------------- | | `credit_accounts` | One locked balance and version per account (personal or team) | | `credit_ledger_entries` | Append-only money history with `balance_after` per entry | | `credit_lots` | Each positive entry as a spendable lot with bucket, priority, expiry | | `credit_lot_allocations` | Which lots a debit consumed, in priority order | | `credit_leases` | Temporary holds and policy snapshots for admission control | | `credit_usage_charges` | Metered charges: operation, amounts, allowance split, rate card | | `usage_charge_payloads` | Measures, model, dimensions, and pricing snapshot per charge | | `account_plan_assignments` | Current plan per account, pinned to a catalog revision | ## The three money invariants 1. **One locked balance per account.** `credit_accounts.balance` is written only by the ledger-posting path, guarded by `version`; nothing mutates it directly. 2. **Balance equals the ledger.** Every mutation appends an entry whose `balance_after` equals the resulting account balance — reading history and reading the balance always agree. 3. **The ledger is append-only and idempotent.** Entries are never updated or deleted, and `(account_id, idempotency_key)` is unique, so a retried write replays the original entry instead of double-posting. ## Transaction boundary Every monetary mutation runs in one store transaction that locks the account before it checks idempotency, policy, and available value. Concurrent deductions therefore cannot authorize against the same balance snapshot, and application code never maintains a second financial counter. ## Balances and availability The account balance is the canonical posted value. Availability subtracts active lease holds, and bucket balances divide spendable credits by priority and expiry policy. Availability reads are suitable for display, but only an atomic deduction or reservation can authorize work under concurrency. Use the [credit lifecycle guide](../guides/credit-lifecycle.mdx) for balance and ledger procedures. Use the Python or TypeScript API reference for exact query signatures. ## Related - [Architecture](./architecture.mdx): the facade and how capabilities map to the schema - [Configuration and catalog revisions](./configuration.mdx): the document that defines buckets, lots, and plans - [Protect monetary operations](../guides/financial-safety.mdx): holds, leases, and the money invariants in action - [Credit lifecycle tutorial](../notebooks/credits-and-controls/04_credit_lifecycle.mdx): every entry kind on one ledger --- ## Pricing model Pricing decides what a usage event costs. Bursar models it in four layers: an operation declares the inputs, a rate card names a pricing table, a price rule selects a charge, and the `PricingEngine` evaluates the charge in exact decimal arithmetic. ## Operations, measures, and dimensions An **operation** is one named billable action — `completion`, `execution`, `image_generation`, whatever your product meters. Each operation declares the inputs its price can reference: | Concept | Role | Example | | --------- | ------------------------------ | ----------------------------- | | Measure | A numeric quantity with a unit | `input_tokens` (unit `token`) | | Dimension | A typed selector, not priced | `model` (`string`) | ```yaml pricing: operations: completion: measures: input_tokens: { unit: token } output_tokens: { unit: token } cache_read_tokens: { unit: token } dimensions: model: { type: string } ``` Dimensions keep provider-specific attributes (model names, region, batch flags) out of the schema. Measures are non-negative exact numbers; a required dimension must be present on every event for the operation. ## Rate cards A **rate card** is a named pricing table keyed by operation. Cards can inherit unpriced operations from a parent card via `extends`, and each plan references exactly one card (`plan.rate_card`). When a plan selects the card, callers never need to name one; a standalone engine requires `rate_card` only when more than one card exists. ## Price rules and unmatched policies Each operation on a card lists ordered `rules`. A rule's `when` block matches dimensions with typed operators, and the **first matching rule wins**: | Operator | Dimension type | Match | | --------------- | -------------- | ---------------------------------- | | `eq` | any | Exact equality | | `in` / `not_in` | any | Membership / non-membership | | `prefix` | string | String prefix | | `range` | number | Bounds `gt` / `gte` / `lt` / `lte` | When no rule matches, the `unmatched` policy decides: `action: reject` refuses the event, or `action: charge` applies a fallback charge. The canonical config uses both — gpt-4o and gpt-4o-mini get per-million-token rates, anything else falls back to an expression; `execution` only prices gpt-4o and rejects the rest: ```yaml rate_cards: standard: operations: completion: rules: - when: { model: { op: in, values: [gpt-4o, gpt-4o-mini] } } charge: type: sum components: - { type: per_unit, measure: input_tokens, rate: "0.0025", unit_size: "1000000", } - { type: per_unit, measure: output_tokens, rate: "0.0100", unit_size: "1000000", } - { type: per_unit, measure: cache_read_tokens, rate: "0.00125", unit_size: "1000000", } unmatched: action: charge charge: { type: expression, formula: input_tokens * 0.005 + output_tokens * 0.015, } execution: rules: - when: { model: { op: eq, value: gpt-4o } } charge: { type: per_unit, measure: jobs, rate: "0.04" } unmatched: { action: reject } ``` ## Charge types A charge computes the credit cost for the event: | Type | What it does | Example | | ------------ | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | `flat` | A constant fee per event | `{ type: flat, amount: "1.00" }` | | `per_unit` | Rate per `unit_size` units — the basis for per-million-token pricing | `{ type: per_unit, measure: input_tokens, rate: "0.0025", unit_size: "1000000" }` | | `package` | Price per fixed block, rounded up/down/nearest | `{ type: package, measure: jobs, units: "10", amount: "0.10", rounding: ceil }` | | `graduated` | Marginal rates per tier | `{ type: graduated, measure: input_tokens, tiers: [{ up_to: "1000", rate: "0" }, { rate: "0.005" }] }` | | `volume` | One rate selected by total volume | `{ type: volume, measure: input_tokens, tiers: [{ up_to: "100000", rate: "0.004" }, { rate: "0.003" }] }` | | `expression` | An arbitrary formula over the operation's measures | `{ type: expression, formula: input_tokens * 0.005 + output_tokens * 0.015 }` | | `sum` | Sum of sub-charges (each a charge) | The `completion` rule above | `per_unit` with `unit_size: "1000000"` is how per-million-token rates stay readable: the rate is what one million tokens cost. `graduated` tiers must end with exactly one open-ended tier, in strictly increasing order; `package` defaults to rounding up. Graduated and volume tiers differ: graduated applies each tier's rate to the marginal units within it, volume applies the selected tier's rate to the whole amount. ## The PricingEngine `PricingEngine` is the stateless, database-free evaluation core. Both SDKs construct it from the same canonical document: ```python from bursar.engine import PricingEngine from bursar.metrics import UsageMetrics engine = PricingEngine.from_dict(config) # validates the whole config cost = engine.calculate( UsageMetrics( operation="completion", measures={"input_tokens": 1000, "output_tokens": 500, "cache_read_tokens": 200}, dimensions={"model": "gpt-4o"}, ), rate_card="standard", ) ``` ```ts import { PricingEngine } from "@zonastery/bursar"; const engine = PricingEngine.fromDict(config); const cost = engine.calculate( { operation: "completion", measures: { input_tokens: 1000, output_tokens: 500, cache_read_tokens: 200, }, dimensions: { model: "gpt-4o" }, }, { rateCard: "standard" }, ); ``` The engine: - validates measures and dimensions against the operation definition — undeclared names, missing required dimensions, and wrong dimension types raise `ConfigError`; - selects the first matching rule, or the unmatched policy, or rejects; - evaluates every charge in exact decimal and quantizes the result to six decimal places with `ROUND_HALF_UP`; - rejects negative or non-finite costs, and never truncates a sub-credit charge to zero. `calculate_batch(metrics, rate_card=...)` / `calculateBatch(metrics, {rateCard})` evaluates a list of events with the same card selection. `get_rate_card_for_plan(plan_id)` returns the card a plan references. ## Evaluation contract The engine receives one declared operation, its non-negative measures, typed dimensions, and optional caller metadata. It returns an exact `CostBreakdown` with the selected rule, rate card, input evidence, and quantized total. Use the [Python pricing reference](../python-api/pricing-engine.mdx) or [TypeScript pricing reference](../javascript-api/pricing-engine.mdx) for field-level input and output contracts. ## Worked example One gpt-4o completion with 1,000 input, 500 output, and 200 cache-read tokens against the `standard` card: | Component | Rate | Cost | | ---------- | -------------- | ------------------------------------ | | input | 0.0025 per 1M | 0.0025 × 1000 / 1000000 = 0.0000025 | | output | 0.0100 per 1M | 0.0100 × 500 / 1000000 = 0.0000050 | | cache read | 0.00125 per 1M | 0.00125 × 200 / 1000000 = 0.00000025 | | **Total** | | 0.00000775 → **0.000008** | The engine charges exactly `0.000008` credits — the unquantized sum `0.00000775` rounded half-up at six decimal places. The same config returns the same number in Python and TypeScript. ## Related - [Expressions](./expressions.mdx): the safe formula language for `expression` charges - API reference: [PricingEngine (Python)](../python-api/pricing-engine.mdx) and [PricingEngine (TypeScript)](../javascript-api/pricing-engine.mdx) - [Configuration and catalog revisions](./configuration.mdx): where operations and rate cards live --- ## Plans and access control A plan bundles everything a paid tier gets: which operations are allowed, which rate card prices them, what credits come free, which features unlock, what usage is limited, and how concurrency is gated. Plans live in the canonical config and are enforced by the database — policy is never only application code. ## What a plan contains | Field | Role | | --------------------------- | -------------------------------------------------------------------------------- | | `display_name` | Customer-facing label | | `rank` | Catalog ordering; lower ranks first (default `0`) | | `rate_card` | The rate card that prices allowed operations | | `allowed_operations` | Operations the plan may run | | `features` | Feature values for the plan (`{ voice_mode: true, max_context: 200000 }`) | | `credit_allowance` | Free credits per window; its `priority` positions them among credit buckets | | `quotas` | Per-operation measure limits over a window | | `credit_policy` | Reference to a `prepaid` or `credit_line` policy | | `admission_policy` | Reference to a named concurrency policy | | `evolution.default_rollout` | Default adoption timing (`immediate`, `next_renewal`, or `new_assignments_only`) | `catalog.default_plan` names the signup plan and is required whenever the catalog defines plans. `rank` controls display order only; it never selects an implicit default. ## Allowances A `credit_allowance` grants free credits that reset on a window — the `free` plan's monthly 10,000 credits: ```yaml catalog: default_plan: free credits: buckets: promotional: { priority: 1 } purchased: { priority: 10 } default_bucket: purchased plans: free: display_name: Free rank: 0 rate_card: standard allowed_operations: [completion] credit_allowance: amount: "10000" priority: 5 window: { type: calendar, unit: month, count: 1 } ``` Windows are `calendar` (aligned to a timezone), `rolling` (a duration since first use), or `plan_assignment` (anchored to when the plan was assigned). Allowance and bucket priorities use one ordering namespace; lower numbers spend first. In the example, promotional credits spend first, then the monthly allowance, then purchased credits. Every allowance must declare a priority, and that value cannot equal a bucket priority. The allowance remains a plan entitlement with its own reset window rather than a synthetic bucket. `DeductionResult.allowance_consumed` reports how much of a charge the allowance covered. `check_allowance(user_id)` returns the current window (`plan_id`, `allowance_remaining`, `period_start`, `period_end`) for display and gating. `unset_user_plan` pauses the allowance window; reassigning re-anchors it. ## Quotas A quota limits one measure of one operation over a window — the `pro` plan's daily output-token cap: ```yaml quotas: daily_tokens: operation: completion measure: output_tokens limit: "500000" window: { type: calendar, unit: day, count: 1 } enforcement: block emit_at_percent: [80, 100] ``` `enforcement` is `block` (the charge fails with `QuotaExceededError`) or `allow` (usage records, the balance still pays). `emit_at_percent` fires `credits.quota_threshold` events as usage crosses each percentage (plus `credits.quota_blocked` when a block fires); persisted events are listed with `list_quota_events`, and current windows with `get_quota_state`. Quotas are checked in the same atomic transaction as the deduction, so a race cannot slip usage past a block. ## Features and entitlements `entitlements.features` declares typed product features with defaults; `check_feature(user_id, feature)` returns the plan's value with a `has_feature` flag. Presence is distinguished from truthiness: `true`, any number (including `0`), and any string (including `""`) count as present; `false`, `null`, and absence do not. ```yaml entitlements: features: voice_mode: { type: boolean, default: false } max_context: { type: integer, default: 128000, minimum: 8000, maximum: 200000 } ``` Feature types are `boolean`, `enum` (with `values`), `integer` (with optional `minimum`/`maximum`), and `string` (with optional `pattern`). Operations can require a feature at charge time: pass `feature="voice_mode"` to `deduct`/`reserve`/`settle` and the store rejects the call with `FeatureNotEntitledError` when the plan lacks it. The database, not the application, remains the gate. ## Admission policies `admission.policies` name reusable concurrency limits — a global `max_in_flight` plus per-operation overrides: ```yaml admission: policies: default: { max_in_flight: 4 } ``` A plan references one policy (`pro` uses `default`). `reserve` enforces the limit; exceeding it raises `ConcurrencyLimitError` before any hold is taken. The same policy can be shared by several plans. ## Credit policies `credits.policies` name prepaid or credit-line policies: | Policy | Behavior | | ------------- | ----------------------------------------------------- | | `prepaid` | Floor at zero; structural zero debt | | `credit_line` | `limit` allows a negative floor — a bounded overdraft | A plan's `credit_policy` reference applies its floor to deductions. The constructor preset (`strict_prepaid` by default) is the fallback for planless users. ## Plan assignment Account creation assigns the explicit `catalog.default_plan` and applies eligible `account_created` grants. Runtime changes update the assignment, re-anchor the applicable policy windows, and emit a plan-change event. Large installations use resumable, bounded plan-migration batches instead of one unbounded transaction. Use the generated credits-service reference for exact assignment and migration signatures. ## Catalog rollout Each plan declares `evolution.default_rollout`. `immediate` moves existing assignments when the revision activates, `next_renewal` schedules subscription-backed assignments for their next provider renewal, and `new_assignments_only` leaves current assignments on their existing revision. ```yaml plans: pro: # ...plan fields... evolution: default_rollout: next_renewal ``` An activation can override that default for one release with a rollout manifest passed to `bursar config set --rollout` or `bursar config activate --rollout`. Pinning is account-specific and separate from the plan strategy. See the [CLI reference](../cli.mdx#config-lifecycle) for the manifest, pin, and due-change commands. ## Related - [Configuration and catalog revisions](./configuration.mdx): plans and policy references in one document - [Protect monetary operations](../guides/financial-safety.mdx): floors, holds, and the lease lifecycle - [Credit lifecycle tutorial](../notebooks/credits-and-controls/04_credit_lifecycle.mdx): end-to-end walkthrough - API reference: [Credits service (Python)](../python-api/credit-manager.mdx) and [Credits service (TypeScript)](../javascript-api/credit-manager.mdx) --- ## Billing and commerce Billing connects your credit ledger to a payment provider. Bursar owns the offer catalog and the normalized event state machine; the provider only executes payments. The `commerce` section of the canonical config declares providers, offers, and auto-recharge guardrails. ## Providers `commerce.providers` names the payment providers an environment supports. Each provider is one of `stripe`, `dodo`, or `custom` (with an `adapter`): ```yaml commerce: providers: stripe: { type: stripe } ``` At runtime, each provider is a `PaymentProvider` adapter (Stripe, Dodo, or custom) registered in `CommerceOptions.providers`. Adapters do two jobs: they create checkout sessions, and they map provider webhooks to **normalized `BillingEvent`s**. Send the provider's unmodified body and headers through `commerce.handle_webhook(...)` / `handleWebhook(...)`, or use an official signature-verifying framework adapter. The verified adapter then submits the normalized event, which Bursar claims by `(provider, event_id, event_type)` so a redelivery cannot apply the lifecycle mutation twice. Direct `ingest_billing_event` / `ingestBillingEvent` calls are only for already trusted custom adapters. ## Offers `commerce.offers` defines what customers can buy. Prices are integer minor units with a currency and a `tax_behavior`; provider references map the offer to provider objects (`stripe_price.price_id`, `dodo_product.product_id`). **Subscription offers** bind a plan to a billing interval, an optional trial, and an optional cycle grant — the Pro monthly plan with a 50,000-credit grant: ```yaml offers: pro_monthly: type: subscription display_name: Pro Monthly price: { amount_minor: 2000, currency: USD } providers: stripe: { type: stripe_price, price_id: price_pro_monthly } plan: pro billing_interval: { unit: month, count: 1 } cycle_grant: amount: "50000" bucket: purchased renewal: replace_previous ``` **Topup offers** sell credit packs — `credits_per_unit` credits per unit, bounded `quantity`, a target `bucket`, and `lot_behavior` (`separate_lots` keeps each purchase as its own lot; `merge_and_refresh` merges): ```yaml credits_10k: type: topup display_name: "10,000 Credits" price: { amount_minor: 500, currency: USD } providers: stripe: { type: stripe_price, price_id: price_credits_10k } credits_per_unit: "10000" quantity: { minimum: 1, maximum: 10, default: 1 } bucket: purchased lot_behavior: separate_lots ``` ## Checkout A checkout has three phases. `create_checkout` resolves the offer, enforces quantity bounds and existing subscriptions, records a checkout intent, and asks the provider for a session URL — the intent exists before any money moves. The user pays on the provider's site. The provider webhook then arrives as a `checkout.completed` event and completes the intent, which is what grants credits or assigns the plan. Because the webhook is the only settlement path, a payment that never touches your checkout endpoint still settles credits through `ingest_billing_event`: on `payment.succeeded` for a topup, the offer's credits are granted to the configured bucket; on `checkout.completed` for a subscription, the cycle grant posts and the plan is assigned. The walkthrough in [Subscription integration](../guides/subscription-integration.mdx) shows the full flow end to end. ## Subscriptions `BillingSubscriptionStatus` covers the provider lifecycle: `incomplete`, `incomplete_expired`, `trialing`, `active`, `past_due`, `canceled`, `unpaid`, `paused`, and `expired`. A `past_due` subscription enters a grace period; when the grace end passes, the subscription is revoked (`expire_past_due_grace_periods` sweeps expired periods) and `grace_expired_at` is recorded. Subscriptions drive entitlement: activation events assign the offer's plan through a provisioning port (`set_user_plan`), and cancellation moves the account to the terminal plan or clears the assignment — the same path an admin's `set_user_plan` uses, so allowances, quotas, and admission all follow from the subscription state. `resolve_offer(provider, product_id=None, price_id=None)` maps a provider object to the configured offer, e.g. from a webhook payload: ```python billing = bursar.require_billing() offer = billing.resolve_offer("stripe", price_id="price_pro_monthly") ``` ## Plan changes `commerce.subscription_changes` configures how plan changes behave per direction — `upgrade`, `downgrade`, `lateral`, and `cadence_change`. Each policy sets `effective` (`immediate` or `renewal`), `proration` (`prorated` or `none`), and `payment_failure` (`prevent_change` or `apply_change`): a downgrade may wait for renewal, while an upgrade applies immediately. ## Auto-recharge `commerce.auto_recharge` turns a low balance into a topup purchase without user interaction. Guardrails bound every decision: | Field | Meaning | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `eligible_topups` | Topup offers auto-recharge may buy | | `balance_below` | Threshold that triggers a purchase (`min`/`max`/`default` credits) | | `rearm_above` | Balance that arms auto-recharge again (must exceed `balance_below.maximum`) | | `quantity` | Units per purchase | | `limits` | `max_purchases` per window, `max_charge_minor`, cooldown, and failure handling (`max_consecutive_failures`, `failure_action: pause`) | ```yaml auto_recharge: eligible_topups: [credits_10k] balance_below: { minimum: "1000", maximum: "5000", default: "2000" } rearm_above: "20000" quantity: { minimum: 1, maximum: 10, default: 1 } limits: max_purchases: 5 window: { type: calendar, unit: day, count: 1 } max_charge_minor: 5000 cooldown: { unit: hour, count: 1 } max_consecutive_failures: 3 failure_action: pause ``` When commerce is enabled, the facade hooks auto-recharge after every deduction. Processing outcomes are `not_configured`, `disabled`, `above_threshold`, `already_processing`, `limit_reached`, `submitted`, `action_required`, and `failed`. Per-user profiles persist threshold, topup, quantity, window counts, and payment method, so recharges are idempotent and bounded even across process restarts. ## Billing persistence boundary Billing and credit state share the same migrated PostgreSQL database and tenant boundary. Billing and commerce capabilities remain unavailable when an integration constructs only the credit store, which keeps payment-provider concerns optional. Use [Configure storage backends](../guides/storage-backends.mdx) for store construction and [Integrate subscriptions and payments](../guides/subscription-integration.mdx) for provider wiring. ## Related - [Configuration and catalog revisions](./configuration.mdx): the `commerce` section and immutable publication lifecycle - [Integrate subscriptions and payments](../guides/subscription-integration.mdx): production provider and store procedures - [Plans and access control](./plans.mdx): what a subscription entitles --- ## Configuration and catalog revisions `BursarConfig` brings the pricing, credit, access, and commerce models together in one strict versioned document. Python and TypeScript validate the same schema, so one active catalog revision produces the same policy decisions in both SDKs. ## One document owns product policy Each top-level section owns one part of the product model: | Section | Responsibility | | -------------- | --------------------------------------------------------------------- | | `pricing` | Metered operations, measures, dimensions, and reusable rate cards | | `credits` | Credit buckets, spending policies, grants, and display conversion | | `entitlements` | Typed product features | | `admission` | Reusable concurrency policies | | `plans` | Product access, allowances, quotas, and policy references | | `commerce` | Payment providers, offers, plan changes, and auto-recharge guardrails | | `catalog` | Catalog-wide settings, including the default signup plan | Keeping these domains in one document lets validation reject broken cross-references before publication. A plan cannot name an unknown rate card, an offer cannot name an unknown plan, and a price rule cannot use a measure or dimension that its operation does not declare. ## Validation is shared across SDKs Pydantic defines the Python contract and generates the published JSON Schema. TypeScript validates the same contract with Ajv. Unknown fields and legacy shapes are rejected instead of being ignored. The validation boundary also enforces these accounting rules: - Exact decimal values use strings instead of binary floating-point numbers - Every integer stays within JavaScript's exact safe-integer range - Offer prices use integer minor units with an explicit currency - Matcher operators agree with their declared dimension types - Expressions reference only measures declared by their operation - Credit buckets and allowance priorities share one collision-free ordering namespace - Subscription-backed plans declare when catalog revisions take effect The smallest valid document declares its schema version and credit boundary: ```yaml version: 1 credits: buckets: purchased: { priority: 10 } default_bucket: purchased ``` Run validation before publication: ```bash title="Terminal" bursar config validate bursar.yaml ``` Use `--json` for structured diagnostics in continuous integration (CI) and editor integrations. Use the [published JSON Schema](https://zonastery.github.io/bursar/pricing-config.schema.json) for editor completion and the [validated complete example](https://github.com/zonastery/bursar/blob/main/skills/bursar/assets/pricing.config.example.yaml) as the canonical full configuration sample. ## Catalog revisions are immutable Publishing validates the document and stores an immutable catalog revision. Activation selects the revision used for new pricing and policy decisions within one tenant. ```mermaid flowchart LR F[Configuration file] --> V[Validate] V --> P[Publish immutable revision] P --> A[Activate for tenant] A --> R[Apply revision policy] R --> N[New and existing accounts] ``` Each revision has a SHA-256 digest of its canonical document. Publishing the same digest reuses the existing revision, while activation records history and leaves prior revisions available for audit and rollback. The catalog exposes three lifecycle operations: | Operation | Effect | | -------------------- | ----------------------------------------------------------------------- | | Publish a draft | Validate and store a revision without changing the active catalog | | Activate a revision | Select one active revision for the tenant and schedule applicable moves | | Publish and activate | Validate, store, and activate one document in a single workflow | Use the [CLI reference](../cli.mdx) for exact `config validate`, `set`, `get`, `list`, `activate`, `diff`, and `schema` commands. Use the generated SDK reference for programmatic catalog signatures. ## Revision policy controls rollout Plans define when an activated revision reaches assigned accounts. `immediate` applies the new revision at activation, `next_renewal` waits for the next subscription renewal, and `new_assignments_only` leaves existing assignments on their current revision until an explicit migration. Subscription-backed plans default to `next_renewal`; other plans default to `immediate`. Declare the policy explicitly when a rollout delay changes customer-visible pricing or access. ## Public projections exclude private configuration The active configuration remains the internal policy document. Catalog projection methods expose only the product fields needed by a client application and keep provider identifiers, internal policies, and private configuration out of public responses. ## Related - [Pricing model](./pricing.mdx): operations, rate cards, and charge types - [Plans and access control](./plans.mdx): allowances, entitlements, quotas, and revision policy - [Billing and commerce](./billing.mdx): providers, offers, subscriptions, and auto-recharge - [Create your first metered charge](../quickstart.mdx): validate and publish a minimal configuration --- ## Expression reference Bursar uses a safe expression language for pricing formulas. The same syntax works identically in Python and TypeScript. All arithmetic is exact decimal (Python `decimal.Decimal`, TypeScript `decimal.js`) — never binary floating point. So `input_tokens * 0.1 + output_tokens * 0.2` with both equal to `1` evaluates to exactly `0.3`, not `0.30000000000000004`, and the result is byte-identical across both SDKs. The engine quantizes the final cost to 6 decimal places with `ROUND_HALF_UP`; it never truncates a sub-credit cost to zero. ## When to use expressions Rate cards can express most pricing with `per_unit` and `sum` charges — the canonical `standard` card prices gpt-4o and gpt-4o-mini entirely that way. Expressions handle the non-linear cases: formulas combining several measures, or prices that need `tier()`, `clamp()`, or `percentile()`. Every expression is validated at config load, so an invalid formula fails validation instead of reaching a charge; a wrong function argument count surfaces as an `ExpressionError` when the formula is first evaluated. ## Arithmetic | Operator | Example | Description | | -------- | -------------------------------------------- | ---------------------- | | `+` | `input_tokens * 0.01 + output_tokens * 0.03` | Addition | | `-` | `-cache_read_tokens * 0.001` | Subtraction / negation | | `*` | `input_tokens * 0.01` | Multiplication | | `/` | `output_tokens * (0.03 / 1000)` | Division | | `//` | `input_tokens // 1000` | Floor division | | `%` | `input_tokens % 1000` | Modulo | :::warning Exponentiation (`**`) is not allowed The `**` / exponentiation operator is **rejected at config-load time** in both SDKs (it raises `ExpressionError`). This is deliberate DoS hardening: an unbounded exponent such as `9 ** 9 ** 9` could allocate gigabytes and hang the process. There is no constant-exponent carve-out — use repeated multiplication (`x * x`) if you need a power. ::: :::warning Division / modulo by zero raises `x / 0`, `x // 0` and `x % 0` raise `ExpressionError` in both SDKs. They do **not** silently produce `inf`/`NaN` and never flow into a charge. Any expression that evaluates to a non-finite result (`inf`/`NaN`) is also rejected as an `ExpressionError`. ::: ## Comparisons | Operator | Example | | -------- | ------------------------- | | `==` | `output_tokens == 0` | | `!=` | `output_tokens != 0` | | `<` | `output_tokens < 1000` | | `<=` | `output_tokens <= 1000` | | `>` | `output_tokens > 1000` | | `>=` | `output_tokens >= 1000` | | `in` | `"gpt-4" in model` | | `not in` | `"batch" not in job_type` | `in` / `not in` are substring containment checks (both sides are coerced to strings) so the two engines agree byte-for-byte. :::warning Chained comparisons (`a < b < c`) are not supported Both SDKs reject chained comparisons at parse time with an `ExpressionError`. Python's chaining semantics (`a < b and b < c`) cannot be reproduced by the TypeScript left-associative parser, so neither engine allows them — this keeps the two engines byte-identical. Write the explicit `and` form instead: `tool_calls > 0 and tool_calls <= 10`. ::: ## Boolean | Operator | Example | | -------- | ------------------------------------------ | | `and` | `tool_calls > 0 and tool_calls <= 10` | | `or` | `tool_calls == 0 or cache_read_tokens > 0` | | `not` | `5 if not (tool_calls > 10) else 10` | ## Ternary Python-style conditional expression: ```text output_tokens * 0.5 if output_tokens > 1000 else output_tokens * 0.3 ``` ## Functions | Function | Arity | Description | Example | | ------------------------------------------- | ------------------ | ------------------------------------------------------ | ---------------------------------------------- | | `ceil(x)` | 1 | Round up | `ceil(input_tokens * 0.001)` | | `floor(x)` | 1 | Round down | `floor(output_tokens / 1000)` | | `round(x)` / `round(x, n)` | 1–2 | Round **half-up** to nearest integer (or `n` decimals) | `round(input_tokens * 0.001)` | | `min(a, b, ...)` | ≥ 1 | Minimum of values | `min(cost_a, cost_b)` | | `max(a, b, ...)` | ≥ 1 | Maximum of values | `max(0, model_cost - allowance)` | | `if(cond, then, else)` | exactly 3 | Conditional | `if(input_tokens > 1000, cost_a, cost_b)` | | `tier(val, t1, r1, [t2, r2, ...], default)` | even, ≥ 4 | Tiered pricing | `tier(input_tokens, 10000, 5, 100000, 10, 20)` | | `clamp(x, lo, hi)` | exactly 3 | Range clamp | `clamp(tool_calls, 0, 100)` | | `percentile(p, v1, v2, ...)` | ≥ 2, `0 ≤ p ≤ 100` | Percentile of values | `percentile(95, model_cost_1, model_cost_2)` | The function set is intentionally small: no `sum`, `abs`, or arbitrary math-library access. A wrong arity (and `percentile`'s `p` outside `0..100`) raises `ExpressionError` when the formula is evaluated, so a typo fails loudly instead of silently mispricing. `round()` uses `ROUND_HALF_UP` in both SDKs (it deliberately diverges from Python's built-in banker's rounding) so the two engines agree to the last digit. These are _expression-level_ helpers a config author can call — the engine never implicitly rounds the total beyond the final 6dp quantization. ### `tier()` details Form: `tier(value, t1, r1, [t2, r2, ...], default)` — a `value`, one or more `(threshold, rate)` pairs, and a trailing `default`. The argument count must therefore be **even and at least 4** (value + N≥1 pairs + default). Odd counts (3, 5, 7, …) and fewer than 4 arguments raise `ExpressionError`. Returns `r_i` for the **first** threshold where `value < t_i`, else `default`: ```text tier(input_tokens, 10000, 5, 100000, 10, 20) # value < 10000 → 5 # value < 100000 → 10 # otherwise → 20 (default) ``` ### `percentile()` details Sorts values, computes p-th percentile (`0 ≤ p ≤ 100`) via linear interpolation. Requires at least 2 arguments; a `p` outside `0..100` raises `ExpressionError`. ```text percentile(50, input_tokens, output_tokens, tool_calls) # median of 3 values percentile(0, a, b, c) # min percentile(100, a, b, c) # max ``` ## Available variables An expression charge may reference the measures declared by its operation, and **must reference at least one** — a constant formula like `1 + 1` is rejected. For example: ```yaml pricing: operations: completion: measures: input_tokens: { unit: token } output_tokens: { unit: token } rate_cards: standard: operations: completion: unmatched: action: charge charge: type: expression formula: input_tokens * 0.01 + output_tokens * 0.03 ``` Dimensions such as `model` are selected through rate-card rules and are not expression variables. An undeclared or misspelled measure name is rejected when the configuration is loaded. ## Safety - **Python:** AST-based validator with a strict node allowlist. The `**` (`ast.Pow`) node is intentionally excluded, as are attribute access, subscripts, lambdas, comprehensions, f-strings, and imports. There is no `exec()`; the only `eval` runs a pre-validated AST in a namespace with no builtins. - **TypeScript:** Recursive-descent parser with a strict allowlist. No `eval()`, no `Function()` constructor. Variable lookup uses own-property checks (not the `in` operator), so prototype-chain identifiers (`__proto__`, `constructor`, `prototype`, `toString`, `hasOwnProperty`) are rejected as undefined variables rather than resolving to inherited members. - **Variable-name validation:** at config-load time every identifier must be a known metric variable or an allowed function; an unknown name (e.g. a typo like `inputtokens`) raises `ExpressionError` then, not at first runtime use. - **Exact decimal, finite results:** all math runs in `Decimal`; `**` is rejected; division/modulo by zero raises; the final result is asserted finite. Non-finite results raise `ExpressionError` and never reach a charge. ## Related - [Pricing](./pricing.mdx) — `expression` charges and how the engine evaluates them - [Configuration and catalog revisions](./configuration.mdx): formulas validated at configuration load - [Write safe pricing expressions](../notebooks/foundations/03_expression_language.mdx) — a hands-on walkthrough --- ## Database schema # Database schema This diagram is generated from the canonical SQL migrations in `python/src/bursar/sql/`. ```mermaid erDiagram tenants { uuid id "PK" text slug "UK" text display_name text status timestamptz created_at timestamptz updated_at } storage_settings { BOOLEAN singleton "PK" INTEGER usage_payload_retention_days INTEGER quota_event_retention_days INTEGER quota_max_lateness_seconds INTEGER quota_correction_window_days INTEGER quota_retention_safety_days INTEGER billing_payload_retention_days INTEGER quota_notification_retention_days INTEGER terminal_lease_payload_retention_days INTEGER usage_rollup_retention_days INTEGER outbox_delivered_retention_days INTEGER outbox_max_retention_days INTEGER maintenance_interval_seconds INTEGER maintenance_batch_size INTEGER maintenance_lock_timeout_ms TIMESTAMPTZ last_maintenance_at TIMESTAMPTZ updated_at } subjects { UUID tenant_id "FK" UUID id "PK" TIMESTAMPTZ pseudonymized_at TIMESTAMPTZ created_at } external_identities { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK" TEXT provider "UK" TEXT provider_environment "UK" TEXT external_subject "UK" TIMESTAMPTZ created_at } tenant_catalog_counters { UUID tenant_id "PK,FK" BIGINT next_revision_no } catalog_revisions { uuid tenant_id uuid id "PK" bigint revision_no integer yaml_schema_version jsonb source_document bytea digest bursar status text label timestamptz created_at timestamptz published_at timestamptz activated_at timestamptz retired_at } catalog_activation_history { uuid tenant_id bigint_GENERATED_ALWAYS_AS_IDENTITY id "PK" uuid catalog_revision_id timestamptz activated_at timestamptz deactivated_at text label } catalog_buckets { uuid tenant_id uuid id "PK" uuid catalog_revision_id text bucket_key text label integer priority jsonb definition jsonb expiry_policy text expiry_type boolean_GENERATED_ALWAYS_AS expires text expires_after_unit integer expires_after_count text expires_after_anchor text expires_after_timezone timestamptz fixed_expires_at boolean allow_overdraft boolean is_default } catalog_operations { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT operation_key "UK" JSONB measures JSONB dimensions JSONB definition } catalog_rate_cards { uuid tenant_id uuid id "PK" uuid catalog_revision_id text rate_card_key text extends_key jsonb definition } catalog_credit_policies { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT policy_key "UK" TEXT policy_type NUMERIC20 credit_limit JSONB definition } catalog_admission_policies { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT policy_key "UK" INTEGER max_in_flight JSONB definition } catalog_admission_operation_policies { UUID tenant_id "FK" UUID catalog_revision_id "PK,FK" TEXT admission_policy_key "PK,FK" TEXT operation_key "PK,FK" INTEGER max_in_flight } catalog_entitlement_features { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT feature_key "UK" TEXT value_type JSONB default_value JSONB definition } catalog_plans { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT plan_key "UK" TEXT display_name TEXT description TEXT rate_card "FK" TEXT allowed_operations TEXT credit_policy_key "FK" TEXT admission_policy_key "FK" TEXT default_rollout NUMERIC20 credit_allowance_amount INTEGER credit_allowance_priority TEXT credit_allowance_bucket "FK" TEXT credit_allowance_reset_unit INTEGER credit_allowance_reset_count TEXT credit_allowance_reset_anchor TEXT credit_allowance_reset_timezone JSONB definition } catalog_plan_features { UUID tenant_id "FK" UUID catalog_revision_id "PK,FK" TEXT plan_key "PK,FK" TEXT feature_key "PK,FK" JSONB feature_value } catalog_plan_quotas { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT plan_key "FK,UK" TEXT quota_key "UK" TEXT operation_key "FK" TEXT measure_key NUMERIC20 quota_limit JSONB window_policy TEXT enforcement INTEGER emit_at_percent JSONB definition } catalog_grant_programs { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT program_key "UK" TEXT trigger_type JSONB availability JSONB eligibility INTEGER max_awards_per_subject TEXT idempotency_scope JSONB definition } catalog_grant_awards { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" UUID grant_program_id "FK,UK" INTEGER award_index "UK" TEXT recipient NUMERIC20 amount TEXT bucket_key "FK" JSONB expiry_policy JSONB definition } catalog_offers { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT offer_key "UK" TEXT display_name TEXT description INTEGER sort_order JSONB availability BIGINT amount_minor TEXT currency TEXT tax_behavior TEXT plan_key "FK" TEXT billing_unit INTEGER billing_count JSONB trial_policy NUMERIC20 cycle_grant_amount TEXT cycle_grant_bucket_key "FK" TEXT cycle_grant_renewal JSONB cycle_grant_expiry_policy JSONB definition } catalog_topups { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT topup_key "UK" TEXT display_name TEXT description INTEGER sort_order JSONB availability BIGINT amount_minor TEXT currency TEXT tax_behavior NUMERIC20 credits_per_unit TEXT bucket_key "FK" INTEGER min_quantity INTEGER max_quantity INTEGER default_quantity JSONB expiry_policy TEXT lot_behavior JSONB definition } catalog_provider_refs { UUID tenant_id "FK" UUID id "PK" UUID catalog_revision_id "FK,UK" TEXT provider "UK" TEXT provider_environment "UK" TEXT lookup_type "UK" TEXT lookup_value "UK" TEXT object_type TEXT object_key } credit_accounts { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK,UK" TEXT account_kind "UK" NUMERIC20 balance BIGINT version TIMESTAMPTZ updated_at TIMESTAMPTZ created_at } credit_ledger_entries { uuid tenant_id uuid id "PK" uuid account_id bursar kind numeric amount numeric balance_after uuid reference_entry_id uuid catalog_revision_id text idempotency_key bytea request_digest text operation jsonb metadata timestamptz created_at } credit_lots { UUID tenant_id "FK" UUID id "PK" UUID account_id "FK" UUID source_entry_id "FK" UUID catalog_revision_id "FK" TEXT bucket_key "FK" INTEGER priority NUMERIC20 granted NUMERIC20 consumed TIMESTAMPTZ expires_at JSONB expiry_policy_snapshot TEXT source_type UUID source_id TIMESTAMPTZ created_at } credit_lot_sources { UUID tenant_id "FK" UUID id "PK" UUID lot_id "FK" UUID ledger_entry_id "FK" NUMERIC20 amount TEXT source_type UUID source_id TIMESTAMPTZ created_at } credit_lot_allocations { UUID tenant_id "FK" UUID id "PK" UUID debit_entry_id "FK,UK" UUID lot_id "FK,UK" NUMERIC20 amount TEXT allocation_kind TIMESTAMPTZ created_at } credit_lot_source_allocations { UUID tenant_id "FK" UUID id "PK" UUID lot_allocation_id "FK,UK" UUID lot_source_id "FK,UK" NUMERIC20 amount TIMESTAMPTZ created_at } credit_lot_restorations { UUID tenant_id "FK" UUID id "PK" UUID refund_entry_id "FK,UK" UUID original_allocation_id "FK,UK" UUID lot_id "FK" NUMERIC20 amount TIMESTAMPTZ created_at } credit_lot_source_restorations { UUID tenant_id "FK" UUID id "PK" UUID lot_restoration_id "FK,UK" UUID source_allocation_id "FK,UK" NUMERIC20 amount TIMESTAMPTZ created_at } credit_unallocated_debits { UUID tenant_id "FK" UUID ledger_entry_id "PK,FK" UUID account_id "FK" NUMERIC20 amount TEXT reason TIMESTAMPTZ created_at } credit_debt_repayments { UUID tenant_id "FK" UUID ledger_entry_id "PK,FK" UUID account_id "FK" NUMERIC20 amount TIMESTAMPTZ created_at } credit_usage_charges { uuid tenant_id uuid id "PK" uuid account_id text operation timestamptz event_at numeric requested numeric charged numeric allowance_requested numeric allowance_covered text billing_disposition uuid catalog_revision_id uuid plan_id text rate_card_key uuid ledger_entry_id text idempotency_key bytea request_digest timestamptz created_at } usage_charge_payloads { UUID tenant_id "FK" UUID charge_id "PK,FK" TIMESTAMPTZ event_at "PK" JSONB measures TEXT feature TEXT model TEXT region JSONB dimensions JSONB metadata JSONB pricing_snapshot TIMESTAMPTZ created_at } usage_daily_rollups { UUID tenant_id "FK" DATE usage_day "PK" UUID account_id "PK,FK" TEXT operation "PK" TEXT model_key "PK" TEXT region_key "PK" SMALLINT rollup_shard "PK" NUMERIC20 charged NUMERIC20 allowance_covered BIGINT charge_count TIMESTAMPTZ updated_at } event_outbox { uuid tenant_id bigint_GENERATED_ALWAYS_AS_IDENTITY id "PK" text topic text aggregate_type uuid aggregate_id text idempotency_key smallint payload_version jsonb payload text status integer attempt_count timestamptz available_at uuid claim_token timestamptz claim_expires_at text last_error timestamptz delivered_at timestamptz created_at timestamptz updated_at } account_plan_assignments { UUID tenant_id "FK" UUID account_id "PK,FK" UUID assignment_id UUID plan_id "FK" UUID catalog_revision_id "FK" TEXT plan_key "FK" BOOLEAN catalog_revision_pinned TEXT source_type UUID source_id TIMESTAMPTZ starts_at TIMESTAMPTZ ends_at TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } account_plan_assignment_history { uuid tenant_id bigint_GENERATED_ALWAYS_AS_IDENTITY id "PK" uuid assignment_id uuid account_id uuid plan_id uuid catalog_revision_id text plan_key boolean catalog_revision_pinned text source_type uuid source_id timestamptz starts_at timestamptz ends_at timestamptz replaced_at text replacement_reason } plan_assignment_changes { uuid tenant_id bigint_GENERATED_ALWAYS_AS_IDENTITY id "PK" uuid account_id uuid from_plan_id uuid to_plan_id text change_kind boolean pin_overridden text strategy timestamptz effective_at text state text reason text error_message timestamptz created_at timestamptz applied_at } allowance_windows { UUID tenant_id "FK" UUID id "PK" UUID account_id "FK,UK" UUID plan_id "FK,UK" UUID catalog_revision_id "FK,UK" TEXT allowance_key "UK" TIMESTAMPTZ window_start "UK" TIMESTAMPTZ window_end "UK" TEXT period_unit INTEGER period_count TEXT period_anchor TEXT period_timezone NUMERIC20 allowance NUMERIC20 reserved NUMERIC20 consumed JSONB policy_snapshot } quota_windows { UUID tenant_id "FK" UUID id "PK" UUID account_id "FK,UK" UUID plan_id "FK,UK" UUID catalog_revision_id "FK,UK" TEXT quota_key "UK" TEXT operation_key TEXT measure_key TIMESTAMPTZ window_start "UK" TIMESTAMPTZ window_end "UK" NUMERIC20 quota_limit NUMERIC20 reserved NUMERIC20 consumed TEXT enforcement JSONB policy_snapshot TIMESTAMPTZ created_at } quota_usage_events { UUID tenant_id "FK" UUID id "PK" UUID account_id "FK,UK" UUID plan_id "FK" UUID catalog_revision_id "FK" UUID catalog_quota_id "FK,UK" TEXT quota_key TEXT operation_key TEXT measure_key NUMERIC20 amount TIMESTAMPTZ event_at UUID usage_charge_id "FK" UUID correction_of_event_id "FK" TEXT idempotency_key "UK" BYTEA request_digest JSONB metadata TIMESTAMPTZ created_at } quota_events { uuid tenant_id uuid id "PK" uuid quota_window_id uuid usage_charge_id text event_type integer threshold_percent text idempotency_key timestamptz created_at } credit_leases { uuid tenant_id uuid id "PK" uuid account_id text operation text feature jsonb measures jsonb dimensions jsonb policy_snapshot jsonb metadata uuid catalog_revision_id uuid plan_id numeric reserved_amount numeric reserved_allowance uuid allowance_window_id numeric minimum_balance integer max_concurrent timestamptz expires_at bursar status text idempotency_key bytea request_digest numeric settled_amount text settlement_idempotency_key bytea settlement_request_digest uuid ledger_entry_id uuid usage_charge_id timestamptz created_at timestamptz updated_at } credit_lease_quota_reservations { UUID tenant_id "FK" UUID lease_id "PK,FK" UUID catalog_quota_id "PK,FK" UUID quota_window_id "FK" NUMERIC20 amount TIMESTAMPTZ window_start TIMESTAMPTZ window_end TIMESTAMPTZ released_at TIMESTAMPTZ created_at } credit_teams { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK,UK" TEXT name TEXT creation_idempotency_key "UK" BYTEA creation_request_digest TIMESTAMPTZ created_at } credit_team_members { UUID tenant_id "FK" UUID team_id "PK,FK" UUID subject_id "PK,FK" TEXT role NUMERIC20 spend_cap TIMESTAMPTZ created_at TIMESTAMPTZ left_at } credit_team_usage_charges { UUID tenant_id "FK" UUID id "PK" UUID team_id "FK,UK" UUID subject_id "FK" UUID ledger_entry_id "FK" TEXT operation NUMERIC20 amount JSONB metadata TEXT idempotency_key "UK" BYTEA request_digest TIMESTAMPTZ created_at } grant_program_events { UUID tenant_id "FK,UK" UUID id "PK" UUID catalog_revision_id "FK" UUID grant_program_id "FK" TEXT program_key "UK" UUID subject_id "FK,UK" TEXT event_key TEXT idempotency_scope TEXT idempotency_key "UK" UUID referrer_subject_id "FK" JSONB metadata TIMESTAMPTZ occurred_at TIMESTAMPTZ created_at } grant_award_executions { UUID tenant_id "FK" UUID id "PK" UUID grant_event_id "FK,UK" UUID catalog_grant_award_id "FK,UK" UUID catalog_revision_id "FK" UUID recipient_subject_id "FK" UUID ledger_entry_id "FK" TIMESTAMPTZ granted_at } credit_plan_migrations { UUID tenant_id "FK" UUID id "PK" UUID from_plan_id "FK" UUID to_plan_id "FK" TEXT strategy TIMESTAMPTZ effective_at UUID cursor_account_id INTEGER migrated_count TEXT status TEXT last_error TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } billing_customers { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK,UK" TEXT provider "UK" TEXT provider_environment "UK" TEXT provider_customer_id "UK" TEXT email JSONB metadata TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } billing_subscriptions { uuid tenant_id uuid id "PK" uuid subject_id text provider text provider_environment text provider_subscription_id text provider_customer_id uuid offer_id uuid catalog_revision_id bursar status timestamptz current_period_start timestamptz current_period_end timestamptz trial_end timestamptz cancel_at boolean cancel_at_period_end timestamptz ended_at timestamptz grace_ends_at timestamptz grace_expired_at timestamptz provider_updated_at timestamptz status_changed_at jsonb metadata timestamptz created_at timestamptz updated_at } billing_entitlement_sources { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK,UK" TEXT provider_environment "FK,UK" UUID subscription_id "FK,UK" BOOLEAN selected TIMESTAMPTZ selected_at TIMESTAMPTZ deselected_at TIMESTAMPTZ created_at } billing_payments { uuid tenant_id uuid id "PK" uuid subject_id text provider text provider_environment text provider_payment_id text provider_invoice_id bigint amount_minor bigint tax_minor text currency text purpose bursar status timestamptz provider_updated_at timestamptz status_changed_at jsonb metadata timestamptz created_at timestamptz updated_at } billing_events { uuid tenant_id uuid id "PK" text provider text provider_environment text provider_event_id text event_type bytea envelope_digest timestamptz payload_received_at timestamptz payload_archived_at text payload_object_key text payload_object_version bursar status integer attempt_count uuid claim_token timestamptz claim_expires_at text last_error timestamptz completed_at timestamptz created_at timestamptz updated_at } billing_event_payloads { UUID tenant_id "FK" UUID event_id "PK,FK" TIMESTAMPTZ received_at "PK" JSONB envelope TIMESTAMPTZ created_at } billing_subscription_conflicts { uuid tenant_id bigint_GENERATED_ALWAYS_AS_IDENTITY id "PK" uuid subject_id text provider text provider_environment text duplicate_provider_subscription_id uuid existing_subscription_id uuid billing_event_id jsonb metadata timestamptz created_at } billing_credit_grants { uuid tenant_id uuid id "PK" uuid payment_id uuid subject_id uuid topup_id uuid subscription_id uuid catalog_revision_id text grant_key numeric configured_credits integer quantity jsonb expiry_policy_snapshot uuid ledger_entry_id uuid billing_event_id timestamptz created_at } billing_refunds { UUID tenant_id "FK,UK" UUID id "PK" UUID payment_id "FK" TEXT provider "UK" TEXT provider_environment "UK" TEXT provider_refund_id "UK" BIGINT amount_minor TEXT currency TEXT status TEXT reason JSONB metadata TIMESTAMPTZ provider_updated_at TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } billing_refund_grants { UUID tenant_id "FK" UUID refund_id "PK,FK" UUID grant_id "PK,FK" BIGINT amount_minor NUMERIC20 credit_amount UUID ledger_entry_id "FK" } billing_subscription_changes { uuid tenant_id bigint_GENERATED_ALWAYS_AS_IDENTITY id "PK" uuid subscription_id text state uuid from_offer_id uuid from_catalog_revision_id uuid to_offer_id uuid to_catalog_revision_id timestamptz effective_at text effective_behavior text proration_behavior text provider_operation_id text idempotency_key text error_message timestamptz created_at timestamptz updated_at } billing_auto_recharge_profiles { UUID tenant_id "PK,FK" UUID subject_id "PK,FK" BOOLEAN enabled BOOLEAN armed TEXT state TEXT provider TEXT provider_environment "PK" UUID catalog_revision_id "FK" UUID topup_id "FK" INTEGER quantity NUMERIC20 threshold NUMERIC20 rearm_above INTEGER max_charges_per_window BIGINT max_charge_minor INTEGER cooldown_seconds INTEGER max_consecutive_failures INTEGER consecutive_failures TEXT window_unit INTEGER window_count TEXT window_anchor TEXT window_timezone TIMESTAMPTZ last_attempt_at TIMESTAMPTZ updated_at } billing_auto_recharge_attempts { uuid tenant_id uuid id "PK" uuid subject_id text provider text provider_environment text idempotency_key bursar state uuid topup_id uuid catalog_revision_id integer quantity timestamptz window_start timestamptz window_end bigint quoted_amount_minor text currency text provider_attempt_id text failure_code text failure_message jsonb metadata timestamptz created_at timestamptz updated_at } catalog_auto_recharge_policies { UUID tenant_id "FK" UUID catalog_revision_id "PK,FK" TEXT eligible_topup_keys TEXT default_topup_key "FK" INTEGER quantity_min INTEGER quantity_max INTEGER quantity NUMERIC20 balance_min NUMERIC20 balance_max NUMERIC20 balance_below NUMERIC20 rearm_above INTEGER max_purchases BIGINT max_charge_minor INTEGER cooldown_seconds INTEGER max_consecutive_failures TEXT failure_action TEXT period_unit INTEGER period_count TEXT period_anchor TEXT period_timezone JSONB definition } billing_checkout_intents { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK,UK" TEXT provider "UK" TEXT provider_environment "UK" TEXT checkout_kind TEXT product_key TEXT region UUID catalog_revision_id "FK" TEXT operation_key "UK" BYTEA request_digest TEXT status TEXT provider_session_id TEXT checkout_url TIMESTAMPTZ expires_at TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } billing_invoices { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK" TEXT provider "FK,UK" TEXT provider_environment "FK,UK" TEXT provider_invoice_id "UK" UUID subscription_id "FK" TEXT status BIGINT amount_due_minor BIGINT amount_paid_minor TEXT currency TIMESTAMPTZ period_start TIMESTAMPTZ period_end TIMESTAMPTZ provider_updated_at JSONB metadata TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } billing_disputes { UUID tenant_id "FK,UK" UUID id "PK" UUID subject_id "FK" TEXT provider "FK,UK" TEXT provider_environment "FK,UK" TEXT provider_dispute_id "UK" UUID payment_id "FK" TEXT status TEXT reason TIMESTAMPTZ provider_updated_at JSONB metadata TIMESTAMPTZ created_at TIMESTAMPTZ updated_at } billing_preferences { UUID tenant_id "PK,FK" UUID subject_id "PK,FK" BOOLEAN auto_recharge BOOLEAN overage_protection BOOLEAN email_notifications BOOLEAN usage_alerts BOOLEAN invoice_reminders TIMESTAMPTZ updated_at } tenants ||--o{ subjects : "tenant_id to id" tenants ||--|| external_identities : "tenant_id to id" subjects ||--o{ external_identities : "subject_id to id" tenants ||--o{ tenant_catalog_counters : "tenant_id to id" tenants ||--o{ catalog_operations : "tenant_id to id" catalog_revisions ||--|| catalog_operations : "catalog_revision_id to id" tenants ||--o{ catalog_credit_policies : "tenant_id to id" catalog_revisions ||--|| catalog_credit_policies : "catalog_revision_id to id" tenants ||--o{ catalog_admission_policies : "tenant_id to id" catalog_revisions ||--|| catalog_admission_policies : "catalog_revision_id to id" tenants ||--o{ catalog_admission_operation_policies : "tenant_id to id" catalog_operations ||--o{ catalog_admission_operation_policies : "catalog_revision_id to catalog_revision_id" catalog_admission_policies ||--o{ catalog_admission_operation_policies : "admission_policy_key to policy_key" catalog_operations ||--o{ catalog_admission_operation_policies : "operation_key to operation_key" tenants ||--o{ catalog_entitlement_features : "tenant_id to id" catalog_revisions ||--|| catalog_entitlement_features : "catalog_revision_id to id" tenants ||--o{ catalog_plans : "tenant_id to id" catalog_buckets ||--|| catalog_plans : "catalog_revision_id to catalog_revision_id" catalog_rate_cards ||--o{ catalog_plans : "rate_card to rate_card_key" catalog_credit_policies ||--o{ catalog_plans : "credit_policy_key to policy_key" catalog_admission_policies ||--o{ catalog_plans : "admission_policy_key to policy_key" catalog_buckets ||--o{ catalog_plans : "credit_allowance_bucket to bucket_key" tenants ||--o{ catalog_plan_features : "tenant_id to id" catalog_entitlement_features ||--o{ catalog_plan_features : "catalog_revision_id to catalog_revision_id" catalog_plans ||--o{ catalog_plan_features : "plan_key to plan_key" catalog_entitlement_features ||--o{ catalog_plan_features : "feature_key to feature_key" tenants ||--o{ catalog_plan_quotas : "tenant_id to id" catalog_operations ||--|| catalog_plan_quotas : "catalog_revision_id to catalog_revision_id" catalog_plans ||--|| catalog_plan_quotas : "plan_key to plan_key" catalog_operations ||--o{ catalog_plan_quotas : "operation_key to operation_key" tenants ||--o{ catalog_grant_programs : "tenant_id to id" catalog_revisions ||--|| catalog_grant_programs : "catalog_revision_id to id" tenants ||--o{ catalog_grant_awards : "tenant_id to id" catalog_buckets ||--|| catalog_grant_awards : "catalog_revision_id to catalog_revision_id" catalog_grant_programs ||--|| catalog_grant_awards : "grant_program_id to id" catalog_buckets ||--o{ catalog_grant_awards : "bucket_key to bucket_key" tenants ||--o{ catalog_offers : "tenant_id to id" catalog_buckets ||--|| catalog_offers : "catalog_revision_id to catalog_revision_id" catalog_plans ||--o{ catalog_offers : "plan_key to plan_key" catalog_buckets ||--o{ catalog_offers : "cycle_grant_bucket_key to bucket_key" tenants ||--o{ catalog_topups : "tenant_id to id" catalog_buckets ||--|| catalog_topups : "catalog_revision_id to catalog_revision_id" catalog_buckets ||--o{ catalog_topups : "bucket_key to bucket_key" tenants ||--o{ catalog_provider_refs : "tenant_id to id" catalog_revisions ||--|| catalog_provider_refs : "catalog_revision_id to id" tenants ||--|| credit_accounts : "tenant_id to id" subjects ||--|| credit_accounts : "subject_id to id" tenants ||--o{ credit_lots : "tenant_id to id" credit_accounts ||--o{ credit_lots : "account_id to id" credit_ledger_entries ||--o{ credit_lots : "source_entry_id to id" catalog_buckets ||--o{ credit_lots : "catalog_revision_id to catalog_revision_id" catalog_buckets ||--o{ credit_lots : "bucket_key to bucket_key" tenants ||--o{ credit_lot_sources : "tenant_id to id" credit_lots ||--o{ credit_lot_sources : "lot_id to id" credit_ledger_entries ||--o{ credit_lot_sources : "ledger_entry_id to id" tenants ||--o{ credit_lot_allocations : "tenant_id to id" credit_ledger_entries ||--|| credit_lot_allocations : "debit_entry_id to id" credit_lots ||--|| credit_lot_allocations : "lot_id to id" tenants ||--o{ credit_lot_source_allocations : "tenant_id to id" credit_lot_allocations ||--|| credit_lot_source_allocations : "lot_allocation_id to id" credit_lot_sources ||--|| credit_lot_source_allocations : "lot_source_id to id" tenants ||--o{ credit_lot_restorations : "tenant_id to id" credit_ledger_entries ||--|| credit_lot_restorations : "refund_entry_id to id" credit_lot_allocations ||--|| credit_lot_restorations : "original_allocation_id to id" credit_lots ||--o{ credit_lot_restorations : "lot_id to id" tenants ||--o{ credit_lot_source_restorations : "tenant_id to id" credit_lot_restorations ||--|| credit_lot_source_restorations : "lot_restoration_id to id" credit_lot_source_allocations ||--|| credit_lot_source_restorations : "source_allocation_id to id" tenants ||--o{ credit_unallocated_debits : "tenant_id to id" credit_ledger_entries ||--o{ credit_unallocated_debits : "ledger_entry_id to id" credit_accounts ||--o{ credit_unallocated_debits : "account_id to id" tenants ||--o{ credit_debt_repayments : "tenant_id to id" credit_ledger_entries ||--o{ credit_debt_repayments : "ledger_entry_id to id" credit_accounts ||--o{ credit_debt_repayments : "account_id to id" tenants ||--o{ usage_charge_payloads : "tenant_id to id" credit_usage_charges ||--o{ usage_charge_payloads : "charge_id to id" tenants ||--o{ usage_daily_rollups : "tenant_id to id" credit_accounts ||--o{ usage_daily_rollups : "account_id to id" tenants ||--o{ account_plan_assignments : "tenant_id to id" credit_accounts ||--o{ account_plan_assignments : "account_id to id" catalog_plans ||--o{ account_plan_assignments : "plan_id to id" catalog_plans ||--o{ account_plan_assignments : "catalog_revision_id to catalog_revision_id" catalog_plans ||--o{ account_plan_assignments : "plan_key to plan_key" tenants ||--o{ allowance_windows : "tenant_id to id" credit_accounts ||--|| allowance_windows : "account_id to id" catalog_plans ||--|| allowance_windows : "plan_id to id" catalog_plans ||--|| allowance_windows : "catalog_revision_id to catalog_revision_id" tenants ||--o{ quota_windows : "tenant_id to id" credit_accounts ||--|| quota_windows : "account_id to id" catalog_plans ||--|| quota_windows : "plan_id to id" catalog_plans ||--|| quota_windows : "catalog_revision_id to catalog_revision_id" tenants ||--o{ quota_usage_events : "tenant_id to id" credit_accounts ||--|| quota_usage_events : "account_id to id" catalog_plans ||--o{ quota_usage_events : "plan_id to id" catalog_plans ||--o{ quota_usage_events : "catalog_revision_id to catalog_revision_id" catalog_plan_quotas ||--|| quota_usage_events : "catalog_quota_id to id" credit_usage_charges ||--o{ quota_usage_events : "usage_charge_id to id" quota_usage_events ||--o{ quota_usage_events : "correction_of_event_id to id" tenants ||--o{ credit_lease_quota_reservations : "tenant_id to id" credit_leases ||--o{ credit_lease_quota_reservations : "lease_id to id" catalog_plan_quotas ||--o{ credit_lease_quota_reservations : "catalog_quota_id to id" quota_windows ||--o{ credit_lease_quota_reservations : "quota_window_id to id" tenants ||--|| credit_teams : "tenant_id to id" subjects ||--|| credit_teams : "subject_id to id" tenants ||--o{ credit_team_members : "tenant_id to id" credit_teams ||--o{ credit_team_members : "team_id to id" subjects ||--o{ credit_team_members : "subject_id to id" tenants ||--o{ credit_team_usage_charges : "tenant_id to id" credit_team_members ||--|| credit_team_usage_charges : "team_id to team_id" credit_team_members ||--o{ credit_team_usage_charges : "subject_id to subject_id" credit_ledger_entries ||--o{ credit_team_usage_charges : "ledger_entry_id to id" tenants ||--|| grant_program_events : "tenant_id to id" catalog_grant_programs ||--o{ grant_program_events : "catalog_revision_id to catalog_revision_id" catalog_grant_programs ||--o{ grant_program_events : "grant_program_id to id" subjects ||--|| grant_program_events : "subject_id to id" subjects ||--o{ grant_program_events : "referrer_subject_id to id" tenants ||--o{ grant_award_executions : "tenant_id to id" grant_program_events ||--|| grant_award_executions : "grant_event_id to id" catalog_grant_awards ||--|| grant_award_executions : "catalog_grant_award_id to id" catalog_grant_awards ||--o{ grant_award_executions : "catalog_revision_id to catalog_revision_id" subjects ||--o{ grant_award_executions : "recipient_subject_id to id" credit_ledger_entries ||--o{ grant_award_executions : "ledger_entry_id to id" tenants ||--o{ credit_plan_migrations : "tenant_id to id" catalog_plans ||--o{ credit_plan_migrations : "from_plan_id to id" catalog_plans ||--o{ credit_plan_migrations : "to_plan_id to id" tenants ||--|| billing_customers : "tenant_id to id" subjects ||--|| billing_customers : "subject_id to id" tenants ||--|| billing_entitlement_sources : "tenant_id to id" billing_subscriptions ||--|| billing_entitlement_sources : "subject_id to subject_id" billing_subscriptions ||--|| billing_entitlement_sources : "provider_environment to provider_environment" billing_subscriptions ||--|| billing_entitlement_sources : "subscription_id to id" tenants ||--o{ billing_event_payloads : "tenant_id to id" billing_events ||--o{ billing_event_payloads : "event_id to id" tenants ||--|| billing_refunds : "tenant_id to id" billing_payments ||--o{ billing_refunds : "payment_id to id" tenants ||--o{ billing_refund_grants : "tenant_id to id" billing_refunds ||--o{ billing_refund_grants : "refund_id to id" billing_credit_grants ||--o{ billing_refund_grants : "grant_id to id" credit_ledger_entries ||--o{ billing_refund_grants : "ledger_entry_id to id" tenants ||--o{ billing_auto_recharge_profiles : "tenant_id to id" subjects ||--o{ billing_auto_recharge_profiles : "subject_id to id" catalog_topups ||--o{ billing_auto_recharge_profiles : "catalog_revision_id to catalog_revision_id" catalog_topups ||--o{ billing_auto_recharge_profiles : "topup_id to id" tenants ||--o{ catalog_auto_recharge_policies : "tenant_id to id" catalog_topups ||--o{ catalog_auto_recharge_policies : "catalog_revision_id to catalog_revision_id" catalog_topups ||--o{ catalog_auto_recharge_policies : "default_topup_key to topup_key" tenants ||--|| billing_checkout_intents : "tenant_id to id" subjects ||--|| billing_checkout_intents : "subject_id to id" catalog_revisions ||--o{ billing_checkout_intents : "catalog_revision_id to id" tenants ||--|| billing_invoices : "tenant_id to id" billing_subscriptions ||--o{ billing_invoices : "subject_id to subject_id" billing_subscriptions ||--|| billing_invoices : "provider to provider" billing_subscriptions ||--|| billing_invoices : "provider_environment to provider_environment" billing_subscriptions ||--o{ billing_invoices : "subscription_id to id" tenants ||--|| billing_disputes : "tenant_id to id" billing_payments ||--o{ billing_disputes : "subject_id to subject_id" billing_payments ||--|| billing_disputes : "provider to provider" billing_payments ||--|| billing_disputes : "provider_environment to provider_environment" billing_payments ||--o{ billing_disputes : "payment_id to id" tenants ||--o{ billing_preferences : "tenant_id to id" subjects ||--o{ billing_preferences : "subject_id to id" ``` --- ## Bursar CLI reference The `bursar` CLI is the operator interface for migrations, tenant lifecycle, and the canonical configuration. Connection values and the financial environment come from environment variables, never the command line: | Variable | Required for | | ------------------------------- | --------------------------------------------------------------------------------------------------------- | | `BURSAR_MIGRATION_DATABASE_URL` | `migrate`; use a dedicated migration owner or administrator | | `BURSAR_OPERATOR_DATABASE_URL` | `tenant create`, `tenant status`, and the provisioning half of `tenant bootstrap` | | `DATABASE_URL` | Database-backed `config` commands, the config half of `tenant bootstrap`, and the application runtime | | `BURSAR_TENANT_ID` | Database-backed `config` commands and `tenant bootstrap` without `--id`; not local `validate` or `schema` | | `BURSAR_PROVIDER_ENVIRONMENT` | Every database-backed `config` command and `tenant bootstrap`; exactly `live`, `test`, or `sandbox` | `config validate` and `config schema` are local operations. They do not need a database connection, tenant ID, or provider environment. ## Migrate ```bash title="Terminal" export BURSAR_MIGRATION_DATABASE_URL=postgresql://bursar_migrator@db.example.com/bursar bursar migrate ``` The command applies pending ordered SQL files, records checksums, and fails if an already-applied file changed. A second run is a no-op. ## Provision caller principals After the first migration, create separate application and operator logins as the migration owner. Set their passwords with your database provider or secret tooling instead of placing credentials in shell history: ```sql title="PostgreSQL" CREATE ROLE bursar_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; GRANT bursar_client TO bursar_app WITH INHERIT FALSE, SET TRUE; CREATE ROLE bursar_ops LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; GRANT bursar_operator TO bursar_ops WITH INHERIT FALSE, SET TRUE; ``` Give each login exactly one Bursar caller role. The SDK enters `bursar_client` only for tenant-scoped transactions; the tenant commands enter `bursar_operator` only for their operator transaction. Do not use a superuser, `BYPASSRLS` role, Supabase `service_role`, or one dual-role login as the application runtime. ## Tenants Provision tenants with the dedicated operator connection: ```bash title="Terminal" export BURSAR_OPERATOR_DATABASE_URL=postgresql://bursar_ops@db.example.com/bursar bursar tenant create acme \ --id 018f7f5f-7b4a-7000-8000-000000000001 \ --display-name "Acme" ``` `tenant create` generates the UUID when `--id` is omitted and prints it. Change lifecycle state with `tenant status`: ```bash title="Terminal" bursar tenant status 018f7f5f-7b4a-7000-8000-000000000001 suspended bursar tenant status 018f7f5f-7b4a-7000-8000-000000000001 active ``` To initialize an embedded Bursar tenant, provision it and publish its initial config through one idempotent command: ```bash title="Terminal" export DATABASE_URL=postgresql://bursar_app@db.example.com/bursar export BURSAR_TENANT_ID=018f7f5f-7b4a-7000-8000-000000000001 export BURSAR_PROVIDER_ENVIRONMENT=test bursar tenant bootstrap acme ./pricing.yaml \ --display-name "Acme" ``` `tenant bootstrap` validates the config before provisioning. Tenant creation and config publication are each idempotent, so the command is safe to retry after an operational failure. It intentionally needs both the operator and application URLs and an explicit provider environment: provisioning runs as `bursar_operator`, while config publication uses the tenant-scoped `bursar_client` path within that financial namespace. Host applications should use these operator commands instead of writing Bursar-owned tables. ## Config lifecycle Catalog versions are immutable and managed per tenant through `bursar config`. Database-backed subcommands require the application `DATABASE_URL` and `BURSAR_TENANT_ID` plus an explicit `BURSAR_PROVIDER_ENVIRONMENT`. Validate or print the schema locally without any of those values: ```bash title="Terminal" bursar config validate ./pricing.yaml # validate without applying (--json for CI) bursar config schema # print the config JSON Schema ``` Then select the database-backed financial namespace: ```bash title="Terminal" export DATABASE_URL=postgresql://bursar_app@db.example.com/bursar export BURSAR_TENANT_ID=018f7f5f-7b4a-7000-8000-000000000001 export BURSAR_PROVIDER_ENVIRONMENT=test bursar config set ./pricing.yaml # publish + activate a new version (--label, --rollout) bursar config get # print the active version as JSON bursar config list # list all versions (* = active) bursar config activate 3 # switch the active version (--rollout) bursar config pin # hold one current assignment on its revision bursar config pin --unpin # remove that hold bursar config apply-due --limit 100 # apply due renewal-effective changes bursar config export 3 # dump one version as JSON bursar config diff 2 3 # unified diff between two versions ``` `config set` always creates a new version and no-ops when the payload is identical to the active one — the command reports "No changes" and does not churn versions. `validate`, `set`, and `tenant bootstrap` accept `-` to read from stdin. `list` marks the active version with `*` and shows labels and timestamps; `diff` compares two canonicalized versions and `schema` prints the JSON Schema for editor autocompletion and CI validation. `export` takes the version number and dumps that one immutable version as JSON. ### Per-release rollout Each plan's `evolution.default_rollout` controls normal revision adoption. To override selected plans for one catalog publication, save a rollout manifest: ```yaml title="rollout.yaml" plans: pro: effective: next_renewal include_pinned: false ``` Pass it while publishing or activating: ```bash title="Terminal" bursar config set ./pricing.yaml --rollout ./rollout.yaml bursar config activate 3 --rollout ./rollout.yaml ``` `effective` is `immediate`, `next_renewal`, or `new_assignments_only`. `next_renewal` is valid only for a plan referenced by a subscription offer. Pinned assignments are excluded unless `include_pinned` is `true`. Run `config apply-due` from a bounded background job to advance changes whose renewal time has arrived. The CLI deliberately stops at Bursar-owned schema, tenant, and catalog operations. The embedding application owns process supervision, scheduling, database availability and recovery, secret delivery, and every provider or cloud deployment decision. --- ## Credits service `bursar.credits` owns credit balances, usage charging, the lease lifecycle, plans, ledger history, analytics, and team charging. Every method is synchronous, and every monetary amount is a `Decimal` quantized to six decimal places with half-up rounding. The account model is bucket-based: credits live in named buckets (e.g. `promotional` priority 1, `purchased` priority 10) and are consumed highest-priority-first. ## Balances and adjustments ```python from decimal import Decimal grant = bursar.credits.add_credits( user_id, Decimal("100"), entry_type="purchase", idempotency_key="purchase:1" ) charge = bursar.credits.deduct_credits( user_id, Decimal("5"), idempotency_key="operation:1" ) refund = bursar.credits.refund_credits( charge.entry_id, reason="clawback", idempotency_key="refund:operation:1" ) ``` `add_credits` and `deduct_credits` move a raw amount; `refund_credits` reverses a prior entry. Every successful monetary result identifies its canonical ledger row (`entry_id` for adjustments, `refund_entry_id` for refunds). `get_balance`, `get_available`, and `get_bucket_balances` are advisory reads. ### Idempotency `add_credits`, `deduct_credits`, and `refund_credits` each require a caller-stable `idempotency_key`: retrying with the same key replays the original result instead of double-applying. `deduct_credits` threads its key through to the store, so administrative deductions are replay-safe too. The high-volume path, however, is `deduct(metrics, idempotency_key)` — see below. | Method | Parameters | Returns | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | | `add_credits` | `user_id: str`, `amount: Decimal \| int \| str`, `idempotency_key: str`, `entry_type="adjustment"`, `metadata=None`, `expires_at=None`, `bucket=None` | `AddCreditsResult` | | `deduct_credits` | `user_id: str`, `amount: Decimal \| int \| str`, `*, entry_type="adjustment"`, `bucket`, `metadata`, `idempotency_key` | `AddCreditsResult` | | `refund_credits` | `entry_id: str`, `amount=None`, `reason`, `metadata`, `idempotency_key` | `RefundResult` | | `get_balance` | `user_id: str` | `BalanceResult` | | `get_available` | `user_id: str` | `AvailableResult` | | `get_bucket_balances` | `user_id: str` | `BucketBalancesResult` | ## Metered charging `deduct(user_id, metrics, idempotency_key, metadata=None, *, feature=None)` prices a `UsageMetrics` event with the active pricing engine and charges the cost in one atomic, idempotency-keyed transaction. The idempotency key is **the** replay guard for metered usage: the same key returns the same `DeductionResult` instead of double-charging. ```python result = bursar.credits.deduct( user_id, UsageMetrics( operation="completion", measures={"input_tokens": 500, "output_tokens": 200}, dimensions={"model": "gpt-4o"}, ), idempotency_key="request:9f8c", ) ``` `deduct` enforces the user's plan: unentitled operations, allowance limits, and quotas can block the charge. `feature` additionally requires a plan feature. `deduct_flat_job` and `grant_subscription_cycle` are specialized siblings for flat-rate jobs and billing-cycle grants. `revoke_credits_by_entry_type` removes all remaining lots sourced by one matching ledger operation; it is a broad cohort operation, not a single-transaction refund. `sweep_expired_credits(dry_run=False)` expires eligible credit lots. | Method | Parameters | Returns | | ------------------------------ | --------------------------------------------------------------------------------------------------- | --------------------- | | `deduct` | `user_id: str`, `metrics: UsageMetrics`, `idempotency_key: str`, `metadata=None`, `*, feature=None` | `DeductionResult` | | `deduct_flat_job` | `user_id: str`, `job_name: str`, `idempotency_key: str`, `metadata=None`, `feature=None` | `DeductionResult` | | `revoke_credits_by_entry_type` | `user_id: str`, `entry_type: str` | `RevokeCreditsResult` | | `sweep_expired_credits` | `dry_run=False` | `SweepResult` | ## AI and batch usage accounting Bursar records financial events, not workflow execution state. Charge a fixed-price workload once with `deduct_flat_job`; keep its stages, retries, latency, model calls, and parent/child trace structure in your workflow store and OpenTelemetry backend. For dynamically priced inference, use `deduct` when the amount is known up front or a lease when the final provider usage is known only after completion. Each deduction or lease settlement creates a canonical `credit_usage_charges` receipt. Put only bounded values needed for pricing and reconciliation in its measures, dimensions, and metadata. Use `record_usage` only when you intentionally need a priced receipt without another account debit, such as usage billed by an external system; it is not a tracing API. Detailed receipt payloads use monthly PostgreSQL partitions by default and follow the configured retention policy. When ClickHouse is enabled, those payloads and usage analytics are projected there instead. Operational AI telemetry—including prompts, completions, latency, retries, errors, and router decisions—belongs in OpenTelemetry/Langfuse. ## Leases When the final cost is only known after the work runs, reserve the estimate, do the work, then settle the actual cost. `reserve` is the only admission gate; a lease whose TTL expires before settlement is released by the store's reaper. `settle` bills the actual amount and finalizes the lease; `release` returns an unused hold; `renew` extends an active lease without changing its captured policy snapshot. `run_billed(user_id, options)` wraps reserve → work → settle in one call: `options.estimate` prices the hold, `options.do_work` runs the operation and returns `(result, actual)`, and any exception from `do_work` releases the lease and re-raises. Long jobs may call `renew` from inside `do_work`; a crash between reserve and settle is covered by the lease TTL. ```python from bursar.credits.service_types import ReserveOptions, SettleOptions lease = bursar.credits.reserve( user_id, UsageMetrics(operation="completion", measures={"input_tokens": 1000}), ReserveOptions(idempotency_key="run:7:reserve", ttl=300), ) # ... do the work ... bursar.credits.settle( user_id, lease.lease_id, actual_metrics, SettleOptions(idempotency_key="run:7:settle"), ) ``` | Method | Parameters | Returns | | ------------ | -------------------------------------------------------------------------------------- | ----------------- | | `reserve` | `user_id: str`, `metrics_or_amount`, `options: ReserveOptions` | `LeaseResult` | | `settle` | `user_id: str`, `lease_id: str`, `metrics_or_amount`, `options: SettleOptions \| None` | `DeductionResult` | | `release` | `user_id: str`, `lease_id: str` | `ReleaseResult` | | `renew` | `user_id: str`, `lease_id: str`, `ttl: int \| None = None` | `LeaseResult` | | `run_billed` | `user_id: str`, `options: RunBilledOptions` | `RunBilledResult` | `ReserveOptions` carries `idempotency_key`, `operation_type`, `billing_mode`, `ttl`, `metadata`, `feature`, and `model`; `SettleOptions` carries `idempotency_key`, `metadata`, and `feature`. A caller-supplied settlement key is validated like every other replay key; if it is omitted, Bursar derives the stable key from the lease ID because a lease can settle only once. ## Plans `set_user_plan(user_id, plan_key, plan_assigned_at=None)` assigns a plan from the active catalog and emits `credits.plan_changed`. `get_user_plan` returns the current plan. `check_allowance` reports the plan's credit allowance (bucket + window) state, and `check_feature` resolves a feature value against the plan's entitlements. ```python bursar.credits.set_user_plan(user_id, "pro") allowance = bursar.credits.check_allowance(user_id) voice = bursar.credits.check_feature(user_id, "voice_mode") ``` | Method | Parameters | Returns | | ----------------- | -------------------------------------------------------- | -------------------- | | `set_user_plan` | `user_id: str`, `plan_key: str`, `plan_assigned_at=None` | `SetUserPlanResult` | | `get_user_plan` | `user_id: str` | `GetUserPlanResult` | | `check_allowance` | `user_id: str` | `AllowanceResult` | | `check_feature` | `user_id: str`, `feature: str` | `CheckFeatureResult` | ## Ledger and usage charges All history views share one cursor contract: stable `(created_at, entry_id)` ordering, cursor-only pagination, and no offset support. `list_ledger_entries` is the full account ledger; `list_usage_entries` filters it to usage entries; `list_usage_charges` returns metered usage charges (including allowance-covered events). ```python page = bursar.credits.list_ledger_entries(user_id, limit=50) entry = bursar.credits.get_ledger_entry(user_id, page.items[0].entry_id) while page.next_cursor: page = bursar.credits.list_ledger_entries( user_id, limit=50, cursor=page.next_cursor ) ``` | Method | Parameters | Returns | | --------------------- | ----------------------------------------------------------------------------------------------- | --------------------- | | `list_ledger_entries` | `user_id: str`, `entry_types=None`, `from_date=None`, `to_date=None`, `limit=50`, `cursor=None` | `LedgerPage` | | `get_ledger_entry` | `user_id: str`, `entry_id: str` | `LedgerEntry \| None` | | `list_usage_entries` | `user_id: str`, `from_date=None`, `to_date=None`, `limit=50`, `cursor=None` | `LedgerPage` | | `list_usage_charges` | `user_id: str`, `from_date=None`, `to_date=None`, `limit=50`, `cursor=None` | `UsageChargePage` | ## Analytics Usage analytics aggregate across all users of the tenant within a `[start, end)` window. These are optional store capabilities — custom stores that omit them raise `CapabilityNotSupportedError`. | Method | Parameters | Returns | | ----------------- | ------------------------------------------------ | ----------------------- | | `spend_by_user` | `start: datetime`, `end: datetime` | `list[SpendByUserRow]` | | `spend_by_model` | `start: datetime`, `end: datetime` | `list[SpendByModelRow]` | | `top_users` | `limit: int`, `start: datetime`, `end: datetime` | `list[TopUserRow]` | | `daily_spend` | `start: datetime`, `end: datetime` | `list[DailySpendRow]` | | `aggregate_stats` | `start: datetime`, `end: datetime` | `AggregateStats` | ## Teams Team management is a store-level capability: `create_team`, `get_team_balance`, `add_team_member`, `remove_team_member`, and `list_team_members` live on the `CreditStore` (call them on your store object), while `bursar.credits.deduct_team` charges the shared pool through the facade. ```python store.create_team(team_id, display_name="Acme") store.add_team_member(team_id, user_id) bursar.credits.deduct_team( team_id, user_id, UsageMetrics(operation="execution", measures={"jobs": 1}, dimensions={"model": "gpt-4o"}), idempotency_key="team:run:3", ) ``` | Method | Parameters | Returns | | ------------- | ------------------------------------------------------------------------------------------------ | --------------------- | | `deduct_team` | `team_id: str`, `user_id: str`, `metrics: UsageMetrics`, `idempotency_key: str`, `metadata=None` | `TeamDeductionResult` | See [Credit lifecycle](/docs/guides/credit-lifecycle) for the full account model, and [Leases and financial safety](/docs/guides/financial-safety) for lease guarantees. --- ## Python API Python 3.12 and 3.13 are supported. Install the Postgres backend with: ```bash title="Terminal" pip install "bursar[postgres]" ``` ## Constructing the facade `Bursar` wires a store, the optional billing/commerce capabilities, and an optional event emitter into one application-facing facade: ```python from bursar import Bursar, PostgresStore store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment="test", ) bursar = Bursar(credit_store=store) ``` - `credit_store` is the only required argument. `PostgresStore` is the bundled implementation; `CreditStore` is the abstract base for custom backends. - `tenant_id` scopes every store operation to a single tenant. - `billing_store` plus `commerce_options` enable the billing and commerce capabilities (`ingest_billing_event`, checkouts, subscriptions, auto-recharge). - `emitter` receives credit lifecycle events (`CreditEventEmitter`). Apply the database schema with `bursar migrate` before constructing a store — the package never runs migrations from the store itself. ## Errors, deadlines, and retries All Bursar-classified failures extend `BursarError` and expose stable `code`, `category`, and `retryable` fields. `to_dict()` returns the safe logging shape without exposing the underlying driver exception. Use the Tenacity-backed retry helper only for reads or mutations protected by a stable idempotency key: ```python from bursar import BursarRetryOptions, is_bursar_error, retry_bursar_operation try: balance = retry_bursar_operation( bursar.credits.get_balance, user_id, retry_options=BursarRetryOptions(max_attempts=3), ) except Exception as error: if is_bursar_error(error): logger.error("Bursar request failed", extra={"bursar": error.to_dict()}) raise ``` `StoreError.indeterminate` means a transport failure occurred after a mutation may have reached PostgreSQL. Retry only with the same idempotency key. Custom stores should use `StoreUnavailableError` or `StoreTimeoutError` for classified transient failures rather than matching error-message text. ## Package layout The `bursar` top level exposes the application-facing facade, stores, pricing engine, common inputs, errors, and retry helpers. Domain-specific result and provider types remain grouped under focused modules such as `bursar.credits.types` and `bursar.providers`. | Export | Purpose | | ----------------------- | ------------------------------------------------------------------------------------------- | | `Bursar` | The facade: `credits`, `catalog`, `accounts`, plus optional `billing`/`commerce` | | `PostgresStore` | Production, tenant-scoped credit store (requires the `[postgres]` extra) | | `PostgresBillingStore` | Billing store for payment-provider lifecycle (requires the `[postgres]` extra) | | `PricingEngine` | Database-free operation-pricing core | | `CreditStore` | Abstract base class for custom credit backends | | `BillingStore` | Abstract base class for custom billing backends | | `UsageMetrics` | One billable operation (measures + dimensions) | | `load_config_from_dict` | Validate and canonicalize a config document | | errors | `BursarError`, `CreditError`, `ConfigError`, `StoreError`, `CapabilityNotSupportedError`, … | | retry helpers | `BursarRetryOptions`, `retry_bursar_operation`, and its async counterpart | `PostgresStore` and `PostgresBillingStore` are lazy-imported: they are only available when the optional `psycopg2` extra is installed. ## Where to go next - [Credits service](/docs/python-api/credit-manager) — balances, metered charging, leases, plans, ledger, analytics, teams - [PricingEngine](/docs/python-api/pricing-engine) — pricing without a database - [Stores](/docs/python-api/stores) — `PostgresStore`, `PostgresBillingStore`, and the `CreditStore` contract - [API reference](/docs/python-api/reference) — generated symbol reference - [Concepts](/docs/concepts) and [configuration](/docs/concepts/configuration) — the canonical config document --- ## PricingEngine `PricingEngine` is Bursar's database-free operation-pricing core. It validates the same canonical configuration as the rest of the SDK and prices `UsageMetrics` events without any store, connection, or facade. ## Creating an engine ```python from bursar import PricingEngine engine = PricingEngine.from_dict(config_dict) ``` `from_dict` accepts a canonical config document and returns an engine, or raises `ConfigError`. Unknown fields, undeclared measures or dimensions, invalid matcher types, and unsafe cross-references are rejected at construction. Credit accounting is a fixed Bursar convention rather than a configuration field; plan rank has an authoring default. The engine is safe to construct from a config you already validated with [`load_config_from_dict`](/docs/python-api#package-layout). ## Calculating cost ```python from bursar import UsageMetrics cost = engine.calculate( UsageMetrics( operation="completion", measures={"input_tokens": 500, "output_tokens": 200}, dimensions={"model": "gpt-4o"}, ) ) print(cost.total) ``` `calculate(metrics, *, rate_card=None)` prices one event. The optional `rate_card` key is required when the config contains more than one rate card and the caller has not otherwise selected one (via `get_rate_card_for_plan`). `calculate_batch(metrics, *, rate_card=None)` evaluates a list of `UsageMetrics` with the same rate-card selection and returns a `list[CostBreakdown]` in the same order. `get_rate_card_for_plan(plan_id)` returns the rate-card key referenced by a configured plan — the canonical way to resolve the rate card for a user whose plan is known. `pricing_schema` returns the validated, canonicalized config as a dict. ## Example Using the canonical demo configuration (`pricing.operations.completion`, rate card `standard`, per-million-token rates for `gpt-4o`/`gpt-4o-mini`, and an expression-based fallback for unmatched models): ```python from decimal import Decimal config = { "version": 1, "pricing": { "operations": { "completion": { "measures": { "input_tokens": {"unit": "token"}, "output_tokens": {"unit": "token"}, "cache_read_tokens": {"unit": "token"}, }, "dimensions": {"model": {"type": "string"}}, } }, "rate_cards": { "standard": { "operations": { "completion": { "rules": [ { "when": {"model": {"op": "in", "values": ["gpt-4o", "gpt-4o-mini"]}}, "charge": { "type": "sum", "components": [ {"type": "per_unit", "measure": "input_tokens", "rate": "0.0025", "unit_size": "1000000"}, {"type": "per_unit", "measure": "output_tokens", "rate": "0.0100", "unit_size": "1000000"}, {"type": "per_unit", "measure": "cache_read_tokens", "rate": "0.00125", "unit_size": "1000000"}, ], }, } ], "unmatched": { "action": "charge", "charge": { "type": "expression", "formula": "input_tokens * 0.005 + output_tokens * 0.015", }, }, } } } }, }, "credits": {}, } engine = PricingEngine.from_dict(config) cost = engine.calculate( UsageMetrics( operation="completion", measures={"input_tokens": 2_000_000, "output_tokens": 500_000}, dimensions={"model": "gpt-4o"}, ) ) assert cost.total == Decimal("0.01") # 2,000,000 × 0.0025/M + 500,000 × 0.0100/M ``` ## UsageMetrics | Field | Type | Meaning | | ------------ | ----------------------------------- | ---------------------------------------------------------- | | `operation` | `str` | A key declared in `pricing.operations` | | `measures` | `dict[str, Decimal]` | Non-negative billable quantities declared by the operation | | `dimensions` | `dict[str, str \| Decimal \| bool]` | Typed values used to select the first matching rate rule | | `metadata` | `dict[str, Any]` | Caller metadata; it does not participate in pricing | Declared measures omitted by the caller are treated as zero. Required dimensions must be present. Undeclared measures or dimensions are rejected. ## CostBreakdown All monetary fields are `Decimal` values quantized to six decimal places with half-up rounding. Operation pricing is reported in `operation_credits`; `total` is the complete quantized result. | Field | Type | | ------------------- | ---------------- | | `operation_credits` | `Decimal` | | `model_credits` | `Decimal` | | `tool_credits` | `Decimal` | | `search_credits` | `Decimal` | | `cache_savings` | `Decimal` | | `fixed_credits` | `Decimal` | | `total` | `Decimal` | | `breakdown` | `dict[str, Any]` | See [Pricing](/docs/concepts/pricing) for how rate cards, rules, and unmatched actions compose, and [Expressions](/docs/concepts/expressions) for the supported expression operators and functions. --- ## Stores Every `Bursar` facade is built on a store — the adapter that owns the database. `PostgresStore` is the only bundled credit store; `PostgresBillingStore` is the bundled billing store; `CreditStore` is the abstract contract a custom backend must implement. ## PostgresStore ```python from bursar import PostgresStore store = PostgresStore( database_url, tenant_id=tenant_id, provider_environment="test", ) ``` Connects through `psycopg2` and runs every mutation as a canonical SQL function (RPC). Requires the `bursar[postgres]` extra (`pip install "bursar[postgres]"`). | Parameter | Type | Meaning | | ---------------------- | -------------------------------- | ----------------------------------------------------------------- | | `database_url` | `str` | Postgres connection string (positional) | | `tenant_id` | `str \| UUID` | Required keyword argument scoping every transaction to one tenant | | `provider_environment` | `"live" \| "test" \| "sandbox"` | Required financial provider namespace | | `max_pool_size` | `int = 20` | Upper bound of the internal `ThreadedConnectionPool` | | `pool` | `ThreadedConnectionPool \| None` | Reuse an existing pool instead of creating one | The store owns its pool: call `store.close()` to drain it. `database_url` is exposed as a read-only property. ## PostgresBillingStore ```python from bursar import PostgresBillingStore billing_store = PostgresBillingStore( database_url, tenant_id=tenant_id, provider_environment="test", ) ``` Same shape: `database_url` (positional), required `tenant_id` and `provider_environment` keywords, and an optional `pool` (defaults to a fresh pool, max 10). It wraps all billing repositories (offer, topup, customer, subscription, event, payment, refund, invoice, dispute, config) behind one interface. Pass it as `billing_store=` to enable billing. Commerce additionally requires `commerce_options=` with the provider environment and factories. The store is lazily imported from the top level and requires the `[postgres]` extra. ## The CreditStore contract `CreditStore` is the abstract base class for a custom backend. Implementations must preserve, for every capability they expose: - **Atomicity** — each mutation commits or rolls back as one unit; balances and their ledger rows never diverge. - **Idempotency** — a replayed `idempotency_key` returns the original result instead of applying a second time. - **Append-only ledger** — history is immutable; corrections are new entries (`adjustment`/`refund`), never updates. - **Credit-lot allocation** — credits are consumed lot-by-lot by priority and expiry (FEFO), with `sweep_expired_credits` expiring eligible lots. - **Stable cursor ordering** — every list view orders by `(created_at, entry_id)` so cursor pagination never skips or duplicates rows. Optional capabilities (usage analytics, team management, usage-charge lists, `get_ledger_entry`) raise `CapabilityNotSupportedError` by default on the ABC; override them if your backend supports them. Billing is a separate hierarchy (`BillingStore`/`PostgresBillingStore`) with its own repositories. ## Migrations The package never applies SQL on construction — neither `PostgresStore` nor `Bursar` runs migrations. Apply the bundled migrations with the `bursar` CLI during application setup, before constructing any store: ```bash title="Terminal" BURSAR_MIGRATION_DATABASE_URL=postgres://... bursar migrate ``` The migration command reads `BURSAR_MIGRATION_DATABASE_URL`. See [CLI](/docs/cli) for the separate tenant, runtime/config, and local-validation credentials. See [Storage backends](/docs/guides/storage-backends) for S3/ClickHouse adapters and the storage runtime, and [custom stores](/docs/notebooks/custom_stores) for a worked example of a custom `CreditStore`. --- ## Credits service(Javascript-api) `bursar.credits` owns credit balances, usage charging, the lease lifecycle, plans, ledger history, analytics, and team charging. Every method is async, and every monetary amount is a `Decimal` quantized to six decimal places with half-up rounding. The account model is bucket-based: credits live in named buckets (e.g. `promotional` priority 1, `purchased` priority 10) and are consumed highest-priority-first. ## Balances and adjustments ```ts const grant = await bursar.credits.addCredits(userId, "100", { type: "purchase", idempotencyKey: "purchase:1", }); const charge = await bursar.credits.deductCredits(userId, "5", { entryType: "adjustment", idempotencyKey: "operation:1", }); const refund = await bursar.credits.refundCredits(charge.entryId, { reason: "clawback", idempotencyKey: "refund:operation:1", }); ``` `addCredits` and `deductCredits` move a raw amount; `refundCredits` reverses a prior entry. Every successful monetary result identifies its canonical ledger row (`entryId` for adjustments, `refundEntryId` for refunds). `getBalance`, `getAvailable`, and `getBucketBalances` are advisory reads. Pass raw credit amounts as decimal strings or `Decimal` instances. Native JavaScript numbers are rejected so financial input cannot arrive through a binary floating-point value. Monetary result fields are `Decimal` instances. ### Idempotency — read carefully All three adjustment methods require a caller-stable `idempotencyKey`: retrying with the same key replays the original result instead of double-applying. | Method | Parameters | Returns | | ------------------- | ------------------------------------------------------------------------ | ------------------------------- | | `addCredits` | `userId: string`, `amount: ExactAmount`, `options: AddCreditsOptions` | `Promise` | | `deductCredits` | `userId: string`, `amount: ExactAmount`, `options: DeductCreditsOptions` | `Promise` | | `refundCredits` | `entryId: string`, `options: RefundCreditsOptions` | `Promise` | | `getBalance` | `userId: string` | `Promise` | | `getAvailable` | `userId: string` | `Promise` | | `getBucketBalances` | `userId: string` | `Promise` | ## Metered charging `deduct(userId, metrics, options)` prices a `UsageMetrics` event with the active pricing engine and charges the cost in one atomic, idempotency-keyed transaction. The idempotency key is **the** replay guard for metered usage: the same key returns the same `DeductionResult` instead of double-charging, so every replayable mutation requires the caller to supply a stable key. ```ts const result = await bursar.credits.deduct( userId, { operation: "completion", measures: { input_tokens: 500, output_tokens: 200 }, dimensions: { model: "gpt-4o" }, }, { idempotencyKey: "request:9f8c" }, ); ``` `deduct` enforces the user's plan: unentitled operations, allowance limits, and quotas can block the charge. `feature` additionally requires a plan feature. `deductFlatJob` and `grantSubscriptionCycle` are specialized siblings for flat-rate jobs and billing-cycle grants. `revokeCreditsByEntryType` removes all remaining lots sourced by one matching ledger operation; it is a broad cohort operation, not a single-transaction refund. `sweepExpiredCredits(dryRun = false)` expires eligible credit lots. | Method | Parameters | Returns | | -------------------------- | -------------------------------------------------------------------- | ------------------------------ | | `deduct` | `userId: string`, `metrics: UsageMetrics`, `options: DeductOptions` | `Promise` | | `deductFlatJob` | `userId: string`, `jobName: string`, `options: DeductFlatJobOptions` | `Promise` | | `revokeCreditsByEntryType` | `userId: string`, `entryType: string` | `Promise` | | `sweepExpiredCredits` | `dryRun = false` | `Promise` | ## AI and batch usage accounting Bursar records financial events, not workflow execution state. Charge a fixed-price workload once with `deductFlatJob`; keep its stages, retries, latency, model calls, and parent/child trace structure in your workflow store and OpenTelemetry backend. For dynamically priced inference, use `deduct` when the amount is known up front or a lease when the final provider usage is known only after completion. Each deduction or lease settlement creates a canonical `credit_usage_charges` receipt. Put only bounded values needed for pricing and reconciliation in its measures, dimensions, and metadata. Use `recordUsage` only when you intentionally need a priced receipt without another account debit, such as usage billed by an external system; it is not a tracing API. Detailed receipt payloads use monthly PostgreSQL partitions by default and follow the configured retention policy. When ClickHouse is enabled, those payloads and usage analytics are projected there instead. Operational AI telemetry—including prompts, completions, latency, retries, errors, and router decisions—belongs in OpenTelemetry/Langfuse. ## Leases When the final cost is only known after the work runs, reserve the estimate, do the work, then settle the actual cost. `reserve` is the only admission gate; a lease whose TTL expires before settlement is released by the store's reaper. `settle` bills the actual amount and finalizes the lease; `release` returns an unused hold; `renew` extends an active lease without changing its captured policy snapshot. `runBilled(userId, options)` wraps reserve → work → settle in one call: `options.estimate` prices the hold, `options.doWork` runs the operation and resolves `{ result, actual }`, and any rejection from `doWork` releases the lease and re-throws. Long jobs may call `renew` from inside `doWork`; a crash between reserve and settle is covered by the lease TTL. ```ts const lease = await bursar.credits.reserve(userId, estimate, { idempotencyKey: "run:7:reserve", ttl: 300, }); // ... do the work ... await bursar.credits.settle(userId, lease.leaseId, actual, { idempotencyKey: "run:7:settle", }); ``` | Method | Parameters | Returns | | ----------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | | `reserve` | `userId: string`, `metricsOrAmount`, `options: ReserveOptions` | `Promise` | | `settle` | `userId: string`, `leaseId: string`, `metricsOrAmount`, `options: SettleOptions \| null` | `Promise` | | `release` | `userId: string`, `leaseId: string` | `Promise` | | `renew` | `userId: string`, `leaseId: string`, `ttl?: number \| null` | `Promise` | | `runBilled` | `userId: string`, `options: RunBilledOptions` | `Promise<{ result: T; deduction: DeductionResult }>` | `ReserveOptions` carries `idempotencyKey`, `operationType`, `billingMode`, `ttl`, `metadata`, `feature`, and `model`; `SettleOptions` carries `idempotencyKey`, `metadata`, and `feature`. A caller-supplied settlement key is validated like every other replay key; if it is omitted, Bursar derives the stable key from the lease ID because a lease can settle only once. ## Plans `setUserPlan(userId, planKey, planAssignedAt?)` assigns a plan from the active catalog and emits `credits.plan_changed`. `getUserPlan` returns the current plan. `checkAllowance` reports the plan's credit allowance (bucket + window) state, and `checkFeature` resolves a feature value against the plan's entitlements. ```ts await bursar.credits.setUserPlan(userId, "pro"); const allowance = await bursar.credits.checkAllowance(userId); const voice = await bursar.credits.checkFeature(userId, "voice_mode"); ``` | Method | Parameters | Returns | | ---------------- | -------------------------------------------------------------------- | ----------------------------- | | `setUserPlan` | `userId: string`, `planKey: string`, `planAssignedAt?: Date \| null` | `Promise` | | `getUserPlan` | `userId: string` | `Promise` | | `checkAllowance` | `userId: string` | `Promise` | | `checkFeature` | `userId: string`, `feature: string` | `Promise` | ## Ledger and usage charges All history views share one cursor contract: stable `(createdAt, entryId)` ordering, cursor-only pagination, and no offset support. `listLedgerEntries` is the full account ledger; `listUsageEntries` filters it to usage entries; `listUsageCharges` returns metered usage charges (including allowance-covered events). ```ts let page = await bursar.credits.listLedgerEntries(userId, { limit: 50 }); const entry = await bursar.credits.getLedgerEntry( userId, page.items[0].entryId, ); while (page.nextCursor) { page = await bursar.credits.listLedgerEntries(userId, { limit: 50, cursor: page.nextCursor, }); } ``` | Method | Parameters | Returns | | ------------------- | ---------------------------------------------------------------------------------- | ------------------------------ | | `listLedgerEntries` | `userId: string`, `options?: { entryTypes?, fromDate?, toDate?, limit?, cursor? }` | `Promise` | | `getLedgerEntry` | `userId: string`, `entryId: string` | `Promise` | | `listUsageEntries` | `userId: string`, `options?: { fromDate?, toDate?, limit?, cursor? }` | `Promise` | | `listUsageCharges` | `userId: string`, `options?: { fromDate?, toDate?, limit?, cursor? }` | `Promise` | ## Analytics Usage analytics aggregate across all users of the tenant within a `[start, end)` window. These are optional store capabilities — custom stores that omit them reject with `CapabilityNotSupportedError`. | Method | Parameters | Returns | | ---------------- | ------------------------------------------- | ---------------------------- | | `spendByUser` | `start: Date`, `end: Date` | `Promise` | | `spendByModel` | `start: Date`, `end: Date` | `Promise` | | `topUsers` | `limit: number`, `start: Date`, `end: Date` | `Promise` | | `dailySpend` | `start: Date`, `end: Date` | `Promise` | | `aggregateStats` | `start: Date`, `end: Date` | `Promise` | ## Teams Team management is a store-level capability: `createTeam`, `getTeamBalance`, `addTeamMember`, `removeTeamMember`, and `listTeamMembers` live on the `CreditStore` (call them on your store object), while `bursar.credits.deductTeam` charges the shared pool through the facade. ```ts await store.createTeam(teamId, { displayName: "Acme" }); await store.addTeamMember(teamId, userId); await bursar.credits.deductTeam( teamId, userId, { operation: "execution", measures: { jobs: 1 }, dimensions: { model: "gpt-4o" }, }, { idempotencyKey: "team:run:3" }, ); ``` | Method | Parameters | Returns | | ------------ | ----------------------------------------------------------------------------------------- | ------------------------------ | | `deductTeam` | `teamId: string`, `userId: string`, `metrics: UsageMetrics`, `options: DeductTeamOptions` | `Promise` | See [Credit lifecycle](/docs/guides/credit-lifecycle) for the full account model, and [Leases and financial safety](/docs/guides/financial-safety) for lease guarantees. --- ## TypeScript API The package is ESM-only and requires Node.js 22 or newer. ```bash npm install @zonastery/bursar pg ``` ## Constructing the facade ```ts import { Bursar, PostgresStore } from "@zonastery/bursar"; const store = new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment: "test", }); const bursar = new Bursar({ creditStore: store }); ``` - `creditStore` is the only required option. `PostgresStore` is the bundled implementation; `CreditStore` is the abstract base for custom backends. - `tenantId` scopes every store operation to a single tenant. - `billingStore` plus `commerceOptions` enable the billing and commerce capabilities (`ingestBillingEvent`, checkouts, subscriptions, auto-recharge). - `emitter` receives credit lifecycle events (`CreditEventEmitter`). Every facade method returns a `Promise`. Exact monetary inputs accept `Decimal | string` and reject native JavaScript numbers; returned and stored amounts are `Decimal`. ## Error and retry contract Every typed SDK failure extends `BursarError` and exposes stable `code`, `category`, and `retryable` fields. Use `isBursarError()` instead of relying only on `instanceof`; the guard also works when an application has two package copies. `error.cause` retains the underlying failure for diagnostics, while `bursarErrorPublicMessage(error)` provides safe user-facing copy. ```ts import { bursarErrorHttpStatus, bursarErrorPublicMessage, isBursarError, retryBursarOperation, } from "@zonastery/bursar"; try { return await retryBursarOperation( () => bursar.credits.getBalance(accountId), { maxAttempts: 3, signal: request.signal }, ); } catch (error) { if (isBursarError(error)) { return Response.json( { code: error.code, message: bursarErrorPublicMessage(error) }, { status: bursarErrorHttpStatus(error) }, ); } throw error; } ``` Retries use `p-retry` for bounded exponential backoff, jitter, elapsed-time budgets, and cancellation. The default classifier retries only errors marked `retryable`. Retry a mutation only when it has a stable idempotency key; when `StoreError.indeterminate` is true, the original attempt may have committed. ## Entry points | Subpath | Contents | | --------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `@zonastery/bursar` | The application API: facade, stores, `PricingEngine`, config, errors | | `@zonastery/bursar/node` | Node-only: `loadConfigFile`, runtime, maintenance, diagnostics, outbox recovery, ClickHouse, and S3 adapters | | `@zonastery/bursar/browser` | Browser-safe: auto-recharge status types only (`AUTO_RECHARGE_STATES`, `BillingAutoRechargeStatus`) | | `@zonastery/bursar/opentelemetry` | Optional API-only OpenTelemetry adapter; the embedding application owns providers and exporters | | `@zonastery/bursar/providers/*` | Payment-provider adapters (Stripe, Dodo, mock) | The root and `node` entry points are server-only and depend on Node built-ins; `browser` contains no database stores or Node-only dependencies and is safe for Client Components. ## Migrations The TypeScript package ships no schema SQL and has no migration command. Install the Python CLI once and run: ```bash title="Terminal" pip install "bursar[postgres]" BURSAR_MIGRATION_DATABASE_URL=postgres://... bursar migrate ``` ## Where to go next - [Credits service](/docs/javascript-api/credit-manager) — balances, metered charging, leases, plans, ledger, analytics, teams - [PricingEngine](/docs/javascript-api/pricing-engine) — pricing without a database - [Stores](/docs/javascript-api/stores) — `PostgresStore`, `PostgresBillingStore`, and the `CreditStore` contract - [API reference](/docs/javascript-api/reference) — generated TypeDoc reference - [Concepts](/docs/concepts) and [configuration](/docs/concepts/configuration) — the canonical config document --- ## PricingEngine(Javascript-api) `PricingEngine` is Bursar's database-free operation-pricing core. It validates the same canonical configuration as the rest of the SDK and prices `UsageMetrics` events without any store, connection, or facade. ## Creating an engine ```typescript import { PricingEngine } from "@zonastery/bursar"; const engine = PricingEngine.fromDict(configDict); ``` `fromDict` accepts a canonical config document and returns an engine, or throws `ConfigError`. Unknown fields, undeclared measures or dimensions, invalid matcher types, and unsafe cross-references are rejected at construction. Credit accounting is a fixed Bursar convention rather than a configuration field; plan rank has an authoring default. ## Calculating cost ```typescript const cost = engine.calculate({ operation: "completion", measures: { input_tokens: 500, output_tokens: 200 }, dimensions: { model: "gpt-4o" }, }); console.log(cost.total.toString()); ``` `calculate(metrics, { rateCard })` prices one event. The optional `rateCard` key is required when the config contains more than one rate card and the caller has not otherwise selected one (via `getRateCardForPlan`). `calculateBatch(metrics, { rateCard })` evaluates an array of `UsageMetrics` with the same rate-card selection and returns `CostBreakdown[]` in the same order. `getRateCardForPlan(planId)` returns the rate-card key referenced by a configured plan — the canonical way to resolve the rate card for a user whose plan is known. `pricingSchema` returns the validated, canonicalized config as a plain object. ## Example Using the canonical demo configuration (`pricing.operations.completion`, rate card `standard`, per-million-token rates for `gpt-4o`/`gpt-4o-mini`, and an expression-based fallback for unmatched models): ```typescript import Decimal from "decimal.js"; const engine = PricingEngine.fromDict({ version: 1, pricing: { operations: { completion: { measures: { input_tokens: { unit: "token" }, output_tokens: { unit: "token" }, cache_read_tokens: { unit: "token" }, }, dimensions: { model: { type: "string" } }, }, }, rate_cards: { standard: { operations: { completion: { rules: [ { when: { model: { op: "in", values: ["gpt-4o", "gpt-4o-mini"] }, }, charge: { type: "sum", components: [ { type: "per_unit", measure: "input_tokens", rate: "0.0025", unit_size: "1000000", }, { type: "per_unit", measure: "output_tokens", rate: "0.0100", unit_size: "1000000", }, { type: "per_unit", measure: "cache_read_tokens", rate: "0.00125", unit_size: "1000000", }, ], }, }, ], unmatched: { action: "charge", charge: { type: "expression", formula: "input_tokens * 0.005 + output_tokens * 0.015", }, }, }, }, }, }, }, credits: {}, }); const cost = engine.calculate({ operation: "completion", measures: { input_tokens: 2_000_000, output_tokens: 500_000 }, dimensions: { model: "gpt-4o" }, }); // Exact decimals: 2,000,000 × 0.0025/M + 500,000 × 0.0100/M = 0.01 const expected = new Decimal("0.0025") .times(2) .plus(new Decimal("0.0100").times("0.5")); if (!cost.total.equals(expected)) { throw new Error(`unexpected total: ${cost.total.toString()}`); } console.log(cost.total.toString()); // "0.01" ``` ## UsageMetrics | Field | Type | Meaning | | ------------ | --------------------------------------------- | ---------------------------------------------------------- | | `operation` | `string` | A key declared in `pricing.operations` | | `measures` | `Record` | Non-negative billable quantities declared by the operation | | `dimensions` | `Record` | Typed values used to select the first matching rate rule | | `metadata` | `Record` | Caller metadata; it does not participate in pricing | Declared measures omitted by the caller are treated as zero. Required dimensions must be present. Undeclared measures or dimensions are rejected. ## CostBreakdown All monetary fields are `Decimal` values quantized to six decimal places with half-up rounding. Operation pricing is reported in `operationCredits`; `total` is the complete quantized result. | Field | Type | | ------------------ | ------------------------- | | `operationCredits` | `Decimal` | | `modelCredits` | `Decimal` | | `toolCredits` | `Decimal` | | `searchCredits` | `Decimal` | | `cacheSavings` | `Decimal` | | `fixedCredits` | `Decimal` | | `total` | `Decimal` | | `breakdown` | `Record` | See [Pricing](/docs/concepts/pricing) for how rate cards, rules, and unmatched actions compose, and [Expressions](/docs/concepts/expressions) for the supported expression operators and functions. --- ## Stores(Javascript-api) Every `Bursar` facade is built on a store — the adapter that owns the database. `PostgresStore` is the only bundled credit store; `PostgresBillingStore` is the bundled billing store; `CreditStore` is the abstract contract a custom backend must implement. ## PostgresStore ```ts import { PostgresStore } from "@zonastery/bursar"; const store = new PostgresStore({ postgres: databaseUrl, tenantId, providerEnvironment: "test", connectionTimeoutMs: 10_000, statementTimeoutMs: 30_000, onPoolError: (error) => console.error("Bursar pool error", error), }); ``` Connects through `pg` and runs every mutation as a canonical SQL function (RPC). The `tenantId` option scopes every transaction to one tenant. | Option | Type | Meaning | | --------------------- | ------------------------------- | -------------------------------------------------------------- | | `postgres` | `string \| pg.Pool` | Connection string for an owned pool, or an application pool | | `tenantId` | `string` | Required tenant UUID scoping every transaction | | `providerEnvironment` | `"live" \| "test" \| "sandbox"` | Required financial provider namespace | | `poolConstructor` | `PoolConstructor` | Optional constructor for testing or custom `pg` initialization | The store owns a pool it creates from a `postgres` connection string: call `await store.close()` to drain it. A supplied pool is borrowed and is never ended by the store. ### PostgreSQL reliability options `PostgresStoreOptions` and `PostgresBillingStoreOptions` share these controls: | Option | Default | Meaning | | -------------------------- | ------------ | ------------------------------------------------------------------------------- | | `connectionTimeoutMs` | `10_000` | Maximum connection establishment or pool-acquisition time for an SDK-owned pool | | `statementTimeoutMs` | `30_000` | Per-transaction PostgreSQL statement deadline; `0` disables it | | `idleTransactionTimeoutMs` | `30_000` | Deadline for an abandoned idle transaction; `0` disables it | | `idleTimeoutMs` | `pg` default | Idle lifetime for connections in an SDK-owned pool | | `maxConnections` | `pg` default | Maximum connections in an SDK-owned pool | | `applicationName` | `bursar-js` | Value reported as PostgreSQL `application_name` | | `onPoolError` | no-op | Observer for typed errors emitted by idle pool clients | The statement deadline is applied with transaction-local PostgreSQL settings, so it also protects operations that use a borrowed pool. Connection, SQLSTATE, timeout, and rollback failures are normalized into `StoreError` subclasses with the original driver error in `cause`. ## PostgresBillingStore ```ts import { PostgresBillingStore } from "@zonastery/bursar"; const billingStore = new PostgresBillingStore({ postgres: databaseUrl, tenantId, providerEnvironment: "test", }); ``` It uses the same object shape: `postgres` (a `pg.Pool` or connection string), `tenantId`, and required `providerEnvironment`. It wraps all billing repositories (offer, topup, customer, subscription, event, payment, refund, invoice, dispute, config) behind one interface and is passed as `billingStore:` to `new Bursar({...})` to enable billing. Commerce additionally requires `commerceOptions:` with the provider environment and factories. `PostgresBillingStoreOptions` accepts the same reliability controls listed above plus `billingPayloadBackend`. ## The CreditStore contract `CreditStore` is the abstract base class for a custom backend. Implementations must preserve, for every capability they expose: - **Atomicity** — each mutation commits or rolls back as one unit; balances and their ledger rows never diverge. - **Idempotency** — a replayed `idempotencyKey` returns the original result instead of applying a second time. - **Append-only ledger** — history is immutable; corrections are new entries (`adjustment`/`refund`), never updates. - **Credit-lot allocation** — credits are consumed lot-by-lot by priority and expiry (FEFO), with `sweepExpiredCredits` expiring eligible lots. - **Stable cursor ordering** — every list view orders by `(createdAt, entryId)` so cursor pagination never skips or duplicates rows. - **Typed failures** — permanent validation/invariant failures use non-retryable `StoreError`; only known transient conditions use `StoreUnavailableError` or `StoreTimeoutError`. Preserve native failures in `cause` and mark uncertain mutation outcomes with `indeterminate: true`. Optional capabilities (usage analytics, team management, usage-charge lists, `getLedgerEntry`) throw `CapabilityNotSupportedError` by default on the base class; override them if your backend supports them. Billing is a separate hierarchy (`BillingStore`/`PostgresBillingStore`) with its own repositories. ## Migrations The TypeScript package ships no schema SQL and has no migration command of its own. Apply the bundled migrations with the Python CLI during application setup, before constructing any store: ```bash title="Terminal" pip install "bursar[postgres]" BURSAR_MIGRATION_DATABASE_URL=postgres://... bursar migrate ``` See [CLI](/docs/cli) for `tenant`, `config`, and validation subcommands, and [Storage backends](/docs/guides/storage-backends) for the Node-only S3/ClickHouse adapters (`@zonastery/bursar/node`). --- ## Go API Install the versioned Go module: ```bash go get github.com/Zonastery/bursar/golang/v2 ``` The Go SDK is an idiomatic mirror of Bursar's Python and TypeScript contracts. Its public operations accept `context.Context` first and return `(T, error)`. It does not include a migration or administrative CLI: use the Python `bursar` CLI to apply the shared PostgreSQL baseline and create tenants. ## Construct the facade Create one tenant-bound store, then attach it to the facade. `TenantID` is a required UUID-formatted string; the constructor validates it before opening a database connection. ```go package main import ( "context" "log" "os" bursar "github.com/Zonastery/bursar/golang/v2" ) func main() { ctx := context.Background() store, err := bursar.NewPostgresStore(ctx, os.Getenv("DATABASE_URL"), bursar.PostgresStoreOptions{ TenantID: os.Getenv("BURSAR_TENANT_ID"), ProviderEnvironment: bursar.ProviderEnvironmentTest, }) if err != nil { log.Fatal(err) } defer store.Close() sdk, err := bursar.New(bursar.Options{CreditStore: store}) if err != nil { log.Fatal(err) } if err := sdk.LoadCatalog(ctx); err != nil { log.Fatal(err) } } ``` The SDK has no migration or administrative CLI. Before this code runs, apply the shared SQL baseline and create the tenant with the Python CLI as shown in the [quickstart](../quickstart.mdx). ## Exact amounts All credits and prices use `bursar.Amount`, an alias of [`shopspring/decimal`](https://pkg.go.dev/github.com/shopspring/decimal)'s exact decimal type. Do not pass `float64` values through application billing paths. Parse external input as a canonical base-10 string and preserve the result as an amount: ```go package main import ( "fmt" bursar "github.com/Zonastery/bursar/golang/v2" ) func main() { amount, err := bursar.NewAmount("25.125") if err != nil { panic(err) } fmt.Println(bursar.QuantizeMoney(amount).StringFixed(bursar.MoneyDecimalPlaces)) // 25.125000 } ``` `QuantizeMoney` applies Bursar's six-decimal, half-up accounting rule. Use `MustAmount` only for static program constants or tests; parse runtime values with `NewAmount` so invalid input returns an error. ## Errors SDK failures are typed `*bursar.BursarError` values. Use Go's normal `errors.As`/`errors.Is` flow or `bursar.AsBursarError`; the stable error code, category, retryability, and indeterminate-write flag let HTTP or RPC adapters make safe decisions without inspecting a message string. ```go if classified, ok := bursar.AsBursarError(err); ok && classified.Retryable { // Retry mutations only with the original idempotency key. } ``` ## PostgreSQL and providers The SDK uses Bursar's existing PostgreSQL RPC contract and binds a tenant to each database transaction. It intentionally does not ship SQL migrations or framework-specific web handlers. Provider integrations consume raw webhook bytes and headers, so applications can use standard `net/http` or their own router without a Next.js-style adapter. Follow the [quickstart](../quickstart.mdx) for the shared migration and tenant setup, then use the generated Go API on [pkg.go.dev](https://pkg.go.dev/github.com/Zonastery/bursar/golang/v2) for the exact facade, store, billing, and commerce signatures.