Skip to content
DocspackagesDocumentation

@lunora/payment

Provider-agnostic payments for Lunora — Stripe/Polar/Autumn/Dodo/Creem adapters, webhook sync, and a payment/subscription state machine.

Packages@lunora/payment@lunora/payment

@lunora/payment is one provider-agnostic API over Stripe, Polar, Autumn, Dodo Payments, and Creem. Webhook events are verified, normalized, and applied through an explicit payment/subscription state machine that makes duplicate and out-of-order deliveries safe by construction, and synced into a durable store that rides the request's ctx.db. Switching providers is a configuration change: the provider is a stateless translator; the store owns all state.

Outbound calls carry idempotency keys wherever the provider accepts one (no double-charge), and every mutation is authorized per-caller. Each adapter takes the provider's SDK client by injection. The provider SDKs are optional peer dependencies, so you install (and bundle) only the providers you actually use.

pnpm add @lunora/payment stripe

Configure

Merge the store's tables into your schema with one .extend:

// lunora/schema.ts
import { paymentExtension } from "@lunora/payment";
import { defineSchema } from "@lunora/server";

export default defineSchema({
    // ... your app tables
}).extend(paymentExtension);

ctx.payments is wired by codegen onto ActionCtx whenever a lunora/ source imports @lunora/payment or reads ctx.payments. The adapter, which carries provider secrets, comes from a config.payment(env) thunk you pass to createShardDO(). The store is built per request from ctx.db, and the default authorizer ties referenceId to ctx.auth.userId.

import { createStripeAdapter } from "@lunora/payment/stripe";
import { createShardDO } from "./_generated/shard";
import Stripe from "stripe";

export const ShardDO = createShardDO({
    payment: (env) => ({
        adapter: createStripeAdapter({
            // The adapter types its client as the real `Stripe` instance (an optional peer dependency),
            // so it's passed straight through — the fetch HTTP client is required on workerd.
            client: new Stripe(env.STRIPE_SECRET_KEY, { httpClient: Stripe.createFetchHttpClient() }),
            webhookSecret: env.STRIPE_WEBHOOK_SECRET,
        }),
        // Override the default "caller owns the referenceId" rule for org/workspace keys. It runs per
        // request with the caller's `userId` and the request's `ctx.db`.
        // authorize: async (orgId, { db, userId }) =>
        //     (await db.findFirst("members", { where: { orgId, role: "owner", userId } })) !== null,
        entitlements: {
            plans: {
                pro: { features: ["export"], limits: { api_calls: 1000 }, priceIds: ["price_123"] },
            },
        },
        observability: (event) => console.log("[payment]", event.type, event),
    }),
});

The thunk returns PaymentsFromContextOptions:

OptionTypeNotes
adapterPaymentAdapterBuilt with createStripeAdapter / createPolarAdapter / createAutumnAdapter / createDodoPaymentsAdapter / createCreemAdapter. Required.
authorizeAuthorizeContextReference(referenceId, { db, userId }) => boolean | Promise<boolean>. Defaults to "caller owns reference".
entitlementsEntitlementsConfigPlan → features/limits map. Required for a featureId check and for listBalances on providers without native entitlements; a priceId check needs none.
observabilityPaymentObserverTelemetry sink for webhook applies, failed payments, past-due subscriptions.

Provider adapters

Every adapter takes its client by injection and verifies inbound webhooks. Each lives at its own subpath so importing one never loads another provider's SDK:

import { createAutumnAdapter } from "@lunora/payment/autumn";
import { createCreemAdapter } from "@lunora/payment/creem";
import { createDodoPaymentsAdapter } from "@lunora/payment/dodopayments";
import { createPolarAdapter } from "@lunora/payment/polar";
import { createStripeAdapter } from "@lunora/payment/stripe";

const stripe = createStripeAdapter({ client, webhookSecret, webhookToleranceSeconds: 300 });
const polar = createPolarAdapter({ client, webhookSecret });
const autumn = createAutumnAdapter({ client, webhookSecret });
const dodo = createDodoPaymentsAdapter({ client, webhookSecret });
const creem = createCreemAdapter({ client, webhookSecret });
OptionStripePolarAutumnDodoCreemNotes
client✓✓✓✓✓The provider's injected SDK client (e.g. a Stripe instance).
webhookSecret✓✓✓✓✓Signing secret for inbound webhook verification.
webhookToleranceSeconds✓✓✓✓Optional clock-skew tolerance (schemes that sign a timestamp).

