# stapel-billing 0.11.0

Payments and billing: per-user credit wallets held as expiry-aware credit lots over an immutable ledger, reservations (hold/capture/release) for work priced only after it runs, partial charges that record what a wallet could not cover as a collectable debt, plan bundles granted with or without a payment provider, one-off credit packages and subscription plans, Stripe-backed checkout, customer portal and refund clawback, idempotent webhooks and a service-to-service debit endpoint.

Contract: axes 1 · surface 37 · extension points 6 · operations 10 · error codes 55.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- PAYMENT_PROVIDER [enum, default "stapel_billing.providers.stripe.StripeProvider"] — Payment processor
  Which payment backend handles checkout, subscriptions and webhooks. Stripe out of the box; any other processor plugs in as a host-project provider class.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- can_afford — stapel_billing.services.can_afford
  instead of: stapel_billing.models.Wallet.balance, stapel_billing.services.hold
  Ask whether a charge of N credits would go through — a pure read over the live lots: no lock, no hold, no row written. Use it for pre-flight refusals and for showing or hiding an action. Never probe with a real hold you then release: each probe returns the credits as a NEW lot, fragmenting the wallet without bound.
- cancel_provider_subscription — stapel_billing.services.cancel_provider_subscription
  instead of: stripe.Subscription.modify
  Cancel the subscription on the processor's side, raising when the remote call fails — deliberately the FIRST half of a cancellation. Call it before marking anything cancelled locally: a local cancellation the processor never heard about tells the user billing stopped while their card keeps being charged.
- claim_provider_object — stapel_billing.services.claim_provider_object
  instead of: stapel_billing.models.Transaction.objects.filter(metadata__stripe_invoice_id=...).exists
  Claim one provider object (a checkout session, an invoice) for a grant, returning False when someone already claimed it — the database-enforced answer to "has this been paid out yet?". Call it inside the transaction that grants, so claim and credit commit together; a read-then-credit check over ledger metadata cannot see a concurrent delivery of a DIFFERENT event describing the same invoice, and both of them pay.
- claw_back_grant — stapel_billing.services.claw_back_grant
  instead of: stapel_billing.services.debit
  Take the credits of one grant back after a refund, dispute or credit note: consumes only that grant's lots, forgives what already expired unspent, records what was already spent as a debt the next top-up collects. The three refund webhooks call it; reach for it when money goes back some other way.
- expire_credit_lots — stapel_billing.tasks.expire_credit_lots
  The task that makes credit expiry real: zeroes lots past expires_at, one EXPIRATION ledger row each. Wire it via get_billing_beat_schedule — nothing else enforces the deadline subscription credits are sold with (check stapel_billing.W105).
- expire_holds — stapel_billing.tasks.expire_holds
  Hourly crash-safety task: releases credit holds past expires_at, recorded as expired. Wire it via get_billing_beat_schedule; without it a pipeline that dies between hold and capture reserves a customer's credits for good.
- grant_plan_bundles — stapel_billing.tasks.grant_plan_bundles
  The worker that grants the current period's bundle to every entitled wallet — without it a free plan's monthly_credits_included is a number nothing reads. Idempotent per period; wire it via get_billing_beat_schedule().
- handle_charge_refunded — stapel_billing.services.handle_charge_refunded
  Built-in handler for `charge.refunded`: claws the granted credits back out of that grant's lots, prorated when the refund was partial. Name it only to wrap or replace it.
- handle_checkout_completed — stapel_billing.services.handle_checkout_completed
  The exact reconciliation this module performs when checkout.session.completed fires: award the package's credits, or open/refresh the Subscription and grant the plan's first monthly bundle, each with its payment.completed / subscription.changed announcement. Read it before writing a parallel handler for the same event, and never call it outside StripeWebhookView's StripeWebhookEvent claim + row lock — at-least-once delivery double-grants otherwise.
- handle_credit_note_created — stapel_billing.services.handle_credit_note_created
  Built-in handler for `credit_note.created`: an invoice credited back takes its bundle back too. Name it only to wrap or replace it.
- handle_dispute_created — stapel_billing.services.handle_dispute_created
  Built-in handler for `charge.dispute.created`: claws back when the dispute OPENS, not when it resolves — a dispute won later is one manual credit, a dispute lost after the credits are spent is unrecoverable.
