Appearance
Stripe
Billing ships with the kit and is off until you give it a key. This page covers turning it on from nothing, what it does once on, and how to remove it entirely if you sell elsewhere.
Without a key
Nothing to do, and nothing broken:
- the app boots, signs users in, and runs every other feature;
GET /api/orgs/{id}/billinganswers200with"enabled": false, which is what the billing page reads to say so instead of offering a checkout;POST .../billing/checkoutand.../billing/portalanswer501 billing is not configuredand write nothing;GET /api/plansstill serves your plan catalogue — pricing is configuration, not a Stripe capability.
This path is covered by TestBillingDisabledWithoutAStripeKey, because it is the configuration most deployments actually run.
Turning it on
1. Keys
Create the product and its prices in the Stripe dashboard, then take your test key from https://dashboard.stripe.com/test/apikeys:
bash
STRIPE_SECRET_KEY=sk_test_...Billing switches on as soon as this is set. Everything below is what makes it useful.
2. Plans
Plans live in the environment, not in the database — Stripe owns prices, the app maps price IDs to plan keys:
bash
BILLING_PLANS='[
{"key":"pro","name":"Pro","priceId":"price_...","amount":2900,
"currency":"eur","interval":"month","features":["Everything"]},
{"key":"lifetime","name":"Lifetime","priceId":"price_...","amount":19900,
"currency":"eur","mode":"payment","features":["One-time payment"]}
]'interval(month/year) makes a subscription;"mode":"payment"makes a one-time product.priceIdmust exist in the same Stripe account as the key.amountandcurrencyare for display only. What gets recorded is re-read from Stripe, so a coupon or a tax rate never desynchronises.- In development, leaving
BILLING_PLANSempty falls back to demo plans so the UI has something to render. In production it means "sells nothing", and a demo..._placeholderprice ID refuses to boot.
3. Webhook
Stripe must reach POST /api/billing/webhook. Locally:
bash
stripe listen --forward-to localhost:8080/api/billing/webhook
# prints whsec_... → STRIPE_WEBHOOK_SECRETIn production, create the endpoint in the dashboard, subscribe to the events below, and copy its signing secret into STRIPE_WEBHOOK_SECRET. Without the secret every delivery is rejected: a signature is the only thing that authenticates a webhook.
4. Events to subscribe to
| Event | What the app does |
|---|---|
checkout.session.completed | fulfils the sale: subscription state, or a purchases row for a one-time payment |
checkout.session.async_payment_succeeded | same, for deferred methods (SEPA) once the funds clear |
checkout.session.async_payment_failed | emails the organization's owners |
customer.subscription.created/updated/deleted | syncs status, plan and period end |
invoice.paid | syncs the subscription behind the invoice |
invoice.payment_failed | syncs, then emails the owners |
charge.refunded | stamps purchases.refunded_at — the row stays, it is what reconciles with Stripe |
charge.dispute.created | logs at Error level so your reporter (Sentry) raises it: a chargeback has a deadline |
Two rules hold for all of them, and are worth keeping if you add more: the event ID is inserted into processed_events in the same transaction as the business write, so a replay can never double-fulfil; and objects are re-fetched from the API by ID rather than trusted from the payload.
5. Local testing
- The labs runs stripe-mock, so checkout calls work without a Stripe account. It answers with fixtures and emits no webhook.
- To exercise webhooks, put real test keys in a
.envnext to the labs compose file and setSTRIPE_API_BASE=(empty) to reach the real test API, then pointstripe listenat the app. stripe trigger charge.refundedand friends replay a real event shape.
6. Going live
Swap the test key and webhook secret for live ones, point the dashboard endpoint at your production URL, and check that BILLING_PLANS carries live price IDs. Nothing else changes.
Removing billing entirely
If you sell elsewhere — or not at all — delete rather than configure:
internal/billing/andinternal/http/handlers/billing.go- the billing routes in
internal/http/server.go(/api/plans,/api/billing/webhook, and the three under/api/orgs/{orgID}) web/src/views/BillingView.vueand its route inweb/src/router/index.ts- the
subscriptions,purchasesandprocessed_eventstables, plus the billing metrics ininternal/http/handlers/admin.go - the
STRIPE_*andBILLING_PLANSentries in.env.example
make test && make lint tells you what you missed.