Skip to content

ADR 0031 Billing With Stripe Adapter

github-actions[bot] edited this page Oct 16, 2026 · 21 revisions

ADR 0031: Billing with a Stripe adapter

Status

Accepted (2026-10-10)

Context

Phase 4 ("Flotte & Abo") separates money from permission: Entitlements answers what an account may do (ADR 0028), a Billing service handles payment (project plan, "Abo & Zahlung", "Dienste im Überblick"). The plan asks for: billing.enabled=false means Billing is not started at all; plans stay data (in Entitlements) with trial days; Stripe Checkout and the Stripe customer portal first, other providers (Paddle, invoices) behind the same PaymentProvider interface ("Erweiterbarkeit"); no card data in the system; webhooks with a signature check, each message processed exactly once; on payment problems a grace period, then only viewing and the emergency stop, never a mower stopping in the middle of the garden. Billing is owner only (billing.manage, OpenFGA can_manage_billing: the owner or a member granted it, admins excluded), businesses get invoices with company data and a tax id. Legal texts (Impressum, AGB, Datenschutz, AVV) and VAT/OSS are part of the phase but need the operator's data. No real Stripe account is available while building: the work is verified with stripe-mock and signed webhook fixtures; the owner adds test keys later.

Decision

A separate, optional service. services/billing (FastAPI, own PostgreSQL database billing) runs only where billing is enabled: Compose starts billing-migrate and billing only with the profile billing, the Helm chart renders them only with services.billing.enabled=true. There is no "off" mode inside the service, so a home installation runs no billing code. The web app probes the public GET /api/v1/billing/status: a network error, a 404 or a non-JSON answer (the SPA fallback) hides every billing element; nothing else in the stack knows whether Billing exists. Entitlements keeps its own switch (MOWDECK_ENTITLEMENTS_BILLING_ENABLED); the operator enables both.

PaymentProvider is the extension point. The port has create_customer, create_checkout(account, customer, plan, price, success_url, cancel_url, trial_days) -> url, create_portal(customer, return_url) -> url, parse_webhook(payload, signature) -> ProviderEvent, fetch_subscription, cancel_subscription and fetch_price. ProviderEvent is provider independent (subscription, invoice, checkout parts). Two implementations: StripeProvider on the Stripe Python SDK 16 (StripeClient, async methods over httpx, API version 2026-09-30.endive, base_addresses for stripe-mock) and FakeProvider (no network, for tests and demos). The fake speaks Stripe's webhook format and verifies the same signature, so the full webhook path runs without an account (python -m billing.cli send-fixture).

Stripe Checkout and the customer portal, nothing hosted by us. One Stripe customer per account, created with metadata.account_id and an idempotency key per account. Checkout runs in mode=subscription with the plan's price; client_reference_id, metadata and subscription_data.metadata carry the account id and the plan, so every later subscription event names them. The trial (subscription_data.trial_period_days) is the plan's trial_days from Entitlements' public plan list, for accounts that never had a subscription. Business accounts (kind from identity.account.created; accounts of unknown kind are treated like businesses) give a tax id and a billing address (tax_id_collection, billing_address_collection=required, customer_update for name and address); automatic_tax (Stripe Tax for EU VAT and OSS) is a setting. An account with an open subscription gets 409 on checkout: plan changes, payment methods, invoices and cancellation happen in the portal. Prices are data too: infra/billing/prices.yaml maps plan keys to Stripe price ids (MOWDECK_BILLING_PRICE_IDS overrides it with the operator's ids); names and trial days stay in Entitlements.