- handle_invoice_paid — stapel_billing.services.handle_invoice_paid
  The renewal grant on invoice.paid — the month-two-onward credits a subscriber would otherwise never receive; skips the initial invoice (already granted at checkout) and claims the invoice through claim_provider_object, so the several distinct events Stripe sends for one paid invoice credit it exactly once. Read it instead of writing a product-side renewal grant, and call it only under the same webhook claim protocol as its siblings.
- handle_subscription_deleted — stapel_billing.services.handle_subscription_deleted
  The terminal cancellation on customer.subscription.deleted: marks the local Subscription cancelled with its timestamp and announces it. Read it before letting a product decide on its own what a deleted provider subscription means locally; call it only under the webhook claim protocol.
- handle_subscription_updated — stapel_billing.services.handle_subscription_updated
  The status reconciliation on customer.subscription.updated: maps the processor's lifecycle (active / trialing / past_due / canceled / incomplete) onto SubscriptionStatus, refreshes the billing period and announces the change. That mapping is the module's contract — anything deciding what "past_due" means locally must read it here rather than invent a second answer.
- reconcile_wallet_balances — stapel_billing.services.reconcile_wallet_balances
  Report every wallet whose cached balance disagrees with the lots it summarises. Read-only on purpose: a balance rewritten to match destroys the evidence that something wrote it from outside this module.
- reconcile_wallets — stapel_billing.tasks.reconcile_wallets
  Daily task over reconcile_wallet_balances: logs at ERROR every wallet whose balance cache no longer matches its lots — how a deployment learns something wrote Wallet.balance directly.
- verify_stripe_signature — stapel_billing.services.verify_stripe_signature
  instead of: hmac.compare_digest, stripe.Webhook.construct_event
  The authenticity gate for an inbound provider callback: verifies the signature through the configured provider and returns the parsed event, raising on anything unsigned or on an unconfigured processor. Every endpoint that accepts webhooks must pass through it — a hand-rolled HMAC comparison is how an anonymous POST turns into a free credit grant.
### factory
- capture — stapel_billing.services.capture
  instead of: stapel_billing.services.debit
  Close a hold at what the work actually cost, writing the one ledger row that explains it. Under-spend goes back to the lots it came from, expiry intact; over-spend takes the difference and can still refuse. Never debit after a hold — that charges twice.
- create_checkout_session — stapel_billing.services.create_checkout_session
  instead of: stripe.checkout.Session.create
  Open a paid checkout for a catalogue package or plan and get back (checkout_url, session_id), provider-agnostically. Use it rather than building a session at the processor: this is what stamps the user_id/package/plan metadata that the webhook handlers reconcile against later, and a session assembled by hand comes back from the provider carrying nothing to grant credits on.
- create_customer_portal — stapel_billing.services.create_customer_portal
  instead of: stripe.billing_portal.Session.create
  URL of the processor's self-service billing portal for a customer id — the sanctioned destination for "manage my card, invoices and cancellation". Reach for it instead of calling the provider's portal API directly, and instead of rebuilding those screens in a product.
- credit — stapel_billing.services.credit
  instead of: stapel_billing.models.Transaction.objects.create, stapel_billing.models.Wallet.balance
  Add credits to a wallet AND write the immutable ledger row that explains them — one transaction, under the wallet's row lock. This is the only sanctioned way credits come into existence: a product that creates its own Transaction, or bumps Wallet.balance, breaks the balance_after invariant that every audit, refund and GDPR export reads back.
- current_period_key — stapel_billing.services.current_period_key
  instead of: datetime.datetime.strftime
  The key naming the current bundle period ('2026-08'). It is grant_plan_bundle's idempotency unit, so spell it with this rather than formatting a date: two spellings of one month are two bundles.
- debit — stapel_billing.services.debit
  instead of: stapel_billing.models.Transaction.objects.create, stapel_billing.models.Wallet.balance
  Spend credits, refusing with InsufficientCreditsError rather than letting a balance go negative — the charging primitive behind every metered feature. Pass idempotency_key from anything delivered at-least-once (comm, webhooks, retried jobs) and a duplicate call returns the original transaction instead of charging twice; a hand-written ledger write has neither the lock nor that guarantee.
