Skip to main content

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() or publish_pricing_from_dict() before deduct().


emitter: An optional CreditEventEmitter for lifecycle events. low_balance: An optional LowBalanceConfig configuring the

credits.low_balance signal (contract §6 / M18 / WS7). When None (the default), no explicit thresholds are configured and the threshold is derived lazily from min_balance at deduct time (see LowBalanceConfig).


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 True for fixed-cost batch jobs (via deduct_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.