diff --git a/.github/workflows/_deploy-environment.yml b/.github/workflows/_deploy-environment.yml index b211b627e..594ec5567 100644 --- a/.github/workflows/_deploy-environment.yml +++ b/.github/workflows/_deploy-environment.yml @@ -80,6 +80,8 @@ jobs: # Sync cutover knobs (optional; omit → enabled/passive defaults). SYNC_CLOUD_MUTATION_MODE: ${{ vars.SYNC_CLOUD_MUTATION_MODE }} SYNC_EXECUTION: ${{ vars.SYNC_EXECUTION }} + # Billing operator pause switch (optional; omit → false/paused). + BILLING_ENFORCEMENT: ${{ vars.BILLING_ENFORCEMENT }} WEB_IMAGE: switchbacktech/compass-web:${{ inputs.environment }}-${{ steps.version.outputs.image_version }} # Sensitive COMPASS_SYNC_TOKEN: ${{ secrets.COMPASS_SYNC_TOKEN }} @@ -202,6 +204,12 @@ jobs: else echo "Stripe config: omitting stripe block (environment=${{ inputs.environment }}; secretKey=$([ -n "$STRIPE_SECRET_KEY" ] && echo present || echo empty); webhookSecret=$([ -n "$STRIPE_WEBHOOK_SECRET" ] && echo present || echo empty); priceId=$([ -n "$STRIPE_PRICE_ID" ] && echo present || echo empty))" >&2 fi + # Billing operator pause switch (optional; omit → schema default false). + if [ -n "$BILLING_ENFORCEMENT" ]; then + printf '%s\n' \ + 'billing:' \ + " enforcement: ${BILLING_ENFORCEMENT}" + fi printf '%s\n' \ 'sync:' \ " mongoUri: \"${SYNC_MONGO_URI}\"" \ diff --git a/compass.example.yaml b/compass.example.yaml index c65acfdd0..8144a9bd0 100644 --- a/compass.example.yaml +++ b/compass.example.yaml @@ -47,6 +47,12 @@ supertokens: # priceId: REPLACE_WITH_STRIPE_PRICE_ID # Omit the whole block for self-host. All three values are required together. +# billing: +# enforcement: false # operator pause switch — keep the app free for everyone +# Independent of `stripe:` above: you can keep Stripe keys configured while +# enforcement stays false, so Checkout/webhook work can continue without +# gating any user. Defaults to false when omitted. + # Compass Sync service — required. The backend exits at startup without # serviceUrl/internalAuthToken, and self-host runs Sync by default. mongoUri # MUST point at an isolated database/user that cannot read the backend's diff --git a/docs/features/billing.md b/docs/features/billing.md index c59710da5..3dc8a0d58 100644 --- a/docs/features/billing.md +++ b/docs/features/billing.md @@ -8,6 +8,30 @@ Self-host installs omit the `stripe:` config block. `/api/config` then reports `billing.isConfigured: false`, the web never shows a paid gate, and event writes stay open. +## Pausing enforcement + +`billing.enforcement` is a separate, independent switch — the operator's global +kill switch for turning the whole trial/billing product on or off, regardless +of whether Stripe is configured. It defaults to `false` (paused): every user, +signed in or not, sees `{kind: "open"}` from `useAppAccess`, no chip, no gate +modal, no `localStorage` clock stamp, and `assertBillingAllowsWrites` no-ops +even with valid Stripe keys present. This lets Stripe keys and Checkout/webhook +work stay live in an environment while the product feels free to every user. + +Set it via `billing.enforcement: true` in `compass.yaml`, or the +`BILLING_ENFORCEMENT` GitHub Environment var for hosted deploys. Flipping it +requires a redeploy (or `./compass restart` if the yaml is already current) — +it is read once at backend startup, not polled. Web-side, it flows through +`/api/config`, which is fetched asynchronously and fails open: a pending or +errored config request always reads as paused, never as enforced, so a slow +network never flashes a gate at a user before the real value loads. + +**Enable-day caveat:** existing signed-in accounts are not grandfathered (see +below) — flipping `enforcement: true` while Stripe is configured immediately +puts any hosted user without a Stripe subscription id into `awaiting_checkout` +and shows them `BillingGateModal`. Run `bun run cli backfill-billing` (or set +`BACKFILL_CUTOFF`) first if that's not the intended effect for existing users. + ## What users see - **Never signed up:** the existing 7-day anonymous `localStorage` trial and diff --git a/packages/backend/src/billing/billing.guard.db.test.ts b/packages/backend/src/billing/billing.guard.db.test.ts index c6195347b..4ab32e670 100644 --- a/packages/backend/src/billing/billing.guard.db.test.ts +++ b/packages/backend/src/billing/billing.guard.db.test.ts @@ -21,6 +21,7 @@ const stripeConfigured = { STRIPE_SECRET_KEY: "rk_test_123", STRIPE_WEBHOOK_SECRET: "whsec_test", STRIPE_PRICE_ID: "price_test", + BILLING_ENFORCEMENT: true, }; const insertUser = async (billing?: Schema_UserBilling) => { @@ -45,6 +46,20 @@ describe("assertBillingAllowsWrites", () => { afterAll(cleanupTestDb); it("no-ops when Stripe is unconfigured", async () => { + using _env = mockEnv({ BILLING_ENFORCEMENT: true }); + const userId = await insertUser({ + subscriptionStatus: "awaiting_checkout", + }); + await expect(assertBillingAllowsWrites(userId)).resolves.toBeUndefined(); + }); + + it("no-ops when enforcement is paused, even with Stripe fully configured", async () => { + using _env = mockEnv({ + STRIPE_SECRET_KEY: "rk_test_123", + STRIPE_WEBHOOK_SECRET: "whsec_test", + STRIPE_PRICE_ID: "price_test", + BILLING_ENFORCEMENT: false, + }); const userId = await insertUser({ subscriptionStatus: "awaiting_checkout", }); diff --git a/packages/backend/src/billing/billing.guard.ts b/packages/backend/src/billing/billing.guard.ts index c0de0c9df..ac92c2c31 100644 --- a/packages/backend/src/billing/billing.guard.ts +++ b/packages/backend/src/billing/billing.guard.ts @@ -1,16 +1,20 @@ import { deriveBillingStatus } from "@backend/billing/services/billing.service"; import { CONFIG } from "@backend/common/constants/config.constants"; -import { isStripeConfigured } from "@backend/common/constants/config.util"; +import { + isBillingEnforced, + isStripeConfigured, +} from "@backend/common/constants/config.util"; import mongoService from "@backend/common/services/mongo.service"; import { eventMutationError } from "@backend/event/event.error"; /** * Throw BILLING_REQUIRED (403) when the user cannot mutate events. - * No-ops when Stripe is unconfigured so self-host stays fully writable. - * Lives in controllers, not the shared `/api/event` route chain, so GET - * stays open. + * No-ops when enforcement is paused (operator kill switch, e.g. pre-launch) + * or when Stripe is unconfigured (self-host). Lives in controllers, not the + * shared `/api/event` route chain, so GET stays open. */ export async function assertBillingAllowsWrites(userId: string): Promise { + if (!isBillingEnforced(CONFIG)) return; if (!isStripeConfigured(CONFIG)) return; const user = await mongoService.user.findOne( diff --git a/packages/backend/src/common/constants/config.constants.ts b/packages/backend/src/common/constants/config.constants.ts index 4de78eac4..3ef7f0775 100644 --- a/packages/backend/src/common/constants/config.constants.ts +++ b/packages/backend/src/common/constants/config.constants.ts @@ -15,6 +15,12 @@ const logger = Logger("app:constants"); const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; +// Accept a yaml boolean or the strings "true"/"false" (env vars are strings). +// Mirrors packages/sync/src/config/sync.config.ts's BooleanFromInput. +const BooleanFromInput = z + .union([z.boolean(), z.enum(["true", "false"])]) + .transform((value) => value === true || value === "true"); + const ConfigSchema = z .object({ BASEURL: z.string().nonempty(), @@ -49,6 +55,9 @@ const ConfigSchema = z STRIPE_SECRET_KEY: z.string().nonempty().optional(), STRIPE_WEBHOOK_SECRET: z.string().nonempty().optional(), STRIPE_PRICE_ID: z.string().nonempty().optional(), + // Operator pause switch: when false, trial/billing gates stay off for + // everyone regardless of Stripe configuration. + BILLING_ENFORCEMENT: BooleanFromInput.default(false), }) .strict() .superRefine((env, context) => { @@ -122,6 +131,7 @@ export function parseRawConfig(config: CompassConfig): Config { STRIPE_SECRET_KEY: nonEmpty(config.stripe?.secretKey), STRIPE_WEBHOOK_SECRET: nonEmpty(config.stripe?.webhookSecret), STRIPE_PRICE_ID: nonEmpty(config.stripe?.priceId), + BILLING_ENFORCEMENT: config.billing?.enforcement, }); } @@ -156,6 +166,7 @@ export function parseConfigFromEnv( STRIPE_SECRET_KEY: nonEmpty(rawEnv["STRIPE_SECRET_KEY"]), STRIPE_WEBHOOK_SECRET: nonEmpty(rawEnv["STRIPE_WEBHOOK_SECRET"]), STRIPE_PRICE_ID: nonEmpty(rawEnv["STRIPE_PRICE_ID"]), + BILLING_ENFORCEMENT: nonEmpty(rawEnv["BILLING_ENFORCEMENT"]), }); } diff --git a/packages/backend/src/common/constants/config.util.ts b/packages/backend/src/common/constants/config.util.ts index ea412495c..04f4890bc 100644 --- a/packages/backend/src/common/constants/config.util.ts +++ b/packages/backend/src/common/constants/config.util.ts @@ -24,3 +24,7 @@ export const isStripeConfigured = ( isStripeValueValid(env.STRIPE_SECRET_KEY) && isStripeValueValid(env.STRIPE_WEBHOOK_SECRET) && isStripeValueValid(env.STRIPE_PRICE_ID); + +export const isBillingEnforced = ( + env: Pick, +): boolean => env.BILLING_ENFORCEMENT; diff --git a/packages/backend/src/config/config.route.test.ts b/packages/backend/src/config/config.route.test.ts index e0235c7a9..d31da6d6c 100644 --- a/packages/backend/src/config/config.route.test.ts +++ b/packages/backend/src/config/config.route.test.ts @@ -28,6 +28,7 @@ describe("GET /api/config", () => { }, billing: { isConfigured: false, + enforcement: false, priceDisplay: "$7.99/month", trialLengthDays: 7, }, @@ -60,6 +61,7 @@ describe("GET /api/config", () => { }, billing: { isConfigured: false, + enforcement: false, priceDisplay: "$7.99/month", trialLengthDays: 7, }, diff --git a/packages/backend/src/config/controllers/config.controller.test.ts b/packages/backend/src/config/controllers/config.controller.test.ts index 51798c8c7..690dd9301 100644 --- a/packages/backend/src/config/controllers/config.controller.test.ts +++ b/packages/backend/src/config/controllers/config.controller.test.ts @@ -44,8 +44,27 @@ describe("ConfigController.get sync cutover posture", () => { }); expect(config.billing).toEqual({ isConfigured: false, + enforcement: false, priceDisplay: "$7.99/month", trialLengthDays: 7, }); }); }); + +describe("ConfigController.get billing enforcement", () => { + const original = CONFIG.BILLING_ENFORCEMENT; + + afterEach(() => { + CONFIG.BILLING_ENFORCEMENT = original; + }); + + it("defaults to paused", () => { + CONFIG.BILLING_ENFORCEMENT = false; + expect(invokeGet().billing.enforcement).toBe(false); + }); + + it("reports true once the operator enables it", () => { + CONFIG.BILLING_ENFORCEMENT = true; + expect(invokeGet().billing.enforcement).toBe(true); + }); +}); diff --git a/packages/backend/src/config/controllers/config.controller.ts b/packages/backend/src/config/controllers/config.controller.ts index 68bb7c391..f1996a808 100644 --- a/packages/backend/src/config/controllers/config.controller.ts +++ b/packages/backend/src/config/controllers/config.controller.ts @@ -3,6 +3,7 @@ import { BILLING_PLAN } from "@core/constants/billing.constants"; import { type AppConfig, AppConfigSchema } from "@core/types/config.types"; import { CONFIG } from "@backend/common/constants/config.constants"; import { + isBillingEnforced, isGoogleConfigured, isStripeConfigured, } from "@backend/common/constants/config.util"; @@ -21,6 +22,7 @@ class ConfigController { }, billing: { isConfigured: isStripeConfigured(CONFIG), + enforcement: isBillingEnforced(CONFIG), priceDisplay: BILLING_PLAN.PRICE_DISPLAY, trialLengthDays: BILLING_PLAN.TRIAL_LENGTH_DAYS, }, diff --git a/packages/core/src/config/compass.config.ts b/packages/core/src/config/compass.config.ts index adb461664..21e07867d 100644 --- a/packages/core/src/config/compass.config.ts +++ b/packages/core/src/config/compass.config.ts @@ -66,6 +66,17 @@ const CompassConfigSchema = z priceId: optionalString, }) .nullish(), + // Operator pause switch for trial/billing gates, independent of whether + // Stripe is configured. Omit or set false to keep the app free for + // everyone while `stripe:` stays populated for in-progress work. + // Accepts a yaml boolean or a "true"/"false" string (env-sourced deploy + // values arrive as strings) — normalized downstream in backend + // config.constants.ts, mirroring sync.enforceLeastPrivilege below. + billing: z + .object({ + enforcement: z.union([z.boolean(), z.string()]).optional(), + }) + .nullish(), // Compass Sync service configuration. Every deployment runs Sync (self-host // included) and delegates provider-connection and event routes to it — there // is no more legacy-vs-sync choice to make. The block itself stays optional diff --git a/packages/core/src/types/config.types.ts b/packages/core/src/types/config.types.ts index 7927f9f1c..f6aee3ceb 100644 --- a/packages/core/src/types/config.types.ts +++ b/packages/core/src/types/config.types.ts @@ -21,16 +21,21 @@ export const AppConfigSchema = z.object({ /** * Hosted billing. `isConfigured: false` is the self-host escape hatch: * the web must not render a paid gate, and the backend must not enforce - * read-only. Defaults keep old `/api/config` payloads parseable. + * read-only. `enforcement: false` is the operator pause switch: trial and + * billing gates stay off for everyone regardless of `isConfigured`, until + * the operator is ready to turn the product on. Defaults keep old + * `/api/config` payloads parseable and default to paused. */ billing: z .object({ isConfigured: z.boolean(), + enforcement: z.boolean().default(false), priceDisplay: z.string(), trialLengthDays: z.number(), }) .default({ isConfigured: false, + enforcement: false, priceDisplay: BILLING_PLAN.PRICE_DISPLAY, trialLengthDays: BILLING_PLAN.TRIAL_LENGTH_DAYS, }), diff --git a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts index 53724f7c6..13c9a0b7e 100644 --- a/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts +++ b/packages/web/src/__tests__/__mocks__/server/mock.handlers.ts @@ -85,6 +85,7 @@ export const globalHandlers = [ google: { isConfigured: false }, billing: { isConfigured: false, + enforcement: true, priceDisplay: "$7.99/month", trialLengthDays: 7, }, diff --git a/packages/web/src/billing/billing.query.ts b/packages/web/src/billing/billing.query.ts index 904ca0c69..d65f18d7b 100644 --- a/packages/web/src/billing/billing.query.ts +++ b/packages/web/src/billing/billing.query.ts @@ -38,6 +38,22 @@ export function useAppConfigQuery() { return useQuery(appConfigQueryOptions()); } +/** + * The operator pause switch. False (paused) whenever config is pending or + * errored, not just when the server says so — a signed visitor with an + * expired anonymous clock must never see a gate flash before config loads. + */ +export function isBillingEnforced( + config: { billing: { enforcement: boolean } } | undefined, +): boolean { + return config?.billing.enforcement === true; +} + +export function useBillingEnforced(): boolean { + const configQuery = useAppConfigQuery(); + return isBillingEnforced(configQuery.data); +} + /** * After Stripe Checkout returns `?checkout=success`, keep refetching billing * status for a short window so a late webhook does not leave the gate up. diff --git a/packages/web/src/billing/useAppAccess.test.tsx b/packages/web/src/billing/useAppAccess.test.tsx index 00f6caa67..8dabbbb70 100644 --- a/packages/web/src/billing/useAppAccess.test.tsx +++ b/packages/web/src/billing/useAppAccess.test.tsx @@ -30,7 +30,7 @@ const createWrapper = (authenticated = false, client?: QueryClient) => { ); }; -const stubConfig = (isConfigured: boolean) => { +const stubConfig = (isConfigured: boolean, enforcement = true) => { server.use( rest.get(`${ENV_WEB.API_BASEURL}/config`, (_req, res, ctx) => res( @@ -38,6 +38,7 @@ const stubConfig = (isConfigured: boolean) => { google: { isConfigured: false }, billing: { isConfigured, + enforcement, priceDisplay: "$7.99/month", trialLengthDays: 7, }, @@ -206,6 +207,59 @@ describe("useAppAccess", () => { hasUserEverAuthenticatedSpy.mockRestore(); }); + it("returns open when enforcement is paused, even with an expired trial stamp", async () => { + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(8)); + stubConfig(true, false); + + const { result } = renderHook(() => useAppAccess(), { + wrapper: createWrapper(), + }); + await waitFor(() => { + expect(result.current).toEqual({ kind: "open" }); + }); + }); + + it("returns open when enforcement is paused for an authenticated, read-only account", async () => { + stubConfig(true, false); + stubBilling({ + subscriptionStatus: "awaiting_checkout", + trialEndsAt: null, + isReadOnly: true, + }); + + const { result } = renderHook(() => useAppAccess(), { + wrapper: createWrapper(true), + }); + await waitFor(() => { + expect(result.current).toEqual({ kind: "open" }); + }); + }); + + it("fails open while config is pending, even with an expired trial stamp", () => { + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(8)); + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/config`, (_req, res, ctx) => + res( + ctx.delay(500), + ctx.json({ + google: { isConfigured: false }, + billing: { + isConfigured: true, + enforcement: true, + priceDisplay: "$7.99/month", + trialLengthDays: 7, + }, + }), + ), + ), + ); + + const { result } = renderHook(() => useAppAccess(), { + wrapper: createWrapper(), + }); + expect(result.current).toEqual({ kind: "open" }); + }); + it("does not keep a cached read-only billing gate after the session is gone", () => { const hasUserEverAuthenticatedSpy = spyOn( authStateUtil, diff --git a/packages/web/src/billing/useAppAccess.ts b/packages/web/src/billing/useAppAccess.ts index 319263da6..0c8bfa7eb 100644 --- a/packages/web/src/billing/useAppAccess.ts +++ b/packages/web/src/billing/useAppAccess.ts @@ -2,6 +2,7 @@ import { useContext } from "react"; import { type BillingSubscriptionStatus } from "@core/types/user.types"; import { SessionContext } from "@web/auth/compass/session/session.context"; import { + isBillingEnforced, useAppConfigQuery, useBillingStatusQuery, } from "@web/billing/billing.query"; @@ -25,19 +26,26 @@ export type AppAccess = * Reconciles the anonymous localStorage trial with server billing. * * Never-signed-up visitors keep today's anonymous trial (and TrialGateModal). - * Signed-in users read `/api/billing/status`. Fail open: a loading, error, or - * unconfigured-Stripe state never locks a paying user out of their calendar. + * Signed-in users read `/api/billing/status`. Fail open: a loading, error, + * unconfigured-Stripe, or paused-enforcement state never locks a paying user + * out of their calendar. */ export function useAppAccess(): AppAccess { const { authenticated } = useContext(SessionContext); const trial = useTrialStatus(); const configQuery = useAppConfigQuery(); + const enforced = isBillingEnforced(configQuery.data); const billingEnabled = + enforced && authenticated && !trial.isAnonymousTrial && configQuery.data?.billing.isConfigured === true; const billingQuery = useBillingStatusQuery(billingEnabled); + if (!enforced) { + return { kind: "open" }; + } + if (trial.isAnonymousTrial) { return { kind: "anonymous-trial", diff --git a/packages/web/src/billing/useTrialStatus.test.ts b/packages/web/src/billing/useTrialStatus.test.ts deleted file mode 100644 index 5f0c386d0..000000000 --- a/packages/web/src/billing/useTrialStatus.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { renderHook } from "@testing-library/react"; -import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; -import { useTrialStatus } from "@web/billing/useTrialStatus"; -import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; -import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; -import { beforeEach, describe, expect, it, spyOn } from "bun:test"; - -const daysAgo = (days: number) => - new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); - -describe("useTrialStatus", () => { - beforeEach(() => { - localStorage.clear(); - }); - - it("starts the clock on first use and reports a full trial", () => { - const { result } = renderHook(() => useTrialStatus()); - - expect(result.current.isExpired).toBe(false); - expect(result.current.daysLeft).toBe(7); - expect(result.current.isAnonymousTrial).toBe(true); - expect( - persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT), - ).toBeTruthy(); - }); - - it("counts down without expiring inside the window", () => { - persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(5)); - - const { result } = renderHook(() => useTrialStatus()); - - expect(result.current.daysLeft).toBe(2); - expect(result.current.isExpired).toBe(false); - }); - - it("expires once the window has passed", () => { - persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(8)); - - const { result } = renderHook(() => useTrialStatus()); - - expect(result.current.daysLeft).toBe(0); - expect(result.current.isExpired).toBe(true); - }); - - // Regression: `authenticated` from SessionContext is false until the async - // SuperTokens check resolves, so it cannot be the only guard. Someone who - // tried Compass anonymously, signed up, and kept the same browser still has - // a stale trial.started-at; gating on the context alone flashed "your trial - // has ended" at them on every load. - // - // hasUserEverAuthenticated is spied directly rather than driven through - // real localStorage state: several other test files in this suite - // mock.module the whole auth.state.util module with a bare `hasAuthenticated` - // stub and no restoration, which leaks process-wide across bun test files - - // spying on this file's own resolved binding sidesteps that ordering- - // dependent pollution instead of adding to it. - it("never gates a user who has authenticated before, despite a stale expired clock", () => { - persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(30)); - const hasUserEverAuthenticatedSpy = spyOn( - authStateUtil, - "hasUserEverAuthenticated", - ).mockReturnValue(true); - - const { result } = renderHook(() => useTrialStatus()); - - expect(result.current.isExpired).toBe(false); - expect(result.current.isAnonymousTrial).toBe(false); - - hasUserEverAuthenticatedSpy.mockRestore(); - }); -}); diff --git a/packages/web/src/billing/useTrialStatus.test.tsx b/packages/web/src/billing/useTrialStatus.test.tsx new file mode 100644 index 000000000..64ab44708 --- /dev/null +++ b/packages/web/src/billing/useTrialStatus.test.tsx @@ -0,0 +1,138 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { rest } from "msw"; +import { type PropsWithChildren } from "react"; +import { server } from "@web/__tests__/__mocks__/server/mock.server"; +import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; +import { billingQueryKeys } from "@web/billing/billing.query"; +import { useTrialStatus } from "@web/billing/useTrialStatus"; +import { ENV_WEB } from "@web/common/constants/env.constants"; +import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; +import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; + +const daysAgo = (days: number) => + new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + +const stubConfig = (enforcement: boolean) => { + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/config`, (_req, res, ctx) => + res( + ctx.json({ + google: { isConfigured: false }, + billing: { + isConfigured: true, + enforcement, + priceDisplay: "$7.99/month", + trialLengthDays: 7, + }, + }), + ), + ), + ); +}; + +const renderTrialStatus = async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const rendered = renderHook(() => useTrialStatus(), { wrapper }); + await waitFor(() => { + expect(queryClient.getQueryState(billingQueryKeys.config)?.status).not.toBe( + "pending", + ); + }); + return rendered; +}; + +describe("useTrialStatus", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("starts the clock on first use and reports a full trial", async () => { + stubConfig(true); + const { result } = await renderTrialStatus(); + + expect(result.current.isExpired).toBe(false); + expect(result.current.daysLeft).toBe(7); + expect(result.current.isAnonymousTrial).toBe(true); + expect( + persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT), + ).toBeTruthy(); + }); + + it("counts down without expiring inside the window", async () => { + stubConfig(true); + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(5)); + + const { result } = await renderTrialStatus(); + + expect(result.current.daysLeft).toBe(2); + expect(result.current.isExpired).toBe(false); + }); + + it("expires once the window has passed", async () => { + stubConfig(true); + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(8)); + + const { result } = await renderTrialStatus(); + + expect(result.current.daysLeft).toBe(0); + expect(result.current.isExpired).toBe(true); + }); + + // Regression: `authenticated` from SessionContext is false until the async + // SuperTokens check resolves, so it cannot be the only guard. Someone who + // tried Compass anonymously, signed up, and kept the same browser still has + // a stale trial.started-at; gating on the context alone flashed "your trial + // has ended" at them on every load. + // + // hasUserEverAuthenticated is spied directly rather than driven through + // real localStorage state: several other test files in this suite + // mock.module the whole auth.state.util module with a bare `hasAuthenticated` + // stub and no restoration, which leaks process-wide across bun test files - + // spying on this file's own resolved binding sidesteps that ordering- + // dependent pollution instead of adding to it. + it("never gates a user who has authenticated before, despite a stale expired clock", async () => { + stubConfig(true); + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(30)); + const hasUserEverAuthenticatedSpy = spyOn( + authStateUtil, + "hasUserEverAuthenticated", + ).mockReturnValue(true); + + const { result } = await renderTrialStatus(); + + expect(result.current.isExpired).toBe(false); + expect(result.current.isAnonymousTrial).toBe(false); + + hasUserEverAuthenticatedSpy.mockRestore(); + }); + + // Regression: pausing must not burn the clock. If we stamped + // trial.started-at while paused, flipping enforcement on later would find + // a browser whose 7 days were already ticking away during the pause. + it("does not gate and does not stamp the clock while enforcement is paused", async () => { + stubConfig(false); + persistentBrowserStore.set(STORAGE_KEYS.TRIAL_STARTED_AT, daysAgo(30)); + + const { result } = await renderTrialStatus(); + + expect(result.current.isExpired).toBe(false); + expect(result.current.isAnonymousTrial).toBe(false); + }); + + it("does not stamp a fresh visitor's clock while enforcement is paused", async () => { + stubConfig(false); + + await renderTrialStatus(); + + expect( + persistentBrowserStore.get(STORAGE_KEYS.TRIAL_STARTED_AT), + ).toBeNull(); + }); +}); diff --git a/packages/web/src/billing/useTrialStatus.ts b/packages/web/src/billing/useTrialStatus.ts index fa5d55bf3..d9fd55354 100644 --- a/packages/web/src/billing/useTrialStatus.ts +++ b/packages/web/src/billing/useTrialStatus.ts @@ -2,6 +2,7 @@ import { useContext, useEffect, useState } from "react"; import { SessionContext } from "@web/auth/compass/session/session.context"; import { hasUserEverAuthenticated } from "@web/auth/compass/state/auth.state.util"; import { track } from "@web/auth/posthog/track"; +import { useBillingEnforced } from "@web/billing/billing.query"; import { ensureTrialStarted, getTrialDaysLeft, @@ -31,13 +32,19 @@ export type TrialStatus = { * months-old trial.started-at behind. Gating on that alone would flash "your * trial has ended" at a signed-up user on every load. hasUserEverAuthenticated * reads localStorage synchronously, so the gate never renders for them. + * + * While the operator pause switch (`billing.enforcement`) is off, everyone is + * exempt and the clock is never stamped — flipping enforcement on later + * should not instantly expire visitors who browsed during the pause. */ export function useTrialStatus(): TrialStatus { const { authenticated } = useContext(SessionContext); - const isGateExempt = authenticated || hasUserEverAuthenticated(); + const enforced = useBillingEnforced(); + const isGateExempt = !enforced || authenticated || hasUserEverAuthenticated(); const [daysLeft, setDaysLeft] = useState(() => getTrialDaysLeft()); useEffect(() => { + if (!enforced) return; if (ensureTrialStarted()) { track("trial_started"); } @@ -51,7 +58,7 @@ export function useTrialStatus(): TrialStatus { document.removeEventListener("visibilitychange", recompute); window.removeEventListener("focus", recompute); }; - }, [isGateExempt]); + }, [enforced, isGateExempt]); if (isGateExempt) { return { diff --git a/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx b/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx index b228bc52e..06048b1f9 100644 --- a/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx +++ b/packages/web/src/components/Sidebar/SidebarStatusBar.test.tsx @@ -85,7 +85,7 @@ describe("SidebarStatusBar", () => { expect(screen.getByText("Saving changes…")).toBeInTheDocument(); }); - it("reserves space for the status line when idle, showing the anonymous trial chip", () => { + it("reserves space for the status line when idle, showing the anonymous trial chip", async () => { const { wrapper } = createStoreWrapper(); render(, { wrapper }); @@ -93,8 +93,10 @@ describe("SidebarStatusBar", () => { // No SessionContext.Provider means these tests render as anonymous, so // an idle bar falls back to the trial countdown chip rather than blank // space — there is no truly empty state for an anonymous user anymore. + // The chip depends on the (async, MSW-stubbed) /config enforcement + // check, so this settles a beat after the initial render. expect( - screen.getByRole("button", { name: /Trial: \d+ days? left/ }), + await screen.findByRole("button", { name: /Trial: \d+ days? left/ }), ).toBeInTheDocument(); });