Stripe is a PSP (manual authorize/capture available). Polar, Dodo Payments, and Creem are Merchants-of-Record: merchantOfRecord is true and they own tax/invoices, so manual capture isn't part of their flow. Stripe webhooks use Stripe's signature scheme; Polar, Autumn, and Dodo Payments use Standard Webhooks (webhook-id / webhook-timestamp / webhook-signature); Creem uses a single creem-signature HMAC-SHA256 header.

Creem

Creem is a Merchant-of-Record for software with an EU-friendly global footprint. Unlike a raw PSP, it is product-based (real product ids), so it slots into the priceId interface directly like Polar/Dodo: hosted checkout sessions, first-class subscriptions (cancel at period end, pause/resume, plan upgrades), and a genuine hosted billing portal via customers.generateBillingLinks (portal is true). Refunds are issued from the Creem dashboard (no SDK endpoint), so refundPayment throws. Webhooks are verified with the creem-signature HMAC-SHA256 header.

import type { CreemClientLike } from "@lunora/payment/creem";
import { createCreemAdapter } from "@lunora/payment/creem";
import { Creem } from "creem";

const creem = createCreemAdapter({
    client: new Creem() as unknown as CreemClientLike,
    webhookSecret: env.CREEM_WEBHOOK_SECRET,
});

Autumn

Autumn is an entitlement-first billing layer that runs on your own Stripe account, so it is not a Merchant-of-Record, but, like Polar, it abstracts the money movement, so manual authorize/capture/refund throw. It keys a subscription on the (customerId, planId) pair, which the adapter encodes as the composite id "<customerId>::<planId>".

Autumn owns entitlement truth: balances, credits, limits, and rollovers are computed on its side from your plan config. The adapter implements the optional checkEntitlement / getBalances hooks, so the facade delegates check and listBalances straight to Autumn's live API, and you do not need to configure entitlements for an Autumn app. Because of that, the authoritative sync path is live queries + reconcile, not webhook fan-in (the autumn-js SDK models no outbound webhook stream). Autumn's dashboard can still emit Standard Webhooks (Svix) events, so parseWebhook is provided as a best-effort convenience; reconcile remains the reliable path for subscription drift.

Construct the client from autumn-js and inject it (the cast keeps the package free of a hard autumn-js dependency):

import type { AutumnClientLike } from "@lunora/payment/autumn";
import { createAutumnAdapter } from "@lunora/payment/autumn";
import { Autumn } from "autumn-js";

const autumn = createAutumnAdapter({
    client: new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }) as unknown as AutumnClientLike,
    webhookSecret: env.AUTUMN_WEBHOOK_SECRET,
});

