Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
6a7afd1
feat: extract product ids to helper, sanitize dates returned from str…
nams1570 Feb 17, 2026
71b3666
refactor/fix: Move product info from stripe webhook to separate table
nams1570 Feb 18, 2026
763094e
WIP: subscription related bugs.
nams1570 Feb 19, 2026
ab776d3
Merge branch 'dev' into payment-subscription-handling-rework
nams1570 Mar 2, 2026
ce4777f
feat(plans): add team-wide billing entitlement helpers
nams1570 Mar 2, 2026
e222272
feat(auth-limits): log non-anonymous signup overages without blocking
nams1570 Mar 3, 2026
162a65e
feat(email-limits): enforce hard cap when sending
nams1570 Mar 3, 2026
6a48fde
feat: add basic UI for purchasing additional seats
nams1570 Mar 3, 2026
e527e6c
feat(analytics): query timeout limits based on plan
nams1570 Mar 3, 2026
481dffc
feat(events): add hard cap to event write
nams1570 Mar 9, 2026
5da1de3
fix: typing issue on plan limits
nams1570 Mar 9, 2026
b0dd832
feat(sesh_rep): add limits to session replays
nams1570 Mar 9, 2026
d7081a8
fix: events must refresh monthly
nams1570 Mar 9, 2026
bd87a35
feat: add onboarding call to plan
nams1570 Mar 9, 2026
b82707d
fix: better error states for limit related ui components
nams1570 Mar 10, 2026
789c931
fix: increase robustness of analytics tests
nams1570 Mar 10, 2026
d736b75
refactor: cleaner typing in configs
nams1570 Mar 10, 2026
6dd4b23
feat: switch to debit first approach for item consumption
nams1570 Mar 10, 2026
5dbf3cd
refactor: clean up error logging to reduce noise
nams1570 Mar 10, 2026
736b775
fix: minor banner movement
nams1570 Mar 10, 2026
1926304
fix(tests): avoid shared singleton mutation
nams1570 Mar 10, 2026
e91c6b4
refactor: cleaner error messages on UI
nams1570 Mar 10, 2026
50d5c10
Merge branch 'dev' into payment-subscription-handling-rework
nams1570 Apr 17, 2026
48a0a72
refactor/fix: entitlement tests, give internal growth plan
nams1570 Apr 20, 2026
5a06425
Merge branch 'dev' into payment-subscription-handling-rework
nams1570 Apr 20, 2026
2a842d9
fix: lint, typecheck failures
nams1570 Apr 20, 2026
ad252a0
feat: test emails also use up quota
nams1570 Apr 20, 2026
725a183
feat(payments): regrant free plan when a paid sub ends
nams1570 Apr 21, 2026
969ffc4
fix: stackable subscriptions in same product line are now cumulative
nams1570 Apr 21, 2026
9e8186b
perf(events): require callers to pass billingTeamId to logEvent
nams1570 Apr 21, 2026
519458a
fix(analytics): reject queries when analytics_timeout_seconds is 0
nams1570 Apr 21, 2026
97a93f0
fix: update seq id on quota exhaustion
nams1570 Apr 21, 2026
573773f
fix: make free plan regrant idempotent
nams1570 Apr 21, 2026
bea92fe
chore: type changes, refund test email on fail
nams1570 Apr 21, 2026
c56b6af
chore: DRY for plan stuff
nams1570 Apr 21, 2026
d9be56a
refactor: guard against manual retry bypass
nams1570 Apr 21, 2026
a4695e8
chore: fix tests
nams1570 Apr 21, 2026
beb273d
refactor: reorg free plan regrant to be less racy
nams1570 Apr 22, 2026
7e6492c
Merge branch 'dev' into payment-subscription-handling-rework
mantrakp04 Apr 23, 2026
d4390f2
Merge branch 'dev' into payment-subscription-handling-rework
mantrakp04 Apr 24, 2026
b69783b
fix: switch to polling in tests
nams1570 May 4, 2026
f62f87e
fix: e2e fallback tests failing
nams1570 May 4, 2026
95e13e3
Merge branch 'dev' into payment-subscription-handling-rework
nams1570 May 4, 2026
6b2c70a
fix: bump windows for analytics tests
nams1570 May 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 74 additions & 9 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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: {
Expand All @@ -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 },
Comment thread
nams1570 marked this conversation as resolved.
},
},
growth: {
Expand All @@ -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 },
Comment thread
nams1570 marked this conversation as resolved.
},
},
"extra-seats": {
Expand All @@ -215,7 +224,7 @@ export async function seed() {
prices: {
monthly: {
USD: "29",
interval: [1, "month"] as any,
interval: MONTHLY_REPEAT,
serverOnly: false,
},
},
Expand All @@ -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: {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions apps/backend/src/app/api/latest/analytics/events/batch/route.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
}
Comment thread
nams1570 marked this conversation as resolved.
Comment thread
nams1570 marked this conversation as resolved.
}
Comment thread
nams1570 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const prisma = await getPrismaClientForTenancy(auth.tenancy);
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] } => {
Expand Down
24 changes: 22 additions & 2 deletions apps/backend/src/app/api/latest/internal/analytics/query/route.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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);
}
Comment thread
nams1570 marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const client = getClickhouseExternalClient();
const queryId = `${auth.tenancy.project.id}:${auth.tenancy.branchId}:${randomUUID()}`;
const resultSet = await Result.fromPromise(client.query({
Expand All @@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions apps/backend/src/app/api/latest/internal/send-test-email/route.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return {
statusCode: 200,
bodyType: 'json',
Expand Down
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading