Skip to main content

Architecture

Module Layout

bursar/
├── expr.py # Safe AST expression evaluator (exact Decimal math)
├── config.py # Pydantic model + dict loading for PricingConfig
├── engine.py # PricingEngine — core calculation logic (Decimal)
├── metrics.py # UsageMetrics, ToolCall dataclasses
├── breakdown.py # CostBreakdown model (Decimal components + total)
├── events.py # CreditEventEmitter — typed pub/sub for lifecycle events
├── manager.py # CreditManager — orchestration layer
├── interface/
│ ├── base.py # CreditStore ABC — core abstract methods + optional
│ │ # capability groups with CapabilityNotSupportedError defaults
│ ├── models.py # Pydantic schemas for all store operations
│ ├── memory.py # MemoryStore (thread-safe, in-memory for testing)
│ ├── supabase.py # HttpxSupabaseStore adapter + run_migrations()
│ └── postgres.py # PostgresStore adapter
└── sql/
├── 001_core_schema.sql # user_credits, credit_transactions, credit_reservations, RLS, signup bonus
├── 002_credit_rpcs.sql # credits_add, get_credits_balance
├── 003_pricing_config.sql # pricing config table + get/set/list/activate RPCs
├── 004_plans.sql # subscription plans, usage windows, allowance RPCs
├── 005_spend_caps.sql # spend cap table + check_spend_cap RPC
├── 006_refunds_and_expiry.sql # refund_credits, expire_credits
├── 007_analytics.sql # analytics + transaction-listing RPCs
├── 008_teams.sql # team balance pools + RPCs
├── 009_deduct_and_leases.sql # deduct_with_allowance() atomic RPC + full lease lifecycle
└── 010_credit_tiers.sql # credit_tiers config + tier-aware deduct/settle/refund/sweep

CreditStore (interface/base.py) declares the core abstract methods every store must implement, plus several optional capability groups that ship with a default implementation and do not need to be overridden by a minimal custom store — see Storage interface: core vs. optional capabilities below. The JavaScript CreditStore interface mirrors the same split (it folds listUserTransactions/listUsageEvents into the same surface but exposes deductWithAllowance and the analytics methods identically).

Credit Lifecycle

CreditManager.deduct() is now a thin wrapper around a single atomic, idempotency-keyed store transaction, deduct_with_allowance (deductWithAllowance in JS). The manager only calculates the cost and maps the result; allowance consumption, spend-cap enforcement, the balance floor and the debit all happen inside one server-side transaction that is all-or-nothing.

User → CreditManager.deduct(metrics, idempotency_key)

├─▶ 1. Calculate cost
│ PricingEngine.calculate(UsageMetrics) → CostBreakdown
│ cost = breakdown.total (exact Decimal — NO truncation)
│ if cost <= 0: short-circuit with a zero-amount result

