Payments & Billing

Billing powered by Stripe and Paddle, configured to run in parallel.

Supported Payment Providers

  • Stripe
  • Paddle

Both providers are configured to work in parallel, letting you choose one or use both.

Core Components

Database Schema

layers/billing/server/database/schema.ts
export const plans = pgTable("Plan", {
  id: text("id").$default(() => cuid()).primaryKey().notNull(),
  name: text("name").notNull(),
  description: text("description").notNull(),
  tier: text("tier").default("FREE").notNull(),
  price: text("price").notNull(),
  period: text("period").notNull(),
  provider: text("provider"),
  providerPriceId: text("providerPriceId"),
  providerProductId: text("providerProductId"), // the Stripe Product behind providerPriceId — Prices are immutable, so a changed amount creates a new Price under the same Product
  unitAmount: integer("unitAmount"), // minor units (e.g. cents); null for plans not managed by Stripe's catalog
  currency: text("currency"), // lowercase ISO 4217; null alongside unitAmount
  maxSeats: integer("maxSeats"), // null = unlimited
  limits: jsonb("limits"), // open-ended bag for future quotas
  buyerTeamMode: text("buyerTeamMode").notNull().default("NEW_TEAM"), // "NEW_TEAM" or "EXISTING_TEAM"
  buyerTeamId: text("buyerTeamId"), // the team an EXISTING_TEAM plan adds the buyer to
  isArchived: boolean("isArchived").notNull().default(false), // soft delete
  // ...timestamps
});

export const subscriptions = pgTable("Subscription", {
  id: text("id").$default(() => cuid()).primaryKey().notNull(),
  teamId: text("teamId").notNull().references(() => teams.id),
  status: text("status").default("ACTIVE").notNull(),
  provider: text("provider").notNull(),
  providerSubscriptionId: text("providerSubscriptionId").notNull(),
  planId: text("planId").notNull().references(() => plans.id),
  currentPeriodStart: timestamp("currentPeriodStart", { withTimezone: true }),
  currentPeriodEnd: timestamp("currentPeriodEnd", { withTimezone: true }),
  cancelAtPeriodEnd: boolean("cancelAtPeriodEnd").default(false).notNull(),
  providerMetadata: jsonb("providerMetadata"),
  customerEmail: text("customerEmail"),
  lastPaymentDate: timestamp("lastPaymentDate", { withTimezone: true }),
  nextPaymentDate: timestamp("nextPaymentDate", { withTimezone: true }),
  // ...timestamps
});

A Plan row belongs to exactly one provider (provider/providerPriceId) — offering the same plan on both Stripe and Paddle means two Plan rows, one per provider.

Each plan also says where a buyer lands after paying: in a team of their own (NEW_TEAM, the buyer becomes its Owner) or in an existing team you choose (EXISTING_TEAM). You set it in the plan form at /admin/plans. The checkout that acts on this setting is the anonymous Buy flow described below.

Webhook Handlers

Located in layers/billing/server/api/webhooks/:

  • stripe.post.ts — handles Stripe subscription events
  • paddle.post.ts — handles Paddle subscription events

Client Integration

The usePayments() composable (layers/billing/app/composables/use-payments.ts) handles client-side checkout, with a different shape per provider:

// Paddle — items with a Paddle price id, opens Paddle's own checkout overlay
const paddle = usePayments("PADDLE");
await paddle?.checkout([{ priceId: "pri_123" }], { data: { teamId: team.id } });

// Stripe — returns a hosted Checkout Session URL to redirect to
const stripe = usePayments("STRIPE");
const sessionUrl = await stripe?.checkout("price_123", team.id);
if (sessionUrl) navigateTo(sessionUrl, { external: true });

Paddle checkout requires NUXT_PUBLIC_PADDLE_CLIENT_TOKEN to be configured — it throws explicitly if that value is empty, rather than silently failing to open.