A companion facade, createAutumnFeatures, holds the Autumn-native concepts that have no cross-provider equivalent: entities (per-seat/per-workspace sub-customers), referrals, usage-events analytics, the plan catalog, and a native checkout (prepaid feature quantities, entity scoping, reward codes, opting out of a plan's trial). It is kept separate so the provider-agnostic surface stays generic. Share the same injected client between the two. (Credit systems need no extra method: Autumn models them as features, so their balances flow through check / listBalances like any other.)

import type { AutumnFeaturesClientLike } from "@lunora/payment/autumn-features";
import { createAutumnFeatures } from "@lunora/payment/autumn-features";

const autumnFeatures = createAutumnFeatures({ client: client as unknown as AutumnFeaturesClientLike });

await autumnFeatures.entities.create(userId, { featureId: "seats", id: "workspace_a", name: "Workspace A" });
const usage = await autumnFeatures.events.aggregate(userId, { featureId: "api_calls", range: "30d" });
const { url } = await autumnFeatures.checkout(userId, { planId: "pro" });

Dodo Payments

Dodo Payments is a Merchant-of-Record: like Polar, it is the legal seller of record and calculates, collects, and remits tax across 190+ jurisdictions, and owns chargebacks and disputes. So merchantOfRecord is true and manual authorize/capture throw, but refunds are first-class (refunds.create). The flow is checkout-session → subscription/payment, synced from Standard Webhooks (the dodopayments SDK exposes the full, verified event catalog: payment.*, subscription.*, refund.*, …). It supports usage-based billing, so reportUsage ingests Dodo usage-events.

Construct the client from dodopayments and inject it (the cast keeps the package free of a hard dodopayments dependency):

import type { DodoPaymentsClientLike } from "@lunora/payment/dodopayments";
import { createDodoPaymentsAdapter } from "@lunora/payment/dodopayments";
import DodoPayments from "dodopayments";

const dodo = createDodoPaymentsAdapter({
    client: new DodoPayments({ bearerToken: env.DODO_PAYMENTS_API_KEY }) as unknown as DodoPaymentsClientLike,
    webhookSecret: env.DODO_PAYMENTS_WEBHOOK_KEY,
});

The ctx.payments API

Call the facade from an action. Every method authorizes the caller against the referenceId first.

MethodReturnsWhat it does
createCheckout(input)CheckoutResultStart a hosted checkout (mode: "payment" | "subscription"); returns a url.
attach(input)CheckoutResultPlan alias of createCheckout with mode defaulting to "subscription".
createPortalSession(referenceId, returnUrl){ url }Open the provider billing portal; customer derived from the store (no IDOR).
cancelSubscription(subscriptionId, options?)SubscriptionCancel now or atPeriodEnd; persists the result.
refundPayment(input)PaymentSessionRefund the caller's own captured payment, in full or in part; persists the result.
capturePayment(input)PaymentSessionCapture the caller's own authorized payment; persists the result.
cancelPayment(sessionId, options?)PaymentSessionCancel the caller's own uncaptured payment; persists the result.
listSubscriptions(referenceId)Subscription[]Synced subscriptions for a reference.
check(input)CheckResultIs a reference allowed something now? Pass featureId or priceId.
listBalances(referenceId)FeatureBalance[]Resolve every configured feature's allowance in one call.
track(input)TrackResultRecord metered usage (exactly-once by idempotency key).
handleWebhook(request)ResponseVerify + normalize + apply a provider webhook.

ctx.payments.adapter is the raw provider client, exposed for the surfaces the facade does not wrap (updateSubscription, resumeSubscription, provider-specific extras). Do not move money through it. adapter.refundPayment / capturePayment / cancelPayment skip everything the facade methods above do first: the caller authorization, the over-refund guard, the local refund ledger (the only thing stopping a retry from issuing a second real refund on a provider whose API takes no idempotency key), the derived idempotency key, and the store write. The facade ends in the same provider call.

import { action, query, v } from "./_generated/server";

export const checkout = action.input({ priceId: v.string() }).action(async ({ ctx, args: { priceId } }): Promise<{ url: string }> => {
    const { url } = await ctx.payments.createCheckout({
        referenceId: ctx.auth.userId,
        priceId,
        mode: "subscription",
        successUrl: "https://app.test/done",
        cancelUrl: "https://app.test/cancel",
    });

    return { url };
});

createCheckout reuses a reference's stored provider customer, minting a new one only on first checkout (the customer is never caller-supplied), and attaches an outbound idempotency key automatically (override via input.idempotencyKey). A key you pass is hashed and namespaced to the authorized reference before it reaches the provider, so two tenants choosing the same key never collide.

Entitlements: check and track

Entitlements are derived from already-synced subscription state, which is cheap and in-Worker and needs no external billing service. A plan is granted when an active (or trialing) subscription holds one of its priceIds. When several active plans cap the same limit, the most-generous value wins.

  • A product check (priceId) is allowed when the reference holds an active subscription on that price.
  • A boolean feature check (featureId, no numeric limit) returns unlimited: true when granted.
  • A metered feature check (featureId with a plan limit) subtracts usage tracked this period and returns { allowed, balance, limit, used }. "This period" is the billing period of the subscription whose plan grants that limit, so an unrelated subscription renewing never resets it.
export const recordApiCall = action.action(async ({ ctx }): Promise<{ recorded: boolean }> => {
    // `mode: "add"` (default) increments; `"set"` reconciles the LOCAL period total.
    // Both are a single append — a `"set"` records the absolute target as a marker
    // the period fold resets to, so concurrent or replayed `"set"` calls resolve
    // last-writer-wins instead of double-applying. Neither mode needs a lock.
    // `"set"` throws on a provider that meters usage upstream — see the callout below.
    const result = await ctx.payments.track({ referenceId: ctx.auth.userId, featureId: "api_calls" });

    return { recorded: result.recorded };
});

export const apiCallsRemaining = action.action(async ({ ctx }): Promise<{ allowed: boolean; balance?: number }> => {
    const result = await ctx.payments.check({ referenceId: ctx.auth.userId, featureId: "api_calls" });

    return { allowed: result.allowed, balance: result.balance };
});

track writes a durable, append-only usage ledger that check folds over the current billing period. When the provider advertises usage metering, the event is also forwarded to its metering API. That forward is best-effort: a reporting failure is observed, never thrown, and the local ledger check reads is always updated.

Deduping a retry is opt-in. Pass your own idempotencyKey — namespaced to the reference and feature, it makes the append exactly-once, so a retried call records once. Omit it (as the example above does) and track mints a fresh crypto.randomUUID() per call, which means "always record": the same call retried after a timeout counts twice. Derive the key from whatever identifies the unit of work — the request id, the message id, the job id.

mode: "set" is local-only, and throws where the provider meters. Provider meters are additive: they take events, not period totals, so a "set" can only be forwarded as a delta against the total just read — and a "set" that LOWERS usage has no negative delta to send. set 10 → set 5 → set 8 would forward 10 then 3, billing 13 upstream against a local period total of 8. Rather than over-bill silently, track rejects "set" with a VALIDATION_ERROR on any provider whose capabilities.usageMetering is true (Stripe, Polar, Dodo, Autumn). Use mode: "add", which is exact on both sides. "set" stays available where usage is enforced locally.

Webhooks

Signature verification needs the raw request body, so the webhook endpoint runs at the Worker edge via httpAction (no ctx.db). It forwards the raw body and signature into the shard via ctx.runAction, where ctx.payments and its store exist:

// lunora/http.ts
import { webhookResponse } from "@lunora/payment";
import { httpAction, httpRouter } from "lunorash/server";
import { processWebhook } from "./billing";

export const app = httpRouter();

app.post(
    "/payment/webhook",
    httpAction(async (ctx, request) => {
        const body = await request.text();
        const signature = request.headers.get("stripe-signature") ?? "";

        // `webhookResponse`, not `Response.json(...)`: only the JSON payload crosses the
        // `runAction` boundary, so the status has to be re-applied on this side or every
        // outcome — including the deliberate 500 below — answers 200.
        return webhookResponse(await ctx.runAction(processWebhook, { body, signature }));
    }),
);
// lunora/billing.ts
import { internalAction, v } from "./_generated/server";

export const processWebhook = internalAction
    .input({ body: v.string(), signature: v.string() })
    .action(async ({ ctx, args: { body, signature } }): Promise<{ applied: boolean; status: number }> => {
        const request = new Request("https://internal/payment/webhook", {
            body,
            headers: { "stripe-signature": signature },
            method: "POST",
        });
        const response = await ctx.payments.handleWebhook(request);
        const result = (await response.json()) as { applied?: boolean };

        return { applied: result.applied ?? false, status: response.status };
    });

handleWebhook verifies the signature, normalizes the event into a WebhookAction (e.g. subscription.active, payment.captured, payment.refunded), and applies it through the state machine. A duplicate or no-op event is acknowledged with 200, not re-applied. The inbound provider event id keys an append-only events log for idempotency and audit. A subscription event older than the last one applied to its row (a redelivery after a 5xx) is acknowledged as stale and ignored, so a late active cannot re-entitle a subscription that has since gone past_due.

There is one deliberate non-200. An orphaned event — one that patches a row whose create event has not arrived yet, which is how providers deliver out-of-order — returns 500 so the provider retries it, and the update applies once the create lands. Do not "fix" that by always answering 200: swallowing the retry is how an out-of-order delivery is lost for good. That is also why the route must return webhookResponse(result) rather than Response.json(result) — the status does not survive the runAction hop on its own. Malformed deliveries (a missing provider event id) likewise surface as a 5xx rather than a 400, so every provider retries them instead of some treating a 400 as "stop".

The orphan retry is bounded to exactly one. The row may never appear at all — a subscription created before the integration existed, a store reset, a completed-but-unpaid checkout whose create event never arrives — and an event retried forever makes the provider hammer the endpoint until it disables it, taking every other event down with it. So the first sighting answers 500 and the second is acknowledged 200 with reason: "unhandled". That budget is spent in wall-clock order, not on a schedule: a sender that retries immediately (svix-backed providers such as Polar and Dodo do) can burn it seconds after the first delivery, while the create event is still in flight. If the create event can lag that far behind, do not lean on the webhook retry — sweep non-terminal rows with reconcile as below.

Webhooks are eventually-but-not-guaranteed: an endpoint down past the provider's retry window can drop an event for good. reconcile re-fetches the provider's current truth for given payment/subscription ids and overwrites the store when it has drifted. Pair it with a @lunora/scheduler job that sweeps non-terminal rows.

Data it stores

paymentExtension ships the store's five tables with the package. .extend() namespaces them with the payment key, and codegen reads them from the installed package, so a new column or index reaches your app when you upgrade @lunora/payment: there is nothing to copy into your schema.

TableHolds
payment_customersone row per (provider, referenceId), carrying the provider's customer id
payment_subscriptionsone row per provider subscription: price, quantity, state, current period
payment_sessionsone row per payment: amountMinor, capturedMinor, refundedMinor, currency, state
payment_eventsthe append-only webhook log — inbound idempotency, audit, debugging
payment_usageEventsthe append-only metered-usage ledger check folds over

Money is stored as (amountMinor: bigint, currency: string) columns; every row carries a provider discriminator so multiple providers can coexist during a migration. A capture or refund is not its own table: both fold into the payment's payment_sessions row as capturedMinor / refundedMinor plus a state (refunded / partially_refunded). Products, prices, checkouts and invoices stay with the provider — the store does not mirror them.

Extension tables live on the shard. Chaining .global() on one of them is not available, so the per-table D1 opt-in an inline table had does not carry over.

Upgrading

  • Caller idempotency keys are now namespaced and hashed before they reach the provider. A retry that spans the deploy reaches the provider under a new key, so it is not deduplicated against the attempt made before it — avoid retrying a payment operation across the deploy.

Upgrading from inline tables

Earlier versions had you declare customers, events, paymentSessions, subscriptions and usageEvents inline in lunora/schema.ts. The store now reads the payment_* tables only, so existing rows have to move:

  1. Keep the inline tables declared, add .extend(paymentExtension), and deploy. New writes go to the payment_* tables from here on.

  2. Call migrateLegacyPaymentTables from an internalMutation until it reports remaining: false. Each call moves up to batchSize rows (default 100), copying each one and deleting the original. A row whose natural key the target already holds (the store wrote it after the deploy) is not copied again, so it is safe to re-run.

    // lunora/payment-upgrade.ts
    import type { LunoraDatabaseLike } from "@lunora/payment";
    import { migrateLegacyPaymentTables } from "@lunora/payment";
    import { internalMutation } from "./_generated/server";
    
    // The table-name-keyed `ctx.db` view, as codegen hands it to `ctx.payments`.
    export const movePaymentRows = internalMutation.mutation(async ({ ctx }) => migrateLegacyPaymentTables(ctx.db as unknown as LunoraDatabaseLike));

    Run it once per shard that holds payment rows, repeating until remaining is false.

  3. Delete the inline tables from lunora/schema.ts and deploy. The schema-drift gate reports the removed tables as breaking; with the move done, accept it with --update-schema-baseline.

The move reads the legacy tables, so it only works while step 1's inline declarations are still in place. Rows it copies are written in the current storage form, which also heals any money column left unqueryable by the pre-fix v.bigint() storage (see the re-projection backfill).

Studio

The Studio Payments panel (under Operations) shows synced customers, subscriptions, and webhook events for the app. The panel only appears once codegen detects payments in use.