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. Send the provider's unmodified body and headers through commerce.handle_webhook(...) / handleWebhook(...), or use an official signature-verifying framework adapter. The verified adapter then submits the normalized event, which Bursar claims by (provider, event_id, event_type) so a redelivery cannot apply the lifecycle mutation twice. Direct ingest_billing_event / ingestBillingEvent calls are only for already trusted custom adapters.

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, incomplete_expired, 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:

billing = bursar.require_billing()
offer = 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.