bursar.manager module
High-level credit manager.
Orchestrates the credit lifecycle. The hot “calculate cost then charge now”
path is a single atomic, idempotency-keyed store transaction
(deduct_with_allowance) — allowance, spend cap, balance floor and debit all
commit (or roll back) together inside the store (contract §2, C1).
Example:
from bursar import CreditManager, UsageMetrics
from bursar.interface.supabase import HttpxSupabaseStore
store = HttpxSupabaseStore(url=supabase_url, key=service_role_key)
manager = CreditManager(store=store)
# One-time setup (creates tables + RPCs)
manager.setup()
# Load pricing from store (credit_pricing_config table)
manager.load_pricing_from_store()
# Deduct credits for a usage event
result = manager.deduct(
user_id="user_abc",
metrics=UsageMetrics(model="claude-opus-4", input_tokens=500, output_tokens=200),
idempotency_key="chat_42_turn_7",
)
print(f"Deducted {result.amount} credits, balance: {result.balance_after}")
exception bursar.manager.ConcurrencyLimitError
Bases: CreditError
Raised when a reserve would exceed an operation’s max_concurrent leases.
exception bursar.manager.CreditError
Bases: Exception
Coherent base for bursar credit-domain errors raised by the manager.
Lets callers except CreditError to catch any admission/settle failure
(interface plan §3 / M2), while still distinguishing specific subclasses.
class bursar.manager.CreditManager(store: CreditStore, engine: PricingEngine | None = None, emitter: CreditEventEmitter | None = None, , policy: str = 'strict_prepaid', overdraft_floor: Decimal | None = None, max_concurrent: int | None = None, low_balance: LowBalanceConfig | None = None, default_ttl_seconds: int = 600, lazy_expiry: bool = False)
Bases: object
Orchestrates credit operations: pricing -> atomic deduct.
Args:
: store: A CreditStore adapter (HttpxSupabaseStore, PostgresStore, etc.).
engine: An optional pre-configured PricingEngine. If omitted,
call
load_pricing_from_store()orpublish_pricing_from_dict()beforededuct().
emitter: An optional CreditEventEmitter for lifecycle events.
low_balance: An optional LowBalanceConfig configuring the
credits.low_balancesignal (contract §6 / M18 / WS7). WhenNone(the default), no explicit thresholds are configured and the threshold is derived lazily frommin_balanceat deduct time (seeLowBalanceConfig).
lazy_expiry: When True, a per-user expiry sweep runs inline before
: every balance-authoritative read/write (get_balance,
get_credit_tiers, deduct, deduct_fixed, deduct_team,
reserve, settle) so expired grants are invisible without
waiting for the periodic cron sweep_expired_credits(). Defaults
to False (unchanged behavior — a background/cron sweep is the
only way expired credits are removed); when False this is a
single boolean check with no other overhead.
add_credits(user_id: str, amount: Decimal | int, tx_type: str = 'adjustment', metadata: CreditMetadata | None = None, expires_at: datetime | None = None, tier: str | None = None) → AddCreditsResult
Add credits to a user’s account (amount is a Decimal).
tier is an optional tier key to grant into (see
get_credit_tiers()); omitted resolves to the configured
is_default tier, or "default" when no tiers are configured.
aggregate_stats(start: datetime, end: datetime) → AggregateStatsRow
Aggregate statistics across all users in a time window.
can_afford(user_id: str, metrics_or_amount: UsageMetrics | Decimal | int, , required_feature: str | None = None, billing_mode: Literal['strict', 'overdraft'] | None = None, operation_type: str = 'usage') → CanAffordResult
Advisory affordability check — UI only, non-locking, may be stale (D4/H3).
spendable in the result reflects the user’s effective spending power:
balance − active holds + allowance_remaining. This matches the headroom
reserve uses so the Send-button check agrees with the admission gate
(Fix 1). Never use this as an admission gate; only reserve is authoritative.
check_allowance(user_id: str) → AllowanceResult
Get remaining free allowance for the current billing period (Fix 6).
Convenience wrapper that routes through the manager so callers never need to reach past it into the raw store. Returns a zero-allowance result for planless users (no exception).
For rolling_30d/anniversary plans (WS9), resolves and threads
period_start so the reported window matches the one actually used by
deduct/settle/reserve — a calendar_month plan (the
default) takes the fast path unchanged.
check_feature(user_id: str, feature: str) → CheckFeatureResult
Check whether a user’s plan has a specific feature entitlement.
Convenience wrapper around the store’s check_feature() — inspect the
features dict on a user’s plan to gate functionality.
Presence is distinguished from truthiness (contract §5, M6): a feature is
present when its key exists and the value is not None/False.
Numeric 0 and empty string "" are therefore present.
- absent /
None/False=>has_feature=False True/ numeric (incl.0) / string (incl."") =>has_feature=True
check_feature_limit(user_id: str, feature: str) → FeatureLimitResult
Advisory, non-locking read of a per-feature invocation-count limit (UI only).
Convenience wrapper mirroring check_allowance()/the store’s
check_spend_cap: resolves the FeatureLimit (if any) from the
user’s plan and the calendar-aligned window, then delegates counting to
the store. Returns limited=False (zeroed fields, action=None)
when no FeatureLimit is configured for feature on the user’s
plan — never used for admission control; that is exclusively the
atomic check-and-increment inside deduct/reserve.
daily_spend(start: datetime, end: datetime) → list[DailySpendRow]
Daily spend aggregation in a time window.
deduct(user_id: str, metrics: UsageMetrics, idempotency_key: str | None = None, metadata: CreditMetadata | None = None, , skip_allowance: bool = False, feature: str | None = None) → DeductionResult
Calculate the cost and charge it in one atomic store transaction.
The flow is thin: breakdown = engine.calculate(metrics) →
cost = breakdown.total (a Decimal, charged exactly with no
truncation) → if cost <= 0 short-circuit with a zero-amount result →
otherwise store.deduct_with_allowance(...). Allowance consumption,
spend-cap enforcement, the balance floor, and the debit all commit (or
roll back) together inside the store (contract §2, C1). The manager only
maps the returned error code to a typed exception and emits events.
Args:
: user_id: The user to charge.
metrics: Usage metrics (model, tokens, tool calls, etc.).
idempotency_key: Optional user-scoped key for idempotent replay.
metadata: Extra metadata to attach to the transaction.
skip_allowance: When True, bypass free-allowance consumption so
the full cost is charged to the balance. Pass
Truefor fixed-cost batch jobs (viadeduct_fixed) to keep inference allowance uncontaminated (Fix 7).
feature: Optional feature name naming a per-feature invocation-count
: limit. When the user’s plan has a FeatureLimit configured for
it, the store enforces it (deny aborts; warn/notify
surface a non-blocking feature_limit_warning) and tags the
transaction’s metadata.feature regardless of whether a limit
is configured.
Returns:
: DeductionResult whose amount is the net (positive) charge to
the balance after free allowance.
Raises:
: PricingNotLoadedError: If pricing hasn’t been loaded.
InsufficientCreditsError: If the balance floor would be breached.
CapReachedError: If a deny spend cap would be exceeded.
FeatureLimitReachedError: If a deny feature limit would be exceeded.
deduct_fixed(user_id: str, job_name: str, idempotency_key: str | None = None, metadata: CreditMetadata | None = None, , use_allowance: bool = False) → DeductionResult
Shortcut for fixed-cost batch jobs (roadmap gen, topic gen, etc.).
Rejects an unknown / unconfigured job_name rather than silently
charging 0 credits (L1): the engine returns None for an unknown job,
which would otherwise become a “successful” free deduction.
use_allowance defaults to False: fixed-cost operations (PDF
generation, training runs, …) and monthly free inference allowances are
separate budgets and must not cross-contaminate. Pass
use_allowance=True to bill fixed-cost jobs against the allowance
pool first instead.
Raises:
: PricingNotLoadedError: If pricing hasn’t been loaded.
ValueError: If job_name is not a configured fixed-cost job.
deduct_team(team_id: str, user_id: str, metrics: UsageMetrics, idempotency_key: str | None = None, metadata: CreditMetadata | None = None) → TeamDeductionResult
Deduct from a team’s shared balance pool.
Calculates cost via the pricing engine, then debits the team pool.
Args: : team_id: The team’s UUID. user_id: The user to attribute the deduction to. metrics: Usage metrics (model, tokens, etc.). idempotency_key: Optional idempotency key. metadata: Extra metadata.
Returns:
: TeamDeductionResult with transaction details.
property engine : PricingEngine | None
The current PricingEngine, or None if not loaded.
get_available(user_id: str) → AvailableResult
Advisory available = balance − Σ active holds read (UI only, D4/H3).
get_balance(user_id: str) → BalanceResult
Get a user’s current credit balance.
get_credit_tiers(user_id: str) → TierBalancesResult
Per-tier balance breakdown for a user (pure read, no event — matches
get_balance()/get_available()).
get_user_plan(user_id: str) → GetUserPlanResult
Fetch user’s current plan (including feature entitlements).
grant_subscription_cycle(user_id: str, amount: Decimal | int, , tier: str = 'subscription', expires_at: datetime | None = None, ttl_days: int | None = None, replace_prior: bool = True, plan_key: str | None = None, idempotency_key: str | None = None, metadata: dict[str, Any] | None = None) → AddCreditsResult
Grant a subscription cycle’s credits idempotently (safe for webhook redelivery).
Typical use: a payment-provider webhook (renewal, signup) calls this once
per cycle. idempotency_key should be the provider’s event id so a
redelivered webhook is a no-op rather than a double-grant.
Args:
: user_id: The user whose subscription cycle is renewing.
amount: The cycle’s credit grant (coerced to Decimal).
tier: The credit tier to grant into (and, when replace_prior,
to zero out first). Requires a store with that tier configured (see
get_credit_tiers()) — this is deliberate: tiers are what let a subscription grant coexist with, and not clobber, credits from other sources (purchases, gifts, …).
expires_at: Explicit expiry for the new grant. Mutually exclusive
: with ttl_days.
ttl_days: Expire the new grant this many days from now. Mutually
: exclusive with expires_at.
replace_prior: When True (the default), any leftover balance in
: tier from a prior cycle is expired immediately before the
new grant lands — a renewal replaces the unused balance rather
than stacking on top of it.
plan_key: When given, also calls set_user_plan() — this
: intentionally re-anchors the allowance window, which is correct
for a new subscription cycle.
idempotency_key: The provider’s event id. Passed through to the
: store’s replay-safe add_credits so a redelivered webhook
does not double-grant.
metadata: Extra metadata to attach to the new grant’s transaction.
Returns:
: The AddCreditsResult for the new cycle’s grant.
Raises:
: ValueError: If both expires_at and ttl_days are given.
list_user_transactions(user_id: str, types: list[str] | None = None, from_date: datetime | None = None, to_date: datetime | None = None, limit: int = 50, offset: int = 0) → list[TransactionRow]
List credit transactions for a user with pagination.
load_pricing_from_store() → None
Load the active pricing config from the store.
publish_pricing(config: PricingConfigData, label: str | None = None) → None
Publish new pricing and update the engine in one call.
publish_pricing_from_dict(data: PricingConfigData | dict[str, Any]) → None
Load pricing from a PricingConfigData or raw dict and sync it.
refund_credits(transaction_id: str, amount: Decimal | int | None = None, reason: str | None = None, metadata: CreditMetadata | None = None) → RefundResult
Refund a previous credit deduction.
Args: : transaction_id: The transaction to refund. amount: Optional partial refund amount. Full refund if omitted. reason: Optional reason for the refund. metadata: Extra metadata to attach to the refund transaction.
Returns:
: RefundResult with the refund transaction details. On a business
failure (over-refund, duplicate, wrong type, not found) error is
set, credits.refund_failed is emitted, and no
credits.refunded event fires (contract §4, H3). Inspect
result.error (codes: over_refund, already_refunded,
not_found) to handle the failure.
release(user_id: str, lease_id: str) → ReleaseResult
Release a lease without charging (work failed/aborted) — idempotent (H1).
renew(user_id: str, lease_id: str, ttl: int | None = None) → LeaseResult
Extend a lease’s TTL for long batch/agentic jobs (B4).
reserve(user_id: str, metrics_or_amount: UsageMetrics | Decimal | int, , operation_type: str = 'usage', billing_mode: Literal['strict', 'overdraft'] | None = None, required_feature: str | None = None, ttl: int | None = None, metadata: CreditMetadata | None = None, model: str | None = None, feature: str | None = None) → LeaseResult
Atomically acquire a lease — the only admission control (D4).
Resolves the effective policy, enforces required_feature, sizes the hold
from metrics_or_amount (worst-case in strict, estimate in overdraft — the
caller chooses what to pass), and calls the store’s atomic create_lease.
The store’s create_lease is allowance-aware: remaining free allowance is
added to the effective headroom so free-tier users are not falsely rejected
for worst-case holds they can cover with allowance (Fix 1 / D4).
model is inferred from UsageMetrics when passed; for raw
Decimal/int amounts use the explicit model kwarg so per-model
spend-caps and analytics remain accurate (Fix 5).
feature names a per-feature invocation-count limit (independent of
required_feature, which is a boolean entitlement gate): when the
user’s plan has a FeatureLimit configured for it, admission enforces
it as deny-only (mirrors how admission only ever enforces deny
spend caps — warn/notify have nothing to warn about yet, since no
charge has happened). Re-supply the same feature at settle()
for accurate per-call counting, exactly as model is already
re-supplied at settle for per-model spend-cap accuracy (Fix 5).
On any business failure raises the coherent typed exception; on success emits
credits.reserved and returns the LeaseResult.
run_billed(user_id: str, , estimate: UsageMetrics | Decimal | int, do_work: Callable[[], tuple[Any, UsageMetrics | Decimal | int]], operation_type: str = 'usage', billing_mode: Literal['strict', 'overdraft'] | None = None, required_feature: str | None = None, idempotency_key: str | None = None, ttl: int | None = None, feature: str | None = None) → dict[str, Any]
One-call shortcut wiring reserve → do_work → settle (interface plan §4).
do_work runs the operation and returns (result, actual) where
actual is the real usage metrics (or amount) to settle. On any exception
from do_work the lease is released and the error re-raised. For long jobs
do_work may call renew(). A crash between reserve and settle is
covered by the lease TTL (and the store’s reaper).
feature names a per-feature invocation-count limit and is passed
through to both reserve() (deny-only admission check) and
settle() (advisory recount + tagging) — the same feature name is
used at both ends since no feature name is persisted on the lease.
set_user_plan(user_id: str, plan_key: str) → SetUserPlanResult
Assign a plan to a user and emit a credits.plan_changed event.
Args:
: user_id: The user to assign the plan to.
plan_key: The plan key to assign (e.g. "pro").
Returns:
: SetUserPlanResult confirming the assignment.
settle(user_id: str, lease_id: str, metrics_or_amount: UsageMetrics | Decimal | int, , idempotency_key: str | None = None, metadata: CreditMetadata | None = None, skip_allowance: bool = False, feature: str | None = None) → DeductionResult
Charge the ACTUAL cost against a lease and finalize it (D5).
De-clamped: bills the full actual cost even if it exceeds the lease hold
(overdraft). Never blocks on floor/cap at settle — a cap breach surfaces as a
non-blocking credits.cap_warning/credits.cap_reached signal. Emits
credits.deducted, then multi-level credits.low_balance and a
credits.overdraft signal if the balance went negative.
skip_allowance=True prevents the free inference allowance from being
consumed at settle time. Use for fixed-cost operations reserved via the
lease pattern (mirrors the deduct_fixed / deduct skip_allowance
flag — Fix 7 / #4).
feature re-supplies the same feature name passed to reserve()
(no feature name is persisted on the lease itself) so the invocation is
tagged and counted for future invocation-count checks — exactly as
model is already re-supplied at settle for per-model spend-cap
accuracy (Fix 5). A breached FeatureLimit at settle is advisory only
(the work already happened) and surfaces as a non-blocking
credits.feature_limit_warning/credits.feature_limit_reached
signal, never a raised exception.
setup() → SetupResult
Run bundled SQL migrations through the store.
spend_by_model(start: datetime, end: datetime) → list[SpendByModelRow]
Aggregate spend by model in a time window.
spend_by_user(start: datetime, end: datetime) → list[SpendByUserRow]
Aggregate spend by user in a time window.
sweep_expired_credits(dry_run: bool = False) → SweepResult
Sweep expired credits from all users’ balances.
Args: : dry_run: If True, report without modifying.
Returns:
: SweepResult with expired count and amount.
top_users(limit: int, start: datetime, end: datetime) → list[TopUserRow]
Top users by spend in a time window.
bursar.manager.DEFAULT_LEASE_TTL_SECONDS = 600
Default lease TTL (seconds) for reserve/runBilled (interface plan §3).
Long batch/agentic jobs call CreditManager.renew() before this elapses.
bursar.manager.DEFAULT_LOW_BALANCE_MULTIPLIER = Decimal('2')
Default low_balance threshold = this multiple of the engine’s
min_balance (contract §6 / M18). Override via the CreditManager
low_balance_threshold constructor argument.
exception bursar.manager.FeatureNotEntitledError
Bases: CreditError
Raised when an operation requires a plan feature the user does not have.
exception bursar.manager.InsufficientCreditsError
Bases: CreditError
Raised when a user does not have enough credits for an operation.
exception bursar.manager.LeaseExpiredError
Bases: CreditError
Raised when settling/renewing a lease whose TTL has already elapsed.
exception bursar.manager.LeaseNotFoundError
Bases: CreditError
Raised when a lease id does not exist, belongs to another user, or was released.
class bursar.manager.LowBalanceConfig(thresholds: list[Decimal] | None = None, on_trigger: Callable[[CreditEvent], None] | None = None)
Bases: object
Configuration for the credits.low_balance signal (interface plan §6 / WS7).
Collapses the previous three overlapping CreditManager constructor
params (low_balance_threshold, low_balance_thresholds,
on_low_balance) into one object.
Args:
: thresholds: Absolute balance levels at/below which a deduction that
: crosses a level emits credits.low_balance. A single-element
list behaves like the old single-threshold form; multiple elements
fire once per level per descent (edge-triggered, high→low), and
each level re-arms independently once the balance climbs back
above it (e.g. via add_credits). When None (the default
when no LowBalanceConfig is passed at all), the threshold is
derived lazily as min_balance * DEFAULT_LOW_BALANCE_MULTIPLIER
at deduct time, so it tracks the engine’s configured floor.
on_trigger: Optional non-blocking callback invoked (in addition to the
: credits.low_balance event) whenever a level fires. Exceptions
raised by the callback are logged and never propagate (H4).
on_trigger : Callable[[CreditEvent], None] | None = None
thresholds : list[Decimal] | None = None
bursar.manager.POLICY_PRESETS = frozenset({'overdraft', 'strict_prepaid'})
Built-in financial-safety presets (interface plan §2). strict_prepaid keeps
the floor >= 0 (structural zero debt); overdraft permits a negative floor
and bills the full actual cost at settle.
exception bursar.manager.PricingNotLoadedError
Bases: CreditError
Raised when deduct() is called before pricing is loaded.