Ops Billing - lukislp/mowdeck GitHub Wiki
Billing (Stripe Checkout, the customer portal and signed webhooks that set plans in Entitlements) is optional per installation and off by default: without it the Billing service is not started, every account keeps the plan unlimited and the web app shows nothing billing related. This page is the operator's checklist for selling plans; the design is ADR 0031, the service reference is services/billing/README.md.
Billing needs data only the operator has. Nothing here is legal or tax advice; have the texts checked.
| Step | What | Where |
|---|---|---|
| 1 | Stripe account; start in test mode (keys sk_test_...), switch to live keys only after a full test run |
dashboard.stripe.com |
| 2 | One product per plan (home, fleet) with a recurring price (e.g. monthly, in EUR); note the price ids price_...
|
Stripe: Product catalogue |
| 3 | Price ids into MowDeck: BILLING_PRICE_IDS=home=price_...,fleet=price_... (or infra/billing/prices.yaml) |
.env / Helm config.BILLING_PRICE_IDS
|
| 4 | Customer portal: enable it and choose what customers may do (update payment method, view invoices, switch between your plan prices, cancel at period end) | Stripe: Settings, Billing, Customer portal |
| 5 | Webhook endpoint https://<your host>/api/v1/billing/webhooks/stripe with the events checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, customer.subscription.trial_will_end, invoice.paid, invoice.payment_failed; copy its signing secret whsec_...
|
Stripe: Developers, Webhooks |
| 6 | Secrets: BILLING_STRIPE_SECRET_KEY (a restricted key with write access to Customers, Checkout Sessions, Customer portal, Subscriptions and read access to Prices is enough) and BILLING_STRIPE_WEBHOOK_SECRET; in production as files (MOWDECK_BILLING_STRIPE_SECRET_KEY_FILE) or Helm secrets.BILLING_STRIPE_*
|
.env / Helm |
| 7 | Stripe Tax for EU VAT: confirm the head office address, choose the product tax code and the tax behaviour (inclusive or exclusive), add your registrations (your country, and the EU OSS registration once cross-border B2C sales exceed EUR 10,000 a year); then BILLING_AUTOMATIC_TAX=true. Without a registration Stripe calculates 0 tax. B2B customers with a valid EU VAT id get the reverse charge |
Stripe: Tax; your tax office (OSS registration at the BZSt in Germany) |
| 8 | Invoices: company name, address and VAT id on invoices (Stripe: Settings, Business details and Invoice template); business customers enter their tax id in Checkout (MowDeck asks business accounts for it) | Stripe |
| 9 | Impressum (company, address, contact, register entry, VAT id, responsible person) | apps/web/static/legal/imprint.md |
| 10 | AGB / terms (subscription term and renewal, prices incl. VAT, payment, cancellation, the consumer's right of withdrawal, liability, governing law) | apps/web/static/legal/terms.md |
| 11 | Datenschutz / privacy policy (controller, purposes and legal bases, processors: hosting and Stripe as payment processor, retention, rights, supervisory authority) | apps/web/static/legal/privacy.md |
| 12 | AVV / data processing agreements: with your hosting provider and with Stripe (Stripe's DPA is part of its services agreement); for business customers who process their staff's data in MowDeck, offer your own AVV | contracts |
| 13 | Show the legal pages: WEB_LEGAL_ENABLED=true links Imprint, Terms and Privacy from the login page (the shipped files are placeholders with a visible banner until you replace them) |
.env / Helm config.WEB_LEGAL_ENABLED
|
| 14 | Enable: COMPOSE_PROFILES=billing and ENTITLEMENTS_BILLING_ENABLED=true (Helm: services.billing.enabled=true, config.ENTITLEMENTS_BILLING_ENABLED="true"), BILLING_APP_URL=https://<your host>; optionally add http://billing:8000 to Identity's module URLs (MOWDECK_IDENTITY_MODULE_URLS) so its descriptor is listed |
.env / Helm |
The legal pages are served from the web image (/legal/<page>.md); to replace them, build the image with your files or mount them read-only over /usr/share/nginx/html/legal/{imprint,terms,privacy}.md of the web container (served with no-cache, never precached by the service worker).
# infra/compose/.env
COMPOSE_PROFILES=billing
ENTITLEMENTS_BILLING_ENABLED=true
BILLING_PROVIDER=stripe
BILLING_STRIPE_SECRET_KEY=sk_test_...
BILLING_STRIPE_WEBHOOK_SECRET=whsec_...
BILLING_PRICE_IDS=home=price_...,fleet=price_...
BILLING_APP_URL=https://mowdeck.example.org
docker compose -f infra/compose/docker-compose.yml up -d --build --wait
curl https://mowdeck.example.org/api/v1/billing/statusGET /api/v1/billing/status lists the plans on sale with the prices fetched from Stripe. Without the profile the route does not exist (404) and the web app hides the billing section. docker compose config --services shows billing and billing-migrate only with the profile.
-
Install the Stripe CLI and
stripe login. -
Forward webhooks to the local stack and copy the printed secret into
BILLING_STRIPE_WEBHOOK_SECRET(it stays the same across restarts;stripe listen --print-secretprints it again):stripe listen --forward-to localhost/api/v1/billing/webhooks/stripe \ --events checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,customer.subscription.trial_will_end,invoice.paid,invoice.payment_failed
-
Start the stack with the profile, log in as an account owner, open the account page: the plans appear with their prices. "Upgrade" opens Stripe Checkout; pay with the test card
4242 4242 4242 4242(any future date, any CVC). Back on the account page the plan showstrial(oractivewithout a trial) with sourcebilling; the operator console's account history showsservice:billing:sub_.... -
"Manage subscription" opens the customer portal; cancel there and watch
customer.subscription.updated(cancel at period end) arrive. -
Payment problems: use the test card
4000 0000 0000 0341(attaches, then fails), orstripe trigger invoice.payment_failed; the subscription goespast_dueand the accountgrace(viewing and emergency stop only). Paying again (update the card in the portal) restoresactive.customer.subscription.trial_will_endcannot be triggered: use a subscription with a short trial or Stripe test clocks. -
Replays:
stripe events resend evt_...delivers an event again; the answer says"duplicate": trueand nothing changes.
-
Fake provider (local demo, nothing charged):
BILLING_PROVIDER=fake, anyBILLING_STRIPE_WEBHOOK_SECRET, price ids of your choice. Checkout and the portal return links back to the account page; send signed, Stripe shaped webhooks withdocker compose -f infra/compose/docker-compose.yml --profile billing exec billing \ python -m billing.cli send-fixture --account-id <account uuid> --status past_due
(
--status active|trialing|past_due|unpaid|canceled|...; the account id is inGET /api/v1/identity/me). -
stripe-mock (
stripe/stripe-mock, pinned in.github/workflows/ci.ymlfor the python job):BILLING_STRIPE_API_BASE=http://stripe-mock:12111with anysk_test_...key validates every request against Stripe's API definition, but it keeps no state and sends no webhooks.
- Logs:
webhook event handled(event id, type, outcome, account),webhook event already handled(a replay),checkout started. The ledger is thewebhook_eventstable of thebillingdatabase (outcomesapplied,ignored,stale,unknown_account,rejected,failed). - Stripe retries failed deliveries for up to three days; a 500 from MowDeck (Entitlements not reachable) is retried automatically.
rejected(e.g. a price whose plan is unknown to Entitlements) is not retried: fix the price ids and resend the event from the Stripe dashboard. - The webhook route is rate limited per client address in Traefik (120 per minute, burst 60); Stripe retries a 429. In Kubernetes attach the same middleware to the
-authIngress (ingress.auth.annotations), which carries the webhook path when Billing is enabled. - Disabling: remove the profile (and set
ENTITLEMENTS_BILLING_ENABLED=falseto give every accountunlimitedagain). Subscriptions keep running at Stripe until they are cancelled there.
Mirrored from docs/ops/billing.md at commit 94c46ef36cabac4c3a0bacfc0cc0c0af4defdca3. Edit the file in the repository, not this wiki page.