All documentation

Billing

Stripe-backed subscription billing. The organization (not the individual user) is the billing entity — see docs/ARCHITECTURE.md's Authentication & Authorization section for how organizations work. One Stripe Customer and one subscription per organization; the org's plan tier sets limits that apply to each member's own broker connections, copy relationships, and API keys. Ownership of that data stays exactly as private as it already was — no organization-wide resource sharing was introduced by this feature.

Plan tiers

StarterProfessionalEnterprise
Broker connections / member210Unlimited
Copy relationships / member20250Unlimited
API access✗✓✓
AI Coach✗✓✓
AI Journal✗✓✓
AI News✗✓✓
Priority execution queue✗✓✓
White label✗✗✓
Priority support / advanced analytics✗✓✓
SSO/SAML, dedicated support, custom SLA✗✗✓
CheckoutSelf-serveSelf-serveContact us
Free trial14 days14 days—

Team seats (members + pending invites) default to 3 / 10 / unlimited — not part of the supplied feature matrix, a reasonable default. All of the above lives in one file, src/lib/billing/plans.ts, and is safe to tune without touching any other code.

Honesty note, carried from `src/lib/billing/plans.ts`'s own doc comment: apiAccess, aiCoach, aiJournal, aiNews, the broker/ copy-relationship/seat limits are all functionally enforced — see "What's actually enforced" below. priorityQueue, whiteLabel, prioritySupport, advancedAnalytics, sso, dedicatedSupport, and customSla are recorded and displayed truthfully as plan attributes (shown on /dashboard/billing/plans) but have no functional gate anywhere in this codebase — there's no priority concept in the Copy Engine's per-master FIFO queue lanes to hook into (see docs/ARCHITECTURE.md's Queue Processing section), and no white- labeling, support-ticketing, or SSO subsystem exists yet.

What's actually enforced

FeatureWhere
Broker connection limitsaveBrokerConnection (src/app/dashboard/accounts/add-broker-actions.ts)
Copy relationship limitcreateCopyRelationship (src/app/dashboard/copier/actions.ts)
API key limit + apiAccess gatecreateApiKey (src/app/dashboard/settings/security/actions.ts)
Team seat limitcreateInvite (src/app/dashboard/settings/organization/actions.ts)
aiCoach/dashboard/ai-assistant
aiJournal/dashboard/journal
aiNewsThe Market News dashboard widget

Every check goes through src/lib/billing/usage-limits.ts (checkResourceLimit, checkFeatureAccess, checkSeatLimit), which resolves the calling user's own personal organization and its current plan (getOrgPlan) — an organization with no subscriptions row, or one whose status isn't trialing/active, gets UNSUBSCRIBED_LIMITS, the most restrictive tier — the de facto free plan; there's no literal 4th plan value for "hasn't subscribed."

Local development

getOrgPlan short-circuits to Enterprise's limits (every count unlimited, every feature flag on) whenever NODE_ENV === 'development' — i.e. only under next dev, never under next build && next start (production sets NODE_ENV=production; vitest sets NODE_ENV=test). This exists purely so a developer running the app locally can connect more than one broker, create more than the free-tier's copy relationships/API keys, and exercise aiCoach/aiJournal/aiNews without needing a real Stripe subscription. It only changes the numbers checkResourceLimit/checkFeatureAccess/checkSeatLimit compare against — supabase.auth.getUser(), RLS, and every other security check are completely unaffected; a signed-out request is still rejected identically in dev and production. The /dashboard/billing page reads the real subscriptions row directly (not through getOrgPlan), so it still shows your actual subscription state (or "No active plan") even while enforcement is lifted.

The same flag also short-circuits the three Stripe-touching Server Actions in src/app/dashboard/billing/actions.ts (startCheckout/changePlan/openBillingPortal) — each checks isLocalDevelopment() right after resolving the caller's org and owner/admin role, and, if true, redirects straight back to /dashboard/billing/plans with an explanatory message instead of calling the real Stripe API. This means STRIPE_SECRET_KEY and every STRIPE_PRICE_* env var are not required to run this app locally at all — without it, real checkout would throw (createCheckoutSession requires a configured Price id per plan/interval; getStripe() throws if STRIPE_SECRET_KEY is unset). src/components/billing/plans-grid.tsx mirrors this on the client (process.env.NODE_ENV === 'development', inlined at build time so the branch doesn't exist in a production bundle at all) by swapping the Subscribe/Change-plan buttons for a short note, so the UI doesn't invite a click that would just bounce back. As above, this is purely a UX/convenience bypass — the caller still has to be signed in and hold the org's owner/admin role to reach any of these actions in the first place.

Architecture

  • Checkout (src/lib/billing/checkout.ts) — Stripe-hosted Checkout

