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 stripe

For a standard Drizzle-backed Beignet app, scaffold the billing slice:

bun beignet make payments
bun beignet db generate
bun beignet db migrate

make 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:

  1. Scaffold billing and create the database migration:

    bun beignet make payments
    bun beignet db generate
    bun beignet db migrate
  2. Create the product and price in Stripe, then set BILLING_TEAM_PRICE_ID to the Stripe Price ID for the plan your app exposes.

  3. 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.

  4. Replace the generated memory provider in server/providers.ts:

    import { stripePaymentsProvider } from "@beignet/provider-payments-stripe";
    
    export const providers = [
      // other providers...
      stripePaymentsProvider,
    ] as const;
  5. Deploy the billing migration before accepting live webhooks. The webhook handler should never be the first code path that discovers the billing_accounts table is missing.

  6. Configure a Stripe webhook endpoint that forwards to https://<your-app>/api/webhooks/payments and subscribe to these events:

    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • invoice.payment_failed
  7. 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 migrate

Checked-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/payments

Set 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:

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.