@lunora/payment
Provider-agnostic payments for Lunora — Stripe/Polar adapters, webhook sync, and a payment/subscription state machine.
@lunora/payment is one provider-agnostic API over Stripe and Polar. 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 — 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 (no double-charge), and every mutation is
authorized per-caller. The package never imports a provider SDK — you inject the
client, so stripe stays an optional peer dependency.
pnpm add @lunora/payment stripenpm install @lunora/payment stripeyarn add @lunora/payment stripebun add @lunora/payment stripeConfigure
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 type { StripeClientLike } from "@lunora/payment";
import { createStripeAdapter } from "@lunora/payment";
import { createShardDO } from "./_generated/shard";
import Stripe from "stripe";
export const ShardDO = createShardDO({
payment: (env) => ({
adapter: createStripeAdapter({
// A real `Stripe` instance satisfies the structural client; the cast keeps
// the package free of a hard `stripe` dependency.
client: new Stripe(env.STRIPE_SECRET_KEY, { httpClient: Stripe.createFetchHttpClient() }) as unknown as StripeClientLike,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
}),
// Override the default "caller owns the referenceId" rule for org/workspace keys.
// authorize: (referenceId) => referenceId === ctx.auth.orgId,
entitlements: {
plans: {
pro: { features: ["export"], limits: { api_calls: 1000 }, priceIds: ["price_123"] },
},
},
observability: (event) => console.log("[payment]", event.type, event),
}),
});The thunk returns PaymentsFromContextOptions:
| Option | Type | Notes |
|---|---|---|
adapter | PaymentAdapter | Built with createStripeAdapter / createPolarAdapter. Required. |
authorize | AuthorizeReference | (referenceId) => boolean | Promise<boolean>. Defaults to "caller owns reference". |
entitlements | EntitlementsConfig | Plan → features/limits map. Required to use check / listBalances. |
observability | PaymentObserver | Telemetry sink for webhook applies, failed payments, past-due subscriptions. |
Provider adapters
Both adapters take their client by injection and verify inbound webhooks:
import { createPolarAdapter, createStripeAdapter } from "@lunora/payment";
const stripe = createStripeAdapter({ client, webhookSecret, webhookToleranceSeconds: 300 });
const polar = createPolarAdapter({ client, webhookSecret });| Option | Stripe | Polar | Notes |
|---|---|---|---|
client | ✓ | ✓ | Structural StripeClientLike / PolarClientLike. |
webhookSecret | ✓ | ✓ | Signing secret for inbound webhook verification. |
webhookToleranceSeconds | ✓ | ✓ | Optional clock-skew tolerance for the timestamp check. |
Stripe is a PSP (manual authorize/capture available). Polar is a
Merchant-of-Record — merchantOfRecord is true and it owns tax/invoices, so
manual capture isn't part of its flow. Stripe webhooks use Stripe's signature
scheme; Polar uses Standard Webhooks (webhook-id / webhook-timestamp /
webhook-signature).
The ctx.payments API
Call the facade from an action. Every method authorizes the caller against the
referenceId first.
| Method | Returns | What it does |
|---|---|---|
createCheckout(input) | CheckoutResult | Start a hosted checkout (mode: "payment" | "subscription"); returns a url. |
attach(input) | CheckoutResult | Plan 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?) | Subscription | Cancel now or atPeriodEnd; persists the result. |
listSubscriptions(referenceId) | Subscription[] | Synced subscriptions for a reference. |
check(input) | CheckResult | Is a reference allowed something now? Pass featureId or priceId. |
listBalances(referenceId) | FeatureBalance[] | Resolve every configured feature's allowance in one call. |
track(input) | TrackResult | Record metered usage (exactly-once by idempotency key). |
handleWebhook(request) | Response | Verify + normalize + apply a provider webhook. |
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 — and attaches an outbound idempotency key
automatically (override via input.idempotencyKey).
Entitlements: check and track
Entitlements are derived from already-synced subscription state — cheap and
in-Worker, 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) isallowedwhen the reference holds an active subscription on that price. - A boolean feature check (
featureId, no numeric limit) returnsunlimited: truewhen granted. - A metered feature check (
featureIdwith a planlimit) subtracts usage tracked this period and returns{ allowed, balance, limit, used }.
export const recordApiCall = action.action(async ({ ctx }): Promise<{ recorded: boolean }> => {
// `mode: "add"` (default) increments; `"set"` reconciles the period total.
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 (exactly-once by idempotency
key) that check sums over the current billing period. When the provider
advertises usage metering, the event is also forwarded to its metering API —
best-effort: a reporting failure is observed, never thrown, and the local ledger
check reads is always updated.
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 { 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") ?? "";
return Response.json(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. Once verified it
always returns 200 so the provider stops retrying — a duplicate or no-op event
is acknowledged, not re-applied. The inbound provider event id keys an
append-only events log for idempotency and audit.
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
Codegen discovers tables by parsing your lunora/schema.ts AST, so it cannot
resolve a cross-package defineSchema({ ...paymentTables }) spread. Declare the
tables you use inline in your own lunora/schema.ts, mirroring the columns
in @lunora/payment's exported paymentTables (the canonical column reference
the store reads/writes). Declaring them locally also lets you chain .global()
on read-heavy tables (e.g. subscriptions) for cross-region reads from D1.
paymentTables covers: products, prices, customers, subscriptions,
checkouts, paymentSessions, payments, captures, refunds, invoices,
events (the append-only webhook log), and usageEvents (the metered-usage
ledger). Money is stored as (amountMinor: bigint, currency: string) columns;
every row carries a provider discriminator so multiple providers can coexist
during a migration. Captures and refunds are append-only records linked to a
payment, not booleans.
Studio
The Studio Payments panel (under Logs) shows synced customers, subscriptions, and webhook events for the app — see Logs. The panel only appears once codegen detects payments in use.
See also
- @lunora/server —
ctx.payments, mirror tables into your schema - @lunora/scheduler — drive the reconciliation sweep
- Studio — the Payments panel
- Architecture