└─▶ 2. store.deduct_with_allowance(user_id, cost, idempotency_key,
min_balance, model, metadata)
────────────── ONE transaction (all-or-nothing) ──────────────
a. SELECT ... FOR UPDATE (lock the user's credit row)
b. Idempotency FIRST (user-scoped): if this idempotency_key was
already used by this user, return the original result with
idempotent=true — no double charge, no double allowance.
c. Allowance: consume = LEAST(remaining_allowance, cost);
increment the monthly usage window by `consume`;
net = cost - consume.
d. Spend cap on `net`: a `deny` cap RAISEs and aborts → returns
{"error":"cap_reached"} (allowance NOT consumed);
`warn`/`notify` set cap_warning and continue.
e. Balance floor: if balance - net < min_balance → returns
{"error":"insufficient_credits"} and aborts.
f. Debit balance by `net`; insert ONE `usage` transaction with
idempotency_key, allowance_consumed, model, merged metadata.
───────────────────────────────────────────────────────────────

└─▶ 3. Map the result:
error → emit credits.deduct_failed, raise the typed exception
(cap_reached → CapReachedError;
insufficient_credits → InsufficientCreditsError)
ok → emit credits.deducted (and credits.cap_warning if the
store returned a warn/notify cap signal)

Any failure rolls back the allowance consumption and the balance change together — there is no partial state, no stranded reservation, and no allowance leak on a failed charge. Idempotency is checked first, so a retried request with the same key replays the original result instead of re-consuming allowance.

Steps a–e (lock, idempotency replay, allowance consume, spend cap, balance floor) are unchanged when credit tiers are configured and remain purely aggregate — they compare against the summed balance across all tiers, exactly as they compare against the single balance today. Only step f changes: when tiers are configured, the debit walks tiers in ascending priority order instead of decrementing one scalar, recording the exact per-tier split as tier_breakdown in the transaction's metadata for later idempotent replay and refund. settle_lease applies the identical tier walk to its own already-floor-clamped net.

Why this replaced the old multi-step flow

The previous design issued four independent store calls (consume allowance → check cap → reserve → deduct) with no spanning transaction and idempotency checked last. That allowed double-consumed allowance on retry, stranded reservations, and racy cap bypass under concurrency. Consolidating into deduct_with_allowance makes the hot path transactional and idempotent end-to-end. Money columns are NUMERIC(18,4) throughout and all windows are pinned to UTC for determinism.

Lease lifecycle: reserve → settle / release / renew

For streaming and agentic jobs — where the actual cost is not known upfront — the CreditManager exposes a full lease lifecycle:

reserve(user_id, estimate) → LeaseResult (lease_id, hold placed on balance)

├── work runs; call renew(lease_id) periodically for long jobs

├── settle(user_id, lease_id, actual) → DeductionResult
│ Bills the ACTUAL cost. Capped to the reserved amount in strict mode;
│ bills full actual in overdraft. Never blocks on floor/cap at settle.

└── release(user_id, lease_id) → ReleaseResult
Cancels without charging (work aborted). Idempotent.

run_billed(user_id, estimate=…, do_work=…) is a one-call shortcut that wires the full cycle: it reserves, calls do_work(), settles on success, and releases on any exception.

reserve enforces admission atomically: FOR UPDATE on the balance row, concurrency cap, spend cap, balance floor, and feature entitlement — all checked inside a single transaction before the hold is granted. A crash between reserve and settle is covered by the lease TTL; the store sweeper reaps expired leases automatically.

The lower-level reserve_credits + deduct_credits pair (column-level reservation) is also retained but does not carry the full policy and concurrency machinery of reserve/settle.

Which charge method should I use?

MethodUse when
deduct() / deduct()The cost is already known before you charge — a simple, immediate, pre-computed-cost deduction. No admission hold.
reserve() → do work → settle() / release()The cost is not known until after the work runs (streaming, agentic, variable-length jobs) and you need admission control (a hold) before starting. run_billed() / runBilled() is a one-call shortcut for this exact shape.
deduct_fixed() / deductFixed()A named, fixed-price batch job (e.g. a report or training run) — single atomic charge, no estimate/settle cycle.
deduct_team() / deductTeam()Charging a shared team credit pool instead of an individual user's balance.

Low-balance alerts

After a successful deduction the manager may emit credits.low_balance. The threshold(s) are configurable via the CreditManager(low_balance=LowBalanceConfig(...)) constructor argument (lowBalance: { thresholds, onTrigger } in JS); when unset it defaults to a single threshold of min_balance * 2 (which is 0 by default, since min_balance now defaults to 0 — see Pricing Configuration). Each threshold is edge-triggered — it fires only on the deduction that takes the balance from above the threshold to at-or-below it (balance_before > threshold >= balance_after), not on every call near the threshold, and re-arms after a top-up brings the balance back above that level.

Additional Operations

  • Refunds: manager.refund_credits() → store restores balance, logs a refund transaction. Supports full and partial refunds; rejects over-refunds, duplicates, and refunding a refund/purchase (error set to over_refund/already_refunded/not_found). The manager checks result.error before emitting — a failed refund emits credits.refund_failed, never a false credits.refunded.
  • Expiry: manager.sweep_expired_credits() → store finds expired grants, debits the balance, and marks swept grants so a second sweep reports zero and never double-debits. Dry-run mode previews without modifying. SQL and MemoryStore behave identically.
  • Team deduction: manager.deduct_team()PricingEngine calculates the cost → store debits the team pool, attributed to a user. Threads an idempotency_key (both SDKs) so a retried team deduction does not double-charge the shared pool.
  • Analytics: spend_by_user, spend_by_model, top_users, daily_spend, aggregate_stats, plus list_user_transactions / list_usage_events (the latter on the SQL stores and the JS manager) filter credit_transactions in a time window — backed by SQL window functions (Postgres/Supabase) or an in-memory scan (MemoryStore).

Storage interface: core vs. optional capabilities

Implementing a custom CreditStore backend used to mean implementing every abstract method, including several feature areas many integrators never touch. The interface now separates core methods (required) from optional capability groups (each with a default implementation that raises CapabilityNotSupportedError unless you override it):

  • Core (required): credit balance / add / deduct (get_balance, add_credits, deduct_with_allowance), the full lease lifecycle (create_lease / settle_lease / release_lease / renew_lease / get_available), pricing config versioning (get_active_pricing, set_active_pricing, get_pricing_history, get_pricing_config, activate_pricing), plan management (set_user_plan, get_user_plan, check_feature, check_allowance), spend cap checks (check_spend_cap), refunds (refund_credits), and the credit expiry sweep (sweep_expired_credits).
  • Optional — Analytics: spend_by_user/spendByUser, spend_by_model/spendByModel, top_users/topUsers, daily_spend/dailySpend, aggregate_stats/aggregateStats.
  • Optional — Transaction listing: list_user_transactions/listUserTransactions.
  • Optional — Teams: create_team/createTeam, get_team_balance/getTeamBalance, add_team_member/addTeamMember, get_team_members/getTeamMembers, deduct_team/deductTeam.

A minimal custom store can skip all three optional groups entirely and still satisfy CreditStore — calling an unoverridden optional method raises CapabilityNotSupportedError (exported from both SDKs) instead of a missing-method TypeError, so the failure mode is explicit and catchable.

Scope & boundaries

A few things bursar deliberately does not do:

  • No credit-pack purchasing or currency conversion. bursar tracks and enforces credit balances; it does not talk to a payment provider or convert currency to credits. Call add_credits / addCredits yourself after a successful payment (Stripe, etc.) completes.
  • CreditEventEmitter is in-process and synchronous only. It is not a durable message bus — handlers run inline, in-process, and are not retried or persisted. If you need durable delivery (webhooks, a queue, at-least-once semantics), bridge to it yourself from an event handler.
  • Plans are immutable per pricing version. Editing a plan's free_allowance or rate overrides means publishing a new pricing version (publish_pricing_from_dict / publishPricingFromDict or publish_pricing / publishPricing) — plans are never edited in place on an already-published version.

Events

CreditEventEmitter is an optional typed pub/sub injected into CreditManager. Success events fire only after the underlying operation commits (not result.error); failures fire dedicated failure events. Handler exceptions are isolated and never break the main flow. Available event types: credits.deducted, credits.deduct_failed, credits.added, credits.refunded, credits.refund_failed, credits.expired, credits.cap_reached, credits.cap_warning, credits.low_balance, credits.plan_changed, credits.reserved, credits.reservation_released, credits.lease_expired, credits.overdraft. Event timestamps are timezone-aware UTC.

Expression Safety — Python

  1. Parse ast.parse(expr, mode="eval")
  2. Walk the AST — every node type must be in a strict allowlist; ast.Pow (**) is intentionally excluded, so exponentiation is rejected
  3. Function calls whitelisted: ceil, floor, min, max, round, if, tier, clamp, percentile
  4. Rejects: attributes (x.__class__), subscripts (x[0]), lambdas, comprehensions, f-strings, imports
  5. Numeric literals are rewritten to exact Decimal from their source text; division/modulo by zero raise; the result is asserted finite
  6. Evaluation namespace has __builtins__ emptied
  7. All expression strings (and variable names) validated at config-load time

Expression Safety — JavaScript

  1. Recursive-descent parser with a strict token allowlist
  2. No eval(), no Function() constructor; ** is rejected
  3. Variable lookup uses own-property checks (not the in operator), so prototype-chain identifiers (__proto__, constructor, prototype, toString, hasOwnProperty) are rejected as undefined variables
  4. All math is decimal.js Decimal; division/modulo by zero throw; non-finite results throw — output is byte-identical to the Python engine
  5. All expressions validated at config-load time