# stapel-billing 0.7.0

Payments and billing: per-user credit wallets with an immutable transaction ledger, one-off credit packages and recurring subscription plans, Stripe-backed checkout and self-service customer portal, idempotent webhooks and a service-to-service debit endpoint.

Contract: axes 1 · surface 15 · extension points 5 · operations 10 · error codes 54.
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
- 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.
- 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_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.
- 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
- 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.
- 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.
- 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.

## 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.
- 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 (54) — 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
