From 6a7afd14ed2bf75244d31749acf1867b9bca13f8 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 16 Feb 2026 18:39:01 -0800 Subject: [PATCH 01/38] feat: extract product ids to helper, sanitize dates returned from stripe for subscription stripe mock gives us invalifd subscription dates which means we dont get the item. so we sanitize it to make it a valid range. In prod, this is unlikely to happen but it will serve as a guard. --- apps/backend/prisma/seed.ts | 96 +++++++++---------- .../[customer_id]/switch/route.ts | 15 +-- apps/backend/src/lib/stripe.tsx | 50 ++++++++-- packages/stack-shared/src/plans.ts | 65 +++++++++++++ 4 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 packages/stack-shared/src/plans.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index d3bd0ecb81..4b8d9c4cc4 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -11,6 +11,7 @@ import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config'; import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails'; import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects'; +import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans'; import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects'; @@ -119,9 +120,24 @@ export async function seed() { }, }, products: { - team_plans: { + free: { + productLineId: "plans", + displayName: "Free", + customerType: "team", + serverOnly: false, + stackable: false, + prices: "include-by-default", + includedItems: { + [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + }, + }, + team: { productLineId: "plans", - displayName: "Team Plans", + displayName: "Team", customerType: "team", serverOnly: false, stackable: false, @@ -129,16 +145,16 @@ export async function seed() { monthly: { USD: "49", interval: [1, "month"] as any, - serverOnly: false - } + serverOnly: false, + }, }, includedItems: { - dashboard_admins: { - quantity: 3, - repeat: "never", - expires: "when-purchase-expires" - } - } + [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + }, }, growth: { productLineId: "plans", @@ -150,63 +166,45 @@ export async function seed() { monthly: { USD: "299", interval: [1, "month"] as any, - serverOnly: false - } + serverOnly: false, + }, }, includedItems: { - dashboard_admins: { - quantity: 5, - repeat: "never", - expires: "when-purchase-expires" - } - } - }, - free: { - productLineId: "plans", - displayName: "Free", - customerType: "team", - serverOnly: false, - stackable: false, - prices: "include-by-default", - includedItems: { - dashboard_admins: { - quantity: 1, - repeat: "never", - expires: "when-purchase-expires" - } - } + [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + }, }, - "extra-admins": { + "extra-seats": { productLineId: "plans", - displayName: "Extra Admins", + displayName: "Extra Seats", customerType: "team", serverOnly: false, stackable: true, prices: { monthly: { - USD: "49", + USD: "29", interval: [1, "month"] as any, - serverOnly: false - } + serverOnly: false, + }, }, includedItems: { - dashboard_admins: { - quantity: 1, - repeat: "never", - expires: "when-purchase-expires" - } + [ITEM_IDS.seats]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, isAddOnTo: { team: true, growth: true, - } - } + }, + }, }, items: { - dashboard_admins: { - displayName: "Dashboard Admins", - customerType: "team" - } + [ITEM_IDS.seats]: { displayName: "Dashboard Admins", customerType: "team" as const }, + [ITEM_IDS.authUsers]: { displayName: "Auth Users", customerType: "team" as const }, + [ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const }, + [ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const }, + [ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const }, }, }, apps: { diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index be901785e3..0b0e880742 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -1,6 +1,6 @@ +import { SubscriptionStatus } from "@/generated/prisma/client"; import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments"; -import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; -import { getStripeForAccount } from "@/lib/stripe"; +import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -8,7 +8,6 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupOb import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrUndefined, typedEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { SubscriptionStatus } from "@/generated/prisma/client"; import Stripe from "stripe"; @@ -200,6 +199,7 @@ export const POST = createSmartRouteHandler({ }, }); const updatedSubscription = updated as Stripe.Subscription; + const sanitizedUpdateDates = sanitizeStripePeriodDates(existingItem.current_period_start, existingItem.current_period_end); await prisma.subscription.update({ where: { @@ -214,8 +214,8 @@ export const POST = createSmartRouteHandler({ priceId: selectedPriceId, quantity, status: updatedSubscription.status, - currentPeriodStart: new Date(existingItem.current_period_start * 1000), - currentPeriodEnd: new Date(existingItem.current_period_end * 1000), + currentPeriodStart: sanitizedUpdateDates.start, + currentPeriodEnd: sanitizedUpdateDates.end, cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end, }, }); @@ -248,6 +248,7 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Stripe subscription has no items", { stripeSubscriptionId: createdSubscription.id }); } const createdItem = createdSubscription.items.data[0]; + const sanitizedCreateDates = sanitizeStripePeriodDates(createdItem.current_period_start, createdItem.current_period_end); await prisma.subscription.create({ data: { @@ -260,8 +261,8 @@ export const POST = createSmartRouteHandler({ quantity, stripeSubscriptionId: createdSubscription.id, status: createdSubscription.status, - currentPeriodStart: new Date(createdItem.current_period_start * 1000), - currentPeriodEnd: new Date(createdItem.current_period_end * 1000), + currentPeriodStart: sanitizedCreateDates.start, + currentPeriodEnd: sanitizedCreateDates.end, cancelAtPeriodEnd: createdSubscription.cancel_at_period_end, creationSource: "PURCHASE_PAGE", }, diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 79d60c76bb..cf96b83914 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,12 +1,12 @@ +import { CustomerType } from "@/generated/prisma/client"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; -import { CustomerType } from "@/generated/prisma/client"; +import { InputJsonValue } from "@prisma/client/runtime/client"; import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import Stripe from "stripe"; import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy"; -import { InputJsonValue } from "@prisma/client/runtime/client"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()); @@ -17,6 +17,43 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? { port: Number(`${stackPortPrefix}23`), } : {}; +/** + * Sanitizes subscription period dates from Stripe. + * + * The Stripe mock returns hardcoded fixture dates that are invalid (e.g., start in 2030, end in 2000). + * This function detects invalid dates and replaces them with sensible defaults. + * + * @param startTimestamp - Unix timestamp in seconds for period start + * @param endTimestamp - Unix timestamp in seconds for period end + * @param intervalMonths - Billing interval in months (default: 1) + * @returns Sanitized Date objects for start and end + */ +export function sanitizeStripePeriodDates( + startTimestamp: number, + endTimestamp: number, + intervalMonths: number = 1 +): { start: Date, end: Date } { + const now = new Date(); + const startDate = new Date(startTimestamp * 1000); + const endDate = new Date(endTimestamp * 1000); + + const tenYearsMs = 10 * 365 * 24 * 60 * 60 * 1000; + const isStartValid = startDate.getTime() > 0 && Math.abs(startDate.getTime() - now.getTime()) < tenYearsMs; + const isEndValid = endDate.getTime() > 0 && Math.abs(endDate.getTime() - now.getTime()) < tenYearsMs; + const isOrderValid = startDate < endDate; + + if (isStartValid && isEndValid && isOrderValid) { + return { start: startDate, end: endDate }; + } + + // Dates are invalid (likely from Stripe mock), use sensible defaults + const defaultStart = now; + const defaultEnd = new Date(now); + defaultEnd.setMonth(defaultEnd.getMonth() + intervalMonths); + + return { start: defaultStart, end: defaultEnd }; +} + export const getStackStripe = (overrides?: StripeOverridesMap) => { if (!stripeSecretKey) { throw new StackAssertionError("STACK_STRIPE_SECRET_KEY environment variable is not set"); @@ -92,6 +129,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s continue; } const item = subscription.items.data[0]; + const sanitizedDates = sanitizeStripePeriodDates(item.current_period_start, item.current_period_end); const priceId = subscription.metadata.priceId as string | undefined; // old subscriptions were created with offer metadata instead of product metadata const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined; @@ -116,8 +154,8 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s status: subscription.status, product: productJson, quantity: item.quantity ?? 1, - currentPeriodEnd: new Date(item.current_period_end * 1000), - currentPeriodStart: new Date(item.current_period_start * 1000), + currentPeriodEnd: sanitizedDates.end, + currentPeriodStart: sanitizedDates.start, cancelAtPeriodEnd: subscription.cancel_at_period_end, priceId: priceId ?? null, }, @@ -131,8 +169,8 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s quantity: item.quantity ?? 1, stripeSubscriptionId: subscription.id, status: subscription.status, - currentPeriodEnd: new Date(item.current_period_end * 1000), - currentPeriodStart: new Date(item.current_period_start * 1000), + currentPeriodEnd: sanitizedDates.end, + currentPeriodStart: sanitizedDates.start, cancelAtPeriodEnd: subscription.cancel_at_period_end, creationSource: "PURCHASE_PAGE" }, diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts new file mode 100644 index 0000000000..d737dce740 --- /dev/null +++ b/packages/stack-shared/src/plans.ts @@ -0,0 +1,65 @@ +/** + * Plan configuration for Stack Auth pricing tiers. + * + * This file defines the limits for each plan and the item IDs used to track them. + * Import these constants in seed.ts and backend code for limit enforcement. + */ + +export const UNLIMITED = 1_000_000_000; + +/** + * Item IDs used across the codebase for tracking plan limits. + */ +export const ITEM_IDS = { + seats: "dashboard_admins", + authUsers: "auth_users", + emailsPerMonth: "emails_per_month", + analyticsTimeoutSeconds: "analytics_timeout_seconds", + analyticsEvents: "analytics_events", +} as const; + +export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS]; + +/** + * The offerings/limits included in a plan. + */ +export type PlanProductOfferings = { + seats: number, + authUsers: number, + emailsPerMonth: number, + analyticsTimeoutSeconds: number, + analyticsEvents: number, +}; + +/** + * Plan limits by plan ID. + */ +export const PLAN_LIMITS: { + free: PlanProductOfferings, + team: PlanProductOfferings, + growth: PlanProductOfferings, +} = { + free: { + seats: 1, + authUsers: 10_000, + emailsPerMonth: 1_000, + analyticsTimeoutSeconds: 10, + analyticsEvents: 100_000, + }, + team: { + seats: 4, + authUsers: 50_000, + emailsPerMonth: 25_000, + analyticsTimeoutSeconds: 60, + analyticsEvents: 500_000, + }, + growth: { + seats: UNLIMITED, + authUsers: UNLIMITED, + emailsPerMonth: 25_000, + analyticsTimeoutSeconds: 300, + analyticsEvents: 1_000_000, + }, +}; + +export type PlanId = keyof typeof PLAN_LIMITS; From 71b366668a06484db4ecd75e8829f87dad703e3d Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Feb 2026 13:43:19 -0800 Subject: [PATCH 02/38] refactor/fix: Move product info from stripe webhook to separate table Stripe metadata has a character limit of 500. We used to pass product info (including num of items) into the metadata of the stripe object. So when we tried to invoke stripe with this metadata, if it was over 500 chars, it would cause stripe to return an error. This was done because when the stripe webhook event fired, it would send the metadata along with it so our handler could pick it up. We rework this to only passing an id for use in a new table lookup in the handler. This decouples the product info from the webhook event. We keep it backwards compatible because there are existing subscriptions that have the product in the metadata, the same way we kept the offer parsing code for the subscriptions that had offer in the metadata. The productVersionId is hashed on the productJson to dedup it. --- .../migration.sql | 11 ++ apps/backend/prisma/schema.prisma | 16 +- .../integrations/stripe/webhooks/route.tsx | 8 +- .../[customer_id]/switch/route.ts | 12 +- .../purchases/purchase-session/route.tsx | 16 +- apps/backend/src/lib/product-versions.tsx | 154 ++++++++++++++++++ apps/backend/src/lib/stripe.tsx | 33 +++- 7 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql create mode 100644 apps/backend/src/lib/product-versions.tsx diff --git a/apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql b/apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql new file mode 100644 index 0000000000..ac592d3801 --- /dev/null +++ b/apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "ProductVersion" ( + "tenancyId" UUID NOT NULL, + "productVersionId" TEXT NOT NULL, + "productId" TEXT, + "productJson" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProductVersion_pkey" PRIMARY KEY ("tenancyId","productVersionId") +); + diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 46e39119eb..5c965cb25e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -99,8 +99,8 @@ model ExternalDbSyncMetadata { singleton BooleanTrue @unique @default(TRUE) - sequencerEnabled Boolean @default(true) - pollerEnabled Boolean @default(true) + sequencerEnabled Boolean @default(true) + pollerEnabled Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1003,6 +1003,16 @@ model Subscription { @@unique([tenancyId, stripeSubscriptionId]) } +model ProductVersion { + tenancyId String @db.Uuid + productVersionId String + productId String? + productJson Json + createdAt DateTime @default(now()) + + @@id([tenancyId, productVersionId]) +} + model ItemQuantityChange { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid @@ -1088,7 +1098,7 @@ model OutgoingRequest { qstashOptions Json startedFulfillingAt DateTime? - deduplicationKey String? + deduplicationKey String? @@unique([deduplicationKey]) @@index([startedFulfillingAt, createdAt]) diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 3eb209851a..a421632245 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -1,5 +1,6 @@ import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails"; import { listPermissions } from "@/lib/permissions"; +import { getProductVersion } from "@/lib/product-versions"; import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; import type { StripeOverridesMap } from "@/lib/stripe-proxy"; import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram"; @@ -183,7 +184,12 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { } const tenancy = await getTenancyForStripeAccountId(accountId, mockData); const prisma = await getPrismaClientForTenancy(tenancy); - const product = JSON.parse(metadata.product || "{}"); + + const productVersionId = metadata.productVersionId as string | undefined; + const product = productVersionId + ? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson + : JSON.parse(metadata.product || "{}"); + const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); const stripePaymentIntentId = paymentIntent.id; if (!metadata.customerId || !metadata.customerType) { diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index 0b0e880742..b031c63ec9 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -1,5 +1,6 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments"; +import { upsertProductVersion } from "@/lib/product-versions"; import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -169,6 +170,13 @@ export const POST = createSmartRouteHandler({ const stripeProduct = await stripe.products.create({ name: toProduct.displayName || "Subscription" }); + const productVersionId = await upsertProductVersion({ + prisma, + tenancyId: auth.tenancy.id, + productId: body.to_product_id, + productJson: toProduct, + }); + if (subscription?.stripeSubscriptionId) { const existingStripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId); if (existingStripeSub.items.data.length === 0) { @@ -194,7 +202,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: body.to_product_id, - product: JSON.stringify(toProduct), + productVersionId, priceId: selectedPriceId, }, }); @@ -239,7 +247,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: body.to_product_id, - product: JSON.stringify(toProduct), + productVersionId, priceId: selectedPriceId, }, }); diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 1e412bb801..1fac08b8fc 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -1,9 +1,10 @@ +import { SubscriptionStatus } from "@/generated/prisma/client"; import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments"; +import { upsertProductVersion } from "@/lib/product-versions"; import { getStripeForAccount } from "@/lib/stripe"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { SubscriptionStatus } from "@/generated/prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -73,6 +74,13 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Price not resolved for purchase session"); } + const productVersionId = await upsertProductVersion({ + prisma, + tenancyId: tenancy.id, + productId: data.productId ?? null, + productJson: data.product, + }); + if (conflictingProductLineSubscriptions.length > 0) { const conflicting = conflictingProductLineSubscriptions[0]; if (conflicting.stripeSubscriptionId) { @@ -99,7 +107,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: data.productId ?? null, - product: JSON.stringify(data.product), + productVersionId, priceId: price_id, }, }); @@ -136,7 +144,7 @@ export const POST = createSmartRouteHandler({ automatic_payment_methods: { enabled: true }, metadata: { productId: data.productId || "", - product: JSON.stringify(data.product), + productVersionId, customerId: data.customerId, customerType: data.product.customerType, purchaseQuantity: String(quantity), @@ -175,7 +183,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: data.productId ?? null, - product: JSON.stringify(data.product), + productVersionId, priceId: price_id, }, }); diff --git a/apps/backend/src/lib/product-versions.tsx b/apps/backend/src/lib/product-versions.tsx new file mode 100644 index 0000000000..48bd4e3f04 --- /dev/null +++ b/apps/backend/src/lib/product-versions.tsx @@ -0,0 +1,154 @@ +import { PrismaClientTransaction } from "@/prisma-client"; +import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import crypto from "crypto"; + +/** + * Deterministically serializes an object to JSON with sorted keys. + * This ensures the same object always produces the same string regardless of property order. + */ +export function canonicalJsonStringify(obj: unknown): string { + return JSON.stringify(obj, (_, value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.keys(value) + .sort() + .reduce((sorted: Record, key) => { + sorted[key] = value[key]; + return sorted; + }, {}); + } + return value; + }); +} + +/** + * Computes a deterministic version ID from a product JSON object. + * Uses SHA-256 hash of the canonical JSON representation. + */ +export function computeProductVersionId(productJson: unknown): string { + const canonical = canonicalJsonStringify(productJson); + const hash = crypto.createHash("sha256").update(canonical).digest(); + return encodeBase64(hash); +} + +/** + * Upserts a ProductVersion record and returns the productVersionId. + * If a record with the same (tenancyId, productVersionId) exists, it's a no-op. + */ +export async function upsertProductVersion(options: { + prisma: PrismaClientTransaction, + tenancyId: string, + productId: string | null, + productJson: unknown, +}): Promise { + const productVersionId = computeProductVersionId(options.productJson); + + await options.prisma.productVersion.upsert({ + where: { + tenancyId_productVersionId: { + tenancyId: options.tenancyId, + productVersionId, + }, + }, + create: { + tenancyId: options.tenancyId, + productVersionId, + productId: options.productId, + productJson: options.productJson as object, + }, + update: {}, + }); + + return productVersionId; +} + +/** + * Retrieves a ProductVersion by tenancyId and productVersionId. + * Throws if not found. + */ +export async function getProductVersion(options: { + prisma: PrismaClientTransaction, + tenancyId: string, + productVersionId: string, +}): Promise<{ productId: string | null, productJson: unknown }> { + const version = await options.prisma.productVersion.findUnique({ + where: { + tenancyId_productVersionId: { + tenancyId: options.tenancyId, + productVersionId: options.productVersionId, + }, + }, + }); + + if (!version) { + throw new StackAssertionError( + "ProductVersion not found. This may indicate a race condition or deleted record.", + { + tenancyId: options.tenancyId, + productVersionId: options.productVersionId, + } + ); + } + + return { + productId: version.productId, + productJson: version.productJson, + }; +} + +import.meta.vitest?.describe("canonicalJsonStringify", (test) => { + test("produces same output regardless of key order", ({ expect }) => { + const obj1 = { b: 2, a: 1, c: 3 }; + const obj2 = { a: 1, b: 2, c: 3 }; + const obj3 = { c: 3, b: 2, a: 1 }; + + expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2)); + expect(canonicalJsonStringify(obj2)).toBe(canonicalJsonStringify(obj3)); + }); + + test("handles nested objects", ({ expect }) => { + const obj1 = { outer: { b: 2, a: 1 }, z: 1 }; + const obj2 = { z: 1, outer: { a: 1, b: 2 } }; + + expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2)); + }); + + test("preserves array order", ({ expect }) => { + const obj1 = { arr: [1, 2, 3] }; + const obj2 = { arr: [3, 2, 1] }; + + expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2)); + }); + + test("different objects produce different output", ({ expect }) => { + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2)); + }); +}); + +import.meta.vitest?.describe("computeProductVersionId", (test) => { + test("produces same hash for same object with different key order", ({ expect }) => { + const obj1 = { b: 2, a: 1, c: 3 }; + const obj2 = { a: 1, b: 2, c: 3 }; + + expect(computeProductVersionId(obj1)).toBe(computeProductVersionId(obj2)); + }); + + test("produces different hash for different objects", ({ expect }) => { + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + expect(computeProductVersionId(obj1)).not.toBe(computeProductVersionId(obj2)); + }); + + test("hash is deterministic", ({ expect }) => { + const obj = { foo: "bar", nested: { x: 1, y: 2 } }; + + const hash1 = computeProductVersionId(obj); + const hash2 = computeProductVersionId(obj); + + expect(hash1).toBe(hash2); + }); +}); diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index cf96b83914..15c3940940 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,4 +1,5 @@ import { CustomerType } from "@/generated/prisma/client"; +import { getProductVersion } from "@/lib/product-versions"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { InputJsonValue } from "@prisma/client/runtime/client"; @@ -131,16 +132,30 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s const item = subscription.items.data[0]; const sanitizedDates = sanitizeStripePeriodDates(item.current_period_start, item.current_period_end); const priceId = subscription.metadata.priceId as string | undefined; - // old subscriptions were created with offer metadata instead of product metadata - const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined; - if (!productString) { - throw new StackAssertionError("Stripe subscription metadata missing product or offer", { subscriptionId: subscription.id }); - } + let productJson: InputJsonValue; - try { - productJson = JSON.parse(productString); - } catch (error) { - throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error }); + const productVersionId = subscription.metadata.productVersionId as string | undefined; + if (productVersionId) { + const version = await getProductVersion({ + prisma, + tenancyId: tenancy.id, + productVersionId, + }); + productJson = version.productJson as InputJsonValue; + } else { + // Backward compat: old subscriptions have product JSON directly in metadata or even older subscriptions were created with offer metadata + const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined; + if (!productString) { + throw new StackAssertionError("Stripe subscription metadata missing productVersionId, product, or offer", { + subscriptionId: subscription.id, + tenancyId: tenancy.id, + }); + } + try { + productJson = JSON.parse(productString); + } catch (error) { + throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error }); + } } await prisma.subscription.upsert({ From 763094ee8f0d2bbd60e8643bb654bc4b0e12cb59 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Feb 2026 19:27:59 -0800 Subject: [PATCH 03/38] WIP: subscription related bugs. One bug where if a subscription was cancelled but its end period hadn't passed, we were getting the items for both it and the default/free plan Another bug where the alreadyOwnsProduct flag was checking all subscriptions, not just active ones. --- apps/backend/src/lib/payments.test.tsx | 547 ++++++++++++++++++++++++- apps/backend/src/lib/payments.tsx | 4 + apps/e2e/tests/js/payments.test.ts | 19 + 3 files changed, 568 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/payments.test.tsx b/apps/backend/src/lib/payments.test.tsx index d65664de04..4d0305c667 100644 --- a/apps/backend/src/lib/payments.test.tsx +++ b/apps/backend/src/lib/payments.test.tsx @@ -1,7 +1,7 @@ import type { PrismaClientTransaction } from '@/prisma-client'; import { KnownErrors } from '@stackframe/stack-shared'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getItemQuantityForCustomer, getSubscriptions, validatePurchaseSession } from './payments'; +import { getCustomerPurchaseContext, getItemQuantityForCustomer, getSubscriptions, validatePurchaseSession } from './payments'; import type { Tenancy } from './tenancies'; function createMockPrisma(overrides: Partial = {}): PrismaClientTransaction { @@ -657,7 +657,52 @@ describe('getItemQuantityForCustomer - subscriptions', () => { vi.useRealTimers(); }); - it('canceled subscription contributes only expired transactions (no active quantity)', async () => { + it('cancelAtPeriodEnd=true with active status still provides items until period ends', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'scheduledCancelItem'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'S', customerType: 'team' } }, + productLines: { plans: { displayName: 'Plans', customerType: 'team' } }, + products: { + teamPlan: { + displayName: 'Team', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + id: 'sub-1', + productId: 'teamPlan', + product: tenancy.config.payments.products['teamPlan'], + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + cancelAtPeriodEnd: true, + status: 'active', + stripeSubscriptionId: 'stripe-sub-1', + }], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'team-1', customerType: 'team' }); + // User scheduled cancellation but period hasn't ended - should still have 4 seats + expect(qty).toBe(4); + vi.useRealTimers(); + }); + + it('subscription with ended period contributes no quantity (items expired via when-purchase-expires)', async () => { const now = new Date('2025-02-10T00:00:00.000Z'); vi.setSystemTime(now); const itemId = 'canceledItem'; @@ -693,6 +738,74 @@ describe('getItemQuantityForCustomer - subscriptions', () => { vi.useRealTimers(); }); + /** + * BUG: canceled-but-not-expired subscription should still provide items + * + * When a subscription is canceled but currentPeriodEnd is still in the future, + * the user paid for that period and should still receive the items. + * The default (free) plan should NOT be injected until the period actually ends. + * + * Current behavior: The default plan gets injected immediately on cancellation, + * causing both the canceled subscription's items AND the default plan's items + * to be counted together (e.g., 4 + 1 = 5 seats instead of just 4). + */ + it.fails('BUG: canceled-but-not-expired subscription should provide items without default plan injection', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'seats'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'Seats', customerType: 'team' } }, + productLines: { plans: { displayName: 'Plans', customerType: 'team' } }, + products: { + freePlan: { + displayName: 'Free', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + teamPlan: { + displayName: 'Team', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [ + { + id: 'sub-1', + productId: 'teamPlan', + product: tenancy.config.payments.products['teamPlan'], + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'canceled', + stripeSubscriptionId: 'stripe-sub-1', + }, + ], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'team-1', customerType: 'team' }); + // Should be 4 (from canceled-but-not-expired Team plan), NOT 5 (4 + 1 from injected Free plan) + expect(qty).toBe(4); + vi.useRealTimers(); + }); + it('ungrouped product works without tenancy groups', async () => { const now = new Date('2025-02-10T00:00:00.000Z'); vi.setSystemTime(now); @@ -767,6 +880,235 @@ describe('getItemQuantityForCustomer - subscriptions', () => { }); }); +describe('getItemQuantityForCustomer - add-ons', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('add-on items count when base plan is active', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'seats'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'Seats', customerType: 'team' } }, + productLines: { plans: { displayName: 'Plans', customerType: 'team' } }, + products: { + teamPlan: { + displayName: 'Team', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + extraSeat: { + displayName: 'Extra Seat', + productLineId: undefined, + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: { monthly: { USD: '10', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: { teamPlan: true }, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [ + { + id: 'sub-1', + productId: 'teamPlan', + product: tenancy.config.payments.products['teamPlan'], + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + stripeSubscriptionId: 'stripe-sub-1', + }, + { + id: 'sub-2', + productId: 'extraSeat', + product: tenancy.config.payments.products['extraSeat'], + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + quantity: 1, + status: 'active', + stripeSubscriptionId: 'stripe-sub-2', + }, + ], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'team-1', customerType: 'team' }); + // 4 from Team plan + 1 from add-on = 5 + expect(qty).toBe(5); + vi.useRealTimers(); + }); + + it('add-on with expires=never persists after base plan expires', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'seats'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'Seats', customerType: 'team' } }, + productLines: { plans: { displayName: 'Plans', customerType: 'team' } }, + products: { + freePlan: { + displayName: 'Free', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + teamPlan: { + displayName: 'Team', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + extraSeat: { + displayName: 'Extra Seat', + productLineId: undefined, + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: { monthly: { USD: '10', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'never' } }, + isAddOnTo: { teamPlan: true }, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [ + { + id: 'sub-1', + productId: 'teamPlan', + product: tenancy.config.payments.products['teamPlan'], + currentPeriodStart: new Date('2025-01-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-02-01T00:00:00.000Z'), + quantity: 1, + status: 'canceled', + stripeSubscriptionId: 'stripe-sub-1', + }, + { + id: 'sub-2', + productId: 'extraSeat', + product: tenancy.config.payments.products['extraSeat'], + currentPeriodStart: new Date('2025-01-15T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-02-15T00:00:00.000Z'), + quantity: 1, + status: 'active', + stripeSubscriptionId: 'stripe-sub-2', + }, + ], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'team-1', customerType: 'team' }); + // Team plan expired (0 seats) + Free plan injected (1 seat) + add-on with expires=never (1 seat) = 2 + expect(qty).toBe(2); + vi.useRealTimers(); + }); + + it('add-on with expires=when-purchase-expires loses items when add-on subscription expires', async () => { + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + const itemId = 'seats'; + + const tenancy = createMockTenancy({ + items: { [itemId]: { displayName: 'Seats', customerType: 'team' } }, + productLines: { plans: { displayName: 'Plans', customerType: 'team' } }, + products: { + freePlan: { + displayName: 'Free', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: 'include-by-default', + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + teamPlan: { + displayName: 'Team', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 4, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: false, + }, + extraSeat: { + displayName: 'Extra Seat', + productLineId: undefined, + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: true, + prices: { monthly: { USD: '10', serverOnly: false } }, + includedItems: { [itemId]: { quantity: 1, repeat: 'never', expires: 'when-purchase-expires' } }, + isAddOnTo: { teamPlan: true }, + }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [ + { + id: 'sub-1', + productId: 'teamPlan', + product: tenancy.config.payments.products['teamPlan'], + currentPeriodStart: new Date('2025-01-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-02-01T00:00:00.000Z'), + quantity: 1, + status: 'canceled', + stripeSubscriptionId: 'stripe-sub-1', + }, + { + id: 'sub-2', + productId: 'extraSeat', + product: tenancy.config.payments.products['extraSeat'], + currentPeriodStart: new Date('2025-01-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-02-01T00:00:00.000Z'), + quantity: 1, + status: 'canceled', + stripeSubscriptionId: 'stripe-sub-2', + }, + ], + }, + } as any); + + const qty = await getItemQuantityForCustomer({ prisma, tenancy, itemId, customerId: 'team-1', customerType: 'team' }); + // Team plan expired (0) + add-on expired (0) + Free plan injected (1) = 1 + expect(qty).toBe(1); + vi.useRealTimers(); + }); +}); + describe('getItemQuantityForCustomer - one-time purchases', () => { it('adds included item quantity multiplied by purchase quantity', async () => { @@ -1282,3 +1624,204 @@ describe('getSubscriptions - defaults behavior', () => { })).rejects.toThrowError('Multiple include-by-default products configured in the same product line'); }); }); + +describe('getCustomerPurchaseContext', () => { + /** + * BUG: canceled subscriptions should not block re-purchase + * + * When a user cancels their subscription, they should be able to re-purchase + * the same product (or upgrade to a different one) without being blocked by + * the "already owns product" check. + * + * Current behavior: canceled subscriptions are included in the alreadyOwnsProduct + * check, preventing users from re-purchasing after cancellation. + */ + it.fails('BUG: alreadyOwnsProduct should be false for canceled subscriptions', async () => { + const tenancy = createMockTenancy({ + items: {}, + products: { + 'team-plan': { + displayName: 'Team Plan', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: {}, + isAddOnTo: false, + }, + }, + productLines: { + plans: { displayName: 'Plans', customerType: 'team' }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + id: 'sub-1', + productId: 'team-plan', + product: { + displayName: 'Team Plan', + productLineId: 'plans', + customerType: 'team', + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: {}, + }, + quantity: 1, + currentPeriodStart: new Date('2025-01-01'), + currentPeriodEnd: new Date('2025-02-01'), + cancelAtPeriodEnd: false, + status: 'canceled', + createdAt: new Date('2025-01-01'), + stripeSubscriptionId: 'stripe-sub-1', + }], + }, + oneTimePurchase: { + findMany: async () => [], + }, + } as any); + + const result = await getCustomerPurchaseContext({ + prisma, + tenancy, + customerType: 'team', + customerId: 'team-1', + productId: 'team-plan', + }); + + expect(result.alreadyOwnsProduct).toBe(false); + }); + + /** + * BUG: canceled-but-not-expired subscriptions should allow re-purchase + * + * When a subscription is canceled but currentPeriodEnd is still in the future, + * the user should be able to re-purchase/renew. The purchase flow should then + * reactivate the existing subscription rather than creating a new one. + * + * Current behavior: alreadyOwnsProduct = true, blocking re-purchase entirely. + */ + it.fails('BUG: alreadyOwnsProduct should be false for canceled-but-not-expired subscriptions (to allow renewal)', async () => { + vi.useFakeTimers(); + const now = new Date('2025-02-10T00:00:00.000Z'); + vi.setSystemTime(now); + + const tenancy = createMockTenancy({ + items: {}, + products: { + 'team-plan': { + displayName: 'Team Plan', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: {}, + isAddOnTo: false, + }, + }, + productLines: { + plans: { displayName: 'Plans', customerType: 'team' }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + id: 'sub-1', + productId: 'team-plan', + product: { + displayName: 'Team Plan', + productLineId: 'plans', + customerType: 'team', + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: {}, + }, + quantity: 1, + currentPeriodStart: new Date('2025-02-01T00:00:00.000Z'), + currentPeriodEnd: new Date('2025-03-01T00:00:00.000Z'), + cancelAtPeriodEnd: false, + status: 'canceled', + createdAt: new Date('2025-02-01T00:00:00.000Z'), + stripeSubscriptionId: 'stripe-sub-1', + }], + }, + oneTimePurchase: { + findMany: async () => [], + }, + } as any); + + const result = await getCustomerPurchaseContext({ + prisma, + tenancy, + customerType: 'team', + customerId: 'team-1', + productId: 'team-plan', + }); + + // Should be false so user can re-purchase/renew (purchase flow should then reactivate, not create new) + expect(result.alreadyOwnsProduct).toBe(false); + vi.useRealTimers(); + }); + + it('alreadyOwnsProduct should be true for active subscriptions', async () => { + const tenancy = createMockTenancy({ + items: {}, + products: { + 'team-plan': { + displayName: 'Team Plan', + productLineId: 'plans', + customerType: 'team', + freeTrial: undefined, + serverOnly: false, + stackable: false, + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: {}, + isAddOnTo: false, + }, + }, + productLines: { + plans: { displayName: 'Plans', customerType: 'team' }, + }, + }); + + const prisma = createMockPrisma({ + subscription: { + findMany: async () => [{ + id: 'sub-1', + productId: 'team-plan', + product: { + displayName: 'Team Plan', + productLineId: 'plans', + customerType: 'team', + prices: { monthly: { USD: '49', serverOnly: false } }, + includedItems: {}, + }, + quantity: 1, + currentPeriodStart: new Date('2025-01-01'), + currentPeriodEnd: new Date('2025-02-01'), + cancelAtPeriodEnd: false, + status: 'active', + createdAt: new Date('2025-01-01'), + stripeSubscriptionId: 'stripe-sub-1', + }], + }, + oneTimePurchase: { + findMany: async () => [], + }, + } as any); + + const result = await getCustomerPurchaseContext({ + prisma, + tenancy, + customerType: 'team', + customerId: 'team-1', + productId: 'team-plan', + }); + + expect(result.alreadyOwnsProduct).toBe(true); + }); +}); diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index f9445ec4fe..19fcae3e25 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -312,6 +312,10 @@ export function isActiveSubscription(subscription: Subscription): boolean { return subscription.status === SubscriptionStatus.active || subscription.status === SubscriptionStatus.trialing; } +/** + * Returns ALL subscriptions for a customer, including canceled ones. + * Use `isActiveSubscription()` to filter if you only need active subscriptions. + */ export async function getSubscriptions(options: { prisma: PrismaClientTransaction, tenancy: Tenancy, diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index cf5efaf32a..ae698e16cd 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -410,3 +410,22 @@ it("supports granting and listing customer products", { timeout: 60_000 }, async expect(customProducts[0].quantity).toBe(1); expect(customProducts[0].displayName).toBe(inlineCustomProduct.display_name); }); + +// TODO: Add E2E test for subscription renewal after cancellation +// +// Scenario: User cancels subscription but it's still active until end of billing period. +// User then wants to renew/reactivate before the period ends. +// +// Expected behavior: +// - User should be able to "re-purchase" the same product +// - The system should reactivate the existing subscription (set cancel_at_period_end: false) +// rather than creating a new subscription and charging again +// - To the user, it should appear to be the same continuous subscription +// +// This requires: +// 1. Fix alreadyOwnsProduct to return false for canceled subscriptions (unit test exists) +// 2. Purchase/switch flow to detect canceled-but-not-expired subscription and reactivate it +// +// See failing unit tests in payments.test.tsx: +// - "BUG: alreadyOwnsProduct should be false for canceled subscriptions" +// - "BUG: alreadyOwnsProduct should be false for canceled-but-not-expired subscriptions (to allow renewal)" From ce4777fd612cf44d2d6a331cdb3bff85ad7833a2 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 2 Mar 2026 15:34:59 -0800 Subject: [PATCH 04/38] feat(plans): add team-wide billing entitlement helpers --- .../backend/src/lib/plan-entitlements.test.ts | 225 ++++++++++++++++++ apps/backend/src/lib/plan-entitlements.ts | 175 ++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 apps/backend/src/lib/plan-entitlements.test.ts create mode 100644 apps/backend/src/lib/plan-entitlements.ts diff --git a/apps/backend/src/lib/plan-entitlements.test.ts b/apps/backend/src/lib/plan-entitlements.test.ts new file mode 100644 index 0000000000..08f467f7d4 --- /dev/null +++ b/apps/backend/src/lib/plan-entitlements.test.ts @@ -0,0 +1,225 @@ +import type { PrismaClientTransaction } from "@/prisma-client"; +import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; +import { describe, expect, it } from "vitest"; +import { + getOwnedProjectIdsForBillingTeam, + getOwnedTenancyIdsForBillingTeam, + getTeamWideAuthUsersCapacityForProjectTenancy, + getTeamWideItemCapacityForTests, + getTeamWideNonAnonymousUserCount, +} from "./plan-entitlements"; +import type { Tenancy } from "./tenancies"; + +type ProjectRow = { id: string, ownerTeamId: string | null }; +type TenancyRow = { id: string, projectId: string }; +type ProjectUserRow = { tenancyId: string, isAnonymous: boolean }; + +function createGlobalPrismaStub(state: { + projects: ProjectRow[], + tenancies: TenancyRow[], + projectUsers: ProjectUserRow[], +}) { + return { + project: { + findMany: async (args: { where: { ownerTeamId: string }, select: { id: true } }) => { + return state.projects + .filter((project) => project.ownerTeamId === args.where.ownerTeamId) + .map((project) => ({ id: project.id })); + }, + }, + tenancy: { + findMany: async (args: { where: { projectId: { in: string[] } }, select: { id: true } }) => { + return state.tenancies + .filter((tenancy) => args.where.projectId.in.includes(tenancy.projectId)) + .map((tenancy) => ({ id: tenancy.id })); + }, + }, + projectUser: { + count: async (args: { where: { tenancyId: { in: string[] }, isAnonymous: boolean } }) => { + return state.projectUsers.filter((user) => ( + args.where.tenancyId.in.includes(user.tenancyId) && + user.isAnonymous === args.where.isAnonymous + )).length; + }, + }, + }; +} + +describe("project billing team assertions", () => { + it("throws for project tenancy without owner team", async () => { + await expect(getTeamWideAuthUsersCapacityForProjectTenancy({ + id: "project-tenancy", + project: { + id: "project-without-owner", + ownerTeamId: null, + }, + config: { payments: {} }, + branchId: "main", + organization: null, + } as unknown as Tenancy, { + project: { + id: "billing-project", + ownerTeamId: "team-1", + }, + id: "billing-tenancy", + config: { payments: {} }, + branchId: "main", + organization: null, + } as unknown as Tenancy)).rejects.toThrow("Project owner team missing"); + }); +}); + +describe("team-wide ownership aggregation", () => { + const globalPrisma = createGlobalPrismaStub({ + projects: [ + { id: "project-a", ownerTeamId: "team-1" }, + { id: "project-b", ownerTeamId: "team-1" }, + { id: "project-c", ownerTeamId: "team-2" }, + { id: "project-d", ownerTeamId: null }, + ], + tenancies: [ + { id: "tenancy-a-main", projectId: "project-a" }, + { id: "tenancy-a-dev", projectId: "project-a" }, + { id: "tenancy-b-main", projectId: "project-b" }, + { id: "tenancy-c-main", projectId: "project-c" }, + { id: "tenancy-d-main", projectId: "project-d" }, + ], + projectUsers: [ + { tenancyId: "tenancy-a-main", isAnonymous: false }, + { tenancyId: "tenancy-a-main", isAnonymous: true }, + { tenancyId: "tenancy-a-dev", isAnonymous: false }, + { tenancyId: "tenancy-b-main", isAnonymous: false }, + { tenancyId: "tenancy-c-main", isAnonymous: false }, + { tenancyId: "tenancy-d-main", isAnonymous: false }, + ], + }); + + it("lists only projects owned by billing team", async () => { + const projectIds = await getOwnedProjectIdsForBillingTeam("team-1", globalPrisma); + expect(projectIds).toEqual(["project-a", "project-b"]); + }); + + it("lists all tenancies for projects owned by billing team", async () => { + const tenancyIds = await getOwnedTenancyIdsForBillingTeam("team-1", globalPrisma); + expect(tenancyIds).toEqual(["tenancy-a-main", "tenancy-a-dev", "tenancy-b-main"]); + }); + + it("counts only non-anonymous users across all owned tenancies", async () => { + const usage = await getTeamWideNonAnonymousUserCount("team-1", globalPrisma); + expect(usage).toBe(3); + }); + + it("returns zero usage for team with no projects", async () => { + const emptyGlobalPrisma = createGlobalPrismaStub({ + projects: [], + tenancies: [], + projectUsers: [], + }); + const usage = await getTeamWideNonAnonymousUserCount("team-does-not-own-projects", emptyGlobalPrisma); + expect(usage).toBe(0); + }); +}); + +describe("capacity lookup helpers", () => { + const billingTeamId = "team-free"; + const billingTenancy = { + id: "internal-billing-tenancy", + project: { id: "internal", ownerTeamId: "internal-team" }, + branchId: "main", + organization: null, + config: { payments: {} }, + } as unknown as Tenancy; + + const teamProjectTenancyA = { + id: "team-free-tenancy-a", + project: { id: "project-a", owner_team_id: billingTeamId }, + branchId: "main", + organization: null, + config: { payments: {} }, + } as unknown as Tenancy; + const teamProjectTenancyB = { + id: "team-free-tenancy-b", + project: { id: "project-b", owner_team_id: billingTeamId }, + branchId: "main", + organization: null, + config: { payments: {} }, + } as unknown as Tenancy; + + const itemLimits = new Map([ + [ITEM_IDS.authUsers, PLAN_LIMITS.free.authUsers], + [ITEM_IDS.emailsPerMonth, PLAN_LIMITS.free.emailsPerMonth], + [ITEM_IDS.seats, PLAN_LIMITS.free.seats], + ]); + + const capacityReaders = { + getPrismaForTenancy: async (): Promise => ({} as PrismaClientTransaction), + getItemQuantityForCustomer: async (options: { + prisma: unknown, + tenancy: Tenancy, + customerId: string, + customerType: "team", + itemId: string, + }) => { + if (options.customerId !== billingTeamId) { + throw new Error("Unexpected billing team"); + } + return itemLimits.get(options.itemId) ?? 0; + }, + }; + + it("returns free auth user capacity for team-wide lookup", async () => { + const capacity = await getTeamWideItemCapacityForTests({ + billingTeamId, + billingTenancy, + itemId: ITEM_IDS.authUsers, + }, capacityReaders); + expect(capacity).toBe(PLAN_LIMITS.free.authUsers); + }); + + it("returns the same auth capacity for two project tenancies of one team", async () => { + const capacityA = await getTeamWideItemCapacityForTests({ + billingTeamId: teamProjectTenancyA.project.owner_team_id ?? (() => { + throw new Error("Expected owner_team_id for tenancy A"); + })(), + billingTenancy, + itemId: ITEM_IDS.authUsers, + }, capacityReaders); + const capacityB = await getTeamWideItemCapacityForTests({ + billingTeamId: teamProjectTenancyB.project.owner_team_id ?? (() => { + throw new Error("Expected owner_team_id for tenancy B"); + })(), + billingTenancy, + itemId: ITEM_IDS.authUsers, + }, capacityReaders); + expect(capacityA).toBe(PLAN_LIMITS.free.authUsers); + expect(capacityB).toBe(PLAN_LIMITS.free.authUsers); + }); + + it("maps emails capacity helper to emails plan item", async () => { + const emailCapacity = await getTeamWideItemCapacityForTests({ + billingTeamId, + billingTenancy, + itemId: ITEM_IDS.emailsPerMonth, + }, capacityReaders); + expect(emailCapacity).toBe(PLAN_LIMITS.free.emailsPerMonth); + }); + + it("maps seats capacity helper to seats plan item", async () => { + const seatsCapacity = await getTeamWideItemCapacityForTests({ + billingTeamId, + billingTenancy, + itemId: ITEM_IDS.seats, + }, capacityReaders); + expect(seatsCapacity).toBe(PLAN_LIMITS.free.seats); + }); + + it("throws on unknown item id", async () => { + await expect(getTeamWideItemCapacityForTests({ + billingTeamId, + billingTenancy, + itemId: "unknown_item", + }, capacityReaders)).rejects.toThrow("Unsupported team-wide capacity item id"); + }); + +}); + diff --git a/apps/backend/src/lib/plan-entitlements.ts b/apps/backend/src/lib/plan-entitlements.ts new file mode 100644 index 0000000000..9e23addf22 --- /dev/null +++ b/apps/backend/src/lib/plan-entitlements.ts @@ -0,0 +1,175 @@ +import { getItemQuantityForCustomer } from "@/lib/payments"; +import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import type { Tenancy } from "./tenancies"; + +type GlobalPrismaLike = { + project: { + findMany: (args: { where: { ownerTeamId: string }, select: { id: true } }) => Promise>, + }, + tenancy: { + findMany: (args: { where: { projectId: { in: string[] } }, select: { id: true } }) => Promise>, + }, + projectUser: { + count: (args: { where: { tenancyId: { in: string[] }, isAnonymous: boolean } }) => Promise, + }, +}; + +type ItemCapacityReaders = { + getPrismaForTenancy: (tenancy: Tenancy) => Promise, + getItemQuantityForCustomer: (options: { + prisma: unknown, + tenancy: Tenancy, + customerId: string, + customerType: "team", + itemId: string, + }) => Promise, +}; + +const TEAM_WIDE_CAPACITY_ITEM_IDS = new Set([ + ITEM_IDS.authUsers, + ITEM_IDS.emailsPerMonth, + ITEM_IDS.seats, +]); + +function getBillingTeamIdOrThrow(project: { id: string, ownerTeamId?: string | null, owner_team_id?: string | null }): string { + const ownerTeamId = project.ownerTeamId ?? project.owner_team_id ?? null; + if (!ownerTeamId) { + throw new StackAssertionError("Project owner team missing; cannot resolve billing team", { + projectId: project.id, + }); + } + return ownerTeamId; +} + +export async function getOwnedProjectIdsForBillingTeam( + billingTeamId: string, + globalPrisma: GlobalPrismaLike = globalPrismaClient, +): Promise { + const projects = await globalPrisma.project.findMany({ + where: { + ownerTeamId: billingTeamId, + }, + select: { + id: true, + }, + }); + return projects.map((project) => project.id); +} + +export async function getOwnedTenancyIdsForBillingTeam( + billingTeamId: string, + globalPrisma: GlobalPrismaLike = globalPrismaClient, +): Promise { + const projectIds = await getOwnedProjectIdsForBillingTeam(billingTeamId, globalPrisma); + if (projectIds.length === 0) { + return []; + } + const tenancies = await globalPrisma.tenancy.findMany({ + where: { + projectId: { + in: projectIds, + }, + }, + select: { + id: true, + }, + }); + return tenancies.map((tenancy) => tenancy.id); +} + +export async function getTeamWideNonAnonymousUserCount( + billingTeamId: string, + globalPrisma: GlobalPrismaLike = globalPrismaClient, +): Promise { + // Usage metric: how many non-anonymous users are currently consumed by this billing team. + // This is compared against auth user capacity to determine over-limit conditions. + const tenancyIds = await getOwnedTenancyIdsForBillingTeam(billingTeamId, globalPrisma); + if (tenancyIds.length === 0) { + return 0; + } + return await globalPrisma.projectUser.count({ + where: { + tenancyId: { + in: tenancyIds, + }, + isAnonymous: false, + }, + }); +} + +async function getTeamWideItemCapacity(options: { + billingTenancy: Tenancy, + billingTeamId: string, + itemId: string, +}, readers: ItemCapacityReaders = { + getPrismaForTenancy: getPrismaClientForTenancy, + getItemQuantityForCustomer: async (readerOptions) => ( + await getItemQuantityForCustomer(readerOptions as Parameters[0]) + ), +}): Promise { + // Capacity metric: entitlement from Stack Auth payments for a specific item. + // Example: auth_users / emails_per_month / dashboard_admins. + if (!TEAM_WIDE_CAPACITY_ITEM_IDS.has(options.itemId)) { + throw new StackAssertionError("Unsupported team-wide capacity item id", { + itemId: options.itemId, + }); + } + const billingPrisma = await readers.getPrismaForTenancy(options.billingTenancy); + return await readers.getItemQuantityForCustomer({ + prisma: billingPrisma, + tenancy: options.billingTenancy, + customerId: options.billingTeamId, + customerType: "team", + itemId: options.itemId, + }); +} + +export async function getTeamWideItemCapacityForTests(options: { + billingTenancy: Tenancy, + billingTeamId: string, + itemId: string, +}, readers: ItemCapacityReaders): Promise { + return await getTeamWideItemCapacity(options, readers); +} + +export async function getTeamWideAuthUsersCapacity(options: { + billingTenancy: Tenancy, + billingTeamId: string, +}): Promise { + return await getTeamWideItemCapacity({ + ...options, + itemId: ITEM_IDS.authUsers, + }); +} + +export async function getTeamWideEmailsPerMonthCapacity(options: { + billingTenancy: Tenancy, + billingTeamId: string, +}): Promise { + return await getTeamWideItemCapacity({ + ...options, + itemId: ITEM_IDS.emailsPerMonth, + }); +} + +export async function getTeamWideDashboardAdminsCapacity(options: { + billingTenancy: Tenancy, + billingTeamId: string, +}): Promise { + return await getTeamWideItemCapacity({ + ...options, + itemId: ITEM_IDS.seats, + }); +} + +export async function getTeamWideAuthUsersCapacityForProjectTenancy( + projectTenancy: Tenancy, + billingTenancy: Tenancy, +): Promise { + return await getTeamWideAuthUsersCapacity({ + billingTenancy, + billingTeamId: getBillingTeamIdOrThrow(projectTenancy.project), + }); +} From e222272218fb5103d0d3c10be5c853a880f8f21e Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 2 Mar 2026 16:44:07 -0800 Subject: [PATCH 05/38] feat(auth-limits): log non-anonymous signup overages without blocking We just captureError when you exceed the limit. --- .../backend/src/app/api/latest/users/crud.tsx | 31 ++++- .../backend/src/lib/plan-entitlements.test.ts | 120 +++++++----------- apps/backend/src/lib/plan-entitlements.ts | 101 +++++++-------- 3 files changed, 118 insertions(+), 134 deletions(-) diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 3a16996d3d..0f7ad78023 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -2,6 +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 { recordExternalDbSyncContactChannelDeletionsForUser, recordExternalDbSyncDeletion, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { grantDefaultProjectPermissions } from "@/lib/permissions"; import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; @@ -246,6 +247,21 @@ async function checkAuthData( } } +async function checkAuthUsersSoftLimit(tenancy: Tenancy) { + const billingTeamId = getBillingTeamId(tenancy.project); + if (billingTeamId == null) { + return; + } + const usage = await getTeamWideNonAnonymousUserCount(billingTeamId); + const capacity = await getTeamWideAuthUsersCapacity(billingTeamId); + if (usage > capacity) { + captureError("auth-users-plan-soft-limit-exceeded", new StackAssertionError( + "Auth users soft limit exceeded for billing team", + { ownerTeamId: billingTeamId, usage, capacity, projectId: tenancy.project.id }, + )); + } +} + export function getUserQuery(projectId: string, branchId: string, userId: string, schema: string, config: OnboardingConfig): RawQuery { return { supportedPrismaClients: ["source-of-truth"], @@ -754,6 +770,10 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC await createPersonalTeamIfEnabled(prisma, auth.tenancy, result); + if (!result.is_anonymous) { + runAsynchronouslyAndWaitUntil(checkAuthUsersSoftLimit(auth.tenancy)); + } + runAsynchronouslyAndWaitUntil(sendUserCreatedWebhook({ projectId: auth.project.id, data: result, @@ -765,7 +785,7 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const primaryEmail = data.primary_email ? normalizeEmail(data.primary_email) : data.primary_email; const passwordHash = await getPasswordHashFromData(data); const prisma = await getPrismaClientForTenancy(auth.tenancy); - const { user } = await retryTransaction(prisma, async (tx) => { + const { user, wasAnonymousUpgrade } = await retryTransaction(prisma, async (tx) => { await ensureUserExists(tx, { tenancyId: auth.tenancy.id, userId: params.user_id }); const config = auth.tenancy.config; @@ -1082,8 +1102,8 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC } } - // if we went from anonymous to non-anonymous: - if (oldUser.isAnonymous && data.is_anonymous === false) { + const wasAnonymousUpgrade = oldUser.isAnonymous && data.is_anonymous === false; + if (wasAnonymousUpgrade) { // rename the personal team await tx.team.updateMany({ where: { @@ -1151,9 +1171,14 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC const user = userPrismaToCrud(db, auth.tenancy.config); return { user, + wasAnonymousUpgrade, }; }); + if (wasAnonymousUpgrade) { + runAsynchronouslyAndWaitUntil(checkAuthUsersSoftLimit(auth.tenancy)); + } + // if user password changed, reset all refresh tokens if (passwordHash !== undefined) { await globalPrismaClient.projectUserRefreshToken.deleteMany({ diff --git a/apps/backend/src/lib/plan-entitlements.test.ts b/apps/backend/src/lib/plan-entitlements.test.ts index 08f467f7d4..bdf2ea6dc3 100644 --- a/apps/backend/src/lib/plan-entitlements.test.ts +++ b/apps/backend/src/lib/plan-entitlements.test.ts @@ -2,9 +2,9 @@ import type { PrismaClientTransaction } from "@/prisma-client"; import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; import { describe, expect, it } from "vitest"; import { + getBillingTeamId, getOwnedProjectIdsForBillingTeam, getOwnedTenancyIdsForBillingTeam, - getTeamWideAuthUsersCapacityForProjectTenancy, getTeamWideItemCapacityForTests, getTeamWideNonAnonymousUserCount, } from "./plan-entitlements"; @@ -45,27 +45,21 @@ function createGlobalPrismaStub(state: { }; } -describe("project billing team assertions", () => { - it("throws for project tenancy without owner team", async () => { - await expect(getTeamWideAuthUsersCapacityForProjectTenancy({ - id: "project-tenancy", - project: { - id: "project-without-owner", - ownerTeamId: null, - }, - config: { payments: {} }, - branchId: "main", - organization: null, - } as unknown as Tenancy, { - project: { - id: "billing-project", - ownerTeamId: "team-1", - }, - id: "billing-tenancy", - config: { payments: {} }, - branchId: "main", - organization: null, - } as unknown as Tenancy)).rejects.toThrow("Project owner team missing"); +describe("getBillingTeamId", () => { + it("returns ownerTeamId when present", () => { + expect(getBillingTeamId({ id: "p1", ownerTeamId: "team-1" })).toBe("team-1"); + }); + + it("returns owner_team_id when ownerTeamId is absent", () => { + expect(getBillingTeamId({ id: "p1", owner_team_id: "team-2" })).toBe("team-2"); + }); + + it("returns null when neither is present", () => { + expect(getBillingTeamId({ id: "p1", ownerTeamId: null, owner_team_id: null })).toBe(null); + }); + + it("prefers ownerTeamId over owner_team_id", () => { + expect(getBillingTeamId({ id: "p1", ownerTeamId: "team-camel", owner_team_id: "team-snake" })).toBe("team-camel"); }); }); @@ -122,28 +116,6 @@ describe("team-wide ownership aggregation", () => { describe("capacity lookup helpers", () => { const billingTeamId = "team-free"; - const billingTenancy = { - id: "internal-billing-tenancy", - project: { id: "internal", ownerTeamId: "internal-team" }, - branchId: "main", - organization: null, - config: { payments: {} }, - } as unknown as Tenancy; - - const teamProjectTenancyA = { - id: "team-free-tenancy-a", - project: { id: "project-a", owner_team_id: billingTeamId }, - branchId: "main", - organization: null, - config: { payments: {} }, - } as unknown as Tenancy; - const teamProjectTenancyB = { - id: "team-free-tenancy-b", - project: { id: "project-b", owner_team_id: billingTeamId }, - branchId: "main", - organization: null, - config: { payments: {} }, - } as unknown as Tenancy; const itemLimits = new Map([ [ITEM_IDS.authUsers, PLAN_LIMITS.free.authUsers], @@ -167,59 +139,53 @@ describe("capacity lookup helpers", () => { }, }; - it("returns free auth user capacity for team-wide lookup", async () => { - const capacity = await getTeamWideItemCapacityForTests({ + it("returns free auth user capacity", async () => { + const capacity = await getTeamWideItemCapacityForTests( billingTeamId, - billingTenancy, - itemId: ITEM_IDS.authUsers, - }, capacityReaders); + ITEM_IDS.authUsers, + capacityReaders, + ); expect(capacity).toBe(PLAN_LIMITS.free.authUsers); }); it("returns the same auth capacity for two project tenancies of one team", async () => { - const capacityA = await getTeamWideItemCapacityForTests({ - billingTeamId: teamProjectTenancyA.project.owner_team_id ?? (() => { - throw new Error("Expected owner_team_id for tenancy A"); - })(), - billingTenancy, - itemId: ITEM_IDS.authUsers, - }, capacityReaders); - const capacityB = await getTeamWideItemCapacityForTests({ - billingTeamId: teamProjectTenancyB.project.owner_team_id ?? (() => { - throw new Error("Expected owner_team_id for tenancy B"); - })(), - billingTenancy, - itemId: ITEM_IDS.authUsers, - }, capacityReaders); + const capacityA = await getTeamWideItemCapacityForTests( + billingTeamId, + ITEM_IDS.authUsers, + capacityReaders, + ); + const capacityB = await getTeamWideItemCapacityForTests( + billingTeamId, + ITEM_IDS.authUsers, + capacityReaders, + ); expect(capacityA).toBe(PLAN_LIMITS.free.authUsers); expect(capacityB).toBe(PLAN_LIMITS.free.authUsers); }); it("maps emails capacity helper to emails plan item", async () => { - const emailCapacity = await getTeamWideItemCapacityForTests({ + const emailCapacity = await getTeamWideItemCapacityForTests( billingTeamId, - billingTenancy, - itemId: ITEM_IDS.emailsPerMonth, - }, capacityReaders); + ITEM_IDS.emailsPerMonth, + capacityReaders, + ); expect(emailCapacity).toBe(PLAN_LIMITS.free.emailsPerMonth); }); it("maps seats capacity helper to seats plan item", async () => { - const seatsCapacity = await getTeamWideItemCapacityForTests({ + const seatsCapacity = await getTeamWideItemCapacityForTests( billingTeamId, - billingTenancy, - itemId: ITEM_IDS.seats, - }, capacityReaders); + ITEM_IDS.seats, + capacityReaders, + ); expect(seatsCapacity).toBe(PLAN_LIMITS.free.seats); }); it("throws on unknown item id", async () => { - await expect(getTeamWideItemCapacityForTests({ + await expect(getTeamWideItemCapacityForTests( billingTeamId, - billingTenancy, - itemId: "unknown_item", - }, capacityReaders)).rejects.toThrow("Unsupported team-wide capacity item id"); + "unknown_item", + capacityReaders, + )).rejects.toThrow("Unsupported team-wide capacity item id"); }); - }); - diff --git a/apps/backend/src/lib/plan-entitlements.ts b/apps/backend/src/lib/plan-entitlements.ts index 9e23addf22..1cd9bc6a74 100644 --- a/apps/backend/src/lib/plan-entitlements.ts +++ b/apps/backend/src/lib/plan-entitlements.ts @@ -2,7 +2,7 @@ import { getItemQuantityForCustomer } from "@/lib/payments"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; -import type { Tenancy } from "./tenancies"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "./tenancies"; type GlobalPrismaLike = { project: { @@ -33,14 +33,19 @@ const TEAM_WIDE_CAPACITY_ITEM_IDS = new Set([ ITEM_IDS.seats, ]); -function getBillingTeamIdOrThrow(project: { id: string, ownerTeamId?: string | null, owner_team_id?: string | null }): string { - const ownerTeamId = project.ownerTeamId ?? project.owner_team_id ?? null; - if (!ownerTeamId) { - throw new StackAssertionError("Project owner team missing; cannot resolve billing team", { - projectId: project.id, +export function getBillingTeamId(project: { id: string, ownerTeamId?: string | null, owner_team_id?: string | null }): string | null { + return project.ownerTeamId ?? project.owner_team_id ?? null; +} + +async function getInternalBillingTenancy(): Promise { + const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (tenancy == null) { + throw new StackAssertionError("Internal billing tenancy not found", { + billingProjectId: "internal", + branchId: DEFAULT_BRANCH_ID, }); } - return ownerTeamId; + return tenancy; } export async function getOwnedProjectIdsForBillingTeam( @@ -99,77 +104,65 @@ export async function getTeamWideNonAnonymousUserCount( }); } -async function getTeamWideItemCapacity(options: { - billingTenancy: Tenancy, +async function getTeamWideItemCapacity( billingTeamId: string, itemId: string, -}, readers: ItemCapacityReaders = { - getPrismaForTenancy: getPrismaClientForTenancy, - getItemQuantityForCustomer: async (readerOptions) => ( - await getItemQuantityForCustomer(readerOptions as Parameters[0]) - ), -}): Promise { + readers: ItemCapacityReaders = { + getPrismaForTenancy: getPrismaClientForTenancy, + getItemQuantityForCustomer: async (readerOptions) => ( + await getItemQuantityForCustomer(readerOptions as Parameters[0]) + ), + }, +): Promise { // Capacity metric: entitlement from Stack Auth payments for a specific item. - // Example: auth_users / emails_per_month / dashboard_admins. - if (!TEAM_WIDE_CAPACITY_ITEM_IDS.has(options.itemId)) { - throw new StackAssertionError("Unsupported team-wide capacity item id", { - itemId: options.itemId, - }); + if (!TEAM_WIDE_CAPACITY_ITEM_IDS.has(itemId)) { + throw new StackAssertionError("Unsupported team-wide capacity item id", { itemId }); } - const billingPrisma = await readers.getPrismaForTenancy(options.billingTenancy); + const internalBillingTenancy = await getInternalBillingTenancy(); + const billingPrisma = await readers.getPrismaForTenancy(internalBillingTenancy); return await readers.getItemQuantityForCustomer({ prisma: billingPrisma, - tenancy: options.billingTenancy, - customerId: options.billingTeamId, + tenancy: internalBillingTenancy, + customerId: billingTeamId, customerType: "team", - itemId: options.itemId, + itemId, }); } -export async function getTeamWideItemCapacityForTests(options: { - billingTenancy: Tenancy, +export async function getTeamWideItemCapacityForTests( billingTeamId: string, itemId: string, -}, readers: ItemCapacityReaders): Promise { - return await getTeamWideItemCapacity(options, readers); + readers: ItemCapacityReaders, +): Promise { + return await getTeamWideItemCapacity(billingTeamId, itemId, readers); } -export async function getTeamWideAuthUsersCapacity(options: { - billingTenancy: Tenancy, +export async function getTeamWideAuthUsersCapacity( billingTeamId: string, -}): Promise { - return await getTeamWideItemCapacity({ - ...options, - itemId: ITEM_IDS.authUsers, - }); +): Promise { + return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.authUsers); } -export async function getTeamWideEmailsPerMonthCapacity(options: { - billingTenancy: Tenancy, +export async function getTeamWideEmailsPerMonthCapacity( billingTeamId: string, -}): Promise { - return await getTeamWideItemCapacity({ - ...options, - itemId: ITEM_IDS.emailsPerMonth, - }); +): Promise { + return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.emailsPerMonth); } -export async function getTeamWideDashboardAdminsCapacity(options: { - billingTenancy: Tenancy, +export async function getTeamWideDashboardAdminsCapacity( billingTeamId: string, -}): Promise { - return await getTeamWideItemCapacity({ - ...options, - itemId: ITEM_IDS.seats, - }); +): Promise { + return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.seats); } export async function getTeamWideAuthUsersCapacityForProjectTenancy( projectTenancy: Tenancy, - billingTenancy: Tenancy, ): Promise { - return await getTeamWideAuthUsersCapacity({ - billingTenancy, - billingTeamId: getBillingTeamIdOrThrow(projectTenancy.project), - }); + const billingTeamId = getBillingTeamId(projectTenancy.project); + if (billingTeamId == null) { + throw new StackAssertionError("Project owner team missing; cannot resolve billing team", { + projectId: projectTenancy.project.id, + }); + } + return await getTeamWideAuthUsersCapacity(billingTeamId); } From 162a65e8f3026f8fa2fe07417342df7d7cb5854c Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 2 Mar 2026 17:52:55 -0800 Subject: [PATCH 06/38] feat(email-limits): enforce hard cap when sending We check in processSingleEmail since that maps to the sending state. The state transition from sending -> server_error already exists and so it would be cleaner to just hook into that transition pathway. Since we early return before the captureErrors, this should not clog up sentry. We check the payments items list as source of truth. --- apps/backend/prisma/seed.ts | 6 +-- apps/backend/src/lib/email-queue-step.tsx | 53 +++++++++++++++++++ .../backend/src/lib/plan-entitlements.test.ts | 18 +++---- apps/backend/src/lib/plan-entitlements.ts | 7 --- 4 files changed, 64 insertions(+), 20 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 2663aa188c..826c6819f4 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -130,7 +130,7 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as const, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, @@ -151,7 +151,7 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as const, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, @@ -172,7 +172,7 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as const, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index e120b34534..3e3e366229 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -3,6 +3,9 @@ import { calculateCapacityRate, getEmailDeliveryStatsForTenancy } from "@/lib/em 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 { getStackServerApp } from "@/stack"; +import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction } from "@/prisma-client"; import { withTraceSpan } from "@/utils/telemetry"; @@ -614,6 +617,7 @@ type TenancyProcessingContext = { tenancy: Tenancy, prisma: Awaited>, emailConfig: Awaited>, + billingTeamId: string | null, }; async function processTenancyBatch(batch: TenancySendBatch): Promise { @@ -621,11 +625,13 @@ async function processTenancyBatch(batch: TenancySendBatch): Promise { const prisma = await getPrismaClientForTenancy(tenancy); const emailConfig = await getEmailConfig(tenancy); + const billingTeamId = getBillingTeamId(tenancy.project); const context: TenancyProcessingContext = { tenancy, prisma, emailConfig, + billingTeamId, }; const promises = batch.rows.map((row) => processSingleEmail(context, row)); @@ -697,6 +703,47 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO } } + if (context.billingTeamId != null) { + const app = getStackServerApp(); + const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); + if (emailItem.quantity <= 0) { + const errorMessage = "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month."; + const errorEntry: SendAttemptError = { + attemptNumber: row.sendRetries + 1, + timestamp: new Date().toISOString(), + externalMessage: errorMessage, + externalDetails: { errorType: "monthly-email-limit-exceeded" }, + internalMessage: errorMessage, + internalDetails: { errorType: "monthly-email-limit-exceeded", remainingQuota: emailItem.quantity, billingTeamId: context.billingTeamId }, + }; + const updatedErrors = appendSendAttemptError(row.sendAttemptErrors as SendAttemptError[] | null, errorEntry); + await globalPrismaClient.emailOutbox.update({ + where: { + tenancyId_id: { + tenancyId: row.tenancyId, + id: row.id, + }, + finishedSendingAt: null, + }, + data: { + finishedSendingAt: new Date(), + canHaveDeliveryInfo: false, + sendRetries: row.sendRetries + 1, + sendAttemptErrors: updatedErrors as Prisma.InputJsonArray, + sendServerErrorExternalMessage: errorMessage, + sendServerErrorExternalDetails: { errorType: "monthly-email-limit-exceeded" }, + sendServerErrorInternalMessage: errorMessage, + sendServerErrorInternalDetails: { + errorType: "monthly-email-limit-exceeded", + remainingQuota: emailItem.quantity, + billingTeamId: context.billingTeamId, + }, + }, + }); + return; + } + } + const result = getEnvBoolean("STACK_EMAIL_BRANCHING_DISABLE_QUEUE_SENDING") ? Result.error({ errorType: "email-sending-disabled", canRetry: false, message: "Email sending is disabled", rawError: new Error("Email sending is disabled") }) : await lowLevelSendEmailDirectWithoutRetries({ @@ -803,6 +850,12 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO sendServerErrorInternalDetails: Prisma.DbNull, }, }); + + if (context.billingTeamId != null) { + const app = getStackServerApp(); + const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); + await emailItem.decreaseQuantity(1); + } } } catch (error) { captureError("email-queue-step-sending-single-error", error); diff --git a/apps/backend/src/lib/plan-entitlements.test.ts b/apps/backend/src/lib/plan-entitlements.test.ts index bdf2ea6dc3..9b05a61e30 100644 --- a/apps/backend/src/lib/plan-entitlements.test.ts +++ b/apps/backend/src/lib/plan-entitlements.test.ts @@ -119,7 +119,6 @@ describe("capacity lookup helpers", () => { const itemLimits = new Map([ [ITEM_IDS.authUsers, PLAN_LIMITS.free.authUsers], - [ITEM_IDS.emailsPerMonth, PLAN_LIMITS.free.emailsPerMonth], [ITEM_IDS.seats, PLAN_LIMITS.free.seats], ]); @@ -163,15 +162,6 @@ describe("capacity lookup helpers", () => { expect(capacityB).toBe(PLAN_LIMITS.free.authUsers); }); - it("maps emails capacity helper to emails plan item", async () => { - const emailCapacity = await getTeamWideItemCapacityForTests( - billingTeamId, - ITEM_IDS.emailsPerMonth, - capacityReaders, - ); - expect(emailCapacity).toBe(PLAN_LIMITS.free.emailsPerMonth); - }); - it("maps seats capacity helper to seats plan item", async () => { const seatsCapacity = await getTeamWideItemCapacityForTests( billingTeamId, @@ -188,4 +178,12 @@ describe("capacity lookup helpers", () => { capacityReaders, )).rejects.toThrow("Unsupported team-wide capacity item id"); }); + + it("rejects emails_per_month as unsupported capacity item (handled via SDK)", async () => { + await expect(getTeamWideItemCapacityForTests( + billingTeamId, + ITEM_IDS.emailsPerMonth, + capacityReaders, + )).rejects.toThrow("Unsupported team-wide capacity item id"); + }); }); diff --git a/apps/backend/src/lib/plan-entitlements.ts b/apps/backend/src/lib/plan-entitlements.ts index 1cd9bc6a74..fcf3524404 100644 --- a/apps/backend/src/lib/plan-entitlements.ts +++ b/apps/backend/src/lib/plan-entitlements.ts @@ -29,7 +29,6 @@ type ItemCapacityReaders = { const TEAM_WIDE_CAPACITY_ITEM_IDS = new Set([ ITEM_IDS.authUsers, - ITEM_IDS.emailsPerMonth, ITEM_IDS.seats, ]); @@ -143,12 +142,6 @@ export async function getTeamWideAuthUsersCapacity( return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.authUsers); } -export async function getTeamWideEmailsPerMonthCapacity( - billingTeamId: string, -): Promise { - return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.emailsPerMonth); -} - export async function getTeamWideDashboardAdminsCapacity( billingTeamId: string, ): Promise { From 6a48fde7ff9a496c5c28f1ef2c1416f10313397e Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 3 Mar 2026 08:07:58 -0800 Subject: [PATCH 07/38] feat: add basic UI for purchasing additional seats We also switch growth to 4 seats by default. Note that extra seats can only be purchased as an add on to the team or growth plans. We keep the add purchase button in the team dialog for now, though the UI is subject to change. --- .../projects/page-client.tsx | 54 ++++++++++++++----- packages/stack-shared/src/plans.ts | 2 +- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 391dfcf78e..525ee90256 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -191,16 +191,21 @@ function TeamAddUserDialogContent(props: { const users = props.team.useUsers(); const admins = props.team.useItem("dashboard_admins"); + const products = props.team.useProducts(); + const hasPaidPlan = products.some( + p => (p.id === "team" || p.id === "growth") && p.type === "subscription" + ); const [email, setEmail] = useState(""); const [formError, setFormError] = useState(null); + const invitationsLoaded = invitations != null; const activeSeats = users.length + (invitations?.length ?? 0); const seatLimit = admins.quantity; - const atCapacity = activeSeats >= seatLimit; + const atCapacity = invitationsLoaded && activeSeats >= seatLimit; const handleInvite = async () => { - if (atCapacity) { + if (!invitationsLoaded || atCapacity) { return; } @@ -221,6 +226,19 @@ function TeamAddUserDialogContent(props: { } }; + const handleAddSeat = async () => { + try { + const checkoutUrl = await props.team.createCheckoutUrl({ + productId: "extra-seats", + returnUrl: window.location.href, + }); + window.location.assign(checkoutUrl); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + toast({ variant: "destructive", title: "Failed to start checkout", description: message }); + } + }; + const handleUpgrade = async () => { try { const checkoutUrl = await props.team.createCheckoutUrl({ @@ -231,7 +249,7 @@ function TeamAddUserDialogContent(props: { } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; toast({ variant: "destructive", title: "Failed to start upgrade", description: message }); - }; + } }; return ( @@ -239,13 +257,19 @@ function TeamAddUserDialogContent(props: {
Dashboard admin seats - - {activeSeats}/{seatLimit} - + {invitationsLoaded ? ( + + {activeSeats}/{seatLimit} + + ) : ( + + )}
{atCapacity && ( - You are at capacity. Upgrade your plan to add more admins. + {hasPaidPlan + ? "You are at capacity. Add an extra seat for $29/month." + : "You are at capacity. Upgrade your plan to add more admins."} )}
@@ -259,7 +283,7 @@ function TeamAddUserDialogContent(props: { }} placeholder="Email" type="email" - disabled={atCapacity} + disabled={!invitationsLoaded || atCapacity} autoFocus /> {formError && ( @@ -308,11 +332,17 @@ function TeamAddUserDialogContent(props: { Close {atCapacity ? ( - + hasPaidPlan ? ( + + ) : ( + + ) ) : ( - )} diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts index d737dce740..20d4b8a6c8 100644 --- a/packages/stack-shared/src/plans.ts +++ b/packages/stack-shared/src/plans.ts @@ -54,7 +54,7 @@ export const PLAN_LIMITS: { analyticsEvents: 500_000, }, growth: { - seats: UNLIMITED, + seats: 4, authUsers: UNLIMITED, emailsPerMonth: 25_000, analyticsTimeoutSeconds: 300, From e527e6cb5bc1f5229ba95b4eb88db7ef1c33f0a7 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 3 Mar 2026 08:45:36 -0800 Subject: [PATCH 08/38] feat(analytics): query timeout limits based on plan We update the schema to enforce a max of the growth plan limit. --- .../latest/internal/analytics/query/route.ts | 17 +++- .../endpoints/api/v1/analytics-query.test.ts | 84 +++++++++++++++++-- 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts index 4e9a4ed484..b107cb942c 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts @@ -1,13 +1,16 @@ import { getClickhouseExternalClient } from "@/lib/clickhouse"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getStackServerApp } from "@/stack"; import { KnownErrors } from "@stackframe/stack-shared"; +import { ITEM_IDS, PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; import { adaptSchema, adminAuthTypeSchema, jsonSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { randomUUID } from "crypto"; -const MAX_QUERY_TIMEOUT_MS = 120_000; +const MAX_QUERY_TIMEOUT_MS = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000; const DEFAULT_QUERY_TIMEOUT_MS = 10_000; export const POST = createSmartRouteHandler({ @@ -36,6 +39,16 @@ export const POST = createSmartRouteHandler({ if (body.include_all_branches) { throw new StackAssertionError("include_all_branches is not supported yet"); } + + let effectiveTimeoutMs = body.timeout_ms; + const billingTeamId = getBillingTeamId(auth.tenancy.project); + if (billingTeamId != null) { + const app = getStackServerApp(); + const timeoutItem = await app.getItem({ itemId: ITEM_IDS.analyticsTimeoutSeconds, teamId: billingTeamId }); + const maxAllowedMs = timeoutItem.quantity * 1000; + effectiveTimeoutMs = Math.min(body.timeout_ms, maxAllowedMs); + } + const client = getClickhouseExternalClient(); const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`; const resultSet = await Result.fromPromise(client.query({ @@ -45,7 +58,7 @@ export const POST = createSmartRouteHandler({ clickhouse_settings: { SQL_project_id: auth.tenancy.project.id, SQL_branch_id: auth.tenancy.branchId, - max_execution_time: body.timeout_ms / 1000, + max_execution_time: effectiveTimeoutMs / 1000, readonly: "1", allow_ddl: 0, max_result_rows: MAX_RESULT_ROWS.toString(), diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 8e847c7d82..1de859ee61 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -1,7 +1,8 @@ +import { PLAN_LIMITS, PlanId } from "@stackframe/stack-shared/dist/plans"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { it } from "../../../../helpers"; -import { Project, User, niceBackendFetch } from "../../../backend-helpers"; +import { backendContext, InternalProjectKeys, Project, User, niceBackendFetch } from "../../../backend-helpers"; async function runQuery(body: { query: string, params?: Record, timeout_ms?: number }) { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); @@ -15,6 +16,33 @@ async function runQuery(body: { query: string, params?: Record, return response; } +async function runQueryWithPlan(planId: PlanId, body: { query: string, params?: Record, timeout_ms?: number }) { + const { createProjectResponse, adminAccessToken } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + if (planId !== "free") { + const savedKeys = backendContext.value.projectKeys; + backendContext.set({ projectKeys: InternalProjectKeys }); + const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, { + method: "POST", + accessType: "server", + body: { product_id: planId }, + }); + if (grantResponse.status !== 200) { + throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); + } + backendContext.set({ projectKeys: savedKeys }); + } + + const response = await niceBackendFetch("/api/v1/internal/analytics/query", { + method: "POST", + accessType: "admin", + body, + }); + + return response; +} + type ExpectLike = ((value: unknown) => { toEqual: (value: unknown) => void }) & { any: (constructor: unknown) => unknown, }; @@ -154,10 +182,11 @@ it("can execute a query with custom timeout", async ({ expect }) => { `); }); -it("rejects timeouts longer than 2 minutes", async ({ expect }) => { +it("rejects timeouts longer than max plan limit", async ({ expect }) => { + const maxSchemaMs = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000; const response = await runQuery({ query: "SELECT 1 as value", - timeout_ms: 120_001, + timeout_ms: maxSchemaMs + 1, }); expect(stripQueryId(response, expect)).toMatchInlineSnapshot(` @@ -168,12 +197,12 @@ it("rejects timeouts longer than 2 minutes", async ({ expect }) => { "details": { "message": deindent\` Request validation failed on POST /api/v1/internal/analytics/query: - - body.timeout_ms must be less than or equal to 120000 + - body.timeout_ms must be less than or equal to ${maxSchemaMs} \`, }, "error": deindent\` Request validation failed on POST /api/v1/internal/analytics/query: - - body.timeout_ms must be less than or equal to 120000 + - body.timeout_ms must be less than or equal to ${maxSchemaMs} \`, }, "headers": Headers { @@ -1524,6 +1553,51 @@ it("does not allow input() function", async ({ expect }) => { `); }); +it("clamps timeout to free plan limit", async ({ expect }) => { + const response = await runQueryWithPlan("free", { + query: "SELECT getSetting('max_execution_time') as max_execution_time", + timeout_ms: 120000, + }); + + expect(response.status).toBe(200); + const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time); + expect(maxExecutionTime).toBe(PLAN_LIMITS.free.analyticsTimeoutSeconds); +}); + +it("clamps timeout to team plan limit", async ({ expect }) => { + const response = await runQueryWithPlan("team", { + query: "SELECT getSetting('max_execution_time') as max_execution_time", + timeout_ms: 120000, + }); + + expect(response.status).toBe(200); + const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time); + expect(maxExecutionTime).toBe(PLAN_LIMITS.team.analyticsTimeoutSeconds); +}); + +it("clamps timeout to growth plan limit", async ({ expect }) => { + const maxSchemaMs = Math.max(...Object.values(PLAN_LIMITS).map(p => p.analyticsTimeoutSeconds)) * 1000; + const response = await runQueryWithPlan("growth", { + query: "SELECT getSetting('max_execution_time') as max_execution_time", + timeout_ms: maxSchemaMs, + }); + + expect(response.status).toBe(200); + const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time); + expect(maxExecutionTime).toBe(PLAN_LIMITS.growth.analyticsTimeoutSeconds); +}); + +it("does not clamp timeout below the plan limit", async ({ expect }) => { + const response = await runQueryWithPlan("team", { + query: "SELECT getSetting('max_execution_time') as max_execution_time", + timeout_ms: 5000, + }); + + expect(response.status).toBe(200); + const maxExecutionTime = Number((response.body?.result as any)?.[0]?.max_execution_time); + expect(maxExecutionTime).toBe(5); +}); + it("does not allow numbers table function with large values", async ({ expect }) => { const response = await runQuery({ query: "SELECT * FROM numbers(1000000000)", From 481dffc9f396fc32b8577bd76a95438852775774 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 11:28:10 -0700 Subject: [PATCH 09/38] feat(events): add hard cap to event write This applies to both client side and server side events. Two write points for events to clickhouse-batch route for client side events, and logEvent for server side. Max batch size for client side events is 500. We decrease item quantity after batch is uploaded for clickhouse. The alternative is to either do a check for eventsItem.quantity -batchSize <=0 before the upload and block upsert based on it (which would result in users not getting their full limit) or doing some weird partial batch upload. At most, with this approach, we would give users ~500 extra events. This is 0.5% extra on free plan, so we judge that it is ok. frontend-banners pop up on analytics when at 80%+ and when you hit your limit, with an option to upgrade your plan. We deliberated using tryDecreaseQuantity instead to both check and debit items, but there is potential for debit to happen even when clickhouse is down with that approach. This would be worse for users, so we accept the slight race condition. Manual testing: on local, changed events for free and team plan to smaller numbers. Checked analytics tables to verify no new events being added after limit reached. --- .../latest/analytics/events/batch/route.tsx | 18 +++ apps/backend/src/lib/events.tsx | 37 ++++- .../analytics/queries/page-client.tsx | 2 + .../analytics/replays/page-client.tsx | 2 + .../projects/[projectId]/analytics/shared.tsx | 84 ++++++++++ .../analytics/tables/page-client.tsx | 2 + .../api/v1/analytics-events-batch.test.ts | 150 +++++++++++++++++- 7 files changed, 292 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index 683b1d8bc7..f0fb2ed358 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -1,8 +1,11 @@ import { getClickhouseAdminClient } from "@/lib/clickhouse"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; import { findRecentSessionReplay } from "@/lib/session-replays"; +import { getStackServerApp } from "@/stack"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; +import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -61,6 +64,16 @@ export const POST = createSmartRouteHandler({ const refreshTokenId = auth.refreshTokenId; const tenancyId = auth.tenancy.id; + const app = getStackServerApp(); + + const billingTeamId = getBillingTeamId(auth.tenancy.project); + if (billingTeamId != null) { + const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); + if (eventsItem.quantity <= 0) { + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, eventsItem.quantity); + } + } + const prisma = await getPrismaClientForTenancy(auth.tenancy); const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId }); @@ -89,6 +102,11 @@ export const POST = createSmartRouteHandler({ }, }); + if (billingTeamId != null) { + const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); + await eventsItem.decreaseQuantity(body.events.length); + } + return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 7fe9a1ae4f..282147ea74 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -1,9 +1,12 @@ import withPostHog from "@/analytics"; import { globalPrismaClient } from "@/prisma-client"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { getStackServerApp } from "@/stack"; import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel"; +import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { urlSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; -import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { HTTP_METHODS } from "@stackframe/stack-shared/dist/utils/http"; import { filterUndefined, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { UnionToIntersection } from "@stackframe/stack-shared/dist/utils/types"; @@ -264,6 +267,31 @@ export async function logEvent( // rest is no more dynamic APIs so we can run it asynchronously runAsynchronouslyAndWaitUntil((async () => { + // Resolve billing team for analytics event quota enforcement + let billingTeamId: string | null = null; + if (projectId) { + const project = await globalPrismaClient.project.findUnique({ + where: { id: projectId }, + select: { id: true, ownerTeamId: true }, + }); + if (project != null) { + billingTeamId = getBillingTeamId(project); + } + } + + // Check analytics event limit before writing anything (Postgres + ClickHouse treated as atomic) + if (billingTeamId != null) { + const app = getStackServerApp(); + const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); + if (eventsItem.quantity <= 0) { + captureError("logEvent", new StackAssertionError( + `Analytics event limit exceeded, dropping event. Project: ${projectId}, owner team: ${billingTeamId}, remaining quantity: ${eventsItem.quantity}`, + { projectId, ownerTeamId: billingTeamId }, + )); + return; + } + } + // log event in DB await globalPrismaClient.event.create({ data: { @@ -377,6 +405,13 @@ export async function logEvent( }); } + // Debit analytics event quota after successful writes + if (billingTeamId != null) { + const app = getStackServerApp(); + const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); + await eventsItem.decreaseQuantity(1); + } + // log event in PostHog if (getNodeEnvironment().includes("production") && !getEnvVariable("CI", "")) { await withPostHog(async posthog => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx index 25f516b6e5..0e40542852 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/queries/page-client.tsx @@ -35,6 +35,7 @@ import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; import { + AnalyticsEventLimitBanner, ErrorDisplay, FolderWithId, RowData, @@ -838,6 +839,7 @@ export default function PageClient() { return ( + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index c3c5de0fc5..47c6274fed 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -26,6 +26,7 @@ import { TeamSearchTable } from "@/components/data-table/team-search-table"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; +import { AnalyticsEventLimitBanner } from "../shared"; import { createInitialState, replayReducer, @@ -1384,6 +1385,7 @@ export default function PageClient() { return ( +
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 136b97b708..ac6ed12318 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -14,9 +14,13 @@ import { ArrowClockwiseIcon, WarningCircleIcon } from "@phosphor-icons/react"; +import { Alert, AlertDescription, Button } from "@/components/ui"; +import { useUser } from "@stackframe/stack"; +import { PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMemo, useRef } from "react"; +import { useAdminApp } from "../use-admin-app"; // ============================================================================ // Types @@ -316,3 +320,83 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () =
); } + +function resolvePlanId(products: Array<{ id: string | null, type: string }>): PlanId { + if (products.some(p => p.id === "growth" && p.type === "subscription")) return "growth"; + if (products.some(p => p.id === "team" && p.type === "subscription")) return "team"; + return "free"; +} + +/** + * Shows a warning banner when analytics event usage is at 80%+ or 100%. + * Fetches the billing team's analytics_events item and computes usage against the plan's total allocation. + */ +export function AnalyticsEventLimitBanner() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const teams = user.useTeams(); + + const ownerTeam = useMemo( + () => teams.find(t => t.id === project.ownerTeamId), + [teams, project.ownerTeamId], + ); + + if (ownerTeam == null) { + return null; + } + + return ; +} + +function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise } }) { + const eventsItem = team.useItem("analytics_events"); + const products = team.useProducts(); + const planId = resolvePlanId(products); + const totalAllocation = PLAN_LIMITS[planId].analyticsEvents; + const used = totalAllocation - eventsItem.quantity; + const usagePercent = totalAllocation > 0 ? (used / totalAllocation) * 100 : 0; + + if (usagePercent < 80) { + return null; + } + + const isExhausted = eventsItem.quantity <= 0; + const canUpgrade = planId !== "growth"; + + const handleUpgrade = async () => { + const targetProduct = planId === "free" ? "team" : "growth"; + const checkoutUrl = await team.createCheckoutUrl({ + productId: targetProduct, + returnUrl: window.location.href, + }); + window.location.assign(checkoutUrl); + }; + + return ( + svg]:text-amber-500"} + > + + + + {isExhausted + ? "You've reached your analytics event limit. New events are no longer being tracked." + : "You're approaching your analytics event limit." + } + {canUpgrade && !isExhausted && " Consider upgrading your plan."} + + {canUpgrade && ( + + )} + + + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx index ddca58c8fa..755da385ef 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx @@ -31,6 +31,7 @@ import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; import { + AnalyticsEventLimitBanner, isDateValue, isJsonValue, JsonValue, @@ -592,6 +593,7 @@ export default function PageClient() { return ( +
{/* Left sidebar - table list (doesn't scroll, border extends full height) */}
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index 5813cd96d2..e7bb249057 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -1,7 +1,8 @@ -import { randomUUID } from "node:crypto"; +import { PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { randomUUID } from "node:crypto"; import { it } from "../../../../helpers"; -import { Auth, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, InternalProjectKeys, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; async function uploadEventBatch(options: { sessionReplaySegmentId: string, @@ -428,3 +429,148 @@ it("inserted events are queryable via analytics query endpoint", async ({ expect } `); }); + +// ============================================================================ +// Analytics event limit enforcement tests +// ============================================================================ + +async function setupProjectWithPlan(planId: PlanId) { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + if (planId !== "free") { + const savedKeys = backendContext.value.projectKeys; + backendContext.set({ projectKeys: InternalProjectKeys }); + const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, { + method: "POST", + accessType: "server", + body: { product_id: planId }, + }); + if (grantResponse.status !== 200) { + throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); + } + backendContext.set({ projectKeys: savedKeys }); + } + + return { ownerTeamId }; +} + +async function withInternalProject(fn: () => Promise): Promise { + const savedKeys = backendContext.value.projectKeys; + const savedUserAuth = backendContext.value.userAuth; + backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); + try { + return await fn(); + } finally { + backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); + } +} + +async function getEventItemQuantity(ownerTeamId: string) { + return await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/analytics_events`, { + accessType: "server", + }); + if (response.status !== 200) { + throw new Error(`Failed to get analytics_events item: ${JSON.stringify(response.body)}`); + } + return response.body.quantity as number; + }); +} + +async function setEventItemQuantity(ownerTeamId: string, quantity: number) { + const currentQuantity = await getEventItemQuantity(ownerTeamId); + const delta = quantity - currentQuantity; + + await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/analytics_events/update-quantity?allow_negative=true`, { + method: "POST", + accessType: "server", + body: { delta }, + }); + if (response.status !== 200) { + throw new Error(`Failed to set analytics_events quantity: ${JSON.stringify(response.body)}`); + } + }); +} + +it("rejects batch when analytics event quota is exhausted", async ({ expect }) => { + const { ownerTeamId } = await setupProjectWithPlan("free"); + await Auth.Otp.signIn(); + + await setEventItemQuantity(ownerTeamId, 0); + + const res = await uploadEventBatch({ + sessionReplaySegmentId: randomUUID(), + batchId: randomUUID(), + sentAtMs: Date.now(), + events: [{ event_type: "$page-view", event_at_ms: Date.now(), data: {} }], + }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); +}); + +it("accepts batch and debits event quota correctly", async ({ expect }) => { + const { ownerTeamId } = await setupProjectWithPlan("free"); + await Auth.Otp.signIn(); + + // Wait for async logEvent debits (sign-in triggers token-refresh/sign-up-rule events asynchronously) + await wait(6000); + + const quantityBeforeBatch = await getEventItemQuantity(ownerTeamId); + + const now = Date.now(); + const eventCount = 3; + const res = await uploadEventBatch({ + sessionReplaySegmentId: randomUUID(), + batchId: randomUUID(), + sentAtMs: now, + events: Array.from({ length: eventCount }, (_, i) => ({ + event_type: "$page-view" as const, + event_at_ms: now - i, + data: { url: `https://example.com/page-${i}`, path: `/page-${i}` }, + })), + }); + + expect(res.status).toBe(200); + expect(res.body.inserted).toBe(eventCount); + + const afterQuantity = await getEventItemQuantity(ownerTeamId); + expect(afterQuantity).toBe(quantityBeforeBatch - eventCount); +}); + +it("rejects batch when remaining quota is less than batch size", async ({ expect }) => { + const { ownerTeamId } = await setupProjectWithPlan("free"); + await Auth.Otp.signIn(); + + await setEventItemQuantity(ownerTeamId, 0); + + const res = await uploadEventBatch({ + sessionReplaySegmentId: randomUUID(), + batchId: randomUUID(), + sentAtMs: Date.now(), + events: [ + { event_type: "$page-view", event_at_ms: Date.now(), data: {} }, + { event_type: "$click", event_at_ms: Date.now(), data: {} }, + ], + }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); +}); + +it("free plan starts with correct analytics event allocation", async ({ expect }) => { + const { ownerTeamId } = await setupProjectWithPlan("free"); + + const quantity = await getEventItemQuantity(ownerTeamId); + expect(quantity).toBe(PLAN_LIMITS.free.analyticsEvents); +}); + +it("team plan starts with correct analytics event allocation", async ({ expect }) => { + const { ownerTeamId } = await setupProjectWithPlan("team"); + + const quantity = await getEventItemQuantity(ownerTeamId); + expect(quantity).toBe(PLAN_LIMITS.team.analyticsEvents); +}); From 5da1de3e4384f020a8fd103f1f813a548abd7f98 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 11:30:05 -0700 Subject: [PATCH 10/38] fix: typing issue on plan limits --- apps/backend/prisma/seed.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 826c6819f4..f80cce5a2b 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -130,7 +130,7 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as const, expires: "when-repeated" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, @@ -151,7 +151,7 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as const, expires: "when-repeated" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, @@ -172,7 +172,7 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as const, expires: "when-repeated" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, From b0dd8324a7362d690d10dfac368f2926e3ad8610 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 13:33:20 -0700 Subject: [PATCH 11/38] feat(sesh_rep): add limits to session replays we block new creations of session replays when limit is hit. Session replays refresh monthly --- apps/backend/prisma/seed.ts | 4 + .../latest/session-replays/batch/route.tsx | 19 +++ .../analytics/replays/page-client.tsx | 3 +- .../projects/[projectId]/analytics/shared.tsx | 52 +++++++ .../endpoints/api/v1/session-replays.test.ts | 134 +++++++++++++++++- packages/stack-shared/src/plans.ts | 5 + 6 files changed, 215 insertions(+), 2 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index f80cce5a2b..b555020204 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -133,6 +133,7 @@ export async function seed() { [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, }, }, team: { @@ -154,6 +155,7 @@ export async function seed() { [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, }, }, growth: { @@ -175,6 +177,7 @@ export async function seed() { [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, }, }, "extra-seats": { @@ -205,6 +208,7 @@ export async function seed() { [ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const }, [ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const }, + [ITEM_IDS.sessionReplays]: { displayName: "Session Replays", customerType: "team" as const }, }, }, apps: { diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 57e1a162e4..2e3c6dda21 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -2,8 +2,11 @@ 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 { findRecentSessionReplay } from "@/lib/session-replays"; +import { getStackServerApp } from "@/stack"; import { KnownErrors } from "@stackframe/stack-shared"; +import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { randomUUID } from "node:crypto"; @@ -106,6 +109,17 @@ export const POST = createSmartRouteHandler({ const prisma = await getPrismaClientForTenancy(auth.tenancy); const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId }); + const app = getStackServerApp(); + + const isNewSession = recentSession == null; + const billingTeamId = getBillingTeamId(auth.tenancy.project); + if (isNewSession && billingTeamId != null) { + const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId }); + if (replaysItem.quantity <= 0) { + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.sessionReplays, billingTeamId, replaysItem.quantity); + } + } + const replayId = recentSession?.id ?? randomUUID(); const s3Key = `session-replays/${projectId}/${branchId}/${replayId}/${batchId}.json.gz`; @@ -197,6 +211,11 @@ export const POST = createSmartRouteHandler({ throw e; } + if (isNewSession && billingTeamId != null) { + const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId }); + await replaysItem.decreaseQuantity(1); + } + return { statusCode: 200, bodyType: "json", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 47c6274fed..3e48d67ae9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -26,7 +26,7 @@ import { TeamSearchTable } from "@/components/data-table/team-search-table"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { AnalyticsEventLimitBanner } from "../shared"; +import { AnalyticsEventLimitBanner, SessionReplayLimitBanner } from "../shared"; import { createInitialState, replayReducer, @@ -1386,6 +1386,7 @@ export default function PageClient() { +
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index ac6ed12318..9e19221824 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -349,6 +349,58 @@ export function AnalyticsEventLimitBanner() { return ; } +/** + * Shows a warning banner when session replay usage is at 80%+ or 100%. + * Since the limit is the same across all plans, no upgrade button is shown. + */ +export function SessionReplayLimitBanner() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const teams = user.useTeams(); + + const ownerTeam = useMemo( + () => teams.find(t => t.id === project.ownerTeamId), + [teams, project.ownerTeamId], + ); + + if (ownerTeam == null) { + return null; + } + + return ; +} + +function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }> } }) { + const replaysItem = team.useItem("session_replays"); + const products = team.useProducts(); + const planId = resolvePlanId(products); + const totalAllocation = PLAN_LIMITS[planId].sessionReplays; + const used = totalAllocation - replaysItem.quantity; + const usagePercent = totalAllocation > 0 ? (used / totalAllocation) * 100 : 0; + + if (usagePercent < 80) { + return null; + } + + const isExhausted = replaysItem.quantity <= 0; + + return ( + svg]:text-amber-500"} + > + + + {isExhausted + ? "You've reached your session replay limit for this month. New session replays are no longer being recorded." + : "You're approaching your session replay limit for this month." + } + + + ); +} + function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise } }) { const eventsItem = team.useItem("analytics_events"); const products = team.useProducts(); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index 0c699b5a1c..8fc7a80515 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; +import { PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../helpers"; -import { Auth, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, InternalProjectKeys, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers"; async function uploadBatch(options: { browserSessionId: string, @@ -1382,3 +1383,134 @@ it("admin list session replays rejects invalid filter parameters", async ({ expe } `); }); + +// ============================================================================ +// Session replay limit enforcement tests +// ============================================================================ + +async function withInternalProject(fn: () => Promise): Promise { + const savedKeys = backendContext.value.projectKeys; + const savedUserAuth = backendContext.value.userAuth; + backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); + try { + return await fn(); + } finally { + backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); + } +} + +async function getSessionReplayItemQuantity(ownerTeamId: string) { + return await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays`, { + accessType: "server", + }); + if (response.status !== 200) { + throw new Error(`Failed to get session_replays item: ${JSON.stringify(response.body)}`); + } + return response.body.quantity as number; + }); +} + +async function setSessionReplayItemQuantity(ownerTeamId: string, quantity: number) { + const currentQuantity = await getSessionReplayItemQuantity(ownerTeamId); + const delta = quantity - currentQuantity; + + await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays/update-quantity?allow_negative=true`, { + method: "POST", + accessType: "server", + body: { delta }, + }); + if (response.status !== 200) { + throw new Error(`Failed to set session_replays quantity: ${JSON.stringify(response.body)}`); + } + }); +} + +it("free plan starts with correct session replay allocation", async ({ expect }) => { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + const quantity = await getSessionReplayItemQuantity(ownerTeamId); + expect(quantity).toBe(PLAN_LIMITS.free.sessionReplays); +}); + +it("rejects new session replay when quota is exhausted", async ({ expect }) => { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + await Auth.Otp.signIn(); + await setSessionReplayItemQuantity(ownerTeamId, 0); + + const now = Date.now(); + const res = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 500, + events: [{ type: 2, timestamp: now + 100 }], + }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); +}); + +it("accepts new session replay and debits quota by 1", async ({ expect }) => { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + await Auth.Otp.signIn(); + + const quantityBefore = await getSessionReplayItemQuantity(ownerTeamId); + + const now = Date.now(); + const res = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 500, + events: [{ type: 2, timestamp: now + 100 }], + }); + + expect(res.status).toBe(200); + expect(res.body.deduped).toBe(false); + + const quantityAfter = await getSessionReplayItemQuantity(ownerTeamId); + expect(quantityAfter).toBe(quantityBefore - 1); +}); + +it("does not debit quota when appending chunks to an existing session replay", async ({ expect }) => { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + await Auth.Otp.signIn(); + + const now = Date.now(); + const firstBatch = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 500, + events: [{ type: 2, timestamp: now + 100 }], + }); + expect(firstBatch.status).toBe(200); + expect(firstBatch.body.deduped).toBe(false); + + const quantityAfterFirst = await getSessionReplayItemQuantity(ownerTeamId); + + const secondBatch = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 1000, + events: [{ type: 3, timestamp: now + 500 }], + }); + expect(secondBatch.status).toBe(200); + expect(secondBatch.body.session_replay_id).toBe(firstBatch.body.session_replay_id); + + const quantityAfterSecond = await getSessionReplayItemQuantity(ownerTeamId); + expect(quantityAfterSecond).toBe(quantityAfterFirst); +}); diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts index 20d4b8a6c8..8fac644f25 100644 --- a/packages/stack-shared/src/plans.ts +++ b/packages/stack-shared/src/plans.ts @@ -16,6 +16,7 @@ export const ITEM_IDS = { emailsPerMonth: "emails_per_month", analyticsTimeoutSeconds: "analytics_timeout_seconds", analyticsEvents: "analytics_events", + sessionReplays: "session_replays", } as const; export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS]; @@ -29,6 +30,7 @@ export type PlanProductOfferings = { emailsPerMonth: number, analyticsTimeoutSeconds: number, analyticsEvents: number, + sessionReplays: number, }; /** @@ -45,6 +47,7 @@ export const PLAN_LIMITS: { emailsPerMonth: 1_000, analyticsTimeoutSeconds: 10, analyticsEvents: 100_000, + sessionReplays: 2_500, }, team: { seats: 4, @@ -52,6 +55,7 @@ export const PLAN_LIMITS: { emailsPerMonth: 25_000, analyticsTimeoutSeconds: 60, analyticsEvents: 500_000, + sessionReplays: 2_500, }, growth: { seats: 4, @@ -59,6 +63,7 @@ export const PLAN_LIMITS: { emailsPerMonth: 25_000, analyticsTimeoutSeconds: 300, analyticsEvents: 1_000_000, + sessionReplays: 2_500, }, }; From d7081a8f1b3ccb058d5a1402c1bd1e86bea124be Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 13:36:25 -0700 Subject: [PATCH 12/38] fix: events must refresh monthly --- apps/backend/prisma/seed.ts | 6 +++--- .../(protected)/projects/[projectId]/analytics/shared.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index b555020204..22a7b4fb13 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -132,7 +132,7 @@ export async function seed() { [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, }, }, @@ -154,7 +154,7 @@ export async function seed() { [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, }, }, @@ -176,7 +176,7 @@ export async function seed() { [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, }, }, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 9e19221824..7ad9e32869 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -393,8 +393,8 @@ function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: str {isExhausted - ? "You've reached your session replay limit for this month. New session replays are no longer being recorded." - : "You're approaching your session replay limit for this month." + ? "You've reached your monthly session replay limit. New session replays are no longer being recorded. Your limit resets next month." + : "You're approaching your monthly session replay limit." } @@ -434,8 +434,8 @@ function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: st {isExhausted - ? "You've reached your analytics event limit. New events are no longer being tracked." - : "You're approaching your analytics event limit." + ? "You've reached your monthly analytics event limit. New events are no longer being tracked. Your limit resets next month." + : "You're approaching your monthly analytics event limit." } {canUpgrade && !isExhausted && " Consider upgrading your plan."} From bd87a35d10298eb50ead7003f09cb9b43c6902ba Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 16:27:35 -0700 Subject: [PATCH 13/38] feat: add onboarding call to plan We defer implementation until new onboarding flow is done. --- apps/backend/prisma/seed.ts | 3 +++ packages/stack-shared/src/plans.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 22a7b4fb13..bc9b68e5db 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -156,6 +156,7 @@ export async function seed() { [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, }, growth: { @@ -178,6 +179,7 @@ export async function seed() { [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, }, "extra-seats": { @@ -209,6 +211,7 @@ export async function seed() { [ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const }, [ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const }, [ITEM_IDS.sessionReplays]: { displayName: "Session Replays", customerType: "team" as const }, + [ITEM_IDS.onboardingCall]: { displayName: "Onboarding Call", customerType: "team" as const }, }, }, apps: { diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts index 8fac644f25..0608a2cd9b 100644 --- a/packages/stack-shared/src/plans.ts +++ b/packages/stack-shared/src/plans.ts @@ -17,6 +17,7 @@ export const ITEM_IDS = { analyticsTimeoutSeconds: "analytics_timeout_seconds", analyticsEvents: "analytics_events", sessionReplays: "session_replays", + onboardingCall: "onboarding_call", } as const; export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS]; From b82707d6b89706703f386c37eaf798987c6edb6a Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 17:38:03 -0700 Subject: [PATCH 14/38] fix: better error states for limit related ui components Button already wraps handleClick in runAsynchronouslyWithAlert --- .../projects/page-client.tsx | 74 ++++++++++++------- .../analytics/replays/page-client.tsx | 2 +- .../projects/[projectId]/analytics/shared.tsx | 8 +- 3 files changed, 51 insertions(+), 33 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 525ee90256..cb7b8c87ac 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -170,18 +170,33 @@ function TeamAddUserDialogContent(props: { onClose: () => void, }) { const [invitations, setInvitations] = useState>>(); + const [invitationsError, setInvitationsError] = useState(null); const fetchInvitations = useCallback(async () => { - const invitations = await listInvitations(props.team.id); - setInvitations(invitations); + setInvitationsError(null); + try { + const invitations = await listInvitations(props.team.id); + setInvitations(invitations); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to load invitations"; + setInvitationsError(message); + throw error; + } }, [props.team.id]); useEffect(() => { let canceled = false; runAsynchronously(async () => { - const invitations = await listInvitations(props.team.id); - if (!canceled) { - setInvitations(invitations); + try { + const invitations = await listInvitations(props.team.id); + if (!canceled) { + setInvitations(invitations); + } + } catch (error) { + if (!canceled) { + const message = error instanceof Error ? error.message : "Failed to load invitations"; + setInvitationsError(message); + } } }); return () => { @@ -227,29 +242,19 @@ function TeamAddUserDialogContent(props: { }; const handleAddSeat = async () => { - try { - const checkoutUrl = await props.team.createCheckoutUrl({ - productId: "extra-seats", - returnUrl: window.location.href, - }); - window.location.assign(checkoutUrl); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - toast({ variant: "destructive", title: "Failed to start checkout", description: message }); - } + const checkoutUrl = await props.team.createCheckoutUrl({ + productId: "extra-seats", + returnUrl: window.location.href, + }); + window.location.assign(checkoutUrl); }; const handleUpgrade = async () => { - try { - const checkoutUrl = await props.team.createCheckoutUrl({ - productId: "team", - returnUrl: window.location.href, - }); - window.location.assign(checkoutUrl); - } catch (error) { - const message = error instanceof Error ? error.message : "Unknown error"; - toast({ variant: "destructive", title: "Failed to start upgrade", description: message }); - } + const checkoutUrl = await props.team.createCheckoutUrl({ + productId: "team", + returnUrl: window.location.href, + }); + window.location.assign(checkoutUrl); }; return ( @@ -283,7 +288,7 @@ function TeamAddUserDialogContent(props: { }} placeholder="Email" type="email" - disabled={!invitationsLoaded || atCapacity} + disabled={(!invitationsLoaded && !invitationsError) || atCapacity} autoFocus /> {formError && ( @@ -295,7 +300,20 @@ function TeamAddUserDialogContent(props: {
Pending invitations - {invitations?.length === 0 ? ( + {invitationsError ? ( +
+ + {invitationsError} + + +
+ ) : invitations?.length === 0 ? ( None ) : (
@@ -342,7 +360,7 @@ function TeamAddUserDialogContent(props: { ) ) : ( - )} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 3e48d67ae9..8891ee81b5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -1387,7 +1387,7 @@ export default function PageClient() { - +
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 7ad9e32869..2fca6c7299 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -376,8 +376,8 @@ function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: str const products = team.useProducts(); const planId = resolvePlanId(products); const totalAllocation = PLAN_LIMITS[planId].sessionReplays; - const used = totalAllocation - replaysItem.quantity; - const usagePercent = totalAllocation > 0 ? (used / totalAllocation) * 100 : 0; + const used = Math.max(0, totalAllocation - replaysItem.quantity); + const usagePercent = totalAllocation > 0 ? Math.min(100, (used / totalAllocation) * 100) : 0; if (usagePercent < 80) { return null; @@ -406,8 +406,8 @@ function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: st const products = team.useProducts(); const planId = resolvePlanId(products); const totalAllocation = PLAN_LIMITS[planId].analyticsEvents; - const used = totalAllocation - eventsItem.quantity; - const usagePercent = totalAllocation > 0 ? (used / totalAllocation) * 100 : 0; + const used = Math.max(0, totalAllocation - eventsItem.quantity); + const usagePercent = totalAllocation > 0 ? Math.min(100, (used / totalAllocation) * 100) : 0; if (usagePercent < 80) { return null; From 789c93135e671fec056a9830b0d842bf2e8d32c3 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 17:53:56 -0700 Subject: [PATCH 15/38] fix: increase robustness of analytics tests --- apps/e2e/tests/backend/backend-helpers.ts | 11 +++++++ .../api/v1/analytics-events-batch.test.ts | 32 ++++++------------- .../endpoints/api/v1/analytics-query.test.ts | 23 +++++++------ .../endpoints/api/v1/session-replays.test.ts | 31 ++++++++++-------- 4 files changed, 50 insertions(+), 47 deletions(-) diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 726568bdc3..3de8852fcc 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -83,6 +83,17 @@ export const InternalProjectKeys = Object.freeze({ superSecretAdminKey: STACK_INTERNAL_PROJECT_ADMIN_KEY, }); +export async function withInternalProject(fn: () => Promise): Promise { + const savedKeys = backendContext.value.projectKeys; + const savedUserAuth = backendContext.value.userAuth; + backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); + try { + return await fn(); + } finally { + backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); + } +} + export const InternalProjectClientKeys = Object.freeze({ projectId: STACK_INTERNAL_PROJECT_ID, publishableClientKey: STACK_INTERNAL_PROJECT_CLIENT_KEY, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index e7bb249057..ae46c77420 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -2,7 +2,7 @@ import { PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { randomUUID } from "node:crypto"; import { it } from "../../../../helpers"; -import { Auth, InternalProjectKeys, Project, backendContext, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, Project, backendContext, niceBackendFetch, withInternalProject } from "../../../backend-helpers"; async function uploadEventBatch(options: { sessionReplaySegmentId: string, @@ -440,33 +440,21 @@ async function setupProjectWithPlan(planId: PlanId) { const ownerTeamId = createProjectResponse.body.owner_team_id; if (planId !== "free") { - const savedKeys = backendContext.value.projectKeys; - backendContext.set({ projectKeys: InternalProjectKeys }); - const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, { - method: "POST", - accessType: "server", - body: { product_id: planId }, + await withInternalProject(async () => { + const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, { + method: "POST", + accessType: "server", + body: { product_id: planId }, + }); + if (grantResponse.status !== 200) { + throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); + } }); - if (grantResponse.status !== 200) { - throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); - } - backendContext.set({ projectKeys: savedKeys }); } return { ownerTeamId }; } -async function withInternalProject(fn: () => Promise): Promise { - const savedKeys = backendContext.value.projectKeys; - const savedUserAuth = backendContext.value.userAuth; - backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); - try { - return await fn(); - } finally { - backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); - } -} - async function getEventItemQuantity(ownerTeamId: string) { return await withInternalProject(async () => { const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/analytics_events`, { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 1de859ee61..0f13020659 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -2,7 +2,7 @@ import { PLAN_LIMITS, PlanId } from "@stackframe/stack-shared/dist/plans"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { it } from "../../../../helpers"; -import { backendContext, InternalProjectKeys, Project, User, niceBackendFetch } from "../../../backend-helpers"; +import { Project, User, niceBackendFetch, withInternalProject } from "../../../backend-helpers"; async function runQuery(body: { query: string, params?: Record, timeout_ms?: number }) { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); @@ -17,21 +17,20 @@ async function runQuery(body: { query: string, params?: Record, } async function runQueryWithPlan(planId: PlanId, body: { query: string, params?: Record, timeout_ms?: number }) { - const { createProjectResponse, adminAccessToken } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); const ownerTeamId = createProjectResponse.body.owner_team_id; if (planId !== "free") { - const savedKeys = backendContext.value.projectKeys; - backendContext.set({ projectKeys: InternalProjectKeys }); - const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, { - method: "POST", - accessType: "server", - body: { product_id: planId }, + await withInternalProject(async () => { + const grantResponse = await niceBackendFetch(`/api/v1/payments/products/team/${ownerTeamId}`, { + method: "POST", + accessType: "server", + body: { product_id: planId }, + }); + if (grantResponse.status !== 200) { + throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); + } }); - if (grantResponse.status !== 200) { - throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); - } - backendContext.set({ projectKeys: savedKeys }); } const response = await niceBackendFetch("/api/v1/internal/analytics/query", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index 8fc7a80515..efa159bf72 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../helpers"; -import { Auth, InternalProjectKeys, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers"; +import { Auth, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch, withInternalProject } from "../../../backend-helpers"; async function uploadBatch(options: { browserSessionId: string, @@ -1388,17 +1388,6 @@ it("admin list session replays rejects invalid filter parameters", async ({ expe // Session replay limit enforcement tests // ============================================================================ -async function withInternalProject(fn: () => Promise): Promise { - const savedKeys = backendContext.value.projectKeys; - const savedUserAuth = backendContext.value.userAuth; - backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); - try { - return await fn(); - } finally { - backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); - } -} - async function getSessionReplayItemQuantity(ownerTeamId: string) { return await withInternalProject(async () => { const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays`, { @@ -1481,7 +1470,7 @@ it("accepts new session replay and debits quota by 1", async ({ expect }) => { expect(quantityAfter).toBe(quantityBefore - 1); }); -it("does not debit quota when appending chunks to an existing session replay", async ({ expect }) => { +it("does not debit quota when appending chunks to an existing session replay, even after quota is exhausted", async ({ expect }) => { const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); const ownerTeamId = createProjectResponse.body.owner_team_id; @@ -1513,4 +1502,20 @@ it("does not debit quota when appending chunks to an existing session replay", a const quantityAfterSecond = await getSessionReplayItemQuantity(ownerTeamId); expect(quantityAfterSecond).toBe(quantityAfterFirst); + + // Exhaust quota — existing replays should still be able to append + await setSessionReplayItemQuantity(ownerTeamId, 0); + + const thirdBatch = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: now, + sentAtMs: now + 1500, + events: [{ type: 3, timestamp: now + 1000 }], + }); + expect(thirdBatch.status).toBe(200); + expect(thirdBatch.body.session_replay_id).toBe(firstBatch.body.session_replay_id); + + const quantityAfterThird = await getSessionReplayItemQuantity(ownerTeamId); + expect(quantityAfterThird).toBe(0); }); From d736b75e7057f192a312513d7407ba69aef3b527 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 9 Mar 2026 18:26:16 -0700 Subject: [PATCH 16/38] refactor: cleaner typing in configs --- apps/backend/prisma/seed.ts | 26 ++++++++++++---------- packages/stack-shared/src/config/format.ts | 14 +++++++----- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index bc9b68e5db..56177d6fd3 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -17,6 +17,8 @@ import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects'; import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; +const MONTHLY_REPEAT: DayInterval = [1, "month"]; + const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063'; const EXPLORATORY_TEAM_DISPLAY_NAME = 'Exploratory Research and Insight Partnership With Very Long Collaborative Name For Testing'; @@ -130,10 +132,10 @@ export async function seed() { includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, - [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, + [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, }, }, team: { @@ -145,17 +147,17 @@ export async function seed() { prices: { monthly: { USD: "49", - interval: [1, "month"] as any, + interval: MONTHLY_REPEAT, serverOnly: false, }, }, includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, - [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, + [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, [ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, }, @@ -168,17 +170,17 @@ export async function seed() { prices: { monthly: { USD: "299", - interval: [1, "month"] as any, + interval: MONTHLY_REPEAT, serverOnly: false, }, }, includedItems: { [ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const }, [ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, [ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const }, - [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: [1, "month"] as any, expires: "when-repeated" as const }, - [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const }, + [ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, + [ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: MONTHLY_REPEAT, expires: "when-repeated" as const }, [ITEM_IDS.onboardingCall]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, }, @@ -191,7 +193,7 @@ export async function seed() { prices: { monthly: { USD: "29", - interval: [1, "month"] as any, + interval: MONTHLY_REPEAT, serverOnly: false, }, }, diff --git a/packages/stack-shared/src/config/format.ts b/packages/stack-shared/src/config/format.ts index 1adf071163..d95babfc43 100644 --- a/packages/stack-shared/src/config/format.ts +++ b/packages/stack-shared/src/config/format.ts @@ -15,12 +15,14 @@ export type NormalizedConfig = { [key: string]: NormalizedConfigValue | undefined, // must support undefined for optional values }; -export type _NormalizesTo = N extends object ? ( - & Config - & { [K in OptionalKeys]?: _NormalizesTo | null } - & { [K in RequiredKeys]: undefined extends N[K] ? _NormalizesTo | null : _NormalizesTo } - & { [K in `${string}.${string}`]: ConfigValue } -) : N; +export type _NormalizesTo = N extends readonly any[] + ? { [K in keyof N]: _NormalizesTo } + : N extends object ? ( + & Config + & { [K in OptionalKeys]?: _NormalizesTo | null } + & { [K in RequiredKeys]: undefined extends N[K] ? _NormalizesTo | null : _NormalizesTo } + & { [K in `${string}.${string}`]: ConfigValue } + ) : N; export type NormalizesTo = _NormalizesTo; /** From 6dd4b235406786b51f378b8d396bfcf04824ba58 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 10 Mar 2026 11:50:54 -0700 Subject: [PATCH 17/38] feat: switch to debit first approach for item consumption This approach prevents any extra debits. Note that we still don't support partial batches, this is a legitimate case of missing behavior. A followup PR which introduces metered pricing will deal with it --- .../latest/analytics/events/batch/route.tsx | 8 ++----- .../latest/session-replays/batch/route.tsx | 8 ++----- apps/backend/src/lib/email-queue-step.tsx | 10 +++------ apps/backend/src/lib/events.tsx | 11 ++-------- .../api/v1/analytics-events-batch.test.ts | 21 +++++++++++++------ 5 files changed, 24 insertions(+), 34 deletions(-) diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index f0fb2ed358..05990d56ed 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -69,7 +69,8 @@ export const POST = createSmartRouteHandler({ const billingTeamId = getBillingTeamId(auth.tenancy.project); if (billingTeamId != null) { const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); - if (eventsItem.quantity <= 0) { + const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length); + if (!isDebited) { throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, eventsItem.quantity); } } @@ -102,11 +103,6 @@ export const POST = createSmartRouteHandler({ }, }); - if (billingTeamId != null) { - const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); - await eventsItem.decreaseQuantity(body.events.length); - } - return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 2e3c6dda21..e2a97a9175 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -115,7 +115,8 @@ export const POST = createSmartRouteHandler({ const billingTeamId = getBillingTeamId(auth.tenancy.project); if (isNewSession && billingTeamId != null) { const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId }); - if (replaysItem.quantity <= 0) { + const isDebited = await replaysItem.tryDecreaseQuantity(1); + if (!isDebited) { throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.sessionReplays, billingTeamId, replaysItem.quantity); } } @@ -211,11 +212,6 @@ export const POST = createSmartRouteHandler({ throw e; } - if (isNewSession && billingTeamId != null) { - const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId }); - await replaysItem.decreaseQuantity(1); - } - return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 3e3e366229..12d0f7712e 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -703,10 +703,11 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO } } - if (context.billingTeamId != null) { + if (context.billingTeamId != null && row.sendRetries === 0) { const app = getStackServerApp(); const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); - if (emailItem.quantity <= 0) { + const isDebited = await emailItem.tryDecreaseQuantity(1); + if (!isDebited) { const errorMessage = "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month."; const errorEntry: SendAttemptError = { attemptNumber: row.sendRetries + 1, @@ -851,11 +852,6 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO }, }); - if (context.billingTeamId != null) { - const app = getStackServerApp(); - const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); - await emailItem.decreaseQuantity(1); - } } } catch (error) { captureError("email-queue-step-sending-single-error", error); diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 282147ea74..17eb8c9fe9 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -279,11 +279,11 @@ export async function logEvent( } } - // Check analytics event limit before writing anything (Postgres + ClickHouse treated as atomic) if (billingTeamId != null) { const app = getStackServerApp(); const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); - if (eventsItem.quantity <= 0) { + const isDebited = await eventsItem.tryDecreaseQuantity(1); + if (!isDebited) { captureError("logEvent", new StackAssertionError( `Analytics event limit exceeded, dropping event. Project: ${projectId}, owner team: ${billingTeamId}, remaining quantity: ${eventsItem.quantity}`, { projectId, ownerTeamId: billingTeamId }, @@ -405,13 +405,6 @@ export async function logEvent( }); } - // Debit analytics event quota after successful writes - if (billingTeamId != null) { - const app = getStackServerApp(); - const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); - await eventsItem.decreaseQuantity(1); - } - // log event in PostHog if (getNodeEnvironment().includes("production") && !getEnvVariable("CI", "")) { await withPostHog(async posthog => { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index ae46c77420..3d51c50d7b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -529,24 +529,33 @@ it("accepts batch and debits event quota correctly", async ({ expect }) => { expect(afterQuantity).toBe(quantityBeforeBatch - eventCount); }); -it("rejects batch when remaining quota is less than batch size", async ({ expect }) => { +// We don't support metered pricing or partial batches for now, so the entire +// batch is rejected when remaining quota is less than the batch size, and +// the quota must remain unchanged (no partial debit). +it("rejects batch when remaining quota is less than batch size and does not debit", async ({ expect }) => { const { ownerTeamId } = await setupProjectWithPlan("free"); await Auth.Otp.signIn(); - await setEventItemQuantity(ownerTeamId, 0); + // Wait for async logEvent debits (sign-in triggers events asynchronously) + await wait(6000); + await setEventItemQuantity(ownerTeamId, 2); const res = await uploadEventBatch({ sessionReplaySegmentId: randomUUID(), batchId: randomUUID(), sentAtMs: Date.now(), - events: [ - { event_type: "$page-view", event_at_ms: Date.now(), data: {} }, - { event_type: "$click", event_at_ms: Date.now(), data: {} }, - ], + events: Array.from({ length: 5 }, (_, i) => ({ + event_type: "$page-view" as const, + event_at_ms: Date.now() - i, + data: {}, + })), }); expect(res.status).toBe(400); expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); + + const quantityAfter = await getEventItemQuantity(ownerTeamId); + expect(quantityAfter).toBe(2); }); it("free plan starts with correct analytics event allocation", async ({ expect }) => { From 5dbf3cd5c62e73dc6f18a02a65b88db6d0b3e5a5 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 10 Mar 2026 11:54:21 -0700 Subject: [PATCH 18/38] refactor: clean up error logging to reduce noise --- apps/backend/src/lib/events.tsx | 4 ---- .../(protected)/(outside-dashboard)/projects/page-client.tsx | 1 - 2 files changed, 5 deletions(-) diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 17eb8c9fe9..064ad0c56c 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -284,10 +284,6 @@ export async function logEvent( const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); const isDebited = await eventsItem.tryDecreaseQuantity(1); if (!isDebited) { - captureError("logEvent", new StackAssertionError( - `Analytics event limit exceeded, dropping event. Project: ${projectId}, owner team: ${billingTeamId}, remaining quantity: ${eventsItem.quantity}`, - { projectId, ownerTeamId: billingTeamId }, - )); return; } } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index cb7b8c87ac..ca2a5488aa 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -180,7 +180,6 @@ function TeamAddUserDialogContent(props: { } catch (error) { const message = error instanceof Error ? error.message : "Failed to load invitations"; setInvitationsError(message); - throw error; } }, [props.team.id]); From 736b7753ad4adfe70f5dd8895a2e2168213a1cba Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 10 Mar 2026 12:34:36 -0700 Subject: [PATCH 19/38] fix: minor banner movement --- .../projects/[projectId]/analytics/replays/page-client.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index 8891ee81b5..705a9bf335 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -26,7 +26,7 @@ import { TeamSearchTable } from "@/components/data-table/team-search-table"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; -import { AnalyticsEventLimitBanner, SessionReplayLimitBanner } from "../shared"; +import { SessionReplayLimitBanner } from "../shared"; import { createInitialState, replayReducer, @@ -1385,7 +1385,6 @@ export default function PageClient() { return ( - From 1926304a8fca47d7da968574aed90bb41eb2c26d Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 10 Mar 2026 13:00:27 -0700 Subject: [PATCH 20/38] fix(tests): avoid shared singleton mutation --- apps/e2e/tests/backend/backend-helpers.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/e2e/tests/backend/backend-helpers.ts b/apps/e2e/tests/backend/backend-helpers.ts index 3de8852fcc..c8cd360b96 100644 --- a/apps/e2e/tests/backend/backend-helpers.ts +++ b/apps/e2e/tests/backend/backend-helpers.ts @@ -84,14 +84,7 @@ export const InternalProjectKeys = Object.freeze({ }); export async function withInternalProject(fn: () => Promise): Promise { - const savedKeys = backendContext.value.projectKeys; - const savedUserAuth = backendContext.value.userAuth; - backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null }); - try { - return await fn(); - } finally { - backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth }); - } + return await backendContext.with({ projectKeys: InternalProjectKeys, userAuth: null }, fn); } export const InternalProjectClientKeys = Object.freeze({ From e91c6b458cfadada8e3d2eb272f66a49def87cf3 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 10 Mar 2026 15:23:46 -0700 Subject: [PATCH 21/38] refactor: cleaner error messages on UI --- .../src/app/api/latest/analytics/events/batch/route.tsx | 2 +- .../src/app/api/latest/session-replays/batch/route.tsx | 2 +- .../(outside-dashboard)/projects/page-client.tsx | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index 05990d56ed..0f78010979 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -71,7 +71,7 @@ export const POST = createSmartRouteHandler({ const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length); if (!isDebited) { - throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, eventsItem.quantity); + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, body.events.length); } } diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index e2a97a9175..6e2dec0fbe 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -117,7 +117,7 @@ export const POST = createSmartRouteHandler({ const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId }); const isDebited = await replaysItem.tryDecreaseQuantity(1); if (!isDebited) { - throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.sessionReplays, billingTeamId, replaysItem.quantity); + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.sessionReplays, billingTeamId, 1); } } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index ca2a5488aa..09125ea686 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -178,8 +178,7 @@ function TeamAddUserDialogContent(props: { const invitations = await listInvitations(props.team.id); setInvitations(invitations); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to load invitations"; - setInvitationsError(message); + setInvitationsError("Failed to load invitations. Please try again."); } }, [props.team.id]); @@ -193,8 +192,7 @@ function TeamAddUserDialogContent(props: { } } catch (error) { if (!canceled) { - const message = error instanceof Error ? error.message : "Failed to load invitations"; - setInvitationsError(message); + setInvitationsError("Failed to load invitations. Please try again."); } } }); From 48a0a72a5708d173413a14f3a0688a273af02be7 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Sun, 19 Apr 2026 20:13:13 -0700 Subject: [PATCH 22/38] refactor/fix: entitlement tests, give internal growth plan --- apps/backend/prisma/seed.ts | 51 +++++++++++++++++++ .../backend/src/app/api/latest/users/crud.tsx | 8 +++ .../backend/src/lib/plan-entitlements.test.ts | 3 +- apps/backend/src/lib/plan-entitlements.ts | 6 +-- apps/e2e/tests/js/payments.test.ts | 19 ------- 5 files changed, 63 insertions(+), 24 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 1d54e50267..c8ceca353e 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,5 +1,6 @@ /* eslint-disable no-restricted-syntax */ import { usersCrudHandlers } from '@/app/api/latest/users/crud'; +import { CustomerType, Prisma, PurchaseCreationSource, SubscriptionStatus } from '@/generated/prisma/client'; import { overrideBranchConfigOverride } from '@/lib/config'; import { LOCAL_EMULATOR_ADMIN_EMAIL, @@ -15,6 +16,7 @@ import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenanc import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config'; import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans'; +import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects'; @@ -273,6 +275,55 @@ export async function seed() { console.log('Internal team created'); } + // The team-create CRUD path auto-grants the free plan to every team in the + // internal project, but the internal team itself is written directly above + // (bypassing that code path), so it would otherwise end up with zero + // entitlements and trip the plan-limit enforcement. Grant it the Growth plan + // so Stack Auth employees using the dashboard get full quotas. Idempotent — + // skipped if an active Growth subscription already exists. + // + // We create the subscription with raw Prisma (matching seed-dummy-data.ts) + // rather than grantProductToCustomer because bulldozer storage tables + // aren't initialized at this point in the seed yet. The Bulldozer init + // call right below this block ingresses the row into the ledger. + const growthProduct = updatedInternalTenancy.config.payments.products.growth; + if (growthProduct.customerType === 'team') { + const existingGrowthSub = await internalPrisma.subscription.findFirst({ + where: { + tenancyId: internalTenancy.id, + customerId: internalTeamId, + customerType: CustomerType.TEAM, + productId: 'growth', + status: SubscriptionStatus.active, + }, + }); + if (!existingGrowthSub) { + const growthPrices = growthProduct.prices === 'include-by-default' ? {} : growthProduct.prices; + const firstPriceId = Object.keys(growthPrices)[0] ?? null; + const now = new Date(); + // Clone to ensure the stored JSON snapshot is independent of the config object + // (mirrors the pattern used in seed-dummy-data.ts). + const storedProduct = JSON.parse(JSON.stringify(growthProduct)) as Prisma.InputJsonValue; + await internalPrisma.subscription.create({ + data: { + tenancyId: internalTenancy.id, + customerId: internalTeamId, + customerType: CustomerType.TEAM, + status: SubscriptionStatus.active, + productId: 'growth', + priceId: firstPriceId, + product: storedProduct, + quantity: 1, + currentPeriodStart: now, + currentPeriodEnd: new Date('2099-12-31T23:59:59Z'), + cancelAtPeriodEnd: false, + creationSource: PurchaseCreationSource.TEST_MODE, + }, + }); + console.log('Granted Growth plan to internal team'); + } + } + // Upsert the internal API key set before any flake-prone work (dummy-project // seed, email/svix, clickhouse). The emulator CLI authenticates against the // internal project using the pck stored here, so it must land before the rest diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index e5380ec1b9..150e059438 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -259,6 +259,14 @@ async function checkAuthData( } async function checkAuthUsersSoftLimit(tenancy: Tenancy) { + // Seed creates dummy-project users via raw Prisma before the bulldozer + // payments ledger has been ingressed, so every read here would see + // capacity=0 and flood logs. Bulldozer's seed-time invariant is that + // nothing reads the ledger until runBulldozerPaymentsInit runs post-seed; + // we honor that here rather than forcing seed to double-init. + if (process.env.STACK_SEED_MODE === 'true') { + return; + } const billingTeamId = getBillingTeamId(tenancy.project); if (billingTeamId == null) { return; diff --git a/apps/backend/src/lib/plan-entitlements.test.ts b/apps/backend/src/lib/plan-entitlements.test.ts index 9b05a61e30..7377720b26 100644 --- a/apps/backend/src/lib/plan-entitlements.test.ts +++ b/apps/backend/src/lib/plan-entitlements.test.ts @@ -8,7 +8,6 @@ import { getTeamWideItemCapacityForTests, getTeamWideNonAnonymousUserCount, } from "./plan-entitlements"; -import type { Tenancy } from "./tenancies"; type ProjectRow = { id: string, ownerTeamId: string | null }; type TenancyRow = { id: string, projectId: string }; @@ -126,7 +125,7 @@ describe("capacity lookup helpers", () => { getPrismaForTenancy: async (): Promise => ({} as PrismaClientTransaction), getItemQuantityForCustomer: async (options: { prisma: unknown, - tenancy: Tenancy, + tenancyId: string, customerId: string, customerType: "team", itemId: string, diff --git a/apps/backend/src/lib/plan-entitlements.ts b/apps/backend/src/lib/plan-entitlements.ts index fcf3524404..1a8fc2d955 100644 --- a/apps/backend/src/lib/plan-entitlements.ts +++ b/apps/backend/src/lib/plan-entitlements.ts @@ -1,4 +1,4 @@ -import { getItemQuantityForCustomer } from "@/lib/payments"; +import { getItemQuantityForCustomer } from "@/lib/payments/customer-data"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -20,7 +20,7 @@ type ItemCapacityReaders = { getPrismaForTenancy: (tenancy: Tenancy) => Promise, getItemQuantityForCustomer: (options: { prisma: unknown, - tenancy: Tenancy, + tenancyId: string, customerId: string, customerType: "team", itemId: string, @@ -121,7 +121,7 @@ async function getTeamWideItemCapacity( const billingPrisma = await readers.getPrismaForTenancy(internalBillingTenancy); return await readers.getItemQuantityForCustomer({ prisma: billingPrisma, - tenancy: internalBillingTenancy, + tenancyId: internalBillingTenancy.id, customerId: billingTeamId, customerType: "team", itemId, diff --git a/apps/e2e/tests/js/payments.test.ts b/apps/e2e/tests/js/payments.test.ts index 406a3f1e19..8dc45aa573 100644 --- a/apps/e2e/tests/js/payments.test.ts +++ b/apps/e2e/tests/js/payments.test.ts @@ -414,22 +414,3 @@ it("supports granting and listing customer products", { timeout: 60_000 }, async expect(customProducts[0].quantity).toBe(1); expect(customProducts[0].displayName).toBe(inlineCustomProduct.display_name); }); - -// TODO: Add E2E test for subscription renewal after cancellation -// -// Scenario: User cancels subscription but it's still active until end of billing period. -// User then wants to renew/reactivate before the period ends. -// -// Expected behavior: -// - User should be able to "re-purchase" the same product -// - The system should reactivate the existing subscription (set cancel_at_period_end: false) -// rather than creating a new subscription and charging again -// - To the user, it should appear to be the same continuous subscription -// -// This requires: -// 1. Fix alreadyOwnsProduct to return false for canceled subscriptions (unit test exists) -// 2. Purchase/switch flow to detect canceled-but-not-expired subscription and reactivate it -// -// See failing unit tests in payments.test.tsx: -// - "BUG: alreadyOwnsProduct should be false for canceled subscriptions" -// - "BUG: alreadyOwnsProduct should be false for canceled-but-not-expired subscriptions (to allow renewal)" From 2a842d94e8d0ce2235af0a7792a21828db2b4a09 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Sun, 19 Apr 2026 21:57:49 -0700 Subject: [PATCH 23/38] fix: lint, typecheck failures --- apps/backend/src/app/api/latest/users/crud.tsx | 3 ++- .../(protected)/projects/[projectId]/analytics/shared.tsx | 6 +++--- .../backend/endpoints/api/v1/analytics-events-batch.test.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/backend/src/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 150e059438..689e60ea74 100644 --- a/apps/backend/src/app/api/latest/users/crud.tsx +++ b/apps/backend/src/app/api/latest/users/crud.tsx @@ -21,6 +21,7 @@ import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fiel import { userIdOrMeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; import { decodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { hashPassword, isPasswordHashValid } from "@stackframe/stack-shared/dist/utils/hashes"; import { has } from "@stackframe/stack-shared/dist/utils/objects"; @@ -264,7 +265,7 @@ async function checkAuthUsersSoftLimit(tenancy: Tenancy) { // capacity=0 and flood logs. Bulldozer's seed-time invariant is that // nothing reads the ledger until runBulldozerPaymentsInit runs post-seed; // we honor that here rather than forcing seed to double-init. - if (process.env.STACK_SEED_MODE === 'true') { + if (getEnvVariable('STACK_SEED_MODE', '') === 'true') { return; } const billingTeamId = getBillingTeamId(tenancy.project); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 2fca6c7299..73e4ec8f57 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -321,7 +321,7 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () = ); } -function resolvePlanId(products: Array<{ id: string | null, type: string }>): PlanId { +function resolvePlanId(products: Array<{ id: string | null, type?: string }>): PlanId { if (products.some(p => p.id === "growth" && p.type === "subscription")) return "growth"; if (products.some(p => p.id === "team" && p.type === "subscription")) return "team"; return "free"; @@ -371,7 +371,7 @@ export function SessionReplayLimitBanner() { return ; } -function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }> } }) { +function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }> } }) { const replaysItem = team.useItem("session_replays"); const products = team.useProducts(); const planId = resolvePlanId(products); @@ -401,7 +401,7 @@ function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: str ); } -function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise } }) { +function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type?: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise } }) { const eventsItem = team.useItem("analytics_events"); const products = team.useProducts(); const planId = resolvePlanId(products); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index e0e50d274e..424f563696 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -496,7 +496,7 @@ async function setupProjectWithPlan(planId: PlanId) { } }); } - + await wait(3000); return { ownerTeamId }; } From ad252a0cb459922ac8477f87c97ea3343aa3306c Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 20 Apr 2026 09:55:34 -0700 Subject: [PATCH 24/38] feat: test emails also use up quota If we don't include the test email, people could spam it and clog up email queue. Also they could spam other people with this. --- .../latest/internal/send-test-email/route.tsx | 19 +++++ .../api/v1/internal/send-test-email.test.ts | 81 +++++++++++++++++++ .../apps/implementations/admin-app-impl.ts | 33 +++++--- 3 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts diff --git a/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx b/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx index ccbbe2b5be..e6d88fdff5 100644 --- a/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx +++ b/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx @@ -1,5 +1,9 @@ import { isSecureEmailPort, lowLevelSendEmailDirectWithoutRetries } from "@/lib/emails-low-level"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getStackServerApp } from "@/stack"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import * as schemaFields from "@stackframe/stack-shared/dist/schema-fields"; import { adaptSchema, adminAuthTypeSchema, emailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -37,6 +41,21 @@ export const POST = createSmartRouteHandler({ }).defined(), }), handler: async ({ body, auth }) => { + // Debit the emails_per_month quota before hitting SMTP so this endpoint + // can't be used as an unbounded SMTP-send-through / socket-exhaustion + // vector (admin provides arbitrary recipient_email and email_config, so + // without a quota guard even a compromised/hostile project admin could + // spam an arbitrary recipient or pin our event loop with 10s SMTP waits). + const billingTeamId = getBillingTeamId(auth.tenancy.project); + if (billingTeamId != null) { + const app = getStackServerApp(); + const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: billingTeamId }); + const isDebited = await emailItem.tryDecreaseQuantity(1); + if (!isDebited) { + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.emailsPerMonth, billingTeamId, 1); + } + } + const resultOuter = await timeout(lowLevelSendEmailDirectWithoutRetries({ tenancyId: auth.tenancy.id, emailConfig: { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts new file mode 100644 index 0000000000..eeadd509c8 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts @@ -0,0 +1,81 @@ +import { describe } from "vitest"; +import { it } from "../../../../../helpers"; +import { Project, niceBackendFetch, withInternalProject } from "../../../../backend-helpers"; + +const dummyEmailConfig = { + host: "nonexistent.example.invalid", + port: 587, + username: "u", + password: "p", + sender_email: "s@example.com", + sender_name: "S", +}; + +async function getEmailItemQuantity(ownerTeamId: string): Promise { + return await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/emails_per_month`, { + accessType: "server", + }); + if (response.status !== 200) { + throw new Error(`Failed to get emails_per_month item: ${JSON.stringify(response.body)}`); + } + return (response.body as { quantity: number }).quantity; + }); +} + +async function setEmailItemQuantity(ownerTeamId: string, quantity: number) { + const currentQuantity = await getEmailItemQuantity(ownerTeamId); + const delta = quantity - currentQuantity; + await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/emails_per_month/update-quantity?allow_negative=true`, { + method: "POST", + accessType: "server", + body: { delta }, + }); + if (response.status !== 200) { + throw new Error(`Failed to set emails_per_month quantity: ${JSON.stringify(response.body)}`); + } + }); +} + +describe("POST /api/v1/internal/send-test-email — emails_per_month quota", () => { + it("rejects with ITEM_QUANTITY_INSUFFICIENT_AMOUNT when quota is exhausted", async ({ expect }) => { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + await setEmailItemQuantity(ownerTeamId, 0); + + const response = await niceBackendFetch("/api/v1/internal/send-test-email", { + method: "POST", + accessType: "admin", + body: { + recipient_email: "test@example.com", + email_config: dummyEmailConfig, + }, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); + }); + + it("debits emails_per_month by 1 even when SMTP fails", async ({ expect }) => { + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + const before = await getEmailItemQuantity(ownerTeamId); + + // SMTP call fails against nonexistent.example.invalid, but the quota debit + // happens before SMTP so the decrement should be observable regardless. + await niceBackendFetch("/api/v1/internal/send-test-email", { + method: "POST", + accessType: "admin", + body: { + recipient_email: "test@example.com", + email_config: dummyEmailConfig, + }, + }); + + const after = await getEmailItemQuantity(ownerTeamId); + expect(after).toBe(before - 1); + }); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 3238b7194b..c6d6ec52da 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -1,4 +1,4 @@ -import { StackAdminInterface } from "@stackframe/stack-shared"; +import { KnownErrors, StackAdminInterface } from "@stackframe/stack-shared"; import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode"; import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface"; import type { MetricsResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; @@ -570,14 +570,29 @@ export class _StackAdminAppImplIncomplete> { - const response = await this._interface.sendTestEmail({ - recipient_email: options.recipientEmail, - email_config: { - ...(pick(options.emailConfig, ['host', 'port', 'username', 'password'])), - sender_email: options.emailConfig.senderEmail, - sender_name: options.emailConfig.senderName, - }, - }); + let response: { success: boolean, error_message?: string }; + try { + response = await this._interface.sendTestEmail({ + recipient_email: options.recipientEmail, + email_config: { + ...(pick(options.emailConfig, ['host', 'port', 'username', 'password'])), + sender_email: options.emailConfig.senderEmail, + sender_name: options.emailConfig.senderName, + }, + }); + } catch (error) { + // Translate the quota-exhaustion KnownError into the existing + // Result.error shape so SDK/dashboard callers don't need to branch on + // exceptions. The backend throws `ItemQuantityInsufficientAmount` + // (consistent with every other limit-rejection endpoint), but this + // method's historical contract has always been a `Result`. + if (error instanceof KnownErrors.ItemQuantityInsufficientAmount) { + return Result.error({ + errorMessage: "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month before sending more test emails.", + }); + } + throw error; + } if (response.success) { return Result.ok(undefined); From 725a1835010204d54f26917e8fdabb5d9d4881ef Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 20 Apr 2026 18:52:26 -0700 Subject: [PATCH 25/38] feat(payments): regrant free plan when a paid sub ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a billing team's team/growth sub is canceled, auto-regrant free so they don't end up with zero entitlements. Wired into the Stripe webhook sync path (primary) and the DELETE product route (waiting on separate PR that fixes DB-only cancel semantics). Also includes a dependency fix: `getEndedAtForSync` in stripe.tsx now treats `canceled` as a terminal Stripe status. Before, Stripe cancels landed in Prisma with `status=canceled` but `endedAt=NULL`, so the Bulldozer timefold never emitted `subscription-end` (needs `endedAtMillis`), the plan stayed "owned" in the ledger, and the regrant check no-op'd. `canceled` is terminal — Stripe sets `ended_at` on the transition — so we trust and store it. Refactor: extract free-plan subscription creation into a shared `createFreePlanSubscriptionRow` used by both team-creation and the new regrant path. Free auto-grants use `API_GRANT` creation source; seed's Growth grant picks TEST_MODE or PURCHASE_PAGE based on the internal project's testMode. --- apps/backend/prisma/seed.ts | 7 +- .../integrations/stripe/webhooks/route.tsx | 1 + .../[customer_id]/[product_id]/route.ts | 19 ++- .../backend/src/app/api/latest/teams/crud.tsx | 62 ++++---- .../src/lib/payments/ensure-free-plan.ts | 136 ++++++++++++++++++ apps/backend/src/lib/stripe.tsx | 27 +++- .../endpoints/api/v1/stripe-webhooks.test.ts | 88 +++++++++++- 7 files changed, 291 insertions(+), 49 deletions(-) create mode 100644 apps/backend/src/lib/payments/ensure-free-plan.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index c4f3d4cc81..bfc16d617b 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -332,6 +332,11 @@ export async function seed() { // Clone to ensure the stored JSON snapshot is independent of the config object // (mirrors the pattern used in seed-dummy-data.ts). const storedProduct = JSON.parse(JSON.stringify(growthProduct)) as Prisma.InputJsonValue; + // Mirror what a real Stripe checkout would produce, based on whether + // the internal project is running in test mode. + const creationSource = updatedInternalTenancy.config.payments.testMode + ? PurchaseCreationSource.TEST_MODE + : PurchaseCreationSource.PURCHASE_PAGE; await internalPrisma.subscription.create({ data: { tenancyId: internalTenancy.id, @@ -345,7 +350,7 @@ export async function seed() { currentPeriodStart: now, currentPeriodEnd: new Date('2099-12-31T23:59:59Z'), cancelAtPeriodEnd: false, - creationSource: PurchaseCreationSource.TEST_MODE, + creationSource, }, }); console.log('Granted Growth plan to internal team'); diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 133f626391..c30da09550 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -48,6 +48,7 @@ const ignoredEvents = [ "charge.failed", "balance.available", "customer.updated", + "customer.created", ] as const satisfies Stripe.Event.Type[]; const isSubscriptionChangedEvent = (event: Stripe.Event): event is Stripe.Event & { type: (typeof subscriptionChangedEvents)[number] } => { diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts index 543ae1381f..dc725483dc 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts @@ -1,15 +1,15 @@ +import { SubscriptionStatus } from "@/generated/prisma/client"; import { customerOwnsProduct, ensureCustomerExists, ensureProductIdOrInlineProduct, isActiveSubscription } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan"; +import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; +import { getStripeForAccount } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { KnownErrors } from "@stackframe/stack-shared"; -import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { SubscriptionStatus } from "@/generated/prisma/client"; -import { getStripeForAccount } from "@/lib/stripe"; -import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; export const DELETE = createSmartRouteHandler({ metadata: { @@ -150,6 +150,13 @@ export const DELETE = createSmartRouteHandler({ await bulldozerWriteSubscription(prisma, updatedSub); } + // Regrant the free plan if a Stack Auth billing team just lost their + // only plans-line sub. Scoped to the internal tenancy — customer + // projects' own sub cancellations are for their own products. + if (auth.tenancy.project.id === "internal" && params.customer_type === "team") { + await ensureFreePlanForBillingTeam(params.customer_id); + } + return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/app/api/latest/teams/crud.tsx b/apps/backend/src/app/api/latest/teams/crud.tsx index 8aeda02f30..e0b0d4d9a7 100644 --- a/apps/backend/src/app/api/latest/teams/crud.tsx +++ b/apps/backend/src/app/api/latest/teams/crud.tsx @@ -1,19 +1,19 @@ import { recordExternalDbSyncDeletion, recordExternalDbSyncTeamInvitationDeletionsForTeam, recordExternalDbSyncTeamMemberDeletionsForTeam, recordExternalDbSyncTeamPermissionDeletionsForTeam, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { createFreePlanSubscriptionRow } from "@/lib/payments/ensure-free-plan"; import { ensureTeamExists, ensureTeamMembershipExists, ensureUserExists, ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { sendTeamCreatedWebhook, sendTeamDeletedWebhook, sendTeamUpdatedWebhook } from "@/lib/webhooks"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { uploadAndGetUrl } from "@/s3"; import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; -import { Prisma } from "@/generated/prisma/client"; +import { Prisma, PurchaseCreationSource } from "@/generated/prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { validateBase64Image } from "@stackframe/stack-shared/dist/utils/base64"; -import { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { addUserToTeam } from "../team-memberships/crud"; @@ -99,43 +99,33 @@ export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsC }); } - let freePlanSubscription = null; - if (auth.project.id === "internal") { - const freePlanProduct = auth.tenancy.config.payments.products.free; - if (freePlanProduct.customerType === "team" && freePlanProduct.productLineId != null) { - const prices = freePlanProduct.prices === "include-by-default" ? {} : freePlanProduct.prices; - const firstPriceEntry = typedEntries(prices)[0] as [string, Record] | undefined; - const now = new Date(); - const priceInterval = firstPriceEntry != null && "interval" in firstPriceEntry[1] - ? firstPriceEntry[1].interval as [number, "day" | "week" | "month" | "year"] | undefined - : undefined; - freePlanSubscription = await tx.subscription.create({ - data: { - tenancyId: auth.tenancy.id, - customerId: db.teamId, - customerType: "TEAM", - status: "active", - productId: "free", - priceId: firstPriceEntry != null ? firstPriceEntry[0] : null, - product: freePlanProduct, - quantity: 1, - currentPeriodStart: now, - currentPeriodEnd: priceInterval != null ? addInterval(now, priceInterval) : new Date("2099-12-31T23:59:59Z"), - cancelAtPeriodEnd: false, - creationSource: "TEST_MODE", - }, - }); - } - } + // Grant the free plan to every new internal-project team in the same + // transaction as the team create, so either both commit or neither + // does. Bulldozer write runs after the tx (it issues its own + // BEGIN/COMMIT and can't nest); if that fails, the sub still exists + // in Prisma and will be reconciled on the next sync/webhook. + // + // Silently skip if the `free` product isn't configured (or isn't a + // team-typed product in a product line) — we don't want to block + // team creation for callers in non-internal projects or in test + // setups where the payments config may not be fully hydrated. + const freePlanProduct = getOrUndefined(auth.tenancy.config.payments.products, "free"); + const shouldGrantFreePlan = auth.project.id === "internal" + && freePlanProduct != null + && freePlanProduct.customerType === "team" + && freePlanProduct.productLineId != null; + const freePlanSubscription = shouldGrantFreePlan + ? await createFreePlanSubscriptionRow({ + prisma: tx, + internalTenancy: auth.tenancy, + billingTeamId: db.teamId, + creationSource: PurchaseCreationSource.API_GRANT, + }) + : null; return { db, freePlanSubscription }; }); - // Bulldozer write must happen outside retryTransaction because it issues its - // own BEGIN/COMMIT (for the advisory lock + sort helpers). If this fails after - // the Prisma transaction committed, the subscription exists in Prisma but not - // in Bulldozer — same trade-off as all other dual-write call sites. The next - // sync or webhook will reconcile. if (freePlanSubscription != null) { await bulldozerWriteSubscription(prisma, freePlanSubscription); } diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts new file mode 100644 index 0000000000..27a5c9146d --- /dev/null +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -0,0 +1,136 @@ +import { CustomerType, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client"; +import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { getOwnedProductsForCustomer } from "@/lib/payments/customer-data"; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies"; +import { getPrismaClientForTenancy, type PrismaClientTransaction } from "@/prisma-client"; +import { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { getOrUndefined, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; + +/** + * Free/team/growth plans live on the internal tenancy; the "customer" is a + * team in the internal project (a billing team). This file owns the two + * writes that touch the free-plan sub for such a team: + * + * - `createFreePlanSubscriptionRow` — Prisma-only insert. Callers run the + * subsequent `bulldozerWriteSubscription` themselves, so they can keep + * the Prisma insert inside whatever outer transaction they own while + * the Bulldozer write (which issues its own BEGIN/COMMIT) happens + * after the tx commits. + * - `ensureFreePlanForBillingTeam` — the regrant path. Idempotent; no-op + * if the team already owns a plan in the same product line. + */ + +async function getInternalBillingTenancy(): Promise { + const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (tenancy == null) { + throw new StackAssertionError("Internal billing tenancy not found"); + } + return tenancy; +} + +function isAddOnProduct(product: { isAddOnTo?: false | Record | null }): boolean { + return product.isAddOnTo != null && product.isAddOnTo !== false && Object.keys(product.isAddOnTo).length > 0; +} + +/** + * Writes the `free` Subscription row. Caller is responsible for a subsequent + * `bulldozerWriteSubscription(prisma, sub)` after any outer transaction + * commits, and for verifying there's no conflicting plan in the same line. + * + * `creationSource` is a parameter because the right value depends on context + * (auto-regrant vs team-creation vs a hypothetical test-mode seed). Throws + * on a misconfigured `free` product so broken deploys fail loudly. + */ +export async function createFreePlanSubscriptionRow(options: { + prisma: PrismaClientTransaction, + internalTenancy: Tenancy, + billingTeamId: string, + creationSource: PurchaseCreationSource, +}): Promise { + const { prisma, internalTenancy, billingTeamId, creationSource } = options; + const freePlanProduct = getOrUndefined(internalTenancy.config.payments.products, "free"); + if (freePlanProduct == null || freePlanProduct.customerType !== "team" || freePlanProduct.productLineId == null) { + throw new StackAssertionError( + "Internal tenancy `free` product is not configured as a team-typed, product-line-tagged plan; cannot grant", + { freePlanProduct }, + ); + } + + // First price, same as validatePurchaseSession's default when no priceId + // is supplied. The `length` check is needed because TS types `[0]` as + // non-undefined (no noUncheckedIndexedAccess in our tsconfig). + const prices = freePlanProduct.prices === "include-by-default" ? {} : freePlanProduct.prices; + const priceEntries = typedEntries(prices); + if (priceEntries.length === 0) { + throw new StackAssertionError("Free plan has no prices configured"); + } + const [firstPriceId, firstPrice] = priceEntries[0]; + const priceInterval = firstPrice.interval; + + const now = new Date(); + return await prisma.subscription.create({ + data: { + tenancyId: internalTenancy.id, + customerId: billingTeamId, + customerType: CustomerType.TEAM, + status: SubscriptionStatus.active, + productId: "free", + priceId: firstPriceId, + product: freePlanProduct, + quantity: 1, + currentPeriodStart: now, + // No interval only happens if the free plan is misconfigured as one-off; + // fall back to a 2099 sentinel so the sub never naturally ends. + currentPeriodEnd: priceInterval != null ? addInterval(now, priceInterval) : new Date("2099-12-31T23:59:59Z"), + cancelAtPeriodEnd: false, + creationSource, + }, + }); +} + +/** + * Regrants the `free` plan if the billing team has no active plan in the + * free plan's product line. Callers can fire this speculatively — it silently + * no-ops on misconfiguration or when a plan is already owned. + */ +export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise { + const internalTenancy = await getInternalBillingTenancy(); + const freePlanProduct = getOrUndefined(internalTenancy.config.payments.products, "free"); + if (freePlanProduct == null || freePlanProduct.customerType !== "team" || freePlanProduct.productLineId == null) { + return; + } + const freeProductLineId = freePlanProduct.productLineId; + + const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + const ownedProducts = await getOwnedProductsForCustomer({ + prisma: internalPrisma, + tenancyId: internalTenancy.id, + customerType: "team", + customerId: billingTeamId, + }); + // Only BASE plans count as "a plan is already active" — add-ons in the same + // product line (e.g. extra-seats top-up) don't provide baseline entitlements + // on their own, so losing the base plan while still holding an add-on + // should still trigger a free-plan regrant. + const alreadyHasBasePlanInLine = Object.values(ownedProducts).some( + (p) => + p.productLineId === freeProductLineId + && p.quantity > 0 + && !isAddOnProduct(p.product), + ); + if (alreadyHasBasePlanInLine) { + return; + } + + const subscription = await createFreePlanSubscriptionRow({ + prisma: internalPrisma, + internalTenancy, + billingTeamId, + // Free is always paymentProvider=stripe (via the non-TEST_MODE CASE), + // regardless of testMode. API_GRANT is the closest semantic fit. + creationSource: PurchaseCreationSource.API_GRANT, + }); + await bulldozerWriteSubscription(internalPrisma, subscription); +} diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 4d62c4098c..2217783326 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,5 +1,6 @@ import { CustomerType } from "@/generated/prisma/client"; import { bulldozerWriteSubscription, bulldozerWriteSubscriptionInvoice } from "@/lib/payments/bulldozer-dual-write"; +import { ensureFreePlanForBillingTeam } from "@/lib/payments/ensure-free-plan"; import { getProductVersion } from "@/lib/product-versions"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; @@ -235,16 +236,24 @@ const getTenancyFromStripeAccountIdOrThrow = async (stripe: Stripe, stripeAccoun return tenancy; }; -const TERMINAL_STRIPE_STATUSES = ["incomplete_expired", "unpaid"] as const; +const TERMINAL_STRIPE_STATUSES = ["canceled", "incomplete_expired", "unpaid"] as const; function getEndedAtForSync(subscription: Stripe.Subscription, sanitizedEnd: Date): { endedAt: Date } | {} { - if (TERMINAL_STRIPE_STATUSES.includes(subscription.status as typeof TERMINAL_STRIPE_STATUSES[number])) { - return { endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : new Date() }; + if (!TERMINAL_STRIPE_STATUSES.includes(subscription.status as typeof TERMINAL_STRIPE_STATUSES[number])) { + return {}; } - if (subscription.status === "canceled" && sanitizedEnd <= new Date()) { + // Prefer Stripe's `ended_at` — real Stripe always sets it on transitions into + // a terminal status. If the webhook payload omits it (mocks, older API + // versions), fall back to the already-past period boundary so the timefold + // can fire sub-end inline; absolute last resort is `now`. + if (subscription.ended_at) { + return { endedAt: new Date(subscription.ended_at * 1000) }; + } + //fallback for if stripe didnt set ended_at but sub definitely ended i.e current_period_end <= now + if (sanitizedEnd <= new Date()) { return { endedAt: sanitizedEnd }; } - return {}; + return { endedAt: new Date() }; } function getCanceledAtForSync(subscription: Stripe.Subscription): { canceledAt: Date } | {} { @@ -331,6 +340,14 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s }); await bulldozerWriteSubscription(prisma, upsertedSub); } + + // If this was a cancellation on our own billing (internal tenancy hosts the + // free/team/growth plans), regrant free so the team doesn't end up at zero + // entitlements. No-op if the team still owns another plan in the line, or + // for customer projects' own Stripe webhooks. + if (tenancy.project.id === "internal" && customerType === CustomerType.TEAM) { + await ensureFreePlanForBillingTeam(customerId); + } } export async function upsertStripeInvoice(stripe: Stripe, stripeAccountId: string, invoice: Stripe.Invoice) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts index 013566bfa1..010902e77c 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/stripe-webhooks.test.ts @@ -1,7 +1,7 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { it } from "../../../../helpers"; -import { Auth, bumpEmailAddress, niceBackendFetch, Payments, Project } from "../../../backend-helpers"; +import { Auth, bumpEmailAddress, niceBackendFetch, Payments, Project, Team } from "../../../backend-helpers"; import { getOutboxEmails } from "./emails/email-helpers"; async function waitForOutboxEmail(subject: string) { @@ -747,3 +747,89 @@ it("updates a user's subscriptions via webhook (add then remove)", async ({ expe expect(afterRemove.status).toBe(200); expect(afterRemove.body.quantity).toBe(0); }); + + +it("does NOT auto-grant `free` when a non-internal tenancy's sub is canceled via webhook", async ({ expect }) => { + // Guard test for the `tenancy.project.id === "internal"` gate: a customer + // project's own Stripe cancellations must never cause a `free` sub to + // appear in their tenancy. + await Project.createAndSwitch(); + await Payments.setup(); + + const customProductId = "customer-product"; + const customItemId = "customer-seat"; + const customProduct = { + displayName: "Customer Product", + customerType: "team", + productLineId: "customer-plans", + serverOnly: false, + stackable: false, + prices: { monthly: { USD: "1000", interval: [1, "month"] } }, + includedItems: { [customItemId]: { quantity: 1, expires: "when-purchase-expires" } }, + }; + await Project.updateConfig({ + payments: { + productLines: { "customer-plans": { displayName: "Customer Plans", customerType: "team" } }, + items: { [customItemId]: { displayName: "Customer Seat", customerType: "team" } }, + products: { [customProductId]: customProduct }, + }, + }); + + await Auth.fastSignUp(); + const { teamId } = await Team.createWithCurrentAsCreator({ accessType: "server" }); + + const accountInfo = await niceBackendFetch( + "/api/latest/internal/payments/stripe/account-info", + { accessType: "admin" }, + ); + const accountId = accountInfo.body.account_id; + const createUrlResponse = await niceBackendFetch( + "/api/latest/payments/purchases/create-purchase-url", + { + method: "POST", + accessType: "client", + body: { customer_type: "team", customer_id: teamId, product_id: customProductId }, + }, + ); + const projectTenancyId = (createUrlResponse.body as { url: string }).url + .split("/purchase/")[1].split("_")[0]; + + const nowSec = Math.floor(Date.now() / 1000); + const webhookResponse = await Payments.sendStripeWebhook({ + id: "evt_customer_cancel", + type: "customer.subscription.deleted", + account: accountId, + data: { + object: { + customer: "cus_customer_cancel", + stack_stripe_mock_data: { + "accounts.retrieve": { metadata: { tenancyId: projectTenancyId } }, + "customers.retrieve": { metadata: { customerId: teamId, customerType: "TEAM" } }, + "subscriptions.list": { data: [{ + id: "sub_customer_cancel", + status: "canceled", + items: { data: [{ + quantity: 1, + current_period_start: nowSec - 2 * 60, + current_period_end: nowSec - 60, + }] }, + metadata: { productId: customProductId, product: JSON.stringify(customProduct), priceId: "monthly" }, + cancel_at_period_end: false, + }] }, + }, + }, + }, + }); + expect(webhookResponse.status).toBe(200); + await wait(2000); + + // Guard: no `free` product should ever appear in a customer-project tenancy's + // ownedProducts, since the gate short-circuits before we touch anything here. + const subsResponse = await niceBackendFetch( + `/api/v1/payments/products/team/${teamId}`, + { accessType: "server" }, + ); + expect(subsResponse.status).toBe(200); + const items = (subsResponse.body as { items: Array<{ id: string | null }> }).items; + expect(items.map((i) => i.id)).not.toContain("free"); +}); From 969ffc48e169c3d5f6b81e500a1237ba4e701fc6 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 20 Apr 2026 20:02:25 -0700 Subject: [PATCH 26/38] fix: stackable subscriptions in same product line are now cumulative --- apps/backend/src/lib/payments.tsx | 15 +++- .../api/v1/payments/products.test.ts | 78 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index f82a0f30a3..a14e382ecb 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -353,12 +353,22 @@ export async function validatePurchaseSession(options: { // Step 6: Block purchase if customer already owns a product in the same product line. // If they do, find active subscriptions to cancel so the caller can replace them. - // Exception: add-on products are allowed even if the base product is in the same line. + // Two exceptions: + // - Add-on products: allowed even if their base product is in the same line. + // - Stackable same-product: a second purchase of a stackable product is + // additive, not a replacement — don't treat the existing holding as a + // conflict. let conflictingSubscriptions: SubscriptionRow[] = []; const productLineId = product.productLineId; const addOnBaseProductIds = product.isAddOnTo ? typedKeys(product.isAddOnTo) : []; + const isStackableSelfMatch = (pid: string) => + productId != null && pid === productId && product.stackable === true; const hasConflictingProductLine = productLineId && Object.entries(ownedProducts).some( - ([pid, p]) => p.productLineId === productLineId && p.quantity > 0 && !addOnBaseProductIds.includes(pid) + ([pid, p]) => + p.productLineId === productLineId + && p.quantity > 0 + && !addOnBaseProductIds.includes(pid) + && !isStackableSelfMatch(pid), ); if (hasConflictingProductLine) { // Find active subscriptions in this product line that can be canceled/replaced @@ -367,6 +377,7 @@ export async function validatePurchaseSession(options: { isActiveSubscription(s) && (s.product as Product).productLineId === productLineId && !addOnBaseProductIds.includes(s.productId ?? "") + && !isStackableSelfMatch(s.productId ?? ""), ); // If no cancelable subscriptions found, the customer owns via OTP — block the purchase. diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts index 8dba7d5eab..8bb22e6f7d 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/products.test.ts @@ -412,6 +412,84 @@ it("should cancel all stackable subscription quantities", async ({ expect }) => `); }); +it("stackable add-on in the same product line accumulates across grants instead of being replaced", async ({ expect }) => { + // Regression test: before the fix, granting a stackable add-on twice while + // the customer already owned a base plan in the same product line caused + // the second grant to treat the first as a "conflict in the plans line", + // canceling the first sub and replacing its quantity instead of adding + // another sub alongside. The bug only surfaced when the stackable product + // had a `productLineId` AND another product in that line was owned — + // stackable products without a productLineId, or without siblings in the + // line, already accumulated correctly (see the sibling test above). + await Project.createAndSwitch(); + await Payments.setup(); + await configureProduct({ + productLines: { + plans: { displayName: "Plans", customerType: "team" }, + }, + products: { + "base-plan": { + displayName: "Base Plan", + customerType: "team", + productLineId: "plans", + serverOnly: false, + stackable: false, + prices: { monthly: { USD: "1000", interval: [1, "month"] } }, + includedItems: {}, + }, + "extra-seats": { + displayName: "Extra Seats", + customerType: "team", + productLineId: "plans", + serverOnly: false, + stackable: true, + isAddOnTo: { "base-plan": true }, + prices: { monthly: { USD: "100", interval: [1, "month"] } }, + includedItems: {}, + }, + }, + }); + + await Auth.fastSignUp(); + const { teamId } = await Team.createWithCurrentAsCreator({ accessType: "server" }); + + // Base plan first so the add-on's isAddOnTo prerequisite is satisfied. + const baseResponse = await niceBackendFetch(`/api/v1/payments/products/team/${teamId}`, { + method: "POST", + accessType: "server", + body: { product_id: "base-plan" }, + }); + expect(baseResponse.status).toBe(200); + + const firstAddOnResponse = await niceBackendFetch(`/api/v1/payments/products/team/${teamId}`, { + method: "POST", + accessType: "server", + body: { product_id: "extra-seats", quantity: 1 }, + }); + expect(firstAddOnResponse.status).toBe(200); + + const secondAddOnResponse = await niceBackendFetch(`/api/v1/payments/products/team/${teamId}`, { + method: "POST", + accessType: "server", + body: { product_id: "extra-seats", quantity: 2 }, + }); + expect(secondAddOnResponse.status).toBe(200); + + const listResponse = await niceBackendFetch(`/api/v1/payments/products/team/${teamId}`, { + accessType: "server", + }); + expect(listResponse.status).toBe(200); + const items = (listResponse.body as { items: Array<{ id: string, quantity: number }> }).items; + const extraSeats = items.find((i) => i.id === "extra-seats"); + const basePlan = items.find((i) => i.id === "base-plan"); + // Base plan untouched by the add-on grants. + expect(basePlan?.quantity).toBe(1); + // Add-on quantities accumulate: 1 (first grant) + 2 (second grant) = 3. + // Before the fix this came out as 2 (the second grant's quantity, because + // the first sub was canceled as a "conflict" and replaced). + expect(extraSeats?.quantity).toBe(3); +}); + it("should reject canceling a one-time purchase product", async ({ expect }) => { await Project.createAndSwitch(); await Payments.setup(); From 9e8186b14a3a5d10300b56f8beb9fb535ec735e4 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 20 Apr 2026 20:26:58 -0700 Subject: [PATCH 27/38] perf(events): require callers to pass billingTeamId to logEvent Token refresh fires logEvent twice per authenticated API call, and each call was doing its own `project.findUnique` to resolve the billing team for analytics quota enforcement. Every caller already has the tenancy in hand, so resolving billingTeamId once at the call site via `getBillingTeamId(tenancy.project)` removes two DB round-trips per refresh. Making the option required (rather than optional with a fallback lookup) keeps the "one DB query per refresh" contract enforceable via the type system. --- apps/backend/src/lib/events.tsx | 25 +++++++++++-------------- apps/backend/src/lib/sign-up-rules.ts | 3 +++ apps/backend/src/lib/tokens.tsx | 10 ++++++++++ 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 620c6ca906..2f59b56d0b 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -1,12 +1,11 @@ import withPostHog from "@/analytics"; import { globalPrismaClient } from "@/prisma-client"; -import { getBillingTeamId } from "@/lib/plan-entitlements"; import { getStackServerApp } from "@/stack"; import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans"; import { urlSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; -import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { HTTP_METHODS } from "@stackframe/stack-shared/dist/utils/http"; import { filterUndefined, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { UnionToIntersection } from "@stackframe/stack-shared/dist/utils/types"; @@ -198,11 +197,19 @@ export async function logEvent( eventTypes: T, data: DataOfMany, options: { + /** + * Billing team id for analytics-quota debiting, or null if the project + * has no owner team. Required: every caller has the tenancy (or project) + * in hand, so resolving this once at the call site via + * `getBillingTeamId(tenancy.project)` is strictly cheaper than a + * per-event DB lookup inside logEvent. + */ + billingTeamId: string | null, time?: Date | { start: Date, end: Date }, refreshTokenId?: string, sessionReplayId?: string, sessionReplaySegmentId?: string, - } = {} + } ) { let timeOrTimeRange = options.time ?? new Date(); const timeRange = "start" in timeOrTimeRange && "end" in timeOrTimeRange ? timeOrTimeRange : { start: timeOrTimeRange, end: timeOrTimeRange }; @@ -267,17 +274,7 @@ export async function logEvent( // rest is no more dynamic APIs so we can run it asynchronously runAsynchronouslyAndWaitUntil((async () => { - // Resolve billing team for analytics event quota enforcement - let billingTeamId: string | null = null; - if (projectId) { - const project = await globalPrismaClient.project.findUnique({ - where: { id: projectId }, - select: { id: true, ownerTeamId: true }, - }); - if (project != null) { - billingTeamId = getBillingTeamId(project); - } - } + const billingTeamId = options.billingTeamId; if (billingTeamId != null) { const app = getStackServerApp(); diff --git a/apps/backend/src/lib/sign-up-rules.ts b/apps/backend/src/lib/sign-up-rules.ts index 16da6acb77..3d0af2b165 100644 --- a/apps/backend/src/lib/sign-up-rules.ts +++ b/apps/backend/src/lib/sign-up-rules.ts @@ -4,6 +4,7 @@ import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { CelEvaluationError, evaluateCelExpression, SignUpRuleContext } from "./cel-evaluator"; import { logEvent, SystemEventTypes } from "./events"; +import { getBillingTeamId } from "./plan-entitlements"; import { Tenancy } from "./tenancies"; /** @@ -25,6 +26,8 @@ async function logRuleTrigger( email: context.email, authMethod: context.authMethod, oauthProvider: context.oauthProvider, + }, { + billingTeamId: getBillingTeamId(tenancy.project), }); } catch (e) { // Don't fail the signup if logging fails diff --git a/apps/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index 6dbb2c464c..c18b2010fa 100644 --- a/apps/backend/src/lib/tokens.tsx +++ b/apps/backend/src/lib/tokens.tsx @@ -15,6 +15,7 @@ import { turnstileResultValues } from '@stackframe/stack-shared/dist/utils/turns import * as jose from 'jose'; import { JOSEError, JWTExpired } from 'jose/errors'; import { getEndUserIpInfoForEvent, logEvent, SystemEventTypes } from './events'; +import { getBillingTeamId } from './plan-entitlements'; import { Tenancy } from './tenancies'; export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/); @@ -274,6 +275,11 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres }), ]); + // Token refresh runs on every access-token roll, so skip the per-event + // billing-team DB lookup by threading it through from the tenancy we + // already have. + const billingTeamId = getBillingTeamId(options.tenancy.project); + // Log session activity event (used for metrics, geo info, etc.) await logEvent( [SystemEventTypes.SessionActivity], @@ -284,6 +290,9 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres sessionId: options.refreshTokenObj.id, isAnonymous: user.is_anonymous, teamId: undefined, + }, + { + billingTeamId, } ); @@ -301,6 +310,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres }, { refreshTokenId: options.refreshTokenObj.id, + billingTeamId, } ); From 519458a86449275e9f6e60796b07b8fb7e7352bb Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 20 Apr 2026 20:49:00 -0700 Subject: [PATCH 28/38] fix(analytics): reject queries when analytics_timeout_seconds is 0 ClickHouse treats `max_execution_time: 0` as "unlimited", which is the opposite of what the plan-limit enforcement intended. A team in the transient window between a paid plan ending and free being regranted (or any billing-misconfigured state) would have otherwise received unbounded query execution. Mirror the pattern used by every other plan-gated resource: throw `ItemQuantityInsufficientAmount` when quota is zero, before clamping. Adds an e2e guard test. --- .../latest/internal/analytics/query/route.ts | 7 ++++ .../endpoints/api/v1/analytics-query.test.ts | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts index f0b9aec974..4b39958629 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/query/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts @@ -45,6 +45,13 @@ export const POST = createSmartRouteHandler({ if (billingTeamId != null) { const app = getStackServerApp(); const timeoutItem = await app.getItem({ itemId: ITEM_IDS.analyticsTimeoutSeconds, teamId: billingTeamId }); + // clickHouse treats max_execution_time=0 as + // "unlimited", so a customer with zero timeout entitlement (no active + // plan in the plans line, or a transient gap between paid-plan end + // and free regrant) would otherwise get unbounded query execution. + if (timeoutItem.quantity <= 0) { + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsTimeoutSeconds, billingTeamId, 1); + } const maxAllowedMs = timeoutItem.quantity * 1000; effectiveTimeoutMs = Math.min(body.timeout_ms, maxAllowedMs); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index c8d7bef3df..9f6fef77b7 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -31,6 +31,7 @@ async function runQueryWithPlan(planId: PlanId, body: { query: string, params?: throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); } }); + await wait(2000); } const response = await niceBackendFetch("/api/v1/internal/analytics/query", { @@ -1764,6 +1765,47 @@ it("does not clamp timeout below the plan limit", async ({ expect }) => { expect(maxExecutionTime).toBe(5); }); +it("rejects analytics queries when the timeout quota is zero (would otherwise send max_execution_time=0 to ClickHouse, i.e. unlimited)", async ({ expect }) => { + // Reachable in practice in the gap between a paid plan ending and the + // free plan being regranted, or any other billing-misconfigured state + // where the team has no plan in the plans line. `Math.min(timeout_ms, 0)` + // would produce `max_execution_time: 0`, which ClickHouse interprets as + // "no timeout" — the opposite of the intended enforcement. + const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + // Drain analytics_timeout_seconds to 0 (free plan starts at 10) via the + // internal-tenancy items endpoint. + await withInternalProject(async () => { + const drainResponse = await niceBackendFetch( + `/api/v1/payments/items/team/${ownerTeamId}/analytics_timeout_seconds/update-quantity?allow_negative=false`, + { + method: "POST", + accessType: "server", + body: { delta: -PLAN_LIMITS.free.analyticsTimeoutSeconds }, + }, + ); + expect(drainResponse.status).toBe(200); + }); + // Let the timefold process + await wait(2000); + + const response = await niceBackendFetch("/api/v1/internal/analytics/query", { + method: "POST", + accessType: "admin", + body: { + query: "SELECT getSetting('max_execution_time') as max_execution_time", + timeout_ms: 5000, + }, + }); + + expect(response.status).toBe(400); + expect(response.body).toMatchObject({ + code: "ITEM_QUANTITY_INSUFFICIENT_AMOUNT", + details: { item_id: "analytics_timeout_seconds" }, + }); +}); + it("does not allow numbers table function with large values", async ({ expect }) => { const response = await runQuery({ query: "SELECT * FROM numbers(1000000000)", From 97a93f0e8333fb45cb037d6b19f14d1b8bb72dd0 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 20 Apr 2026 20:52:46 -0700 Subject: [PATCH 29/38] fix: update seq id on quota exhaustion --- apps/backend/src/lib/email-queue-step.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 2174a0f02d..fa38bfa871 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -681,6 +681,7 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO remainingQuota: emailItem.quantity, billingTeamId: context.billingTeamId, }, + shouldUpdateSequenceId: true, }, }); return; From 573773f488dc805f4026b2871e37aa362cd5f99f Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 21 Apr 2026 12:49:30 -0700 Subject: [PATCH 30/38] fix: make free plan regrant idempotent --- .../src/lib/payments/ensure-free-plan.ts | 76 +++++++++++++++---- 1 file changed, 61 insertions(+), 15 deletions(-) diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 27a5c9146d..16a6cf7b8e 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -3,7 +3,7 @@ import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write" import { getOwnedProductsForCustomer } from "@/lib/payments/customer-data"; // eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy, type PrismaClientTransaction } from "@/prisma-client"; +import { getPrismaClientForTenancy, retryTransaction, type PrismaClientTransaction } from "@/prisma-client"; import { addInterval } from "@stackframe/stack-shared/dist/utils/dates"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrUndefined, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -93,7 +93,27 @@ export async function createFreePlanSubscriptionRow(options: { /** * Regrants the `free` plan if the billing team has no active plan in the * free plan's product line. Callers can fire this speculatively — it silently - * no-ops on misconfiguration or when a plan is already owned. + * no-ops on misconfiguration, when a plan is already owned, or when a + * concurrent caller already established the free sub. + * + * Two-phase concurrency story: + * + * 1. Fast path — optimistic bulldozer read. In the overwhelming common + * case the billing team already has some plan in the product line + * (free, team, or growth), so we return immediately without touching + * Prisma's Subscription table. + * + * 2. Slow path — if the fast path sees "no base plan," re-check the + * Subscription source-of-truth under SERIALIZABLE isolation and + * insert atomically. The bulldozer derived ledger lags pg_cron and + * can't detect in-flight writes from concurrent callers, so we + * cannot rely on it for idempotency; reading the Subscription table + * directly under SSI is the only way to serialize the check+insert. + * `retryTransaction` handles P2028 serialization failures by + * retrying; on the retry the other caller's row is visible and we + * skip the insert. + * + * We do this */ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise { const internalTenancy = await getInternalBillingTenancy(); @@ -104,16 +124,17 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi const freeProductLineId = freePlanProduct.productLineId; const internalPrisma = await getPrismaClientForTenancy(internalTenancy); + + // Fast path: bulldozer-based optimistic check. Only BASE plans count — + // add-ons (extra-seats etc.) don't provide baseline entitlements on their + // own, so losing the base plan while still holding an add-on still + // triggers a regrant. const ownedProducts = await getOwnedProductsForCustomer({ prisma: internalPrisma, tenancyId: internalTenancy.id, customerType: "team", customerId: billingTeamId, }); - // Only BASE plans count as "a plan is already active" — add-ons in the same - // product line (e.g. extra-seats top-up) don't provide baseline entitlements - // on their own, so losing the base plan while still holding an add-on - // should still trigger a free-plan regrant. const alreadyHasBasePlanInLine = Object.values(ownedProducts).some( (p) => p.productLineId === freeProductLineId @@ -124,13 +145,38 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi return; } - const subscription = await createFreePlanSubscriptionRow({ - prisma: internalPrisma, - internalTenancy, - billingTeamId, - // Free is always paymentProvider=stripe (via the non-TEST_MODE CASE), - // regardless of testMode. API_GRANT is the closest semantic fit. - creationSource: PurchaseCreationSource.API_GRANT, - }); - await bulldozerWriteSubscription(internalPrisma, subscription); + // Slow path: the team appears to have no base plan. Re-check under + // SERIALIZABLE isolation and insert atomically so a concurrent caller + // can't produce a duplicate free sub. + const createdSub = await retryTransaction(internalPrisma, async (tx) => { + const existing = await tx.subscription.findFirst({ + where: { + tenancyId: internalTenancy.id, + customerId: billingTeamId, + customerType: CustomerType.TEAM, + productId: "free", + status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, + }, + select: { id: true }, + }); + if (existing != null) { + return null; + } + return await createFreePlanSubscriptionRow({ + prisma: tx, + internalTenancy, + billingTeamId, + // Free is always paymentProvider=stripe (via the non-TEST_MODE CASE), + // regardless of testMode. API_GRANT is the closest semantic fit. + creationSource: PurchaseCreationSource.API_GRANT, + }); + }, { level: "serializable" }); + + if (createdSub != null) { + // Bulldozer write happens outside the tx — it issues its own BEGIN/ + // COMMIT and can't nest. If it fails after the Prisma insert committed, + // the sub exists in Prisma but not yet in Bulldozer; same trade-off as + // all other dual-write call sites, reconciled by the next sync. + await bulldozerWriteSubscription(internalPrisma, createdSub); + } } From bea92fe68b40d95f6a963a70f44b2754e322e7c3 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 21 Apr 2026 13:34:48 -0700 Subject: [PATCH 31/38] chore: type changes, refund test email on fail --- .../latest/internal/send-test-email/route.tsx | 17 ++++++++++++++--- .../src/lib/payments/ensure-free-plan.ts | 10 ++++++++-- .../projects/[projectId]/analytics/shared.tsx | 13 +++++++++---- .../api/v1/internal/send-test-email.test.ts | 15 ++++++++++----- packages/stack-shared/src/plans.ts | 7 +++++++ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx b/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx index e6d88fdff5..a40341d915 100644 --- a/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx +++ b/apps/backend/src/app/api/latest/internal/send-test-email/route.tsx @@ -46,10 +46,13 @@ export const POST = createSmartRouteHandler({ // vector (admin provides arbitrary recipient_email and email_config, so // without a quota guard even a compromised/hostile project admin could // spam an arbitrary recipient or pin our event loop with 10s SMTP waits). + // 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); - if (billingTeamId != null) { - const app = getStackServerApp(); - const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: billingTeamId }); + const emailItem = billingTeamId == null + ? null + : await getStackServerApp().getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: billingTeamId }); + if (emailItem != null && billingTeamId != null) { const isDebited = await emailItem.tryDecreaseQuantity(1); if (!isDebited) { throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.emailsPerMonth, billingTeamId, 1); @@ -97,6 +100,14 @@ export const POST = createSmartRouteHandler({ } } + // Refund the quota if we never actually delivered to SMTP — admins + // iterating on a misconfigured mail server shouldn't burn through + // their monthly allowance. Spam prevention is preserved because a + // successful delivery still consumes 1 from the debit above. + if (result.status === 'error' && emailItem != null) { + await emailItem.increaseQuantity(1); + } + return { statusCode: 200, bodyType: 'json', diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index 16a6cf7b8e..e22fda3ef0 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -1,4 +1,4 @@ -import { CustomerType, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client"; +import { CustomerType, PrismaClient, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer } from "@/lib/payments/customer-data"; // eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) @@ -39,12 +39,18 @@ function isAddOnProduct(product: { isAddOnTo?: false | Record | nu * `bulldozerWriteSubscription(prisma, sub)` after any outer transaction * commits, and for verifying there's no conflicting plan in the same line. * + * `prisma` is deliberately typed as the union — the helper does a single + * `subscription.create` that works identically with a full client or a tx + * client. When called with a full client, the Prisma insert and the + * downstream `bulldozerWriteSubscription` are NOT atomic; same trade-off as + * every other dual-write call site. + * * `creationSource` is a parameter because the right value depends on context * (auto-regrant vs team-creation vs a hypothetical test-mode seed). Throws * on a misconfigured `free` product so broken deploys fail loudly. */ export async function createFreePlanSubscriptionRow(options: { - prisma: PrismaClientTransaction, + prisma: PrismaClient | PrismaClientTransaction, internalTenancy: Tenancy, billingTeamId: string, creationSource: PurchaseCreationSource, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 73e4ec8f57..091b83511c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -16,7 +16,7 @@ import { } from "@phosphor-icons/react"; import { Alert, AlertDescription, Button } from "@/components/ui"; import { useUser } from "@stackframe/stack"; -import { PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; +import { BASE_PLAN_IDS_BY_TIER, PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMemo, useRef } from "react"; @@ -322,9 +322,14 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () = } function resolvePlanId(products: Array<{ id: string | null, type?: string }>): PlanId { - if (products.some(p => p.id === "growth" && p.type === "subscription")) return "growth"; - if (products.some(p => p.id === "team" && p.type === "subscription")) return "team"; - return "free"; + // Pick the highest-tier base plan the customer holds. Source of truth is + // `BASE_PLAN_IDS_BY_TIER` (ordered best→worst) so adding a plan in + // `plans.ts` doesn't require touching this file, and "free" stays the + // last-resort fallback. + const activeSubscriptionPlanIds = new Set( + products.filter(p => p.type === "subscription" && p.id != null).map(p => p.id), + ); + return BASE_PLAN_IDS_BY_TIER.find(id => activeSubscriptionPlanIds.has(id)) ?? "free"; } /** diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts index eeadd509c8..53093eb30e 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts @@ -58,15 +58,17 @@ describe("POST /api/v1/internal/send-test-email — emails_per_month quota", () expect(response.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); }); - it("debits emails_per_month by 1 even when SMTP fails", async ({ expect }) => { + it("refunds emails_per_month when SMTP delivery fails", async ({ expect }) => { const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); const ownerTeamId = createProjectResponse.body.owner_team_id; const before = await getEmailItemQuantity(ownerTeamId); - // SMTP call fails against nonexistent.example.invalid, but the quota debit - // happens before SMTP so the decrement should be observable regardless. - await niceBackendFetch("/api/v1/internal/send-test-email", { + // SMTP call fails against nonexistent.example.invalid. The quota is + // debited up-front (to bound concurrent SMTP attempts), then refunded + // when the send reports failure so admins iterating on a misconfigured + // mail server don't burn through their monthly allowance. + const response = await niceBackendFetch("/api/v1/internal/send-test-email", { method: "POST", accessType: "admin", body: { @@ -75,7 +77,10 @@ describe("POST /api/v1/internal/send-test-email — emails_per_month quota", () }, }); + expect(response.status).toBe(200); + expect(response.body.success).toBe(false); + const after = await getEmailItemQuantity(ownerTeamId); - expect(after).toBe(before - 1); + expect(after).toBe(before); }); }); diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts index 0608a2cd9b..c288361a95 100644 --- a/packages/stack-shared/src/plans.ts +++ b/packages/stack-shared/src/plans.ts @@ -69,3 +69,10 @@ export const PLAN_LIMITS: { }; export type PlanId = keyof typeof PLAN_LIMITS; + +/** + * Base plan IDs ordered from highest to lowest tier. Use this (instead of + * string literals) whenever code needs to pick a customer's "current" plan + * from their product list, so the choice stays in sync with `PLAN_LIMITS`. + */ +export const BASE_PLAN_IDS_BY_TIER = ["growth", "team", "free"] as const satisfies readonly PlanId[]; From c56b6af6ecf7b913ca347c36c18d5837116f022d Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 21 Apr 2026 13:42:04 -0700 Subject: [PATCH 32/38] chore: DRY for plan stuff --- .../projects/page-client.tsx | 5 ++-- .../projects/[projectId]/analytics/shared.tsx | 13 +-------- packages/stack-shared/src/plans.ts | 29 +++++++++++++++++++ 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index 0299248db5..b39b856311 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -8,6 +8,7 @@ import { getPublicEnvVar } from "@/lib/env"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { GearIcon } from "@phosphor-icons/react"; import { AdminOwnedProject, Team, useStackApp, useUser } from "@stackframe/stack"; +import { isPaidPlan } from "@stackframe/stack-shared/dist/plans"; import { projectOnboardingStatusValues, strictEmailSchema, yupObject, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields"; import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays"; import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; @@ -412,9 +413,7 @@ function TeamAddUserDialogContent(props: { const users = props.team.useUsers(); const admins = props.team.useItem("dashboard_admins"); const products = props.team.useProducts(); - const hasPaidPlan = products.some( - p => (p.id === "team" || p.id === "growth") && p.type === "subscription" - ); + const hasPaidPlan = isPaidPlan(products); const [email, setEmail] = useState(""); const [formError, setFormError] = useState(null); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 091b83511c..2e44c34b1d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -16,7 +16,7 @@ import { } from "@phosphor-icons/react"; import { Alert, AlertDescription, Button } from "@/components/ui"; import { useUser } from "@stackframe/stack"; -import { BASE_PLAN_IDS_BY_TIER, PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; +import { PLAN_LIMITS, resolvePlanId } from "@stackframe/stack-shared/dist/plans"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useMemo, useRef } from "react"; @@ -321,17 +321,6 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () = ); } -function resolvePlanId(products: Array<{ id: string | null, type?: string }>): PlanId { - // Pick the highest-tier base plan the customer holds. Source of truth is - // `BASE_PLAN_IDS_BY_TIER` (ordered best→worst) so adding a plan in - // `plans.ts` doesn't require touching this file, and "free" stays the - // last-resort fallback. - const activeSubscriptionPlanIds = new Set( - products.filter(p => p.type === "subscription" && p.id != null).map(p => p.id), - ); - return BASE_PLAN_IDS_BY_TIER.find(id => activeSubscriptionPlanIds.has(id)) ?? "free"; -} - /** * Shows a warning banner when analytics event usage is at 80%+ or 100%. * Fetches the billing team's analytics_events item and computes usage against the plan's total allocation. diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts index c288361a95..b270a39c1b 100644 --- a/packages/stack-shared/src/plans.ts +++ b/packages/stack-shared/src/plans.ts @@ -76,3 +76,32 @@ export type PlanId = keyof typeof PLAN_LIMITS; * from their product list, so the choice stays in sync with `PLAN_LIMITS`. */ export const BASE_PLAN_IDS_BY_TIER = ["growth", "team", "free"] as const satisfies readonly PlanId[]; + +/** + * Minimal shape of a product entry as it comes out of `team.useProducts()` / + * `customer.useProducts()` on both the SDK and dashboard sides. Structural so + * we don't pull SDK types into `stack-shared`. + */ +type PlanResolutionProduct = { id: string | null, type?: string }; + +/** + * Picks the customer's highest-tier active base plan (growth → team → free), + * falling back to `"free"` if none of the known plans appear as a + * subscription. Single source of truth for plan gating in the dashboard — + * do not reintroduce ad-hoc `p.id === "team" || p.id === "growth"` checks. + */ +export function resolvePlanId(products: ReadonlyArray): PlanId { + const activeSubscriptionPlanIds = new Set( + products.filter(p => p.type === "subscription" && p.id != null).map(p => p.id), + ); + return BASE_PLAN_IDS_BY_TIER.find(id => activeSubscriptionPlanIds.has(id)) ?? "free"; +} + +/** + * Convenience predicate for "is this customer on a paid plan?". Anything + * above free counts, so new paid tiers added to `PLAN_LIMITS` are picked up + * automatically. + */ +export function isPaidPlan(products: ReadonlyArray): boolean { + return resolvePlanId(products) !== "free"; +} From d9be56a7e31e77aa8c7aec08aedbcae75c8f78a8 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 21 Apr 2026 13:53:33 -0700 Subject: [PATCH 33/38] refactor: guard against manual retry bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No existing code path nulls finishedSendingAt without also resetting sendRetries to 0 (the outbox-update path that re-renders content explicitly resets both; the stuck-email reaper only touches rows with finishedRenderingAt: null, which quota-blocked rows aren't). So this is latent risk, not an active CVE. That said, applying the suggested fix is cheap and makes sendRetries semantically honest — it now strictly tracks SMTP attempts, not pre-SMTP decisions. Any future admin "retry" flow that un-finalises a row is now safe against this class of bypass, regardless of whether it remembers to reset the counter. Added an inline comment explaining the invariant. --- apps/backend/src/lib/email-queue-step.tsx | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index fa38bfa871..d7a2d410e4 100644 --- a/apps/backend/src/lib/email-queue-step.tsx +++ b/apps/backend/src/lib/email-queue-step.tsx @@ -651,15 +651,16 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO const isDebited = await emailItem.tryDecreaseQuantity(1); if (!isDebited) { const errorMessage = "Monthly email sending limit exceeded for your plan. Please upgrade your plan or wait until next month."; - const errorEntry: SendAttemptError = { - attemptNumber: row.sendRetries + 1, - timestamp: new Date().toISOString(), - externalMessage: errorMessage, - externalDetails: { errorType: "monthly-email-limit-exceeded" }, - internalMessage: errorMessage, - internalDetails: { errorType: "monthly-email-limit-exceeded", remainingQuota: emailItem.quantity, billingTeamId: context.billingTeamId }, - }; - const updatedErrors = appendSendAttemptError(row.sendAttemptErrors as SendAttemptError[] | null, errorEntry); + // Intentionally do NOT increment sendRetries or append to + // sendAttemptErrors. sendRetries tracks SMTP attempts, and a quota + // rejection never reaches SMTP; the DB-level + // EmailOutbox_sendAttemptErrors_requires_failure CHECK also forbids + // non-null sendAttemptErrors while sendRetries is 0. All the + // debugging info is captured in sendServerError* below. Keeping + // sendRetries at 0 means that if a future admin flow ever + // un-finalises this row (clearing finishedSendingAt), the + // `sendRetries === 0` quota gate above re-fires instead of being + // silently skipped. await globalPrismaClient.emailOutbox.update({ where: { tenancyId_id: { @@ -671,8 +672,6 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO data: { finishedSendingAt: new Date(), canHaveDeliveryInfo: false, - sendRetries: row.sendRetries + 1, - sendAttemptErrors: updatedErrors as Prisma.InputJsonArray, sendServerErrorExternalMessage: errorMessage, sendServerErrorExternalDetails: { errorType: "monthly-email-limit-exceeded" }, sendServerErrorInternalMessage: errorMessage, From a4695e85600861f322e6cdb117afefb0d1390b61 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 21 Apr 2026 14:30:40 -0700 Subject: [PATCH 34/38] chore: fix tests Wait is to let bulldozer run --- apps/e2e/tests/js/email.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/e2e/tests/js/email.test.ts b/apps/e2e/tests/js/email.test.ts index e2a730ed9d..1201ca2f44 100644 --- a/apps/e2e/tests/js/email.test.ts +++ b/apps/e2e/tests/js/email.test.ts @@ -181,6 +181,12 @@ it("should provide delivery statistics", async ({ expect }) => { primaryEmailVerified: true, }); + // Give Bulldozer's pg_cron tick time to materialise the billing team's + // `emails_per_month` quota from its freshly-granted free plan. Without + // this wait the first email gets quota-blocked into a permanent + // server-error terminal and `stats.hour.sent` never reaches 1. + await wait(2000); + await serverApp.sendEmail({ userIds: [user.id], html: "

Stats

", @@ -245,6 +251,12 @@ it("should send test email with custom SMTP configuration", async ({ expect }) = // Verify config is not shared expect(config.emails.server.isShared).toBe(false); + // Give Bulldozer's pg_cron tick time to materialise the billing team's + // `emails_per_month` quota from its freshly-granted free plan; otherwise + // the `tryDecreaseQuantity` inside the route rejects with + // ItemQuantityInsufficientAmount before SMTP is ever dialled. + await wait(2000); + // Send a test email const result = await adminApp.sendTestEmail({ recipientEmail: "test-recipient@example.com", From beb273de599bae3b2939176b36d8a67559c29a26 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 22 Apr 2026 11:22:59 -0700 Subject: [PATCH 35/38] refactor: reorg free plan regrant to be less racy --- .../[customer_type]/[customer_id]/route.ts | 6 +- .../[customer_id]/switch/route.ts | 6 +- apps/backend/src/lib/payments.tsx | 13 ++ .../src/lib/payments/ensure-free-plan.test.ts | 190 ++++++++++++++++++ .../src/lib/payments/ensure-free-plan.ts | 116 +++++++---- 5 files changed, 286 insertions(+), 45 deletions(-) create mode 100644 apps/backend/src/lib/payments/ensure-free-plan.test.ts diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts index c2428ab8d3..b8f974ee80 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts @@ -1,4 +1,4 @@ -import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, productToInlineProduct } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, ensureProductIdOrInlineProduct, grantProductToCustomer, isActiveSubscription, isAddOnProduct, productToInlineProduct } from "@/lib/payments"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -6,7 +6,7 @@ import { KnownErrors } from "@stackframe/stack-shared"; import { customerProductsListResponseSchema } from "@stackframe/stack-shared/dist/interface/crud/products"; import { adaptSchema, clientOrHigherAuthTypeSchema, inlineProductSchema, serverOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { typedEntries, typedFromEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; +import { typedEntries, typedFromEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; export const GET = createSmartRouteHandler({ @@ -82,7 +82,7 @@ export const GET = createSmartRouteHandler({ if (product.prices === "include-by-default") continue; const hasIntervalPrice = typedEntries(product.prices).some(([, price]) => price.interval); if (!hasIntervalPrice) continue; - if (product.isAddOnTo && typedKeys(product.isAddOnTo).length > 0) continue; + if (isAddOnProduct(product)) continue; const inlineProduct = productToInlineProduct(product); const intervalPrices = typedFromEntries( diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index 5ea8b243d4..aebf658fde 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -1,5 +1,5 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; -import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription } from "@/lib/payments"; +import { ensureClientCanAccessCustomer, ensureCustomerExists, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull, isActiveSubscription, isAddOnProduct } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; import { getOwnedProductsForCustomer, getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; import { upsertProductVersion } from "@/lib/product-versions"; @@ -9,7 +9,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { getOrUndefined, typedEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; +import { getOrUndefined, typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; import Stripe from "stripe"; @@ -72,7 +72,7 @@ export const POST = createSmartRouteHandler({ if (body.from_product_id === body.to_product_id) { throw new StatusError(400, "Product is already active."); } - if (toProduct.isAddOnTo && typedKeys(toProduct.isAddOnTo).length > 0) { + if (isAddOnProduct(toProduct)) { throw new StatusError(400, "Add-on products cannot be selected for plan switching."); } const fromIsIncludeByDefault = fromProduct.prices === "include-by-default"; diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index a14e382ecb..5200deed91 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -121,6 +121,19 @@ export function isActiveSubscription(subscription: { status: string }): boolean return s === "active" || s === SubscriptionStatus.active || s === "trialing" || s === SubscriptionStatus.trialing; } +/** + * True when the given product config / snapshot declares itself as an add-on + * to one or more other products. Add-ons share a product line with their base + * plan but don't satisfy "base plan owned" invariants on their own. + * + * The predicate normalises the three ways a product can signal "not an + * add-on" (absent, explicitly `false`, or an empty record) so callers don't + * have to reimplement the check. + */ +export function isAddOnProduct(product: { isAddOnTo?: false | Record | null }): boolean { + return product.isAddOnTo != null && product.isAddOnTo !== false && Object.keys(product.isAddOnTo).length > 0; +} + type OwnedProducts = OwnedProductsRow["ownedProducts"]; /** diff --git a/apps/backend/src/lib/payments/ensure-free-plan.test.ts b/apps/backend/src/lib/payments/ensure-free-plan.test.ts new file mode 100644 index 0000000000..655597817f --- /dev/null +++ b/apps/backend/src/lib/payments/ensure-free-plan.test.ts @@ -0,0 +1,190 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { getOrUndefined } from "@stackframe/stack-shared/dist/utils/objects"; +import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +// eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { ensureFreePlanForBillingTeam } from "./ensure-free-plan"; + +// Uses the real internal tenancy (relies on its seeded free/team/growth/ +// extra-seats product config) and random UUIDs as billing team IDs. +// Subscription rows aren't FK-checked against the Team table, so inserting +// a sub for a non-existent team works and keeps tests side-effect-free on +// real teams. +describe.sequential("ensureFreePlanForBillingTeam (real DB)", () => { + async function getInternal() { + const tenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID, true); + if (tenancy == null) throw new Error("Internal billing tenancy not found"); + const prisma = await getPrismaClientForTenancy(tenancy); + return { tenancy, prisma }; + } + + // Returns subs that haven't ended yet — matches the "occupies the product + // line" semantics of `ensureFreePlanForBillingTeam`'s predicate, which is + // endedAt-based (not status-based) to mirror the Subscription TimeFold. + async function getUnendedSubsForTeam(tenancyId: string, billingTeamId: string, prisma: unknown) { + const subMap = await getSubscriptionMapForCustomer({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- mirrors `payments.test.tsx`: PrismaClient is structurally compatible with PrismaClientTransaction here + prisma: prisma as any, + tenancyId, + customerType: "team", + customerId: billingTeamId, + }); + const nowMillis = Date.now(); + return Object.values(subMap).filter((s) => s.endedAtMillis == null || s.endedAtMillis > nowMillis); + } + + async function seedSub(options: { + tenancyId: string, + billingTeamId: string, + productId: string, + productSnapshot: unknown, + status?: "active" | "trialing" | "incomplete" | "past_due", + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see `getUnendedSubsForTeam` + prisma: any, + }) { + const now = new Date(); + await bulldozerWriteSubscription(options.prisma, { + id: randomUUID(), + tenancyId: options.tenancyId, + customerId: options.billingTeamId, + customerType: "TEAM", + productId: options.productId, + priceId: null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ProductSnapshot is a structural JSON type; bulldozerWriteSubscription will stamp it into the stored row as-is. + product: options.productSnapshot as any, + quantity: 1, + stripeSubscriptionId: `stripe-${randomUUID()}`, + status: options.status ?? "active", + currentPeriodStart: now, + currentPeriodEnd: new Date(now.getTime() + 30 * 24 * 3600 * 1000), + cancelAtPeriodEnd: false, + canceledAt: null, + endedAt: null, + refundedAt: null, + creationSource: "PURCHASE_PAGE", + createdAt: now, + }); + } + + it("fast path: no-op when team already owns an active base plan in the line", async () => { + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + const teamProduct = getOrUndefined(tenancy.config.payments.products, "team"); + if (teamProduct == null) throw new Error("Internal tenancy missing `team` product"); + + await seedSub({ + tenancyId: tenancy.id, + billingTeamId, + productId: "team", + productSnapshot: teamProduct, + prisma, + }); + + await ensureFreePlanForBillingTeam(billingTeamId); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + expect(subs).toHaveLength(1); + expect(subs[0].productId).toBe("team"); + }); + + it("regression: an `incomplete` paid sub still occupies the line — no free regrant", async () => { + // Reproduces the Stripe webhook race the endedAt-based predicate + // defends against: `subscription.created` lands first with + // `status=incomplete` and no `endedAt`; the subsequent `invoice.paid` + // flips it to `active`. Between those two webhooks, `ensureFree...` + // must treat the incomplete sub as occupying the line — gating on + // `status` alone would regrant free on top and leave the customer + // with both subs active (exactly the chauncey-team dashboard bug). + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + const teamProduct = getOrUndefined(tenancy.config.payments.products, "team"); + if (teamProduct == null) throw new Error("Internal tenancy missing `team` product"); + + await seedSub({ + tenancyId: tenancy.id, + billingTeamId, + productId: "team", + productSnapshot: teamProduct, + status: "incomplete", + prisma, + }); + + await ensureFreePlanForBillingTeam(billingTeamId); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + expect(subs).toHaveLength(1); + expect(subs[0].productId).toBe("team"); + }); + + it("slow path: creates a free sub when team has no prior sub in the line", async () => { + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + await ensureFreePlanForBillingTeam(billingTeamId); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + expect(subs).toHaveLength(1); + expect(subs[0].productId).toBe("free"); + }); + + it("idempotent: sequential double-call creates exactly one free sub", async () => { + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + await ensureFreePlanForBillingTeam(billingTeamId); + await ensureFreePlanForBillingTeam(billingTeamId); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + expect(subs).toHaveLength(1); + expect(subs[0].productId).toBe("free"); + }); + + it("slow path race: concurrent Promise.all calls create exactly one free sub", async () => { + // Exercises the SERIALIZABLE slow path's retry-on-conflict behaviour — + // both invocations enter the tx concurrently, one commits, the other + // retries under a fresh snapshot, sees the committed row, and skips. + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + await Promise.all([ + ensureFreePlanForBillingTeam(billingTeamId), + ensureFreePlanForBillingTeam(billingTeamId), + ]); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + expect(subs).toHaveLength(1); + expect(subs[0].productId).toBe("free"); + }); + + it("add-on does not count as a base plan — free is still regranted", async () => { + const { tenancy, prisma } = await getInternal(); + const billingTeamId = randomUUID(); + + // `extra-seats` is an add-on (isAddOnTo: { team, growth }) but lives in + // the same product line as the free plan. It must NOT short-circuit the + // fast path; the team should still get a free sub on top. + const extraSeatsProduct = getOrUndefined(tenancy.config.payments.products, "extra-seats"); + if (extraSeatsProduct == null) throw new Error("Internal tenancy missing `extra-seats` product"); + + await seedSub({ + tenancyId: tenancy.id, + billingTeamId, + productId: "extra-seats", + productSnapshot: extraSeatsProduct, + prisma, + }); + + await ensureFreePlanForBillingTeam(billingTeamId); + + const subs = await getUnendedSubsForTeam(tenancy.id, billingTeamId, prisma); + const productIds = new Set(subs.map((s) => s.productId)); + expect(subs).toHaveLength(2); + expect(productIds.has("free")).toBe(true); + expect(productIds.has("extra-seats")).toBe(true); + }); +}); diff --git a/apps/backend/src/lib/payments/ensure-free-plan.ts b/apps/backend/src/lib/payments/ensure-free-plan.ts index e22fda3ef0..584338ee3c 100644 --- a/apps/backend/src/lib/payments/ensure-free-plan.ts +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -1,6 +1,8 @@ import { CustomerType, PrismaClient, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client"; +import { isAddOnProduct } from "@/lib/payments"; import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; -import { getOwnedProductsForCustomer } from "@/lib/payments/customer-data"; +import { getSubscriptionMapForCustomer } from "@/lib/payments/customer-data"; +import type { ProductSnapshot } from "@/lib/payments/schema/types"; // eslint-disable-next-line @typescript-eslint/no-deprecated -- idiomatic way to get the internal tenancy today (see plan-entitlements.ts) import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, type Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, retryTransaction, type PrismaClientTransaction } from "@/prisma-client"; @@ -30,10 +32,6 @@ async function getInternalBillingTenancy(): Promise { return tenancy; } -function isAddOnProduct(product: { isAddOnTo?: false | Record | null }): boolean { - return product.isAddOnTo != null && product.isAddOnTo !== false && Object.keys(product.isAddOnTo).length > 0; -} - /** * Writes the `free` Subscription row. Caller is responsible for a subsequent * `bulldozerWriteSubscription(prisma, sub)` after any outer transaction @@ -104,22 +102,29 @@ export async function createFreePlanSubscriptionRow(options: { * * Two-phase concurrency story: * - * 1. Fast path — optimistic bulldozer read. In the overwhelming common - * case the billing team already has some plan in the product line - * (free, team, or growth), so we return immediately without touching - * Prisma's Subscription table. + * 1. Fast path — O(1) read against the `subscriptionMapByCustomer` + * LFold. That LFold is a `GroupBy → Sort → LFold` chain with no + * TimeFold in its dependencies, so its row-change triggers cascade + * synchronously during `bulldozerWriteSubscription`'s `setRow` + * (unlike `ownedProducts`, which sits downstream of a TimeFold and + * only catches up when `pg_cron` drains the queue). That means + * callers that just committed a sub mutation upstream (the DELETE + * cancel route, the Stripe webhook handler) see their own writes + * here and we don't spuriously regrant on stale data. * - * 2. Slow path — if the fast path sees "no base plan," re-check the - * Subscription source-of-truth under SERIALIZABLE isolation and - * insert atomically. The bulldozer derived ledger lags pg_cron and - * can't detect in-flight writes from concurrent callers, so we - * cannot rely on it for idempotency; reading the Subscription table - * directly under SSI is the only way to serialize the check+insert. - * `retryTransaction` handles P2028 serialization failures by - * retrying; on the retry the other caller's row is visible and we - * skip the insert. + * 2. Slow path — if the fast path found nothing, re-check against the + * Prisma Subscription source-of-truth under SERIALIZABLE isolation + * and insert atomically so two concurrent callers can't both create + * a duplicate free sub. `retryTransaction` handles P2028 + * serialization failures by retrying; on the retry the other + * caller's row is visible and we skip the insert. * - * We do this + * TODO: once "default products" lands and the free plan is granted + * implicitly by config rather than a DB row, this whole regrant dance + * goes away. The slow-path Prisma write is also a pre-Bulldozer- + * deprecation artefact — when Bulldozer owns subscription writes + * directly, the SERIALIZABLE Prisma tx becomes a Bulldozer insert with + * its own concurrency story. */ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promise { const internalTenancy = await getInternalBillingTenancy(); @@ -131,41 +136,74 @@ export async function ensureFreePlanForBillingTeam(billingTeamId: string): Promi const internalPrisma = await getPrismaClientForTenancy(internalTenancy); - // Fast path: bulldozer-based optimistic check. Only BASE plans count — - // add-ons (extra-seats etc.) don't provide baseline entitlements on their - // own, so losing the base plan while still holding an add-on still - // triggers a regrant. - const ownedProducts = await getOwnedProductsForCustomer({ + // Snapshot-based "occupies the free plan's product line" predicate. We + // treat a sub as occupying the line iff its captured product snapshot + // lives in that line, isn't an add-on, and HASN'T ENDED YET (endedAt in + // the future or absent). Crucially we do NOT gate on `status` — + // `incomplete` / `past_due` / `unpaid` subs that arrive mid-Stripe-flow + // still reserve the line (they will either transition to `active` or to + // a terminal status with `endedAt` set), and this matches the semantics + // that `ownedProducts` derives via the Subscription TimeFold (see + // `subscription-timefold-algo.ts` — `subscription-start` emits on row + // insert regardless of status; `subscription-end` emits at + // `endedAtMillis`). Treating only active/trialing as occupying would + // (and did) cause the free plan to be double-granted on top of a + // just-created incomplete paid sub. + const nowMillis = Date.now(); + const productLineStillOccupiedBy = (sub: { + product: ProductSnapshot, + endedAtMillis?: number | null, + endedAt?: Date | null, + }): boolean => { + if (sub.product.productLineId !== freeProductLineId) return false; + if (isAddOnProduct(sub.product)) return false; + const endedAtMillis = sub.endedAtMillis != null + ? sub.endedAtMillis + : sub.endedAt != null ? sub.endedAt.getTime() : null; + return endedAtMillis == null || endedAtMillis > nowMillis; + }; + + // Fast path: read the customer's synchronous subscription LFold. Note + // that Bulldozer SubscriptionRow uses the schema-side lowercase + // CustomerType (`"team"`), not the Prisma enum — see + // `bulldozer-dual-write.ts:subscriptionToStoredRow` which + // `.toLowerCase()`s on write. + const subscriptionMap = await getSubscriptionMapForCustomer({ prisma: internalPrisma, tenancyId: internalTenancy.id, customerType: "team", customerId: billingTeamId, }); - const alreadyHasBasePlanInLine = Object.values(ownedProducts).some( - (p) => - p.productLineId === freeProductLineId - && p.quantity > 0 - && !isAddOnProduct(p.product), - ); - if (alreadyHasBasePlanInLine) { + if (Object.values(subscriptionMap).some(productLineStillOccupiedBy)) { return; } - // Slow path: the team appears to have no base plan. Re-check under - // SERIALIZABLE isolation and insert atomically so a concurrent caller - // can't produce a duplicate free sub. + // Slow path: the team appears to have no occupying sub. Re-check under + // SERIALIZABLE isolation against the Prisma source-of-truth and insert + // atomically so concurrent callers can't both produce a duplicate free + // sub. Prisma here (not Bulldozer) because the insert is a Prisma write + // and we want the check and insert to serialize on the same row. We + // filter `endedAt IS NULL OR endedAt > NOW()` at the SQL level and + // apply the snapshot predicate in-memory — per-customer sub counts are + // tiny, and the `(tenancyId, customerId, customerType)` index is used. + const now = new Date(); const createdSub = await retryTransaction(internalPrisma, async (tx) => { - const existing = await tx.subscription.findFirst({ + const unendedSubs = await tx.subscription.findMany({ where: { tenancyId: internalTenancy.id, customerId: billingTeamId, customerType: CustomerType.TEAM, - productId: "free", - status: { in: [SubscriptionStatus.active, SubscriptionStatus.trialing] }, + OR: [{ endedAt: null }, { endedAt: { gt: now } }], }, - select: { id: true }, + select: { product: true, endedAt: true }, }); - if (existing != null) { + const existing = unendedSubs.some((sub) => + productLineStillOccupiedBy({ + product: sub.product as ProductSnapshot, + endedAt: sub.endedAt, + }), + ); + if (existing) { return null; } return await createFreePlanSubscriptionRow({ From b69783b1528fe26cd4fe4bde18f0df268dd9257f Mon Sep 17 00:00:00 2001 From: nams1570 Date: Sun, 3 May 2026 18:02:45 -0700 Subject: [PATCH 36/38] fix: switch to polling in tests --- .../api/v1/analytics-events-batch.test.ts | 65 +++------ .../endpoints/api/v1/analytics-query.test.ts | 16 ++- .../tests/backend/payment-quota-helpers.ts | 125 ++++++++++++++++++ 3 files changed, 156 insertions(+), 50 deletions(-) create mode 100644 apps/e2e/tests/backend/payment-quota-helpers.ts diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index 424f563696..d6f4356c54 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -1,8 +1,15 @@ -import { PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; +import { ITEM_IDS, PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { randomUUID } from "node:crypto"; import { it } from "../../../../helpers"; import { Auth, Project, backendContext, niceBackendFetch, withInternalProject } from "../../../backend-helpers"; +import { + getItemQuantity, + setItemQuantity, + waitForItemQuantityToReach, + waitForItemQuantityToStabilize, +} from "../../../payment-quota-helpers"; async function uploadEventBatch(options: { sessionReplaySegmentId: string, @@ -492,47 +499,19 @@ async function setupProjectWithPlan(planId: PlanId) { body: { product_id: planId }, }); if (grantResponse.status !== 200) { - throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); + throw new StackAssertionError(`Failed to grant plan '${planId}' to team '${ownerTeamId}'`, { response: grantResponse }); } }); } - await wait(3000); + await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsEvents, PLAN_LIMITS[planId].analyticsEvents); return { ownerTeamId }; } -async function getEventItemQuantity(ownerTeamId: string) { - return await withInternalProject(async () => { - const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/analytics_events`, { - accessType: "server", - }); - if (response.status !== 200) { - throw new Error(`Failed to get analytics_events item: ${JSON.stringify(response.body)}`); - } - return response.body.quantity as number; - }); -} - -async function setEventItemQuantity(ownerTeamId: string, quantity: number) { - const currentQuantity = await getEventItemQuantity(ownerTeamId); - const delta = quantity - currentQuantity; - - await withInternalProject(async () => { - const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/analytics_events/update-quantity?allow_negative=true`, { - method: "POST", - accessType: "server", - body: { delta }, - }); - if (response.status !== 200) { - throw new Error(`Failed to set analytics_events quantity: ${JSON.stringify(response.body)}`); - } - }); -} - it("rejects batch when analytics event quota is exhausted", async ({ expect }) => { const { ownerTeamId } = await setupProjectWithPlan("free"); await Auth.Otp.signIn(); - await setEventItemQuantity(ownerTeamId, 0); + await setItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents, 0); const res = await uploadEventBatch({ sessionReplaySegmentId: randomUUID(), @@ -549,10 +528,8 @@ it("accepts batch and debits event quota correctly", async ({ expect }) => { const { ownerTeamId } = await setupProjectWithPlan("free"); await Auth.Otp.signIn(); - // Wait for async logEvent debits (sign-in triggers token-refresh/sign-up-rule events asynchronously) - await wait(6000); - - const quantityBeforeBatch = await getEventItemQuantity(ownerTeamId); + // Drain async logEvent debits (sign-in triggers token-refresh/sign-up-rule events asynchronously) before measuring baseline. + const quantityBeforeBatch = await waitForItemQuantityToStabilize(ownerTeamId, ITEM_IDS.analyticsEvents); const now = Date.now(); const eventCount = 3; @@ -570,7 +547,7 @@ it("accepts batch and debits event quota correctly", async ({ expect }) => { expect(res.status).toBe(200); expect(res.body.inserted).toBe(eventCount); - const afterQuantity = await getEventItemQuantity(ownerTeamId); + const afterQuantity = await getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents); expect(afterQuantity).toBe(quantityBeforeBatch - eventCount); }); @@ -581,9 +558,11 @@ it("rejects batch when remaining quota is less than batch size and does not debi const { ownerTeamId } = await setupProjectWithPlan("free"); await Auth.Otp.signIn(); - // Wait for async logEvent debits (sign-in triggers events asynchronously) - await wait(6000); - await setEventItemQuantity(ownerTeamId, 2); + // Drain async logEvent debits before forcing the quota down to a known + // value — otherwise a trailing in-flight debit would push it negative + // after we set it to 2 and break the post-condition. + await waitForItemQuantityToStabilize(ownerTeamId, ITEM_IDS.analyticsEvents); + await setItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents, 2); const res = await uploadEventBatch({ sessionReplaySegmentId: randomUUID(), @@ -599,20 +578,20 @@ it("rejects batch when remaining quota is less than batch size and does not debi expect(res.status).toBe(400); expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT"); - const quantityAfter = await getEventItemQuantity(ownerTeamId); + const quantityAfter = await getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents); expect(quantityAfter).toBe(2); }); it("free plan starts with correct analytics event allocation", async ({ expect }) => { const { ownerTeamId } = await setupProjectWithPlan("free"); - const quantity = await getEventItemQuantity(ownerTeamId); + const quantity = await getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents); expect(quantity).toBe(PLAN_LIMITS.free.analyticsEvents); }); it("team plan starts with correct analytics event allocation", async ({ expect }) => { const { ownerTeamId } = await setupProjectWithPlan("team"); - const quantity = await getEventItemQuantity(ownerTeamId); + const quantity = await getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents); expect(quantity).toBe(PLAN_LIMITS.team.analyticsEvents); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 9f6fef77b7..04d8936035 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -1,8 +1,10 @@ -import { PLAN_LIMITS, PlanId } from "@stackframe/stack-shared/dist/plans"; +import { ITEM_IDS, PLAN_LIMITS, PlanId } from "@stackframe/stack-shared/dist/plans"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { it } from "../../../../helpers"; import { Project, User, niceBackendFetch, withInternalProject } from "../../../backend-helpers"; +import { waitForItemQuantityToReach } from "../../../payment-quota-helpers"; async function runQuery(body: { query: string, params?: Record, timeout_ms?: number }) { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); @@ -28,10 +30,10 @@ async function runQueryWithPlan(planId: PlanId, body: { query: string, params?: body: { product_id: planId }, }); if (grantResponse.status !== 200) { - throw new Error(`Failed to grant plan '${planId}' to team '${ownerTeamId}': ${JSON.stringify(grantResponse.body)}`); + throw new StackAssertionError(`Failed to grant plan '${planId}' to team '${ownerTeamId}'`, { response: grantResponse }); } }); - await wait(2000); + await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsTimeoutSeconds, PLAN_LIMITS[planId].analyticsTimeoutSeconds); } const response = await niceBackendFetch("/api/v1/internal/analytics/query", { @@ -103,7 +105,7 @@ it("can fetch query timing by query_id", async ({ expect }) => { expect(response.status).toBe(200); expect(queryId).toEqual(expect.any(String)); if (typeof queryId !== "string") { - throw new Error("Expected analytics query response to include query_id."); + throw new StackAssertionError("Expected analytics query response to include query_id"); } const timingResponse = await fetchQueryTimingWithRetry(queryId); @@ -128,7 +130,7 @@ it("does not allow fetching timing for another project's query", async ({ expect expect(projectAQuery.status).toBe(200); expect(projectAQueryId).toEqual(expect.any(String)); if (typeof projectAQueryId !== "string") { - throw new Error("Expected analytics query response to include query_id."); + throw new StackAssertionError("Expected analytics query response to include query_id"); } await Project.createAndSwitch({ config: { magic_link_enabled: true } }); @@ -1787,8 +1789,8 @@ it("rejects analytics queries when the timeout quota is zero (would otherwise se ); expect(drainResponse.status).toBe(200); }); - // Let the timefold process - await wait(2000); + // Wait for the bulldozer timefold to materialize the drained quota. + await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsTimeoutSeconds, 0); const response = await niceBackendFetch("/api/v1/internal/analytics/query", { method: "POST", diff --git a/apps/e2e/tests/backend/payment-quota-helpers.ts b/apps/e2e/tests/backend/payment-quota-helpers.ts new file mode 100644 index 0000000000..d48effcc79 --- /dev/null +++ b/apps/e2e/tests/backend/payment-quota-helpers.ts @@ -0,0 +1,125 @@ +import { ItemId } from "@stackframe/stack-shared/dist/plans"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { wait } from "@stackframe/stack-shared/dist/utils/promises"; +import { niceBackendFetch, withInternalProject } from "./backend-helpers"; + +// Helpers for reading and waiting on payment-item quantities held by an +// owner team in the internal project (the "billing team" of a Stack Auth +// customer's project). Used by tests that need to assert against post-grant +// quota state without sleeping for arbitrarily-large fixed durations. + +/** + * Fetches the current quantity of a payment item. Throws if the API call + * fails (e.g. tenancy/team misconfigured). + */ +export async function getItemQuantity(ownerTeamId: string, itemId: ItemId): Promise { + return await withInternalProject(async () => { + const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/${itemId}`, { + accessType: "server", + }); + if (response.status !== 200) { + throw new StackAssertionError(`Failed to fetch item quantity`, { ownerTeamId, itemId, response }); + } + return response.body.quantity as number; + }); +} + +/** + * Sets the quantity of a payment item to an exact value by computing and + * applying the delta from the current value. Used in tests to force the + * quota into a known state. `allow_negative=true` so callers can drive the + * quota past zero when they need to. + */ +export async function setItemQuantity(ownerTeamId: string, itemId: ItemId, quantity: number): Promise { + const current = await getItemQuantity(ownerTeamId, itemId); + const delta = quantity - current; + await withInternalProject(async () => { + const response = await niceBackendFetch( + `/api/v1/payments/items/team/${ownerTeamId}/${itemId}/update-quantity?allow_negative=true`, + { method: "POST", accessType: "server", body: { delta } }, + ); + if (response.status !== 200) { + throw new StackAssertionError(`Failed to set item quantity`, { ownerTeamId, itemId, quantity, response }); + } + }); +} + +/** + * Polls the item quantity every 200ms until it equals `expected`, then + * returns. Throws if it doesn't get there within 8 seconds. + * + * Use this when you know the exact target value — for example, right after + * granting a plan, the quota should equal that plan's allotment once + * Bulldozer's timefold has materialised the entitlement. + */ +export async function waitForItemQuantityToReach( + ownerTeamId: string, + itemId: ItemId, + expected: number, +): Promise { + const pollIntervalMs = 200; + const timeoutMs = 8000; + const startedAt = performance.now(); + + while (true) { + const current = await getItemQuantity(ownerTeamId, itemId); + if (current === expected) return; + + if (performance.now() - startedAt > timeoutMs) { + throw new StackAssertionError(`Item quantity did not reach expected value within timeout`, { + ownerTeamId, itemId, expected, current, timeoutMs, + }); + } + + await wait(pollIntervalMs); + } +} + +/** + * Polls the item quantity every 500ms until it stops changing for 8 reads + * in a row (~4 seconds of no movement), then returns the stable value. + * Throws if no stable value is observed within 15 seconds. + * + * Use this when you DON'T know the exact target — for example, after + * `Auth.Otp.signIn()` triggers an unknown number of async logEvent debits + * (token-refresh + sign-up-rule events) and you just want them to drain + * before measuring a baseline. + * + * The 4-second stability window is deliberately conservative: a shorter + * one (we tried 1.2s) exits during brief lulls between batches of late- + * arriving sign-up-rule debits and lets ~2 extra debits land between the + * baseline read and the next test request, breaking exact-quota + * assertions. + */ +export async function waitForItemQuantityToStabilize( + ownerTeamId: string, + itemId: ItemId, +): Promise { + const pollIntervalMs = 500; + const stableForReads = 8; + const timeoutMs = 15000; + const startedAt = performance.now(); + + let last = await getItemQuantity(ownerTeamId, itemId); + let stableReads = 1; + + while (stableReads < stableForReads) { + if (performance.now() - startedAt > timeoutMs) { + throw new StackAssertionError(`Item quantity did not stabilise within timeout`, { + ownerTeamId, itemId, last, stableReads, stableForReads, timeoutMs, + }); + } + + await wait(pollIntervalMs); + const next = await getItemQuantity(ownerTeamId, itemId); + + if (next === last) { + stableReads++; + } else { + stableReads = 1; + last = next; + } + } + + return last; +} From f62f87ed72e897f18d564c63cfea2496066142c9 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Sun, 3 May 2026 19:23:57 -0700 Subject: [PATCH 37/38] fix: e2e fallback tests failing They seem to be failing because the fallback path was not exercised by the backend stackserverapp. fallback path is _withFallback(), and lets it default when there is an issue. --- apps/backend/src/stack.tsx | 13 ++++++++++++- apps/e2e/tests/helpers.ts | 17 +++++++++++++++++ apps/e2e/tests/js/cookies.test.ts | 4 ++-- apps/e2e/tests/js/inheritance.test.ts | 6 ++---- apps/e2e/tests/js/js-helpers.ts | 6 ++---- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/stack.tsx b/apps/backend/src/stack.tsx index 97ecdd370b..0fcd087b8e 100644 --- a/apps/backend/src/stack.tsx +++ b/apps/backend/src/stack.tsx @@ -2,10 +2,21 @@ import { StackServerApp } from '@stackframe/stack'; import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; export function getStackServerApp() { + // Fail fast if the backend self-URL env var is missing — without it the SDK + // would silently inherit `defaultBaseUrl` (https://api.stack-auth.com), which + // is almost never what we want for backend self-calls. + // + // We deliberately do NOT pass it as an explicit `baseUrl` to the SDK: doing + // so collapses `resolveApiUrls` to a single-element URL list, which short- + // circuits `_withFallback` (`apiUrls.length <= 1` branch). The SDK reads the + // same env var internally and additionally appends its hardcoded fallback + // URLs, which is what the e2e-fallback-tests workflow relies on so backend + // self-calls (quota debits in email-queue-step, send-test-email, analytics + // events batch, etc.) survive a primary-port outage. + getEnvVariable('NEXT_PUBLIC_STACK_API_URL'); return new StackServerApp({ projectId: 'internal', tokenStore: null, - baseUrl: getEnvVariable('NEXT_PUBLIC_STACK_API_URL'), publishableClientKey: getEnvVariable('STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY'), secretServerKey: getEnvVariable('STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY'), }); diff --git a/apps/e2e/tests/helpers.ts b/apps/e2e/tests/helpers.ts index 32a2255f79..99130e6ccb 100644 --- a/apps/e2e/tests/helpers.ts +++ b/apps/e2e/tests/helpers.ts @@ -306,6 +306,23 @@ for (const [key, value] of Object.entries(process.env)) { } export const STACK_DASHBOARD_BASE_URL = getEnvVariable("STACK_DASHBOARD_BASE_URL"); export const STACK_BACKEND_BASE_URL = getEnvVariable("STACK_BACKEND_BASE_URL"); + +/** + * The `baseUrl` to pass to SDK constructors (`StackClientApp`, `StackServerApp`, + * `StackAdminApp`) in JS-SDK e2e tests. + * + * Normally this is `STACK_BACKEND_BASE_URL` (single, explicit URL). + * + * In the e2e-fallback-tests workflow (`STACK_TEST_SDK_FALLBACK=true`) we leave + * this `undefined` so the SDK resolves the base URL from + * `NEXT_PUBLIC_STACK_API_URL` *and* appends its hardcoded fallback URL list, + * which is what the workflow exercises by running the backend only on the + * fallback port. Always thread this through to SDK constructors instead of + * hardcoding `STACK_BACKEND_BASE_URL`. + */ +export const SDK_BASE_URL: string | undefined = process.env.STACK_TEST_SDK_FALLBACK + ? undefined + : STACK_BACKEND_BASE_URL; export const STACK_INTERNAL_PROJECT_ID = getEnvVariable("STACK_INTERNAL_PROJECT_ID"); export const STACK_INTERNAL_PROJECT_CLIENT_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_CLIENT_KEY"); export const STACK_INTERNAL_PROJECT_SERVER_KEY = getEnvVariable("STACK_INTERNAL_PROJECT_SERVER_KEY"); diff --git a/apps/e2e/tests/js/cookies.test.ts b/apps/e2e/tests/js/cookies.test.ts index 6dc3781b43..22af17892f 100644 --- a/apps/e2e/tests/js/cookies.test.ts +++ b/apps/e2e/tests/js/cookies.test.ts @@ -2,7 +2,7 @@ import { StackClientApp } from "@stackframe/js"; import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes"; import { TextEncoder } from "util"; import { vi } from "vitest"; -import { STACK_BACKEND_BASE_URL } from "../helpers"; +import { SDK_BASE_URL } from "../helpers"; import { it } from "../helpers"; import { createApp } from "./js-helpers"; @@ -410,7 +410,7 @@ it("should eagerly create cross-subdomain cookie on construction when session ex // Construct a new client app (simulates page reload) // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const reloadedApp = new StackClientApp({ - baseUrl: STACK_BACKEND_BASE_URL, + baseUrl: SDK_BASE_URL, projectId: clientApp.projectId, publishableClientKey: apiKey.publishableClientKey, tokenStore: "cookie", diff --git a/apps/e2e/tests/js/inheritance.test.ts b/apps/e2e/tests/js/inheritance.test.ts index 6efa7e96f3..d91f1f8774 100644 --- a/apps/e2e/tests/js/inheritance.test.ts +++ b/apps/e2e/tests/js/inheritance.test.ts @@ -1,12 +1,10 @@ import { StackAdminApp, StackClientApp, StackServerApp } from "@stackframe/js"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; -import { STACK_BACKEND_BASE_URL, it } from "../helpers"; +import { SDK_BASE_URL, it } from "../helpers"; import { scaffoldProject } from "./js-helpers"; -// When STACK_TEST_SDK_FALLBACK is set, omit explicit baseUrl so the SDK resolves -// from NEXT_PUBLIC_STACK_API_URL and exercises its fallback logic -const sdkBaseUrl = process.env.STACK_TEST_SDK_FALLBACK ? undefined : STACK_BACKEND_BASE_URL; +const sdkBaseUrl = SDK_BASE_URL; it("StackServerApp can inherit configuration from StackClientApp", async ({ expect }) => { const { project, adminUser } = await scaffoldProject(); diff --git a/apps/e2e/tests/js/js-helpers.ts b/apps/e2e/tests/js/js-helpers.ts index 22bfc3f2cc..ddb873d477 100644 --- a/apps/e2e/tests/js/js-helpers.ts +++ b/apps/e2e/tests/js/js-helpers.ts @@ -2,15 +2,13 @@ import type { StackClientAppConstructorOptions, StackServerAppConstructorOptions import { AdminProjectCreateOptions, StackAdminApp, StackClientApp, StackServerApp } from '@stackframe/js'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; -import { STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY } from '../helpers'; +import { SDK_BASE_URL, STACK_INTERNAL_PROJECT_ADMIN_KEY, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY } from '../helpers'; const testExtraRequestHeaders = { "x-stack-disable-artificial-development-delay": "yes", }; -// When STACK_TEST_SDK_FALLBACK is set, omit explicit baseUrl so the SDK resolves -// from NEXT_PUBLIC_STACK_API_URL and exercises its fallback logic -const sdkBaseUrl = process.env.STACK_TEST_SDK_FALLBACK ? undefined : STACK_BACKEND_BASE_URL; +const sdkBaseUrl = SDK_BASE_URL; export async function scaffoldProject(body?: Omit & { displayName?: string }) { const internalApp = new StackAdminApp({ From 6b2c70a71b51f52682a14be7ff1fe6e4eac7dee5 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Sun, 3 May 2026 20:14:48 -0700 Subject: [PATCH 38/38] fix: bump windows for analytics tests --- .../api/v1/analytics-events-batch.test.ts | 21 +++++++++-- .../endpoints/api/v1/analytics-events.test.ts | 21 ++++++++--- .../sessions/current/refresh-race.test.ts | 9 +++-- .../tests/backend/payment-quota-helpers.ts | 36 +++++++++++-------- 4 files changed, 63 insertions(+), 24 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index d6f4356c54..21709284fb 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -528,8 +528,17 @@ it("accepts batch and debits event quota correctly", async ({ expect }) => { const { ownerTeamId } = await setupProjectWithPlan("free"); await Auth.Otp.signIn(); - // Drain async logEvent debits (sign-in triggers token-refresh/sign-up-rule events asynchronously) before measuring baseline. - const quantityBeforeBatch = await waitForItemQuantityToStabilize(ownerTeamId, ITEM_IDS.analyticsEvents); + // Drain async logEvent debits (sign-in triggers token-refresh/sign-up-rule + // events asynchronously) before measuring baseline. The + // `minimumElapsedMs` guards against the failure mode where stability is + // declared before the async events have had a chance to fire — without + // it the test reads e.g. 100000, declares it stable, then ~5s later the + // async events land and the post-batch read is short by 2. + const quantityBeforeBatch = await waitForItemQuantityToStabilize( + ownerTeamId, + ITEM_IDS.analyticsEvents, + { minimumElapsedMs: 5000 }, + ); const now = Date.now(); const eventCount = 3; @@ -561,7 +570,13 @@ it("rejects batch when remaining quota is less than batch size and does not debi // Drain async logEvent debits before forcing the quota down to a known // value — otherwise a trailing in-flight debit would push it negative // after we set it to 2 and break the post-condition. - await waitForItemQuantityToStabilize(ownerTeamId, ITEM_IDS.analyticsEvents); + // `minimumElapsedMs` guards against returning before the async events + // have started firing. + await waitForItemQuantityToStabilize( + ownerTeamId, + ITEM_IDS.analyticsEvents, + { minimumElapsedMs: 5000 }, + ); await setItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents, 2); const res = await uploadEventBatch({ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts index b1529c2b2b..e2e0f09d73 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events.test.ts @@ -60,12 +60,25 @@ const queryEventDataJson = async (params: { }, }); +// Defaults give 40 attempts * 500ms = ~20s of polling. +// +// The events under test are produced *asynchronously* by the sign-in path: +// `runAsynchronouslyAndWaitUntil(logEvent)` fires after the HTTP response +// returns and runs through SDK self-call → quota debit → Postgres insert → +// ClickHouse async_insert (which is server-buffered, no wait_for_async_insert). +// Under CI load this whole pipeline can take well over 10s before the row +// becomes queryable, so the previous 7.5s window was still flaking with +// "expected 0 to be greater than 0". 20s is conservative; the loop breaks +// out as soon as the row appears, so there's no cost on the happy path. +const DEFAULT_QUERY_RETRY_ATTEMPTS = 40; +const DEFAULT_QUERY_RETRY_DELAY_MS = 500; + const fetchEventDataJsonWithRetry = async ( params: { userId?: string, eventType?: string }, options: { attempts?: number, delayMs?: number } = {} ) => { - const attempts = options.attempts ?? 5; - const delayMs = options.delayMs ?? 250; + const attempts = options.attempts ?? DEFAULT_QUERY_RETRY_ATTEMPTS; + const delayMs = options.delayMs ?? DEFAULT_QUERY_RETRY_DELAY_MS; let response = await queryEventDataJson(params); for (let attempt = 0; attempt < attempts; attempt++) { @@ -87,8 +100,8 @@ const fetchEventsWithRetry = async ( params: { userId?: string, eventType?: string }, options: { attempts?: number, delayMs?: number } = {} ) => { - const attempts = options.attempts ?? 5; - const delayMs = options.delayMs ?? 250; + const attempts = options.attempts ?? DEFAULT_QUERY_RETRY_ATTEMPTS; + const delayMs = options.delayMs ?? DEFAULT_QUERY_RETRY_DELAY_MS; let response = await queryEvents(params); for (let attempt = 0; attempt < attempts; attempt++) { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts index 42192d7bfc..490ce4e354 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/refresh-race.test.ts @@ -45,7 +45,11 @@ it("does not 500 when a refresh races with a sign-out of the same session", { ti mailbox: createMailbox(`refresh-race--${randomUUID()}${generatedEmailSuffix}`), userAuth: null, }); - await Auth.Password.signUpWithEmail(); + // `noWaitForEmail`: the race test only needs a refresh token, which is + // returned by the sign-up response itself. Waiting for the verification + // email costs 5–10s per iteration on CI and pushes the test past its + // 120s timeout. + await Auth.Password.signUpWithEmail({ noWaitForEmail: true }); const rt = backendContext.value.userAuth!.refreshToken!; const refreshP = niceBackendFetch("/api/v1/auth/sessions/current/refresh", { @@ -84,7 +88,8 @@ it("does not 500 when an OAuth refresh-token grant races with a sign-out of the mailbox: createMailbox(`oauth-refresh-race--${randomUUID()}${generatedEmailSuffix}`), userAuth: null, }); - await Auth.Password.signUpWithEmail(); + // See note above on `noWaitForEmail`. + await Auth.Password.signUpWithEmail({ noWaitForEmail: true }); const rt = backendContext.value.userAuth!.refreshToken!; const projectKeys = backendContext.value.projectKeys; if (projectKeys === "no-project") throw new Error("No project keys found in the backend context"); diff --git a/apps/e2e/tests/backend/payment-quota-helpers.ts b/apps/e2e/tests/backend/payment-quota-helpers.ts index d48effcc79..346f308770 100644 --- a/apps/e2e/tests/backend/payment-quota-helpers.ts +++ b/apps/e2e/tests/backend/payment-quota-helpers.ts @@ -76,37 +76,45 @@ export async function waitForItemQuantityToReach( } /** - * Polls the item quantity every 500ms until it stops changing for 8 reads - * in a row (~4 seconds of no movement), then returns the stable value. - * Throws if no stable value is observed within 15 seconds. + * Polls the item quantity every 500ms until it stops changing for + * `stableForReads` reads in a row, then returns the stable value. Throws + * if no stable value is observed within `timeoutMs`. * * Use this when you DON'T know the exact target — for example, after * `Auth.Otp.signIn()` triggers an unknown number of async logEvent debits * (token-refresh + sign-up-rule events) and you just want them to drain * before measuring a baseline. * - * The 4-second stability window is deliberately conservative: a shorter - * one (we tried 1.2s) exits during brief lulls between batches of late- - * arriving sign-up-rule debits and lets ~2 extra debits land between the - * baseline read and the next test request, breaking exact-quota - * assertions. + * + * `options.minimumElapsedMs` (default 0) refuses to return until at least + * that much wall time has passed since the function was called, even if + * the quantity has been stable the whole time. This is useful when the + * caller knows async events should fire but hasn't seen them yet — it + * prevents the function from declaring stability before the async work + * has even started. */ export async function waitForItemQuantityToStabilize( ownerTeamId: string, itemId: ItemId, + options: { minimumElapsedMs?: number } = {}, ): Promise { const pollIntervalMs = 500; - const stableForReads = 8; - const timeoutMs = 15000; + const stableForReads = 16; + const timeoutMs = 30000; + const minimumElapsedMs = options.minimumElapsedMs ?? 0; const startedAt = performance.now(); let last = await getItemQuantity(ownerTeamId, itemId); let stableReads = 1; - while (stableReads < stableForReads) { - if (performance.now() - startedAt > timeoutMs) { + while (true) { + const elapsed = performance.now() - startedAt; + if (stableReads >= stableForReads && elapsed >= minimumElapsedMs) { + return last; + } + if (elapsed > timeoutMs) { throw new StackAssertionError(`Item quantity did not stabilise within timeout`, { - ownerTeamId, itemId, last, stableReads, stableForReads, timeoutMs, + ownerTeamId, itemId, last, stableReads, stableForReads, timeoutMs, minimumElapsedMs, }); } @@ -120,6 +128,4 @@ export async function waitForItemQuantityToStabilize( last = next; } } - - return last; }