Skip to content

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}/billing answers 200 with "enabled": false, which is what the billing page reads to say so instead of offering a checkout;
  • POST .../billing/checkout and .../billing/portal answer 501 billing is not configured and write nothing;
  • GET /api/plans still 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.
  • priceId must exist in the same Stripe account as the key.
  • amount and currency are for display only. What gets recorded is re-read from Stripe, so a coupon or a tax rate never desynchronises.
  • In development, leaving BILLING_PLANS empty falls back to demo plans so the UI has something to render. In production it means "sells nothing", and a demo ..._placeholder price 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_SECRET

In 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 ​

EventWhat the app does
checkout.session.completedfulfils the sale: subscription state, or a purchases row for a one-time payment
checkout.session.async_payment_succeededsame, for deferred methods (SEPA) once the funds clear
checkout.session.async_payment_failedemails the organization's owners
customer.subscription.created/updated/deletedsyncs status, plan and period end
invoice.paidsyncs the subscription behind the invoice
invoice.payment_failedsyncs, then emails the owners
charge.refundedstamps purchases.refunded_at — the row stays, it is what reconciles with Stripe
charge.dispute.createdlogs 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 .env next to the labs compose file and set STRIPE_API_BASE= (empty) to reach the real test API, then point stripe listen at the app.
  • stripe trigger charge.refunded and 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:

  1. internal/billing/ and internal/http/handlers/billing.go
  2. the billing routes in internal/http/server.go (/api/plans, /api/billing/webhook, and the three under /api/orgs/{orgID})
  3. web/src/views/BillingView.vue and its route in web/src/router/index.ts
  4. the subscriptions, purchases and processed_events tables, plus the billing metrics in internal/http/handlers/admin.go
  5. the STRIPE_* and BILLING_PLANS entries in .env.example

make test && make lint tells you what you missed.

Commercial license — you buy it once, the code is yours.