Session, mode: 'subscription', allow_promotion_codes: true (coupon support — Stripe renders its own promo-code field, no custom UI needed), trial_period_days from the plan config (free trial support). The Stripe Customer is found-or-created via the Customer Search API on organization_id metadata, not persisted ahead of time — subscriptions is written only by the webhook.

  • Customer Portal (src/lib/billing/portal.ts) — Stripe's own hosted

UI for payment method, billing address, and self-cancel. Deliberately not rebuilt as custom UI — that would mean handling payment details directly, which the Portal exists to avoid.

  • In-app plan change (src/lib/billing/change-plan.ts) — a second,

in-app upgrade/downgrade path alongside the Portal's own, via stripe.subscriptions.update() with proration_behavior: 'create_prorations'.

  • Webhook (src/app/api/webhooks/stripe/route.ts) — the only writer

of subscriptions/invoices. Verifies Stripe's signature against the raw request body (request.text(), before any JSON parsing — Next.js App Router Route Handlers don't auto-parse, unlike the old Pages API routes). Idempotent via stripe_webhook_events (Stripe explicitly documents at-least-once delivery). Handles checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed.

  • Dashboard (/dashboard/billing, /plans, /invoices) — overview

with usage meters, a plan comparison grid with a monthly/yearly toggle, and invoice history linking straight to Stripe's own hosted invoice_pdf for download (no PDF generation of our own).

Required environment variables

Required in production; not required to run `next dev` locally — see "Local development" above. See .env.local.example for the full list with descriptions: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_STARTER_MONTHLY, STRIPE_PRICE_STARTER_YEARLY, STRIPE_PRICE_PROFESSIONAL_MONTHLY, STRIPE_PRICE_PROFESSIONAL_YEARLY, and the optional NEXT_PUBLIC_SALES_EMAIL. No Enterprise Price env vars exist — flipping Enterprise to self-serve later means adding STRIPE_PRICE_ENTERPRISE_{MONTHLY,YEARLY} and setting selfServe: true in src/lib/billing/plans.ts — no other code changes.

Not live-tested

No part of this integration has been exercised against a real Stripe account — there is no Stripe account or API key available in this environment to test against (unlike this session's Supabase work, which was verified live via the supabase CLI). Every piece was built and verified against the official stripe npm package's own installed TypeScript types (node_modules/stripe/cjs/**) rather than guessed — several real, version-specific API details were caught this way and would have been wrong otherwise: Subscription.current_period_end no longer exists at the top level in this API version (it moved to each subscription item, items.data[0].current_period_end), and Invoice.subscription similarly moved to invoice.parent.subscription_details.subscription. Confidence in field names/shapes is high; confidence in the live checkout → webhook → active-subscription loop is unverified.

Validation checklist

Run this once real Stripe test-mode keys are available, before trusting this with real money:

1. Stripe setup — create two Products in test mode (Starter, Professional), each with a monthly and a yearly recurring Price; copy all four Price ids into .env.local. Add STRIPE_SECRET_KEY (test mode). Run stripe listen --forward-to localhost:3000/api/webhooks/stripe and paste the signing secret it prints into STRIPE_WEBHOOK_SECRET. 2. Checkout — from /dashboard/billing/plans, subscribe to Starter monthly. Confirm the Stripe-hosted Checkout page loads with the correct price and a 14-day trial notice, and that a promo-code field is present. 3. Webhook delivery — after completing test-mode checkout (test card 4242 4242 4242 4242), confirm stripe listen's terminal shows checkout.session.completed and customer.subscription.created/ updated delivered with a 200, and that /dashboard/billing now shows the org on the Starter plan, trialing. 4. Usage enforcement — as a Starter-plan org, confirm the 3rd broker connection attempt is blocked with an upgrade message (limit is 2); confirm /dashboard/ai-assistant shows the upgrade prompt, not the feature. 5. Plan change — use both the in-app "Switch to this plan" button and the Stripe Customer Portal's own plan-switching UI to move to Professional; confirm customer.subscription.updated fires and /dashboard/billing reflects the new plan/limits (broker limit now 10, AI features unlocked) within moments. 6. Invoices — confirm /dashboard/billing/invoices lists the invoice from step 2/5 and that "Download" opens Stripe's hosted PDF. 7. Cancel — cancel via the Customer Portal; confirm customer.subscription.deleted fires and the org's status becomes canceled, with usage limits reverting to UNSUBSCRIBED_LIMITS. 8. Webhook security — send a POST to /api/webhooks/stripe with a made-up stripe-signature header and confirm it's rejected with 400, never processed. 9. Idempotency — replay a already-delivered event from the Stripe Dashboard's webhook event log; confirm it's a no-op (no duplicate invoices row, no duplicate audit log entry).