Skip to content

Commit 660dded

Browse files
committed
refactor/fix: Move product info from stripe webhook to separate table
Stripe metadata has a character limit of 500. We used to pass product info (including num of items) into the metadata of the stripe object. So when we tried to invoke stripe with this metadata, if it was over 500 chars, it would cause stripe to return an error. This was done because when the stripe webhook event fired, it would send the metadata along with it so our handler could pick it up. We rework this to only passing an id for use in a new table lookup in the handler. This decouples the product info from the webhook event. We keep it backwards compatible because there are existing subscriptions that have the product in the metadata, the same way we kept the offer parsing code for the subscriptions that had offer in the metadata. The productVersionId is hashed on the productJson to dedup it.
1 parent 107d0cc commit 660dded

7 files changed

Lines changed: 289 additions & 38 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/src/app/api/latest/integrations/stripe/webhooks/route.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails";
22
import { listPermissions } from "@/lib/permissions";
3+
import { getProductVersion } from "@/lib/product-versions";
34
import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe";
45
import type { StripeOverridesMap } from "@/lib/stripe-proxy";
56
import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram";
@@ -183,7 +184,12 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
183184
}
184185
const tenancy = await getTenancyForStripeAccountId(accountId, mockData);
185186
const prisma = await getPrismaClientForTenancy(tenancy);
186-
const product = JSON.parse(metadata.product || "{}");
187+
188+
const productVersionId = metadata.productVersionId as string | undefined;
189+
const product = productVersionId
190+
? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson
191+
: JSON.parse(metadata.product || "{}");
192+
187193
const qty = Math.max(1, Number(metadata.purchaseQuantity || 1));
188194
const stripePaymentIntentId = paymentIntent.id;
189195
if (!metadata.customerId || !metadata.customerType) {
@@ -264,7 +270,10 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise<void> {
264270
customerType,
265271
customerId: metadata.customerId,
266272
});
267-
const product = JSON.parse(metadata.product || "{}");
273+
const productVersionId = metadata.productVersionId as string | undefined;
274+
const product = productVersionId
275+
? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson
276+
: JSON.parse(metadata.product || "{}");
268277
const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase";
269278
const failureReason = paymentIntent.last_payment_error?.message;
270279
const extraVariables: Record<string, string | number> = {

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SubscriptionStatus } from "@/generated/prisma/client";
22
import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments";
3+
import { upsertProductVersion } from "@/lib/product-versions";
34
import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe";
45
import { getPrismaClientForTenancy } from "@/prisma-client";
56
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@@ -169,6 +170,13 @@ export const POST = createSmartRouteHandler({
169170

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

173+
const productVersionId = await upsertProductVersion({
174+
prisma,
175+
tenancyId: auth.tenancy.id,
176+
productId: body.to_product_id,
177+
productJson: toProduct,
178+
});
179+
172180
if (subscription?.stripeSubscriptionId) {
173181
const existingStripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId);
174182
if (existingStripeSub.items.data.length === 0) {
@@ -194,12 +202,16 @@ export const POST = createSmartRouteHandler({
194202
}],
195203
metadata: {
196204
productId: body.to_product_id,
197-
product: JSON.stringify(toProduct),
205+
productVersionId,
198206
priceId: selectedPriceId,
199207
},
200208
});
201209
const updatedSubscription = updated as Stripe.Subscription;
202-
const sanitizedUpdateDates = sanitizeStripePeriodDates(existingItem.current_period_start, existingItem.current_period_end);
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: {
@@ -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,7 +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];
251-
const sanitizedCreateDates = sanitizeStripePeriodDates(createdItem.current_period_start, createdItem.current_period_end);
263+
const sanitizedCreateDates = sanitizeStripePeriodDates(
264+
createdItem.current_period_start,
265+
createdItem.current_period_end,
266+
{ subscriptionId: createdSubscription.id, tenancyId: auth.tenancy.id }
267+
);
252268

253269
await prisma.subscription.create({
254270
data: {

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
});
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { PrismaClientTransaction } from "@/prisma-client";
2+
import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes";
3+
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
4+
import crypto from "crypto";
5+
6+
/**
7+
* Deterministically serializes an object to JSON with sorted keys.
8+
* This ensures the same object always produces the same string regardless of property order.
9+
*/
10+
export function canonicalJsonStringify(obj: unknown): string {
11+
return JSON.stringify(obj, (_, value) => {
12+
if (value && typeof value === "object" && !Array.isArray(value)) {
13+
return Object.keys(value)
14+
.sort()
15+
.reduce((sorted: Record<string, unknown>, key) => {
16+
sorted[key] = value[key];
17+
return sorted;
18+
}, {});
19+
}
20+
return value;
21+
});
22+
}
23+
24+
/**
25+
* Computes a deterministic version ID from a productId and product JSON object.
26+
* Uses SHA-256 hash of the canonical JSON representation.
27+
*
28+
* Including productId ensures different products with identical JSON get separate rows.
29+
* Inline products (null productId) with identical JSON will still share a row,
30+
* which is acceptable since if they have the same productJson, semantically they are the same product.
31+
*/
32+
export function computeProductVersionId(productId: string | null, productJson: unknown): string {
33+
const canonical = canonicalJsonStringify({ productId, productJson });
34+
const hash = crypto.createHash("sha256").update(canonical).digest();
35+
return encodeBase64(hash);
36+
}
37+
38+
/**
39+
* Upserts a ProductVersion record and returns the productVersionId.
40+
* If a record with the same (tenancyId, productVersionId) exists, it's a no-op.
41+
*/
42+
export async function upsertProductVersion(options: {
43+
prisma: PrismaClientTransaction,
44+
tenancyId: string,
45+
productId: string | null,
46+
productJson: unknown,
47+
}): Promise<string> {
48+
const productVersionId = computeProductVersionId(options.productId, options.productJson);
49+
50+
await options.prisma.productVersion.upsert({
51+
where: {
52+
tenancyId_productVersionId: {
53+
tenancyId: options.tenancyId,
54+
productVersionId,
55+
},
56+
},
57+
create: {
58+
tenancyId: options.tenancyId,
59+
productVersionId,
60+
productId: options.productId,
61+
productJson: options.productJson as object,
62+
},
63+
update: {},
64+
});
65+
66+
return productVersionId;
67+
}
68+
69+
/**
70+
* Retrieves a ProductVersion by tenancyId and productVersionId.
71+
* Throws if not found.
72+
*/
73+
export async function getProductVersion(options: {
74+
prisma: PrismaClientTransaction,
75+
tenancyId: string,
76+
productVersionId: string,
77+
}): Promise<{ productId: string | null, productJson: unknown }> {
78+
const version = await options.prisma.productVersion.findUnique({
79+
where: {
80+
tenancyId_productVersionId: {
81+
tenancyId: options.tenancyId,
82+
productVersionId: options.productVersionId,
83+
},
84+
},
85+
});
86+
87+
if (!version) {
88+
throw new StackAssertionError(
89+
"ProductVersion not found. This may indicate a race condition or deleted record.",
90+
{
91+
tenancyId: options.tenancyId,
92+
productVersionId: options.productVersionId,
93+
}
94+
);
95+
}
96+
97+
return {
98+
productId: version.productId,
99+
productJson: version.productJson,
100+
};
101+
}
102+
103+
import.meta.vitest?.describe("canonicalJsonStringify", (test) => {
104+
test("produces same output regardless of key order", ({ expect }) => {
105+
const obj1 = { b: 2, a: 1, c: 3 };
106+
const obj2 = { a: 1, b: 2, c: 3 };
107+
const obj3 = { c: 3, b: 2, a: 1 };
108+
109+
expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2));
110+
expect(canonicalJsonStringify(obj2)).toBe(canonicalJsonStringify(obj3));
111+
});
112+
113+
test("handles nested objects", ({ expect }) => {
114+
const obj1 = { outer: { b: 2, a: 1 }, z: 1 };
115+
const obj2 = { z: 1, outer: { a: 1, b: 2 } };
116+
117+
expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2));
118+
});
119+
120+
test("preserves array order", ({ expect }) => {
121+
const obj1 = { arr: [1, 2, 3] };
122+
const obj2 = { arr: [3, 2, 1] };
123+
124+
expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2));
125+
});
126+
127+
test("different objects produce different output", ({ expect }) => {
128+
const obj1 = { a: 1 };
129+
const obj2 = { a: 2 };
130+
131+
expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2));
132+
});
133+
});
134+
135+
import.meta.vitest?.describe("computeProductVersionId", (test) => {
136+
test("produces same hash for same productId and object with different key order", ({ expect }) => {
137+
const obj1 = { b: 2, a: 1, c: 3 };
138+
const obj2 = { a: 1, b: 2, c: 3 };
139+
140+
expect(computeProductVersionId("prod-1", obj1)).toBe(computeProductVersionId("prod-1", obj2));
141+
});
142+
143+
test("produces different hash for different objects", ({ expect }) => {
144+
const obj1 = { a: 1 };
145+
const obj2 = { a: 2 };
146+
147+
expect(computeProductVersionId("prod-1", obj1)).not.toBe(computeProductVersionId("prod-1", obj2));
148+
});
149+
150+
test("produces different hash for different productIds with same object", ({ expect }) => {
151+
const obj = { a: 1 };
152+
153+
expect(computeProductVersionId("prod-1", obj)).not.toBe(computeProductVersionId("prod-2", obj));
154+
});
155+
156+
test("produces same hash for null productIds with same object", ({ expect }) => {
157+
const obj = { a: 1 };
158+
159+
expect(computeProductVersionId(null, obj)).toBe(computeProductVersionId(null, obj));
160+
});
161+
162+
test("produces different hash for null productIds with different objects", ({ expect }) => {
163+
const obj1 = { a: 1 };
164+
const obj2 = { a: 2 };
165+
166+
expect(computeProductVersionId(null, obj1)).not.toBe(computeProductVersionId(null, obj2));
167+
});
168+
169+
test("hash is deterministic", ({ expect }) => {
170+
const obj = { foo: "bar", nested: { x: 1, y: 2 } };
171+
172+
const hash1 = computeProductVersionId("prod-1", obj);
173+
const hash2 = computeProductVersionId("prod-1", obj);
174+
175+
expect(hash1).toBe(hash2);
176+
});
177+
});

0 commit comments

Comments
 (0)