[Feat]: set flag to disable billing (#1417)

### Context
There are some kinks to work out with deploying plan limits onto prod,
so we'd like to disable it temporarily.

### Summary of Changes
We update all call sites of the item quantity things with a flag based
check. Idea is when flag is set to true, it should function as if there
are no limits.
This commit is contained in:
Aman Ganapathy 2026-05-06 14:58:06 -07:00 committed by GitHub
parent bd8c4489ed
commit bc6347e3c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 83 additions and 15 deletions

View File

@ -34,6 +34,8 @@ STACK_SPOTIFY_CLIENT_SECRET=# client secret
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=# allow shared oauth provider to also use connected account access token, this should only be used for development and testing
STACK_DISABLE_PLAN_LIMITS=# set to "true" to bypass enforcement of Stack Auth's own internal-tenancy plan limits (analytics_events, session_replays, emails_per_month, dashboard_admins seat cap, auth_users soft cap, analytics_timeout_seconds). Default unset/false preserves enforcement. Intended as a temporary cutover safety net while the plan-limits infrastructure rolls out — customer projects' own item APIs are unaffected by this flag.
# Email
# For local development, you can spin up a local SMTP server like inbucket
STACK_EMAIL_HOST=# for local inbucket: 127.0.0.1

View File

@ -43,6 +43,12 @@ STACK_SPOTIFY_CLIENT_SECRET=MOCK
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true
# Default to enforcing plan limits in local dev so behavior matches prod.
# Flip to "true" to bypass every Stack-Auth-internal plan-limit enforcement
# site (e.g. session_replays, analytics_events, emails_per_month). See
# apps/backend/src/lib/plan-entitlements.ts:arePlanLimitsEnforced.
STACK_DISABLE_PLAN_LIMITS=false
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28/stackframe
STACK_DATABASE_REPLICA_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34/stackframe
STACK_DATABASE_REPLICATION_WAIT_STRATEGY=pg-stat-replication

View File

@ -1,5 +1,5 @@
import { getClickhouseAdminClient } from "@/lib/clickhouse";
import { getBillingTeamId } from "@/lib/plan-entitlements";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { findRecentSessionReplay } from "@/lib/session-replays";
import { getStackServerApp } from "@/stack";
import { getPrismaClientForTenancy } from "@/prisma-client";
@ -121,7 +121,7 @@ export const POST = createSmartRouteHandler({
const app = getStackServerApp();
const billingTeamId = getBillingTeamId(auth.tenancy.project);
if (billingTeamId != null) {
if (billingTeamId != null && arePlanLimitsEnforced()) {
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length);
if (!isDebited) {

View File

@ -1,6 +1,6 @@
import { getClickhouseExternalClient } from "@/lib/clickhouse";
import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors";
import { getBillingTeamId } from "@/lib/plan-entitlements";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getStackServerApp } from "@/stack";
import { KnownErrors } from "@stackframe/stack-shared";
@ -42,7 +42,7 @@ export const POST = createSmartRouteHandler({
let effectiveTimeoutMs = body.timeout_ms;
const billingTeamId = getBillingTeamId(auth.tenancy.project);
if (billingTeamId != null) {
if (billingTeamId != null && arePlanLimitsEnforced()) {
const app = getStackServerApp();
const timeoutItem = await app.getItem({ itemId: ITEM_IDS.analyticsTimeoutSeconds, teamId: billingTeamId });
// clickHouse treats max_execution_time=0 as

View File

@ -1,5 +1,5 @@
import { isSecureEmailPort, lowLevelSendEmailDirectWithoutRetries } from "@/lib/emails-low-level";
import { getBillingTeamId } from "@/lib/plan-entitlements";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { getStackServerApp } from "@/stack";
import { KnownErrors } from "@stackframe/stack-shared";
@ -49,7 +49,7 @@ export const POST = createSmartRouteHandler({
// The debit is refunded on any failure below so admins iterating on an
// incorrect SMTP config don't burn through their monthly quota.
const billingTeamId = getBillingTeamId(auth.tenancy.project);
const emailItem = billingTeamId == null
const emailItem = billingTeamId == null || !arePlanLimitsEnforced()
? null
: await getStackServerApp().getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: billingTeamId });
if (emailItem != null && billingTeamId != null) {

View File

@ -2,7 +2,7 @@ import { getPrismaClientForTenancy } from "@/prisma-client";
import { uploadBytes } from "@/s3";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { Prisma } from "@/generated/prisma/client";
import { getBillingTeamId } from "@/lib/plan-entitlements";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { findRecentSessionReplay } from "@/lib/session-replays";
import { getStackServerApp } from "@/stack";
import { KnownErrors } from "@stackframe/stack-shared";
@ -113,7 +113,7 @@ export const POST = createSmartRouteHandler({
const isNewSession = recentSession == null;
const billingTeamId = getBillingTeamId(auth.tenancy.project);
if (isNewSession && billingTeamId != null) {
if (isNewSession && billingTeamId != null && arePlanLimitsEnforced()) {
const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId });
const isDebited = await replaysItem.tryDecreaseQuantity(1);
if (!isDebited) {

View File

@ -1,5 +1,6 @@
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
import { getItemQuantityForCustomer } from "@/lib/payments/customer-data";
import { arePlanLimitsEnforced } from "@/lib/plan-entitlements";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { globalPrismaClient } from "@/prisma-client";
import { VerificationCodeType } from "@/generated/prisma/client";
@ -104,7 +105,7 @@ export const POST = createSmartRouteHandler({
}
await retryTransaction(prisma, async (tx) => {
if (auth.tenancy.project.id === "internal") {
if (auth.tenancy.project.id === "internal" && arePlanLimitsEnforced()) {
const currentMemberCount = await tx.teamMember.count({
where: {
tenancyId: auth.tenancy.id,

View File

@ -1,6 +1,7 @@
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
import { normalizeEmail, sendEmailFromDefaultTemplate } from "@/lib/emails";
import { getItemQuantityForCustomer } from "@/lib/payments/customer-data";
import { arePlanLimitsEnforced } from "@/lib/plan-entitlements";
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
@ -102,7 +103,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
}
const prisma = await getPrismaClientForTenancy(tenancy);
if (tenancy.project.id === "internal") {
if (tenancy.project.id === "internal" && arePlanLimitsEnforced()) {
const currentMemberCount = await prisma.teamMember.count({
where: {
tenancyId: tenancy.id,

View File

@ -2,7 +2,7 @@ import { BooleanTrue, Prisma } from "@/generated/prisma/client";
import { getRenderedOrganizationConfigQuery, getRenderedProjectConfigQuery } from "@/lib/config";
import { demoteAllContactChannelsToNonPrimary, setContactChannelAsPrimaryByValue } from "@/lib/contact-channel";
import { normalizeEmail } from "@/lib/emails";
import { getBillingTeamId, getTeamWideAuthUsersCapacity, getTeamWideNonAnonymousUserCount } from "@/lib/plan-entitlements";
import { arePlanLimitsEnforced, getBillingTeamId, getTeamWideAuthUsersCapacity, getTeamWideNonAnonymousUserCount } from "@/lib/plan-entitlements";
import { recordExternalDbSyncContactChannelDeletionsForUser, recordExternalDbSyncDeletion, recordExternalDbSyncNotificationPreferenceDeletionsForUser, recordExternalDbSyncOAuthAccountDeletionsForUser, recordExternalDbSyncProjectPermissionDeletionsForUser, recordExternalDbSyncRefreshTokenDeletionsForUser, recordExternalDbSyncTeamMemberDeletionsForUser, recordExternalDbSyncTeamPermissionDeletionsForUser, withExternalDbSyncUpdate } from "@/lib/external-db-sync";
import { grantDefaultProjectPermissions } from "@/lib/permissions";
import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks";
@ -268,6 +268,9 @@ async function checkAuthUsersSoftLimit(tenancy: Tenancy) {
if (getEnvVariable('STACK_SEED_MODE', '') === 'true') {
return;
}
if (!arePlanLimitsEnforced()) {
return;
}
const billingTeamId = getBillingTeamId(tenancy.project);
if (billingTeamId == null) {
return;

View File

@ -3,7 +3,7 @@ import { calculateCapacityRate, getEmailCapacityBoostExpiresAt, getEmailDelivery
import { getEmailThemeForThemeId, renderEmailsForTenancyBatched } from "@/lib/email-rendering";
import { EmailOutboxRecipient, getEmailConfig, } from "@/lib/emails";
import { generateUnsubscribeLink, getNotificationCategoryById, hasNotificationEnabled, listNotificationCategories } from "@/lib/notification-categories";
import { getBillingTeamId } from "@/lib/plan-entitlements";
import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements";
import { getStackServerApp } from "@/stack";
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
import { getTenancy, Tenancy } from "@/lib/tenancies";
@ -693,7 +693,7 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO
}
}
if (context.billingTeamId != null && row.sendRetries === 0) {
if (context.billingTeamId != null && row.sendRetries === 0 && arePlanLimitsEnforced()) {
const app = getStackServerApp();
const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId });
const isDebited = await emailItem.tryDecreaseQuantity(1);

View File

@ -1,4 +1,5 @@
import withPostHog from "@/analytics";
import { arePlanLimitsEnforced } from "@/lib/plan-entitlements";
import { globalPrismaClient } from "@/prisma-client";
import { getStackServerApp } from "@/stack";
import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks";
@ -276,7 +277,7 @@ export async function logEvent<T extends EventType[]>(
runAsynchronouslyAndWaitUntil((async () => {
const billingTeamId = options.billingTeamId;
if (billingTeamId != null) {
if (billingTeamId != null && arePlanLimitsEnforced()) {
const app = getStackServerApp();
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
const isDebited = await eventsItem.tryDecreaseQuantity(1);

View File

@ -1,7 +1,8 @@
import type { PrismaClientTransaction } from "@/prisma-client";
import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
arePlanLimitsEnforced,
getBillingTeamId,
getOwnedProjectIdsForBillingTeam,
getOwnedTenancyIdsForBillingTeam,
@ -186,3 +187,33 @@ describe("capacity lookup helpers", () => {
)).rejects.toThrow("Unsupported team-wide capacity item id");
});
});
describe("arePlanLimitsEnforced", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns true when env var is unset (default-on enforcement)", () => {
vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", "");
expect(arePlanLimitsEnforced()).toBe(true);
});
it("returns false when env var is exactly 'true'", () => {
vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", "true");
expect(arePlanLimitsEnforced()).toBe(false);
});
it("returns true when env var is 'false'", () => {
vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", "false");
expect(arePlanLimitsEnforced()).toBe(true);
});
it("returns true for any non-'true' value (e.g. '1', 'yes', 'TRUE')", () => {
// Explicit string match is intentional — we don't want to risk a typo
// like STACK_DISABLE_PLAN_LIMITS=trueee silently disabling enforcement.
for (const value of ["1", "yes", "TRUE", "True", " true", "true ", "trueee"]) {
vi.stubEnv("STACK_DISABLE_PLAN_LIMITS", value);
expect(arePlanLimitsEnforced()).toBe(true);
}
});
});

View File

@ -1,9 +1,32 @@
import { getItemQuantityForCustomer } from "@/lib/payments/customer-data";
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "./tenancies";
/**
* Whether Stack Auth's own plan-limit enforcement (quotas like `analytics_events`,
* `session_replays`, `emails_per_month`, the `auth_users` soft cap, and the
* `dashboard_admins` seat check) should be enforced for billing teams in the
* internal tenancy.
*
* Setting `STACK_DISABLE_PLAN_LIMITS=true` short-circuits every enforcement
* site BEFORE the underlying `getItem` lookup, so missing item config (e.g.
* a deploy where the internal tenancy hasn't been migrated to include the
* new items yet) cannot cascade into 500s either.
*
* Intended as a temporary cutover safety net while the plan-limits
* infrastructure rolls out to prod; the flag should be removed once we trust
* enforcement to behave correctly in every environment.
*
* Customer projects' own item APIs (`/payments/items/.../update-quantity`)
* are unaffected by this flag.
*/
export function arePlanLimitsEnforced(): boolean {
return getEnvVariable("STACK_DISABLE_PLAN_LIMITS", "false") !== "true";
}
type GlobalPrismaLike = {
project: {
findMany: (args: { where: { ownerTeamId: string }, select: { id: true } }) => Promise<Array<{ id: string }>>,