From ba62a02001b04c0271d0e09d15d25b3dfb33e24c Mon Sep 17 00:00:00 2001 From: Ruslan Konviser Date: Tue, 29 Sep 2026 11:34:26 +0200 Subject: [PATCH] fix(billing): scope Stripe linking, paywall and billing pages to this deployment's product (#10331) Product-scoped (hosted plan only) Stripe webhook linking, signup paywall, lazy tenant link and /billing routes; checkout-session proof at register/onboarding; BILLING_PRODUCT, BILLING_SIGNUP_PAYWALL and BILLING_WEBHOOK_LINKING (default off); 402 payment_method_required on a paid upgrade without a card. See the PR description for details. Co-Authored-By: Claude Opus 5.5 --- .env.sample | 21 + .../settings/billing/billing.component.ts | 21 +- .../pages/settings/billing/billing.service.ts | 8 +- packages/contracts/src/lib/tenant.model.ts | 6 + packages/contracts/src/lib/user.model.ts | 5 + .../src/lib/shared/billing/billing-product.ts | 214 +++++ .../shared/billing/billing.controller.spec.ts | 146 ++++ .../lib/shared/billing/billing.controller.ts | 75 +- .../shared/billing/billing.service.spec.ts | 417 ++++++++++ .../src/lib/shared/billing/billing.service.ts | 190 ++++- .../stripe-subscription.service.spec.ts | 424 ++++++++++ .../billing/stripe-subscription.service.ts | 275 ++++++- .../billing/stripe-webhook.controller.spec.ts | 771 ++++++++++++++++++ .../billing/stripe-webhook.controller.ts | 264 +++++- .../subscription-required.guard.spec.ts | 146 ++++ .../guards/subscription-required.guard.ts | 41 +- .../src/lib/tenant/dto/create-tenant.dto.ts | 19 +- .../tenant.service.billing-link.spec.ts | 206 +++++ .../core/src/lib/tenant/tenant.service.ts | 112 ++- .../lib/user/dto/register-user.dto.spec.ts | 81 ++ .../src/lib/user/dto/register-user.dto.ts | 20 + .../tenant-onboarding.component.ts | 14 +- .../components/register/register.component.ts | 13 +- .../services/auth/auth-strategy.service.ts | 7 +- .../src/lib/services/auth/checkout-session.ts | 54 ++ .../core/src/lib/services/auth/index.ts | 1 + packages/ui-core/i18n/assets/i18n/en.json | 2 + 27 files changed, 3447 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/lib/shared/billing/billing-product.ts create mode 100644 packages/core/src/lib/shared/billing/billing.controller.spec.ts create mode 100644 packages/core/src/lib/shared/billing/billing.service.spec.ts create mode 100644 packages/core/src/lib/shared/billing/stripe-subscription.service.spec.ts create mode 100644 packages/core/src/lib/shared/billing/stripe-webhook.controller.spec.ts create mode 100644 packages/core/src/lib/shared/guards/subscription-required.guard.spec.ts create mode 100644 packages/core/src/lib/tenant/tenant.service.billing-link.spec.ts create mode 100644 packages/core/src/lib/user/dto/register-user.dto.spec.ts create mode 100644 packages/ui-core/core/src/lib/services/auth/checkout-session.ts diff --git a/.env.sample b/.env.sample index 402039861c..9a2062300c 100644 --- a/.env.sample +++ b/.env.sample @@ -947,3 +947,24 @@ STRIPE_LIVE_MODE= STRIPE_WEBHOOK_SECRET= # Where an unsubscribed visitor is sent to pick a plan. Shared by every Ever product. EVER_CHECKOUT_URL=https://ever.co/checkout +# Which Ever product this deployment bills for: the `` in the catalog's lookup keys +# (`ever____`) and in `metadata.ever_product`. Default `gauzy`. +# The Stripe account is shared by every Ever product, so the webhook, the signup paywall, the tenant +# link and the billing pages all count ONLY this product's subscriptions. The Ever Teams deployment +# of this API sets `teams`. Only this product's HOSTED plans count (`ever__cloud_*`, or +# `ever_hosting` absent/`cloud`): a self-hosted license subscription never does. A value that is not a +# lower-case product key disables billing (logged) and, while BILLING_SIGNUP_PAYWALL is on, refuses +# every signup until it is fixed - the paywall fails closed, never open. +BILLING_PRODUCT= +# Whether signing up (POST /api/auth/register) requires a subscription to BILLING_PRODUCT. Default +# `true` (only `false`/`0`/`no`/`off` turn it off). Has no effect unless billing is on. `false` keeps +# everything else — tenant linking, the billing pages, the webhook — and drops only the paywall; the +# Ever Teams deployment uses that. +BILLING_SIGNUP_PAYWALL= +# Whether the Stripe webhook may WRITE a tenant's billing link. Default `false` (only +# `true`/`1`/`yes`/`on` turn it on). Off, the webhook still runs every check and logs +# `decision: would-link`, and tenants are linked by the buyer's own Checkout Session at onboarding or +# by an admin opening Settings > Billing. Turning it on lets a purchase made under an admin's address +# by somebody else bind that admin's tenant, and lets an existing admin's new purchase bind their OLD +# tenant before they register the new one - leave it off unless you accept both. +BILLING_WEBHOOK_LINKING= diff --git a/apps/gauzy/src/app/pages/settings/billing/billing.component.ts b/apps/gauzy/src/app/pages/settings/billing/billing.component.ts index abef803deb..6ed7afc8f3 100644 --- a/apps/gauzy/src/app/pages/settings/billing/billing.component.ts +++ b/apps/gauzy/src/app/pages/settings/billing/billing.component.ts @@ -121,12 +121,29 @@ export class BillingComponent extends TranslationBaseComponent implements OnInit if (this.working || this.isCurrentPlan(plan)) return; this.working = true; try { - this.subscription = await firstValueFrom(this.billingService.changePlan(plan.lookupKey)); + this.subscription = await firstValueFrom( + this.billingService.changePlan(plan.lookupKey, window.location.href) + ); this.toastrService.success('SETTINGS_MENU.BILLING_PLAN_CHANGED', { name: plan.productName }); // Switching plans issues an invoice, so the list below is now stale. this.invoices = await this.safe(() => firstValueFrom(this.billingService.getInvoices()), this.invoices); } catch (error) { - this.errorHandlingService.handleError(error); + // An upgrade to a paid plan from one with no card on file: the API refuses it rather than + // leave an invoice nobody can pay, and hands back a Stripe portal link to add a card. Send the + // admin there; they return to this page and switch again. + const body = (error as { status?: number; error?: { code?: string; portalUrl?: string } })?.error; + if ((error as { status?: number })?.status === 402 && body?.code === 'payment_method_required') { + // The API still answers 402 when Stripe could not open a portal session, just without the + // link — then say so, and point at the page's own "manage billing" action instead. + if (body.portalUrl) { + this.toastrService.info('SETTINGS_MENU.BILLING_PAYMENT_METHOD_REQUIRED', 'TOASTR.TITLE.INFO'); + window.location.href = body.portalUrl; + return; + } + this.toastrService.warning('SETTINGS_MENU.BILLING_PAYMENT_METHOD_REQUIRED_NO_PORTAL'); + } else { + this.errorHandlingService.handleError(error); + } } finally { this.working = false; } diff --git a/apps/gauzy/src/app/pages/settings/billing/billing.service.ts b/apps/gauzy/src/app/pages/settings/billing/billing.service.ts index 9952054255..41f4c4dbaa 100644 --- a/apps/gauzy/src/app/pages/settings/billing/billing.service.ts +++ b/apps/gauzy/src/app/pages/settings/billing/billing.service.ts @@ -74,8 +74,12 @@ export class BillingService { return this.http.get(`${this.endpoint}/plans`); } - changePlan(lookupKey: string): Observable { - return this.http.post(`${this.endpoint}/subscription/change`, { lookupKey }); + /** + * Switch plans. `returnUrl` is where Stripe's portal sends the admin back to if the API answers 402 + * `payment_method_required` — an upgrade to a paid plan from one with no card on file. + */ + changePlan(lookupKey: string, returnUrl?: string): Observable { + return this.http.post(`${this.endpoint}/subscription/change`, { lookupKey, returnUrl }); } cancel(): Observable { diff --git a/packages/contracts/src/lib/tenant.model.ts b/packages/contracts/src/lib/tenant.model.ts index c0b7f950c2..43a37a6335 100644 --- a/packages/contracts/src/lib/tenant.model.ts +++ b/packages/contracts/src/lib/tenant.model.ts @@ -33,6 +33,12 @@ export interface ITenantCreateInput extends ITenantUpdateInput { isImporting?: boolean; sourceId?: string; userSourceId?: ID; + /** + * The Stripe Checkout Session the creator completed before registering, when there is one. On a + * hosted deployment the new tenant is linked to that session's customer once the server has verified + * it; the id itself is never stored on the tenant. + */ + stripeCheckoutSessionId?: string; } export interface ITenantUpdateInput extends IRelationalImageAsset { diff --git a/packages/contracts/src/lib/user.model.ts b/packages/contracts/src/lib/user.model.ts index 813219df6c..520d77cf33 100644 --- a/packages/contracts/src/lib/user.model.ts +++ b/packages/contracts/src/lib/user.model.ts @@ -160,6 +160,11 @@ export interface IUserRegistrationInput extends ITermsAcceptanceInput { sourceId?: string; inviteId?: string; featureAsEmployee?: boolean; + /** + * The Stripe Checkout Session (`cs_live_...` / `cs_test_...`) the registrant completed on the shared + * checkout, when they arrive from it. Hosted deployments use it as proof of purchase; ignored elsewhere. + */ + stripeCheckoutSessionId?: string; } /** diff --git a/packages/core/src/lib/shared/billing/billing-product.ts b/packages/core/src/lib/shared/billing/billing-product.ts new file mode 100644 index 0000000000..98d641f165 --- /dev/null +++ b/packages/core/src/lib/shared/billing/billing-product.ts @@ -0,0 +1,214 @@ +// cspell:ignore selfhosted +/** + * Which Ever product this deployment bills for, and how to tell whether a Stripe object belongs to it. + * + * Every Ever product sells through ONE shared Stripe account (the ever.co checkout, Ever Works' own + * checkout, directory sites, GitHands, ...). Anything that reads that account — the webhook, the + * signup paywall, the lazy tenant link, the billing pages — therefore sees every product's customers + * and subscriptions, and must decide for itself which ones are its own. Before this module none of + * them did: a Teams, Works or Platform subscription counted as a Gauzy one everywhere. + * + * "This product" means this product's HOSTED plan. ever.co also sells self-hosted licenses of Gauzy + * and Teams as recurring subscriptions on the same account (`ever__selfhosted_*` prices, + * `metadata.ever_hosting = 'selfhosted'`). A license is not a cloud plan: counting one as the cloud + * subscription would bind the buyer's cloud tenant to their license, show the license as the cloud + * plan, and let "Cancel" or "Switch plan" act on it. So a subscription or session whose + * `ever_hosting` says anything other than `cloud`, or whose plan price is a non-cloud price of this + * product, is never this product's. + * + * The rules are deliberately an ALLOWLIST. Ever Works, GitHands and the directory sites never set + * `metadata.ever_product` (Works pay-as-you-go subscriptions carry only `metadata.kind`), so a denylist keyed + * on known foreign products would silently accept all of them. + * + * Pure functions only: nothing here performs I/O, so every caller can apply the predicate before it + * touches the database or Stripe. + */ + +/** The product a deployment bills for when `BILLING_PRODUCT` is not set. */ +export const DEFAULT_BILLING_PRODUCT = 'gauzy'; + +/** + * Product keys as the shared catalog spells them (`gauzy`, `teams`, `platform`, `works`, ...). The + * key is embedded in every lookup key as `ever____`, so anything + * outside this shape could never match a real price. + */ +const PRODUCT_KEY_PATTERN = /^[a-z][a-z0-9]{0,31}$/; + +/** + * A Stripe Checkout Session id. Stripe ids are base62 after the prefix; the length bounds keep an + * arbitrary string from being forwarded to the Stripe API as a path segment. + */ +export const CHECKOUT_SESSION_ID_PATTERN = /^cs_(live|test)_[A-Za-z0-9]{8,255}$/; + +/** Whether `value` is shaped like a Stripe Checkout Session id. */ +export function isCheckoutSessionId(value: unknown): value is string { + return typeof value === 'string' && CHECKOUT_SESSION_ID_PATTERN.test(value); +} + +/** + * Resolve `BILLING_PRODUCT`. + * + * Returns `{ product }` for a usable key (lower-cased, trimmed; unset means `gauzy`), or + * `{ product: null, invalid }` when the value cannot be a catalog product key. An invalid value is + * NOT silently replaced by the default: on the Ever Teams deployment that would quietly start linking + * Gauzy purchases to Teams tenants. The caller disables billing instead and says why. + */ +export function resolveBillingProduct(raw: string | undefined = process.env.BILLING_PRODUCT): { + product: string | null; + invalid?: string; +} { + const value = (raw ?? '').trim().toLowerCase(); + if (!value) return { product: DEFAULT_BILLING_PRODUCT }; + if (!PRODUCT_KEY_PATTERN.test(value)) return { product: null, invalid: value.slice(0, 64) }; + return { product: value }; +} + +/** + * Resolve `BILLING_SIGNUP_PAYWALL`: whether `POST /auth/register` requires a subscription. + * + * Defaults to `true` — the behaviour app.gauzy.co has had since the paywall shipped. Only an explicit + * off value (`false`, `0`, `no`, `off`) turns it off, so a typo keeps the stricter behaviour rather + * than opening signup. The Ever Teams deployment sets it to `false`: Teams links billing but does not + * put a paywall in front of signup. + */ +export function resolveSignupPaywall(raw: string | undefined = process.env.BILLING_SIGNUP_PAYWALL): boolean { + const value = (raw ?? '').trim().toLowerCase(); + return !['false', '0', 'no', 'off'].includes(value); +} + +/** `ever__` — the prefix every lookup key of that product starts with. */ +export function lookupKeyPrefix(product: string): string { + return `ever_${product}_`; +} + +/** The only `metadata.ever_hosting` value a hosted deployment counts as its own. */ +export const CLOUD_HOSTING = 'cloud'; + +/** `ever__cloud_` — the prefix of that product's hosted plans (the prefix listPlans uses). */ +export function cloudLookupKeyPrefix(product: string): string { + return `${lookupKeyPrefix(product)}${CLOUD_HOSTING}_`; +} + +/** + * Whether `metadata.ever_hosting` allows the object to be a hosted-plan purchase: absent (objects + * made before the checkout stamped it, or in the Dashboard) or exactly `cloud`. `selfhosted` — or + * anything else — is not. + */ +export function hostingIsCloud(metadata: Record | null | undefined): boolean { + const hosting = metadata?.ever_hosting; + return hosting === undefined || hosting === null || hosting === '' || hosting === CLOUD_HOSTING; +} + +/** + * Resolve `BILLING_WEBHOOK_LINKING`: whether the Stripe webhook may WRITE a tenant's billing link. + * + * Defaults to `false`. The webhook can only match a purchase to a tenant by the address the payer + * typed at checkout, and a free Starter can be started under anybody's address, so it cannot tell + * the account owner's purchase from somebody else's made in their name. It can also bind an existing + * admin's OLD tenant a minute before the same buyer registers the NEW one they just paid for. With + * the flag off the webhook still runs every check and logs `would-link`, but writes nothing; links + * are made by the buyer's own Checkout Session at onboarding and by an admin opening Settings > + * Billing. Only an explicit on value (`true`, `1`, `yes`, `on`) turns writing on. + */ +export function resolveWebhookLinking(raw: string | undefined = process.env.BILLING_WEBHOOK_LINKING): boolean { + const value = (raw ?? '').trim().toLowerCase(); + return ['true', '1', 'yes', 'on'].includes(value); +} + +/** The minimal shape of a Stripe Subscription that the product predicate reads. */ +export interface ProductScopedSubscription { + metadata?: Record | null; + items?: { data?: Array<{ price?: { lookup_key?: string | null } | null }> } | null; +} + +/** The minimal shape of a Stripe Checkout Session that the product predicate reads. */ +export interface ProductScopedCheckoutSession { + mode?: string | null; + metadata?: Record | null; +} + +/** + * Whether a Subscription is a HOSTED (cloud) plan of `product`. + * + * `metadata.ever_product` is what the shared checkout stamps on every subscription it creates. The + * lookup-key branch covers subscriptions made in the Stripe Dashboard or the customer portal, which + * carry no metadata but still sit on a catalog price. Only the FIRST item is read: it is the plan + * (add-ons come after it) and it is the item `changePlan` operates on. + * + * A self-hosted license of the same product is refused on either signal: `ever_hosting` other than + * `cloud`, or a plan price of this product that is not an `ever__cloud_` price. + * + * When the plan sits on a catalog price (`ever__...`), the price decides and the metadata may + * only agree with it. Stripe updates a subscription's price and its metadata independently, so a + * Teams price under `ever_product: 'gauzy'` metadata (or the reverse) is not this product's plan. + * The metadata alone decides only when the plan has no catalog lookup key. + */ +export function subscriptionIsForProduct( + subscription: ProductScopedSubscription | null | undefined, + product: string | null | undefined +): boolean { + if (!subscription || !product) return false; + if (!hostingIsCloud(subscription.metadata)) return false; + const lookupKey = subscription.items?.data?.[0]?.price?.lookup_key; + const metadataProduct = subscription.metadata?.ever_product; + if (typeof lookupKey === 'string' && CATALOG_LOOKUP_KEY.test(lookupKey)) { + return lookupKey.startsWith(cloudLookupKeyPrefix(product)) && (!metadataProduct || metadataProduct === product); + } + return metadataProduct === product; +} + +/** Any catalog lookup key: `ever___...`. */ +const CATALOG_LOOKUP_KEY = /^ever_[a-z0-9]+_/; + +/** + * Whether a completed Checkout Session is a purchase of `product`'s HOSTED plan that can establish a + * tenant link. + * + * All are required. `mode === 'subscription'` excludes payment-mode sessions (lifetime licenses, + * Ever Works credit packs) and setup-mode card saves, none of which buys a hosted plan; the hosting + * check excludes self-hosted license subscriptions (`ever_hosting: 'selfhosted'`); and a + * `metadata.ever_lookup_key`, which the shared checkout stamps on every session, must be one of this + * product's cloud prices when it is present. + */ +export function checkoutSessionIsForProduct( + session: ProductScopedCheckoutSession | null | undefined, + product: string | null | undefined +): boolean { + if (!session || !product) return false; + if ( + session.metadata?.ever_product !== product || + session.mode !== 'subscription' || + !hostingIsCloud(session.metadata) + ) { + return false; + } + const lookupKey = session.metadata?.ever_lookup_key; + return !lookupKey || lookupKey.startsWith(cloudLookupKeyPrefix(product)); +} + +/** + * A short, non-identifying label for which product an object appears to belong to — for logs only, + * so a skipped event still says what it was. Never decides anything. + */ +export function describeProduct(object: { + metadata?: Record | null; + items?: ProductScopedSubscription['items']; +}): string { + const metadata = object?.metadata ?? {}; + if (typeof metadata.ever_product === 'string' && metadata.ever_product) { + return clip(metadata.ever_product); + } + const lookupKey = object?.items?.data?.[0]?.price?.lookup_key; + if (typeof lookupKey === 'string') { + const match = /^ever_([a-z0-9]+)_/.exec(lookupKey); + if (match) return clip(match[1]); + } + if (typeof metadata.kind === 'string' && metadata.kind) { + return `kind:${clip(metadata.kind)}`; + } + return 'unknown'; +} + +function clip(value: string): string { + return value.replace(/[^A-Za-z0-9_.:-]/g, '').slice(0, 40) || 'unknown'; +} diff --git a/packages/core/src/lib/shared/billing/billing.controller.spec.ts b/packages/core/src/lib/shared/billing/billing.controller.spec.ts new file mode 100644 index 0000000000..09672936b6 --- /dev/null +++ b/packages/core/src/lib/shared/billing/billing.controller.spec.ts @@ -0,0 +1,146 @@ +/** + * 🛑 This import must stay FIRST — the controller imports repositories and TenantService. + */ +import '../../core/entities/internal'; +import { HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { RequestContext } from '../../core/context'; +import { BillingController } from './billing.controller'; +import { PaymentMethodRequiredError } from './billing.service'; + +/** + * Two controller-level rules added with the product scoping: + * + * - a tenant whose stored link points at a customer who never subscribed to THIS product is treated + * as not linked (404), so no route shows or acts on another product's (another person's) billing; + * - an upgrade refused for want of a card answers 402 `payment_method_required` with a Stripe portal + * link the web app sends the admin to — never a silent past_due subscription. + */ + +const TENANT_ID = '11111111-1111-4111-8111-111111111111'; + +function build(options: { isCustomerOfProduct: boolean; changePlan?: jest.Mock; portal?: jest.Mock }) { + const billingService: any = { + isBillingEnforced: () => true, + product: 'gauzy', + isCustomerOfProduct: jest.fn(async () => options.isCustomerOfProduct), + getSubscription: jest.fn(async () => ({ id: 'sub_g' })), + listInvoices: jest.fn(async () => []), + getPaymentMethod: jest.fn(async () => null), + createPortalSession: options.portal ?? jest.fn(async () => 'https://billing.stripe.test/session/abc'), + changePlan: options.changePlan ?? jest.fn(async () => ({ id: 'sub_g' })) + }; + const tenantRepository: any = { findOne: jest.fn(async () => ({ id: TENANT_ID, stripeCustomerId: 'cus_1' })) }; + const tenantService: any = { ensureStripeCustomerLink: jest.fn(async () => null) }; + return { controller: new BillingController(billingService, tenantRepository, tenantService), billingService }; +} + +const savedBase = process.env.CLIENT_BASE_URL; + +beforeEach(() => { + process.env.CLIENT_BASE_URL = 'https://app.gauzy.test'; + jest.spyOn(RequestContext, 'currentTenantId').mockReturnValue(TENANT_ID); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + if (savedBase === undefined) delete process.env.CLIENT_BASE_URL; + else process.env.CLIENT_BASE_URL = savedBase; + jest.restoreAllMocks(); +}); + +describe("BillingController — a link to another product's customer is no link", () => { + it.each([ + ['subscription', (c: BillingController) => c.subscription()], + ['invoices', (c: BillingController) => c.invoices()], + ['payment method', (c: BillingController) => c.paymentMethod()], + ['cancel', (c: BillingController) => c.cancel()], + ['portal', (c: BillingController) => c.portal({ returnUrl: 'https://app.gauzy.test/#/pages/settings/billing' })] + ])('%s answers 404 and never reaches Stripe on that customer', async (_label, call) => { + const { controller, billingService } = build({ isCustomerOfProduct: false }); + await expect(call(controller)).rejects.toBeInstanceOf(NotFoundException); + expect(billingService.getSubscription).not.toHaveBeenCalled(); + expect(billingService.listInvoices).not.toHaveBeenCalled(); + expect(billingService.getPaymentMethod).not.toHaveBeenCalled(); + expect(billingService.createPortalSession).not.toHaveBeenCalled(); + }); + + it('a customer of this product is served as before', async () => { + const { controller } = build({ isCustomerOfProduct: true }); + await expect(controller.subscription()).resolves.toEqual({ id: 'sub_g' }); + }); +}); + +describe('BillingController.changePlan — CC02-13', () => { + it('answers 402 payment_method_required with a portal link that returns to the page', async () => { + const portal = jest.fn(async () => 'https://billing.stripe.test/session/abc'); + const { controller } = build({ + isCustomerOfProduct: true, + portal, + changePlan: jest.fn(async () => { + throw new PaymentMethodRequiredError(); + }) + }); + + const error: HttpException = await controller + .changePlan({ + lookupKey: 'ever_gauzy_cloud_small_business_annual', + returnUrl: 'https://app.gauzy.test/#/pages/settings/billing' + }) + .then( + () => null, + (e) => e + ); + + expect(error).toBeInstanceOf(HttpException); + expect(error.getStatus()).toBe(HttpStatus.PAYMENT_REQUIRED); + expect(error.getResponse()).toMatchObject({ + code: 'payment_method_required', + portalUrl: 'https://billing.stripe.test/session/abc' + }); + expect(portal).toHaveBeenCalledWith('cus_1', 'https://app.gauzy.test/#/pages/settings/billing'); + }); + + it('never hands the portal a foreign return URL', async () => { + const portal = jest.fn(async () => 'https://billing.stripe.test/session/abc'); + const { controller } = build({ + isCustomerOfProduct: true, + portal, + changePlan: jest.fn(async () => { + throw new PaymentMethodRequiredError(); + }) + }); + await controller + .changePlan({ lookupKey: 'ever_gauzy_x', returnUrl: 'https://evil.test/' }) + .catch(() => undefined); + expect(portal).toHaveBeenCalledWith('cus_1', 'https://app.gauzy.test'); + }); + + it('still answers 402 (without a link) if the portal cannot be opened', async () => { + const { controller } = build({ + isCustomerOfProduct: true, + portal: jest.fn(async () => { + throw new Error('portal down'); + }), + changePlan: jest.fn(async () => { + throw new PaymentMethodRequiredError(); + }) + }); + const error: HttpException = await controller.changePlan({ lookupKey: 'ever_gauzy_x' }).then( + () => null, + (e) => e + ); + expect(error.getStatus()).toBe(HttpStatus.PAYMENT_REQUIRED); + expect(error.getResponse()).not.toHaveProperty('portalUrl'); + }); + + it('passes every other failure through unchanged', async () => { + const boom = new NotFoundException('This account has no active subscription.'); + const { controller } = build({ + isCustomerOfProduct: true, + changePlan: jest.fn(async () => { + throw boom; + }) + }); + await expect(controller.changePlan({ lookupKey: 'ever_gauzy_x' })).rejects.toBe(boom); + }); +}); diff --git a/packages/core/src/lib/shared/billing/billing.controller.ts b/packages/core/src/lib/shared/billing/billing.controller.ts index 223acd4387..d09abadbe6 100644 --- a/packages/core/src/lib/shared/billing/billing.controller.ts +++ b/packages/core/src/lib/shared/billing/billing.controller.ts @@ -4,7 +4,9 @@ import { Body, Controller, Get, + HttpException, HttpStatus, + Logger, NotFoundException, Post, UseGuards @@ -20,7 +22,8 @@ import { BillingPaymentMethod, BillingPlan, BillingService, - BillingSubscription + BillingSubscription, + PaymentMethodRequiredError } from './billing.service'; /** @@ -42,6 +45,8 @@ import { @ApiTags('Billing') @Controller('/billing') export class BillingController { + private readonly logger = new Logger(BillingController.name); + constructor( private readonly billingService: BillingService, private readonly typeOrmTenantRepository: TypeOrmTenantRepository, @@ -78,7 +83,7 @@ export class BillingController { @Roles(RolesEnum.SUPER_ADMIN, RolesEnum.ADMIN) async plans(): Promise { this.requireBillingEnabled(); - return this.billingService.listPlans(EVER_PRODUCT_KEY); + return this.billingService.listPlans(this.productKey()); } @ApiOperation({ summary: 'Switch the subscription to another plan' }) @@ -86,15 +91,20 @@ export class BillingController { @Post('/subscription/change') @UseGuards(RoleGuard) @Roles(RolesEnum.SUPER_ADMIN, RolesEnum.ADMIN) - async changePlan(@Body() body: { lookupKey?: string }): Promise { + async changePlan(@Body() body: { lookupKey?: string; returnUrl?: string }): Promise { const customerId = await this.requireCustomerId(); // 400, not 404: this controller uses 404 to mean "billing is not configured on this // deployment", and reusing it for a missing field would make the two indistinguishable. - const lookupKey = body?.lookupKey?.trim(); + const lookupKey = typeof body?.lookupKey === 'string' ? body.lookupKey.trim() : ''; if (!lookupKey) { throw new BadRequestException('A plan must be supplied.'); } - return this.billingService.changePlan(customerId, lookupKey, EVER_PRODUCT_KEY); + try { + return await this.billingService.changePlan(customerId, lookupKey, this.productKey()); + } catch (error) { + if (!(error instanceof PaymentMethodRequiredError)) throw error; + throw await this.paymentMethodRequired(customerId, body?.returnUrl); + } } @ApiOperation({ summary: 'Cancel at the end of the current period' }) @@ -157,6 +167,47 @@ export class BillingController { /* ------------------------------------------------------------------ internals */ + /** + * 402 for an upgrade that nothing could pay for, with a customer-portal link to add a card. + * + * The portal is where the card is collected — the platform never renders a payment form — and its + * "update payment method" feature makes the card the customer's invoice default, so the admin can + * simply retry the switch on return. If the portal session cannot be created the 402 still stands; + * the admin can open the portal from the page instead. + */ + private async paymentMethodRequired(customerId: string, returnUrl?: string): Promise { + let portalUrl: string | undefined; + try { + portalUrl = await this.billingService.createPortalSession(customerId, this.safeReturnUrl(returnUrl)); + } catch (error) { + this.logger.warn( + `Could not open a billing portal session for a paid upgrade: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + return new HttpException( + { + statusCode: HttpStatus.PAYMENT_REQUIRED, + code: 'payment_method_required', + message: + 'Add a payment method before switching to a paid plan. Your current plan has no card on file, ' + + 'so the new plan could not be charged.', + ...(portalUrl ? { portalUrl } : {}) + }, + HttpStatus.PAYMENT_REQUIRED + ); + } + + /** The Ever product this deployment sells (`BILLING_PRODUCT`, default `gauzy`). */ + private productKey(): string { + const product = this.billingService.product; + if (!product) { + throw new NotFoundException('Billing is not available on this deployment.'); + } + return product; + } + private requireBillingEnabled(): void { if (!this.billingService.isBillingEnforced()) { // Not a 403: on a self-hosted install this feature genuinely does not exist. @@ -183,7 +234,13 @@ export class BillingController { }); const customerId = tenant?.stripeCustomerId?.trim(); - if (customerId) return customerId; + if (customerId) { + // A stored link to a customer who never subscribed to THIS product is treated as no link: + // that customer's invoices, card and subscriptions belong to another Ever product (and quite + // possibly another person), and nothing on these routes may show or act on them. + if (await this.billingService.isCustomerOfProduct(customerId)) return customerId; + throw new NotFoundException('This account is not linked to a billing customer.'); + } // No link yet. That is the normal state for someone who has just bought: onboarding refuses to // make the link until the buyer has confirmed their email address, because an unconfirmed @@ -221,9 +278,3 @@ export class BillingController { return base; } } - -/** - * Which Ever product's plans this deployment offers. The catalog keys every price as - * `ever____`, and this platform is Gauzy. - */ -const EVER_PRODUCT_KEY = 'gauzy'; diff --git a/packages/core/src/lib/shared/billing/billing.service.spec.ts b/packages/core/src/lib/shared/billing/billing.service.spec.ts new file mode 100644 index 0000000000..0c64248371 --- /dev/null +++ b/packages/core/src/lib/shared/billing/billing.service.spec.ts @@ -0,0 +1,417 @@ +// cspell:ignore selfhosted +import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; +import { BillingService, PaymentMethodRequiredError } from './billing.service'; +import { StripeSubscriptionService } from './stripe-subscription.service'; + +/** + * The in-product billing pages act on a Stripe customer that may also hold subscriptions to other Ever + * products (the account is shared). These tests pin that the pages see, cancel and re-price ONLY this + * deployment's product, and that an upgrade to a paid plan is refused — before anything is changed — + * when there is no card to charge (CC02-13: the $0 Starter subscriptions collect none). + */ + +const CUSTOMER = 'cus_1'; + +function sub(overrides: Record = {}) { + return { + id: 'sub_g', + status: 'trialing', + created: 1_700_000_000, + metadata: { ever_product: 'gauzy' }, + items: { + data: [ + { + id: 'si_g', + price: { + id: 'price_starter', + lookup_key: 'ever_gauzy_cloud_starter_annual', + unit_amount: 0, + currency: 'usd', + recurring: { interval: 'year' } + } + } + ] + }, + default_payment_method: null, + default_source: null, + latest_invoice: 'in_1', + ...overrides + }; +} + +function teams(overrides: Record = {}) { + return sub({ + id: 'sub_t', + status: 'active', + created: 1_800_000_000, // newer than the Gauzy one on purpose + metadata: { ever_product: 'teams' }, + items: { + data: [ + { + id: 'si_t', + price: { + id: 'price_t', + lookup_key: 'ever_teams_cloud_starter_monthly', + unit_amount: 0, + currency: 'usd', + recurring: { interval: 'month' } + } + } + ] + }, + ...overrides + }); +} + +/** A Gauzy SELF-HOSTED license ($1,668/yr) — same product key, not the hosted plan. */ +function selfHosted(overrides: Record = {}) { + return sub({ + id: 'sub_s', + status: 'active', + created: 1_900_000_000, // the newest on purpose + metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' }, + items: { + data: [ + { + id: 'si_s', + price: { + id: 'price_s', + lookup_key: 'ever_gauzy_selfhosted_enterprise_annual', + unit_amount: 166800, + currency: 'usd', + recurring: { interval: 'year' } + } + } + ] + }, + ...overrides + }); +} + +const PAID_PRICE = { + id: 'price_pro', + lookup_key: 'ever_gauzy_cloud_small_business_annual', + unit_amount: 16680, + currency: 'usd', + recurring: { interval: 'year' } +}; +const FREE_PRICE = { + id: 'price_free_monthly', + lookup_key: 'ever_gauzy_cloud_starter_monthly', + unit_amount: 0, + currency: 'usd', + recurring: { interval: 'month' } +}; + +interface StripeState { + subscriptions: any[]; + prices: Record; + customer: Record; + invoices?: any[]; +} + +let calls: Array<{ method: string; path: string; body?: string }> = []; + +function stubStripe(state: StripeState) { + calls = []; + (global as any).fetch = jest.fn(async (input: string, init: any = {}) => { + const method = init.method ?? 'GET'; + const path = String(input).replace('https://api.stripe.com/v1', ''); + calls.push({ method, path, body: init.body }); + let body: any; + if (method === 'GET' && path.startsWith(`/subscriptions?customer=${CUSTOMER}&status=all`)) { + body = { data: state.subscriptions, has_more: false }; + } else if (method === 'GET' && path.startsWith('/prices?lookup_keys[]=')) { + const key = decodeURIComponent(/lookup_keys\[\]=([^&]+)/.exec(path)[1]); + body = { data: state.prices[key] ? [state.prices[key]] : [] }; + } else if (method === 'GET' && path.startsWith('/invoices?subscription=')) { + // Stripe filters by subscription server-side; so does this stub. + const id = decodeURIComponent(/subscription=([^&]+)/.exec(path)[1]); + const limit = Number(/limit=(\d+)/.exec(path)?.[1] ?? 10); + const own = (state.invoices ?? []).filter((invoice) => invoice.subscription === id); + body = { data: own.slice(0, limit), has_more: own.length > limit }; + } else if (method === 'GET' && path === `/customers/${CUSTOMER}`) { + body = { id: CUSTOMER, ...state.customer }; + } else if (method === 'POST' && path.startsWith('/subscriptions/')) { + const params = new URLSearchParams(init.body); + const current = state.subscriptions.find((s) => path === `/subscriptions/${s.id}`); + const newPrice = + Object.values(state.prices).find((p: any) => p.id === params.get('items[0][price]')) ?? + current.items.data[0].price; + body = { ...current, items: { data: [{ id: current.items.data[0].id, price: newPrice }] } }; + } else { + throw new Error(`Unexpected Stripe request in test: ${method} ${path}`); + } + return { ok: true, status: 200, text: async () => JSON.stringify(body), json: async () => body }; + }); +} + +const posts = () => calls.filter((c) => c.method === 'POST'); + +const ENV_KEYS = ['STRIPE_SECRET_KEY', 'STRIPE_LIVE_MODE', 'DEMO', 'BILLING_PRODUCT']; +const saved: Record = {}; +const realFetch = (global as any).fetch; + +beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + process.env.STRIPE_SECRET_KEY = 'sk_test_billing_scope_fixture'; + delete process.env.STRIPE_LIVE_MODE; + delete process.env.DEMO; + delete process.env.BILLING_PRODUCT; + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + (global as any).fetch = realFetch; + jest.restoreAllMocks(); +}); + +const service = () => new BillingService(new StripeSubscriptionService()); + +describe('BillingService — reads only this product', () => { + it('shows the Gauzy subscription even when a newer Teams one exists on the same customer', async () => { + stubStripe({ subscriptions: [teams(), sub()], prices: {}, customer: {} }); + const current = await service().getSubscription(CUSTOMER); + expect(current).toMatchObject({ id: 'sub_g', lookupKey: 'ever_gauzy_cloud_starter_annual' }); + }); + + it('shows no subscription when the customer only holds another product', async () => { + stubStripe({ subscriptions: [teams()], prices: {}, customer: {} }); + await expect(service().getSubscription(CUSTOMER)).resolves.toBeNull(); + }); + + it('a Dashboard-made subscription on an ever_gauzy_ price (no metadata) is still Gauzy', async () => { + stubStripe({ subscriptions: [sub({ metadata: {} })], prices: {}, customer: {} }); + await expect(service().getSubscription(CUSTOMER)).resolves.toMatchObject({ id: 'sub_g' }); + }); + + it("cancel never touches another product's subscription", async () => { + stubStripe({ subscriptions: [teams()], prices: {}, customer: {} }); + await expect(service().cancelSubscription(CUSTOMER)).rejects.toBeInstanceOf(NotFoundException); + expect(posts()).toEqual([]); + }); + + it('isCustomerOfProduct: any-status Gauzy history counts; Teams-only does not', async () => { + stubStripe({ subscriptions: [teams()], prices: {}, customer: {} }); + await expect(service().isCustomerOfProduct(CUSTOMER)).resolves.toBe(false); + stubStripe({ subscriptions: [teams(), sub({ status: 'canceled' })], prices: {}, customer: {} }); + await expect(service().isCustomerOfProduct(CUSTOMER)).resolves.toBe(true); + }); + + it('a Gauzy SELF-HOSTED license is never shown, canceled or counted as the cloud plan', async () => { + stubStripe({ subscriptions: [selfHosted(), sub()], prices: {}, customer: {} }); + await expect(service().getSubscription(CUSTOMER)).resolves.toMatchObject({ id: 'sub_g' }); + + stubStripe({ subscriptions: [selfHosted()], prices: {}, customer: {} }); + await expect(service().getSubscription(CUSTOMER)).resolves.toBeNull(); + await expect(service().cancelSubscription(CUSTOMER)).rejects.toBeInstanceOf(NotFoundException); + expect(posts()).toEqual([]); + await expect(service().isCustomerOfProduct(CUSTOMER)).resolves.toBe(false); + }); + + it('isCustomerOfProduct caches only a positive answer', async () => { + const billing = service(); + stubStripe({ subscriptions: [teams()], prices: {}, customer: {} }); + await expect(billing.isCustomerOfProduct(CUSTOMER)).resolves.toBe(false); + await expect(billing.isCustomerOfProduct(CUSTOMER)).resolves.toBe(false); + expect(calls).toHaveLength(2); // a "no" is re-checked every time + + stubStripe({ subscriptions: [sub()], prices: {}, customer: {} }); + await expect(billing.isCustomerOfProduct(CUSTOMER)).resolves.toBe(true); + await expect(billing.isCustomerOfProduct(CUSTOMER)).resolves.toBe(true); + expect(calls).toHaveLength(1); // the second "yes" came from the cache + }); + + it("invoices: only this product's subscriptions, merged newest first — never another product's or one-offs", async () => { + const invoice = (id: string, subscription: string | null, created: number) => ({ + id, + number: id.toUpperCase(), + status: 'paid', + amount_paid: 100, + amount_due: 100, + currency: 'usd', + created, + subscription + }); + // 150 NEWER foreign invoices: a per-customer listing would fill a whole Stripe page with them and + // never reach the Gauzy ones. Listing per subscription cannot be crowded out. + const foreign = Array.from({ length: 150 }, (_, i) => invoice(`in_teams_${i}`, 'sub_t', 1_900_000_000 + i)); + stubStripe({ + subscriptions: [teams(), sub(), sub({ id: 'sub_g_old', status: 'canceled', created: 1_600_000_000 })], + prices: {}, + customer: {}, + invoices: [ + ...foreign, + invoice('in_license', null, 1_950_000_000), + invoice('in_gauzy_old', 'sub_g_old', 1_600_000_100), + invoice('in_gauzy', 'sub_g', 1_700_000_100), + invoice('in_gauzy_renewal', 'sub_g', 1_731_536_100) + ] + }); + const invoices = await service().listInvoices(CUSTOMER); + expect(invoices.map((i) => i.id)).toEqual(['in_gauzy_renewal', 'in_gauzy', 'in_gauzy_old']); + // Asked per Gauzy subscription only — the Teams subscription's invoices are never requested. + const invoiceCalls = calls.filter((c) => c.path.startsWith('/invoices')).map((c) => c.path); + expect(invoiceCalls.sort()).toEqual([ + '/invoices?subscription=sub_g&limit=24', + '/invoices?subscription=sub_g_old&limit=24' + ]); + }); + + it('invoices: the page limit applies after merging', async () => { + stubStripe({ + subscriptions: [sub(), sub({ id: 'sub_g_old', status: 'canceled', created: 1_600_000_000 })], + prices: {}, + customer: {}, + invoices: [ + { id: 'in_a', subscription: 'sub_g', created: 3, currency: 'usd' }, + { id: 'in_b', subscription: 'sub_g_old', created: 2, currency: 'usd' }, + { id: 'in_c', subscription: 'sub_g', created: 1, currency: 'usd' } + ] + }); + const invoices = await service().listInvoices(CUSTOMER, 2); + expect(invoices.map((i) => i.id)).toEqual(['in_a', 'in_b']); + // Each subscription is asked for no more than the page needs. + expect(calls.filter((c) => c.path.startsWith('/invoices')).every((c) => c.path.endsWith('&limit=2'))).toBe( + true + ); + }); + + it('invoices: a customer with no subscription to this product gets none, and Stripe is not asked for them', async () => { + stubStripe({ + subscriptions: [teams()], + prices: {}, + customer: {}, + invoices: [{ id: 'in_teams', subscription: 'sub_t' }] + }); + await expect(service().listInvoices(CUSTOMER)).resolves.toEqual([]); + expect(calls.some((c) => c.path.startsWith('/invoices'))).toBe(false); + }); +}); + +describe('BillingService.changePlan — product scope', () => { + it('refuses a target plan of another product without calling Stripe', async () => { + stubStripe({ subscriptions: [sub()], prices: {}, customer: {} }); + await expect( + service().changePlan(CUSTOMER, 'ever_teams_cloud_starter_monthly', 'gauzy') + ).rejects.toBeInstanceOf(BadRequestException); + expect(calls).toEqual([]); + }); + + it("refuses to re-price a customer whose only live subscription is another product's", async () => { + stubStripe({ subscriptions: [teams()], prices: { [FREE_PRICE.lookup_key]: FREE_PRICE }, customer: {} }); + await expect(service().changePlan(CUSTOMER, FREE_PRICE.lookup_key, 'gauzy')).rejects.toBeInstanceOf( + NotFoundException + ); + expect(posts()).toEqual([]); + }); + + it('never re-prices a self-hosted license: alone it is "no subscription", beside the cloud plan it is skipped', async () => { + stubStripe({ subscriptions: [selfHosted()], prices: { [FREE_PRICE.lookup_key]: FREE_PRICE }, customer: {} }); + await expect(service().changePlan(CUSTOMER, FREE_PRICE.lookup_key, 'gauzy')).rejects.toBeInstanceOf( + NotFoundException + ); + expect(posts()).toEqual([]); + + stubStripe({ + subscriptions: [selfHosted(), sub()], + prices: { [FREE_PRICE.lookup_key]: FREE_PRICE }, + customer: {} + }); + await service().changePlan(CUSTOMER, FREE_PRICE.lookup_key, 'gauzy'); + expect(posts().map((p) => p.path)).toEqual(['/subscriptions/sub_g']); + }); + + it('refuses a self-hosted target price without calling Stripe', async () => { + stubStripe({ subscriptions: [sub()], prices: {}, customer: {} }); + await expect( + service().changePlan(CUSTOMER, 'ever_gauzy_selfhosted_enterprise_annual', 'gauzy') + ).rejects.toBeInstanceOf(BadRequestException); + expect(calls).toEqual([]); + }); + + it('re-prices the Gauzy subscription, never the Teams one beside it', async () => { + stubStripe({ + subscriptions: [teams(), sub()], + prices: { [FREE_PRICE.lookup_key]: FREE_PRICE }, + customer: {} + }); + await service().changePlan(CUSTOMER, FREE_PRICE.lookup_key, 'gauzy'); + expect(posts().map((p) => p.path)).toEqual(['/subscriptions/sub_g']); + }); +}); + +describe('BillingService.changePlan — CC02-13: a paid upgrade needs a card first', () => { + it('refuses a $0 → paid switch when nothing could pay, and changes NOTHING', async () => { + stubStripe({ + subscriptions: [sub()], + prices: { [PAID_PRICE.lookup_key]: PAID_PRICE }, + customer: { invoice_settings: { default_payment_method: null }, default_source: null } + }); + await expect(service().changePlan(CUSTOMER, PAID_PRICE.lookup_key, 'gauzy')).rejects.toBeInstanceOf( + PaymentMethodRequiredError + ); + expect(posts()).toEqual([]); + }); + + it('a card merely attached (not the invoice default) does not count', async () => { + // No `invoice_settings.default_payment_method`: Stripe would not charge it automatically. + stubStripe({ + subscriptions: [sub()], + prices: { [PAID_PRICE.lookup_key]: PAID_PRICE }, + customer: { invoice_settings: {} } + }); + await expect(service().changePlan(CUSTOMER, PAID_PRICE.lookup_key, 'gauzy')).rejects.toBeInstanceOf( + PaymentMethodRequiredError + ); + expect(posts()).toEqual([]); + }); + + it('switches once the customer has an invoice default (e.g. added in the portal)', async () => { + stubStripe({ + subscriptions: [sub()], + prices: { [PAID_PRICE.lookup_key]: PAID_PRICE }, + customer: { invoice_settings: { default_payment_method: 'pm_card' } } + }); + const updated = await service().changePlan(CUSTOMER, PAID_PRICE.lookup_key, 'gauzy'); + expect(updated.lookupKey).toBe(PAID_PRICE.lookup_key); + expect(posts().map((p) => p.path)).toEqual(['/subscriptions/sub_g']); + }); + + it("uses the subscription's own default payment method without asking for the customer", async () => { + stubStripe({ + subscriptions: [sub({ default_payment_method: 'pm_sub' })], + prices: { [PAID_PRICE.lookup_key]: PAID_PRICE }, + customer: {} + }); + await service().changePlan(CUSTOMER, PAID_PRICE.lookup_key, 'gauzy'); + expect(calls.some((c) => c.path === `/customers/${CUSTOMER}`)).toBe(false); + expect(posts()).toHaveLength(1); + }); + + it('a switch to another $0 plan needs no card', async () => { + stubStripe({ subscriptions: [sub()], prices: { [FREE_PRICE.lookup_key]: FREE_PRICE }, customer: {} }); + await service().changePlan(CUSTOMER, FREE_PRICE.lookup_key, 'gauzy'); + expect(calls.some((c) => c.path === `/customers/${CUSTOMER}`)).toBe(false); + expect(posts()).toHaveLength(1); + }); + + it('a price with no unit_amount (tiered/metered) is treated as paid', async () => { + const tiered = { + ...PAID_PRICE, + id: 'price_tiered', + lookup_key: 'ever_gauzy_cloud_enterprise_annual', + unit_amount: null + }; + stubStripe({ subscriptions: [sub()], prices: { [tiered.lookup_key]: tiered }, customer: {} }); + await expect(service().changePlan(CUSTOMER, tiered.lookup_key, 'gauzy')).rejects.toBeInstanceOf( + PaymentMethodRequiredError + ); + expect(posts()).toEqual([]); + }); +}); diff --git a/packages/core/src/lib/shared/billing/billing.service.ts b/packages/core/src/lib/shared/billing/billing.service.ts index 5a9c6b9704..ba5acbe4ee 100644 --- a/packages/core/src/lib/shared/billing/billing.service.ts +++ b/packages/core/src/lib/shared/billing/billing.service.ts @@ -7,6 +7,7 @@ import { ServiceUnavailableException, UnauthorizedException } from '@nestjs/common'; +import { subscriptionIsForProduct } from './billing-product'; import { StripeSubscriptionService } from './stripe-subscription.service'; /** @@ -81,10 +82,28 @@ export interface BillingPaymentMethod { const STRIPE_API = 'https://api.stripe.com/v1'; +/** + * Raised by `changePlan` when the target plan costs money and nothing could pay for it. + * + * The $0 Starter subscriptions sold through the shared checkout collect no card, so swapping one onto + * a paid price directly would issue an invoice nobody can pay and leave the subscription `past_due` + * — which this platform still treats as a live plan. The controller answers it with 402 and a Stripe + * customer-portal link where the admin can add a card, then retry the switch. + */ +export class PaymentMethodRequiredError extends Error { + constructor() { + super('A payment method is required before switching to a paid plan.'); + this.name = 'PaymentMethodRequiredError'; + } +} + @Injectable() export class BillingService { private readonly logger = new Logger(BillingService.name); + /** `:` -> expiry (ms) of a positive isCustomerOfProduct answer. */ + private readonly productCustomerCache = new Map(); + constructor(private readonly stripeSubscriptionService: StripeSubscriptionService) {} /** Mirrors the subscription service's switch, so the controller only has to ask one object. */ @@ -97,6 +116,39 @@ export class BillingService { return this.stripeSubscriptionService.mode; } + /** The Ever product this deployment bills for (`BILLING_PRODUCT`, default `gauzy`). */ + get product(): string | null { + return this.stripeSubscriptionService.billingProduct; + } + + /** + * Whether this Stripe customer has EVER subscribed to this deployment's product (any status). + * + * The billing routes resolve the customer from the tenant's stored link. If that link points at a + * customer who only ever bought another Ever product — whether written by an older, product-blind + * version of the linking code or by hand — this is false, and the routes treat the tenant as not + * linked rather than show, cancel, re-price or open a portal on someone else's billing. + */ + async isCustomerOfProduct(stripeCustomerId: string): Promise { + const product = this.product; + if (!product) return false; + + // Every /billing route asks this before it does anything, so one page load would otherwise walk + // the customer's subscription list once per route. Only a positive answer is cached: with + // `status=all` it can never turn false again (Stripe cancels subscriptions, it does not delete + // them), whereas a negative one must be re-checked the moment the customer subscribes. + const cacheKey = `${product}:${stripeCustomerId}`; + const cachedUntil = this.productCustomerCache.get(cacheKey); + if (cachedUntil && cachedUntil > Date.now()) return true; + + const isCustomer = (await this.listProductSubscriptions(stripeCustomerId, product)).length > 0; + if (isCustomer) { + if (this.productCustomerCache.size >= PRODUCT_CUSTOMER_CACHE_MAX) this.productCustomerCache.clear(); + this.productCustomerCache.set(cacheKey, Date.now() + PRODUCT_CUSTOMER_CACHE_TTL_MS); + } + return isCustomer; + } + /** * The tenant's current subscription, or null when it has never had one. * @@ -171,6 +223,15 @@ export class BillingService { const subscription = await this.requireSubscriptionObject(stripeCustomerId); + // The CURRENT subscription must be this product's too, not only the target. Otherwise a tenant + // linked to a customer who holds a Teams or Works subscription could re-price that subscription + // onto a Gauzy price and bill the proration to someone else's card. findCurrentSubscription + // already filters on the deployment's product; this says the same for the product the caller + // asked about, so the two can never disagree silently. + if (!subscriptionIsForProduct(subscription, productKey)) { + throw new BadRequestException('The current subscription does not belong to this product.'); + } + const { data: prices } = await this.get<{ data: StripePriceObject[] }>( `/prices?lookup_keys[]=${encodeURIComponent(lookupKey)}&active=true&limit=1` ); @@ -187,6 +248,12 @@ export class BillingService { throw new BadRequestException('The subscription is already on that plan.'); } + // A paid target needs something to pay with. Checked here, before anything is changed, because + // afterwards the only evidence is an unpaid invoice and a past_due subscription. + if (isPaidPrice(price) && !(await this.hasDefaultPaymentMethod(stripeCustomerId, subscription))) { + throw new PaymentMethodRequiredError(); + } + const updated = await this.post( `/subscriptions/${subscription.id}`, { @@ -225,25 +292,53 @@ export class BillingService { return this.toSubscription(updated); } - /** Invoice history, newest first. */ + /** + * Invoice history of THIS deployment's product, newest first. + * + * Invoices used to be listed per customer, and one customer can also hold another Ever product (the + * same buyer's Teams plan, a one-off license). Those invoices are not this tenant's to see, and + * enough of them would push this product's own invoices off the page entirely. So invoices are + * listed per subscription of this product instead — Stripe filters server-side, so there is no + * page of foreign invoices to scan past — then merged newest first. + */ async listInvoices(stripeCustomerId: string, limit = 24): Promise { - const { data } = await this.get<{ data: StripeInvoiceObject[] }>( - `/invoices?customer=${encodeURIComponent(stripeCustomerId)}&limit=${limit}` + const product = this.product; + if (!product) return []; + const subscriptions = (await this.listProductSubscriptions(stripeCustomerId, product)) + .sort((a, b) => (b.created ?? 0) - (a.created ?? 0)) + .slice(0, MAX_SUBSCRIPTIONS_FOR_INVOICES); + if (!subscriptions.length) return []; + + const pages = await Promise.all( + subscriptions.map((subscription) => + this.get<{ data: StripeInvoiceObject[] }>( + `/invoices?subscription=${encodeURIComponent(subscription.id)}&limit=${limit}` + ) + ) ); - return (data ?? []).map((invoice) => ({ - id: invoice.id, - number: invoice.number ?? null, - status: invoice.status ?? null, - amountPaid: invoice.amount_paid ?? 0, - // An unpaid or failed invoice has amount_paid = 0, so a table showing only that renders the - // row as $0.00 — exactly the invoices someone is looking for when something has gone wrong. - amountDue: invoice.amount_due ?? invoice.amount_paid ?? 0, - currency: invoice.currency, - createdAt: new Date((invoice.created ?? 0) * 1000).toISOString(), - hostedInvoiceUrl: invoice.hosted_invoice_url ?? null, - invoicePdfUrl: invoice.invoice_pdf ?? null - })); + // One invoice belongs to one subscription, so duplicates cannot normally occur; keyed by id anyway. + const byId = new Map(); + for (const page of pages) { + for (const invoice of page?.data ?? []) byId.set(invoice.id, invoice); + } + + return [...byId.values()] + .sort((a, b) => (b.created ?? 0) - (a.created ?? 0)) + .slice(0, limit) + .map((invoice) => ({ + id: invoice.id, + number: invoice.number ?? null, + status: invoice.status ?? null, + amountPaid: invoice.amount_paid ?? 0, + // An unpaid or failed invoice has amount_paid = 0, so a table showing only that renders the + // row as $0.00 — exactly the invoices someone is looking for when something has gone wrong. + amountDue: invoice.amount_due ?? invoice.amount_paid ?? 0, + currency: invoice.currency, + createdAt: new Date((invoice.created ?? 0) * 1000).toISOString(), + hostedInvoiceUrl: invoice.hosted_invoice_url ?? null, + invoicePdfUrl: invoice.invoice_pdf ?? null + })); } /** @@ -302,7 +397,11 @@ export class BillingService { `/subscriptions?customer=${encodeURIComponent(stripeCustomerId)}&status=all` ); - const live = subscriptions.filter((s) => LIVE_STATUSES.has(s.status)); + // Only this deployment's product. The Stripe account is shared by every Ever product, so one + // customer can hold, say, a Teams subscription beside a Gauzy one — and the Gauzy billing page + // must neither show that as the Gauzy plan nor cancel or re-price it. + const product = this.product; + const live = subscriptions.filter((s) => LIVE_STATUSES.has(s.status) && subscriptionIsForProduct(s, product)); if (!live.length) return null; // Established subscriptions outrank `incomplete` ones before recency is considered. An @@ -318,6 +417,34 @@ export class BillingService { return live[0]; } + /** + * Whether Stripe has a payment method it would charge this subscription's invoices to. + * + * Stripe's own order: the subscription's default, then the customer's invoice default, then the + * customer's legacy default source. A card merely attached to the customer is NOT charged + * automatically, so it does not count. The customer portal's "add payment method" sets the + * customer's invoice default, which is what makes the retry after a 402 succeed. + */ + private async hasDefaultPaymentMethod( + stripeCustomerId: string, + subscription: StripeSubscriptionObject + ): Promise { + if (subscription.default_payment_method || subscription.default_source) return true; + const customer = await this.get(`/customers/${encodeURIComponent(stripeCustomerId)}`); + return Boolean(customer?.invoice_settings?.default_payment_method || customer?.default_source); + } + + /** Every subscription (any status) this customer holds on `product`. */ + private async listProductSubscriptions( + stripeCustomerId: string, + product: string + ): Promise { + const subscriptions = await this.listAll( + `/subscriptions?customer=${encodeURIComponent(stripeCustomerId)}&status=all` + ); + return subscriptions.filter((subscription) => subscriptionIsForProduct(subscription, product)); + } + private async requireSubscriptionObject(stripeCustomerId: string): Promise { const current = await this.findCurrentSubscription(stripeCustomerId); if (!current) { @@ -526,9 +653,30 @@ function planChangeIdempotencyKey(subscription: StripeSubscriptionObject, target return ['change', subscription.id, currentPrice, targetPriceId, latestInvoice, String(window)].join(':'); } +/** How long a positive isCustomerOfProduct answer is reused, and how many are kept. */ +const PRODUCT_CUSTOMER_CACHE_TTL_MS = 5 * 60 * 1000; +const PRODUCT_CUSTOMER_CACHE_MAX = 1000; + +/** + * How many of this product's subscriptions (newest first) contribute to the invoice history. A tenant + * normally has one, plus a few after resubscribing; the bound keeps a pathological customer from + * fanning out into dozens of Stripe requests on one page load. + */ +const MAX_SUBSCRIPTIONS_FOR_INVOICES = 10; + /** Statuses that represent a subscription the customer still has. */ const LIVE_STATUSES = new Set(['active', 'trialing', 'past_due', 'unpaid', 'incomplete']); +/** + * Whether switching to this price would bill anything. + * + * A price with no `unit_amount` (tiered or metered billing) is treated as paid: it is not free, and + * guessing that it is would re-create exactly the unpaid-invoice state the check exists to prevent. + */ +function isPaidPrice(price: StripePriceObject): boolean { + return typeof price.unit_amount === 'number' ? price.unit_amount > 0 : true; +} + /** Stripe's recurring interval, or `one_time` for a price with no `recurring` block. */ function toInterval(price?: StripePriceObject): BillingInterval { const interval = price?.recurring?.interval; @@ -564,6 +712,9 @@ interface StripeSubscriptionObject { id: string; status: string; created?: number; + metadata?: Record | null; + default_payment_method?: string | { id?: string } | null; + default_source?: string | { id?: string } | null; trial_end?: number | null; current_period_end?: number | null; cancel_at_period_end?: boolean; @@ -572,6 +723,11 @@ interface StripeSubscriptionObject { latest_invoice?: string | { id?: string } | null; } +interface StripeCustomerObject { + default_source?: string | { id?: string } | null; + invoice_settings?: { default_payment_method?: string | { id?: string } | null } | null; +} + interface StripeInvoiceObject { id: string; number?: string | null; diff --git a/packages/core/src/lib/shared/billing/stripe-subscription.service.spec.ts b/packages/core/src/lib/shared/billing/stripe-subscription.service.spec.ts new file mode 100644 index 0000000000..a045cc9b9d --- /dev/null +++ b/packages/core/src/lib/shared/billing/stripe-subscription.service.spec.ts @@ -0,0 +1,424 @@ +// cspell:ignore payg gauzyx flase selfhosted +import { Logger } from '@nestjs/common'; +import { EntitlementResult, StripeSubscriptionService } from './stripe-subscription.service'; +import { + checkoutSessionIsForProduct, + isCheckoutSessionId, + resolveBillingProduct, + resolveSignupPaywall, + subscriptionIsForProduct +} from './billing-product'; + +/** + * The paywall, the lazy tenant link and the proven-identity (Checkout Session) link all read a Stripe + * account shared by every Ever product. These tests pin that each of them counts ONLY this + * deployment's product (`BILLING_PRODUCT`), and that a Checkout Session is accepted as proof of + * purchase only when every rule holds. Stripe is a fixture router over `fetch`; no network. + */ + +const EMAIL = 'Buyer@Example.test'; +const SESSION_LIVE = 'cs_live_a1FixtureSessionForBillingScopeTests000000000000000000000'; +const SESSION_TEST = 'cs_test_a1FixtureSessionForBillingScopeTests000000000000000000000'; + +type Route = (path: string) => { status?: number; body: any } | undefined; + +let fetchCalls: string[] = []; +function stubStripe(route: Route) { + fetchCalls = []; + (global as any).fetch = jest.fn(async (input: string) => { + const path = String(input).replace('https://api.stripe.com/v1', ''); + fetchCalls.push(path); + const answer = route(path); + if (!answer) throw new Error(`Unexpected Stripe request in test: ${path}`); + const status = answer.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + json: async () => answer.body, + text: async () => JSON.stringify(answer.body) + }; + }); +} + +function gauzySub(overrides: Record = {}) { + return { + id: 'sub_1', + status: 'trialing', + metadata: { ever_product: 'gauzy' }, + items: { data: [{ price: { lookup_key: 'ever_gauzy_cloud_starter_annual' } }] }, + ...overrides + }; +} + +function teamsSub(overrides: Record = {}) { + return { + id: 'sub_t', + status: 'active', + metadata: { ever_product: 'teams' }, + items: { data: [{ price: { lookup_key: 'ever_teams_cloud_starter_monthly' } }] }, + ...overrides + }; +} + +function selfHostedSub(overrides: Record = {}) { + return { + id: 'sub_s', + status: 'active', + metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' }, + items: { data: [{ price: { lookup_key: 'ever_gauzy_selfhosted_enterprise_annual' } }] }, + ...overrides + }; +} + +function completeSession(overrides: Record = {}) { + return { + id: SESSION_TEST, + status: 'complete', + mode: 'subscription', + created: Math.floor(Date.now() / 1000) - 120, + customer: 'cus_1', + customer_email: null, + customer_details: { email: EMAIL }, + metadata: { ever_product: 'gauzy' }, + subscription: gauzySub(), + ...overrides + }; +} + +/** Customers + subscriptions router for the email-based lookup. */ +function customersRoute(customers: Record): Route { + return (path) => { + if (path.startsWith('/customers?email=')) { + return { body: { data: Object.keys(customers).map((id) => ({ id })), has_more: false } }; + } + const m = /^\/subscriptions\?customer=([^&]+)&status=all/.exec(path); + if (m) return { body: { data: customers[decodeURIComponent(m[1])] ?? [], has_more: false } }; + return undefined; + }; +} + +const ENV_KEYS = ['STRIPE_SECRET_KEY', 'STRIPE_LIVE_MODE', 'DEMO', 'BILLING_PRODUCT', 'BILLING_SIGNUP_PAYWALL']; +const saved: Record = {}; +const realFetch = (global as any).fetch; + +beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + process.env.STRIPE_SECRET_KEY = 'sk_test_billing_scope_fixture'; + delete process.env.STRIPE_LIVE_MODE; + delete process.env.DEMO; + delete process.env.BILLING_PRODUCT; + delete process.env.BILLING_SIGNUP_PAYWALL; + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + (global as any).fetch = realFetch; + jest.restoreAllMocks(); +}); + +describe('billing-product predicates', () => { + it('BILLING_PRODUCT defaults to gauzy, is normalised, and an unusable value is reported, not replaced', () => { + expect(resolveBillingProduct(undefined)).toEqual({ product: 'gauzy' }); + expect(resolveBillingProduct(' Teams ')).toEqual({ product: 'teams' }); + expect(resolveBillingProduct('ever teams!')).toEqual({ product: null, invalid: 'ever teams!' }); + }); + + it('BILLING_SIGNUP_PAYWALL defaults to on; only an explicit off value turns it off', () => { + expect(resolveSignupPaywall(undefined)).toBe(true); + expect(resolveSignupPaywall('')).toBe(true); + expect(resolveSignupPaywall('true')).toBe(true); + expect(resolveSignupPaywall('flase')).toBe(true); // a typo keeps the stricter behaviour + for (const off of ['false', 'FALSE', '0', 'no', 'off']) expect(resolveSignupPaywall(off)).toBe(false); + }); + + it('a subscription belongs to a product by metadata, or by its first price lookup key', () => { + expect(subscriptionIsForProduct(gauzySub(), 'gauzy')).toBe(true); + expect(subscriptionIsForProduct(gauzySub({ metadata: {} }), 'gauzy')).toBe(true); + expect(subscriptionIsForProduct(teamsSub(), 'gauzy')).toBe(false); + expect(subscriptionIsForProduct(teamsSub({ metadata: {} }), 'gauzy')).toBe(false); + expect(subscriptionIsForProduct({ metadata: { kind: 'payg-subscription' } }, 'gauzy')).toBe(false); + // `ever_gauzyx_` is not `ever_gauzy_`. + expect( + subscriptionIsForProduct({ items: { data: [{ price: { lookup_key: 'ever_gauzyx_cloud' } }] } }, 'gauzy') + ).toBe(false); + expect(subscriptionIsForProduct(gauzySub(), null)).toBe(false); + }); + + it("a self-hosted license subscription is never the product's hosted plan", () => { + expect( + subscriptionIsForProduct(gauzySub({ metadata: { ever_product: 'gauzy', ever_hosting: 'cloud' } }), 'gauzy') + ).toBe(true); + expect(subscriptionIsForProduct(selfHostedSub(), 'gauzy')).toBe(false); + // Either signal alone is enough to refuse it. + expect(subscriptionIsForProduct(selfHostedSub({ metadata: {} }), 'gauzy')).toBe(false); + expect(subscriptionIsForProduct(selfHostedSub({ metadata: { ever_product: 'gauzy' } }), 'gauzy')).toBe(false); + expect( + subscriptionIsForProduct( + gauzySub({ metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' } }), + 'gauzy' + ) + ).toBe(false); + // The price decides when it is a catalog price; metadata may only agree with it. + expect( + subscriptionIsForProduct(teamsSub({ metadata: { ever_product: 'gauzy', ever_hosting: 'cloud' } }), 'gauzy') + ).toBe(false); + expect(subscriptionIsForProduct(gauzySub({ metadata: { ever_product: 'teams' } }), 'gauzy')).toBe(false); + // Metadata alone decides only when there is no catalog price. + expect( + subscriptionIsForProduct(gauzySub({ items: { data: [{ price: { lookup_key: null } }] } }), 'gauzy') + ).toBe(true); + // A catalog price of this product that is not a cloud price. + expect( + subscriptionIsForProduct( + { items: { data: [{ price: { lookup_key: 'ever_gauzy_starter_monthly' } }] } }, + 'gauzy' + ) + ).toBe(false); + }); + + it('a checkout session needs BOTH ever_product and subscription mode', () => { + expect( + checkoutSessionIsForProduct({ mode: 'subscription', metadata: { ever_product: 'gauzy' } }, 'gauzy') + ).toBe(true); + expect(checkoutSessionIsForProduct({ mode: 'payment', metadata: { ever_product: 'gauzy' } }, 'gauzy')).toBe( + false + ); + expect(checkoutSessionIsForProduct({ mode: 'setup', metadata: { ever_product: 'gauzy' } }, 'gauzy')).toBe( + false + ); + expect( + checkoutSessionIsForProduct({ mode: 'subscription', metadata: { ever_product: 'teams' } }, 'gauzy') + ).toBe(false); + expect(checkoutSessionIsForProduct({ mode: 'subscription', metadata: { kind: 'plan' } }, 'gauzy')).toBe(false); + expect( + checkoutSessionIsForProduct( + { mode: 'subscription', metadata: { ever_product: 'gauzy', ever_hosting: 'cloud' } }, + 'gauzy' + ) + ).toBe(true); + expect( + checkoutSessionIsForProduct( + { mode: 'subscription', metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' } }, + 'gauzy' + ) + ).toBe(false); + // A stamped lookup key must be one of this product's cloud prices. + const withKey = (ever_lookup_key: string) => ({ + mode: 'subscription', + metadata: { ever_product: 'gauzy', ever_hosting: 'cloud', ever_lookup_key } + }); + expect(checkoutSessionIsForProduct(withKey('ever_gauzy_cloud_starter_annual'), 'gauzy')).toBe(true); + expect(checkoutSessionIsForProduct(withKey('ever_gauzy_selfhosted_enterprise_annual'), 'gauzy')).toBe(false); + expect(checkoutSessionIsForProduct(withKey('ever_teams_cloud_starter_monthly'), 'gauzy')).toBe(false); + }); + + it('recognizes Checkout Session ids and nothing else', () => { + expect(isCheckoutSessionId(SESSION_LIVE)).toBe(true); + expect(isCheckoutSessionId(SESSION_TEST)).toBe(true); + for (const bad of [ + '', + 'cs_live_', + 'cs_live_short', + 'sub_123456789012', + 'cs_live_abc/../../customers', + 'cs_prod_abcdefghijkl', + 42, + null + ]) { + expect(isCheckoutSessionId(bad)).toBe(false); + } + }); +}); + +describe('StripeSubscriptionService — entitlement counts only this product', () => { + it('a Teams subscriber is NOT entitled to Gauzy signup (the paywall no longer accepts any product)', async () => { + stubStripe(customersRoute({ cus_t: [teamsSub()] })); + const service = new StripeSubscriptionService(); + await expect(service.getEntitlement(EMAIL)).resolves.toBe(EntitlementResult.NOT_ENTITLED); + await expect(service.findCustomerIdForEmail(EMAIL)).resolves.toBeNull(); + }); + + it('a Gauzy subscriber is entitled, and the lazy link finds their Gauzy customer, not their Teams one', async () => { + stubStripe(customersRoute({ cus_t: [teamsSub()], cus_g: [gauzySub()] })); + const service = new StripeSubscriptionService(); + await expect(service.getEntitlement(EMAIL)).resolves.toBe(EntitlementResult.ENTITLED); + await expect(service.findCustomerIdForEmail(EMAIL)).resolves.toBe('cus_g'); + }); + + it('a Dashboard-made Gauzy subscription (no metadata, ever_gauzy_ price) counts', async () => { + stubStripe(customersRoute({ cus_g: [gauzySub({ metadata: {} })] })); + await expect(new StripeSubscriptionService().getEntitlement(EMAIL)).resolves.toBe(EntitlementResult.ENTITLED); + }); + + it('a Gauzy SELF-HOSTED license subscription does not count, for the paywall or the lazy link', async () => { + stubStripe(customersRoute({ cus_s: [selfHostedSub()] })); + const service = new StripeSubscriptionService(); + await expect(service.getEntitlement(EMAIL)).resolves.toBe(EntitlementResult.NOT_ENTITLED); + await expect(service.findCustomerIdForEmail(EMAIL)).resolves.toBeNull(); + await expect(service.customerHasEntitlingSubscription('cus_s', 1000)).resolves.toBe(false); + }); + + it('a cancelled Gauzy subscription does not count', async () => { + stubStripe(customersRoute({ cus_g: [gauzySub({ status: 'canceled' })] })); + await expect(new StripeSubscriptionService().getEntitlement(EMAIL)).resolves.toBe( + EntitlementResult.NOT_ENTITLED + ); + }); + + it('on the Teams deployment the same lookup counts only Teams', async () => { + process.env.BILLING_PRODUCT = 'teams'; + stubStripe(customersRoute({ cus_t: [teamsSub()], cus_g: [gauzySub()] })); + await expect(new StripeSubscriptionService().findCustomerIdForEmail(EMAIL)).resolves.toBe('cus_t'); + }); + + it('customerHasEntitlingSubscription throws when Stripe fails, so the webhook cannot read it as "no"', async () => { + stubStripe((path) => (path.startsWith('/subscriptions') ? { status: 500, body: { error: {} } } : undefined)); + await expect(new StripeSubscriptionService().customerHasEntitlingSubscription('cus_1', 1000)).rejects.toThrow(); + }); +}); + +describe('StripeSubscriptionService — switches', () => { + it('an unusable BILLING_PRODUCT disables billing but keeps the paywall CLOSED (fails closed, not open)', async () => { + process.env.BILLING_PRODUCT = 'gauzy teams'; + stubStripe(() => undefined); // any Stripe call fails the test + const service = new StripeSubscriptionService(); + expect(service.isBillingEnforced()).toBe(false); + expect(service.billingProduct).toBeNull(); + expect(service.isSignupPaywallEnabled()).toBe(true); + await expect(service.getEntitlement(EMAIL)).resolves.toBe(EntitlementResult.NOT_ENTITLED); + expect(fetchCalls).toEqual([]); + // ...unless the paywall is deliberately off, or there is no usable key at all. + process.env.BILLING_SIGNUP_PAYWALL = 'false'; + expect(new StripeSubscriptionService().isSignupPaywallEnabled()).toBe(false); + delete process.env.BILLING_SIGNUP_PAYWALL; + process.env.DEMO = 'true'; + expect(new StripeSubscriptionService().isSignupPaywallEnabled()).toBe(false); + await expect(new StripeSubscriptionService().getEntitlement(EMAIL)).resolves.toBe(EntitlementResult.ENTITLED); + delete process.env.DEMO; + process.env.STRIPE_SECRET_KEY = 'sk_live_fixture_without_opt_in'; + expect(new StripeSubscriptionService().isSignupPaywallEnabled()).toBe(false); + }); + + it('BILLING_SIGNUP_PAYWALL=false keeps billing on but turns the paywall off', () => { + process.env.BILLING_SIGNUP_PAYWALL = 'false'; + const service = new StripeSubscriptionService(); + expect(service.isBillingEnforced()).toBe(true); + expect(service.isSignupPaywallEnabled()).toBe(false); + }); + + it('the paywall is on by default when billing is on, and off when billing is off', () => { + expect(new StripeSubscriptionService().isSignupPaywallEnabled()).toBe(true); + delete process.env.STRIPE_SECRET_KEY; + expect(new StripeSubscriptionService().isSignupPaywallEnabled()).toBe(false); + }); +}); + +describe('StripeSubscriptionService.verifyCheckoutSession — proven identity', () => { + const sessionRoute = + (session: any, status = 200): Route => + (path) => + path.startsWith('/checkout/sessions/') ? { status, body: session } : undefined; + + it('accepts a complete Gauzy subscription session paid under the same address (any case)', async () => { + stubStripe(sessionRoute(completeSession())); + const result = await new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, 'buyer@example.TEST'); + expect(result).toEqual({ ok: true, customerId: 'cus_1' }); + expect(fetchCalls).toEqual([`/checkout/sessions/${SESSION_TEST}?expand[]=subscription`]); + }); + + it.each<[string, Record, string]>([ + ['not complete', { status: 'open' }, 'incomplete'], + ['expired session object', { status: 'expired' }, 'incomplete'], + ['payment mode (a license, a credit pack)', { mode: 'payment' }, 'foreign-product'], + ['setup mode', { mode: 'setup' }, 'foreign-product'], + ['another product', { metadata: { ever_product: 'teams' } }, 'foreign-product'], + [ + 'a Gauzy SELF-HOSTED license purchase', + { metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' }, subscription: selfHostedSub() }, + 'foreign-product' + ], + [ + 'a cloud session whose subscription is a self-hosted license', + { subscription: selfHostedSub() }, + 'not-entitled' + ], + ['no ever_product (Ever Works, GitHands)', { metadata: { kind: 'plan' } }, 'foreign-product'], + ['paid under another address', { customer_details: { email: 'someone.else@example.test' } }, 'email-mismatch'], + ['no address on the session', { customer_details: null, customer_email: null }, 'email-mismatch'], + ['older than 72 hours', { created: Math.floor(Date.now() / 1000) - 73 * 3600 }, 'expired'], + ['no customer', { customer: null }, 'no-customer'], + ['its subscription was cancelled', { subscription: gauzySub({ status: 'canceled' }) }, 'not-entitled'], + ['its subscription moved to another product', { subscription: teamsSub() }, 'not-entitled'] + ])('declines a session that is %s', async (_label, overrides, reason) => { + stubStripe(sessionRoute(completeSession(overrides))); + const result = await new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL); + expect(result).toEqual({ ok: false, reason }); + }); + + it('still accepts a session 71 hours old', async () => { + stubStripe(sessionRoute(completeSession({ created: Math.floor(Date.now() / 1000) - 71 * 3600 }))); + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL)).resolves.toEqual({ + ok: true, + customerId: 'cus_1' + }); + }); + + it('declines without calling Stripe when the id is malformed', async () => { + stubStripe(() => undefined); + const result = await new StripeSubscriptionService().verifyCheckoutSession('cs_test_../../customers', EMAIL); + expect(result).toEqual({ ok: false, reason: 'malformed' }); + expect(fetchCalls).toEqual([]); + }); + + it('declines without calling Stripe when a live session id meets a test key (and vice versa)', async () => { + stubStripe(() => undefined); + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_LIVE, EMAIL)).resolves.toEqual({ + ok: false, + reason: 'mode-mismatch' + }); + process.env.STRIPE_SECRET_KEY = 'sk_live_billing_scope_fixture'; + process.env.STRIPE_LIVE_MODE = 'true'; + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL)).resolves.toEqual({ + ok: false, + reason: 'mode-mismatch' + }); + expect(fetchCalls).toEqual([]); + }); + + it('reports a missing session as not-found and a Stripe outage as stripe-unavailable', async () => { + stubStripe(sessionRoute({ error: { message: 'No such checkout.session' } }, 404)); + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL)).resolves.toEqual({ + ok: false, + reason: 'not-found' + }); + stubStripe(sessionRoute({ error: {} }, 503)); + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL)).resolves.toEqual({ + ok: false, + reason: 'stripe-unavailable' + }); + }); + + it('on the Teams deployment a Gauzy session is foreign', async () => { + process.env.BILLING_PRODUCT = 'teams'; + stubStripe(sessionRoute(completeSession())); + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL)).resolves.toEqual({ + ok: false, + reason: 'foreign-product' + }); + }); + + it('does nothing at all when billing is off', async () => { + delete process.env.STRIPE_SECRET_KEY; + stubStripe(() => undefined); + await expect(new StripeSubscriptionService().verifyCheckoutSession(SESSION_TEST, EMAIL)).resolves.toEqual({ + ok: false, + reason: 'billing-disabled' + }); + expect(fetchCalls).toEqual([]); + }); +}); diff --git a/packages/core/src/lib/shared/billing/stripe-subscription.service.ts b/packages/core/src/lib/shared/billing/stripe-subscription.service.ts index cb672207da..2fc5e05f04 100644 --- a/packages/core/src/lib/shared/billing/stripe-subscription.service.ts +++ b/packages/core/src/lib/shared/billing/stripe-subscription.service.ts @@ -1,4 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; +import { + checkoutSessionIsForProduct, + isCheckoutSessionId, + resolveBillingProduct, + resolveSignupPaywall, + subscriptionIsForProduct +} from './billing-product'; /** * Minimal Stripe lookup used to decide whether an email is entitled to register on a hosted Ever @@ -31,10 +38,19 @@ function redactPath(path: string): string { } /** An error's message with any stray email address masked, for safe logging. */ -function describe(error: unknown): string { +export function describeError(error: unknown): string { const message = error instanceof Error ? error.message : String(error); return message.replace(/[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+/g, ''); } +const describe = describeError; + +/** A non-2xx answer from Stripe, with the status kept so callers can tell "not found" from "down". */ +class StripeHttpError extends Error { + constructor(message: string, readonly status: number) { + super(message); + this.name = 'StripeHttpError'; + } +} /** * Secret credentials that spend real money. @@ -70,6 +86,38 @@ const MAX_CUSTOMERS_EXAMINED = 20; */ const OVERALL_DEADLINE_MS = 10_000; +/** Per-request timeout for a Stripe GET, unless the caller brings a tighter budget. */ +const REQUEST_TIMEOUT_MS = 8000; + +/** + * How old a Checkout Session may be and still prove who bought it. + * + * Buyers register a median of one minute after paying (51 minutes at most in the first 62 LIVE + * purchases). The session id is a bearer credential that sits in the buyer's browser history, in the + * checkout's completion URL and in anything that records URLs, so a short window bounds how long a + * leaked one stays useful. 72 hours still covers a buyer who finishes signing up days later; anyone + * outside it is linked later by the verified-email path instead. + */ +const CHECKOUT_SESSION_MAX_AGE_SECONDS = 72 * 60 * 60; + +/** Why a Checkout Session was not accepted as proof of purchase. Logged; never shown to the user. */ +export type CheckoutSessionDeclineReason = + | 'billing-disabled' + | 'malformed' + | 'mode-mismatch' + | 'not-found' + | 'stripe-unavailable' + | 'incomplete' + | 'foreign-product' + | 'email-mismatch' + | 'expired' + | 'no-customer' + | 'not-entitled'; + +export type CheckoutSessionVerification = + | { ok: true; customerId: string } + | { ok: false; reason: CheckoutSessionDeclineReason }; + export enum EntitlementResult { /** A matching customer holds an entitling subscription. */ ENTITLED = 'entitled', @@ -114,6 +162,19 @@ export class StripeSubscriptionService { return undefined; } + // A deployment that cannot say which product it bills for must not bill at all. Falling back to + // the default would, on the Ever Teams deployment, quietly start treating Gauzy purchases as + // Teams ones — so an unusable BILLING_PRODUCT switches billing off, loudly, instead. + const { invalid } = resolveBillingProduct(); + if (invalid !== undefined) { + this.warnOnce( + 'product', + `BILLING_PRODUCT="${invalid}" is not a catalog product key (e.g. "gauzy", "teams"). Billing is disabled ` + + 'until it is corrected.' + ); + return undefined; + } + if (LIVE_KEY_PREFIX.test(key) && process.env.STRIPE_LIVE_MODE !== 'true') { this.warnOnce( 'live', @@ -169,6 +230,45 @@ export class StripeSubscriptionService { return Boolean(this.secretKey); } + /** + * The Ever product this deployment bills for: `BILLING_PRODUCT`, default `gauzy`. + * + * Every read of the shared Stripe account is scoped to it — the webhook, the signup paywall, the + * lazy tenant link and the billing pages all count only this product's subscriptions. Null when the + * variable is set to something unusable, in which case billing is disabled (see `secretKey`). + */ + get billingProduct(): string | null { + return resolveBillingProduct().product; + } + + /** + * Whether `POST /auth/register` must be backed by a subscription to this product. + * + * Billing has to be on for a paywall to mean anything, and `BILLING_SIGNUP_PAYWALL=false` turns the + * paywall off while leaving the rest of billing (linking, the billing pages) working — which is + * what the Ever Teams deployment needs. Unset keeps today's behaviour: the paywall is on. + */ + isSignupPaywallEnabled(): boolean { + if (!resolveSignupPaywall()) return false; + return this.isBillingEnforced() || this.isProductMisconfigured(); + } + + /** + * A usable Stripe key is configured on a non-demo deployment, but `BILLING_PRODUCT` is not a + * catalog key, so billing is off (see `secretKey`). + * + * The signup paywall must not fail OPEN on a typo: on app.gauzy.co that would turn a config slip + * into free, unpaid signup. So in this state the paywall stays on and nobody is entitled — signup + * is refused, loudly, until the variable is fixed, which is the same direction + * `BILLING_SIGNUP_PAYWALL` fails in. + */ + private isProductMisconfigured(): boolean { + const key = process.env.STRIPE_SECRET_KEY?.trim(); + if (!key || process.env.DEMO === 'true') return false; + if (LIVE_KEY_PREFIX.test(key) && process.env.STRIPE_LIVE_MODE !== 'true') return false; + return resolveBillingProduct().invalid !== undefined; + } + /** * Look up whether `email` holds a subscription that entitles them to register. * @@ -178,7 +278,18 @@ export class StripeSubscriptionService { * a bad minute is a far worse failure than briefly admitting someone who slipped past. */ async getEntitlement(email: string): Promise { - if (!this.secretKey) return EntitlementResult.ENTITLED; // billing off: nothing to check + if (!this.secretKey) { + // Billing off: nothing to check — unless it is off only because BILLING_PRODUCT is unusable, + // in which case the paywall fails closed (see isProductMisconfigured). + if (this.isProductMisconfigured()) { + this.logger.error( + 'Refusing a registration: BILLING_PRODUCT is not a catalog product key, so no subscription can be ' + + 'confirmed. Fix BILLING_PRODUCT (or set BILLING_SIGNUP_PAYWALL=false) to reopen signup.' + ); + return EntitlementResult.NOT_ENTITLED; + } + return EntitlementResult.ENTITLED; + } try { const customerId = await this.findEntitlingCustomerId(email); @@ -225,14 +336,15 @@ export class StripeSubscriptionService { * Returns null rather than throwing: this resolves a link, and failing to resolve one must never * escalate into failing the operation that triggered it. */ - async getCustomerEmail(customerId: string): Promise { + async getCustomerEmail(customerId: string, timeoutMs = REQUEST_TIMEOUT_MS): Promise { const key = this.secretKey; if (!key || !customerId?.trim()) return null; try { const customer = await this.request<{ email?: string | null; deleted?: boolean }>( key, - `/customers/${encodeURIComponent(customerId.trim())}` + `/customers/${encodeURIComponent(customerId.trim())}`, + timeoutMs ); // A deleted customer comes back as `{ deleted: true }` with no email. return customer?.email?.trim() || null; @@ -242,6 +354,102 @@ export class StripeSubscriptionService { } } + /** + * Whether this customer holds an entitling (active, trialing or past_due) subscription to THIS + * deployment's product. + * + * The webhook asks this before it links anything, so a customer that only ever bought another Ever + * product, or only made a one-off payment or saved a card, is never adopted. Throws when Stripe + * cannot answer: the caller must treat that as "do not link", never as "no". + */ + async customerHasEntitlingSubscription(customerId: string, timeoutMs = REQUEST_TIMEOUT_MS): Promise { + const key = this.secretKey; + if (!key) throw new Error('Billing is not configured on this deployment.'); + if (!customerId?.trim()) return false; + return this.hasEntitlingSubscription(key, customerId.trim(), Date.now() + timeoutMs, timeoutMs); + } + + /** + * Check that a Checkout Session proves `email` bought THIS deployment's product, and return the + * Stripe customer it created. + * + * This is the proven-identity link. The ever.co checkout forwards the session id to the register + * form (`checkout_session`), and only the browser that completed the checkout has it. Every rule + * below must hold, or the session is declined and the caller falls back to the slower paths: + * + * - it is a session of this deployment's Stripe mode (a `cs_live_` id is never looked up with a + * test key, or the reverse); + * - Stripe says it is `complete`, in `subscription` mode, for `metadata.ever_product` = this + * product with no non-cloud `ever_hosting` — never a payment-mode license, a setup-mode card + * save, a self-hosted license subscription or another product; + * - the address the buyer gave Stripe equals `email` (case-insensitive), so a session id cannot be + * replayed onto somebody else's registration; + * - it is recent (CHECKOUT_SESSION_MAX_AGE_SECONDS) and has a customer; + * - its subscription is still entitling and still on this product's price. + * + * Never throws; a Stripe failure is the `stripe-unavailable` decline. + */ + async verifyCheckoutSession( + sessionId: string, + email: string, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { + const key = this.secretKey; + const product = this.billingProduct; + if (!key || !product) return { ok: false, reason: 'billing-disabled' }; + if (!isCheckoutSessionId(sessionId)) return { ok: false, reason: 'malformed' }; + + const expectLive = sessionId.startsWith('cs_live_'); + if (expectLive !== LIVE_KEY_PREFIX.test(key)) return { ok: false, reason: 'mode-mismatch' }; + + const typed = typeof email === 'string' ? email.trim().toLowerCase() : ''; + if (!typed) return { ok: false, reason: 'email-mismatch' }; + + let session: StripeCheckoutSessionObject; + try { + session = await this.request( + key, + `/checkout/sessions/${encodeURIComponent(sessionId)}?expand[]=subscription`, + timeoutMs + ); + } catch (error) { + if (error instanceof StripeHttpError && error.status === 404) return { ok: false, reason: 'not-found' }; + this.logger.error(`Could not retrieve a Checkout Session to verify a purchase. ${describe(error)}`); + return { ok: false, reason: 'stripe-unavailable' }; + } + + if (session?.status !== 'complete') return { ok: false, reason: 'incomplete' }; + // The webhook's predicate: this product, subscription mode, and a hosted (not self-hosted) plan. + if (!checkoutSessionIsForProduct(session, product)) { + return { ok: false, reason: 'foreign-product' }; + } + + const buyerEmail = (session.customer_details?.email ?? session.customer_email ?? '').trim().toLowerCase(); + if (!buyerEmail || buyerEmail !== typed) return { ok: false, reason: 'email-mismatch' }; + + const ageSeconds = Date.now() / 1000 - Number(session.created ?? 0); + if (!Number.isFinite(ageSeconds) || ageSeconds > CHECKOUT_SESSION_MAX_AGE_SECONDS) { + return { ok: false, reason: 'expired' }; + } + + const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id; + if (!customerId) return { ok: false, reason: 'no-customer' }; + + try { + const subscription = session.subscription; + const entitled = + subscription && typeof subscription === 'object' + ? ENTITLING_STATUSES.has(subscription.status ?? '') && subscriptionIsForProduct(subscription, product) + : await this.customerHasEntitlingSubscription(customerId, timeoutMs); + if (!entitled) return { ok: false, reason: 'not-entitled' }; + } catch (error) { + this.logger.error(`Could not confirm the subscription behind a Checkout Session. ${describe(error)}`); + return { ok: false, reason: 'stripe-unavailable' }; + } + + return { ok: true, customerId }; + } + /** * Shared lookup: the first customer sharing this email that holds an entitling subscription. * @@ -295,14 +503,30 @@ export class StripeSubscriptionService { return null; } - /** Whether this customer holds any subscription in an entitling status. */ - private async hasEntitlingSubscription(key: string, customerId: string, deadline: number): Promise { - for await (const subscription of this.paginate<{ id: string; status: string }>( + /** + * Whether this customer holds a subscription to THIS deployment's product in an entitling status. + * + * The product half is what stops a Teams, Platform, Works or GitHands subscription on the shared + * account from passing the Gauzy paywall or being linked to a Gauzy tenant. + */ + private async hasEntitlingSubscription( + key: string, + customerId: string, + deadline: number, + timeoutMs = REQUEST_TIMEOUT_MS + ): Promise { + const product = this.billingProduct; + if (!product) return false; + + for await (const subscription of this.paginate( key, `/subscriptions?customer=${encodeURIComponent(customerId)}&status=all`, - deadline + deadline, + timeoutMs )) { - if (ENTITLING_STATUSES.has(subscription.status)) return true; + if (ENTITLING_STATUSES.has(subscription.status) && subscriptionIsForProduct(subscription, product)) { + return true; + } } return false; } @@ -321,7 +545,8 @@ export class StripeSubscriptionService { private async *paginate( key: string, path: string, - deadline: number + deadline: number, + timeoutMs = REQUEST_TIMEOUT_MS ): AsyncGenerator { let startingAfter: string | undefined; const separator = path.includes('?') ? '&' : '?'; @@ -333,7 +558,8 @@ export class StripeSubscriptionService { const page = await this.request<{ data: T[]; has_more?: boolean }>( key, - `${path}${separator}limit=${PAGE_SIZE}${startingAfter ? `&starting_after=${startingAfter}` : ''}` + `${path}${separator}limit=${PAGE_SIZE}${startingAfter ? `&starting_after=${startingAfter}` : ''}`, + Math.max(1, Math.min(timeoutMs, deadline - Date.now())) ); const rows = page.data ?? []; @@ -348,9 +574,9 @@ export class StripeSubscriptionService { /** * GET a Stripe endpoint, with a short timeout so a hanging call cannot stall registration. */ - private async request(key: string, path: string): Promise { + private async request(key: string, path: string, timeoutMs = REQUEST_TIMEOUT_MS): Promise { const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 8000); + const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${STRIPE_API}${path}`, { headers: { @@ -365,7 +591,7 @@ export class StripeSubscriptionService { // `?email=`, and Stripe echoes the offending parameters back in its error // message. Logging either would put an address someone typed into a signup form into // the application log. The endpoint and status are enough to debug with. - throw new Error(`Stripe GET ${redactPath(path)} -> ${response.status}`); + throw new StripeHttpError(`Stripe GET ${redactPath(path)} -> ${response.status}`, response.status); } return (await response.json()) as T; @@ -374,3 +600,24 @@ export class StripeSubscriptionService { } } } + +/* Minimal shapes for the Stripe payloads read above. */ + +interface StripeSubscriptionListItem { + id: string; + status: string; + metadata?: Record | null; + items?: { data?: Array<{ price?: { lookup_key?: string | null } | null }> } | null; +} + +interface StripeCheckoutSessionObject { + id?: string; + status?: string | null; + mode?: string | null; + created?: number; + customer?: string | { id?: string } | null; + customer_email?: string | null; + customer_details?: { email?: string | null } | null; + metadata?: Record | null; + subscription?: string | (Partial & { status?: string }) | null; +} diff --git a/packages/core/src/lib/shared/billing/stripe-webhook.controller.spec.ts b/packages/core/src/lib/shared/billing/stripe-webhook.controller.spec.ts new file mode 100644 index 0000000000..e852bf90c0 --- /dev/null +++ b/packages/core/src/lib/shared/billing/stripe-webhook.controller.spec.ts @@ -0,0 +1,771 @@ +// cspell:ignore whsec payg selfhosted +/** + * 🛑 This import must stay FIRST, before any import that pulls a repository — the entity graph has to + * finish initializing before the TypeORM repositories the controller imports are evaluated. + */ +import '../../core/entities/internal'; +import { ForbiddenException, Logger } from '@nestjs/common'; +import { createHmac } from 'crypto'; +import { IsNull } from 'typeorm'; +import { StripeWebhookController } from './stripe-webhook.controller'; +import { StripeSubscriptionService } from './stripe-subscription.service'; + +/** + * The Stripe webhook is registered on an account that EVERY Ever product sells through, so most of + * what reaches it is somebody else's purchase. These tests drive the real controller with payloads + * signed by Stripe's real scheme (`t=,v1=HMAC-SHA256("." + body)`) and assert what it + * decided, what it logged, and — the part that matters — that nothing except an administrator's + * purchase of this deployment's product ever writes `tenant.stripeCustomerId`. + * + * No database and no network: the repositories are in-memory doubles that record every call, and + * `fetch` (the only way the service reaches Stripe) is replaced by a router over fixture objects that + * fails the test on any request it was not told about. + */ + +const WEBHOOK_SECRET = 'whsec_test_billing_scope_fixture_secret'; +const TENANT_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_TENANT_ID = '22222222-2222-4222-8222-222222222222'; +const USER_ID = '33333333-3333-4333-8333-333333333333'; +const BUYER_EMAIL = 'Owner@Acme.test'; + +/* ------------------------------------------------------------------ fixtures */ + +function session(overrides: Record = {}): Record { + return { + id: 'cs_live_a1FixtureSessionForBillingScopeTests000000000000000000000', + object: 'checkout.session', + mode: 'subscription', + status: 'complete', + customer: 'cus_Buyer0000000001', + customer_email: null, + customer_details: { email: BUYER_EMAIL }, + subscription: 'sub_Buyer0000000001', + client_reference_id: null, + metadata: { + ever_product: 'gauzy', + ever_hosting: 'cloud', + ever_tier: 'starter', + ever_interval: 'annual', + ever_lookup_key: 'ever_gauzy_cloud_starter_annual' + }, + ...overrides + }; +} + +function subscription(overrides: Record = {}): Record { + return { + id: 'sub_Buyer0000000001', + object: 'subscription', + customer: 'cus_Buyer0000000001', + status: 'trialing', + metadata: { ever_product: 'gauzy' }, + items: { data: [{ id: 'si_1', price: { id: 'price_1', lookup_key: 'ever_gauzy_cloud_starter_annual' } }] }, + ...overrides + }; +} + +function event(type: string, object: Record, id = 'evt_fixture_0000000001'): Record { + return { id, object: 'event', type, api_version: '2020-08-27', data: { object } }; +} + +/** Sign exactly as Stripe does. */ +function signed( + body: Record | string, + secret = WEBHOOK_SECRET, + timestamp = Math.floor(Date.now() / 1000) +) { + const raw = typeof body === 'string' ? body : JSON.stringify(body); + const v1 = createHmac('sha256', secret).update(`${timestamp}.${raw}`).digest('hex'); + return { headers: { 'stripe-signature': `t=${timestamp},v1=${v1}` }, rawBody: Buffer.from(raw, 'utf8') }; +} + +/* ------------------------------------------------------------------ doubles */ + +interface Harness { + controller: StripeWebhookController; + users: any[]; + claimedBy: { id: string } | null; + updateAffected: number; + userQueries: Array>; + tenantFindOne: jest.Mock; + tenantUpdate: jest.Mock; + createQueryBuilder: jest.Mock; + stripe: { + customers: Record; + subscriptions: Record; + failSubscriptions?: boolean; + }; + fetchCalls: string[]; + logLines: string[]; + decisions: () => Array>; +} + +function buildHarness(): Harness { + const h = { + users: [] as any[], + claimedBy: null as { id: string } | null, + updateAffected: 1, + userQueries: [] as Array>, + stripe: { + customers: {} as Record, + subscriptions: {} as Record, + failSubscriptions: false + }, + fetchCalls: [] as string[], + logLines: [] as string[] + } as Partial as Harness; + + const queryBuilder: any = {}; + for (const method of ['leftJoin', 'select', 'andWhere', 'limit']) { + queryBuilder[method] = jest.fn(() => queryBuilder); + } + queryBuilder.where = jest.fn((_sql: string, params: Record) => { + h.userQueries.push(params); + return queryBuilder; + }); + queryBuilder.getMany = jest.fn(async () => h.users); + + h.createQueryBuilder = jest.fn(() => queryBuilder); + h.tenantFindOne = jest.fn(async () => h.claimedBy); + h.tenantUpdate = jest.fn(async () => ({ affected: h.updateAffected })); + + const userRepository: any = { createQueryBuilder: h.createQueryBuilder }; + const tenantRepository: any = { findOne: h.tenantFindOne, update: h.tenantUpdate }; + + h.controller = new StripeWebhookController(new StripeSubscriptionService(), tenantRepository, userRepository); + + (global as any).fetch = jest.fn(async (input: string) => { + const url = String(input); + h.fetchCalls.push(url); + const path = url.replace('https://api.stripe.com/v1', ''); + + const customer = /^\/customers\/([^/?]+)$/.exec(path); + if (customer) { + const found = h.stripe.customers[decodeURIComponent(customer[1])]; + return jsonResponse( + found ? { id: customer[1], ...found } : { error: { message: 'No such customer' } }, + found ? 200 : 404 + ); + } + + const subs = /^\/subscriptions\?customer=([^&]+)&status=all/.exec(path); + if (subs) { + if (h.stripe.failSubscriptions) return jsonResponse({ error: { message: 'boom' } }, 500); + return jsonResponse({ data: h.stripe.subscriptions[decodeURIComponent(subs[1])] ?? [], has_more: false }); + } + + throw new Error(`Unexpected Stripe request in test: ${path}`); + }); + + jest.spyOn(Logger.prototype, 'log').mockImplementation(function (message: any) { + h.logLines.push(String(message)); + }); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(function (message: any) { + h.logLines.push(String(message)); + }); + jest.spyOn(Logger.prototype, 'error').mockImplementation(function (message: any) { + h.logLines.push(String(message)); + }); + + h.decisions = () => + h.logLines + .filter((line) => line.startsWith('stripe-webhook ')) + .map((line) => JSON.parse(line.slice('stripe-webhook '.length))); + + return h; +} + +function jsonResponse(body: any, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +function admin(overrides: Record = {}) { + return { + id: USER_ID, + tenantId: TENANT_ID, + emailVerifiedAt: new Date('2026-09-01T00:00:00Z'), + role: { id: 'role-1', name: 'SUPER_ADMIN' }, + ...overrides + }; +} + +/* ------------------------------------------------------------------ environment */ + +const ENV_KEYS = [ + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'STRIPE_LIVE_MODE', + 'DEMO', + 'BILLING_PRODUCT', + 'BILLING_SIGNUP_PAYWALL', + 'BILLING_WEBHOOK_LINKING' +]; +const savedEnv: Record = {}; +const realFetch = (global as any).fetch; + +beforeEach(() => { + for (const key of ENV_KEYS) savedEnv[key] = process.env[key]; + process.env.STRIPE_SECRET_KEY = 'sk_test_billing_scope_fixture'; + process.env.STRIPE_WEBHOOK_SECRET = WEBHOOK_SECRET; + delete process.env.STRIPE_LIVE_MODE; + delete process.env.DEMO; + delete process.env.BILLING_PRODUCT; + delete process.env.BILLING_SIGNUP_PAYWALL; + // Writing is off by default (see the "BILLING_WEBHOOK_LINKING unset" block). Everywhere else it is + // ON, so every "must not link" case proves it writes nothing even when writing is allowed. + process.env.BILLING_WEBHOOK_LINKING = 'true'; +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + (global as any).fetch = realFetch; + jest.restoreAllMocks(); +}); + +/* ------------------------------------------------------------------ tests */ + +describe('StripeWebhookController — signature', () => { + it('rejects an unsigned request', async () => { + const h = buildHarness(); + await expect(h.controller.handle({ headers: {}, rawBody: Buffer.from('{}') } as any)).rejects.toBeInstanceOf( + ForbiddenException + ); + expect(h.createQueryBuilder).not.toHaveBeenCalled(); + }); + + it('rejects a payload signed with the wrong secret', async () => { + const h = buildHarness(); + const request = signed(event('checkout.session.completed', session()), 'whsec_someone_else'); + await expect(h.controller.handle(request as any)).rejects.toThrow('Invalid Stripe signature.'); + expect(h.createQueryBuilder).not.toHaveBeenCalled(); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + }); + + it('rejects a correctly signed payload older than five minutes (replay)', async () => { + const h = buildHarness(); + const stale = Math.floor(Date.now() / 1000) - 3600; + const request = signed(event('checkout.session.completed', session()), WEBHOOK_SECRET, stale); + await expect(h.controller.handle(request as any)).rejects.toThrow('Invalid Stripe signature.'); + }); + + it('rejects a signed body that is JSON null instead of throwing a 500', async () => { + const h = buildHarness(); + await expect(h.controller.handle(signed('null') as any)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('refuses everything when billing is not configured', async () => { + const h = buildHarness(); + delete process.env.STRIPE_SECRET_KEY; + await expect( + h.controller.handle(signed(event('checkout.session.completed', session())) as any) + ).rejects.toThrow('Billing webhooks are not enabled on this deployment.'); + }); +}); + +describe('StripeWebhookController — foreign events are acknowledged with no DB or Stripe access', () => { + const foreign: Array<[string, string, Record]> = [ + ['ever.co Teams session', 'checkout.session.completed', session({ metadata: { ever_product: 'teams' } })], + ['ever.co Platform session', 'checkout.session.completed', session({ metadata: { ever_product: 'platform' } })], + ['ever.co Works session', 'checkout.session.completed', session({ metadata: { ever_product: 'works' } })], + ['ever.co Rec session', 'checkout.session.completed', session({ metadata: { ever_product: 'rec' } })], + ['ever.co Demand session', 'checkout.session.completed', session({ metadata: { ever_product: 'demand' } })], + [ + 'Ever Works own plan checkout (metadata.kind, no ever_product)', + 'checkout.session.completed', + session({ customer: 'cus_Works', metadata: { kind: 'plan-subscription', organizationId: 'org-1' } }) + ], + [ + 'Ever Works credit pack (payment mode)', + 'checkout.session.completed', + session({ mode: 'payment', subscription: null, customer: 'cus_Works', metadata: { kind: 'credit-pack' } }) + ], + [ + 'Ever Works card save (setup mode)', + 'checkout.session.completed', + session({ mode: 'setup', subscription: null, customer: 'cus_Works', metadata: { kind: 'setup' } }) + ], + [ + 'Ever Works PAYG subscription (metadata.kind only)', + 'customer.subscription.created', + subscription({ + customer: 'cus_Works', + status: 'active', + metadata: { kind: 'payg-subscription' }, + items: { data: [{ id: 'si_w', price: { id: 'price_w', lookup_key: null } }] } + }) + ], + [ + 'ever.co lifetime license (payment mode, customer=null)', + 'checkout.session.completed', + session({ + mode: 'payment', + customer: null, + subscription: null, + metadata: { + ever_product: 'gauzy', + ever_hosting: 'selfhosted', + ever_tier: 'small_business', + ever_interval: 'lifetime' + } + }) + ], + [ + 'Gauzy-branded session in payment mode (a gauzy product, but not a hosted plan)', + 'checkout.session.completed', + session({ mode: 'payment', metadata: { ever_product: 'gauzy' } }) + ], + [ + 'GitHands subscription checkout (client_reference_id, no ever_product)', + 'checkout.session.completed', + session({ customer: 'cus_GitHands', client_reference_id: USER_ID, metadata: { plan: 'pro' } }) + ], + [ + 'directory-site sponsor ad checkout', + 'checkout.session.completed', + session({ customer: 'cus_Directory', metadata: { type: 'sponsor_ad', itemSlug: 'x' } }) + ], + [ + 'Teams subscription with no metadata (Dashboard-made) on an ever_teams_ price', + 'customer.subscription.created', + subscription({ + metadata: {}, + items: { + data: [{ id: 'si_t', price: { id: 'price_t', lookup_key: 'ever_teams_cloud_starter_monthly' } }] + } + }) + ], + [ + 'ever.co Gauzy SELF-HOSTED license session (subscription mode, ever_hosting=selfhosted)', + 'checkout.session.completed', + session({ + metadata: { + ever_product: 'gauzy', + ever_hosting: 'selfhosted', + ever_tier: 'enterprise', + ever_interval: 'annual', + ever_lookup_key: 'ever_gauzy_selfhosted_enterprise_annual' + } + }) + ], + [ + 'Gauzy self-hosted license subscription (ever_hosting=selfhosted)', + 'customer.subscription.created', + subscription({ + status: 'active', + metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' }, + items: { + data: [ + { id: 'si_s', price: { id: 'price_s', lookup_key: 'ever_gauzy_selfhosted_enterprise_annual' } } + ] + } + }) + ], + [ + 'Gauzy self-hosted license subscription with no metadata (Dashboard-made) on an ever_gauzy_selfhosted_ price', + 'customer.subscription.created', + subscription({ + status: 'active', + metadata: {}, + items: { + data: [ + { + id: 'si_s', + price: { id: 'price_s', lookup_key: 'ever_gauzy_selfhosted_small_business_monthly' } + } + ] + } + }) + ], + [ + 'ever_product=gauzy with no ever_hosting but a self-hosted plan price', + 'customer.subscription.created', + subscription({ + status: 'active', + metadata: { ever_product: 'gauzy' }, + items: { + data: [ + { id: 'si_s', price: { id: 'price_s', lookup_key: 'ever_gauzy_selfhosted_enterprise_monthly' } } + ] + } + }) + ], + [ + 'Gauzy-stamped session whose lookup key is a self-hosted price', + 'checkout.session.completed', + session({ + metadata: { + ever_product: 'gauzy', + ever_hosting: 'cloud', + ever_lookup_key: 'ever_gauzy_selfhosted_enterprise_annual' + } + }) + ], + [ + 'ever_product=gauzy metadata on a Teams price', + 'customer.subscription.created', + subscription({ + status: 'active', + metadata: { ever_product: 'gauzy' }, + items: { + data: [{ id: 'si_t', price: { id: 'price_t', lookup_key: 'ever_teams_cloud_starter_monthly' } }] + } + }) + ], + [ + 'subscription with neither metadata nor a lookup key', + 'customer.subscription.created', + subscription({ + metadata: {}, + items: { data: [{ id: 'si_x', price: { id: 'price_x', lookup_key: null } }] } + }) + ] + ]; + + it.each(foreign)('%s', async (_label, type, object) => { + const h = buildHarness(); + // Even with a perfect admin match waiting, a foreign event must not get as far as looking. + h.users = [admin()]; + + const result = await h.controller.handle(signed(event(type, object)) as any); + + expect(result).toEqual({ received: true }); + expect(h.createQueryBuilder).not.toHaveBeenCalled(); + expect(h.tenantFindOne).not.toHaveBeenCalled(); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.fetchCalls).toEqual([]); + expect(h.decisions()).toEqual([ + expect.objectContaining({ decision: 'skipped-foreign', product: 'gauzy', type }) + ]); + }); + + it('logs no email address, only ids', async () => { + const h = buildHarness(); + await h.controller.handle( + signed(event('checkout.session.completed', session({ metadata: { ever_product: 'teams' } }))) as any + ); + expect(h.logLines.join('\n')).not.toMatch(/@/); + expect(h.decisions()[0]).toMatchObject({ event: 'evt_fixture_0000000001', eventProduct: 'teams' }); + }); + + it('ignores non-linking events without logging or touching anything', async () => { + const h = buildHarness(); + await h.controller.handle(signed(event('customer.subscription.updated', subscription())) as any); + expect(h.decisions()).toEqual([]); + expect(h.createQueryBuilder).not.toHaveBeenCalled(); + expect(h.fetchCalls).toEqual([]); + }); +}); + +describe('StripeWebhookController — Gauzy events that must NOT link', () => { + const gauzyEvent = () => signed(event('checkout.session.completed', session())); + + beforeEach(() => undefined); + + it('no user has the address yet (buy-then-register) → no-user', async () => { + const h = buildHarness(); + h.users = []; + await h.controller.handle(gauzyEvent() as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([ + expect.objectContaining({ decision: 'no-user', customer: 'cus_Buyer0000000001' }) + ]); + // The lookup is case-insensitive on our side, and the address never reaches the log. + expect(h.userQueries).toEqual([{ email: BUYER_EMAIL.toLowerCase() }]); + }); + + it('the address exists in two tenants → multi', async () => { + const h = buildHarness(); + h.users = [admin(), admin({ id: 'other-user', tenantId: OTHER_TENANT_ID })]; + await h.controller.handle(gauzyEvent() as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'multi' })]); + }); + + it.each([ + ['EMPLOYEE (invite acceptance auto-verifies)', 'EMPLOYEE'], + ['MANAGER', 'MANAGER'], + ['client contact (VIEWER via /invite/contact)', 'VIEWER'], + ['CANDIDATE', 'CANDIDATE'], + ['DATA_ENTRY', 'DATA_ENTRY'], + ['user with no role', undefined] + ])('a verified %s → non-admin, the employer tenant is never bound', async (_label, roleName) => { + const h = buildHarness(); + h.users = [admin({ role: roleName ? { id: 'r', name: roleName } : null })]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + await h.controller.handle(gauzyEvent() as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.tenantFindOne).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'non-admin', tenant: TENANT_ID })]); + }); + + it('an unverified SUPER_ADMIN → unverified', async () => { + const h = buildHarness(); + h.users = [admin({ emailVerifiedAt: null })]; + await h.controller.handle(gauzyEvent() as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'unverified' })]); + }); + + it('the customer already belongs to another tenant → claimed', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.claimedBy = { id: OTHER_TENANT_ID }; + await h.controller.handle(gauzyEvent() as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([ + expect.objectContaining({ decision: 'claimed', detail: `held-by:${OTHER_TENANT_ID}` }) + ]); + }); + + it('the customer holds no entitling Gauzy subscription (only Teams, or cancelled) → not-entitled', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [ + subscription({ + metadata: { ever_product: 'teams' }, + items: { data: [{ price: { lookup_key: 'ever_teams_cloud_starter_monthly' } }] } + }), + subscription({ status: 'canceled' }), + subscription({ status: 'incomplete_expired' }) + ]; + await h.controller.handle(gauzyEvent() as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'not-entitled' })]); + }); + + it('the customer holds only a Gauzy SELF-HOSTED license subscription → not-entitled', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [ + subscription({ + status: 'active', + metadata: { ever_product: 'gauzy', ever_hosting: 'selfhosted' }, + items: { data: [{ price: { lookup_key: 'ever_gauzy_selfhosted_enterprise_annual' } }] } + }) + ]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'not-entitled' })]); + }); + + it('Stripe cannot confirm the subscription → stripe-unavailable, nothing written, still 200', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.failSubscriptions = true; + const result = await h.controller.handle(gauzyEvent() as any); + expect(result).toEqual({ received: true }); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'stripe-unavailable' })]); + }); + + it('a database error is acknowledged, logged once as error, and writes nothing', async () => { + const h = buildHarness(); + h.createQueryBuilder.mockImplementation(() => { + throw new Error(`connection terminated while looking up ${BUYER_EMAIL}`); + }); + const result = await h.controller.handle(gauzyEvent() as any); + expect(result).toEqual({ received: true }); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'error' })]); + expect(h.logLines.join('\n')).not.toContain(BUYER_EMAIL); + }); +}); + +describe('StripeWebhookController — the one case that links', () => { + it('a verified SUPER_ADMIN buying Gauzy links their own tenant, once, only where empty', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + + const result = await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + + expect(result).toEqual({ received: true }); + expect(h.tenantUpdate).toHaveBeenCalledTimes(1); + expect(h.tenantUpdate).toHaveBeenCalledWith( + { id: TENANT_ID, stripeCustomerId: IsNull() }, + { stripeCustomerId: 'cus_Buyer0000000001' } + ); + expect(h.decisions()).toEqual([ + expect.objectContaining({ + decision: 'linked', + tenant: TENANT_ID, + customer: 'cus_Buyer0000000001', + product: 'gauzy' + }) + ]); + }); + + it('a verified ADMIN also qualifies', async () => { + const h = buildHarness(); + h.users = [admin({ role: { id: 'r', name: 'ADMIN' } })]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription({ status: 'active' })]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + expect(h.tenantUpdate).toHaveBeenCalledTimes(1); + expect(h.decisions()[0].decision).toBe('linked'); + }); + + it('a Dashboard-made subscription with no metadata but an ever_gauzy_cloud_ price links (email from Stripe)', async () => { + const h = buildHarness(); + h.users = [admin()]; + const sub = subscription({ metadata: {}, status: 'active' }); + h.stripe.customers['cus_Buyer0000000001'] = { email: BUYER_EMAIL }; + h.stripe.subscriptions['cus_Buyer0000000001'] = [sub]; + + await h.controller.handle(signed(event('customer.subscription.created', sub)) as any); + + expect(h.tenantUpdate).toHaveBeenCalledTimes(1); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'linked', eventProduct: 'gauzy' })]); + expect(h.fetchCalls.some((url) => url.endsWith('/customers/cus_Buyer0000000001'))).toBe(true); + }); + + it('a tenant that already has a customer is left alone → already-linked', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.updateAffected = 0; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + // The conditional UPDATE ran (it is what refuses to repoint) and changed nothing. + expect(h.tenantUpdate).toHaveBeenCalledWith( + { id: TENANT_ID, stripeCustomerId: IsNull() }, + { stripeCustomerId: 'cus_Buyer0000000001' } + ); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'already-linked' })]); + }); +}); + +describe('StripeWebhookController — BILLING_WEBHOOK_LINKING unset (the default): checks run, nothing is written', () => { + beforeEach(() => { + delete process.env.BILLING_WEBHOOK_LINKING; + }); + + it('a purchase that passes every check → would-link, 0 tenant writes', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + + const result = await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + + expect(result).toEqual({ received: true }); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([ + expect.objectContaining({ decision: 'would-link', tenant: TENANT_ID, customer: 'cus_Buyer0000000001' }) + ]); + }); + + it('a $0 Starter somebody else started under a verified SUPER_ADMIN address does not bind that tenant', async () => { + // gauzy-code-02: nothing in the event tells the owner's purchase from one made in their name. + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Attacker000001'] = [ + subscription({ id: 'sub_Attacker000001', customer: 'cus_Attacker000001', status: 'active' }) + ]; + await h.controller.handle( + signed( + event( + 'checkout.session.completed', + session({ customer: 'cus_Attacker000001', subscription: 'sub_Attacker000001' }) + ) + ) as any + ); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'would-link' })]); + }); + + it('an existing admin who buys, then registers a NEW account, keeps the OLD tenant unlinked', async () => { + // The event lands before the new registration. Writing here would bind the OLD tenant and leave + // the new one's Checkout-Session link refused as "claimed"; with writing off, the customer stays + // free for onboarding (tenant.service.billing-link.spec covers that side). + const h = buildHarness(); + h.users = [admin({ tenantId: OTHER_TENANT_ID })]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'would-link', tenant: OTHER_TENANT_ID })]); + }); + + it.each(['false', '0', 'no', 'off', '', 'enabled', 'TRUE-ish'])( + 'BILLING_WEBHOOK_LINKING=%p does not enable writing', + async (value) => { + process.env.BILLING_WEBHOOK_LINKING = value; + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()[0].decision).toBe('would-link'); + } + ); + + it.each(['true', '1', 'yes', 'on', ' TRUE '])('BILLING_WEBHOOK_LINKING=%p enables writing', async (value) => { + process.env.BILLING_WEBHOOK_LINKING = value; + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + expect(h.tenantUpdate).toHaveBeenCalledTimes(1); + expect(h.decisions()[0].decision).toBe('linked'); + }); +}); + +describe('StripeWebhookController — BILLING_PRODUCT=teams (the Ever Teams deployment)', () => { + beforeEach(() => { + process.env.BILLING_PRODUCT = 'teams'; + }); + + it('links a Teams purchase by a Teams admin', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [ + subscription({ + metadata: { ever_product: 'teams' }, + items: { data: [{ price: { lookup_key: 'ever_teams_cloud_starter_monthly' } }] } + }) + ]; + await h.controller.handle( + signed(event('checkout.session.completed', session({ metadata: { ever_product: 'teams' } }))) as any + ); + expect(h.tenantUpdate).toHaveBeenCalledTimes(1); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'linked', product: 'teams' })]); + }); + + it('skips a Gauzy purchase', async () => { + const h = buildHarness(); + h.users = [admin()]; + await h.controller.handle(signed(event('checkout.session.completed', session())) as any); + expect(h.createQueryBuilder).not.toHaveBeenCalled(); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([ + expect.objectContaining({ decision: 'skipped-foreign', product: 'teams', eventProduct: 'gauzy' }) + ]); + }); + + it('a Teams customer whose only live subscription is Gauzy is not entitled here', async () => { + const h = buildHarness(); + h.users = [admin()]; + h.stripe.subscriptions['cus_Buyer0000000001'] = [subscription()]; + await h.controller.handle( + signed(event('checkout.session.completed', session({ metadata: { ever_product: 'teams' } }))) as any + ); + expect(h.tenantUpdate).not.toHaveBeenCalled(); + expect(h.decisions()).toEqual([expect.objectContaining({ decision: 'not-entitled' })]); + }); +}); + +describe('StripeWebhookController — an unusable BILLING_PRODUCT', () => { + it('disables billing (403) rather than falling back to gauzy', async () => { + const h = buildHarness(); + process.env.BILLING_PRODUCT = 'ever teams!'; + await expect( + h.controller.handle(signed(event('checkout.session.completed', session())) as any) + ).rejects.toThrow('Billing webhooks are not enabled on this deployment.'); + expect(h.createQueryBuilder).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/lib/shared/billing/stripe-webhook.controller.ts b/packages/core/src/lib/shared/billing/stripe-webhook.controller.ts index 379152c6b5..9814a93c08 100644 --- a/packages/core/src/lib/shared/billing/stripe-webhook.controller.ts +++ b/packages/core/src/lib/shared/billing/stripe-webhook.controller.ts @@ -3,9 +3,17 @@ import { ApiExcludeController } from '@nestjs/swagger'; import { createHmac, timingSafeEqual } from 'crypto'; import { IsNull } from 'typeorm'; import { Public } from '@gauzy/common'; +import { RolesEnum } from '@gauzy/contracts'; import { TypeOrmTenantRepository } from '../../tenant/repository/type-orm-tenant.repository'; import { TypeOrmUserRepository } from '../../user/repository/type-orm-user.repository'; -import { StripeSubscriptionService } from './stripe-subscription.service'; +import { + ProductScopedSubscription, + checkoutSessionIsForProduct, + describeProduct, + resolveWebhookLinking, + subscriptionIsForProduct +} from './billing-product'; +import { StripeSubscriptionService, describeError } from './stripe-subscription.service'; /** * Stripe webhook receiver. @@ -18,6 +26,22 @@ import { StripeSubscriptionService } from './stripe-subscription.service'; * Unsigned or unverifiable payloads are rejected. A webhook endpoint that trusts its body is an * unauthenticated write into the billing state of every tenant, so the signature check is not * optional and there is no bypass for local development. + * + * The endpoint is registered on a Stripe account that EVERY Ever product sells through, so most of + * what arrives here is somebody else's: Teams, Platform, Rec, Works and directory-site purchases, + * lifetime licenses, card saves, GitHands. Nothing is linked unless the event is provably a purchase + * of this deployment's product (`BILLING_PRODUCT`), the matched user is an administrator of the + * tenant it would link, and the customer holds an entitling subscription to that product. Every + * linking-type event produces exactly one structured log line saying what was decided and why. + * + * Even then the tenant is only WRITTEN when `BILLING_WEBHOOK_LINKING` is on, which it is not by + * default. All the webhook has to go on is the address typed at checkout, and a free Starter can be + * started under anybody's address: a verified admin cannot be told apart from somebody buying in + * their name. And for an existing admin who buys and then registers the NEW account the checkout + * sends them to, the event lands a minute before that registration and would bind their OLD tenant, + * leaving the tenant they paid for impossible to link. With the flag off the decision is logged as + * `would-link` and the link is made by the buyer's own Checkout Session at onboarding, or by an + * admin opening Settings > Billing. */ @ApiExcludeController() @Controller('/billing/webhook') @@ -58,6 +82,9 @@ export class StripeWebhookController { } catch { throw new ForbiddenException('Malformed webhook payload.'); } + if (!event || typeof event !== 'object') { + throw new ForbiddenException('Malformed webhook payload.'); + } // Always 200 once the signature is good — which means the handler's own failures must be // swallowed here, not propagated. Stripe retries on any non-2xx, so a bug in apply() would @@ -66,18 +93,15 @@ export class StripeWebhookController { try { await this.apply(event); } catch (error) { - this.logger.error( - `Failed to apply Stripe webhook ${event.type}; acknowledging anyway. ${ - error instanceof Error ? error.message : error - }` - ); + this.logger.error(`Failed to apply Stripe webhook ${event.type}; acknowledging anyway. ${describeError(error)}`); } return { received: true }; } /** - * React to the handful of events that can change which customer a tenant bills through. + * React to the handful of events that can change which customer a tenant bills through, and log + * exactly one decision line for each of them. * * Everything else — status transitions, invoice payments — is read live by the billing pages, so * mirroring it into our database would only create a second copy to keep in sync. @@ -86,51 +110,109 @@ export class StripeWebhookController { if (!LINKING_EVENTS.has(event.type)) return; const object = event.data?.object ?? {}; + const outcome: LinkOutcome = { decision: 'error' }; + try { + await this.decide(event.type, object, outcome); + } catch (error) { + outcome.decision = 'error'; + outcome.detail = describeError(error).slice(0, 200); + throw error; + } finally { + this.logOutcome(event, object, outcome); + } + } + + /** + * Work out whether this event may link a tenant, and link it if so. Records the decision on + * `outcome` as it goes, so the caller can log it whatever path is taken — including a throw. + */ + private async decide(type: string, object: StripeEventObject, outcome: LinkOutcome): Promise { + const product = this.stripeSubscriptionService.billingProduct; + + // 1. Product allowlist, before ANY database or Stripe access. This endpoint receives every Ever + // product's events, and an allowlist is the only safe shape: Ever Works, GitHands and the + // directory sites never set `ever_product`, so a denylist of known products would let them + // all through. + const ours = + type === 'checkout.session.completed' + ? checkoutSessionIsForProduct(object, product) + : subscriptionIsForProduct(object as ProductScopedSubscription, product); + if (!ours) { + outcome.decision = 'skipped-foreign'; + return; + } + // `customer` is an id string normally, but an expanded object when the event was created with // expansion — take the id either way rather than silently ignoring the expanded form. - const customerId = - typeof object.customer === 'string' ? object.customer : object.customer?.id; - if (!customerId) return; + const customerId = typeof object.customer === 'string' ? object.customer : object.customer?.id; + outcome.customerId = customerId; + if (!customerId) { + outcome.decision = 'no-customer'; + return; + } // Only `checkout.session.completed` carries the address inline. A Subscription object has no // email field at all, so reading it off the event alone would make `customer.subscription.created` // a permanent no-op — precisely the portal- and Dashboard-created subscriptions this receiver - // exists to catch. Fall back to asking Stripe, which costs one request on that path only. + // exists to catch. Fall back to asking Stripe, on a short budget so the acknowledgement is not + // held up by a slow Stripe. const email = object.customer_email ?? object.customer_details?.email ?? - (await this.stripeSubscriptionService.getCustomerEmail(customerId)); + (await this.stripeSubscriptionService.getCustomerEmail(customerId, STRIPE_BUDGET_MS)); + if (!email) { + outcome.decision = 'no-email'; + return; + } - if (!email) return; - - // Tenant has no `users` relation, so the tenant is reached through the user that owns the - // email rather than by joining from the other side. + // 2. Who owns this address. Tenant has no `users` relation, so the tenant is reached through the + // user rather than by joining from the other side. + // // Deliberately not `.catch(() => null)`: a transient database error would then be // indistinguishable from "nobody has this address", and the event would be acknowledged as // handled when nothing happened. Letting it throw sends it to the handler above, which logs // it — and the event can still be replayed from the Stripe dashboard. - const users = await this.typeOrmUserRepository + const users: MatchedUser[] = await this.typeOrmUserRepository .createQueryBuilder('user') - .select(['user.id', 'user.tenantId', 'user.emailVerifiedAt']) + .leftJoin('user.role', 'role') + .select(['user.id', 'user.tenantId', 'user.emailVerifiedAt', 'role.id', 'role.name']) .where('LOWER(user.email) = LOWER(:email)', { email: email.toLowerCase() }) .andWhere('user.tenantId IS NOT NULL') .limit(2) .getMany(); + if (!users.length) { + // The normal case for a new buyer: the event arrives a minute before they register. Their + // tenant is linked at onboarding from the Checkout Session instead. + outcome.decision = 'no-user'; + return; + } + // One address can exist in more than one tenant. Picking arbitrarily would attach a Stripe // customer to whichever row the database happened to return first, so this declines instead // and leaves the link to be made deliberately. - if (users.length !== 1) { - if (users.length > 1) { - this.logger.warn( - `Stripe ${event.type} matched ${users.length} tenants for one address; not linking automatically.` - ); - } + if (users.length > 1) { + outcome.decision = 'multi'; return; } const user = users[0]; - if (!user?.tenantId) return; + outcome.tenantId = user?.tenantId ?? undefined; + if (!user?.tenantId) { + outcome.decision = 'no-user'; + return; + } + + // 3. Only the tenant's administrators may bind it to a billing account. Accepting an invite + // verifies the invitee's address automatically, so without this an employee's, manager's or + // client contact's PERSONAL purchase — or a free Starter anyone can start under their + // address — would bind their EMPLOYER's tenant to that person's Stripe customer, and the + // employer's admins could then read, cancel and re-price it. The other two writers of this + // column (onboarding and the /billing lazy link) are admin-only already. + if (!ADMIN_ROLES.has(user.role?.name ?? '')) { + outcome.decision = 'non-admin'; + return; + } // The address in this event is whatever the payer typed at checkout, and email is not unique in // this platform, so matching on it alone would let someone who registered under a paying @@ -139,24 +221,43 @@ export class StripeWebhookController { // can type a victim's address but cannot read their mail. An unverified match is left alone; the // link is made later, once the address is confirmed. if (!user.emailVerifiedAt) { - this.logger.log(`Stripe ${event.type} matched an unverified address; not linking automatically.`); + outcome.decision = 'unverified'; return; } // Never adopt a Stripe customer that another tenant already bills through. The write below // guards the *target* tenant from being repointed, but says nothing about the customer: two // tenants could end up sharing one billing account, and whichever opened /billing would be - // looking at the other's invoices, card and subscription. The onboarding path has refused - // this since it was written; the webhook reaches the same column and had no equivalent. + // looking at the other's invoices, card and subscription. const claimedBy = await this.typeOrmTenantRepository.findOne({ where: { stripeCustomerId: customerId }, select: { id: true } }); if (claimedBy && claimedBy.id !== user.tenantId) { - this.logger.warn( - `Stripe ${event.type} would link tenant ${user.tenantId} to a customer already held by ` + - `tenant ${claimedBy.id}; declining.` - ); + outcome.decision = 'claimed'; + outcome.detail = `held-by:${claimedBy.id}`; + return; + } + + // 4. Only a customer that actually holds an entitling subscription to this product. The event + // says a subscription was bought; this says it is still alive and still on our price. + let entitled: boolean; + try { + entitled = await this.stripeSubscriptionService.customerHasEntitlingSubscription(customerId, STRIPE_BUDGET_MS); + } catch (error) { + outcome.decision = 'stripe-unavailable'; + outcome.detail = describeError(error).slice(0, 200); + return; + } + if (!entitled) { + outcome.decision = 'not-entitled'; + return; + } + + // 5. Every check passed. Writing is a separate, default-off decision (see the class comment): the + // checks above cannot prove the purchase was the account owner's own. + if (!resolveWebhookLinking()) { + outcome.decision = 'would-link'; return; } @@ -166,9 +267,29 @@ export class StripeWebhookController { { id: user.tenantId, stripeCustomerId: IsNull() }, { stripeCustomerId: customerId } ); + outcome.decision = updated?.affected ? 'linked' : 'already-linked'; + } - if (updated.affected) { - this.logger.log(`Linked tenant ${user.tenantId} to Stripe customer ${customerId} from ${event.type}.`); + /** + * One line per linking-type event. Ids only — never the email address the decision was made on. + * Without this, "correctly ignored" and "never processed" look the same in the logs. + */ + private logOutcome(event: StripeEvent, object: StripeEventObject, outcome: LinkOutcome): void { + const line = JSON.stringify({ + event: typeof event.id === 'string' ? event.id : undefined, + type: event.type, + product: this.stripeSubscriptionService.billingProduct, + eventProduct: describeProduct(object), + decision: outcome.decision, + tenant: outcome.tenantId, + customer: outcome.customerId, + detail: outcome.detail + }); + const message = `stripe-webhook ${line}`; + if (WARN_DECISIONS.has(outcome.decision)) { + this.logger.warn(message); + } else { + this.logger.log(message); } } } @@ -176,6 +297,65 @@ export class StripeWebhookController { /** Events that can establish a tenant's billing customer for the first time. */ const LINKING_EVENTS = new Set(['checkout.session.completed', 'customer.subscription.created']); +/** Roles that may bind a tenant to a billing account — the same set the /billing routes allow. */ +const ADMIN_ROLES = new Set([RolesEnum.SUPER_ADMIN, RolesEnum.ADMIN]); + +/** Outcomes that deserve an operator's attention rather than being routine. */ +const WARN_DECISIONS = new Set(['multi', 'claimed', 'stripe-unavailable', 'error']); + +/** + * Budget for each Stripe call made while Stripe is waiting for our acknowledgement. Short, so a slow + * Stripe cannot push the response past Stripe's own delivery timeout and turn into a retry. + */ +const STRIPE_BUDGET_MS = 3000; + +/** + * What happened to one linking-type event. + * + * - `skipped-foreign`: another product's event (or payment/setup mode) — no DB or Stripe access. + * - `no-customer` / `no-email`: nothing to match on. + * - `no-user`: nobody has the address yet (the normal buy-then-register case). + * - `multi`: the address exists in more than one tenant. + * - `non-admin`: the one match is not SUPER_ADMIN/ADMIN of their tenant. + * - `unverified`: the one match has not confirmed the address. + * - `claimed`: another tenant already bills through this customer. + * - `not-entitled`: the customer holds no active/trialing/past_due subscription to this product. + * - `stripe-unavailable`: that could not be established; nothing was written. + * - `would-link`: every check passed, but `BILLING_WEBHOOK_LINKING` is off, so nothing was written. + * - `already-linked`: the tenant already had a customer; nothing was changed. + * - `linked`: the tenant was linked. + * - `error`: an unexpected failure (logged separately, still acknowledged). + */ +export type LinkDecision = + | 'skipped-foreign' + | 'no-customer' + | 'no-email' + | 'no-user' + | 'multi' + | 'non-admin' + | 'unverified' + | 'claimed' + | 'not-entitled' + | 'stripe-unavailable' + | 'would-link' + | 'already-linked' + | 'linked' + | 'error'; + +interface LinkOutcome { + decision: LinkDecision; + tenantId?: string; + customerId?: string; + detail?: string; +} + +interface MatchedUser { + id?: string; + tenantId?: string | null; + emailVerifiedAt?: Date | null; + role?: { id?: string; name?: string } | null; +} + /** * Verify Stripe's `Stripe-Signature` header. * @@ -213,13 +393,19 @@ interface RawBodyRequest { rawBody?: Buffer; } +interface StripeEventObject { + mode?: string | null; + metadata?: Record | null; + customer?: string | { id?: string } | null; + customer_email?: string | null; + customer_details?: { email?: string | null } | null; + items?: ProductScopedSubscription['items']; +} + interface StripeEvent { + id?: string; type: string; data?: { - object?: { - customer?: string | { id?: string }; - customer_email?: string; - customer_details?: { email?: string }; - }; + object?: StripeEventObject; }; } diff --git a/packages/core/src/lib/shared/guards/subscription-required.guard.spec.ts b/packages/core/src/lib/shared/guards/subscription-required.guard.spec.ts new file mode 100644 index 0000000000..0a8394d403 --- /dev/null +++ b/packages/core/src/lib/shared/guards/subscription-required.guard.spec.ts @@ -0,0 +1,146 @@ +import { ForbiddenException, Logger } from '@nestjs/common'; +import { StripeSubscriptionService } from '../billing/stripe-subscription.service'; +import { SubscriptionRequiredGuard } from './subscription-required.guard'; + +/** + * The signup paywall on `POST /auth/register`: only a subscription to THIS deployment's product opens + * it, a completed Checkout Session for that product is accepted as proof directly, and a deployment + * can bill without a paywall at all (`BILLING_SIGNUP_PAYWALL=false`, the Ever Teams deployment). + */ + +const EMAIL = 'buyer@example.test'; +const SESSION = 'cs_test_a1FixtureSessionForBillingScopeTests000000000000000000000'; + +let paths: string[] = []; +function stubStripe(answer: (path: string) => any) { + paths = []; + (global as any).fetch = jest.fn(async (input: string) => { + const path = String(input).replace('https://api.stripe.com/v1', ''); + paths.push(path); + const body = answer(path); + if (body === undefined) throw new Error(`Unexpected Stripe request in test: ${path}`); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }; + }); +} + +const gauzySub = { id: 'sub_g', status: 'trialing', metadata: { ever_product: 'gauzy' } }; +const teamsSub = { + id: 'sub_t', + status: 'active', + metadata: { ever_product: 'teams' }, + items: { data: [{ price: { lookup_key: 'ever_teams_cloud_starter_monthly' } }] } +}; + +/** One customer with the given subscriptions, found by email. */ +const byEmail = (subscriptions: any[]) => (path: string) => { + if (path.startsWith('/customers?email=')) return { data: [{ id: 'cus_1' }], has_more: false }; + if (path.startsWith('/subscriptions?customer=cus_1')) return { data: subscriptions, has_more: false }; + return undefined; +}; + +function context(body: any, user?: any) { + return { switchToHttp: () => ({ getRequest: () => ({ body, user }) }) } as any; +} + +const ENV_KEYS = ['STRIPE_SECRET_KEY', 'STRIPE_LIVE_MODE', 'DEMO', 'BILLING_PRODUCT', 'BILLING_SIGNUP_PAYWALL']; +const saved: Record = {}; +const realFetch = (global as any).fetch; + +beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + process.env.STRIPE_SECRET_KEY = 'sk_test_billing_scope_fixture'; + delete process.env.STRIPE_LIVE_MODE; + delete process.env.DEMO; + delete process.env.BILLING_PRODUCT; + delete process.env.BILLING_SIGNUP_PAYWALL; + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + (global as any).fetch = realFetch; + jest.restoreAllMocks(); +}); + +const guard = () => new SubscriptionRequiredGuard(new StripeSubscriptionService()); + +describe('SubscriptionRequiredGuard', () => { + it('lets a Gauzy subscriber register', async () => { + stubStripe(byEmail([gauzySub])); + await expect(guard().canActivate(context({ user: { email: EMAIL } }))).resolves.toBe(true); + }); + + it('no longer lets a Teams (or any other product) subscriber through the Gauzy paywall', async () => { + stubStripe(byEmail([teamsSub])); + await expect(guard().canActivate(context({ user: { email: EMAIL } }))).rejects.toBeInstanceOf( + ForbiddenException + ); + }); + + it('BILLING_SIGNUP_PAYWALL=false: signup is open and Stripe is never asked', async () => { + process.env.BILLING_SIGNUP_PAYWALL = 'false'; + stubStripe(() => undefined); + await expect(guard().canActivate(context({ user: { email: EMAIL } }))).resolves.toBe(true); + expect(paths).toEqual([]); + }); + + it("accepts the buyer's own completed Checkout Session as proof, without an email lookup", async () => { + stubStripe((path) => + path.startsWith('/checkout/sessions/') + ? { + id: SESSION, + status: 'complete', + mode: 'subscription', + created: Math.floor(Date.now() / 1000), + customer: 'cus_1', + customer_details: { email: EMAIL.toUpperCase() }, + metadata: { ever_product: 'gauzy' }, + subscription: gauzySub + } + : undefined + ); + await expect( + guard().canActivate(context({ user: { email: EMAIL }, stripeCheckoutSessionId: SESSION })) + ).resolves.toBe(true); + expect(paths).toEqual([`/checkout/sessions/${SESSION}?expand[]=subscription`]); + }); + + it('a session paid under another address proves nothing: falls back to the email lookup', async () => { + stubStripe((path) => { + if (path.startsWith('/checkout/sessions/')) { + return { + status: 'complete', + mode: 'subscription', + created: Math.floor(Date.now() / 1000), + customer: 'cus_victim', + customer_details: { email: 'victim@example.test' }, + metadata: { ever_product: 'gauzy' }, + subscription: gauzySub + }; + } + return byEmail([])(path); + }); + await expect( + guard().canActivate(context({ user: { email: EMAIL }, stripeCheckoutSessionId: SESSION })) + ).rejects.toBeInstanceOf(ForbiddenException); + expect(paths.some((p) => p.startsWith('/customers?email='))).toBe(true); + }); + + it('a malformed session id is ignored rather than sent to Stripe', async () => { + stubStripe(byEmail([gauzySub])); + await expect( + guard().canActivate(context({ user: { email: EMAIL }, stripeCheckoutSessionId: 'cs_test_../../x' })) + ).resolves.toBe(true); + expect(paths.some((p) => p.startsWith('/checkout/sessions/'))).toBe(false); + }); + + it('an authenticated admin creating a user is never asked for a subscription', async () => { + stubStripe(() => undefined); + await expect(guard().canActivate(context({ user: { email: EMAIL } }, { id: 'admin' }))).resolves.toBe(true); + expect(paths).toEqual([]); + }); +}); diff --git a/packages/core/src/lib/shared/guards/subscription-required.guard.ts b/packages/core/src/lib/shared/guards/subscription-required.guard.ts index 5477c62c47..931a49dce5 100644 --- a/packages/core/src/lib/shared/guards/subscription-required.guard.ts +++ b/packages/core/src/lib/shared/guards/subscription-required.guard.ts @@ -1,4 +1,5 @@ -import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { CanActivate, ExecutionContext, ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import { isCheckoutSessionId } from '../billing/billing-product'; import { EntitlementResult, StripeSubscriptionService } from '../billing/stripe-subscription.service'; /** @@ -7,6 +8,13 @@ import { EntitlementResult, StripeSubscriptionService } from '../billing/stripe- */ const CHECKOUT_URL = process.env.EVER_CHECKOUT_URL?.trim() || 'https://ever.co/checkout'; +/** + * Per-request Stripe budget for checking a forwarded Checkout Session. It runs BEFORE the email + * lookup, which has its own 10 s deadline, so a slow Stripe must not hold `POST /auth/register` for + * both in full. A session that cannot be checked in time falls through to the email lookup. + */ +const CHECKOUT_SESSION_BUDGET_MS = 3500; + /** * Requires the registering email to hold a Stripe subscription. * @@ -18,16 +26,25 @@ const CHECKOUT_URL = process.env.EVER_CHECKOUT_URL?.trim() || 'https://ever.co/c * repo, set no Stripe key, and registration behaves exactly as it always has — no Stripe call is * made, no subscription is required, and this guard returns true before doing anything else. * + * **Also inert when `BILLING_SIGNUP_PAYWALL=false`.** A deployment can bill (link tenants, show the + * billing pages) without requiring a subscription to sign up — Ever Teams does exactly that. + * + * Only a subscription to THIS deployment's product (`BILLING_PRODUCT`, default `gauzy`) counts: + * the Stripe account is shared by every Ever product, and a free Teams or Platform Starter must not + * open Gauzy signup. + * * Runs alongside RegisterAuthorizationGuard, which handles a different question (whether privileged * fields in the body are allowed). Neither subsumes the other. */ @Injectable() export class SubscriptionRequiredGuard implements CanActivate { + private readonly logger = new Logger(SubscriptionRequiredGuard.name); + constructor(private readonly stripeSubscriptionService: StripeSubscriptionService) {} async canActivate(context: ExecutionContext): Promise { - // Self-hosted, or simply not configured for billing: nothing to enforce. - if (!this.stripeSubscriptionService.isBillingEnforced()) { + // Self-hosted, not configured for billing, or billing without a signup paywall: nothing to enforce. + if (!this.stripeSubscriptionService.isSignupPaywallEnabled()) { return true; } @@ -59,6 +76,24 @@ export class SubscriptionRequiredGuard implements CanActivate { return true; } + // The buyer's own Checkout Session, forwarded by the checkout's completion page, is the most + // direct proof there is: Stripe says this address completed a purchase of this product. Guards + // run before the validation pipe, so the raw body value is shape-checked here. A session that + // does not verify is not an error — the address is simply looked up the ordinary way below. + const sessionId: unknown = request.body?.stripeCheckoutSessionId; + if (isCheckoutSessionId(sessionId)) { + const verification = await this.stripeSubscriptionService.verifyCheckoutSession( + sessionId, + email, + CHECKOUT_SESSION_BUDGET_MS + ); + if (verification.ok === false) { + this.logger.log(`Checkout Session not accepted as registration proof (${verification.reason}).`); + } else { + return true; + } + } + const entitlement = await this.stripeSubscriptionService.getEntitlement(email); // UNKNOWN means Stripe could not answer. Let it through — see getEntitlement() for why an diff --git a/packages/core/src/lib/tenant/dto/create-tenant.dto.ts b/packages/core/src/lib/tenant/dto/create-tenant.dto.ts index 8f771159a9..08e4ab8e75 100644 --- a/packages/core/src/lib/tenant/dto/create-tenant.dto.ts +++ b/packages/core/src/lib/tenant/dto/create-tenant.dto.ts @@ -1,6 +1,7 @@ import { ITenantCreateInput } from "@gauzy/contracts"; -import { ApiHideProperty } from "@nestjs/swagger"; -import { IsBoolean, IsOptional } from "class-validator"; +import { ApiHideProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional, IsString, Matches } from "class-validator"; +import { CHECKOUT_SESSION_ID_PATTERN } from "../../shared/billing/billing-product"; import { TenantDTO } from "./tenant.dto"; export class CreateTenantDTO extends TenantDTO implements ITenantCreateInput { @@ -17,4 +18,16 @@ export class CreateTenantDTO extends TenantDTO implements ITenantCreateInput { @ApiHideProperty() @IsOptional() readonly userSourceId: string; -} \ No newline at end of file + + /** + * The Stripe Checkout Session the creator completed before registering. On a hosted deployment the + * new tenant is linked to that session's customer, but only after the server has confirmed with + * Stripe that the session is complete, for this product, and was paid under the creator's own + * address. Never persisted on the tenant. + */ + @ApiPropertyOptional({ type: () => String, description: 'Stripe Checkout Session id (cs_live_... / cs_test_...)' }) + @IsOptional() + @IsString() + @Matches(CHECKOUT_SESSION_ID_PATTERN, { message: 'stripeCheckoutSessionId is not a Stripe Checkout Session id.' }) + readonly stripeCheckoutSessionId?: string; +} diff --git a/packages/core/src/lib/tenant/tenant.service.billing-link.spec.ts b/packages/core/src/lib/tenant/tenant.service.billing-link.spec.ts new file mode 100644 index 0000000000..d6b2306998 --- /dev/null +++ b/packages/core/src/lib/tenant/tenant.service.billing-link.spec.ts @@ -0,0 +1,206 @@ +/** + * 🛑 This import must stay FIRST, before any import that pulls a core service — the entity graph has + * to finish initializing before TenantService (and the repositories it imports) are evaluated. + */ +import '../core/entities/internal'; +import { Logger } from '@nestjs/common'; +import { StripeSubscriptionService } from '../shared/billing/stripe-subscription.service'; +import { TenantService } from './tenant.service'; + +/** + * Proven-identity linking at tenant onboarding. + * + * The ever.co checkout forwards the buyer's Checkout Session id to the register form; the web app + * carries it into `POST /tenant`. There, once the tenant exists and its creator is SUPER_ADMIN, the + * tenant is linked to the session's Stripe customer — but only after Stripe confirms the session is + * complete, is a subscription to THIS product, and was paid under the creator's own address. These + * tests drive the real TenantService methods with an in-memory repository and a fixture Stripe. + */ + +const TENANT_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_TENANT_ID = '22222222-2222-4222-8222-222222222222'; +const USER_ID = '33333333-3333-4333-8333-333333333333'; +const EMAIL = 'founder@newco.test'; +const SESSION = 'cs_test_a1FixtureSessionForBillingScopeTests000000000000000000000'; + +function completeSession(overrides: Record = {}) { + return { + id: SESSION, + status: 'complete', + mode: 'subscription', + created: Math.floor(Date.now() / 1000) - 60, + customer: 'cus_new', + customer_details: { email: 'Founder@NewCo.test' }, + metadata: { ever_product: 'gauzy' }, + subscription: { id: 'sub_new', status: 'trialing', metadata: { ever_product: 'gauzy' } }, + ...overrides + }; +} + +let stripePaths: string[] = []; +function stubStripe(session: any, extra: (path: string) => any = () => undefined) { + stripePaths = []; + (global as any).fetch = jest.fn(async (input: string) => { + const path = String(input).replace('https://api.stripe.com/v1', ''); + stripePaths.push(path); + const body = path.startsWith('/checkout/sessions/') ? session : extra(path); + if (body === undefined) throw new Error(`Unexpected Stripe request in test: ${path}`); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }; + }); +} + +function buildService(claimedBy: { id: string } | null = null) { + const service: any = Object.create(TenantService.prototype); + service.stripeSubscriptionService = new StripeSubscriptionService(); + service.billingLogger = new Logger('TenantBillingLink'); + service.typeOrmTenantRepository = { findOne: jest.fn(async () => claimedBy) }; + service.update = jest.fn(async () => ({ affected: 1 })); + return service as TenantService & { update: jest.Mock; typeOrmTenantRepository: { findOne: jest.Mock } }; +} + +const tenant = () => ({ id: TENANT_ID, name: 'NewCo' }) as any; +const creator = (overrides: Record = {}) => + ({ id: USER_ID, email: EMAIL, emailVerifiedAt: null, ...overrides }) as any; + +const ENV_KEYS = ['STRIPE_SECRET_KEY', 'STRIPE_LIVE_MODE', 'DEMO', 'BILLING_PRODUCT']; +const saved: Record = {}; +const realFetch = (global as any).fetch; +let logLines: string[] = []; + +beforeEach(() => { + for (const key of ENV_KEYS) saved[key] = process.env[key]; + process.env.STRIPE_SECRET_KEY = 'sk_test_billing_scope_fixture'; + delete process.env.STRIPE_LIVE_MODE; + delete process.env.DEMO; + delete process.env.BILLING_PRODUCT; + logLines = []; + for (const level of ['log', 'warn', 'error'] as const) { + jest.spyOn(Logger.prototype, level).mockImplementation((message: any) => { + logLines.push(String(message)); + }); + } +}); + +afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + (global as any).fetch = realFetch; + jest.restoreAllMocks(); +}); + +const linkDecisions = () => + logLines.filter((l) => l.startsWith('stripe-link ')).map((l) => JSON.parse(l.slice('stripe-link '.length))); + +describe('TenantService.linkStripeCustomerFromCheckoutSession', () => { + it('links the new tenant to the session customer — no verified email needed', async () => { + stubStripe(completeSession()); + const service = buildService(); + const t = tenant(); + + await expect(service.linkStripeCustomerFromCheckoutSession(t, creator(), SESSION)).resolves.toBe('cus_new'); + + expect(service.update).toHaveBeenCalledTimes(1); + expect(service.update).toHaveBeenCalledWith(TENANT_ID, { stripeCustomerId: 'cus_new' }); + expect(t.stripeCustomerId).toBe('cus_new'); + expect(linkDecisions()).toEqual([ + expect.objectContaining({ + decision: 'session-linked', + tenant: TENANT_ID, + customer: 'cus_new', + product: 'gauzy' + }) + ]); + expect(logLines.join('\n')).not.toMatch(/@/); + }); + + it.each<[string, Record, string]>([ + [ + 'paid under another address (a replayed or leaked session id)', + { customer_details: { email: 'victim@corp.test' } }, + 'email-mismatch' + ], + ['for another product', { metadata: { ever_product: 'teams' } }, 'foreign-product'], + ['a payment-mode license', { mode: 'payment' }, 'foreign-product'], + ['not complete', { status: 'open' }, 'incomplete'], + ['too old', { created: Math.floor(Date.now() / 1000) - 30 * 86400 }, 'expired'] + ])('does not link from a session that is %s', async (_label, overrides, reason) => { + stubStripe(completeSession(overrides)); + const service = buildService(); + await expect(service.linkStripeCustomerFromCheckoutSession(tenant(), creator(), SESSION)).resolves.toBeNull(); + expect(service.update).not.toHaveBeenCalled(); + expect(linkDecisions()).toEqual([expect.objectContaining({ decision: 'session-declined', detail: reason })]); + }); + + it('never adopts a customer another tenant already bills through', async () => { + stubStripe(completeSession()); + const service = buildService({ id: OTHER_TENANT_ID }); + await expect(service.linkStripeCustomerFromCheckoutSession(tenant(), creator(), SESSION)).resolves.toBeNull(); + expect(service.update).not.toHaveBeenCalled(); + expect(linkDecisions()).toEqual([expect.objectContaining({ decision: 'claimed' })]); + }); + + it('does nothing, and asks Stripe nothing, when billing is off', async () => { + delete process.env.STRIPE_SECRET_KEY; + stubStripe(completeSession()); + const service = buildService(); + await expect(service.linkStripeCustomerFromCheckoutSession(tenant(), creator(), SESSION)).resolves.toBeNull(); + expect(stripePaths).toEqual([]); + expect(service.update).not.toHaveBeenCalled(); + }); +}); + +describe('TenantService.linkStripeCustomerAtOnboarding', () => { + it('falls back to the verified-email path when the session is declined — and that path still needs verification', async () => { + stubStripe(completeSession({ customer_details: { email: 'victim@corp.test' } })); + const service = buildService(); + await expect(service.linkStripeCustomerAtOnboarding(tenant(), creator(), SESSION)).resolves.toBeNull(); + expect(service.update).not.toHaveBeenCalled(); + }); + + it('never fails onboarding: a write error (e.g. the unique index losing a race) is logged and swallowed', async () => { + stubStripe(completeSession()); + const service = buildService(); + service.update.mockRejectedValueOnce(new Error('duplicate key value violates unique constraint')); + await expect(service.linkStripeCustomerAtOnboarding(tenant(), creator(), SESSION)).resolves.toBeNull(); + expect(logLines.some((l) => l.includes('Could not link tenant'))).toBe(true); + }); +}); + +describe('TenantService.onboardTenant — the session id is proof, not a tenant field', () => { + it('keeps it out of the created entity and links only after the creator is SUPER_ADMIN', async () => { + const service: any = buildService(); + const order: string[] = []; + service.create = jest.fn(async (input: any) => { + order.push('create'); + return { id: TENANT_ID, ...input }; + }); + service.commandBus = { execute: jest.fn(async () => undefined) }; + service.executeTenantUpdateTasks = jest.fn(); + service.typeOrmRoleRepository = { findOneBy: jest.fn(async () => ({ id: 'role-super-admin' })) }; + service.typeOrmUserRepository = { + update: jest.fn(async () => { + order.push('assign-super-admin'); + return { affected: 1 }; + }) + }; + service.importRecords = jest.fn(async () => undefined); + service.linkStripeCustomerAtOnboarding = jest.fn(async () => { + order.push('link'); + return 'cus_new'; + }); + jest.spyOn(console, 'time').mockImplementation(() => undefined); + jest.spyOn(console, 'timeEnd').mockImplementation(() => undefined); + + await service.onboardTenant({ name: 'NewCo', stripeCheckoutSessionId: SESSION }, creator()); + + expect(service.create).toHaveBeenCalledWith({ name: 'NewCo' }); + expect(service.linkStripeCustomerAtOnboarding).toHaveBeenCalledWith( + expect.objectContaining({ id: TENANT_ID }), + expect.objectContaining({ id: USER_ID }), + SESSION + ); + expect(order).toEqual(['create', 'assign-super-admin', 'link']); + }); +}); diff --git a/packages/core/src/lib/tenant/tenant.service.ts b/packages/core/src/lib/tenant/tenant.service.ts index 7e27fc6e27..0a0a2e1c4d 100644 --- a/packages/core/src/lib/tenant/tenant.service.ts +++ b/packages/core/src/lib/tenant/tenant.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { ITenantCreateInput, RolesEnum, ITenant, IUser, FileStorageProviderEnum } from '@gauzy/contracts'; import { ConfigService } from '@gauzy/config'; @@ -19,10 +19,12 @@ import { MikroOrmUserRepository } from '../user/repository/mikro-orm-user.reposi import { TypeOrmTenantRepository } from './repository/type-orm-tenant.repository'; import { MikroOrmTenantRepository } from './repository/mikro-orm-tenant.repository'; import { Tenant } from './tenant.entity'; -import { StripeSubscriptionService } from '../shared/billing/stripe-subscription.service'; +import { StripeSubscriptionService, describeError } from '../shared/billing/stripe-subscription.service'; @Injectable() export class TenantService extends CrudService { + private readonly billingLogger = new Logger('TenantBillingLink'); + constructor( readonly typeOrmTenantRepository: TypeOrmTenantRepository, readonly mikroOrmTenantRepository: MikroOrmTenantRepository, @@ -48,14 +50,13 @@ export class TenantService extends CrudService { public async onboardTenant(entity: ITenantCreateInput, user: IUser): Promise { console.time('On Boarding Tenant'); - // Creates and saves a tenant entity from the given details. - const tenant = await this.create(entity); + // The Checkout Session id is a one-time proof of purchase, not a tenant attribute: take it out + // of the payload so it can never be spread into the entity, and use it below once the creator + // holds the tenant's SUPER_ADMIN role. + const { stripeCheckoutSessionId, ...tenantInput } = entity; - // Record which Stripe customer this tenant bills through, if that is already safe to determine. - // Usually it is not at this point — the link needs a confirmed email address and the buyer has - // only just been sent the confirmation — so this commonly does nothing and the link is made on - // their first visit to the billing page instead. See linkStripeCustomer() for why. - await this.linkStripeCustomer(tenant, user); + // Creates and saves a tenant entity from the given details. + const tenant = await this.create(tenantInput); // Create Role/Permissions to relative tenants. await this.commandBus.execute(new TenantRoleBulkCreateCommand([tenant])); @@ -101,6 +102,13 @@ export class TenantService extends CrudService { break; } + // Record which Stripe customer this tenant bills through, if that can be established safely now. + // A buyer arriving from the shared checkout brings their Checkout Session, which proves the + // purchase outright; anyone else is linked by verified email, which at this moment usually does + // nothing (the confirmation mail has only just gone out) and is completed on their first visit to + // the billing page instead. Best-effort: never fails the onboarding around it. + await this.linkStripeCustomerAtOnboarding(tenant, user, stripeCheckoutSessionId); + // Create Import Records while migrating for relative tenant. await this.importRecords(entity, tenant, user); @@ -108,6 +116,92 @@ export class TenantService extends CrudService { return tenant; } + /** + * Link a freshly created tenant to its Stripe customer: from the creator's Checkout Session when + * they brought one, otherwise by verified email. Never throws — onboarding must not fail because a + * payments provider, or a race on the unique link index, had a bad moment. + */ + async linkStripeCustomerAtOnboarding( + tenant: ITenant, + user: IUser, + stripeCheckoutSessionId?: string + ): Promise { + try { + if (stripeCheckoutSessionId) { + const linked = await this.linkStripeCustomerFromCheckoutSession(tenant, user, stripeCheckoutSessionId); + if (linked) return linked; + } + return await this.linkStripeCustomer(tenant, user); + } catch (error) { + this.billingLogger.warn( + `Could not link tenant ${tenant?.id} to a Stripe customer: ${describeError(error)}` + ); + return null; + } + } + + /** + * Link a new tenant to the Stripe customer of the Checkout Session its creator completed. + * + * This is the proven-identity path, and the reason a buyer no longer has to confirm their email + * before their tenant is linked. Only the browser that completed the checkout holds the session id, + * and `verifyCheckoutSession` confirms with Stripe that the session is complete, is a subscription + * to this deployment's product, is recent, and was paid under the creator's OWN address. The + * creator has just become this tenant's SUPER_ADMIN, so the link goes to the tenant they own. + * + * Never adopts a customer another tenant already holds. Logs exactly one line with the outcome + * (`session-linked` or the reason it was declined), with ids only. + */ + async linkStripeCustomerFromCheckoutSession( + tenant: ITenant, + user: IUser, + stripeCheckoutSessionId: string + ): Promise { + if (!this.stripeSubscriptionService.isBillingEnforced()) return null; + if (!tenant?.id || !user?.email) return null; + + const log = (decision: string, customerId?: string, detail?: string) => + this.billingLogger.log( + `stripe-link ${JSON.stringify({ + source: 'checkout-session', + product: this.stripeSubscriptionService.billingProduct, + decision, + tenant: tenant.id, + customer: customerId, + detail + })}` + ); + + const verification = await this.stripeSubscriptionService.verifyCheckoutSession( + stripeCheckoutSessionId, + user.email + ); + if (verification.ok === false) { + log('session-declined', undefined, verification.reason); + return null; + } + const { customerId } = verification; + + const claimedBy = await this.typeOrmTenantRepository.findOne({ + where: { stripeCustomerId: customerId }, + select: { id: true } + }); + if (claimedBy && claimedBy.id !== tenant.id) { + log('claimed', customerId, `held-by:${claimedBy.id}`); + return null; + } + if (tenant.stripeCustomerId && tenant.stripeCustomerId !== customerId) { + log('already-linked', customerId); + return null; + } + + // Through CrudService, for the same multi-ORM reason as linkStripeCustomer below. + await this.update(tenant.id, { stripeCustomerId: customerId } as Partial); + tenant.stripeCustomerId = customerId; + log('session-linked', customerId); + return customerId; + } + /** * Records which Stripe customer a tenant bills through, when that can be established safely. * diff --git a/packages/core/src/lib/user/dto/register-user.dto.spec.ts b/packages/core/src/lib/user/dto/register-user.dto.spec.ts new file mode 100644 index 0000000000..f2aa4c24fa --- /dev/null +++ b/packages/core/src/lib/user/dto/register-user.dto.spec.ts @@ -0,0 +1,81 @@ +/** + * 🛑 Keep this import first: RegisterUserDTO reaches entity-backed validators through RoleFeatureDTO. + */ +import '../../core/entities/internal'; +import { plainToInstance } from 'class-transformer'; +import { validate, ValidationError } from 'class-validator'; +import { CreateTenantDTO } from '../../tenant/dto/create-tenant.dto'; +import { RegisterUserDTO } from './register-user.dto'; + +/** + * `POST /auth/register` and `POST /tenant` both run the validation pipe with `whitelist: true`, so a + * property the DTO does not declare is silently stripped. `stripeCheckoutSessionId` is new on both: + * + * - an OLD client never sends it, and must register exactly as before (it is optional); + * - a NEW client talking to an OLD API has it stripped, not rejected (no `forbidNonWhitelisted`); + * - when it IS sent it must be shaped like a Checkout Session id, because the API puts it into a + * Stripe URL path. + */ + +const SESSION = 'cs_live_a1FixtureSessionForBillingScopeTests000000000000000000000'; + +async function check(cls: new () => T, payload: Record) { + const dto = plainToInstance(cls, payload) as T; + const errors: ValidationError[] = await validate(dto, { whitelist: true }); + return { dto: dto as any, errors }; +} + +const registration = (extra: Record = {}) => ({ + password: 'correct-horse-battery', + confirmPassword: 'correct-horse-battery', + user: { email: 'founder@newco.test', firstName: 'Ada', lastName: 'Lovelace' }, + ...extra +}); + +describe('RegisterUserDTO.stripeCheckoutSessionId', () => { + it('an old client that never sends it registers exactly as before', async () => { + const { dto, errors } = await check(RegisterUserDTO, registration()); + expect(errors).toEqual([]); + expect(dto.stripeCheckoutSessionId).toBeUndefined(); + }); + + it('keeps a well-formed live or test session id', async () => { + for (const id of [SESSION, SESSION.replace('cs_live_', 'cs_test_')]) { + const { dto, errors } = await check(RegisterUserDTO, registration({ stripeCheckoutSessionId: id })); + expect(errors).toEqual([]); + expect(dto.stripeCheckoutSessionId).toBe(id); + } + }); + + it.each(['not-a-session', 'cs_live_../../customers/cus_x', 'sub_1234567890abc', 'cs_live_', ''])( + 'rejects %p rather than put it into a Stripe URL', + async (value) => { + const { errors } = await check(RegisterUserDTO, registration({ stripeCheckoutSessionId: value })); + expect(errors.some((e) => e.property === 'stripeCheckoutSessionId')).toBe(true); + } + ); + + // Control: `whitelist: true` really is in force here, so "it is kept" above is not vacuous. + it('still strips a property the DTO does not declare', async () => { + const { dto } = await check(RegisterUserDTO, registration({ stripeCustomerId: 'cus_victim' })); + expect(dto.stripeCustomerId).toBeUndefined(); + }); +}); + +describe('CreateTenantDTO.stripeCheckoutSessionId', () => { + it('is optional, keeps a well-formed id, and rejects anything else', async () => { + expect((await check(CreateTenantDTO, { name: 'NewCo' })).errors).toEqual([]); + + const kept = await check(CreateTenantDTO, { name: 'NewCo', stripeCheckoutSessionId: SESSION }); + expect(kept.errors).toEqual([]); + expect(kept.dto.stripeCheckoutSessionId).toBe(SESSION); + + const bad = await check(CreateTenantDTO, { name: 'NewCo', stripeCheckoutSessionId: 'cus_victim' }); + expect(bad.errors.some((e) => e.property === 'stripeCheckoutSessionId')).toBe(true); + }); + + it('still refuses a client-chosen stripeCustomerId (stripped by the whitelist)', async () => { + const { dto } = await check(CreateTenantDTO, { name: 'NewCo', stripeCustomerId: 'cus_victim' }); + expect(dto.stripeCustomerId).toBeUndefined(); + }); +}); diff --git a/packages/core/src/lib/user/dto/register-user.dto.ts b/packages/core/src/lib/user/dto/register-user.dto.ts index af50be3c59..25898f0ed3 100644 --- a/packages/core/src/lib/user/dto/register-user.dto.ts +++ b/packages/core/src/lib/user/dto/register-user.dto.ts @@ -7,11 +7,14 @@ import { IsNotEmpty, IsNotEmptyObject, IsOptional, + IsString, IsUUID, + Matches, MinLength, ValidateNested } from 'class-validator'; import { IUserRegistrationInput } from '@gauzy/contracts'; +import { CHECKOUT_SESSION_ID_PATTERN } from './../../shared/billing/billing-product'; import { Match } from './../../shared/validators'; import { TermsAcceptanceClaimDTO } from './../../terms-acceptance/dto'; import { CreateUserDTO } from './create-user.dto'; @@ -78,4 +81,21 @@ export class RegisterUserDTO implements IUserRegistrationInput { @ValidateNested({ each: true }) @Type(() => TermsAcceptanceClaimDTO) readonly terms?: TermsAcceptanceClaimDTO[]; + + /** + * The Stripe Checkout Session the registrant just completed on the shared ever.co checkout, which + * forwards it to the register form as `checkout_session`. + * + * Optional, so every existing client keeps working unchanged (and an older API simply strips it, + * because this route whitelists). When present it is read by `SubscriptionRequiredGuard` as proof of + * purchase, and the web app carries the same id into tenant onboarding, where the new tenant is + * linked to the session's Stripe customer. Shape-checked here; everything that matters — that the + * session is complete, for this product, and was paid under this very address — is checked against + * Stripe server-side. + */ + @ApiPropertyOptional({ type: () => String, description: 'Stripe Checkout Session id (cs_live_... / cs_test_...)' }) + @IsOptional() + @IsString() + @Matches(CHECKOUT_SESSION_ID_PATTERN, { message: 'stripeCheckoutSessionId is not a Stripe Checkout Session id.' }) + readonly stripeCheckoutSessionId?: string; } diff --git a/packages/plugins/onboarding-ui/src/lib/components/tenant-onboarding/tenant-onboarding.component.ts b/packages/plugins/onboarding-ui/src/lib/components/tenant-onboarding/tenant-onboarding.component.ts index 189ebd7cab..d5cf8f65ea 100644 --- a/packages/plugins/onboarding-ui/src/lib/components/tenant-onboarding/tenant-onboarding.component.ts +++ b/packages/plugins/onboarding-ui/src/lib/components/tenant-onboarding/tenant-onboarding.component.ts @@ -10,7 +10,9 @@ import { OrganizationsService, Store, TenantService, - UsersService + UsersService, + clearRememberedCheckoutSession, + readRememberedCheckoutSession } from '@gauzy/ui-core/core'; @UntilDestroy() @@ -57,7 +59,15 @@ export class TenantOnboardingComponent implements OnInit, OnDestroy { this.loading = true; try { - const tenant = await this._tenantService.create({ name: organization.name }); + // A buyer who came from the shared checkout brought their Stripe Checkout Session through the + // register form. Sending it here lets the API link the new tenant to their Stripe customer + // straight away (after verifying it with Stripe) instead of waiting for a confirmed email. + const stripeCheckoutSessionId = readRememberedCheckoutSession(); + const tenant = await this._tenantService.create({ + name: organization.name, + ...(stripeCheckoutSessionId ? { stripeCheckoutSessionId } : {}) + }); + clearRememberedCheckoutSession(); this.user = await this._usersService.getMe(['tenant']); this._store.user = this.user; diff --git a/packages/ui-auth/src/lib/components/register/register.component.ts b/packages/ui-auth/src/lib/components/register/register.component.ts index 2302a4200b..797e15b4fa 100644 --- a/packages/ui-auth/src/lib/components/register/register.component.ts +++ b/packages/ui-auth/src/lib/components/register/register.component.ts @@ -7,7 +7,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { patterns } from '@gauzy/constants'; import { ITermsAcceptanceDocument } from '@gauzy/contracts'; -import { AuthService } from '@gauzy/ui-core/core'; +import { AuthService, isCheckoutSessionId, rememberCheckoutSession } from '@gauzy/ui-core/core'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -98,10 +98,19 @@ export class NgxRegisterComponent extends NbRegisterComponent implements OnInit * The name is a prefill and stays editable. Stripe collects one full name, which is the * shape this form wants, but it knows nothing of the length limits configured here, so the * buyer has to be able to correct it. + * + * `checkout_session` is the buyer's completed Stripe Checkout Session. It goes to the API with + * the registration (proof of purchase for the signup paywall) and is remembered for tenant + * onboarding, where the API links the new tenant to the buyer's Stripe customer after checking + * the session with Stripe. Anything not shaped like a session id is ignored. */ - tap(({ email, name }: Params) => { + tap(({ email, name, checkout_session }: Params) => { if (email) this.user.email = email; if (name) this.user.fullName = name; + if (isCheckoutSessionId(checkout_session)) { + this.user.stripeCheckoutSessionId = checkout_session; + rememberCheckoutSession(checkout_session); + } }), // Use 'untilDestroyed' to handle component lifecycle and avoid memory leaks. diff --git a/packages/ui-core/core/src/lib/services/auth/auth-strategy.service.ts b/packages/ui-core/core/src/lib/services/auth/auth-strategy.service.ts index faacf63c21..0497ceb9b9 100644 --- a/packages/ui-core/core/src/lib/services/auth/auth-strategy.service.ts +++ b/packages/ui-core/core/src/lib/services/auth/auth-strategy.service.ts @@ -18,6 +18,7 @@ import { Store } from '../store/store.service'; import { TimeTrackerService } from '../time-tracker/time-tracker.service'; import { TimesheetFilterService } from '../timesheet/timesheet-filter.service'; import { AuthService } from './auth.service'; +import { isCheckoutSessionId } from './checkout-session'; import { ElectronService } from './electron.service'; @Injectable() @@ -152,6 +153,7 @@ export class AuthStrategy extends NbAuthStrategy { tags, terms, termsDocuments, + stripeCheckoutSessionId, preferredLanguage = LanguagesEnum.ENGLISH } = data; if (password !== confirmPassword) { @@ -189,7 +191,10 @@ export class AuthStrategy extends NbAuthStrategy { }, password, confirmPassword, - terms: termsClaims + terms: termsClaims, + // The buyer's completed Stripe Checkout Session, when they came from the shared checkout. + // Only a well-formed id is sent; the API verifies it with Stripe. + ...(isCheckoutSessionId(stripeCheckoutSessionId) ? { stripeCheckoutSessionId } : {}) }; return this.authService.register(register).pipe( switchMap((res: IUser | any) => { diff --git a/packages/ui-core/core/src/lib/services/auth/checkout-session.ts b/packages/ui-core/core/src/lib/services/auth/checkout-session.ts new file mode 100644 index 0000000000..df0da024d1 --- /dev/null +++ b/packages/ui-core/core/src/lib/services/auth/checkout-session.ts @@ -0,0 +1,54 @@ +/** + * The Stripe Checkout Session a visitor completed on the shared ever.co checkout, carried from the + * register form to tenant onboarding. + * + * ever.co/checkout/complete forwards the session id to the register form as `checkout_session`. The + * API reads it twice: at registration, as proof of purchase for the signup paywall, and at tenant + * onboarding — a separate request, after the login that follows registration — where it links the new + * tenant to the buyer's Stripe customer. Registration creates no tenant, so the id has to survive in + * between; it is kept in this browser's session storage (not local storage: it is only needed for the + * next few minutes, and a shared computer should not keep it). + * + * The id is only ever SENT to our own API, which verifies it with Stripe. Nothing in the browser + * trusts it. + */ + +/** Same shape the API validates: `cs_live_` / `cs_test_` followed by Stripe's base62 id. */ +export const CHECKOUT_SESSION_ID_PATTERN = /^cs_(live|test)_[A-Za-z0-9]{8,255}$/; + +const STORAGE_KEY = 'gauzy.stripeCheckoutSessionId'; + +/** Whether `value` is shaped like a Stripe Checkout Session id. */ +export function isCheckoutSessionId(value: unknown): value is string { + return typeof value === 'string' && CHECKOUT_SESSION_ID_PATTERN.test(value); +} + +/** Remember a Checkout Session id for tenant onboarding. Ignores anything not shaped like one. */ +export function rememberCheckoutSession(value: unknown): void { + if (!isCheckoutSessionId(value)) return; + try { + sessionStorage.setItem(STORAGE_KEY, value); + } catch { + // Storage can be unavailable (private mode, blocked site data). The purchase is still linked + // later by verified email, so this is not worth failing anything over. + } +} + +/** The remembered Checkout Session id, if there is a well-formed one. */ +export function readRememberedCheckoutSession(): string | undefined { + try { + const value = sessionStorage.getItem(STORAGE_KEY); + return isCheckoutSessionId(value) ? value : undefined; + } catch { + return undefined; + } +} + +/** Forget the remembered Checkout Session id — once onboarding has used it. */ +export function clearRememberedCheckoutSession(): void { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing to do: see rememberCheckoutSession. + } +} diff --git a/packages/ui-core/core/src/lib/services/auth/index.ts b/packages/ui-core/core/src/lib/services/auth/index.ts index 7afb3e0782..eb19b4eb20 100644 --- a/packages/ui-core/core/src/lib/services/auth/index.ts +++ b/packages/ui-core/core/src/lib/services/auth/index.ts @@ -1,3 +1,4 @@ export * from './auth.service'; export * from './auth-strategy.service'; +export * from './checkout-session'; export * from './electron.service'; diff --git a/packages/ui-core/i18n/assets/i18n/en.json b/packages/ui-core/i18n/assets/i18n/en.json index 3f9b978e23..8efc2b62dd 100644 --- a/packages/ui-core/i18n/assets/i18n/en.json +++ b/packages/ui-core/i18n/assets/i18n/en.json @@ -1262,6 +1262,8 @@ "BILLING_CURRENT": "Current", "BILLING_SWITCH": "Switch to this plan", "BILLING_PLAN_CHANGED": "Switched to {{ name }}.", + "BILLING_PAYMENT_METHOD_REQUIRED": "Add a payment method to switch to a paid plan. Opening the billing portal...", + "BILLING_PAYMENT_METHOD_REQUIRED_NO_PORTAL": "Add a payment method to switch to a paid plan. Open the billing portal from this page to add one, then switch again.", "BILLING_PAYMENT_METHOD": "Payment method", "BILLING_NO_CARD": "No card on file.", "BILLING_INVOICES": "Invoices",