- default_plan_bundle_entitlements — stapel_billing.services.default_plan_bundle_entitlements
  instead of: stapel_billing.models.Subscription.objects.filter
  The stock answer to 'who gets a non-provider bundle': every wallet whose plan bundles credits Stripe is not already granting. Wrap it when writing your own resolver instead of re-deriving the Stripe-vs-local rule that stops a subscriber being granted twice.
- get_billing_beat_schedule — stapel_billing.tasks.get_billing_beat_schedule
  Spread `**get_billing_beat_schedule()` into CELERY_BEAT_SCHEDULE to wire all three billing workers at once (credit expiry, abandoned-hold release, reconciliation). Hand-authoring the entries is how expire_credit_lots gets silently dropped.
- get_credit_packages — stapel_billing.catalog.get_credit_packages
  instead of: django.conf.settings.STAPEL_BILLING["CREDIT_PACKAGES"], stapel_billing.catalog.DEFAULT_CREDIT_PACKAGES
  The resolved credit-package catalogue — host overrides applied, dicts coerced to CreditPackage. Read packages through this (or the lazy CREDIT_PACKAGES / CREDIT_PACKAGES_BY_SLUG views) instead of reaching into the settings dict: a deployment that sells its own packages has already replaced them here, while DEFAULT_CREDIT_PACKAGES is only what an unconfigured project falls back to.
- get_or_create_wallet — stapel_billing.services.get_or_create_wallet
  instead of: stapel_billing.models.Wallet.objects.get_or_create
  The one way to reach a user's Wallet — created on first touch, so no caller has to decide what "a user who never paid" means. Read balances through it instead of querying or creating Wallet rows yourself; a hand-made row is harmless, a hand-set balance is a ledger its transactions no longer explain.
- get_plans — stapel_billing.catalog.get_plans
  instead of: django.conf.settings.STAPEL_BILLING["PLANS"], stapel_billing.catalog.DEFAULT_PLANS
  The resolved subscription-plan catalogue, each entry carrying the entitlements map the billing.check_entitlement Function answers from. Read plans through this rather than from settings or DEFAULT_PLANS, or a product will price its pages and gate its features against a catalogue this deployment does not actually sell.
- get_provider — stapel_billing.services.get_provider
  instead of: stapel_billing.providers.stripe.StripeProvider, stripe.api_key
  Resolve the configured PaymentProvider (STAPEL_BILLING["PAYMENT_PROVIDER"], Stripe by default) — the entry point for a billing flow this module does not already serve. Reach for it instead of importing the Stripe SDK or instantiating StripeProvider: a deployment that swapped the processor, and a test that configured none, both have to arrive through here.
- grant_plan_bundle — stapel_billing.services.grant_plan_bundle
  instead of: stapel_billing.services.credit
  Grant one period's plan bundle WITHOUT a payment provider — the free tier, an invoiced agreement, a plan a host assigns itself. Idempotent per (wallet, plan, period), and the lot expires with the period. Every other grant here runs off a Stripe webhook, so a plan not sold through Stripe granted nothing until you call this.
- hold — stapel_billing.services.hold
  instead of: stapel_billing.services.debit
  Reserve credits BEFORE work whose real cost is only known afterwards. The credits leave the lots immediately, so two concurrent requests cannot both spend the last one; nothing is billed until capture. Use it instead of debiting an estimate — a refund that never runs because the worker died is a charge for work nobody did.
- live_lots — stapel_billing.services.live_lots
  instead of: stapel_billing.models.CreditLot.objects.filter
  The wallet's live credit lots in the exact order debit() will spend them — expiring soonest, non-expiring last — read without taking the row lock. Report expiry through it (a "3000 credits expire on the 28th" banner, an admin screen) instead of querying CreditLot: a product that orders the lots itself has forked the consumption rule, and will show a deadline the charge does not honour.
- open_debts — stapel_billing.services.open_debts
  instead of: stapel_billing.models.CreditDebt.objects.filter
  One wallet's uncollected debts, oldest first — the order they are collected in. Read it to show an owner why their next top-up will partly disappear; the collection itself happens inside credit().
