Packages

Billing orchestration

The multi-provider checkout drivers, provider-to-domain-event parsers, and dual-layer webhook idempotency composed on top of the open BillingProvider port.

@caisson/billing-orchestration is the commercial companion to @caisson/billing: the open package owns raw-body signature verification, the BillingProvider port, and the DomainBillingEvent contract; this package composes those primitives into the full verify → parse → checkout → idempotency flow for Stripe, Paddle, LemonSqueezy, and Polar.

What it does

  • Four checkout drivers: createStripeBilling, createPaddleBilling, createLemonSqueezyBilling, createPolarBilling, each returning a BillingProvider: verifyAndParse (signature check + envelope validation + domain mapping) and createCheckout (REST checkout/transaction creation).
  • Provider → domain event parsers: parseStripeEvent, parsePaddleEvent, parseLemonSqueezyEvent, parsePolarEvent map each provider's native webhook shape onto the one DomainBillingEvent union the rest of the platform reads.
  • Dual-layer webhook idempotency: processEvent claims a whole event exactly once; withIdempotentSideEffect claims one named side-effect of an event, so a redelivered webhook never double-fires a non-DB effect (a Discord role push, a receipt email) even though the credit ledger is already idempotent on its own.

Install

bun add @caisson/billing-orchestration

Quickstart

import { createPaddleBilling } from "@caisson/billing-orchestration";

const billing = createPaddleBilling({
  apiKey: process.env.PADDLE_API_KEY!,
  webhookSecret: process.env.PADDLE_WEBHOOK_SECRET!,
  env: "production",
});

// Route handler: verify the RAW body, get back a typed domain event or null.
const event = billing.verifyAndParse(rawBody, req.headers["paddle-signature"]);

const { url } = await billing.createCheckout({
  accountId,
  priceId,
  mode: "subscription",
  successUrl,
  cancelUrl,
});

LemonSqueezy and Polar drivers are dormant until you supply credentials, createLemonSqueezyBilling and createPolarBilling throw a ConfigError at construction time if apiKey/storeId or accessToken/webhookSecret are missing, so a misconfigured driver never gets constructed silently.

Idempotent webhook fulfillment

The credit ledger is already idempotent on its own insert. processEvent adds the outer claim for everything else a webhook triggers:

import {
  processEvent,
  withIdempotentSideEffect,
} from "@caisson/billing-orchestration";

await withTenant(tx, accountId, async () => {
  const { alreadyProcessed } = await processEvent(
    tx,
    event.sourceEventId,
    async () => {
      await grantCredits(tx, accountId, event.amountTotal);
    },
  );

  if (!alreadyProcessed) {
    await withIdempotentSideEffect(
      tx,
      event.sourceEventId,
      "discord-role",
      async () => {
        await pushDiscordRole(accountId);
      },
    );
  }
});

Both run inside the caller's withTenant transaction, so a claim only persists once the work it guards has durably committed, a mid-transaction throw rolls back the claim too, and the next delivery retries cleanly.

Configuration

DriverRequired config
createStripeBillingapiKey, webhookSecret
createPaddleBillingapiKey, webhookSecret; optional env (sandbox | production, default production), onWarn
createLemonSqueezyBillingapiKey, webhookSecret, storeId
createPolarBillingaccessToken, webhookSecret; optional env (sandbox | production, default production)

PROCESSED_EVENT_SCHEMA_SQL ships the idempotency claim table as a checksum-pinned migration string, apply it as a new platform migration file, the same convention every other kernel-owned schema string follows.

Composing with the base

Every driver satisfies the identical open BillingProvider port from @caisson/billing: swapping providers is a new driver, not a rewrite of call sites. The idempotency claim table is tenant-owned: processEvent/withIdempotentSideEffect bind account_id from the @caisson/tenancy-rls tenant GUC, so a claim attempted outside withTenant fails a non-blank CHECK instead of landing under a shared tenant.

Sold standalone as a single SKU; the raw-body signature verifiers and the DomainBillingEvent contract it builds on stay open in @caisson/billing.