From 4255a99c9e7a012146644a38740b1617714d7594 Mon Sep 17 00:00:00 2001 From: Aleksandar Grbic Date: Sat, 10 Oct 2026 10:39:33 +0200 Subject: [PATCH 1/6] fix(billing): grant paid plan features and stop duplicate subscriptions Plans were seeded without plan_features, so Pro granted nothing. One typed catalog now seeds plans and features. Checkout locks the account row, returns 409 for a live Stripe subscription found locally or in Stripe, reuses an open session and rejects the Free plan. A delayed payment no longer activates a plan. The plan list is public, and an optional yearly price is supported. --- apps/api/.env.example | 2 + apps/api/docs/agents/billing.md | 79 +- apps/api/src/api/billing/billing.constants.ts | 27 + apps/api/src/api/billing/billing.plans.ts | 88 +++ apps/api/src/api/billing/billing.routes.ts | 33 +- apps/api/src/api/billing/billing.schemas.ts | 8 + apps/api/src/api/billing/billing.service.ts | 684 +++++++++++----- apps/api/src/api/billing/billing.types.ts | 6 + .../clients/postgres/schema/billing.schema.ts | 6 +- apps/api/src/config/env/schema.ts | 2 + apps/api/src/config/env/validate.ts | 15 + .../tests/api/billing/billing.plans.test.ts | 100 +++ .../tests/api/billing/billing.routes.test.ts | 36 +- .../tests/api/billing/billing.service.test.ts | 748 +++++++++++++++++- apps/api/tests/config/env/validate.test.ts | 42 +- .../tests/helpers/stripe-webhook-fixtures.ts | 204 ++++- apps/api/tests/setup-test-env.ts | 1 + apps/docs/src/content/docs/api/billing.mdx | 28 +- .../src/content/docs/recipes/add-stripe.mdx | 26 +- 19 files changed, 1879 insertions(+), 256 deletions(-) create mode 100644 apps/api/src/api/billing/billing.constants.ts create mode 100644 apps/api/src/api/billing/billing.plans.ts create mode 100644 apps/api/tests/api/billing/billing.plans.test.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index c1103c2d..8c1aa84d 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -182,6 +182,8 @@ STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_PRICE_ID_FREE= STRIPE_PRICE_ID_PRO= +# Optional yearly price for the Pro plan. Empty = yearly checkout is not offered. +STRIPE_PRICE_ID_PRO_YEARLY= # Queues (BullMQ) + cache. The VALKEY_* + provider keys are required # only when the relevant feature flag is true. QUEUES_ENABLED must be diff --git a/apps/api/docs/agents/billing.md b/apps/api/docs/agents/billing.md index bfdefb55..b6a84f59 100644 --- a/apps/api/docs/agents/billing.md +++ b/apps/api/docs/agents/billing.md @@ -15,9 +15,84 @@ The `stripe-webhooks` plugin enforces: ## Configuration -Plan IDs come from env (`STRIPE_PRICE_ID_FREE`, `STRIPE_PRICE_ID_PRO`) -— never hardcoded. +Price IDs come from env (`STRIPE_PRICE_ID_FREE`, `STRIPE_PRICE_ID_PRO`, +and the optional `STRIPE_PRICE_ID_PRO_YEARLY`), never hardcoded. An empty +yearly ID means yearly is not offered. The validator refuses a yearly ID equal +to the monthly one, since that would make the webhook's price-to-plan mapping +ambiguous. `Stripe never knows about feature keys.` The app derives feature gating from `account_plans` at request time; Stripe only triggers the plan-key update via webhook. + +## Plan catalog + +`billing.plans.ts` is the single declaration of the built-in plans: name, +default flag, the price ID per interval and the feature values. Pro's values +are typed against `FEATURES` (`PlanFeatureValues` requires a key for every +feature), so adding a feature key fails the type check until Pro gets a value. +`billing.plans.test.ts` repeats that check at runtime. `PRO_SEAT_LIMIT` (10) is +the place to change the Pro seat limit. + +Free carries no `plan_features` rows: a missing key resolves to its `FEATURES` +default. `ensureConfiguredPlans` upserts the plan rows and their feature rows on +every call, and deletes stored feature rows the catalog does not assign to a +built-in plan. Consequence: a hand-edit to Free or Pro in the database is +overwritten on the next plan read. Use `account_feature_overrides` for +per-account exceptions. + +## Public plan list + +`GET /api/v1/billing/plans` is public (no auth) so a pricing page can read it. +It only touches the database and env-derived catalog, never the Stripe client. +When `BILLING_ENABLED=false` it still answers, with `purchasableIntervals: []` +on every plan. The other billing routes keep returning 404 while billing is +disabled (`getBillingService()` gates them). + +`purchasableIntervals` lists the intervals a checkout can be started for: billing +enabled, the plan is not the default, and that interval's price is set. + +## Checkout + +`POST /billing/stripe/checkout-session` takes an optional `interval` +(`month` default, or `year`). Errors: + +- Default (Free) plan: 400 `invalidInput`, before any Stripe call. +- Interval with no configured price: 400 `invalidInput` on `interval`. + +The account row is locked (`SELECT ... FOR UPDATE`) for the whole sequence, +so two quick clicks serialize. Inside the lock: + +1. A local `account_plans` row that is not `canceled` rejects with 409 + ("Manage your existing subscription from Billing"). +2. A Stripe subscription for the customer that is not `canceled` or + `incomplete_expired` rejects with 409. This covers the window before the + webhook lands. +3. An open Checkout session for the same account, plan and price is returned + instead of creating another. The session's `metadata.priceId` carries the + price for that match. + +The Stripe client is injectable (`new BillingService({ stripe, settings })`), +so tests run against a real `Stripe` client with a fake HTTP layer +(`createFakeStripe` in `tests/helpers/stripe-webhook-fixtures.ts`). + +## Payment status + +`checkout.session.completed` sets `active` only when `payment_status` is +`paid` or `no_payment_required`. Otherwise (`unpaid`, as with bank debit) the +row is `incomplete`, which does not entitle (`isPlanEntitling`). + +- `checkout.session.async_payment_succeeded`: sets `active` on the matching row. +- `checkout.session.async_payment_failed`: sets `incomplete` on the matching row. + +When the row already belongs to the same subscription, a `completed` event only +records its event id and timestamp. The `customer.subscription.*` events stay +the authority for status. The async events set status because they are the only +signal that a delayed payment settled or failed. + +## Yearly and monthly price mapping + +`customer.subscription.created` and `.updated` map a subscription's price to a +plan through the catalog: either Pro price maps to Pro. The stored +`plans.stripe_price_id` holds the monthly price only, so the mapping must not +read that column. diff --git a/apps/api/src/api/billing/billing.constants.ts b/apps/api/src/api/billing/billing.constants.ts new file mode 100644 index 00000000..69ef5896 --- /dev/null +++ b/apps/api/src/api/billing/billing.constants.ts @@ -0,0 +1,27 @@ +export const PLAN_NAME = { + free: "Free", + pro: "Pro", +} as const; + +export const BILLING_INTERVALS = ["month", "year"] as const; + +export const DEFAULT_BILLING_INTERVAL = "month" as const; + +export const SUBSCRIPTION_CONFLICT_MESSAGE = + "Manage your existing subscription from Billing"; + +/* + * `incomplete_expired` is persisted as `canceled` (see STRIPE_STATUS_MAP), so + * the local check only needs the one terminal status. Stripe's own statuses + * are compared against both values. + */ +export const TERMINAL_SUBSCRIPTION_STATUSES = [ + "canceled", + "incomplete_expired", +] as const; + +export const CHECKOUT_SESSION_MODE = "subscription" as const; + +export const CHECKOUT_SESSION_OPEN = "open" as const; + +export const STRIPE_LIST_PAGE_SIZE = 100; diff --git a/apps/api/src/api/billing/billing.plans.ts b/apps/api/src/api/billing/billing.plans.ts new file mode 100644 index 00000000..ec348aa9 --- /dev/null +++ b/apps/api/src/api/billing/billing.plans.ts @@ -0,0 +1,88 @@ +import type { FEATURES } from "../../lib/acl/acl.constants"; +import { FEATURE_KEYS } from "../../lib/acl/acl.constants"; +import type { FeatureKey } from "../../lib/acl/acl.types"; + +import { BILLING_INTERVALS, PLAN_NAME } from "./billing.constants"; +import type { BillingInterval } from "./billing.types"; + +/* + * THE place to change what each built-in plan grants. + * + * Feature values are typed against the FEATURES catalog: a boolean feature + * takes `{ bool }`, a limit takes `{ number }`. `PlanFeatureValues` requires + * a key for every feature, so adding a key to FEATURES fails the type check + * on Pro until a value is chosen for it. The unit test in + * billing.plans.test.ts repeats that check at runtime. + * + * Free carries no rows on purpose: a missing feature resolves to its FEATURES + * default, which is the free-tier behaviour. + */ +export const PRO_SEAT_LIMIT = 10; + +type FeatureJson = + (typeof FEATURES)[K]["kind"] extends "boolean" + ? { bool: boolean } + : { number: number }; + +export type PlanFeatureValues = { + readonly [K in FeatureKey]: FeatureJson; +}; + +const FREE_FEATURES: Partial = {}; + +const PRO_FEATURES = { + can_export: { bool: true }, + can_invite_team: { bool: true }, + max_seats: { number: PRO_SEAT_LIMIT }, +} satisfies PlanFeatureValues; + +export interface IPlanPriceIds { + readonly free: string; + readonly proMonthly: string; + readonly proYearly: string; +} + +export interface IBuiltInPlan { + readonly name: string; + readonly isDefault: boolean; + /** Empty string means the interval has no price configured. */ + readonly prices: Readonly>; + readonly features: Partial; +} + +export const buildBuiltInPlans = ( + priceIds: IPlanPriceIds +): readonly IBuiltInPlan[] => [ + { + name: PLAN_NAME.free, + isDefault: true, + prices: { month: priceIds.free, year: "" }, + features: FREE_FEATURES, + }, + { + name: PLAN_NAME.pro, + isDefault: false, + prices: { month: priceIds.proMonthly, year: priceIds.proYearly }, + features: PRO_FEATURES, + }, +]; + +/** Feature keys a plan does not assign a value to. Empty for a complete plan. */ +export const featureKeysWithoutValue = (plan: IBuiltInPlan): FeatureKey[] => + FEATURE_KEYS.filter((key) => plan.features[key] === undefined); + +/** + * Maps a Stripe price back to the built-in plan that sells it, for either + * interval. Empty price ids never match, so an unconfigured interval cannot + * claim a subscription that carries an empty price. + */ +export const findPlanNameByPriceId = ( + catalog: readonly IBuiltInPlan[], + priceId: string +): string | undefined => { + return priceId === "" + ? undefined + : catalog.find((plan) => + BILLING_INTERVALS.some((interval) => plan.prices[interval] === priceId) + )?.name; +}; diff --git a/apps/api/src/api/billing/billing.routes.ts b/apps/api/src/api/billing/billing.routes.ts index 79a6758f..ab9204f3 100644 --- a/apps/api/src/api/billing/billing.routes.ts +++ b/apps/api/src/api/billing/billing.routes.ts @@ -13,7 +13,8 @@ import { SubscriptionResponse, WebhookResponse, } from "./billing.schemas"; -import { getBillingService } from "./billing.service"; +import { DEFAULT_BILLING_INTERVAL } from "./billing.constants"; +import { getBillingService, listBillingPlans } from "./billing.service"; import { grantTestTeamPlan } from "./test-plan"; import { env } from "../../config/env"; import { now } from "../../lib/time/now"; @@ -69,14 +70,6 @@ const billingRoutes = new Elysia() }, } ) - .get("/plans", async () => getBillingService().listPlans(), { - response: PlanListResponse, - detail: { - tags: ["Billing"], - summary: "List available plans", - security: [{ cookieAuth: [] }], - }, - }) .get( "/subscription", async ({ accountId, user }) => { @@ -119,7 +112,8 @@ const billingRoutes = new Elysia() billingAccountId, user.id, body.successUrl, - body.cancelUrl + body.cancelUrl, + body.interval ?? DEFAULT_BILLING_INTERVAL ); }, { @@ -157,6 +151,25 @@ const billingRoutes = new Elysia() } ) ) + .use( + /* + * Public, outside requireAuth: the pricing page reads this. It never + * touches Stripe and works when BILLING_ENABLED=false (every + * purchasableIntervals is then empty). The other routes in this file keep + * returning 404 while billing is disabled, via getBillingService(). + */ + new Elysia() + .onError(({ code, error, set }) => + errorHandler({ code: String(code), error, set }) + ) + .get("/plans", async () => listBillingPlans(), { + response: PlanListResponse, + detail: { + tags: ["Billing"], + summary: "List available plans (public)", + }, + }) + ) .use( new Elysia() .onError(({ code, error, set }) => diff --git a/apps/api/src/api/billing/billing.schemas.ts b/apps/api/src/api/billing/billing.schemas.ts index c680fccd..9119377d 100644 --- a/apps/api/src/api/billing/billing.schemas.ts +++ b/apps/api/src/api/billing/billing.schemas.ts @@ -1,9 +1,15 @@ import { t } from "elysia"; +export const BillingIntervalSchema = t.Union([ + t.Literal("month"), + t.Literal("year"), +]); + export const PlanResponse = t.Object({ id: t.Number(), name: t.String(), isDefault: t.Boolean(), + purchasableIntervals: t.Array(BillingIntervalSchema), }); export const PlanListResponse = t.Array(PlanResponse); @@ -35,6 +41,8 @@ export const WebhookResponse = t.Object({ export const CreateCheckoutSessionSchema = t.Object({ planId: t.Number(), + /* Optional; omitted means "month". A yearly interval needs STRIPE_PRICE_ID_PRO_YEARLY. */ + interval: t.Optional(BillingIntervalSchema), successUrl: t.String({ format: "uri" }), cancelUrl: t.String({ format: "uri" }), }); diff --git a/apps/api/src/api/billing/billing.service.ts b/apps/api/src/api/billing/billing.service.ts index 18de998b..6e8be4f7 100644 --- a/apps/api/src/api/billing/billing.service.ts +++ b/apps/api/src/api/billing/billing.service.ts @@ -1,20 +1,35 @@ -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, isNull, notInArray, or } from "drizzle-orm"; import Stripe from "stripe"; import { db } from "../../clients/postgres"; import { accountPlans, accounts, + planFeatures, plans, stripeWebhookEvents, } from "../../clients/postgres/schema"; import { env } from "../../config/env"; import { logger } from "../../config/logger"; +import { FEATURE_KEYS } from "../../lib/acl/acl.constants"; import { AUDIT_ACTIONS, auditLogService } from "../../lib/audit-log"; import { ApiErrors, getErrorMessage } from "../../lib/errors"; import { now } from "../../lib/time/now"; +import { + BILLING_INTERVALS, + CHECKOUT_SESSION_MODE, + CHECKOUT_SESSION_OPEN, + DEFAULT_BILLING_INTERVAL, + STRIPE_LIST_PAGE_SIZE, + SUBSCRIPTION_CONFLICT_MESSAGE, + TERMINAL_SUBSCRIPTION_STATUSES, +} from "./billing.constants"; +import type { IPlanPriceIds } from "./billing.plans"; +import { buildBuiltInPlans, findPlanNameByPriceId } from "./billing.plans"; import type { + AccountPlanStatus, + BillingInterval, ICheckoutSessionResult, IPlanSummary, IPortalSessionResult, @@ -22,7 +37,7 @@ import type { } from "./billing.types"; import { assertAllowedBillingRedirectUrl } from "./billing.utils"; -const STRIPE_STATUS_MAP: Record = { +const STRIPE_STATUS_MAP: Record = { active: "active", trialing: "trialing", past_due: "past_due", @@ -33,9 +48,14 @@ const STRIPE_STATUS_MAP: Record = { incomplete_expired: "canceled", }; -const mapStripeStatus = (stripeStatus: string): string => +const mapStripeStatus = (stripeStatus: string): AccountPlanStatus => STRIPE_STATUS_MAP[stripeStatus] ?? "incomplete"; +const terminalStatuses: readonly string[] = TERMINAL_SUBSCRIPTION_STATUSES; + +const isLiveSubscriptionStatus = (status: string): boolean => + !terminalStatuses.includes(status); + type BillingTransaction = Parameters[0]>[0]; interface IStripeEventSnapshot { @@ -49,6 +69,30 @@ interface IStripeWebhookDetails { readonly eventCreated: number; } +/* + * Injected for tests. Production leaves both unset: the client is built on + * first use, so reading plans or constructing the service never needs a + * Stripe key, and settings are read from env at call time. + */ +export interface IBillingServiceOptions { + readonly stripe?: Stripe; + readonly settings?: IBillingSettings; +} + +export interface IBillingSettings { + readonly enabled: boolean; + readonly priceIds: IPlanPriceIds; +} + +const currentBillingSettings = (): IBillingSettings => ({ + enabled: env.BILLING_ENABLED, + priceIds: { + free: env.STRIPE_PRICE_ID_FREE, + proMonthly: env.STRIPE_PRICE_ID_PRO, + proYearly: env.STRIPE_PRICE_ID_PRO_YEARLY, + }, +}); + const stripeEventOccurredAt = (eventCreated: number): string => new Date(eventCreated * 1000).toISOString(); @@ -65,41 +109,180 @@ const isOlderStripeEvent = ( const STRIPE_REQUEST_TIMEOUT_MS = 10_000; -export class BillingService { - private readonly stripe: Stripe; +const createStripeClient = (): Stripe => { + if (env.STRIPE_SECRET_KEY === "") { + throw ApiErrors.internal( + "BillingService instantiated without STRIPE_SECRET_KEY" + ); + } + + /* + * Explicit budget: the SDK's implicit 80s default would hold + * checkout/portal request handlers hostage to a slow Stripe API. + * The SDK retries idempotent calls internally, so each attempt + * gets this budget. + */ + return new Stripe(env.STRIPE_SECRET_KEY, { + timeout: STRIPE_REQUEST_TIMEOUT_MS, + }); +}; + +const priceForPlan = ( + settings: IBillingSettings, + planName: string, + interval: BillingInterval +): string => + buildBuiltInPlans(settings.priceIds).find((plan) => plan.name === planName) + ?.prices[interval] ?? ""; + +/* + * Seeds the built-in plan rows and their feature values. Idempotent: rows are + * upserted by natural key, and the catalog in billing.plans.ts is the source of + * truth, so a hand-edit to a built-in plan is overwritten on the next call. + */ +const ensureConfiguredPlans = async ( + settings: IBillingSettings +): Promise => { + const catalog = buildBuiltInPlans(settings.priceIds); + + await db.transaction(async (tx) => { + for (const definition of catalog) { + const [row] = await tx + .insert(plans) + .values({ + name: definition.name, + isDefault: definition.isDefault, + stripePriceId: definition.prices.month, + }) + .onConflictDoUpdate({ + target: plans.name, + set: { + isDefault: definition.isDefault, + stripePriceId: definition.prices.month, + }, + }) + .returning({ id: plans.id }); + + if (row === undefined) { + throw ApiErrors.internal("Built-in plan row could not be resolved"); + } - constructor() { - if (env.STRIPE_SECRET_KEY === "") { - throw ApiErrors.internal( - "BillingService instantiated without STRIPE_SECRET_KEY" + const assignedKeys = FEATURE_KEYS.filter( + (featureKey) => definition.features[featureKey] !== undefined ); + + for (const featureKey of assignedKeys) { + const value = definition.features[featureKey]; + + await tx + .insert(planFeatures) + .values({ planId: row.id, featureKey, value }) + .onConflictDoUpdate({ + target: [planFeatures.planId, planFeatures.featureKey], + set: { value }, + }); + } + + /* + * Stored rows for keys the catalog does not assign are removed, so a + * built-in plan's features always match billing.plans.ts. + */ + await tx + .delete(planFeatures) + .where( + and( + eq(planFeatures.planId, row.id), + notInArray(planFeatures.featureKey, assignedKeys) + ) + ); } + }); +}; - /* - * Explicit budget: the SDK's implicit 80s default would hold - * checkout/portal request handlers hostage to a slow Stripe API. - * The SDK retries idempotent calls internally, so each attempt - * gets this budget. - */ - this.stripe = new Stripe(env.STRIPE_SECRET_KEY, { - timeout: STRIPE_REQUEST_TIMEOUT_MS, - }); +const purchasableIntervalsFor = ( + settings: IBillingSettings, + planName: string, + isDefault: boolean +): BillingInterval[] => { + const definition = buildBuiltInPlans(settings.priceIds).find( + (plan) => plan.name === planName + ); + + return !settings.enabled || isDefault || definition === undefined + ? [] + : BILLING_INTERVALS.filter( + (interval) => definition.prices[interval] !== "" + ); +}; + +/* + * Public: this is the pricing-page read. It touches only the database and the + * env-derived catalog, never the Stripe client, and it answers even when + * billing is disabled (in which case no interval is purchasable). + */ +export const listBillingPlans = async ( + settings: IBillingSettings = currentBillingSettings() +): Promise => { + await ensureConfiguredPlans(settings); + + const rows = await db.query.plans.findMany({ orderBy: [plans.name] }); + + return rows.map((plan) => ({ + id: plan.id, + name: plan.name, + isDefault: plan.isDefault, + purchasableIntervals: purchasableIntervalsFor( + settings, + plan.name, + plan.isDefault + ), + })); +}; + +const isPaidCheckout = (session: Stripe.Checkout.Session): boolean => + session.payment_status === "paid" || + session.payment_status === "no_payment_required"; + +/* + * Status a checkout event implies for the account's subscription row. + * `unpaid` (delayed payment methods such as bank debit) must not entitle + * until the async success event arrives. + */ +const checkoutStatusFor = ( + eventType: string, + session: Stripe.Checkout.Session +): AccountPlanStatus => { + switch (eventType) { + case "checkout.session.async_payment_succeeded": + return "active"; + case "checkout.session.async_payment_failed": + return "incomplete"; + default: + return isPaidCheckout(session) ? "active" : "incomplete"; } +}; - async listPlans(): Promise { - await this.ensureConfiguredPlans(); +export class BillingService { + private stripeClient: Stripe | undefined; + private readonly settings: IBillingSettings; + + constructor(options: IBillingServiceOptions = {}) { + this.stripeClient = options.stripe; + this.settings = options.settings ?? currentBillingSettings(); + } - const rows = await db.query.plans.findMany({ orderBy: [plans.name] }); + private get stripe(): Stripe { + this.stripeClient ??= createStripeClient(); - return rows.map((plan) => ({ - id: plan.id, - name: plan.name, - isDefault: plan.isDefault, - })); + return this.stripeClient; + } + + listPlans(): Promise { + return listBillingPlans(this.settings); } async getSubscription(accountId: string): Promise { - await this.ensureConfiguredPlans(); + await ensureConfiguredPlans(this.settings); const [accountPlan, defaultPlan] = await Promise.all([ /* @@ -143,141 +326,248 @@ export class BillingService { }; } + /** + * Starts (or reuses) a Stripe Checkout session for one plan and interval. + * + * The account row is locked for the whole sequence, so two quick clicks + * serialize: the second request waits, then finds the first request's + * open session on Stripe and returns its URL instead of creating another. + * A live subscription, from either the local row or Stripe itself (the + * webhook may not have landed yet), rejects the request with a conflict. + */ async createCheckoutSession( planId: number, accountId: string, actorUserId: string, successUrl: string, - cancelUrl: string + cancelUrl: string, + interval: BillingInterval = DEFAULT_BILLING_INTERVAL ): Promise { assertAllowedBillingRedirectUrl(successUrl, "successUrl"); assertAllowedBillingRedirectUrl(cancelUrl, "cancelUrl"); - await this.ensureConfiguredPlans(); + await ensureConfiguredPlans(this.settings); - const [plan, account] = await Promise.all([ - db.query.plans.findFirst({ where: eq(plans.id, planId) }), - db.query.accounts.findFirst({ - where: and(eq(accounts.id, accountId), isNull(accounts.deletedAt)), - }), - ]); + const plan = await db.query.plans.findFirst({ + where: eq(plans.id, planId), + }); if (!plan) { throw ApiErrors.notFound("Plan"); } - if (!account) { - throw ApiErrors.notFound("Account"); + if (plan.isDefault) { + throw ApiErrors.invalidInput("Choose a paid subscription plan", "planId"); } - if (plan.stripePriceId === "") { - throw ApiErrors.internal("Plan is missing a Stripe Price ID"); + const priceId = priceForPlan(this.settings, plan.name, interval); + + if (priceId === "") { + throw ApiErrors.invalidInput( + "This billing interval is not available for the plan", + "interval" + ); } - let stripeCustomerId = account.stripeCustomerId; + return db.transaction(async (tx) => { + const [account] = await tx + .select() + .from(accounts) + .where(and(eq(accounts.id, accountId), isNull(accounts.deletedAt))) + .for("update"); + + if (account === undefined) { + throw ApiErrors.notFound("Account"); + } + + const localSubscription = await tx.query.accountPlans.findFirst({ + where: and( + eq(accountPlans.accountId, accountId), + isNull(accountPlans.revokedAt) + ), + }); - if (stripeCustomerId === null || stripeCustomerId === "") { /* - * Idempotency key keyed on the account id makes Stripe collapse a - * double-click into a single customer record. Without it, two - * parallel checkout requests each see `stripeCustomerId === null`, - * each `customers.create()` returns a *different* id, and the - * second DB write wins: the orphaned customer's future webhook - * deliveries don't resolve the account and the subscription - * silently lands on the wrong tenant. Stripe holds the key for - * 24h, which covers any plausible double-submit window. + * Only a Stripe-backed row blocks checkout: an admin grant or a test + * fixture plan has no subscription to manage, and buying replaces it. */ - const customer = await this.stripe.customers.create( - { - name: account.name, - metadata: { accountId: account.id }, - }, - { idempotencyKey: `account-customer:${account.id}` } + if ( + localSubscription !== undefined && + (localSubscription.stripeSubscriptionId ?? "") !== "" && + isLiveSubscriptionStatus(localSubscription.status) + ) { + throw ApiErrors.conflict(SUBSCRIPTION_CONFLICT_MESSAGE); + } + + const stripeCustomerId = await this.ensureStripeCustomer(tx, account); + + await this.assertNoLiveStripeSubscription(stripeCustomerId); + + const reusableUrl = await this.findReusableCheckoutUrl( + stripeCustomerId, + accountId, + plan.id, + priceId ); - stripeCustomerId = customer.id; + if (reusableUrl !== null) { + return { url: reusableUrl }; + } - /* - * Conditional update: only write the customer id when the column - * is still empty. A concurrent request that beat us to the Stripe - * API may have already filled it with the same value (idempotency - * key collapse) or, in theory, raced past our row in a different - * order; either way, leaving the existing value alone keeps the - * write idempotent. - */ - await db - .update(accounts) - .set({ stripeCustomerId }) - .where( - and( - eq(accounts.id, accountId), - or( - isNull(accounts.stripeCustomerId), - eq(accounts.stripeCustomerId, "") - ) + const session = await this.stripe.checkout.sessions.create({ + customer: stripeCustomerId, + mode: CHECKOUT_SESSION_MODE, + line_items: [{ price: priceId, quantity: 1 }], + success_url: successUrl, + cancel_url: cancelUrl, + metadata: { + planId: String(plan.id), + priceId, + accountId: account.id, + }, + }); + + if (session.url === null) { + throw ApiErrors.internal("Stripe did not return a checkout URL"); + } + + void auditLogService.record({ + userId: actorUserId, + action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + targetAccountId: accountId, + metadata: { + planId: plan.id, + sessionId: session.id, + accountId, + interval, + }, + }); + + return { url: session.url }; + }); + } + + private async ensureStripeCustomer( + tx: BillingTransaction, + account: typeof accounts.$inferSelect + ): Promise { + const existing = account.stripeCustomerId; + + if (existing !== null && existing !== "") { + return existing; + } + + /* + * Idempotency key keyed on the account id makes Stripe collapse a + * double-click into a single customer record. Without it, two + * parallel checkout requests each see `stripeCustomerId === null`, + * each `customers.create()` returns a *different* id, and the + * second DB write wins: the orphaned customer's future webhook + * deliveries don't resolve the account and the subscription + * silently lands on the wrong tenant. Stripe holds the key for + * 24h, which covers any plausible double-submit window. + */ + const customer = await this.stripe.customers.create( + { + name: account.name, + metadata: { accountId: account.id }, + }, + { idempotencyKey: `account-customer:${account.id}` } + ); + + /* + * Conditional update: only write the customer id when the column is still + * empty, so an existing value is never overwritten. + */ + await tx + .update(accounts) + .set({ stripeCustomerId: customer.id }) + .where( + and( + eq(accounts.id, account.id), + or( + isNull(accounts.stripeCustomerId), + eq(accounts.stripeCustomerId, "") ) - ); + ) + ); + + return customer.id; + } + + private async assertNoLiveStripeSubscription( + stripeCustomerId: string + ): Promise { + for await (const subscription of this.stripe.subscriptions.list({ + customer: stripeCustomerId, + status: "all", + limit: STRIPE_LIST_PAGE_SIZE, + })) { + if (isLiveSubscriptionStatus(subscription.status)) { + throw ApiErrors.conflict(SUBSCRIPTION_CONFLICT_MESSAGE); + } } + } - const session = await this.stripe.checkout.sessions.create({ + private async findReusableCheckoutUrl( + stripeCustomerId: string, + accountId: string, + planId: number, + priceId: string + ): Promise { + for await (const session of this.stripe.checkout.sessions.list({ customer: stripeCustomerId, - mode: "subscription", - line_items: [{ price: plan.stripePriceId, quantity: 1 }], - success_url: successUrl, - cancel_url: cancelUrl, - metadata: { - planId: String(plan.id), - accountId: account.id, - }, + status: CHECKOUT_SESSION_OPEN, + limit: STRIPE_LIST_PAGE_SIZE, + })) { + if ( + session.mode === CHECKOUT_SESSION_MODE && + session.metadata?.accountId === accountId && + session.metadata.planId === String(planId) && + session.metadata.priceId === priceId && + session.url !== null + ) { + return session.url; + } + } + + return null; + } + + async createPortalSession( + accountId: string, + actorUserId: string, + returnUrl: string + ): Promise { + assertAllowedBillingRedirectUrl(returnUrl, "returnUrl"); + + const account = await db.query.accounts.findFirst({ + where: and(eq(accounts.id, accountId), isNull(accounts.deletedAt)), }); + const stripeCustomerId = account?.stripeCustomerId; - if (session.url === null) { - throw ApiErrors.internal("Stripe did not return a checkout URL"); + if ( + stripeCustomerId === undefined || + stripeCustomerId === null || + stripeCustomerId === "" + ) { + throw ApiErrors.notFound("Stripe customer for account"); } + const session = await this.stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: returnUrl, + }); + void auditLogService.record({ userId: actorUserId, - action: AUDIT_ACTIONS.BILLING_CHECKOUT_SESSION_CREATED, + action: AUDIT_ACTIONS.BILLING_PORTAL_SESSION_CREATED, targetAccountId: accountId, - metadata: { planId: plan.id, sessionId: session.id, accountId }, + metadata: { sessionId: session.id, accountId }, }); return { url: session.url }; } - private async ensureConfiguredPlans(): Promise { - await db.transaction(async (tx) => { - await tx - .insert(plans) - .values({ - name: "Free", - isDefault: true, - stripePriceId: env.STRIPE_PRICE_ID_FREE, - }) - .onConflictDoUpdate({ - target: plans.name, - set: { - isDefault: true, - stripePriceId: env.STRIPE_PRICE_ID_FREE, - }, - }); - - await tx - .insert(plans) - .values({ - name: "Pro", - isDefault: false, - stripePriceId: env.STRIPE_PRICE_ID_PRO, - }) - .onConflictDoUpdate({ - target: plans.name, - set: { - isDefault: false, - stripePriceId: env.STRIPE_PRICE_ID_PRO, - }, - }); - }); - } - private async ensureIdempotent( tx: BillingTransaction, eventId: string, @@ -342,7 +632,21 @@ export class BillingService { return true; } - private async handleCheckoutSessionCompleted( + /* + * Handles checkout.session.completed and the two async-payment events. + * + * Completion alone does not write status when the subscription row already + * exists: `customer.subscription.*` is the authority there, and Stripe emits + * both with the same `created` second. The async events are the exception, + * since they are the only signal that a delayed payment settled or failed, + * so they set the status on a matching row. + * + * Revoking the row and inserting a replacement discards the period, and a + * row without `currentPeriodEnd` is permanently unsweepable, since the + * downgrade job filters on `isNotNull(currentPeriodEnd)`. So a new row is + * only inserted when no row exists for this subscription. + */ + private async handleCheckoutSessionEvent( tx: BillingTransaction, session: Stripe.Checkout.Session, details: IStripeWebhookDetails @@ -406,6 +710,9 @@ export class BillingService { ? session.subscription : (session.subscription?.id ?? null); + const targetStatus = checkoutStatusFor(details.eventType, session); + const isCompletion = details.eventType === "checkout.session.completed"; + const current = await tx.query.accountPlans.findFirst({ where: and( eq(accountPlans.accountId, accountId), @@ -413,25 +720,6 @@ export class BillingService { ), }); - /* - * A row for this same subscription is already materialized, so - * `customer.subscription.*` is the authority on it and checkout only - * records that it saw the event. - * - * Status and plan are deliberately NOT written here. Checkout - * completion means "the customer finished the hosted flow", which is - * not the same claim as "the subscription is active": Stripe emits - * both events with the same `created` second, so neither is skipped by - * the ordering guard and whichever lands last wins. Writing `active` - * from this side lets a checkout arriving after an `unpaid`, - * `past_due`, `canceled` or `trialing` update silently restore paid - * entitlement to a delinquent account. - * - * Revoking the row and inserting a replacement is worse still: it - * discards the period, and a row without `currentPeriodEnd` is - * permanently unsweepable, since the downgrade job filters on - * `isNotNull(currentPeriodEnd)`. - */ if ( current !== undefined && subscriptionId !== null && @@ -440,6 +728,7 @@ export class BillingService { await tx .update(accountPlans) .set({ + ...(isCompletion ? {} : { status: targetStatus }), lastStripeEventId: details.eventId, lastStripeEventAt: stripeEventOccurredAt(details.eventCreated), }) @@ -450,6 +739,10 @@ export class BillingService { ) ); + if (!isCompletion) { + this.recordReconciled(accountId, details, planId, targetStatus); + } + return; } @@ -466,7 +759,7 @@ export class BillingService { await tx.insert(accountPlans).values({ accountId, planId, - status: "active", + status: targetStatus, source: "stripe", /* * The session names its subscription. Dropping it makes @@ -486,6 +779,15 @@ export class BillingService { planId, }); + this.recordReconciled(accountId, details, planId, targetStatus); + } + + private recordReconciled( + accountId: string, + details: IStripeWebhookDetails, + planId: number | undefined, + status: AccountPlanStatus + ): void { void auditLogService.record({ userId: null, action: AUDIT_ACTIONS.STRIPE_RECONCILED, @@ -495,7 +797,7 @@ export class BillingService { eventId: details.eventId, eventType: details.eventType, planId, - status: "active", + status, }, }); } @@ -515,12 +817,25 @@ export class BillingService { return; } + /* + * Matched against the catalog, not plans.stripePriceId: the row holds the + * monthly price only, and either Pro price must map to Pro. + */ + const planName = findPlanNameByPriceId( + buildBuiltInPlans(this.settings.priceIds), + newPriceId + ); + + if (planName === undefined) { + return; + } + const [account, newPlan] = await Promise.all([ tx.query.accounts.findFirst({ where: eq(accounts.stripeCustomerId, customerId), }), tx.query.plans.findFirst({ - where: eq(plans.stripePriceId, newPriceId), + where: eq(plans.name, planName), }), ]); @@ -560,18 +875,12 @@ export class BillingService { lastStripeEventAt: stripeEventOccurredAt(details.eventCreated), }); - void auditLogService.record({ - userId: null, - action: AUDIT_ACTIONS.STRIPE_RECONCILED, - resource: `account:${account.id}`, - targetAccountId: account.id, - metadata: { - eventId: details.eventId, - eventType: details.eventType, - planId: newPlan.id, - status: mapStripeStatus(subscription.status), - }, - }); + this.recordReconciled( + account.id, + details, + newPlan.id, + mapStripeStatus(subscription.status) + ); } private async handleSubscriptionDeleted( @@ -617,52 +926,7 @@ export class BillingService { ) ); - void auditLogService.record({ - userId: null, - action: AUDIT_ACTIONS.STRIPE_RECONCILED, - resource: `account:${account.id}`, - targetAccountId: account.id, - metadata: { - eventId: details.eventId, - eventType: details.eventType, - status: "canceled", - }, - }); - } - - async createPortalSession( - accountId: string, - actorUserId: string, - returnUrl: string - ): Promise { - assertAllowedBillingRedirectUrl(returnUrl, "returnUrl"); - - const account = await db.query.accounts.findFirst({ - where: and(eq(accounts.id, accountId), isNull(accounts.deletedAt)), - }); - const stripeCustomerId = account?.stripeCustomerId; - - if ( - stripeCustomerId === undefined || - stripeCustomerId === null || - stripeCustomerId === "" - ) { - throw ApiErrors.notFound("Stripe customer for account"); - } - - const session = await this.stripe.billingPortal.sessions.create({ - customer: stripeCustomerId, - return_url: returnUrl, - }); - - void auditLogService.record({ - userId: actorUserId, - action: AUDIT_ACTIONS.BILLING_PORTAL_SESSION_CREATED, - targetAccountId: accountId, - metadata: { sessionId: session.id, accountId }, - }); - - return { url: session.url }; + this.recordReconciled(account.id, details, undefined, "canceled"); } async constructWebhookEvent( @@ -700,12 +964,14 @@ export class BillingService { } switch (event.type) { - case "checkout.session.completed": { - await this.handleCheckoutSessionCompleted( - tx, - event.data.object, - details - ); + case "checkout.session.completed": + + // falls through + case "checkout.session.async_payment_succeeded": + + // falls through + case "checkout.session.async_payment_failed": { + await this.handleCheckoutSessionEvent(tx, event.data.object, details); return; } diff --git a/apps/api/src/api/billing/billing.types.ts b/apps/api/src/api/billing/billing.types.ts index 25ecde03..4cc504cf 100644 --- a/apps/api/src/api/billing/billing.types.ts +++ b/apps/api/src/api/billing/billing.types.ts @@ -1,7 +1,13 @@ +import type { BILLING_INTERVALS } from "./billing.constants"; + +export type BillingInterval = (typeof BILLING_INTERVALS)[number]; + export interface IPlanSummary { id: number; name: string; isDefault: boolean; + /** Intervals a checkout can be started for right now (billing on, price set). */ + purchasableIntervals: BillingInterval[]; } export type AccountPlanStatus = diff --git a/apps/api/src/clients/postgres/schema/billing.schema.ts b/apps/api/src/clients/postgres/schema/billing.schema.ts index 87113f1d..c811f162 100644 --- a/apps/api/src/clients/postgres/schema/billing.schema.ts +++ b/apps/api/src/clients/postgres/schema/billing.schema.ts @@ -37,8 +37,10 @@ export const plans = billing.table( ); /* - * Plan ↔ feature mapping. Per-deployment overridable: seeds at boot - * via `ensureConfiguredPlans` and operators can hand-edit rows. + * Plan ↔ feature mapping. The built-in plans' rows are written from the + * catalog in `api/billing/billing.plans.ts` on every plan read, so a hand + * edit to Free or Pro is reverted; per-account exceptions belong in + * `account_feature_overrides`. * `featureKey` is validated in code against the `FEATURES` catalog; * `value` is jsonb shaped as `{ bool: boolean }` for boolean features * and `{ number: number }` for limits. diff --git a/apps/api/src/config/env/schema.ts b/apps/api/src/config/env/schema.ts index f7971ffd..b7ec41d7 100644 --- a/apps/api/src/config/env/schema.ts +++ b/apps/api/src/config/env/schema.ts @@ -193,6 +193,8 @@ export const envSchema = t.Object({ STRIPE_WEBHOOK_SECRET: t.String({ default: "" }), STRIPE_PRICE_ID_FREE: t.String({ default: "" }), STRIPE_PRICE_ID_PRO: t.String({ default: "" }), + /* Optional. Empty means the yearly interval is not offered. */ + STRIPE_PRICE_ID_PRO_YEARLY: t.String({ default: "" }), QUEUES_ENABLED: t.Boolean({ default: true }), CACHE_ENABLED: t.Boolean({ default: true }), diff --git a/apps/api/src/config/env/validate.ts b/apps/api/src/config/env/validate.ts index f27cce85..f73fafd0 100644 --- a/apps/api/src/config/env/validate.ts +++ b/apps/api/src/config/env/validate.ts @@ -272,6 +272,7 @@ const readBilling = (source: EnvSource) => ({ STRIPE_WEBHOOK_SECRET: source.STRIPE_WEBHOOK_SECRET ?? "", STRIPE_PRICE_ID_FREE: source.STRIPE_PRICE_ID_FREE ?? "", STRIPE_PRICE_ID_PRO: source.STRIPE_PRICE_ID_PRO ?? "", + STRIPE_PRICE_ID_PRO_YEARLY: source.STRIPE_PRICE_ID_PRO_YEARLY ?? "", }); const readBackground = (source: EnvSource) => ({ @@ -621,6 +622,20 @@ const checkBilling = (env: Env): string[] => { errors.push("STRIPE_PRICE_ID_PRO required when BILLING_ENABLED=true"); } + /* + * The webhook maps a subscription's price back to a plan. A yearly price + * equal to the monthly one would make that mapping ambiguous about the + * interval, so refuse it at boot rather than at the first renewal. + */ + if ( + env.STRIPE_PRICE_ID_PRO_YEARLY !== "" && + env.STRIPE_PRICE_ID_PRO_YEARLY === env.STRIPE_PRICE_ID_PRO + ) { + errors.push( + "STRIPE_PRICE_ID_PRO_YEARLY must differ from STRIPE_PRICE_ID_PRO" + ); + } + /* * A non-empty but obviously-fake Stripe secret (copy-pasted placeholder) * passes the emptiness checks above and boots, then fails at the first diff --git a/apps/api/tests/api/billing/billing.plans.test.ts b/apps/api/tests/api/billing/billing.plans.test.ts new file mode 100644 index 00000000..ccf885b0 --- /dev/null +++ b/apps/api/tests/api/billing/billing.plans.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; + +import { + PRO_SEAT_LIMIT, + buildBuiltInPlans, + featureKeysWithoutValue, + findPlanNameByPriceId, +} from "../../../src/api/billing/billing.plans"; +import { FEATURE_KEYS, FEATURES } from "../../../src/lib/acl/acl.constants"; + +const PRICES = { + free: "price_free", + proMonthly: "price_pro_monthly", + proYearly: "price_pro_yearly", +}; + +const catalog = buildBuiltInPlans(PRICES); + +const requirePlan = (name: string) => { + const plan = catalog.find((candidate) => candidate.name === name); + + if (plan === undefined) { + throw new Error(`plan ${name} missing from catalog`); + } + + return plan; +}; + +describe("built-in plan catalog", () => { + test("Pro carries a value for every feature key", () => { + expect(featureKeysWithoutValue(requirePlan("Pro"))).toEqual([]); + }); + + test("Free carries no feature rows (missing keys resolve to FEATURES defaults)", () => { + expect(Object.keys(requirePlan("Free").features)).toEqual([]); + }); + + test("every Pro value has the JSON shape its FEATURES kind requires", () => { + const pro = requirePlan("Pro"); + + for (const key of FEATURE_KEYS) { + const json = JSON.stringify(pro.features[key]); + + if (FEATURES[key].kind === "boolean") { + expect(json).toMatch(/^\{"bool":(?:true|false)\}$/u); + } else { + expect(json).toMatch(/^\{"number":\d+\}$/u); + } + } + }); + + test("Pro grants every boolean feature and the documented seat limit", () => { + const pro = requirePlan("Pro"); + + for (const key of FEATURE_KEYS) { + if (FEATURES[key].kind === "boolean") { + expect(pro.features[key]).toEqual({ bool: true }); + } + } + + expect(PRO_SEAT_LIMIT).toBe(10); + expect(pro.features.max_seats).toEqual({ number: PRO_SEAT_LIMIT }); + }); + + test("Free is the default plan and Pro is not", () => { + expect(requirePlan("Free").isDefault).toBe(true); + expect(requirePlan("Pro").isDefault).toBe(false); + }); + + test("Pro exposes a monthly and a yearly price; Free exposes no yearly price", () => { + const pro = requirePlan("Pro"); + + expect(pro.prices).toEqual({ + month: PRICES.proMonthly, + year: PRICES.proYearly, + }); + expect(requirePlan("Free").prices.year).toBe(""); + }); +}); + +describe("findPlanNameByPriceId", () => { + test("maps both Pro prices to Pro", () => { + expect(findPlanNameByPriceId(catalog, PRICES.proMonthly)).toBe("Pro"); + expect(findPlanNameByPriceId(catalog, PRICES.proYearly)).toBe("Pro"); + }); + + test("maps the Free price to Free", () => { + expect(findPlanNameByPriceId(catalog, PRICES.free)).toBe("Free"); + }); + + test("returns undefined for an unknown price", () => { + expect(findPlanNameByPriceId(catalog, "price_unknown")).toBeUndefined(); + }); + + test("never matches an empty price id, even when the yearly price is unset", () => { + const withoutYearly = buildBuiltInPlans({ ...PRICES, proYearly: "" }); + + expect(findPlanNameByPriceId(withoutYearly, "")).toBeUndefined(); + }); +}); diff --git a/apps/api/tests/api/billing/billing.routes.test.ts b/apps/api/tests/api/billing/billing.routes.test.ts index cad7022d..6d17fe9b 100644 --- a/apps/api/tests/api/billing/billing.routes.test.ts +++ b/apps/api/tests/api/billing/billing.routes.test.ts @@ -77,7 +77,7 @@ describe("GET /api/v1/billing/plans", () => { await cleanDatabase(); }); - test("401 without an auth cookie", async () => { + test("200 without an auth cookie: the pricing page reads the catalog publicly", async () => { if (!(await requireDb())) { return; } @@ -87,7 +87,7 @@ describe("GET /api/v1/billing/plans", () => { new Request("http://localhost/api/v1/billing/plans") ); - expect(res.status).toBe(401); + expect(res.status).toBe(200); }); test("200 + plans array for an authenticated user", async () => { @@ -245,6 +245,38 @@ describe("POST /api/v1/billing/stripe/checkout-session", () => { expect(res.status).toBeGreaterThanOrEqual(400); expect(res.status).toBeLessThan(500); }); + + test("rejects an interval other than month or year (TypeBox validation)", async () => { + if (!(await requireDb())) { + return; + } + + const { password } = await seedVerifiedUser({ + email: "checkout-interval@example.com", + }); + const app = createApp(); + const cookie = await loginCookie( + app, + "checkout-interval@example.com", + password + ); + + const res = await app.handle( + new Request("http://localhost/api/v1/billing/stripe/checkout-session", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + planId: 1, + interval: "week", + successUrl: `${env.FRONTEND_URL}/billing/success`, + cancelUrl: `${env.FRONTEND_URL}/billing/cancel`, + }), + }) + ); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); }); describe("POST /api/v1/billing/stripe/portal-session", () => { diff --git a/apps/api/tests/api/billing/billing.service.test.ts b/apps/api/tests/api/billing/billing.service.test.ts index c0afd5f4..fbec7ac7 100644 --- a/apps/api/tests/api/billing/billing.service.test.ts +++ b/apps/api/tests/api/billing/billing.service.test.ts @@ -1,7 +1,13 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { getBillingService } from "../../../src/api/billing/billing.service"; +import { + BillingService, + getBillingService, + listBillingPlans, +} from "../../../src/api/billing/billing.service"; import { env } from "../../../src/config/env"; +import { FEATURE_KEYS } from "../../../src/lib/acl/acl.constants"; +import { resolveAccountFeatures } from "../../../src/lib/acl"; import { AUDIT_ACTIONS } from "../../../src/lib/audit-log"; import { ApiError } from "../../../src/lib/errors/api-error"; import { seedVerifiedUser } from "../../helpers/auth"; @@ -14,12 +20,15 @@ import { db, eq, isNull, + planFeatures, plans, requireDb, stripeWebhookEvents, } from "../../helpers/db"; import { checkoutSessionCompletedEvent, + checkoutSessionEvent, + createFakeStripe, customerSubscriptionUpdatedEvent, } from "../../helpers/stripe-webhook-fixtures"; @@ -488,3 +497,740 @@ describe("billingService.handleWebhookEvent", () => { expect(rows).toHaveLength(0); }); }); + +const FRONTEND_SUCCESS_URL = `${env.FRONTEND_URL}/billing/success`; +const FRONTEND_CANCEL_URL = `${env.FRONTEND_URL}/billing/cancel`; +const BASE_EVENT_TIME = 1_800_000_000; + +let ownerSequence = 0; + +const seedOwner = async (): Promise<{ accountId: string; userId: string }> => { + ownerSequence += 1; + + const { account, user } = await seedVerifiedUser({ + email: `owner-${String(ownerSequence)}-${String(Date.now())}@example.com`, + }); + + return { accountId: account.id, userId: user.id }; +}; + +const planIdByName = async (name: string): Promise => { + await listBillingPlans(); + + const plan = await db.query.plans.findFirst({ where: eq(plans.name, name) }); + + if (!plan) { + throw new Error(`plan ${name} not seeded`); + } + + return plan.id; +}; + +const currentPlanRow = async (accountId: string) => + db.query.accountPlans.findFirst({ + where: and( + eq(accountPlans.accountId, accountId), + isNull(accountPlans.revokedAt) + ), + }); + +const canExport = async (accountId: string): Promise => { + const features = await resolveAccountFeatures(accountId); + + return features.can_export; +}; + +const captureApiError = async ( + run: () => Promise +): Promise => { + try { + await run(); + } catch (error: unknown) { + if (error instanceof ApiError) { + return error; + } + + throw error; + } + + throw new Error("expected the call to throw an ApiError"); +}; + +describe("billingService.createCheckoutSession", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("rejects the default Free plan with a 400 before contacting Stripe", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const freePlanId = await planIdByName("Free"); + + const error = await captureApiError(() => + new BillingService({ stripe: fake.stripe }).createCheckoutSession( + freePlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ) + ); + + expect(error.statusCode).toBe(400); + expect(fake.requests).toHaveLength(0); + }); + + test("rejects a yearly checkout when no yearly price is configured, before contacting Stripe", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + const service = new BillingService({ + stripe: fake.stripe, + settings: { + enabled: true, + priceIds: { + free: "price_free", + proMonthly: "price_pro", + proYearly: "", + }, + }, + }); + + const error = await captureApiError(() => + service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "year" + ) + ); + + expect(error.statusCode).toBe(400); + expect(error.message).toMatch(/interval/u); + expect(fake.requests).toHaveLength(0); + }); + + test("creates the Stripe customer once and a monthly session on the monthly Pro price", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + const result = await new BillingService({ + stripe: fake.stripe, + }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(result.url).toMatch(/^https:\/\/checkout\.stripe\.test\//u); + expect(fake.sessions).toHaveLength(1); + expect(fake.sessions[0]?.metadata).toEqual({ + planId: String(proPlanId), + priceId: env.STRIPE_PRICE_ID_PRO, + accountId, + }); + + const customerCreates = fake.requests.filter( + (request) => request.path === "/v1/customers" + ); + + expect(customerCreates).toHaveLength(1); + expect(customerCreates[0]?.idempotencyKey).toBe( + `account-customer:${accountId}` + ); + + const account = await db.query.accounts.findFirst({ + where: eq(accounts.id, accountId), + }); + + expect(account?.stripeCustomerId).toMatch(/^cus_fake_/u); + }); + + test("interval year uses the yearly Pro price", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await new BillingService({ stripe: fake.stripe }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "year" + ); + + expect(fake.sessions[0]?.metadata.priceId).toBe( + env.STRIPE_PRICE_ID_PRO_YEARLY + ); + }); + + test("two concurrent checkouts for one account create one session and return the same URL", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const service = new BillingService({ stripe: fake.stripe }); + const proPlanId = await planIdByName("Pro"); + + const [first, second] = await Promise.all([ + service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ), + service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ), + ]); + + expect(second.url).toBe(first.url); + expect(fake.sessions).toHaveLength(1); + expect( + fake.requests.filter((request) => request.path === "/v1/customers") + ).toHaveLength(1); + }); + + test("a second checkout for the same plan and interval reuses the open session", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const service = new BillingService({ stripe: fake.stripe }); + const proPlanId = await planIdByName("Pro"); + + const first = await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + const second = await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(second.url).toBe(first.url); + expect(fake.sessions).toHaveLength(1); + }); + + test("a different interval is a different price, so it gets its own session", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const service = new BillingService({ stripe: fake.stripe }); + const proPlanId = await planIdByName("Pro"); + + await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "month" + ); + await service.createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL, + "year" + ); + + expect(fake.sessions).toHaveLength(2); + }); + + test("rejects with a 409 when the local subscription row is live, without creating a session", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db.insert(accountPlans).values({ + accountId, + planId: proPlanId, + status: "active", + source: "stripe", + stripeSubscriptionId: "sub_local_live", + }); + + const error = await captureApiError(() => + new BillingService({ stripe: fake.stripe }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ) + ); + + expect(error.statusCode).toBe(409); + expect(error.message).toBe( + "Manage your existing subscription from Billing" + ); + expect(fake.sessions).toHaveLength(0); + }); + + test("an admin-granted plan without a Stripe subscription does not block checkout", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db.insert(accountPlans).values({ + accountId, + planId: proPlanId, + status: "active", + source: "admin_grant", + }); + + const result = await new BillingService({ + stripe: fake.stripe, + }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(result.url).toContain("https://"); + expect(fake.sessions).toHaveLength(1); + }); + + test("rejects with a 409 when Stripe lists a live subscription the webhook has not reported yet", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db + .update(accounts) + .set({ stripeCustomerId: "cus_webhook_lag" }) + .where(eq(accounts.id, accountId)); + fake.subscriptions.push({ + id: "sub_webhook_lag", + customer: "cus_webhook_lag", + status: "active", + }); + + const error = await captureApiError(() => + new BillingService({ stripe: fake.stripe }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ) + ); + + expect(error.statusCode).toBe(409); + expect(fake.sessions).toHaveLength(0); + }); + + test("a canceled local row and a canceled Stripe subscription do not block a new checkout", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, userId } = await seedOwner(); + const fake = createFakeStripe(); + const proPlanId = await planIdByName("Pro"); + + await db.insert(accountPlans).values({ + accountId, + planId: proPlanId, + status: "canceled", + source: "stripe", + stripeSubscriptionId: "sub_local_canceled", + }); + await db + .update(accounts) + .set({ stripeCustomerId: "cus_canceled" }) + .where(eq(accounts.id, accountId)); + fake.subscriptions.push({ + id: "sub_stripe_canceled", + customer: "cus_canceled", + status: "canceled", + }); + + const result = await new BillingService({ + stripe: fake.stripe, + }).createCheckoutSession( + proPlanId, + accountId, + userId, + FRONTEND_SUCCESS_URL, + FRONTEND_CANCEL_URL + ); + + expect(result.url).toMatch(/^https:\/\//u); + expect(fake.sessions).toHaveLength(1); + }); +}); + +describe("billingService.listPlans (public catalog)", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("reads without a Stripe client and reports no purchasable interval when billing is disabled", async () => { + if (!(await requireDb())) { + return; + } + + const result = await listBillingPlans({ + enabled: false, + priceIds: { + free: "price_free", + proMonthly: "price_pro", + proYearly: "price_pro_yearly", + }, + }); + + expect(result.map((plan) => plan.purchasableIntervals)).toEqual( + result.map(() => []) + ); + }); + + test("reports month and year as purchasable for Pro when both prices are configured; Free is never purchasable", async () => { + if (!(await requireDb())) { + return; + } + + const result = await listBillingPlans({ + enabled: true, + priceIds: { + free: "price_free", + proMonthly: "price_pro", + proYearly: "price_pro_yearly", + }, + }); + + expect( + result.find((plan) => plan.name === "Pro")?.purchasableIntervals + ).toEqual(["month", "year"]); + expect( + result.find((plan) => plan.name === "Free")?.purchasableIntervals + ).toEqual([]); + }); + + test("reports only month when the yearly price is unset", async () => { + if (!(await requireDb())) { + return; + } + + const result = await listBillingPlans({ + enabled: true, + priceIds: { free: "price_free", proMonthly: "price_pro", proYearly: "" }, + }); + + expect( + result.find((plan) => plan.name === "Pro")?.purchasableIntervals + ).toEqual(["month"]); + }); + + test("removes a stored feature row the catalog no longer assigns to a built-in plan", async () => { + if (!(await requireDb())) { + return; + } + + await listBillingPlans(); + + const freeId = await planIdByName("Free"); + + await db.insert(planFeatures).values({ + planId: freeId, + featureKey: "max_seats", + value: { number: 99 }, + }); + + await listBillingPlans(); + + const freeRows = await db.query.planFeatures.findMany({ + where: eq(planFeatures.planId, freeId), + }); + + expect(freeRows).toHaveLength(0); + }); + + test("seeds one feature row per key for Pro and none for Free, idempotently", async () => { + if (!(await requireDb())) { + return; + } + + await listBillingPlans(); + await listBillingPlans(); + + const proId = await planIdByName("Pro"); + const freeId = await planIdByName("Free"); + const proRows = await db.query.planFeatures.findMany({ + where: eq(planFeatures.planId, proId), + }); + const freeRows = await db.query.planFeatures.findMany({ + where: eq(planFeatures.planId, freeId), + }); + + expect(proRows.map((row) => row.featureKey).sort()).toEqual( + [...FEATURE_KEYS].sort() + ); + expect(freeRows).toHaveLength(0); + }); +}); + +describe("billingService entitlements after Stripe events", () => { + beforeEach(async () => { + if (!(await requireDb())) { + return; + } + + await cleanDatabase(); + }); + + test("a paid Pro checkout resolves Pro features; Free resolves the defaults", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + + expect(await resolveAccountFeatures(accountId)).toEqual({ + can_export: false, + can_invite_team: false, + max_seats: 1, + }); + + await getBillingService().handleWebhookEvent( + await checkoutSessionCompletedEvent("evt_ent_paid", { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_ent_paid", + }) + ); + + expect(await resolveAccountFeatures(accountId)).toEqual({ + can_export: true, + can_invite_team: true, + max_seats: 10, + }); + }); + + test("an unpaid (delayed payment) checkout is incomplete and grants no paid features", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await checkoutSessionCompletedEvent( + "evt_unpaid_checkout", + { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_unpaid", + payment_status: "unpaid", + }, + BASE_EVENT_TIME + ) + ); + + const row = await currentPlanRow(accountId); + + expect(row?.status).toBe("incomplete"); + expect(row?.stripeSubscriptionId).toBe("sub_unpaid"); + expect(await canExport(accountId)).toBe(false); + }); + + test("async_payment_succeeded activates a delayed checkout and grants Pro features", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + const service = getBillingService(); + const session = { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_delayed_ok", + }; + + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.completed", + "evt_delayed_1", + { ...session, payment_status: "unpaid" }, + BASE_EVENT_TIME + ) + ); + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.async_payment_succeeded", + "evt_delayed_2", + { ...session, payment_status: "paid" }, + BASE_EVENT_TIME + 60 + ) + ); + + const activeRow = await currentPlanRow(accountId); + + expect(activeRow?.status).toBe("active"); + expect(await canExport(accountId)).toBe(true); + }); + + test("async_payment_failed marks the subscription incomplete and removes paid features", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId, proPlanId } = + await seedAccountWithStripeCustomer(); + const service = getBillingService(); + const session = { + customer: customerId, + metadata: { accountId, planId: String(proPlanId) }, + subscription: "sub_delayed_fail", + }; + + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.async_payment_succeeded", + "evt_fail_1", + { ...session, payment_status: "paid" }, + BASE_EVENT_TIME + ) + ); + expect(await canExport(accountId)).toBe(true); + + await service.handleWebhookEvent( + await checkoutSessionEvent( + "checkout.session.async_payment_failed", + "evt_fail_2", + { ...session, payment_status: "unpaid" }, + BASE_EVENT_TIME + 60 + ) + ); + + const failedRow = await currentPlanRow(accountId); + + expect(failedRow?.status).toBe("incomplete"); + expect(await canExport(accountId)).toBe(false); + }); + + test("customer.subscription.updated maps the yearly Pro price to the Pro plan", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId } = await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await customerSubscriptionUpdatedEvent("evt_yearly_sub", { + id: "sub_yearly", + customer: customerId, + status: "active", + created: 1_700_000_000, + items: { + data: [ + { + price: { id: env.STRIPE_PRICE_ID_PRO_YEARLY }, + current_period_end: 1_800_000_000, + }, + ], + }, + }) + ); + + const row = await currentPlanRow(accountId); + const proId = await planIdByName("Pro"); + + expect(row?.planId).toBe(proId); + expect(row?.status).toBe("active"); + }); + + test("customer.subscription.updated ignores a price that no built-in plan sells", async () => { + if (!(await requireDb())) { + return; + } + + const { accountId, customerId } = await seedAccountWithStripeCustomer(); + + await getBillingService().handleWebhookEvent( + await customerSubscriptionUpdatedEvent("evt_unknown_price", { + id: "sub_unknown_price", + customer: customerId, + status: "active", + created: 1_700_000_000, + items: { + data: [ + { + price: { id: "price_not_ours" }, + current_period_end: 1_800_000_000, + }, + ], + }, + }) + ); + + expect(await currentPlanRow(accountId)).toBeUndefined(); + }); +}); diff --git a/apps/api/tests/config/env/validate.test.ts b/apps/api/tests/config/env/validate.test.ts index 21225946..6bd2674d 100644 --- a/apps/api/tests/config/env/validate.test.ts +++ b/apps/api/tests/config/env/validate.test.ts @@ -147,6 +147,8 @@ describe("toCsv", () => { type TestEnv = Record; +const STRIPE_WEBHOOK_TEST_SECRET = "test-stripe-webhook-secret"; + let testEnv: TestEnv; const seedValid = (): TestEnv => ({ @@ -375,7 +377,7 @@ describe("validateEnv", () => { it("rejects a placeholder Stripe secret when BILLING_ENABLED=true", () => { testEnv.BILLING_ENABLED = "true"; testEnv.STRIPE_SECRET_KEY = "your-stripe-secret-key"; - testEnv.STRIPE_WEBHOOK_SECRET = "test-stripe-webhook-secret"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; testEnv.STRIPE_PRICE_ID_FREE = "price_free"; testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; testEnv.RESEND_API_KEY = "rk_test"; @@ -387,13 +389,49 @@ describe("validateEnv", () => { it("accepts real-looking Stripe secrets when BILLING_ENABLED=true", () => { testEnv.BILLING_ENABLED = "true"; testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; - testEnv.STRIPE_WEBHOOK_SECRET = "test-stripe-webhook-secret"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; testEnv.STRIPE_PRICE_ID_FREE = "price_free"; testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; testEnv.RESEND_API_KEY = "rk_test"; expect(() => validateEnv(testEnv)).not.toThrow(); }); + it("STRIPE_PRICE_ID_PRO_YEARLY is optional and defaults to empty (yearly not offered)", () => { + testEnv.BILLING_ENABLED = "true"; + testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; + testEnv.STRIPE_PRICE_ID_FREE = "price_free"; + testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; + testEnv.RESEND_API_KEY = "rk_test"; + expect(validateEnv(testEnv).STRIPE_PRICE_ID_PRO_YEARLY).toBe(""); + }); + + it("parses STRIPE_PRICE_ID_PRO_YEARLY when it differs from the monthly price", () => { + testEnv.BILLING_ENABLED = "true"; + testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; + testEnv.STRIPE_PRICE_ID_FREE = "price_free"; + testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; + testEnv.STRIPE_PRICE_ID_PRO_YEARLY = "price_pro_yearly"; + testEnv.RESEND_API_KEY = "rk_test"; + expect(validateEnv(testEnv).STRIPE_PRICE_ID_PRO_YEARLY).toBe( + "price_pro_yearly" + ); + }); + + it("rejects STRIPE_PRICE_ID_PRO_YEARLY equal to the monthly Pro price", () => { + testEnv.BILLING_ENABLED = "true"; + testEnv.STRIPE_SECRET_KEY = "sk_test_51RealKeyValue"; + testEnv.STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_TEST_SECRET; + testEnv.STRIPE_PRICE_ID_FREE = "price_free"; + testEnv.STRIPE_PRICE_ID_PRO = "price_pro"; + testEnv.STRIPE_PRICE_ID_PRO_YEARLY = "price_pro"; + testEnv.RESEND_API_KEY = "rk_test"; + expect(() => validateEnv(testEnv)).toThrow( + /STRIPE_PRICE_ID_PRO_YEARLY must differ from STRIPE_PRICE_ID_PRO/ + ); + }); + it("requires VALKEY_PASSWORD in production with QUEUES_ENABLED=true", () => { testEnv.NODE_ENV = "production"; testEnv.ALLOWED_ORIGINS = "https://example.com"; diff --git a/apps/api/tests/helpers/stripe-webhook-fixtures.ts b/apps/api/tests/helpers/stripe-webhook-fixtures.ts index 04d27c29..e93e2c0c 100644 --- a/apps/api/tests/helpers/stripe-webhook-fixtures.ts +++ b/apps/api/tests/helpers/stripe-webhook-fixtures.ts @@ -38,18 +38,41 @@ const buildTestEvent = async ( ); }; +export interface ICheckoutSessionFixture { + customer: string; + metadata?: Record; + /** The subscription the checkout created, when there is one. */ + subscription?: string; + /** Defaults to `paid`, the value a card checkout reports. */ + payment_status?: "paid" | "unpaid" | "no_payment_required"; +} + +export type CheckoutSessionEventType = + | "checkout.session.completed" + | "checkout.session.async_payment_succeeded" + | "checkout.session.async_payment_failed"; + +/** Any of the three checkout events the billing service consumes. */ +export const checkoutSessionEvent = ( + type: CheckoutSessionEventType, + id: string, + session: ICheckoutSessionFixture, + eventCreated?: number +): Promise => + buildTestEvent( + id, + type, + { payment_status: "paid", ...session }, + eventCreated + ); + /** Minimal fields the billing service reads from `checkout.session.completed`. */ export const checkoutSessionCompletedEvent = ( id: string, - session: { - customer: string; - metadata?: Record; - /** The subscription the completed checkout created, when there is one. */ - subscription?: string; - }, + session: ICheckoutSessionFixture, eventCreated?: number ): Promise => - buildTestEvent(id, "checkout.session.completed", session, eventCreated); + checkoutSessionEvent("checkout.session.completed", id, session, eventCreated); /** Minimal fields the billing service reads from `customer.subscription.updated`. */ export const customerSubscriptionUpdatedEvent = ( @@ -98,3 +121,170 @@ export const customerSubscriptionDeletedEvent = ( subscription, eventCreated ); + +/* + * A real `Stripe` client whose HTTP layer is a fake. The SDK still encodes the + * request and parses the response, so the code under test uses the same + * client type and parameter shapes as production, and nothing leaves the + * process. Only the four endpoints the billing service calls are modelled. + */ +export interface IFakeStripeRequest { + readonly method: string; + readonly path: string; + readonly params: URLSearchParams; + readonly idempotencyKey: string | null; +} + +export interface IFakeStripeSubscription { + readonly id: string; + readonly customer: string; + readonly status: string; +} + +export interface IFakeStripeSession { + readonly id: string; + readonly customer: string; + readonly url: string; + readonly mode: string; + readonly status: string; + readonly metadata: Record; +} + +export interface IFakeStripe { + readonly stripe: Stripe; + readonly requests: IFakeStripeRequest[]; + readonly subscriptions: IFakeStripeSubscription[]; + readonly sessions: IFakeStripeSession[]; +} + +const HTTP_METHOD = { get: "GET", post: "POST" } as const; + +const ENDPOINT = { + customers: "/v1/customers", + subscriptions: "/v1/subscriptions", + checkoutSessions: "/v1/checkout/sessions", +} as const; + +const SESSION_OPEN = "open" as const; + +const jsonResponse = (status: number, value: unknown): Response => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); + +const metadataFromParams = ( + params: URLSearchParams +): Record => { + const metadata: Record = {}; + + for (const [key, value] of params.entries()) { + const match = /^metadata\[(.+)\]$/u.exec(key); + + if (match?.[1] !== undefined) { + metadata[match[1]] = value; + } + } + + return metadata; +}; + +export const createFakeStripe = (): IFakeStripe => { + const requests: IFakeStripeRequest[] = []; + const subscriptions: IFakeStripeSubscription[] = []; + const sessions: IFakeStripeSession[] = []; + let sequence = 0; + + const nextId = (prefix: string): string => { + sequence += 1; + + return `${prefix}_fake_${String(sequence)}`; + }; + + const route = ( + method: string, + url: URL, + params: URLSearchParams + ): Response => { + const path = url.pathname; + const customer = params.get("customer"); + + if (method === HTTP_METHOD.post && path === ENDPOINT.customers) { + return jsonResponse(200, { id: nextId("cus"), object: "customer" }); + } + + if (method === HTTP_METHOD.get && path === ENDPOINT.subscriptions) { + return jsonResponse(200, { + object: "list", + has_more: false, + url: path, + data: subscriptions.filter((item) => item.customer === customer), + }); + } + + if (method === HTTP_METHOD.get && path === ENDPOINT.checkoutSessions) { + return jsonResponse(200, { + object: "list", + has_more: false, + url: path, + data: sessions.filter( + (item) => item.customer === customer && item.status === SESSION_OPEN + ), + }); + } + + if (method === HTTP_METHOD.post && path === ENDPOINT.checkoutSessions) { + const session: IFakeStripeSession = { + id: nextId("cs"), + customer: customer ?? "", + url: `https://checkout.stripe.test/${nextId("pay")}`, + mode: params.get("mode") ?? "", + status: SESSION_OPEN, + metadata: metadataFromParams(params), + }; + + sessions.push(session); + + return jsonResponse(200, { ...session, object: "checkout.session" }); + } + + return jsonResponse(404, { + error: { + type: "invalid_request_error", + message: `no fake for ${method} ${path}`, + }, + }); + }; + + const fetchImpl = ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const method = init?.method ?? HTTP_METHOD.get; + const rawForm = init?.body; + const params = + method === HTTP_METHOD.get + ? url.searchParams + : new URLSearchParams(typeof rawForm === "string" ? rawForm : ""); + + requests.push({ + method, + path: url.pathname, + params, + idempotencyKey: new Headers(init?.headers).get("idempotency-key"), + }); + + return Promise.resolve(route(method, url, params)); + }; + + /* Bun's `typeof fetch` also carries `preconnect`; the fake must match it. */ + const fakeFetch = Object.assign(fetchImpl, { preconnect: () => undefined }); + + const stripe = new Stripe("sk_test_fake_for_tests", { + httpClient: Stripe.createFetchHttpClient(fakeFetch), + maxNetworkRetries: 0, + }); + + return { stripe, requests, subscriptions, sessions }; +}; diff --git a/apps/api/tests/setup-test-env.ts b/apps/api/tests/setup-test-env.ts index 81e20982..aec93836 100644 --- a/apps/api/tests/setup-test-env.ts +++ b/apps/api/tests/setup-test-env.ts @@ -83,6 +83,7 @@ process.env.STRIPE_SECRET_KEY = ["sk", "test", "fake", "for", "tests"].join( process.env.STRIPE_WEBHOOK_SECRET = ["whsec", "fake", "for", "tests"].join("_"); process.env.STRIPE_PRICE_ID_FREE = "price_test_free"; process.env.STRIPE_PRICE_ID_PRO = "price_test_pro"; +process.env.STRIPE_PRICE_ID_PRO_YEARLY = "price_test_pro_yearly"; /* * Email webhook secrets. The Resend signing secret is a constant whsec diff --git a/apps/docs/src/content/docs/api/billing.mdx b/apps/docs/src/content/docs/api/billing.mdx index 4018233a..a1423f76 100644 --- a/apps/docs/src/content/docs/api/billing.mdx +++ b/apps/docs/src/content/docs/api/billing.mdx @@ -20,18 +20,29 @@ sequenceDiagram UI->>API: POST /api/v1/billing/stripe/checkout-session API->>API: verify auth_token cookie + resolve active account API->>API: allowlist successUrl + cancelUrl against FRONTEND_URL origin - API->>DB: upsert Free / Pro plans from STRIPE_PRICE_ID_* - API->>Stripe: create checkout session (account.stripe_customer_id) + API->>DB: upsert Free / Pro plans and Pro's features from the plan catalog + API->>DB: lock the account row (SELECT ... FOR UPDATE) + API->>Stripe: 409 if the account already has a live subscription + API->>Stripe: reuse an open checkout session, or create one API->>DB: update account.stripe_customer_id when first needed API-->>UI: { url } UI->>Stripe: redirect browser to hosted checkout ``` +Checkout refuses the default (Free) plan, and returns 409 when the account +already has a live subscription, locally or in Stripe (its webhook may not +have arrived yet). Concurrent requests are serialized on the account row, so a +double click yields one session. The body takes an optional `interval` +(`month`, the default, or `year`); `year` needs `STRIPE_PRICE_ID_PRO_YEARLY`. + +After Stripe redirects back with `?checkout=success`, the billing page polls +the subscription until the webhook has landed, then refreshes the session. + The customer portal follows the same pattern: authenticated user, `returnUrl` allowlisted against `FRONTEND_URL`, Stripe returns a hosted URL. ## Design -Billing is feature-flagged so local apps boot without Stripe credentials. Plans come from env (`STRIPE_PRICE_ID_FREE` and `STRIPE_PRICE_ID_PRO`) and upsert default plan rows without manual SQL. Customer routes (checkout, portal, plan reads) use the same cookie-auth OpenAPI contract as the rest of the API. Stripe-hosted flows may only return to the configured `FRONTEND_URL` origin. +Billing is feature-flagged so local apps boot without Stripe credentials. Plans and what they grant live in one typed catalog, `apps/api/src/api/billing/billing.plans.ts`, with Stripe price ids from env. Every plan read upserts the plan rows and their `plan_features`, so a fresh database needs no manual SQL. Pro must give every feature key a value; the type check and a unit test fail until it does. `GET /billing/plans` is public, so a pricing page can read it without a session, and it answers even with billing off. Checkout, portal and subscription routes use cookie auth. Stripe-hosted flows may only return to the configured `FRONTEND_URL` origin. The webhook route passes the exact request payload to Stripe signature verification (raw body verification). Every Stripe event ID is claimed in the same transaction as the side effect, providing Postgres-backed idempotency. @@ -55,10 +66,10 @@ sequenceDiagram Handled events: -- `checkout.session.completed`: creates or updates `billing.account_plans` for the account and plan in session metadata. -- `customer.subscription.updated`: maps the active Stripe price id back to a local plan and updates the account plan; also tracks `past_due`, `unpaid`, `paused`, `canceled`, `incomplete`, `trialing`, and `active` for the [feature resolver](/api/acl/#feature-gates). +- `checkout.session.completed`: creates or updates `billing.account_plans` for the account and plan in session metadata. The plan is `active` only when `payment_status` is `paid` or `no_payment_required`; a delayed payment method (bank debit) leaves it `incomplete`, which grants nothing. +- `checkout.session.async_payment_succeeded` / `checkout.session.async_payment_failed`: settle a delayed payment to `active` or `incomplete`. +- `customer.subscription.updated`: maps the active Stripe price id (monthly or yearly) back to a local plan through the catalog and updates the account plan; also tracks `past_due`, `unpaid`, `paused`, `canceled`, `incomplete`, `trialing`, and `active` for the [feature resolver](/api/acl/#feature-gates). - `customer.subscription.deleted`: handles deletion only for the matching subscription; an older subscription cannot revoke its replacement. Effective access follows the resulting status and period. -- `invoice.paid` / `invoice.payment_failed`: status transitions for the active plan row. Unknown event types are logged at debug level and ignored. Event identity prevents duplicate processing; ordering checks and subscription identity prevent stale deliveries from overwriting the current subscription. A deletion for an older subscription must not revoke its replacement. Same-second checkout and subscription updates preserve identity, status and period in either tested delivery order; a delayed checkout must not restore a delinquent subscription. These cases do not establish correctness for every possible provider event sequence. @@ -83,7 +94,8 @@ Sensitive subscription reads recheck membership and return 403 for a revoked mem - `BILLING_ENABLED`: Turns the route group on; false means all billing paths return 404. - `STRIPE_SECRET_KEY`: Used by the Stripe SDK for Checkout, Portal, and webhook construction. - `STRIPE_WEBHOOK_SECRET`: Used to verify Stripe-Signature on raw webhook payloads. -- `STRIPE_PRICE_ID_FREE` / `STRIPE_PRICE_ID_PRO`: Seeds and keeps the built-in plan rows aligned with Stripe. +- `STRIPE_PRICE_ID_FREE` / `STRIPE_PRICE_ID_PRO`: Seeds and keeps the built-in plan rows aligned with Stripe. `STRIPE_PRICE_ID_PRO` is the monthly price. +- `STRIPE_PRICE_ID_PRO_YEARLY` (optional): a yearly Pro price. Empty means yearly checkout is not offered.