Payments & Billing

Billing powered by Stripe.

Payment Provider

Stripe is the only payment provider. Plans, subscriptions and the buy flow all run through it.

Every paid Plan row is backed by a Stripe Product and Price (stripeProductId/stripePriceId); the Free plan has neither.

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

Client Integration

Checkout starts on the billing page of a Space (layers/billing/app/pages/[[team]]/settings/billing.vue). It calls stripe.createStripeCheckoutSession and sends the browser to the hosted Stripe Checkout URL:

const session = await useTrpcClient().stripe.createStripeCheckoutSession.mutate({
  priceId: plan.stripePriceId,
  teamId: team.id,
  redirectUrl: window.location.pathname,
});
await navigateTo(session.url, { external: true });

Setup Steps

  1. Deploy, then sign in as a platform admin and open /admin/plans > Connections. Paste the provider's API key (and, for Stripe, the webhook signing secret) there. The key is tested before it is saved and is never shown again.
  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. stripePriceId/stripeProductId are written by that sync and never typed by hand.
  3. Set up the webhook endpoint in the provider's dashboard, using the URL shown in Connections:
    • Stripe: /api/webhooks/billing/stripe
    • Asaas: /api/webhooks/billing/asaas

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 through Stripe Checkout. No account or team exists yet at this point.
  2. PendingPurchase (layers/billing/server/database/schema.ts) — created at checkout start, confirmed by Stripe'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 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, no proprietary wrappers, with architecture ready for millions of requests on Cloudflare.

© 2026 Nuxfire