Skip to content

Commit 71b3666

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 6a7afd1 commit 71b3666

7 files changed

Lines changed: 231 additions & 19 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: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ model ExternalDbSyncMetadata {
9999
100100
singleton BooleanTrue @unique @default(TRUE)
101101
102-
sequencerEnabled Boolean @default(true)
103-
pollerEnabled Boolean @default(true)
102+
sequencerEnabled Boolean @default(true)
103+
pollerEnabled Boolean @default(true)
104104
105105
createdAt DateTime @default(now())
106106
updatedAt DateTime @updatedAt
@@ -1003,6 +1003,16 @@ model Subscription {
10031003
@@unique([tenancyId, stripeSubscriptionId])
10041004
}
10051005

1006+
model ProductVersion {
1007+
tenancyId String @db.Uuid
1008+
productVersionId String
1009+
productId String?
1010+
productJson Json
1011+
createdAt DateTime @default(now())
1012+
1013+
@@id([tenancyId, productVersionId])
1014+
}
1015+
10061016
model ItemQuantityChange {
10071017
id String @default(uuid()) @db.Uuid
10081018
tenancyId String @db.Uuid
@@ -1088,7 +1098,7 @@ model OutgoingRequest {
10881098
10891099
qstashOptions Json
10901100
startedFulfillingAt DateTime?
1091-
deduplicationKey String?
1101+
deduplicationKey String?
10921102
10931103
@@unique([deduplicationKey])
10941104
@@index([startedFulfillingAt, createdAt])

apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx

Lines changed: 7 additions & 1 deletion
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) {

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

Lines changed: 10 additions & 2 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,7 +202,7 @@ 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
});
@@ -239,7 +247,7 @@ export const POST = createSmartRouteHandler({
239247
}],
240248
metadata: {
241249
productId: body.to_product_id,
242-
product: JSON.stringify(toProduct),
250+
productVersionId,
243251
priceId: selectedPriceId,
244252
},
245253
});

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: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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 product JSON object.
26+
* Uses SHA-256 hash of the canonical JSON representation.
27+
*/
28+
export function computeProductVersionId(productJson: unknown): string {
29+
const canonical = canonicalJsonStringify(productJson);
30+
const hash = crypto.createHash("sha256").update(canonical).digest();
31+
return encodeBase64(hash);
32+
}
33+
34+
/**
35+
* Upserts a ProductVersion record and returns the productVersionId.
36+
* If a record with the same (tenancyId, productVersionId) exists, it's a no-op.
37+
*/
38+
export async function upsertProductVersion(options: {
39+
prisma: PrismaClientTransaction,
40+
tenancyId: string,
41+
productId: string | null,
42+
productJson: unknown,
43+
}): Promise<string> {
44+
const productVersionId = computeProductVersionId(options.productJson);
45+
46+
await options.prisma.productVersion.upsert({
47+
where: {
48+
tenancyId_productVersionId: {
49+
tenancyId: options.tenancyId,
50+
productVersionId,
51+
},
52+
},
53+
create: {
54+
tenancyId: options.tenancyId,
55+
productVersionId,
56+
productId: options.productId,
57+
productJson: options.productJson as object,
58+
},
59+
update: {},
60+
});
61+
62+
return productVersionId;
63+
}
64+
65+
/**
66+
* Retrieves a ProductVersion by tenancyId and productVersionId.
67+
* Throws if not found.
68+
*/
69+
export async function getProductVersion(options: {
70+
prisma: PrismaClientTransaction,
71+
tenancyId: string,
72+
productVersionId: string,
73+
}): Promise<{ productId: string | null, productJson: unknown }> {
74+
const version = await options.prisma.productVersion.findUnique({
75+
where: {
76+
tenancyId_productVersionId: {
77+
tenancyId: options.tenancyId,
78+
productVersionId: options.productVersionId,
79+
},
80+
},
81+
});
82+
83+
if (!version) {
84+
throw new StackAssertionError(
85+
"ProductVersion not found. This may indicate a race condition or deleted record.",
86+
{
87+
tenancyId: options.tenancyId,
88+
productVersionId: options.productVersionId,
89+
}
90+
);
91+
}
92+
93+
return {
94+
productId: version.productId,
95+
productJson: version.productJson,
96+
};
97+
}
98+
99+
import.meta.vitest?.describe("canonicalJsonStringify", (test) => {
100+
test("produces same output regardless of key order", ({ expect }) => {
101+
const obj1 = { b: 2, a: 1, c: 3 };
102+
const obj2 = { a: 1, b: 2, c: 3 };
103+
const obj3 = { c: 3, b: 2, a: 1 };
104+
105+
expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2));
106+
expect(canonicalJsonStringify(obj2)).toBe(canonicalJsonStringify(obj3));
107+
});
108+
109+
test("handles nested objects", ({ expect }) => {
110+
const obj1 = { outer: { b: 2, a: 1 }, z: 1 };
111+
const obj2 = { z: 1, outer: { a: 1, b: 2 } };
112+
113+
expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2));
114+
});
115+
116+
test("preserves array order", ({ expect }) => {
117+
const obj1 = { arr: [1, 2, 3] };
118+
const obj2 = { arr: [3, 2, 1] };
119+
120+
expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2));
121+
});
122+
123+
test("different objects produce different output", ({ expect }) => {
124+
const obj1 = { a: 1 };
125+
const obj2 = { a: 2 };
126+
127+
expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2));
128+
});
129+
});
130+
131+
import.meta.vitest?.describe("computeProductVersionId", (test) => {
132+
test("produces same hash for same object with different key order", ({ expect }) => {
133+
const obj1 = { b: 2, a: 1, c: 3 };
134+
const obj2 = { a: 1, b: 2, c: 3 };
135+
136+
expect(computeProductVersionId(obj1)).toBe(computeProductVersionId(obj2));
137+
});
138+
139+
test("produces different hash for different objects", ({ expect }) => {
140+
const obj1 = { a: 1 };
141+
const obj2 = { a: 2 };
142+
143+
expect(computeProductVersionId(obj1)).not.toBe(computeProductVersionId(obj2));
144+
});
145+
146+
test("hash is deterministic", ({ expect }) => {
147+
const obj = { foo: "bar", nested: { x: 1, y: 2 } };
148+
149+
const hash1 = computeProductVersionId(obj);
150+
const hash2 = computeProductVersionId(obj);
151+
152+
expect(hash1).toBe(hash2);
153+
});
154+
});

