Skip to main content
Version: 2.x

Billing and commerce

Billing connects your credit ledger to a payment provider. Bursar owns the offer catalog and the normalized event state machine; the provider only executes payments. The commerce section of the canonical config declares providers, offers, and auto-recharge guardrails.

Providers

commerce.providers names the payment providers an environment supports. Each provider is one of stripe, dodo, or custom (with an adapter):

commerce:
providers:
stripe: { type: stripe }

At runtime, each provider is a PaymentProvider adapter (Stripe, Dodo, or custom) registered in CommerceOptions.providers. Adapters do two jobs: they create checkout sessions, and they map provider webhooks to normalized BillingEvents. Events enter through bursar.ingest_billing_event(event) / ingestBillingEvent, which claims each event by (provider, event_id, event_type), routes it through the lifecycle handlers, and replays nothing twice.

Offers

commerce.offers defines what customers can buy. Prices are integer minor units with a currency and a tax_behavior; provider references map the offer to provider objects (stripe_price.price_id, dodo_product.product_id).

Subscription offers bind a plan to a billing interval, an optional trial, and an optional cycle grant — the Pro monthly plan with a 50,000-credit grant:

offers:
pro_monthly:
type: subscription
display_name: Pro Monthly
price: { amount_minor: 2000, currency: USD }
providers:
stripe: { type: stripe_price, price_id: price_pro_monthly }
plan: pro
billing_interval: { unit: month, count: 1 }
cycle_grant:
amount: "50000"
bucket: purchased
renewal: replace_previous

Topup offers sell credit packs — credits_per_unit credits per unit, bounded quantity, a target bucket, and lot_behavior (separate_lots keeps each purchase as its own lot; merge_and_refresh merges):

credits_10k:
type: topup
display_name: "10,000 Credits"
price: { amount_minor: 500, currency: USD }
providers:
stripe: { type: stripe_price, price_id: price_credits_10k }
credits_per_unit: "10000"
quantity: { minimum: 1, maximum: 10, default: 1 }
bucket: purchased
lot_behavior: separate_lots

Checkout

A checkout has three phases. create_checkout resolves the offer, enforces quantity bounds and existing subscriptions, records a checkout intent, and asks the provider for a session URL — the intent exists before any money moves. The user pays on the provider's site. The provider webhook then arrives as a checkout.completed event and completes the intent, which is what grants credits or assigns the plan. Because the webhook is the only settlement path, a payment that never touches your checkout endpoint still settles credits through ingest_billing_event: on payment.succeeded for a topup, the offer's credits are granted to the configured bucket; on checkout.completed for a subscription, the cycle grant posts and the plan is assigned. The walkthrough in Subscription integration shows the full flow end to end.

Subscriptions

BillingSubscriptionStatus covers the provider lifecycle: incomplete, trialing, active, past_due, canceled, unpaid, paused, and expired. A past_due subscription enters a grace period; when the grace end passes, the subscription is revoked (expire_past_due_grace_periods sweeps expired periods) and grace_expired_at is recorded.

Subscriptions drive entitlement: activation events assign the offer's plan through a provisioning port (set_user_plan), and cancellation moves the account to the terminal plan or clears the assignment — the same path an admin's set_user_plan uses, so allowances, quotas, and admission all follow from the subscription state. resolve_offer(provider, product_id=None, price_id=None) maps a provider object to the configured offer, e.g. from a webhook payload:

offer = bursar.billing.resolve_offer("stripe", price_id="price_pro_monthly")

Plan changes

commerce.subscription_changes configures how plan changes behave per direction — upgrade, downgrade, lateral, and cadence_change. Each policy sets effective (immediate or renewal), proration (prorated or none), and payment_failure (prevent_change or apply_change): a downgrade may wait for renewal, while an upgrade applies immediately.

Auto-recharge

commerce.auto_recharge turns a low balance into a topup purchase without user interaction. Guardrails bound every decision:

FieldMeaning
eligible_topupsTopup offers auto-recharge may buy
balance_belowThreshold that triggers a purchase (min/max/default credits)
rearm_aboveBalance that arms auto-recharge again (must exceed balance_below.maximum)
quantityUnits per purchase
limitsmax_purchases per window, max_charge_minor, cooldown, and failure handling (max_consecutive_failures, failure_action: pause)
auto_recharge:
eligible_topups: [credits_10k]
balance_below: { minimum: "1000", maximum: "5000", default: "2000" }
rearm_above: "20000"
quantity: { minimum: 1, maximum: 10, default: 1 }
limits:
max_purchases: 5
window: { type: calendar, unit: day, count: 1 }
max_charge_minor: 5000
cooldown: { unit: hour, count: 1 }
max_consecutive_failures: 3
failure_action: pause

When commerce is enabled, the facade hooks auto-recharge after every deduction. Processing outcomes are not_configured, disabled, above_threshold, already_processing, limit_reached, submitted, action_required, and failed. Per-user profiles persist threshold, topup, quantity, window counts, and payment method, so recharges are idempotent and bounded even across process restarts.

Billing persistence boundary

Billing and credit state share the same migrated PostgreSQL database and tenant boundary. Billing and commerce capabilities remain unavailable when an integration constructs only the credit store, which keeps payment-provider concerns optional.

Use Configure storage backends for store construction and Integrate subscriptions and payments for provider wiring.