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.