Skip to main content
Version: 2.x

:::info Executable tutorial

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

:::

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.

from datetime import datetime, timezone

from bursar import CreditStore, PostgresStore
from bursar.errors import CapabilityNotSupportedError

# The 27 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.)

# A store is only a store when it implements the whole abstract surface.
# Here we implement only three core methods; the other 24 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("...")

# 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:

bursar migrate # bundled schema, checksummed and idempotent
bursar migrate --post-migrate-sql ./host/integration.sql

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. If it targets a completely 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.