:::info Executable tutorial
This page is generated from a tested Jupyter notebook. Open it in Google Colab or view the source notebook.
:::
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.
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.
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.
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.
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.
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.
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.
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)
# 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.
plain = Bursar(credit_store=store)
print("billing:", plain.billing)
print("commerce:", plain.commerce)
print("all done")