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.

:::

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 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)

def mock_factory(ctx):
return DemoMockPaymentProvider(sink=ctx.event_sink)

commerce_options = CommerceOptions(
tenant_id=store.tenant_id,
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.create(
credit_store=store,
billing_store=billing_store,
commerce_options=commerce_options,
)
print("billing:", bursar.billing is not None)
print("commerce:", bursar.commerce is not None)
bursar.accounts.on_account_created(USER_ADA, "acct_ada")

Resolving offers and top-ups

bursar.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 = bursar.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 = bursar.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 bursar.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:", bursar.commerce.get_checkout_status(checkout.intent_id, USER_ADA).status)

The payment webhook

The provider posts a payment.succeeded webhook; the mapper normalizes it and the billing service settles the payment, completes the checkout intent, and grants 10,000 credits into the purchased bucket. The metadata.userId and checkout_intent_id fields are how the event is attributed.

body = {
"type": "payment.succeeded",
"data": {
"id": "evt_topup_1",
"payment_id": "pay_dodo_topup_1",
"customer_id": "dodo_cus_ada",
"amount": 500,
"settlement_amount": 500,
"currency": "USD",
"product_id": "prod_credits_10k",
"metadata": {
"userId": USER_ADA,
"checkout_intent_id": checkout.intent_id,
},
},
}
webhook = await bursar.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:", bursar.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"),
)
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.

bursar.billing.upsert_customer("dodo", "dodo_cus_ada", USER_ADA, "ada@example.com")

enabled = await bursar.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 bursar.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 bursar.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)

bursar.commerce.auto_recharge.disable(USER_ADA)
status = await bursar.commerce.auto_recharge.get_status(USER_ADA)
print("after disable:", status.enabled, status.state)

Without a billing store

A facade built from publish_config alone has no billing or commerce capability — good to remember when sharing one store between services.

plain = publish_config(store, base_config())
print("billing:", plain.billing)
print("commerce:", plain.commerce)
print("all done")