Setup Steps

  1. Set required secrets using the SST CLI:
    # Stripe
    sst secret set STRIPE_SECRET_KEY sk_test_xxx
    sst secret set STRIPE_WEBHOOK_SECRET_KEY whsec_xxx
    
    # Paddle
    sst secret set PADDLE_API_KEY xxx
    sst secret set PADDLE_WEBHOOK_SECRET xxx
    sst secret set PADDLE_CLIENT_TOKEN xxx
    
  2. Create plans at /admin/plans — see Platform Admin Console. This is a platform-owner action, not a seed script: plans aren't hardcoded, so price ids stay per-environment (test vs. live) without touching code.
    For a Stripe-managed plan, no price id is pasted by hand. The admin form takes a decimal amount, currency, and billing period; layers/billing/server/utils/stripe-catalog.ts's syncStripePlanCatalog() creates (or updates) the Stripe Product and creates a new Price via the Stripe API whenever amount/currency/period actually changes — Prices are immutable on Stripe, so a change always mints a new Price and archives the old one under the same Product. providerPriceId/providerProductId are still a manual field for a Paddle-provider plan (Paddle's own catalog isn't managed from here) or for a Stripe plan predating this sync.
  3. Set up webhook endpoints in your provider dashboards:
    • Stripe: /api/webhooks/stripe
    • Paddle: /api/webhooks/paddle

Buying Without an Account (the Buy CTA)

A visitor with no account and no team can pay for a plan using only an email address, then claim what they paid for after creating an account. This is what powers the landing page's (apps/web) Buy CTA.

  1. /buy/[planId] (apps/app, public) — the buyer picks a Plan (by its id in the URL), enters an email, and pays via that plan's provider (Stripe Checkout or Paddle's overlay). No account or team exists yet at this point.
  2. PendingPurchase (layers/billing/server/database/schema.ts) — created at checkout start, confirmed by the provider's webhook once the payment/subscription is active. States: PENDING → CLAIMED, or EXPIRED/REJECTED. An unclaimed purchase expires 30 days after creation. The webhook also sends the buyer a claim e-mail (PurchaseClaim template, packages/emails).
  3. /claim/[id] (apps/app, protected — the buyer creates an account or logs in first, with the same e-mail) — finishes what the admin configured for that plan: a brand-new team with the buyer as Owner (NEW_TEAM), or membership in the chosen existing team as Member (EXISTING_TEAM). A destination that is no longer valid (the existing team was deleted or suspended) is refused safely — nothing is created, and the case stays visible to the platform admin.
  4. /admin/pending-purchases — every purchase, all statuses, for investigating a payment that never turned into access.

Wiring the landing page's CTA — no redeploy needed

Which plan the Buy CTA on apps/web (CTAButton, both the hero and the pricing card) sells is a database flag, not a deploy-time setting: Plan.isPublicOffer (at most one plan holds it at a time). Toggle it from /admin/plans — the "Show on landing page" checkbox in the plan form, or the row menu's "Show on landing page" / "Remove from landing page" action — and the CTA updates on the next page load, for every visitor, with no env var and no redeploy.

How the landing page (a separate Cloudflare Worker, its own domain) reads a database row that lives behind apps/app's API without exposing that API publicly:

  1. layers/billing/server/api/public/offer.get.ts — a public, unauthenticated route on apps/app returning whichever plan has isPublicOffer: true (and isn't archived), or null.
  2. infra/web.ts links the App Worker into the Web Worker's link array — the same Cloudflare service binding pattern this project already uses for App → Auth (see the comment on link: [auth] in infra/app.ts, quoting Cloudflare's own best practices against a plain fetch() between Workers). No CORS, no public exposure: the call never leaves Cloudflare's network.
  3. apps/web/server/api/offer.get.ts calls that route through the binding (event.context.cloudflare.env.AppWorker, falling back to a plain fetch() for local dev where the binding may not be wired) and returns the plan JSON to the browser.
  4. apps/web/app/composables/use-offer.ts fetches /api/offer and builds ${appUrl}/buy/<planId> — the same URL shape as /buy/[planId] above — with no configuration of any kind.

No plan flagged yet: the CTA falls back to the same "coming soon" state it always had.

The Free plan

Exactly one plan is created automatically, by apps/functions/src/database/seed/index.ts on every deploy (idempotent — it checks for an existing tier: "FREE" row first):

apps/functions/src/database/seed/data.ts
export const FREE_PLAN: InsertPlan = {
  name: "Free Tier",
  tier: "FREE",
  // ...
};

Every paid plan is deliberately left out of the seed — Stripe/Paddle price ids are per-environment, so they're created through /admin/plans instead of being baked into a script.

Nuxfire Production Kit

Ready to build and launch your SaaS?

Get 100% full source code ownership, zero proprietary wrappers, and architecture engineered for millions of requests on Cloudflare.

© 2026 Nuxfire