Payments and billing
Beignet treats payments as a provider-backed port and billing as app-owned
product logic. Application workflows call ctx.ports.payments; providers
adapt Stripe or another payment service. Your features/billing feature owns
plans, prices, entitlements, subscription records, policies, and customer
state.
The port is intentionally small: hosted checkout, billing portal sessions, refunds, and verified webhook events. It does not try to model tax, metering, ledgers, invoices, or every provider-specific payment API.
App-facing port
bun add @beignet/core @beignet/next @beignet/provider-payments-stripe stripeFor a standard Drizzle-backed Beignet app, scaffold the billing slice:
bun beignet make payments
bun beignet db generate
bun beignet db migratemake payments creates features/billing, a billing_accounts Drizzle
schema and repository, app/api/webhooks/payments/route.ts, a typed pricing
module, memory payments provider wiring for local development, and
BILLING_TEAM_PRICE_ID env validation. The generated app stays provider
neutral; swap to the Stripe provider when you are ready to use live Stripe
credentials.
Add the port to your app:
// ports/index.ts
import type { PaymentsPort } from "@beignet/core/payments";
export type AppPorts = {
payments: PaymentsPort;
// app-owned billing repositories and other ports...
};When a provider contributes the port at startup, list payments as a deferred
key in infra/app-ports.ts:
export const appPorts = definePorts<AppPorts>()({
bound: {
gate,
},
deferred: ["payments", "logger", "uow"],
});Stripe provider
Wire the Stripe provider in server/providers.ts:
import { stripePaymentsProvider } from "@beignet/provider-payments-stripe";
export const providers = [
// other providers...
stripePaymentsProvider,
] as const;Set the provider env vars:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
The provider installs ctx.ports.payments and exposes
ctx.ports.stripe.client as an escape hatch for
Stripe-specific operations the stable port does not model.
Cut over to Stripe
The generated billing slice starts with memory payments so local development and tests do not need Stripe credentials. Move to Stripe deliberately:
-
Scaffold billing and create the database migration:
bun beignet make payments bun beignet db generate bun beignet db migrate -
Create the product and price in Stripe, then set
BILLING_TEAM_PRICE_IDto the Stripe Price ID for the plan your app exposes. -
Set the Stripe provider env vars for the environment you are deploying:
STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PUBLISHABLE_KEY=pk_live_...Keep test-mode and live-mode keys separate. The webhook secret is specific to the endpoint or local Stripe CLI session that produced it.
-
Replace the generated memory provider in
server/providers.ts:import { stripePaymentsProvider } from "@beignet/provider-payments-stripe"; export const providers = [ // other providers... stripePaymentsProvider, ] as const; -
Deploy the billing migration before accepting live webhooks. The webhook handler should never be the first code path that discovers the
billing_accountstable is missing. -
Configure a Stripe webhook endpoint that forwards to
https://<your-app>/api/webhooks/paymentsand subscribe to these events:checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
-
Run
bun beignet doctor --strict, create a checkout session, complete it in Stripe test mode, and verify that your app-owned billing state changes only after verified webhook events are handled.
Create checkout from a use case
Keep checkout creation behind an application use case. The use case decides which tenant, plan, and price are allowed; the payments port only creates the external session.
// features/billing/pricing.ts
import type {
PaymentCheckoutLineItem,
PaymentCheckoutMode,
} from "@beignet/core/payments";
import { env } from "@/lib/env";
import type { BillingPlan } from "./schemas";
export type BillingPlanDefinition = {
id: BillingPlan;
label: string;
mode: PaymentCheckoutMode;
lineItems: readonly PaymentCheckoutLineItem[];
};
export const billingPlans = {
team: {
id: "team",
label: "Team",
mode: "subscription",
lineItems: [{ priceId: env.BILLING_TEAM_PRICE_ID, quantity: 1 }],
},
} as const satisfies Record<BillingPlan, BillingPlanDefinition>;
export function getBillingPlan(plan: BillingPlan): BillingPlanDefinition {
return billingPlans[plan];
}Then use that plan definition from checkout:
// features/billing/use-cases/create-checkout-session.ts
import { requireTenant } from "@beignet/core/ports";
import { z } from "zod";
import { env } from "@/lib/env";
import { useCase } from "@/lib/use-case";
import { getBillingPlan } from "../pricing";
export const createCheckoutSessionUseCase = useCase
.command("billing.createCheckoutSession")
.input(
z.object({
plan: z.enum(["team"]),
}),
)
.run(async ({ ctx, input }) => {
const tenant = requireTenant(ctx);
const plan = getBillingPlan(input.plan);
return ctx.ports.payments.createCheckoutSession({
mode: plan.mode,
lineItems: plan.lineItems,
successUrl: `${env.APP_URL}/billing/success`,
cancelUrl: `${env.APP_URL}/billing`,
clientReferenceId: tenant.id,
metadata: {
tenantId: tenant.id,
plan: input.plan,
},
});
});Expose that use case through a normal Beignet contract and route. Add idempotency metadata to the contract so browser retries reuse the same logical checkout request.
Do not use a tenant-wide provider idempotency key for checkout creation. A future legitimate checkout attempt for the same tenant and plan should create a new provider session instead of replaying an old one. If your app has a per-request idempotency key available at the use-case boundary, passing that to the payments port is reasonable.
Treat the returned checkout URL as workflow intent, not proof of payment. A success redirect is a user experience signal only; fulfillment should happen from verified provider webhooks.
Billing persistence and migrations
Billing state should be app-owned and durable. Store provider customer IDs,
subscription IDs, the current app entitlement status, period end, cancellation
intent, checkout session ID, and last processed webhook event ID in a
repository under features/billing/ports.ts.
For Drizzle apps, keep the billing table in infra/db/schema/billing.ts and
export it from infra/db/schema/index.ts. After adding or changing billing
columns, run:
bun beignet db generate
bun beignet db migrateChecked-in demo apps may use local bootstrapping so examples are repeatable. Production apps should prefer checked-in Drizzle migrations generated from the schema.
Verify webhooks
Webhook routes are focused boundary adapters because provider signatures need
the raw request body. In Next.js, use createPaymentWebhookRoute(...) so the
raw-body read and signature verification stay consistent:
// app/api/webhooks/payments/route.ts
import { createPaymentWebhookRoute } from "@beignet/next";
import { handlePaymentWebhookUseCase } from "@/features/billing/use-cases";
import { server } from "@/server";
export const runtime = "nodejs";
export const { POST } = createPaymentWebhookRoute({
server,
handle: async ({ ctx, event }) => {
await handlePaymentWebhookUseCase.run({ ctx, input: event });
return {
status: 200,
body: { received: true },
};
},
});Do not parse the body as JSON before verification, and do not put Stripe SDK calls in feature use cases. The provider owns signature verification; the feature owns what the verified event means. Webhooks should not go through the normal JSON contract route path: the route must read the raw request body once and verify the signature before running app-owned fulfillment.
Webhook operations
For local Stripe testing, forward events to the same route your deployed app uses:
stripe listen --forward-to http://localhost:3000/api/webhooks/paymentsSet STRIPE_WEBHOOK_SECRET to the whsec_... value printed by that command
for the local app process. That secret is not interchangeable with a Dashboard
endpoint secret or a live-mode endpoint secret.
Provider webhooks are at-least-once delivery. The billing use case should key
idempotency on the provider and event ID, so replaying an event from Stripe is
safe. Events can also arrive out of order; recover tenant state from
client_reference_id, subscription metadata, or the existing billing account
instead of assuming checkout, subscription, and invoice events arrive in one
sequence.
For subscription products, treat checkout.session.completed as the event
that connects a tenant to provider IDs. Durable access should come from the
subscription and invoice state you store after handling
customer.subscription.*, invoice.payment_succeeded, and
invoice.payment_failed.
If webhooks fail:
- Confirm the route uses
createPaymentWebhookRoute(...)and keepsexport const runtime = "nodejs";. - Confirm the
STRIPE_WEBHOOK_SECRETbelongs to the exact endpoint and mode that sent the event. - Confirm no middleware or route code parsed the request body before signature verification.
- Inspect
payments.webhook.failedprovider events in devtools or your provider logs. - Replay the event from the Stripe Dashboard or Stripe CLI after fixing the configuration; the idempotency key should make duplicate delivery safe.
Handle payment events
Provider webhooks are delivered at least once. Key idempotency on the provider event ID, then update app-owned billing state in a Unit of Work:
// features/billing/use-cases/index.ts
import {
createIdempotencyFingerprint,
runIdempotently,
} from "@beignet/core/idempotency";
import type { PaymentWebhookEvent } from "@beignet/core/payments";
import { z } from "zod";
import { useCase } from "@/lib/use-case";
const PaymentWebhookEventInput = z.custom<PaymentWebhookEvent>();
export const handlePaymentWebhookUseCase = useCase
.command("billing.handlePaymentWebhook")
.input(PaymentWebhookEventInput)
.run(async ({ ctx, input }) => {
const fingerprint = await createIdempotencyFingerprint({
type: input.type,
data: input.data,
});
return runIdempotently(ctx.ports.idempotency, {
namespace: "webhooks.payments",
key: input.id,
scope: { provider: input.provider },
fingerprint,
ttlSec: 60 * 60 * 24 * 30,
run: () =>
ctx.ports.uow.transaction(async (tx) => {
const data = input.data as {
id?: string;
client_reference_id?: string;
customer?: string | { id?: string };
subscription?: string | { id?: string };
status?: string;
metadata?: Record<string, string>;
};
const customerId =
typeof data.customer === "string"
? data.customer
: data.customer?.id;
const subscriptionId =
typeof data.subscription === "string"
? data.subscription
: data.subscription?.id;
if (input.type === "checkout.session.completed") {
const tenantId =
data.client_reference_id ?? data.metadata?.tenantId;
if (tenantId) {
const existing = await tx.billing.findByTenantId(tenantId);
await tx.billing.save({
tenantId,
provider: input.provider,
plan: "team",
status: existing?.status ?? "inactive",
customerId,
subscriptionId,
checkoutSessionId: data.id,
lastEventId: input.id,
updatedAt: new Date().toISOString(),
currentPeriodEnd: existing?.currentPeriodEnd,
});
}
}
if (input.type.startsWith("customer.subscription.") && customerId) {
const existing = await tx.billing.findByCustomerId(
customerId,
input.provider,
);
const tenantId = data.metadata?.tenantId ?? existing?.tenantId;
if (tenantId) {
await tx.billing.save({
tenantId,
provider: input.provider,
plan: data.metadata?.plan ?? existing?.plan ?? "team",
status:
input.type === "customer.subscription.deleted"
? "canceled"
: data.status === "past_due"
? "past_due"
: "active",
customerId,
subscriptionId: data.id ?? existing?.subscriptionId,
checkoutSessionId: existing?.checkoutSessionId,
lastEventId: input.id,
updatedAt: new Date().toISOString(),
currentPeriodEnd: existing?.currentPeriodEnd,
});
}
}
return { received: true };
}),
});
});Handle at least checkout completion, subscription create/update/delete,
invoice payment success, and invoice payment failure. Use checkout completion
to capture provider IDs and tenant association. Use subscription and invoice
events to decide durable access, past-due behavior, and cancellation state.
Keep canceled accounts from being reactivated by later invoice success events,
and prefer subscription metadata or client_reference_id for recovering tenant
state when events arrive out of order.
Record domain events inside the transaction when other workflows need to react, then deliver mail, notifications, analytics syncs, or entitlement fan-out through outbox and jobs.
In a production app, keep the same shape under features/billing: checkout
and portal use cases, a persistent billing account repository, a
createPaymentWebhookRoute(...) adapter, and idempotent webhook fulfillment.
Refunds and portal sessions
Use the same port for customer self-service and refunds:
const portal = await ctx.ports.payments.createBillingPortalSession({
customerId: billingAccount.providerCustomerId,
returnUrl: `${env.APP_URL}/settings/billing`,
});
await ctx.ports.payments.createRefund({
paymentId: payment.providerPaymentId,
amount: 500,
reason: "requested_by_customer",
idempotencyKey: `refund:${payment.id}:support-credit`,
});Keep the app's billing repository as the source of product access. Provider IDs are external references, not your entitlement model.
Testing
Use the memory adapter in tests:
import { createMemoryPayments } from "@beignet/core/payments";
const payments = createMemoryPayments({
id: (prefix) => `${prefix}_test`,
});
const checkout = await payments.createCheckoutSession({
mode: "subscription",
lineItems: [{ priceId: "price_pro" }],
successUrl: "https://app.example.test/success",
cancelUrl: "https://app.example.test/cancel",
});
expect(checkout.provider).toBe("memory");
expect(payments.checkoutSessions).toHaveLength(1);createTestPorts(...) from @beignet/core/testing also includes a memory
payments port.
Read next
- Idempotency for retry-safe checkout and webhook handling.
- Outbox for durable post-commit billing side effects.
- Providers for provider wiring and escape hatches.
- Devtools for provider instrumentation watchers.