add-billing-provider
Implement a real payment provider adapter (Stripe, Paddle) behind Mathiz's billing.Provider interface, wire it into serve, and verify the money loop. Use when adding a payment provider, changing webho
SKILL.md.claude/skills/add-billing-provider/SKILL.md
GitHubAdding a billing provider adapter
Read specs/14-monetisation.md first — §4 (the abstraction) and the
"Provider decision: Stripe" section are binding. The interface is
deliberately thin: if your adapter seems to need more than the three
methods, the answer is almost always "keep it private to the adapter
file", not "widen billing.Provider".
The contract (internal/saas/billing/billing.go)
Provider: Name / CreateCheckout / PortalURL / ParseWebhook →
[]Event with types subscription_activated | subscription_renewed | subscription_canceled | topup_purchased. Everything downstream —
credit grants, plan-credit expiry on renewal, billing_states updates —
is billing.Service.Apply's job. An adapter never touches the credits
ledger directly, and entitlements never live provider-side.
Event.EventID becomes the ledger idempotency source (sub:<id> /
topup:<id>), so it must be the provider's stable event ID — never a
generated UUID, or webhook retries double-grant.
Recipe (Stripe as the worked example)
internal/saas/billing/stripe.go— implement the four methods:CreateCheckout: Checkout Session —mode=subscriptionwhenPlan.Subscription(),mode=paymentfor top-ups. Price fromPlan.ProviderPriceID(envMATHIZ_BILLING_PRICE_<PLAN>). PutFamilySpaceID+PlanIDin session metadata (you need them back in the webhook). Success/cancel URLs =CheckoutParamspaths resolved againstMATHIZ_PUBLIC_BASE_URL.PortalURL: a Billing Portal session for the stored customer ID.ParseWebhook: verify theStripe-Signatureheader against a newMATHIZ_STRIPE_WEBHOOK_SECRETenv var — reject unsigned/tampered payloads with an error (the handler turns that into 400). Map:checkout.session.completed→ activated/topup (by mode),invoice.paid→ renewed,customer.subscription.deleted→ canceled. Ignore (return zero events for) event types we don't model.- Per the spec: no metered billing, no Stripe credit grants.
- Wire it: extend the
MATHIZ_BILLING_PROVIDERswitch incmd/serve.go(thefakearm is the template — removestripefrom the "unsupported" error message). New env vars go ininternal/saas/server/config.go,.env.example,docker-compose.yml, and CLAUDE.md's env list. - Leave the
fakeprovider untouched — it is the dev UX and the test double for everything above the adapter.
Tests / verification
- Unit-test
ParseWebhookwith synthesized payloads: valid signature, tampered signature (rejected), each mapped event type, unmapped types ignored. - Replay safety: apply the same event twice through
billing.Service.Applyand assert the balance is unchanged the second time. The lifecycle pattern to copy isTestBillingLifecycleininternal/saas/server/billing_api_test.go. go test -race ./internal/saas/..., then theverifyskill matrix.- Live smoke (needs user-supplied Stripe test-mode keys; never commit
them): checkout a plan, confirm the webhook lands and
GET /api/v1/family/{id}/billingshows the new balance/plan/status, then resend the webhook from the Stripe dashboard and confirm no double-grant.stripe listen --forward-to localhost:8080/api/v1/billing/webhookbridges webhooks to a local server.
Invariant checklist before committing
- Adapter never grants/debits credits itself — only
Service.Applydoes - Every
Event.EventIDis the provider's stable event ID - Invalid/unsigned webhook → error → 400 (never a silent 2xx)
- Kid surfaces untouched: no prices/balances outside the parent dashboard
- Empty
MATHIZ_BILLING_PROVIDERstill boots with billing off (free) - Secrets via env only; test keys never committed