- open_holds — stapel_billing.services.open_holds
  instead of: stapel_billing.models.CreditHold.objects.filter
  The reservations still holding a wallet's credits (status=held) — the credits already gone from the spendable balance and not yet billed. Ask it what is outstanding rather than filtering CreditHold by hand: captured, released and expired holds are history, and counting them is how a product invents a second, wrong 'available balance'.
- outstanding_debt — stapel_billing.services.outstanding_debt
  instead of: stapel_billing.models.CreditDebt.objects.aggregate
  Total credits a wallet owes, as one integer — what stands between its balance and what the owner can spend next period. 0 for almost every wallet.
- plan_bundle_entitlements — stapel_billing.services.plan_bundle_entitlements
  instead of: django.conf.settings.STAPEL_BILLING["PLAN_BUNDLE_ENTITLEMENTS"]
  The configured entitlement resolver, normalised to {user_id, plan} rows and tolerant of one bad row. Call this, not the setting's callable, if you drive the grants from your own scheduler.
- release — stapel_billing.services.release
  instead of: stapel_billing.services.credit
  Give a hold's credits back when the work failed. Each portion returns with the expiry of the lot it came from, so a released subscription bundle does not become credits that never expire. Idempotent through the hold; skipping it locks the credits until expire_holds notices.

## Extension points — what a product replaces, fork-free
- AUTH_USER_MODEL [swappable_model]
  Standard Django user swap — wallet/subscription rows bind to settings.AUTH_USER_MODEL; the module never references a concrete user class.
- CREDIT_PACKAGES [catalog_override]
  Replace the one-off credit package catalog from settings (list of dicts or CreditPackage instances) — re-read lazily on every access, no fork.
- PAYMENT_PROVIDER [dotted_path]
  Swap the payment backend: implement the PaymentProvider ABC (checkout, portal, cancel, webhook verify) in the host project and point the setting at it.
- PLANS [catalog_override]
  Replace the subscription plan catalog from settings (list of dicts or PlanCatalogEntry instances) — re-read lazily on every access, no fork.
- PLAN_BUNDLE_ENTITLEMENTS [dotted_path]
  Decide who receives a plan bundle that no payment provider grants (the free tier, an invoiced agreement, a plan the host assigns itself). Point the setting at a callable yielding {user_id, plan} rows; the default reads the local Subscription row, which is only right for hosts that keep plan membership there.
- serializer_seams [class_override]
  Every billing view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-auth (optional) — issues the authenticated user sessions the wallet/checkout/subscription endpoints require and the user.deleted event this module consumes; the model binding itself is the standard AUTH_USER_MODEL swap
- stapel-core (required) — comm bus (payment.completed / subscription.changed emits, user.deleted consumer), JWT authentication, GDPR provider registry, AppSettings config layer

## HTTP operations (10) — call by operationId, never by a typed path
Paths are relative to `/billing/api/v1/`.
### Catalog
- GET /products — billing_api_v1_products_retrieve
### Checkout
- POST /checkout — billing_api_v1_checkout_create
- GET /portal — billing_api_v1_portal_retrieve
### Internal
- POST /internal/debit — billing_api_v1_internal_debit_create
### Subscription
- POST /subscription/cancel — billing_api_v1_subscription_cancel_create
- GET /subscription — billing_api_v1_subscription_retrieve
### Wallet
- PATCH /wallet — billing_api_v1_wallet_partial_update
- GET /wallet — billing_api_v1_wallet_retrieve
- GET /wallet/transactions — billing_api_v1_wallet_transactions_retrieve
### Webhooks
- POST /webhooks/stripe — billing_api_v1_webhooks_stripe_create

## Error codes (55) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.amount_invalid [400] fix_input
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.invalid_package [400] fix_input
- error.400.invalid_plan [400] fix_input
- error.400.invalid_stripe_signature [400] contact_support
- error.400.invalid_webhook_payload [400] contact_support
- error.400.redirect_url_not_allowed [400] contact_support
- error.400.redirect_url_not_configured [400] contact_support
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.insufficient_credits [402] fix_input
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.forbidden_billing [403] contact_support
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.subscription_not_found [404] fix_input
- error.404.transaction_not_found [404] fix_input
- error.404.verification_challenge_not_found [404] verify
- error.404.wallet_not_found [404] fix_input
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.409.duplicate_webhook_event [409] retry
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