apps/backend/src/lib/stripe.tsx

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CustomerType } from "@/generated/prisma/client";
2+
import { getProductVersion } from "@/lib/product-versions";
23
import { getTenancy, Tenancy } from "@/lib/tenancies";
34
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
45
import { InputJsonValue } from "@prisma/client/runtime/client";
@@ -131,16 +132,30 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s
131132
const item = subscription.items.data[0];
132133
const sanitizedDates = sanitizeStripePeriodDates(item.current_period_start, item.current_period_end);
133134
const priceId = subscription.metadata.priceId as string | undefined;
134-
// old subscriptions were created with offer metadata instead of product metadata
135-
const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined;
136-
if (!productString) {
137-
throw new StackAssertionError("Stripe subscription metadata missing product or offer", { subscriptionId: subscription.id });
138-
}
135+
139136
let productJson: InputJsonValue;
140-
try {
141-
productJson = JSON.parse(productString);
142-
} catch (error) {
143-
throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error });
137+
const productVersionId = subscription.metadata.productVersionId as string | undefined;
138+
if (productVersionId) {
139+
const version = await getProductVersion({
140+
prisma,
141+
tenancyId: tenancy.id,
142+
productVersionId,
143+
});
144+
productJson = version.productJson as InputJsonValue;
145+
} else {
146+
// Backward compat: old subscriptions have product JSON directly in metadata or even older subscriptions were created with offer metadata
147+
const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined;
148+
if (!productString) {
149+
throw new StackAssertionError("Stripe subscription metadata missing productVersionId, product, or offer", {
150+
subscriptionId: subscription.id,
151+
tenancyId: tenancy.id,
152+
});
153+
}
154+
try {
155+
productJson = JSON.parse(productString);
156+
} catch (error) {
157+
throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error });
158+
}
144159
}
145160

146161
await prisma.subscription.upsert({

0 commit comments

Comments
 (0)