Skip to content

Commit 107d0cc

Browse files
committed
feat: extract product ids to helper, sanitize dates returned from stripe for subscription
stripe mock gives us invalifd subscription dates which means we dont get the item. so we sanitize it to make it a valid range. In prod, this is unlikely to happen but it will serve as a guard.
1 parent 8052a2b commit 107d0cc

4 files changed

Lines changed: 164 additions & 62 deletions

File tree

apps/backend/prisma/seed.ts

Lines changed: 47 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction
1111
import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config';
1212
import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails';
1313
import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects';
14+
import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans';
1415
import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates';
1516
import { throwErr } from '@stackframe/stack-shared/dist/utils/errors';
1617
import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/utils/objects';
@@ -119,26 +120,41 @@ export async function seed() {
119120
},
120121
},
121122
products: {
122-
team_plans: {
123+
free: {
124+
productLineId: "plans",
125+
displayName: "Free",
126+
customerType: "team",
127+
serverOnly: false,
128+
stackable: false,
129+
prices: "include-by-default",
130+
includedItems: {
131+
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.free.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
132+
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.free.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
133+
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
134+
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
135+
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
136+
},
137+
},
138+
team: {
123139
productLineId: "plans",
124-
displayName: "Team Plans",
140+
displayName: "Team",
125141
customerType: "team",
126142
serverOnly: false,
127143
stackable: false,
128144
prices: {
129145
monthly: {
130146
USD: "49",
131147
interval: [1, "month"] as any,
132-
serverOnly: false
133-
}
148+
serverOnly: false,
149+
},
134150
},
135151
includedItems: {
136-
dashboard_admins: {
137-
quantity: 3,
138-
repeat: "never",
139-
expires: "when-purchase-expires"
140-
}
141-
}
152+
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.team.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
153+
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.team.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
154+
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
155+
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
156+
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
157+
},
142158
},
143159
growth: {
144160
productLineId: "plans",
@@ -150,63 +166,45 @@ export async function seed() {
150166
monthly: {
151167
USD: "299",
152168
interval: [1, "month"] as any,
153-
serverOnly: false
154-
}
169+
serverOnly: false,
170+
},
155171
},
156172
includedItems: {
157-
dashboard_admins: {
158-
quantity: 5,
159-
repeat: "never",
160-
expires: "when-purchase-expires"
161-
}
162-
}
163-
},
164-
free: {
165-
productLineId: "plans",
166-
displayName: "Free",
167-
customerType: "team",
168-
serverOnly: false,
169-
stackable: false,
170-
prices: "include-by-default",
171-
includedItems: {
172-
dashboard_admins: {
173-
quantity: 1,
174-
repeat: "never",
175-
expires: "when-purchase-expires"
176-
}
177-
}
173+
[ITEM_IDS.seats]: { quantity: PLAN_LIMITS.growth.seats, repeat: "never" as const, expires: "when-purchase-expires" as const },
174+
[ITEM_IDS.authUsers]: { quantity: PLAN_LIMITS.growth.authUsers, repeat: "never" as const, expires: "when-purchase-expires" as const },
175+
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: "never" as const, expires: "when-purchase-expires" as const },
176+
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
177+
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
178+
},
178179
},
179-
"extra-admins": {
180+
"extra-seats": {
180181
productLineId: "plans",
181-
displayName: "Extra Admins",
182+
displayName: "Extra Seats",
182183
customerType: "team",
183184
serverOnly: false,
184185
stackable: true,
185186
prices: {
186187
monthly: {
187-
USD: "49",
188+
USD: "29",
188189
interval: [1, "month"] as any,
189-
serverOnly: false
190-
}
190+
serverOnly: false,
191+
},
191192
},
192193
includedItems: {
193-
dashboard_admins: {
194-
quantity: 1,
195-
repeat: "never",
196-
expires: "when-purchase-expires"
197-
}
194+
[ITEM_IDS.seats]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const },
198195
},
199196
isAddOnTo: {
200197
team: true,
201198
growth: true,
202-
}
203-
}
199+
},
200+
},
204201
},
205202
items: {
206-
dashboard_admins: {
207-
displayName: "Dashboard Admins",
208-
customerType: "team"
209-
}
203+
[ITEM_IDS.seats]: { displayName: "Dashboard Admins", customerType: "team" as const },
204+
[ITEM_IDS.authUsers]: { displayName: "Auth Users", customerType: "team" as const },
205+
[ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const },
206+
[ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const },
207+
[ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const },
210208
},
211209
},
212210
apps: {

apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1+
import { SubscriptionStatus } from "@/generated/prisma/client";
12
import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments";
2-
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
3-
import { getStripeForAccount } from "@/lib/stripe";
3+
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
44
import { getPrismaClientForTenancy } from "@/prisma-client";
55
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
66
import { KnownErrors } from "@stackframe/stack-shared";
77
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
88
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
99
import { getOrUndefined, typedEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
1010
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
11-
import { SubscriptionStatus } from "@/generated/prisma/client";
1211
import Stripe from "stripe";
1312

1413

@@ -200,6 +199,7 @@ export const POST = createSmartRouteHandler({
200199
},
201200
});
202201
const updatedSubscription = updated as Stripe.Subscription;
202+
const sanitizedUpdateDates = sanitizeStripePeriodDates(existingItem.current_period_start, existingItem.current_period_end);
203203

204204
await prisma.subscription.update({
205205
where: {
@@ -214,8 +214,8 @@ export const POST = createSmartRouteHandler({
214214
priceId: selectedPriceId,
215215
quantity,
216216
status: updatedSubscription.status,
217-
currentPeriodStart: new Date(existingItem.current_period_start * 1000),
218-
currentPeriodEnd: new Date(existingItem.current_period_end * 1000),
217+
currentPeriodStart: sanitizedUpdateDates.start,
218+
currentPeriodEnd: sanitizedUpdateDates.end,
219219
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
220220
},
221221
});
@@ -248,6 +248,7 @@ export const POST = createSmartRouteHandler({
248248
throw new StackAssertionError("Stripe subscription has no items", { stripeSubscriptionId: createdSubscription.id });
249249
}
250250
const createdItem = createdSubscription.items.data[0];
251+
const sanitizedCreateDates = sanitizeStripePeriodDates(createdItem.current_period_start, createdItem.current_period_end);
251252

252253
await prisma.subscription.create({
253254
data: {
@@ -260,8 +261,8 @@ export const POST = createSmartRouteHandler({
260261
quantity,
261262
stripeSubscriptionId: createdSubscription.id,
262263
status: createdSubscription.status,
263-
currentPeriodStart: new Date(createdItem.current_period_start * 1000),
264-
currentPeriodEnd: new Date(createdItem.current_period_end * 1000),
264+
currentPeriodStart: sanitizedCreateDates.start,
265+
currentPeriodEnd: sanitizedCreateDates.end,
265266
cancelAtPeriodEnd: createdSubscription.cancel_at_period_end,
266267
creationSource: "PURCHASE_PAGE",
267268
},

apps/backend/src/lib/stripe.tsx

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
import { CustomerType } from "@/generated/prisma/client";
12
import { getTenancy, Tenancy } from "@/lib/tenancies";
23
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
3-
import { CustomerType } from "@/generated/prisma/client";
4+
import { InputJsonValue } from "@prisma/client/runtime/client";
45
import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays";
56
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
67
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
78
import Stripe from "stripe";
89
import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy";
9-
import { InputJsonValue } from "@prisma/client/runtime/client";
1010

1111
const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", "");
1212
const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment());
@@ -17,6 +17,43 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? {
1717
port: Number(`${stackPortPrefix}23`),
1818
} : {};
1919

20+
/**
21+
* Sanitizes subscription period dates from Stripe.
22+
*
23+
* The Stripe mock returns hardcoded fixture dates that are invalid (e.g., start in 2030, end in 2000).
24+
* This function detects invalid dates and replaces them with sensible defaults.
25+
*
26+
* @param startTimestamp - Unix timestamp in seconds for period start
27+
* @param endTimestamp - Unix timestamp in seconds for period end
28+
* @param intervalMonths - Billing interval in months (default: 1)
29+
* @returns Sanitized Date objects for start and end
30+
*/
31+
export function sanitizeStripePeriodDates(
32+
startTimestamp: number,
33+
endTimestamp: number,
34+
intervalMonths: number = 1
35+
): { start: Date, end: Date } {
36+
const now = new Date();
37+
const startDate = new Date(startTimestamp * 1000);
38+
const endDate = new Date(endTimestamp * 1000);
39+
40+
const tenYearsMs = 10 * 365 * 24 * 60 * 60 * 1000;
41+
const isStartValid = startDate.getTime() > 0 && Math.abs(startDate.getTime() - now.getTime()) < tenYearsMs;
42+
const isEndValid = endDate.getTime() > 0 && Math.abs(endDate.getTime() - now.getTime()) < tenYearsMs;
43+
const isOrderValid = startDate < endDate;
44+
45+
if (isStartValid && isEndValid && isOrderValid) {
46+
return { start: startDate, end: endDate };
47+
}
48+
49+
// Dates are invalid (likely from Stripe mock), use sensible defaults
50+
const defaultStart = now;
51+
const defaultEnd = new Date(now);
52+
defaultEnd.setMonth(defaultEnd.getMonth() + intervalMonths);
53+
54+
return { start: defaultStart, end: defaultEnd };
55+
}
56+
2057
export const getStackStripe = (overrides?: StripeOverridesMap) => {
2158
if (!stripeSecretKey) {
2259
throw new StackAssertionError("STACK_STRIPE_SECRET_KEY environment variable is not set");
@@ -92,6 +129,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
92129
continue;
93130
}
94131
const item = subscription.items.data[0];
132+
const sanitizedDates = sanitizeStripePeriodDates(item.current_period_start, item.current_period_end);
95133
const priceId = subscription.metadata.priceId as string | undefined;
96134
// old subscriptions were created with offer metadata instead of product metadata
97135
const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined;
@@ -116,8 +154,8 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
116154
status: subscription.status,
117155
product: productJson,
118156
quantity: item.quantity ?? 1,
119-
currentPeriodEnd: new Date(item.current_period_end * 1000),
120-
currentPeriodStart: new Date(item.current_period_start * 1000),
157+
currentPeriodEnd: sanitizedDates.end,
158+
currentPeriodStart: sanitizedDates.start,
121159
cancelAtPeriodEnd: subscription.cancel_at_period_end,
122160
priceId: priceId ?? null,
123161
},
@@ -131,8 +169,8 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
131169
quantity: item.quantity ?? 1,
132170
stripeSubscriptionId: subscription.id,
133171
status: subscription.status,
134-
currentPeriodEnd: new Date(item.current_period_end * 1000),
135-
currentPeriodStart: new Date(item.current_period_start * 1000),
172+
currentPeriodEnd: sanitizedDates.end,
173+
currentPeriodStart: sanitizedDates.start,
136174
cancelAtPeriodEnd: subscription.cancel_at_period_end,
137175
creationSource: "PURCHASE_PAGE"
138176
},

packages/stack-shared/src/plans.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Plan configuration for Stack Auth pricing tiers.
3+
*
4+
* This file defines the limits for each plan and the item IDs used to track them.
5+
* Import these constants in seed.ts and backend code for limit enforcement.
6+
*/
7+
8+
export const UNLIMITED = 1_000_000_000;
9+
10+
/**
11+
* Item IDs used across the codebase for tracking plan limits.
12+
*/
13+
export const ITEM_IDS = {
14+
seats: "dashboard_admins",
15+
authUsers: "auth_users",
16+
emailsPerMonth: "emails_per_month",
17+
analyticsTimeoutSeconds: "analytics_timeout_seconds",
18+
analyticsEvents: "analytics_events",
19+
} as const;
20+
21+
export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS];
22+
23+
/**
24+
* The offerings/limits included in a plan.
25+
*/
26+
export type PlanProductOfferings = {
27+
seats: number,
28+
authUsers: number,
29+
emailsPerMonth: number,
30+
analyticsTimeoutSeconds: number,
31+
analyticsEvents: number,
32+
};
33+
34+
/**
35+
* Plan limits by plan ID.
36+
*/
37+
export const PLAN_LIMITS: {
38+
free: PlanProductOfferings,
39+
team: PlanProductOfferings,
40+
growth: PlanProductOfferings,
41+
} = {
42+
free: {
43+
seats: 1,
44+
authUsers: 10_000,
45+
emailsPerMonth: 1_000,
46+
analyticsTimeoutSeconds: 10,
47+
analyticsEvents: 100_000,
48+
},
49+
team: {
50+
seats: 4,
51+
authUsers: 50_000,
52+
emailsPerMonth: 25_000,
53+
analyticsTimeoutSeconds: 60,
54+
analyticsEvents: 500_000,
55+
},
56+
growth: {
57+
seats: UNLIMITED,
58+
authUsers: UNLIMITED,
59+
emailsPerMonth: 25_000,
60+
analyticsTimeoutSeconds: 300,
61+
analyticsEvents: 1_000_000,
62+
},
63+
};
64+
65+
export type PlanId = keyof typeof PLAN_LIMITS;

0 commit comments

Comments
 (0)