Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/_deploy-environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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}\"" \
Expand Down
6 changes: 6 additions & 0 deletions compass.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions docs/features/billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/backend/src/billing/billing.guard.db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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",
});
Expand Down
12 changes: 8 additions & 4 deletions packages/backend/src/billing/billing.guard.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
if (!isBillingEnforced(CONFIG)) return;
if (!isStripeConfigured(CONFIG)) return;

const user = await mongoService.user.findOne(
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/src/common/constants/config.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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"]),
});
}

Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/common/constants/config.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Config, "BILLING_ENFORCEMENT">,
): boolean => env.BILLING_ENFORCEMENT;
2 changes: 2 additions & 0 deletions packages/backend/src/config/config.route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe("GET /api/config", () => {
},
billing: {
isConfigured: false,
enforcement: false,
priceDisplay: "$7.99/month",
trialLengthDays: 7,
},
Expand Down Expand Up @@ -60,6 +61,7 @@ describe("GET /api/config", () => {
},
billing: {
isConfigured: false,
enforcement: false,
priceDisplay: "$7.99/month",
trialLengthDays: 7,
},
Expand Down
19 changes: 19 additions & 0 deletions packages/backend/src/config/controllers/config.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 2 additions & 0 deletions packages/backend/src/config/controllers/config.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +22,7 @@ class ConfigController {
},
billing: {
isConfigured: isStripeConfigured(CONFIG),
enforcement: isBillingEnforced(CONFIG),
priceDisplay: BILLING_PLAN.PRICE_DISPLAY,
trialLengthDays: BILLING_PLAN.TRIAL_LENGTH_DAYS,
},
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/config/compass.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/types/config.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const globalHandlers = [
google: { isConfigured: false },
billing: {
isConfigured: false,
enforcement: true,
priceDisplay: "$7.99/month",
trialLengthDays: 7,
},
Expand Down
16 changes: 16 additions & 0 deletions packages/web/src/billing/billing.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 55 additions & 1 deletion packages/web/src/billing/useAppAccess.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ 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(
ctx.json({
google: { isConfigured: false },
billing: {
isConfigured,
enforcement,
priceDisplay: "$7.99/month",
trialLengthDays: 7,
},
Expand Down Expand Up @@ -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,
Expand Down
Loading