Billing (Stripe)
The template ships one first-class Stripe integration: subscriptions via Checkout + Customer Portal, webhook-driven local state, and an entitlement API the rest of the app calls. There is deliberately no multi-provider abstraction: payment providers differ at the merchant-of-record/legal layer, not the API layer, and an interface that pretends otherwise ships untested adapters. For Paddle or Lemon Squeezy, treat this module as the reference implementation and see Swapping providers.
Billing is optional as a whole: no STRIPE_SECRET_KEY → the module
is dormant, /pricing 404s, the settings section hides, the webhook
route 404s. STRIPE_SECRET_KEY without STRIPE_WEBHOOK_SECRET fails
boot, as does setting any STRIPE_* var without the secret key.
STRIPE_PRO_PRICE_ID is required only if you sell subscriptions.
Stripe dashboard setup
Everything the app needs from the Stripe side, in order. Stripe has two parallel environments: Test mode and Live mode (toggle, top right of the dashboard). Every step below exists separately in each: test keys/prices/webhooks for development and staging, live ones for production. Do the full list twice.
1. Product + price
Product catalog → Add product. One product ("Pro"), one recurring
monthly price. Copy the price id (price_…, on the price row, not the
product id prod_…) into STRIPE_PRO_PRICE_ID.
The amount you set here is what customers pay. The number on the
template's pricing page is display copy (app/pricing/PricingCards.tsx
and the landing-page tiers in app/page.tsx). Keep them in sync when
you change the price.
2. API key
Developers → API keys. Copy the secret key (sk_test_… /
sk_live_…) into STRIPE_SECRET_KEY. The publishable key (pk_…) is
not used: the template never touches Stripe from the browser; both
Checkout and the Portal are server-minted redirects.
For a tighter blast radius in production, create a restricted key (same page) instead, with write access to Checkout Sessions, Customers, Subscriptions, and Billing Portal, plus read access to Charges: that's the template's whole API surface.
3. Webhook endpoint
Developers → Webhooks → Add endpoint.
Endpoint URL:
https://<your-backend-host>/webhooks/stripe(the backend origin, not the frontend origin).Subscribe to exactly these eight events:
checkout.session.completed checkout.session.async_payment_succeeded checkout.session.async_payment_failed customer.subscription.created customer.subscription.updated customer.subscription.deleted charge.refunded charge.dispute.funds_withdrawnAfter creating it, reveal the signing secret (
whsec_…) on the endpoint's page →STRIPE_WEBHOOK_SECRET.
A missing event fails silently: the endpoint 200s everything it does
receive, it just never hears about the rest. In particular, an endpoint
without charge.dispute.funds_withdrawn never sets the dispute lock,
one without customer.subscription.deleted never downgrades churned
customers, and one without
checkout.session.async_payment_succeeded never fulfils a one-time
payment made with an asynchronous method (see the payment_status note in
the table below). Don't subscribe to "all events" either: the handler
skips unknown types harmlessly, but your audit log fills with
skipped_unhandled noise.
The Webhooks page shows delivery attempts and responses: it's the
first place to look when subscription state seems stale. Cross-check
against the billing_webhook_events table, which records what the
backend did with each delivery.
4. Customer Portal
Settings → Billing → Customer portal → Activate. Downgrades, cancellations, payment-method updates, and invoice history all happen here: the app deliberately reimplements none of it. Recommended configuration: allow customers to cancel subscriptions (immediately or at period end, your call), allow updating payment methods, show invoice history. With a single paid plan there's nothing to configure under plan switching.
The portal is per-mode too: activating it in test mode does not
activate it in live mode. An inactive portal makes POST /billing/portal
fail at request time.
5. Stripe Tax (optional)
Only if you set STRIPE_AUTOMATIC_TAX=true: complete Settings → Tax
first (origin address, registrations). With the flag on and Tax
unconfigured, every checkout fails at session creation. Remember tax
registration and filing stay with you: Stripe Tax calculates and
collects, it doesn't file.
6. backend/.env
STRIPE_SECRET_KEY=sk_test_… # step 2
STRIPE_WEBHOOK_SECRET=whsec_… # step 3 (or yarn dev:stripe locally)
STRIPE_PRO_PRICE_ID=price_… # step 1 (subscriptions only)
FRONTEND_URL=http://localhost:3000 # checkout/portal return origin
# STRIPE_AUTOMATIC_TAX=true # step 5, optional
Dev loop (no dashboard webhook needed)
Locally, the Stripe CLI replaces step 3:
cd backend && yarn dev:stripe # wraps: stripe listen --forward-to localhost:4000/webhooks/stripe
stripe listen prints a whsec_… signing secret on startup: that's
your dev STRIPE_WEBHOOK_SECRET (it differs from any dashboard
endpoint's secret). It forwards all events, so no event selection
applies in dev. Test cards: 4242 4242 4242 4242, any future
expiry/CVC. Trigger individual events with
stripe trigger checkout.session.completed.
Smoke test the full loop
With test keys configured and yarn dev:stripe running: sign in →
/pricing → Upgrade to Pro → pay with 4242 4242 4242 4242 → land on
/billing/success (it may show "Finishing up…" for a second while the
webhook races the redirect) → Settings shows Pro with a renewal date →
"Manage billing" opens the Portal → cancel there → Settings drops to
Free. If any step stalls, read the stripe listen output and the
billing_webhook_events table in that order.
Architecture
/pricing (RSC) → POST /billing/checkout → Stripe Checkout (hosted)
/settings billing section → POST /billing/portal → Customer Portal (hosted)
Stripe → POST /webhooks/stripe (raw body, signature-verified)
└→ billing_subscriptions (guarded upsert)
└→ billing_webhook_events (audit log, one row per delivery)
└→ billing_payment_hook_claims (payment-hook dedupe)
└→ billing_disputed_customers (chargebacks, so the
lock survives arriving before the row)
└→ BILLING_PAYMENT_HOOK (your app: grant on a
one-time payment, revoke on its reversal)
any feature → BillingService.getEntitlement(userId)
billing.config.ts: env parsing, enabled/partial detection, pinned API version.billing.service.ts: Stripe client, Checkout/Portal sessions,getEntitlement.billing-webhook.service.ts: event interpretation + dispatch.billing-subscription-store.ts: the ONLY writer of billing state -billing_subscriptionsplus thebilling_disputed_customersrecord that binds it. Idempotency cursor, disputed lock, race retry, same-second tie resolution.billing-events.service.ts: insert-only audit trail (swept after 30 days bysession-cleanup.worker).billing-payment-hook-claim-store.ts: BILLING_PAYMENT_HOOK dedupe - claims the Stripe event id before the hook runs (swept after 30 days bysession-cleanup.worker).billing-payment-hook.ts: theBILLING_PAYMENT_HOOKcontract your app implements.onPaymentCompletedis required,onPaymentReversedoptional.billing-status.ts: pure status/field mapping,grantsPaidAccess, andfieldsForEvent(the row shape an event type implies).
Webhook semantics
| Event | Effect |
|---|---|
checkout.session.completed (subscription) |
upsert plan/status/period |
checkout.session.completed (payment) |
fires BILLING_PAYMENT_HOOK (see below) unless payment_status is unpaid |
checkout.session.async_payment_succeeded |
the money landed: fires BILLING_PAYMENT_HOOK |
checkout.session.async_payment_failed |
logged processed, grants nothing |
customer.subscription.created / updated |
upsert plan/status/period |
customer.subscription.deleted |
plan=free, status=canceled |
charge.refunded (full only) |
plan=free, status=refunded (under the event cursor), plus onPaymentReversed: also cancel the subscription in Stripe when refunding; refunded isn't locked, so a still-active subscription's next event restores access |
charge.dispute.funds_withdrawn |
recorded in billing_disputed_customers, then plan=free, status=disputed (locked), plus onPaymentReversed. Recorded even with no row yet, so one created later is born locked |
| anything else | logged skipped_unhandled, 200 |
invoice.payment_failed is deliberately unhandled: Stripe moves the
subscription itself to past_due and fires customer.subscription.updated
for the same transition; a second writer on the invoice stream only adds
ordering hazards.
Idempotency. Subscription-stream writes go through a guarded upsert
(BillingSubscriptionStore): the UPDATE only lands if the row isn't
disputed, the exact event id hasn't been applied, and event.created is
strictly newer than the last applied event. First-delivery races resolve
via INSERT … ON CONFLICT DO NOTHING, then one more guarded pass (so a
strictly-newer event that lost the insert race still applies). Because
event.created is second-resolution, two distinct events can tie: the
store then fetches live subscription state from Stripe and writes that
(truth is order-independent), but only when both events name the
subscription the row already holds. A tie naming a different subscription
is stale, because the row is the newer one and fetching the rejected
event's subscription would overwrite a paying customer with the one that
lost.
Every delivery, applied, skipped (with the precise reason), or errored,
gets its own row in billing_webhook_events. One row per DELIVERY, not
per event id: Stripe redelivers, and a first attempt that errored
followed by a retry that processed is two rows, in order. Read one
event's full history with:
SELECT outcome, detail, created_at
FROM billing_webhook_events
WHERE event_id = '<evt_...>'
ORDER BY created_at;
That table deduplicates nothing. Deduplication lives where the work is:
the event cursor on billing_subscriptions and the claim row in
billing_payment_hook_claims.
The refund write shares that cursor, and it has to. refunded is
deliberately not a lock, so re-subscribing works self-serve, which makes
it the one write a redelivery could otherwise use to overwrite newer
state: refund → customer re-subscribes → Stripe redelivers the original
charge.refunded → the paying customer lands back on free. A refund
arriving after a newer event is stale by definition, so it is skipped.
The comparison is strict, the same as the subscription path, so a refund
sharing a second with a subscription event is skipped rather than applied.
That is deliberate: once a tied event moves last_event_id off the
refund, a non-strict comparison would stop recognising a redelivery of
that refund as a duplicate and wave it through onto a paying customer.
A logged skip beats a silent wrong write, and the case it leaves open is
the one the table above already tells you to handle by cancelling the
subscription in Stripe when you refund.
The chargeback write is the one deliberate exception. Funds have already left the account, so it lands whatever the cursor says: cursor-guarding it would let a dispute that opened before the last subscription event leave access switched on. For the same reason, the chargeback lock is written before the reversal hook runs, so a failing hook or a Stripe blip cannot leave paid access switched on for someone who took their money back.
Access policy. past_due keeps paid access (Stripe is retrying the
card; punishing a hiccup churns customers: if dunning fails Stripe
fires subscription.deleted). incomplete never grants (first payment
never confirmed). The effective plan is free whenever the status
doesn't grant paid access: a canceled pro row never leaks pro.
Disputes. When chargeback funds actually leave the account, the row
locks: Stripe keeps reporting the subscription as active during a
dispute, so without the lock the next subscription.updated would hand
access back to someone doing a chargeback. The lock is one-way.
The chargeback is also recorded in billing_disputed_customers, keyed on
the Stripe customer, and that record is what makes the lock survive
delivery order. A dispute can arrive before the subscription row exists -
the subscription delivery failed once and is in Stripe's retry backoff,
or the endpoint was briefly down and Stripe is working through a backlog.
The lock is an UPDATE, so it would match nothing, and the 200 it returns
means Stripe never sends the dispute again. The insert path reads this
table, so a row created after a chargeback is born disputed instead of
pro/active.
If you win the dispute (or want to forgive), unlock manually. Clear both, or the next subscription event re-locks the row:
UPDATE billing_subscriptions
SET status = 'canceled' -- they can re-subscribe self-serve
WHERE user_id = '<id>' AND status = 'disputed';
DELETE FROM billing_disputed_customers
WHERE stripe_customer_id = '<cus_...>';
Rows in billing_disputed_customers are never swept: a customer who
charged back once is worth knowing about when the same card comes back.
Gating features
One call, one branch: the Items module is the live example
(items.service.ts, free plan caps at 100):
const entitlement = await this.billingService.getEntitlement(userId);
if (!entitlement.paidAccess && count >= FREE_PLAN_ITEM_LIMIT) {
throw new ForbiddenException('Free plan limit reached. Upgrade to Pro.');
}
On deploys without billing, everyone is free-plan: gate on limits, not on "is billing enabled".
Account deletion
GDPR account deletion runs local erasure first, via
UsersService.ghostDelete - the legally binding half, which must never
be blocked by a Stripe outage or a bad key. Only after it succeeds does
UserDeletionWorker call BillingService.deleteCustomerData, a no-op
when billing is dormant or the user's billing_subscriptions row (at
most one, per the UNIQUE constraint on user_id) has no
stripe_customer_id yet. Otherwise it deletes that Stripe customer,
cascading cancellation of any live subscription. A non-missing Stripe
failure throws, so the queue retries the whole item: ghostDelete again
(idempotent and cheap) then deleteCustomerData again, since
ghostDelete never touches billing_subscriptions. Which tables the
local half reaches, and which it deliberately does not, is in
Architecture.
That retry is capped at five attempts, roughly 43 minutes (30s, 2min, 8min, 32min of backoff). After that the row is abandoned and nothing retries it. Because deleting the customer is also what cancels the subscription, an abandoned row means a deleted user may keep being billed every month, and their email and name are still on the Stripe Customer. An outage longer than that window, or a key rotated and not noticed, abandons every deletion in flight.
List them with admin_get_abandoned_deletions on the admin MCP server,
or directly:
SELECT id, user_id, status, attempts, last_error, updated_at
FROM user_deletion_queue
WHERE attempts >= 5; -- USER_DELETION_MAX_ATTEMPTS in users.service.ts
last_error is prefixed with the half that failed, and the two leave very
different things behind:
stripe cleanup: …- local erasure succeeded. An orphaned Stripe customer remains, and it may still be billing a deleted user.local erasure: …- the worse one. The user's own record was never anonymized, so their email, name and sessions are all still there. Erase that user by hand first; this is the legally binding half.status = 'processing'with a nulllast_error- the run died mid-item and recorded neither. Assume nothing and check the user.
Once the underlying cause is fixed (usually a restored API key), requeue them:
UPDATE user_deletion_queue
SET attempts = 0, status = 'pending', next_retry_at = NULL, last_error = NULL
WHERE attempts >= 5;
Cancel the subscription and delete the customer by hand in Stripe for any row you do not requeue. See Architecture for the worker mechanics.
The local row itself isn't touched by deletion directly. Where the
customer delete does happen, Stripe then emits
customer.subscription.deleted (already a handled event), and when that
delivery arrives the handler drops the row to plan=free, status=canceled through the normal webhook path. Until it arrives, or if
it never does, the row reads as it did before.
stripe_customer_id is then left pointing at a deleted Stripe
customer - a harmless dead pointer for a deleted account: the only
code that re-reads it (createCheckoutSession) is unreachable for a
ghosted user, who can no longer sign in.
Deletion erases no billing table, and where it reaches Stripe it adds
to one. Both effects below are downstream of the customer delete, so
they only happen when billing is on and the user had a
stripe_customer_id; otherwise deleteCustomerData returns early and
neither table moves. When it does run, billing_subscriptions is dropped
to free/canceled through the webhook path rather than cleared, and
billing_webhook_events gains a row for that cancellation carrying the
deleted user's user_id and a free-text detail, which the hourly
worker sweeps 30 days later.
billing_disputed_customers is the one that outlives even those
sweeps. It is keyed on the Stripe customer id, has no foreign key to
users, and is never swept, so a chargeback record outlives the account
it came from. That is deliberate: the reason to keep it is precisely that
the person may come back. Whether a Stripe customer id counts as personal
data you may retain on a legitimate-interest basis after an erasure
request is your call and your jurisdiction's, not the template's. If you
decide it does not, delete it alongside the account:
DELETE FROM billing_disputed_customers
WHERE stripe_customer_id = '<cus_...>';
One-time payments
The template handles receiving and reversing a one-time payment.
Creating the mode: 'payment' Checkout Session is yours to write. There
is no one-time checkout endpoint and no product catalog here, because a
checkout endpoint that takes a price id from the client lets the client
name its own price. The id has to be resolved server-side from something
the client cannot choose - a product key mapped to price ids in your
config, a row in your own products table, a plan the user already owns -
and that resolution is the part only you can write.
The receiving side is all here: the hook, the per-event dedupe, the
payment_status guard for asynchronous methods, and the reversal half.
The session you create
Two things the webhook will need. Both have to be set here, at session-creation time, because neither can be recovered later:
// Yours to write. The price id comes from your catalog, never from the
// request body.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceIdForProduct(productKey), quantity: 1 }],
client_reference_id: userId,
// The webhook Session carries no expanded line_items, so whatever the
// hook needs to know about WHAT was bought has to be stamped here.
metadata: { userId, productKey },
customer_email: email,
success_url: `${frontendUrl}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${frontendUrl}/pricing`,
});
The service that receives it
// purchases/purchases.service.ts
import { Injectable } from '@nestjs/common';
import { BillingPaymentHook } from '../billing/billing-payment-hook';
import { StripeCheckoutSession } from '../billing/stripe-types';
@Injectable()
export class PurchasesService implements BillingPaymentHook {
async onPaymentCompleted(session: StripeCheckoutSession): Promise<void> {
const { userId, productKey } = session.metadata ?? {};
if (!userId || !productKey) return; // not one of ours
// UNIQUE (stripe_checkout_session_id) is what makes this idempotent.
// See "Belt-and-braces" below.
await this.grants.grant({ userId, productKey, sessionId: session.id });
}
// Optional. Fires on refunds and chargebacks; see the reversal section.
async onPaymentReversed(
session: StripeCheckoutSession,
reason: 'refund' | 'dispute',
): Promise<void> {
await this.grants.revokeBySessionId(session.id, reason);
}
}
BillingPaymentHook lives in backend/src/billing/billing-payment-hook.ts
and StripeCheckoutSession in backend/src/billing/stripe-types.ts.
Binding it
Bind it in app.module.ts:
// app.module.ts: replaces the plain BillingModule entry
BillingModule.withPaymentHook(PurchasesModule, PurchasesService),
PurchasesModule must export PurchasesService, which implements
BillingPaymentHook. Don't declare the BILLING_PAYMENT_HOOK token in
your own module: Nest provider scopes are per-module, so the webhook
service (which lives in BillingModule) would never see it and the
@Optional injection silently resolves to nothing.
Never import BillingModule anywhere else. It is @Global(), so
inject BillingService and you have it. An import creates a second
module instance - Nest keys instances by class plus dynamic metadata,
so the static import and withPaymentHook(...) are two different
modules, each with its own StripeWebhookController, and the one that
wins POST /webhooks/stripe may be the one with no hook bound. The
failure it produces is the quiet kind: a real payment answers 200 and is
fulfilled by nobody. The module now throws at boot if it is registered
twice rather than letting you find out from the audit log.
PurchasesService.onPaymentCompleted(session) owns what a purchase
means: create a record, grant access, send an email. Throw to make Stripe
retry.
Delivery semantics. Stripe delivers webhooks at-least-once: the same
event id can arrive twice (a retry after a slow 200, or - rarely - two
genuinely concurrent deliveries). BillingWebhookService claims the
event id in billing_payment_hook_claims (INSERT … ON CONFLICT DO NOTHING) before invoking the hook, not after, so a redelivery sees the
claim and skips instead of re-invoking. The hook runs at most once per
event id. If the hook throws, the claim is released so Stripe's retry can
claim and run it again.
One gap remains, inherent to claiming before running without a lease or TTL: if the process crashes between the claim succeeding and the hook finishing - an actual process death, not a thrown error - the claim row is never released and Stripe's retry is silently skipped. The failure mode is a lost grant, meaning the customer paid and the hook never ran, so monitor for stuck claim rows if your grant is irreversible. It is also why the belt-and-braces advice below still stands.
Asynchronous methods pay late. Pix, UPI and some bank debits complete
the Checkout Session while the funds are still moving, arriving as
checkout.session.completed with payment_status: 'unpaid'. The hook is
skipped for those and fires on the later
checkout.session.async_payment_succeeded instead, so a grant is never
handed out before the money lands.
checkout.session.async_payment_failed is recorded and grants nothing.
The two deliveries carry different event ids, so the dedupe claim is
taken only when the hook actually runs.
Which methods a session offers is the Stripe account's configuration, not this code's, and Managed Payments picks the set for you - so an async method can appear without a code change. That is why this is guarded even if none of your enabled methods is delayed-notification today.
Belt-and-braces: key on session.id, not just the event. Dedupe by
Stripe event id protects the hook's own invocation count, but it's still
your app's job to make the grant itself idempotent - put a UNIQUE
constraint on the stripe_checkout_session_id column of whatever table
records the grant. If your payment hook ever runs twice for the same
session (a backfill, an admin replay, a future refactor of this dedupe),
that unique constraint turns a double-grant into a caught conflict
instead of a silent double-credit.
Undoing a one-time payment
Refunds and chargebacks resolve a buyer through billing_subscriptions
by Stripe customer id. A guest one-time checkout never creates that
row, so without a seam here a refunded or charged-back buyer keeps
whatever the grant gave them, and nothing says so. Implement the optional
other half:
async onPaymentReversed(
session: StripeCheckoutSession,
reason: 'refund' | 'dispute',
): Promise<void> {
await this.purchases.revokeBySessionId(session.id, reason);
}
It fires on charge.refunded (full refunds only) and
charge.dispute.funds_withdrawn, in addition to the subscription write
rather than instead of it, so a subscriber's refund still drops the row
to free. The webhook resolves the Session from the charge's payment
intent via checkout.sessions.list; a charge that was not a Checkout
sale resolves to nothing and is skipped quietly.
Three choices worth knowing:
- It hands you the Session, not the PaymentIntent, so you can key off
session.id- the same id the belt-and-braces advice above tells you to store. A payment intent would force a nullable column, an index and a migration purely to correlate. - It is deliberately NOT deduped per event id, the opposite of
onPaymentCompleted. Reversal is expected to be idempotent, so a redelivery costs nothing, while a missed one leaves live access that should have ended. Make your revoke idempotent and let it run twice. - Throwing makes Stripe retry, same as the granting half. The webhook records only that the hook ran, since only your app knows what "revoked" means; log your own outcome.
A reversal can arrive before the grant it reverses, and this one is
yours to handle. Delivery order is not guaranteed. If the completion
delivery failed once and is backing off while the refund is delivered
fresh, onPaymentReversed is handed a session you have never granted.
Returning quietly because you found nothing leaves the door open: the
completion lands afterwards under a different event id, so the dedupe
claim succeeds and onPaymentCompleted grants a purchase that was
already refunded.
Neither dedupe by event id nor keying both halves on session.id helps,
because the problem is ordering, not duplication. Nor does having the
grant check for a reversal first: if (await isReversed(id)) return;
before grant(id) is check-then-act, and a reversal landing between the
two lines walks straight through the gap.
Record both facts and let the read decide. One row per session, two independent columns, each write touching only its own:
// Either handler may run first. Neither reads the other's column.
onPaymentCompleted: INSERT (session_id, granted_at)
ON CONFLICT (session_id) DO UPDATE SET granted_at = now()
onPaymentReversed: INSERT (session_id, reversed_at)
ON CONFLICT (session_id) DO UPDATE SET reversed_at = now()
and the entitlement check the rest of your app calls:
SELECT granted_at IS NOT NULL AND reversed_at IS NULL AS has_access
FROM purchases WHERE session_id = $1
Ordering stops mattering: whichever delivery lands first, the final row
holds both facts and the read resolves them the same way. No window to
race, no lock to hold, and the redelivery safety of the
UNIQUE (stripe_checkout_session_id) constraint is unchanged.
The template deliberately leaves this to you. The grant lives in your table and only you know what access means, so a tombstone kept here would have to be consulted by your code anyway and would become a second source of truth about the same purchase.
Pinned API version
billing.config.ts pins STRIPE_API_VERSION so a dashboard default
bump can't ship silent breaking changes (the Dahlia release moved
invoice.subscription and relocated billing periods onto subscription
items). To upgrade: read the Stripe API changelog, bump the constant,
run the billing specs, exercise the dev loop once.
The SDK version and this constant move together. stripe's own types
name the API version they were generated against, so upgrading the
package without the constant fails the build with
Type '"2026-05-27.dahlia"' is not assignable to type '"2026-07-29.dahlia"'
That error is the reminder, not a problem to route around. Do not pin the package to a patch range to silence it: the committed lockfile already gives every fresh clone the same version, so narrowing the range only hides the prompt on the one occasion you want it.
Swapping providers
The seam is the module boundary: the rest of the app only knows
BillingService.getEntitlement(userId) and the two redirect endpoints.
To use another provider, keep billing_subscriptions + the entitlement
shape and replace the Stripe specifics:
- Checkout/portal session creation in
billing.service.ts→ your provider's hosted equivalents. billing-webhook.service.ts→ your provider's webhook verification and event names. Keep the guarded-upsert pattern: every provider redelivers and reorders webhooks.billing.config.tsenv surface + the boot validation.
Mind the merchant-of-record difference: with MoR providers (Paddle, Lemon Squeezy) the provider is the seller and handles VAT; with Stripe you are the merchant (Stripe Tax calculates, you register/file).
Related
- Environment Variables:
STRIPE_*,FRONTEND_URL - Architecture: where billing sits in the module graph
- Adding a Module: the conventions this module follows