Billing orchestration
One BillingProvider port normalizes Paddle, Stripe, LemonSqueezy, and Polar into one event stream, a subscription renewal grants credits exactly once, a mid-cycle charge never over-grants.
What it is
Billing orchestration is Caisson's multi-provider commerce seam: one BillingProvider port (createPaddleBilling, createStripeBilling, plus LemonSqueezy and Polar drivers) normalizes checkout, webhook signature verification, and event parsing across all four providers into one domain event stream. A dual-layer idempotency claim table makes a re-delivered webhook (and its downstream credit grant) settle exactly once, never twice.
What ships in the module
One port, four provider drivers
createStripeBilling and createPaddleBilling ship in this package's index.ts alongside createLemonSqueezyBilling and createPolarBilling, all four hand-rolled over each provider's plain REST API (no vendor SDK) behind the one BillingProvider port from @caisson/billing. Paddle is the live platform merchant of record; the LemonSqueezy and Polar drivers are dormant until you construct them with your own credentials.
Envelope shape checked before the mapper ever runs
createStripeBilling and createPaddleBilling both parseStrict the raw webhook body against StripeEventSchema / PaddleEventSchema after signature verification, a missing or wrong-typed id/type/data is rejected before parseStripeEvent or parsePaddleEvent ever reads it. PaddleEventSchema stops short of .strict() on purpose: a strict envelope rejected every real Paddle delivery in live verification, so only the top-level shape is pinned.
A renewal grants once, a mid-cycle charge never over-grants
parsePaddleEvent reads the transaction's origin field: subscription_recurring maps to billingReason "subscription_cycle" (a renewal), while subscription_charge (a mid-cycle addon or top-up on the same subscription) passes through as its own non-granting reason instead of being read as another cycle. Only a reason inside services/license's GRANTING_REASONS ever triggers a credit grant; an absent or unrecognized origin falls through unmapped and grants nothing.
A re-delivered webhook settles exactly once
processEvent claims a sourceEventId once via an INSERT ... ON CONFLICT DO NOTHING on billing_processed_event; a re-delivery finds the claim and skips the grant function entirely, and with it the detached post-commit Discord role push, which is gated on that same outer claim. withIdempotentSideEffect claims a composite ${sourceEventId}:${sideEffect} key so a named transactional side effect fires at most once across retries, on top of the credit ledger's own UNIQUE(source_event_id, event_type) constraint.
Browser-safe entry point
Import @caisson/billing-orchestration/browser inside a client bundle for the pure claim-key half, assertValidSourceEventId and sideEffectEventKey, the same guards processEvent and withIdempotentSideEffect delegate to. The claim itself stays on the main entry, because it runs as an INSERT inside your tenant transaction; the main entry keeps the complete surface, and every browser-entry export is also on it.
The claim table is tenant-scoped, not just event-scoped
billing_processed_event binds account_id from the tenant GUC on insert and runs under buildTenantPolicySql's force-RLS policy; a CHECK (account_id <> '') rejects a claim attempted outside withTenant rather than letting it land under a shared blank tenant.
Integer money at every provider boundary
readMoneyMinorUnits rounds LemonSqueezy's numeric money fields (which can carry sub-cent artifacts from currency-rate conversion (e.g. 1499.985)) to the nearest integer minor unit before the amount enters the domain event. Every driver's amountTotal reaches the credit-grant seam as an integer, never a float.
if (txnId === "") return null;
return {
type: "invoice.paid",
sourceEventId: event.event_id,
accountId,
amountTotal: readGrandTotal(obj),
currency: readString(obj.currency_code, "usd"),
subscriptionId,
priceId: readItemPriceId(obj),
// Paddle's `origin` says HOW the charge arose (verified against developer.paddle.com's
// transaction.completed reference + the subscription-created/renewed simulator scenarios,
// 2026-07-01), mapped onto the billingReason vocabulary the cycle->grant gate
// (services/license GRANTING_REASONS) recognizes:
// web | api → the subscription's FIRST charge (Paddle.js checkout / an
// API-created transaction, e.g. provider.ts createCheckout)
// → "subscription_create" (grants)
// subscription_recurring → a renewal cycle → "subscription_cycle" (grants)
// subscription_charge → a MID-CYCLE one-time charge FOR the subscription
// (addon/topup) — NOT the first charge (the earlier reading);
// granting the plan's cycle allotment here would OVER-grant,
// so it passes through as its own non-granting reason
// subscription_update / subscription_payment_method_change → proration / $0
// method-change transactions — non-granting (the SD-1
// next-cycle rule)
// An absent origin passes through as "" — not in GRANTING_REASONS, so it grants nothing
// (fail-closed; Paddle documents `origin` as always present on a transaction).
billingReason: ((): string => {
const origin = readString(obj.origin);
if (origin === "subscription_recurring") return "subscription_cycle";
if (origin === "web" || origin === "api")
return "subscription_create";
return origin;
})(),- origin drives billingReason, subscription_recurring becomes subscription_cycle (a renewal, grants), while subscription_charge passes through unmapped so a mid-cycle addon charge never triggers the plan's cycle credit grant.
- An absent or unrecognized origin falls through as the raw string, which never matches services/license's GRANTING_REASONS, fail-closed to no grant rather than a guessed one.
- Paddle fires this same transaction.completed event for both a subscription's first charge and every renewal, there is no separate per-cycle webhook, so origin is the only signal this mapper has.