Boring Docs

Payments — Three Rails, One Ledger

Checkout, portal, cancel, webhooks, idempotency, price-from-row.

Payments

One variable: BILLING_PROVIDER=stripe|lemonsqueezy|dodo. Unified surface per rail: createCheckoutSession, createPortal, verifyAndParse (+ getSubscription, cancelSubscription).

Checkout

  • Platform sale (template/subscription, guest OK): POST /api/billing/create-checkout with { successUrl, cancelUrl, priceId?/variantId?, mode? }. Price falls back to env/config per rail.
  • Marketplace sale (seller product, login required): { productDbId (preferred) | productId, successUrl, cancelUrl? }. Server loads the product row, uses its provider + providerRef, derives mode from pricingType. Amount always comes from the row — never the client.
  • Legacy Stripe shim POST /api/stripe/create-checkout allowlists priceId against config/billing.js plans.

Validate successUrl/cancelUrl as same-origin relative URLs before sending to the PSP.

Portal + cancel

  • POST /api/billing/create-portal — resolves rail via body.provider ?? user.billingProvider, opens PSP portal, backfills customerId. Validate returnUrl same-origin.
  • POST /api/billing/cancel-subscription — requires ownership: order buyer match or customerId match or admin; then PSP cancel + revokeAccess. Returns 403 otherwise (anti-IDOR).

Webhooks (verify → parse → grant/revoke → 200/500 correctly)

  • Dodo POST /api/webhook/dodo, LS /api/webhook/lemonsqueezy, Stripe /api/webhook/stripe. All verify signatures (Standard Webhooks / HMAC / Stripe SDK), enforce replay tolerance, then call providers/billing/access.js.
  • grantAccess: platform sale → set hasAccess/customerId/priceId/billingProvider; marketplace → dedupe on providerPaymentRef, findBuyer, create exactly one Order with amountCents from the product row.
  • revokeAccess: mark order reason, then clear hasAccess only when no other completed orders remain (lifetime users preserved).
  • Return 200 only on success; 4xx on bad signature, 500 on processing failure so PSPs retry. Never trust webhook amounts.

Ops

Sync helper: POST /api/product/sync-dodo (paid owner, sellerId match) creates the Dodo product and saves providerRef. Creation script: node scripts/dodo-create-product.js.