Repository navigation
ADR 0031 Billing With Stripe Adapter
Accepted (2026-10-10)
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.
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).
- 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:
graceandexpiredkeep 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
billingnotification kind frombilling.subscription.changed, account deletion cancelling subscriptions (BillingService.cancelexists), further providers behindPaymentProvider, 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.
Mirrored from the docs/ directory at commit a117684672263ba4f0b7e2e3538233456122d24e. Edit the files in the repository, not this wiki.
MowDeck documentation
- Home
- Phases
- Architecture
-
Decisions (ADR)
- Architecture decisions
- ADR 0001: OpenFGA instead of self-built authorization
- ADR 0002: Self-built Identity service in FastAPI instead of Keycloak or Zitadel
- ADR 0003: SvelteKit for the web app
- ADR 0004: Monorepo with a uv workspace
- ADR 0005: AGPL-3.0 for services and apps, Apache-2.0 for libraries
- ADR 0006: PostgreSQL for all persistence, no SQLite
- ADR 0007: NATS JetStream as event bus
- ADR 0008: Traefik as gateway
- ADR 0009: Command and event envelopes, subject naming and de-duplication
- ADR 0010: Python 3.13 for all services and libraries
- ADR 0011: Separate GPL-3.0 package for the PyMammotion adapter
- ADR 0012: Mower control interface and safety rules
- ADR 0013: Device-Gateway registry, sharding and per-mower concurrency
- ADR 0014: Authorization model, tuple ownership and the mowdeck-authz client
- ADR 0015: Tokens, sessions and key rotation
- ADR 0016: Fleet commands and authorization per command kind
- ADR 0017: Telemetry storage and retention
- ADR 0018: Realtime WebSocket protocol
- ADR 0019: Mammotion adapter transports, manual drive and secrets
- ADR 0020: Web app architecture
- ADR 0021: Browser authentication flow
- ADR 0022: Notifications: rules, recipients and delivery
- ADR 0023: Map rendering and projection
- ADR 0024: Scheduler: recurrence, rain pause and command issuing
- ADR 0025: Home Assistant integration as a pure API client
- ADR 0026: Manual drive channel over the Realtime WebSocket
- ADR 0027: Health history and theft alarm
- ADR 0028: Entitlements: plans as data and enforcement
- ADR 0029: Helm chart and release pipeline
- ADR 0030: Mowing patterns as plug-ins and the Planner
- ADR 0031: Billing with a Stripe adapter
- ADR 0032: Live camera over Agora, joined directly by the browser
- ADR 0033: Vision-assisted recovery with human approval
- ADR 0034: Linked cloud accounts and device discovery
- ADR 0035: Server-side camera frames and automatic AI analysis on errors
- ADR 0036: Native app shells with Capacitor, loading the web app from the server
- ADR 0037: Camera panorama calibration in the browser, with the AI supplying points only
- ADR 0038: Dual link per mower and firmware updates
- ADR 0039: Spot mowing by MowDeck-driven paths
- ADR 0040: Link heartbeat for online status
- ADR 0041: Spot runs from the dock: undock, transit and return
- ADR 0042: Drive calibration
- ADR 0043: Turn styles
- ADR 0044: Drive view with camera
- Development
- Operations
- Spikes
- Guides
- Original plan (German)