diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 6081d98af0..bfc16d617b 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,9 +16,12 @@ 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'; +const MONTHLY_REPEAT: DayInterval = [1, "month"]; + const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063'; let didEnableSeedLogTimestamps = false; @@ -159,9 +163,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: "never" as const, expires: "when-purchase-expires" 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: "never" as const, expires: "when-purchase-expires" 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: { @@ -173,16 +178,18 @@ 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: "never" as const, expires: "when-purchase-expires" 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: "never" as const, expires: "when-purchase-expires" 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 }, }, }, growth: { @@ -194,16 +201,18 @@ 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: "never" as const, expires: "when-purchase-expires" 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: "never" as const, expires: "when-purchase-expires" 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 }, }, }, "extra-seats": { @@ -215,7 +224,7 @@ export async function seed() { prices: { monthly: { USD: "29", - interval: [1, "month"] as any, + interval: MONTHLY_REPEAT, serverOnly: false, }, }, @@ -234,6 +243,8 @@ 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 }, + [ITEM_IDS.onboardingCall]: { displayName: "Onboarding Call", customerType: "team" as const }, }, }, apps: { @@ -292,6 +303,60 @@ 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; + // 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, + 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, + }, + }); + 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/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index d1403c87fc..aef41a2ecf 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"; @@ -83,6 +86,17 @@ 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 }); + const isDebited = await eventsItem.tryDecreaseQuantity(body.events.length); + if (!isDebited) { + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, body.events.length); + } + } + const prisma = await getPrismaClientForTenancy(auth.tenancy); const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId }); 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/internal/analytics/query/route.ts b/apps/backend/src/app/api/latest/internal/analytics/query/route.ts index e204bd0e2b..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 @@ -1,13 +1,16 @@ import { getClickhouseExternalClient } from "@/lib/clickhouse"; import { getSafeClickhouseErrorMessage } from "@/lib/clickhouse-errors"; +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 { 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,23 @@ 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 }); + // 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); + } + const client = getClickhouseExternalClient(); const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`; const resultSet = await Result.fromPromise(client.query({ @@ -45,7 +65,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/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..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 @@ -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,24 @@ 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). + // The debit is refunded on any failure below so admins iterating on an + // incorrect SMTP config don't burn through their monthly quota. + const billingTeamId = getBillingTeamId(auth.tenancy.project); + const emailItem = billingTeamId == null + ? 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); + } + } + const resultOuter = await timeout(lowLevelSendEmailDirectWithoutRetries({ tenancyId: auth.tenancy.id, emailConfig: { @@ -78,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/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/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/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index db417b7af5..fe02e16c22 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,18 @@ 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 }); + const isDebited = await replaysItem.tryDecreaseQuantity(1); + if (!isDebited) { + throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.sessionReplays, billingTeamId, 1); + } + } + const replayId = recentSession?.id ?? randomUUID(); const s3Key = `session-replays/${projectId}/${branchId}/${replayId}/${batchId}.json.gz`; 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/app/api/latest/users/crud.tsx b/apps/backend/src/app/api/latest/users/crud.tsx index 6b28e88840..689e60ea74 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, recordExternalDbSyncNotificationPreferenceDeletionsForUser, recordExternalDbSyncOAuthAccountDeletionsForUser, recordExternalDbSyncProjectPermissionDeletionsForUser, recordExternalDbSyncRefreshTokenDeletionsForUser, recordExternalDbSyncTeamMemberDeletionsForUser, recordExternalDbSyncTeamPermissionDeletionsForUser, withExternalDbSyncUpdate } from "@/lib/external-db-sync"; import { grantDefaultProjectPermissions } from "@/lib/permissions"; import { ensureTeamMembershipExists, ensureUserExists } from "@/lib/request-checks"; @@ -20,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"; @@ -257,6 +259,29 @@ 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 (getEnvVariable('STACK_SEED_MODE', '') === 'true') { + return; + } + 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"], @@ -792,6 +817,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, @@ -803,7 +832,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; @@ -1120,8 +1149,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: { @@ -1197,9 +1226,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 recordExternalDbSyncRefreshTokenDeletionsForUser(globalPrismaClient, { diff --git a/apps/backend/src/lib/email-queue-step.tsx b/apps/backend/src/lib/email-queue-step.tsx index 1fd52c9f5f..e97e5f46db 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, getEmailCapacityBoostExpiresAt, getEmailDelivery import { getEmailThemeForThemeId, renderEmailsForTenancyBatched } from "@/lib/email-rendering"; import { EmailOutboxRecipient, getEmailConfig, } from "@/lib/emails"; import { generateUnsubscribeLink, getNotificationCategoryById, hasNotificationEnabled, listNotificationCategories } from "@/lib/notification-categories"; +import { getBillingTeamId } from "@/lib/plan-entitlements"; +import { 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 { allPromisesAndWaitUntilEach } from "@/utils/background-tasks"; @@ -604,6 +607,7 @@ type TenancyProcessingContext = { tenancy: Tenancy, prisma: Awaited>, emailConfig: Awaited>, + billingTeamId: string | null, }; async function processTenancyBatch(batch: TenancySendBatch): Promise { @@ -611,11 +615,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)); @@ -687,6 +693,48 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO } } + if (context.billingTeamId != null && row.sendRetries === 0) { + const app = getStackServerApp(); + const emailItem = await app.getItem({ itemId: ITEM_IDS.emailsPerMonth, teamId: context.billingTeamId }); + 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."; + // 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: { + tenancyId: row.tenancyId, + id: row.id, + }, + finishedSendingAt: null, + }, + data: { + finishedSendingAt: new Date(), + canHaveDeliveryInfo: false, + sendServerErrorExternalMessage: errorMessage, + sendServerErrorExternalDetails: { errorType: "monthly-email-limit-exceeded" }, + sendServerErrorInternalMessage: errorMessage, + sendServerErrorInternalDetails: { + errorType: "monthly-email-limit-exceeded", + remainingQuota: emailItem.quantity, + billingTeamId: context.billingTeamId, + }, + shouldUpdateSequenceId: true, + }, + }); + 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({ @@ -796,6 +844,7 @@ async function processSingleEmail(context: TenancyProcessingContext, row: EmailO shouldUpdateSequenceId: true, }, }); + } } 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 77fb19606a..2f59b56d0b 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -1,6 +1,8 @@ import withPostHog from "@/analytics"; import { globalPrismaClient } from "@/prisma-client"; +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 { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -195,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 }; @@ -264,6 +274,17 @@ export async function logEvent( // rest is no more dynamic APIs so we can run it asynchronously runAsynchronouslyAndWaitUntil((async () => { + const billingTeamId = options.billingTeamId; + + if (billingTeamId != null) { + const app = getStackServerApp(); + const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId }); + const isDebited = await eventsItem.tryDecreaseQuantity(1); + if (!isDebited) { + return; + } + } + // log event in DB await globalPrismaClient.event.create({ data: { diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index f82a0f30a3..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"]; /** @@ -353,12 +366,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 +390,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/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 new file mode 100644 index 0000000000..584338ee3c --- /dev/null +++ b/apps/backend/src/lib/payments/ensure-free-plan.ts @@ -0,0 +1,226 @@ +import { CustomerType, PrismaClient, PurchaseCreationSource, Subscription, SubscriptionStatus } from "@/generated/prisma/client"; +import { isAddOnProduct } from "@/lib/payments"; +import { bulldozerWriteSubscription } from "@/lib/payments/bulldozer-dual-write"; +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"; +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; +} + +/** + * 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. + * + * `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: PrismaClient | 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, when a plan is already owned, or when a + * concurrent caller already established the free sub. + * + * Two-phase concurrency story: + * + * 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 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. + * + * 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(); + 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); + + // 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, + }); + if (Object.values(subscriptionMap).some(productLineStillOccupiedBy)) { + return; + } + + // 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 unendedSubs = await tx.subscription.findMany({ + where: { + tenancyId: internalTenancy.id, + customerId: billingTeamId, + customerType: CustomerType.TEAM, + OR: [{ endedAt: null }, { endedAt: { gt: now } }], + }, + select: { product: true, endedAt: true }, + }); + const existing = unendedSubs.some((sub) => + productLineStillOccupiedBy({ + product: sub.product as ProductSnapshot, + endedAt: sub.endedAt, + }), + ); + if (existing) { + 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); + } +} 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..7377720b26 --- /dev/null +++ b/apps/backend/src/lib/plan-entitlements.test.ts @@ -0,0 +1,188 @@ +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, + getTeamWideItemCapacityForTests, + getTeamWideNonAnonymousUserCount, +} from "./plan-entitlements"; + +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("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"); + }); +}); + +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 itemLimits = new Map([ + [ITEM_IDS.authUsers, PLAN_LIMITS.free.authUsers], + [ITEM_IDS.seats, PLAN_LIMITS.free.seats], + ]); + + const capacityReaders = { + getPrismaForTenancy: async (): Promise => ({} as PrismaClientTransaction), + getItemQuantityForCustomer: async (options: { + prisma: unknown, + tenancyId: string, + 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", async () => { + const capacity = await getTeamWideItemCapacityForTests( + billingTeamId, + 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, + 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 seats capacity helper to seats plan item", async () => { + const seatsCapacity = await getTeamWideItemCapacityForTests( + billingTeamId, + ITEM_IDS.seats, + capacityReaders, + ); + expect(seatsCapacity).toBe(PLAN_LIMITS.free.seats); + }); + + it("throws on unknown item id", async () => { + await expect(getTeamWideItemCapacityForTests( + billingTeamId, + "unknown_item", + 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 new file mode 100644 index 0000000000..1a8fc2d955 --- /dev/null +++ b/apps/backend/src/lib/plan-entitlements.ts @@ -0,0 +1,161 @@ +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"; +import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch, 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, + tenancyId: string, + customerId: string, + customerType: "team", + itemId: string, + }) => Promise, +}; + +const TEAM_WIDE_CAPACITY_ITEM_IDS = new Set([ + ITEM_IDS.authUsers, + ITEM_IDS.seats, +]); + +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 tenancy; +} + +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( + 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. + if (!TEAM_WIDE_CAPACITY_ITEM_IDS.has(itemId)) { + throw new StackAssertionError("Unsupported team-wide capacity item id", { itemId }); + } + const internalBillingTenancy = await getInternalBillingTenancy(); + const billingPrisma = await readers.getPrismaForTenancy(internalBillingTenancy); + return await readers.getItemQuantityForCustomer({ + prisma: billingPrisma, + tenancyId: internalBillingTenancy.id, + customerId: billingTeamId, + customerType: "team", + itemId, + }); +} + +export async function getTeamWideItemCapacityForTests( + billingTeamId: string, + itemId: string, + readers: ItemCapacityReaders, +): Promise { + return await getTeamWideItemCapacity(billingTeamId, itemId, readers); +} + +export async function getTeamWideAuthUsersCapacity( + billingTeamId: string, +): Promise { + return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.authUsers); +} + +export async function getTeamWideDashboardAdminsCapacity( + billingTeamId: string, +): Promise { + return await getTeamWideItemCapacity(billingTeamId, ITEM_IDS.seats); +} + +export async function getTeamWideAuthUsersCapacityForProjectTenancy( + projectTenancy: Tenancy, +): Promise { + 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); +} 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/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 7a574defc1..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"; @@ -186,7 +187,6 @@ import.meta.vitest?.describe("resolveProductFromStripeMetadata", (test) => { }); }); }); - export const getStackStripe = (overrides?: StripeOverridesMap) => { if (!stripeSecretKey) { throw new StackAssertionError("STACK_STRIPE_SECRET_KEY environment variable is not set"); @@ -236,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 {}; + } + // 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) }; } - if (subscription.status === "canceled" && sanitizedEnd <= new Date()) { + //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 } | {} { @@ -332,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/backend/src/lib/tokens.tsx b/apps/backend/src/lib/tokens.tsx index c7810bce00..f834513f51 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 [^ ]+$/); @@ -275,6 +276,11 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres }); if (projectUserUpdate.count === 0) return null; + // 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], @@ -285,6 +291,9 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres sessionId: options.refreshTokenObj.id, isAnonymous: user.is_anonymous, teamId: undefined, + }, + { + billingTeamId, } ); @@ -302,6 +311,7 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres }, { refreshTokenId: options.refreshTokenObj.id, + billingTeamId, } ); 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/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 9afd65da81..b8e3cdbfa1 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"; @@ -417,18 +418,30 @@ 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) { + setInvitationsError("Failed to load invitations. Please try again."); + } }, [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) { + setInvitationsError("Failed to load invitations. Please try again."); + } } }); return () => { @@ -438,16 +451,19 @@ function TeamAddUserDialogContent(props: { const users = props.team.useUsers(); const admins = props.team.useItem("dashboard_admins"); + const products = props.team.useProducts(); + const hasPaidPlan = isPaidPlan(products); 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; } @@ -468,17 +484,20 @@ function TeamAddUserDialogContent(props: { } }; + const handleAddSeat = async () => { + 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 ( @@ -486,13 +505,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."} )}
@@ -506,7 +531,7 @@ function TeamAddUserDialogContent(props: { }} placeholder="Email" type="email" - disabled={atCapacity} + disabled={(!invitationsLoaded && !invitationsError) || atCapacity} autoFocus /> {formError && ( @@ -518,7 +543,20 @@ function TeamAddUserDialogContent(props: {
Pending invitations - {invitations?.length === 0 ? ( + {invitationsError ? ( +
+ + {invitationsError} + + +
+ ) : invitations?.length === 0 ? ( None ) : (
@@ -555,11 +593,17 @@ function TeamAddUserDialogContent(props: { Close {atCapacity ? ( - + hasPaidPlan ? ( + + ) : ( + + ) ) : ( - )} 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 0005b09ae1..c8ae360c63 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 { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; import { useAdminApp } from "../../use-admin-app"; +import { SessionReplayLimitBanner } from "../shared"; import { ALLOWED_PLAYER_SPEEDS, createInitialState, @@ -1449,7 +1450,8 @@ export default function PageClient({ initialReplayId }: PageClientProps) { ) : undefined} fillWidth > - + + {!isStandaloneReplayPage && ( <> 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..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 @@ -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, 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"; +import { useAdminApp } from "../use-admin-app"; // ============================================================================ // Types @@ -316,3 +320,129 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () =
); } + +/** + * 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 ; +} + +/** + * 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 = Math.max(0, totalAllocation - replaysItem.quantity); + const usagePercent = totalAllocation > 0 ? Math.min(100, (used / totalAllocation) * 100) : 0; + + if (usagePercent < 80) { + return null; + } + + const isExhausted = replaysItem.quantity <= 0; + + return ( + svg]:text-amber-500"} + > + + + {isExhausted + ? "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." + } + + + ); +} + +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 = Math.max(0, totalAllocation - eventsItem.quantity); + const usagePercent = totalAllocation > 0 ? Math.min(100, (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 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."} + + {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 b91f79b4a9..6150348132 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 @@ -8,6 +8,7 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { useCallback, useState } from "react"; import { AppEnabledGuard } from "../../app-enabled-guard"; import { PageLayout } from "../../page-layout"; +import { AnalyticsEventLimitBanner } from "../shared"; import { AiQueryBar } from "./ai-query-bar"; import { AiQueryDialog } from "./ai-query-dialog"; import { @@ -229,6 +230,7 @@ export default function PageClient() { return ( +
{/* Left sidebar — hidden on mobile */}
(fn: () => Promise): Promise { + return await backendContext.with({ projectKeys: InternalProjectKeys, userAuth: null }, fn); +} + 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 c47f9a6f59..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 @@ -1,7 +1,15 @@ -import { randomUUID } from "node:crypto"; +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 } from "../../../backend-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, @@ -473,3 +481,132 @@ 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") { + 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 StackAssertionError(`Failed to grant plan '${planId}' to team '${ownerTeamId}'`, { response: grantResponse }); + } + }); + } + await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsEvents, PLAN_LIMITS[planId].analyticsEvents); + return { ownerTeamId }; +} + +it("rejects batch when analytics event quota is exhausted", async ({ expect }) => { + const { ownerTeamId } = await setupProjectWithPlan("free"); + await Auth.Otp.signIn(); + + await setItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents, 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(); + + // 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; + 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 getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents); + expect(afterQuantity).toBe(quantityBeforeBatch - eventCount); +}); + +// 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(); + + // 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. + // `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({ + sessionReplaySegmentId: randomUUID(), + batchId: randomUUID(), + sentAtMs: Date.now(), + 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 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 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 getItemQuantity(ownerTeamId, ITEM_IDS.analyticsEvents); + expect(quantity).toBe(PLAN_LIMITS.team.analyticsEvents); +}); 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/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 7776140d4b..f5e5c20a75 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,10 @@ +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 } from "../../../backend-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 } }); @@ -15,6 +18,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 } = await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + const ownerTeamId = createProjectResponse.body.owner_team_id; + + if (planId !== "free") { + 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 StackAssertionError(`Failed to grant plan '${planId}' to team '${ownerTeamId}'`, { response: grantResponse }); + } + }); + await waitForItemQuantityToReach(ownerTeamId, ITEM_IDS.analyticsTimeoutSeconds, PLAN_LIMITS[planId].analyticsTimeoutSeconds); + } + + 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, }; @@ -75,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); @@ -100,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 } }); @@ -154,10 +184,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 +199,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 { @@ -1760,6 +1791,92 @@ it("does not leak column names from restricted tables via unknown identifier (co `); }); +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("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); + }); + // 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", + 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)", 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/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..53093eb30e --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/send-test-email.test.ts @@ -0,0 +1,86 @@ +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("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. 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: { + recipient_email: "test@example.com", + email_config: dummyEmailConfig, + }, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(false); + + const after = await getEmailItemQuantity(ownerTeamId); + expect(after).toBe(before); + }); +}); 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(); 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 e3ab55637a..02d2b3c78c 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, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch, withInternalProject } from "../../../backend-helpers"; async function uploadBatch(options: { browserSessionId: string, @@ -1516,3 +1517,139 @@ it("admin list session replays rejects invalid filter parameters", async ({ expe } `); }); + +// ============================================================================ +// Session replay limit enforcement tests +// ============================================================================ + +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, 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; + + 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); + + // 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); +}); 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"); +}); 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..346f308770 --- /dev/null +++ b/apps/e2e/tests/backend/payment-quota-helpers.ts @@ -0,0 +1,131 @@ +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 + * `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. + * + * + * `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 = 16; + const timeoutMs = 30000; + const minimumElapsedMs = options.minimumElapsedMs ?? 0; + const startedAt = performance.now(); + + let last = await getItemQuantity(ownerTeamId, itemId); + let stableReads = 1; + + 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, minimumElapsedMs, + }); + } + + await wait(pollIntervalMs); + const next = await getItemQuantity(ownerTeamId, itemId); + + if (next === last) { + stableReads++; + } else { + stableReads = 1; + last = next; + } + } +} 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/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", 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({ 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; /** diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts index d737dce740..b270a39c1b 100644 --- a/packages/stack-shared/src/plans.ts +++ b/packages/stack-shared/src/plans.ts @@ -16,6 +16,8 @@ export const ITEM_IDS = { emailsPerMonth: "emails_per_month", 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]; @@ -29,6 +31,7 @@ export type PlanProductOfferings = { emailsPerMonth: number, analyticsTimeoutSeconds: number, analyticsEvents: number, + sessionReplays: number, }; /** @@ -45,6 +48,7 @@ export const PLAN_LIMITS: { emailsPerMonth: 1_000, analyticsTimeoutSeconds: 10, analyticsEvents: 100_000, + sessionReplays: 2_500, }, team: { seats: 4, @@ -52,14 +56,52 @@ export const PLAN_LIMITS: { emailsPerMonth: 25_000, analyticsTimeoutSeconds: 60, analyticsEvents: 500_000, + sessionReplays: 2_500, }, growth: { - seats: UNLIMITED, + seats: 4, authUsers: UNLIMITED, emailsPerMonth: 25_000, analyticsTimeoutSeconds: 300, analyticsEvents: 1_000_000, + sessionReplays: 2_500, }, }; 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[]; + +/** + * 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"; +} 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 02a27b8e41..5a090c4b4b 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, MetricsUserCounts } from "@stackframe/stack-shared/dist/interface/admin-metrics"; @@ -576,14 +576,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);