Webhooks: signature first, each event once. POST /api/v1/billing/webhooks/stripe has no token; the Stripe-Signature header is verified over the raw body with the endpoint secret (300 s tolerance) before anything is parsed, and Traefik rate limits the path. Every event id is inserted into webhook_events (primary key provider and event id) and claimed with a lease before it is handled; a processed event answers 200 with duplicate: true. When Entitlements does not answer, the claim is released and the endpoint answers 500, so Stripe retries. Delivery order is not guaranteed: an event older than the last one applied to its subscription is stored as stale. Handled types: checkout.session.completed (remember the customer, fetch the subscription), customer.subscription.created|updated|deleted, customer.subscription.trial_will_end (published only), invoice.paid and invoice.payment_failed (invoice references with the hosted and PDF links; the documents stay at Stripe). Field paths follow the pinned API version (period on the subscription items, the invoice's subscription under parent.subscription_details), with the older fields as fallback.

Status mapping and Entitlements. trialing sets trial until the trial end, active sets active, past_due sets grace until the end of the last paid period plus MOWDECK_BILLING_GRACE_DAYS (14), canceled, unpaid, incomplete_expired and paused set grace while that point lies ahead and expired after it, incomplete changes nothing. "End of the last paid period" is current_period_start for past_due and unpaid (a failed renewal has already moved current_period_end a period ahead) and ended_at (else current_period_end) for ended subscriptions; this is the plan's "period end" made precise. A late event of an old subscription never restricts an account with a newer running one. Billing sets the plan with a new internal core NATS request entitlements.plan.set (PlanSetRequest{account_id, plan, status, trial_until, grace_until, reason, account_name, reference} -> PlanSetReply{ok, plan, status, changed, error}), answered by Entitlements with source=billing, a history row (actor service:billing:<subscription>) and entitlements.plan.changed; a request equal to the stored row changes nothing, so retries are harmless. Core NATS is reachable by services only, like fleet.commands.issue. Billing publishes billing.subscription.changed (SubscriptionChanged) for a later billing notification; nothing consumes it yet.

Authorization. The account endpoints (GET /subscription, POST /checkout, POST /portal, GET /invoices) act on the token's account and need can_manage_billing on it, checked like other hidden objects: allowed for the owner (or a member granted billing.manage), 403 for a member who can view the account (admins), 404 otherwise. Billing defines no permission of its own. GET /status is public (a pricing view needs no login).

Legal and tax are the operator's. The web app ships placeholder pages (/legal/imprint, /legal/terms, /legal/privacy, with a visible "placeholder" banner), linked from the login page only when MOWDECK_WEB_LEGAL_ENABLED is on; Billing operations has the checklist (Stripe account, products and prices, webhook endpoint and secret, Stripe Tax and the OSS registration, Impressum, AGB, Datenschutz, AVV).

Considered: putting billing into Entitlements (rejected: money and permission are separate by the plan, and a home installation would carry payment code), polling Stripe instead of webhooks (rejected: the plan scales Billing by webhooks, and polling adds delay and API load), Stripe Billing's embedded components (rejected for now: hosted Checkout and portal keep every payment page and card field at Stripe).

Consequences

  • Home installations are unchanged: no billing container, no route, no UI.
  • An installation that sells plans sets COMPOSE_PROFILES=billing, ENTITLEMENTS_BILLING_ENABLED=true, the Stripe keys and the price ids, and adds the webhook endpoint in Stripe; nothing else is configured per plan.
  • Payment problems never stop a mower: grace and expired keep viewing and the emergency stop (ADR 0028).
  • stripe-mock verifies every request against Stripe's OpenAPI document but keeps no state and sends no webhooks; the webhook path is covered by signed fixtures. A run against Stripe test mode (Stripe CLI stripe listen, stripe trigger) is pending the owner's keys.
  • Follow-ups: a billing notification kind from billing.subscription.changed, account deletion cancelling subscriptions (BillingService.cancel exists), further providers behind PaymentProvider, real legal texts by the operator.

Mirrored from docs/adr/0031-billing-with-stripe-adapter.md at commit a117684672263ba4f0b7e2e3538233456122d24e. Edit the file in the repository, not this wiki page.

MowDeck documentation

Clone this wiki locally