Skip to content

Commit e9886bc

Browse files
authored
[Fix] [Refactor] Implement Base Settings for Stack-Auth Plans and Move Metadata from Stripe Webhook Event to Table (#1214)
### Context We're looking at implementing plan pricing. While doing so, we encountered a problem with Stripe. **Problem:** when we run a stripe operation (purchase), the product info is encoded as part of the stripe metadata request. Stripe encodes metadata as key-value pairs, and the [value has a limit of 500 chars](https://docs.stripe.com/metadata#data). We do this because once we run the stripe operation, stripe fires a webhook event which is caught by our stripe webhook handler syncStripeSubscriptions. This gets the stripe metadata info from the event and then updates our db in prisma. ### Summary of Changes We add a `ProductVersion` table and only pass the `productVersionId` via stripe metadata instead of the whole product json. This `productVersionId` is created by hashing the `productJson`. Since the same product may be ordered differently without being intrinsically different, we add a helper function for ensuring a canonical order to the json. We also pass tenancy id and product id to the table. Since there are existing subscriptions which used to pass the productJson via metadata, we ensure backwards compatibility.
1 parent 975f0e7 commit e9886bc

9 files changed

Lines changed: 552 additions & 87 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- CreateTable
2+
CREATE TABLE "ProductVersion" (
3+
"tenancyId" UUID NOT NULL,
4+
"productVersionId" TEXT NOT NULL,
5+
"productId" TEXT,
6+
"productJson" JSONB NOT NULL,
7+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8+
9+
CONSTRAINT "ProductVersion_pkey" PRIMARY KEY ("tenancyId","productVersionId")
10+
);
11+

apps/backend/prisma/schema.prisma

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,16 @@ model Subscription {
10731073
@@unique([tenancyId, stripeSubscriptionId])
10741074
}
10751075

1076+
model ProductVersion {
1077+
tenancyId String @db.Uuid
1078+
productVersionId String
1079+
productId String?
1080+
productJson Json
1081+
createdAt DateTime @default(now())
1082+
1083+
@@id([tenancyId, productVersionId])
1084+
}
1085+
10761086
model ItemQuantityChange {
10771087
id String @default(uuid()) @db.Uuid
10781088
tenancyId String @db.Uuid

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/integrations/stripe/webhooks/route.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails";
22
import { listPermissions } from "@/lib/permissions";
3-
import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
3+
import { getStackStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
44
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
55
import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram";
66
import { getTenancy, type Tenancy } from "@/lib/tenancies";
@@ -183,7 +183,14 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
183183
}
184184
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
185185
const prisma = await getPrismaClientForTenancy(tenancy);
186-
const product = JSON.parse(metadata.product || "{}");
186+
187+
const product = await resolveProductFromStripeMetadata({
188+
prisma,
189+
tenancyId: tenancy.id,
190+
metadata: metadata as Record<string, string | undefined>,
191+
context: { paymentIntentId: paymentIntent.id },
192+
});
193+
187194
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
188195
const stripePaymentIntentId = paymentIntent.id;
189196
if (!metadata.customerId || !metadata.customerType) {
@@ -226,7 +233,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
226233
customerId: metadata.customerId,
227234
});
228235
const receiptLink = paymentIntent.charges?.data?.[0]?.receipt_url ?? null;
229-
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
236+
const productName = product.displayName ?? "Purchase";
230237
const extraVariables: Record<string, string | number> = {
231238
productName,
232239
quantity: qty,
@@ -264,8 +271,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
264271
customerType,
265272
customerId: metadata.customerId,
266273
});
267-
const product = JSON.parse(metadata.product || "{}");
268-
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
274+
const product = await resolveProductFromStripeMetadata({
275+
prisma,
276+
tenancyId: tenancy.id,
277+
metadata: metadata as Record<string, string | undefined>,
278+
context: { paymentIntentId: paymentIntent.id },
279+
});
280+
const productName = product.displayName ?? "Purchase";
269281
const failureReason = paymentIntent.last_payment_error?.message;
270282
const extraVariables: Record<string, string | number> = {
271283
productName,

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
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 { upsertProductVersion } from "@/lib/product-versions";
4+
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
45
import { getPrismaClientForTenancy } from "@/prisma-client";
56
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
67
import { KnownErrors } from "@stackframe/stack-shared";
78
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
89
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
910
import { getOrUndefined, typedEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
1011
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
11-
import { SubscriptionStatus } from "@/generated/prisma/client";
1212
import Stripe from "stripe";
1313

1414

@@ -170,6 +170,13 @@ export const POST = createSmartRouteHandler({
170170

171171
const stripeProduct = await stripe.products.create({ name: toProduct.displayName || "Subscription" });
172172

173+
const productVersionId = await upsertProductVersion({
174+
prisma,
175+
tenancyId: auth.tenancy.id,
176+
productId: body.to_product_id,
177+
productJson: toProduct,
178+
});
179+
173180
if (subscription?.stripeSubscriptionId) {
174181
const existingStripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
175182
if (existingStripeSub.items.data.length === 0) {
@@ -195,11 +202,16 @@ export const POST = createSmartRouteHandler({
195202
}],
196203
metadata: {
197204
productId: body.to_product_id,
198-
product: JSON.stringify(toProduct),
205+
productVersionId,
199206
priceId: selectedPriceId,
200207
},
201208
});
202209
const updatedSubscription = updated as Stripe.Subscription;
210+
const sanitizedUpdateDates = sanitizeStripePeriodDates(
211+
existingItem.current_period_start,
212+
existingItem.current_period_end,
213+
{ subscriptionId: subscription.stripeSubscriptionId, tenancyId: auth.tenancy.id }
214+
);
203215

204216
await prisma.subscription.update({
205217
where: {
@@ -214,8 +226,8 @@ export const POST = createSmartRouteHandler({
214226
priceId: selectedPriceId,
215227
quantity,
216228
status: updatedSubscription.status,
217-
currentPeriodStart: new Date(existingItem.current_period_start * 1000),
218-
currentPeriodEnd: new Date(existingItem.current_period_end * 1000),
229+
currentPeriodStart: sanitizedUpdateDates.start,
230+
currentPeriodEnd: sanitizedUpdateDates.end,
219231
cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end,
220232
},
221233
});
@@ -239,7 +251,7 @@ export const POST = createSmartRouteHandler({
239251
}],
240252
metadata: {
241253
productId: body.to_product_id,
242-
product: JSON.stringify(toProduct),
254+
productVersionId,
243255
priceId: selectedPriceId,
244256
},
245257
});
@@ -248,6 +260,11 @@ export const POST = createSmartRouteHandler({
248260
throw new StackAssertionError("Stripe subscription has no items", { stripeSubscriptionId: createdSubscription.id });
249261
}
250262
const createdItem = createdSubscription.items.data[0];
263+
const sanitizedCreateDates = sanitizeStripePeriodDates(
264+
createdItem.current_period_start,
265+
createdItem.current_period_end,
266+
{ subscriptionId: createdSubscription.id, tenancyId: auth.tenancy.id }
267+
);
251268

252269
await prisma.subscription.create({
253270
data: {
@@ -260,8 +277,8 @@ export const POST = createSmartRouteHandler({
260277
quantity,
261278
stripeSubscriptionId: createdSubscription.id,
262279
status: createdSubscription.status,
263-
currentPeriodStart: new Date(createdItem.current_period_start * 1000),
264-
currentPeriodEnd: new Date(createdItem.current_period_end * 1000),
280+
currentPeriodStart: sanitizedCreateDates.start,
281+
currentPeriodEnd: sanitizedCreateDates.end,
265282
cancelAtPeriodEnd: createdSubscription.cancel_at_period_end,
266283
creationSource: "PURCHASE_PAGE",
267284
},

apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { SubscriptionStatus } from "@/generated/prisma/client";
12
import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments";
3+
import { upsertProductVersion } from "@/lib/product-versions";
24
import { getStripeForAccount } from "@/lib/stripe";
35
import { getTenancy } from "@/lib/tenancies";
46
import { getPrismaClientForTenancy } from "@/prisma-client";
57
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
6-
import { SubscriptionStatus } from "@/generated/prisma/client";
78
import { KnownErrors } from "@stackframe/stack-shared";
89
import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
910
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
@@ -73,6 +74,13 @@ export const POST = createSmartRouteHandler({
7374
throw new StackAssertionError("Price not resolved for purchase session");
7475
}
7576

77+
const productVersionId = await upsertProductVersion({
78+
prisma,
79+
tenancyId: tenancy.id,
80+
productId: data.productId ?? null,
81+
productJson: data.product,
82+
});
83+
7684
if (conflictingProductLineSubscriptions.length > 0) {
7785
const conflicting = conflictingProductLineSubscriptions[0];
7886
if (conflicting.stripeSubscriptionId) {
@@ -99,7 +107,7 @@ export const POST = createSmartRouteHandler({
99107
}],
100108
metadata: {
101109
productId: data.productId ?? null,
102-
product: JSON.stringify(data.product),
110+
productVersionId,
103111
priceId: price_id,
104112
},
105113
});
@@ -136,7 +144,7 @@ export const POST = createSmartRouteHandler({
136144
automatic_payment_methods: { enabled: true },
137145
metadata: {
138146
productId: data.productId || "",
139-
product: JSON.stringify(data.product),
147+
productVersionId,
140148
customerId: data.customerId,
141149
customerType: data.product.customerType,
142150
purchaseQuantity: String(quantity),
@@ -175,7 +183,7 @@ export const POST = createSmartRouteHandler({
175183
}],
176184
metadata: {
177185
productId: data.productId ?? null,
178-
product: JSON.stringify(data.product),
186+
productVersionId,
179187
priceId: price_id,
180188
},
181189
});

0 commit comments

Comments
 (0)