From 107d0cc8812011d70dab3d174c611f88f9f7adce Mon Sep 17 00:00:00 2001 From: nams1570 Date: Mon, 16 Feb 2026 18:39:01 -0800 Subject: [PATCH 1/3] 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. --- apps/backend/prisma/seed.ts | 96 +++++++++---------- .../[customer_id]/switch/route.ts | 15 +-- apps/backend/src/lib/stripe.tsx | 50 ++++++++-- packages/stack-shared/src/plans.ts | 65 +++++++++++++ 4 files changed, 164 insertions(+), 62 deletions(-) create mode 100644 packages/stack-shared/src/plans.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 93df1dd7b1..2663aa188c 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -11,6 +11,7 @@ import { getPrismaClientForTenancy, globalPrismaClient, PrismaClientTransaction import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config'; import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails'; import { AdminUserProjectsCrud, ProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects'; +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'; @@ -119,9 +120,24 @@ export async function seed() { }, }, products: { - team_plans: { + free: { + productLineId: "plans", + displayName: "Free", + customerType: "team", + serverOnly: false, + stackable: false, + prices: "include-by-default", + 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.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 }, + }, + }, + team: { productLineId: "plans", - displayName: "Team Plans", + displayName: "Team", customerType: "team", serverOnly: false, stackable: false, @@ -129,16 +145,16 @@ export async function seed() { monthly: { USD: "49", interval: [1, "month"] as any, - serverOnly: false - } + serverOnly: false, + }, }, includedItems: { - dashboard_admins: { - quantity: 3, - repeat: "never", - expires: "when-purchase-expires" - } - } + [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.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 }, + }, }, growth: { productLineId: "plans", @@ -150,63 +166,45 @@ export async function seed() { monthly: { USD: "299", interval: [1, "month"] as any, - serverOnly: false - } + serverOnly: false, + }, }, includedItems: { - dashboard_admins: { - quantity: 5, - repeat: "never", - expires: "when-purchase-expires" - } - } - }, - free: { - productLineId: "plans", - displayName: "Free", - customerType: "team", - serverOnly: false, - stackable: false, - prices: "include-by-default", - includedItems: { - dashboard_admins: { - quantity: 1, - repeat: "never", - expires: "when-purchase-expires" - } - } + [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.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 }, + }, }, - "extra-admins": { + "extra-seats": { productLineId: "plans", - displayName: "Extra Admins", + displayName: "Extra Seats", customerType: "team", serverOnly: false, stackable: true, prices: { monthly: { - USD: "49", + USD: "29", interval: [1, "month"] as any, - serverOnly: false - } + serverOnly: false, + }, }, includedItems: { - dashboard_admins: { - quantity: 1, - repeat: "never", - expires: "when-purchase-expires" - } + [ITEM_IDS.seats]: { quantity: 1, repeat: "never" as const, expires: "when-purchase-expires" as const }, }, isAddOnTo: { team: true, growth: true, - } - } + }, + }, }, items: { - dashboard_admins: { - displayName: "Dashboard Admins", - customerType: "team" - } + [ITEM_IDS.seats]: { displayName: "Dashboard Admins", customerType: "team" as const }, + [ITEM_IDS.authUsers]: { displayName: "Auth Users", customerType: "team" as const }, + [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 }, }, }, apps: { diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index be901785e3..0b0e880742 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -1,6 +1,6 @@ +import { SubscriptionStatus } from "@/generated/prisma/client"; import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments"; -import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; -import { getStripeForAccount } from "@/lib/stripe"; +import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; @@ -8,7 +8,6 @@ import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupOb import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { getOrUndefined, typedEntries, typedKeys } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; -import { SubscriptionStatus } from "@/generated/prisma/client"; import Stripe from "stripe"; @@ -200,6 +199,7 @@ export const POST = createSmartRouteHandler({ }, }); const updatedSubscription = updated as Stripe.Subscription; + const sanitizedUpdateDates = sanitizeStripePeriodDates(existingItem.current_period_start, existingItem.current_period_end); await prisma.subscription.update({ where: { @@ -214,8 +214,8 @@ export const POST = createSmartRouteHandler({ priceId: selectedPriceId, quantity, status: updatedSubscription.status, - currentPeriodStart: new Date(existingItem.current_period_start * 1000), - currentPeriodEnd: new Date(existingItem.current_period_end * 1000), + currentPeriodStart: sanitizedUpdateDates.start, + currentPeriodEnd: sanitizedUpdateDates.end, cancelAtPeriodEnd: updatedSubscription.cancel_at_period_end, }, }); @@ -248,6 +248,7 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Stripe subscription has no items", { stripeSubscriptionId: createdSubscription.id }); } const createdItem = createdSubscription.items.data[0]; + const sanitizedCreateDates = sanitizeStripePeriodDates(createdItem.current_period_start, createdItem.current_period_end); await prisma.subscription.create({ data: { @@ -260,8 +261,8 @@ export const POST = createSmartRouteHandler({ quantity, stripeSubscriptionId: createdSubscription.id, status: createdSubscription.status, - currentPeriodStart: new Date(createdItem.current_period_start * 1000), - currentPeriodEnd: new Date(createdItem.current_period_end * 1000), + currentPeriodStart: sanitizedCreateDates.start, + currentPeriodEnd: sanitizedCreateDates.end, cancelAtPeriodEnd: createdSubscription.cancel_at_period_end, creationSource: "PURCHASE_PAGE", }, diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 79d60c76bb..cf96b83914 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,12 +1,12 @@ +import { CustomerType } from "@/generated/prisma/client"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; -import { CustomerType } from "@/generated/prisma/client"; +import { InputJsonValue } from "@prisma/client/runtime/client"; import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import Stripe from "stripe"; import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy"; -import { InputJsonValue } from "@prisma/client/runtime/client"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()); @@ -17,6 +17,43 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? { port: Number(`${stackPortPrefix}23`), } : {}; +/** + * Sanitizes subscription period dates from Stripe. + * + * The Stripe mock returns hardcoded fixture dates that are invalid (e.g., start in 2030, end in 2000). + * This function detects invalid dates and replaces them with sensible defaults. + * + * @param startTimestamp - Unix timestamp in seconds for period start + * @param endTimestamp - Unix timestamp in seconds for period end + * @param intervalMonths - Billing interval in months (default: 1) + * @returns Sanitized Date objects for start and end + */ +export function sanitizeStripePeriodDates( + startTimestamp: number, + endTimestamp: number, + intervalMonths: number = 1 +): { start: Date, end: Date } { + const now = new Date(); + const startDate = new Date(startTimestamp * 1000); + const endDate = new Date(endTimestamp * 1000); + + const tenYearsMs = 10 * 365 * 24 * 60 * 60 * 1000; + const isStartValid = startDate.getTime() > 0 && Math.abs(startDate.getTime() - now.getTime()) < tenYearsMs; + const isEndValid = endDate.getTime() > 0 && Math.abs(endDate.getTime() - now.getTime()) < tenYearsMs; + const isOrderValid = startDate < endDate; + + if (isStartValid && isEndValid && isOrderValid) { + return { start: startDate, end: endDate }; + } + + // Dates are invalid (likely from Stripe mock), use sensible defaults + const defaultStart = now; + const defaultEnd = new Date(now); + defaultEnd.setMonth(defaultEnd.getMonth() + intervalMonths); + + return { start: defaultStart, end: defaultEnd }; +} + export const getStackStripe = (overrides?: StripeOverridesMap) => { if (!stripeSecretKey) { throw new StackAssertionError("STACK_STRIPE_SECRET_KEY environment variable is not set"); @@ -92,6 +129,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s continue; } const item = subscription.items.data[0]; + const sanitizedDates = sanitizeStripePeriodDates(item.current_period_start, item.current_period_end); const priceId = subscription.metadata.priceId as string | undefined; // old subscriptions were created with offer metadata instead of product metadata 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 status: subscription.status, product: productJson, quantity: item.quantity ?? 1, - currentPeriodEnd: new Date(item.current_period_end * 1000), - currentPeriodStart: new Date(item.current_period_start * 1000), + currentPeriodEnd: sanitizedDates.end, + currentPeriodStart: sanitizedDates.start, cancelAtPeriodEnd: subscription.cancel_at_period_end, priceId: priceId ?? null, }, @@ -131,8 +169,8 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s quantity: item.quantity ?? 1, stripeSubscriptionId: subscription.id, status: subscription.status, - currentPeriodEnd: new Date(item.current_period_end * 1000), - currentPeriodStart: new Date(item.current_period_start * 1000), + currentPeriodEnd: sanitizedDates.end, + currentPeriodStart: sanitizedDates.start, cancelAtPeriodEnd: subscription.cancel_at_period_end, creationSource: "PURCHASE_PAGE" }, diff --git a/packages/stack-shared/src/plans.ts b/packages/stack-shared/src/plans.ts new file mode 100644 index 0000000000..d737dce740 --- /dev/null +++ b/packages/stack-shared/src/plans.ts @@ -0,0 +1,65 @@ +/** + * Plan configuration for Stack Auth pricing tiers. + * + * This file defines the limits for each plan and the item IDs used to track them. + * Import these constants in seed.ts and backend code for limit enforcement. + */ + +export const UNLIMITED = 1_000_000_000; + +/** + * Item IDs used across the codebase for tracking plan limits. + */ +export const ITEM_IDS = { + seats: "dashboard_admins", + authUsers: "auth_users", + emailsPerMonth: "emails_per_month", + analyticsTimeoutSeconds: "analytics_timeout_seconds", + analyticsEvents: "analytics_events", +} as const; + +export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS]; + +/** + * The offerings/limits included in a plan. + */ +export type PlanProductOfferings = { + seats: number, + authUsers: number, + emailsPerMonth: number, + analyticsTimeoutSeconds: number, + analyticsEvents: number, +}; + +/** + * Plan limits by plan ID. + */ +export const PLAN_LIMITS: { + free: PlanProductOfferings, + team: PlanProductOfferings, + growth: PlanProductOfferings, +} = { + free: { + seats: 1, + authUsers: 10_000, + emailsPerMonth: 1_000, + analyticsTimeoutSeconds: 10, + analyticsEvents: 100_000, + }, + team: { + seats: 4, + authUsers: 50_000, + emailsPerMonth: 25_000, + analyticsTimeoutSeconds: 60, + analyticsEvents: 500_000, + }, + growth: { + seats: UNLIMITED, + authUsers: UNLIMITED, + emailsPerMonth: 25_000, + analyticsTimeoutSeconds: 300, + analyticsEvents: 1_000_000, + }, +}; + +export type PlanId = keyof typeof PLAN_LIMITS; From 660ddedd35410685bc7893ed67954a28199f4a4f Mon Sep 17 00:00:00 2001 From: nams1570 Date: Wed, 18 Feb 2026 13:43:19 -0800 Subject: [PATCH 2/3] 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. --- .../migration.sql | 11 ++ apps/backend/prisma/schema.prisma | 10 + .../integrations/stripe/webhooks/route.tsx | 13 +- .../[customer_id]/switch/route.ts | 24 ++- .../purchases/purchase-session/route.tsx | 16 +- apps/backend/src/lib/product-versions.tsx | 177 ++++++++++++++++++ apps/backend/src/lib/stripe.tsx | 76 +++++--- 7 files changed, 289 insertions(+), 38 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql create mode 100644 apps/backend/src/lib/product-versions.tsx diff --git a/apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql b/apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql new file mode 100644 index 0000000000..ac592d3801 --- /dev/null +++ b/apps/backend/prisma/migrations/20260218194816_add_product_versions/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "ProductVersion" ( + "tenancyId" UUID NOT NULL, + "productVersionId" TEXT NOT NULL, + "productId" TEXT, + "productJson" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProductVersion_pkey" PRIMARY KEY ("tenancyId","productVersionId") +); + diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 93d73842c9..0ce2ed3914 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1073,6 +1073,16 @@ model Subscription { @@unique([tenancyId, stripeSubscriptionId]) } +model ProductVersion { + tenancyId String @db.Uuid + productVersionId String + productId String? + productJson Json + createdAt DateTime @default(now()) + + @@id([tenancyId, productVersionId]) +} + model ItemQuantityChange { id String @default(uuid()) @db.Uuid tenancyId String @db.Uuid diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 3eb209851a..5681a776fe 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -1,5 +1,6 @@ import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails"; import { listPermissions } from "@/lib/permissions"; +import { getProductVersion } from "@/lib/product-versions"; import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; import type { StripeOverridesMap } from "@/lib/stripe-proxy"; import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram"; @@ -183,7 +184,12 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { } const tenancy = await getTenancyForStripeAccountId(accountId, mockData); const prisma = await getPrismaClientForTenancy(tenancy); - const product = JSON.parse(metadata.product || "{}"); + + const productVersionId = metadata.productVersionId as string | undefined; + const product = productVersionId + ? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson + : JSON.parse(metadata.product || "{}"); + const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); const stripePaymentIntentId = paymentIntent.id; if (!metadata.customerId || !metadata.customerType) { @@ -264,7 +270,10 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { customerType, customerId: metadata.customerId, }); - const product = JSON.parse(metadata.product || "{}"); + const productVersionId = metadata.productVersionId as string | undefined; + const product = productVersionId + ? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson + : JSON.parse(metadata.product || "{}"); const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase"; const failureReason = paymentIntent.last_payment_error?.message; const extraVariables: Record = { diff --git a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts index 0b0e880742..daf2fe7eea 100644 --- a/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts +++ b/apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts @@ -1,5 +1,6 @@ import { SubscriptionStatus } from "@/generated/prisma/client"; import { ensureClientCanAccessCustomer, getCustomerPurchaseContext, getDefaultCardPaymentMethodSummary, getStripeCustomerForCustomerOrNull } from "@/lib/payments"; +import { upsertProductVersion } from "@/lib/product-versions"; import { getStripeForAccount, sanitizeStripePeriodDates } from "@/lib/stripe"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -169,6 +170,13 @@ export const POST = createSmartRouteHandler({ const stripeProduct = await stripe.products.create({ name: toProduct.displayName || "Subscription" }); + const productVersionId = await upsertProductVersion({ + prisma, + tenancyId: auth.tenancy.id, + productId: body.to_product_id, + productJson: toProduct, + }); + if (subscription?.stripeSubscriptionId) { const existingStripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId); if (existingStripeSub.items.data.length === 0) { @@ -194,12 +202,16 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: body.to_product_id, - product: JSON.stringify(toProduct), + productVersionId, priceId: selectedPriceId, }, }); const updatedSubscription = updated as Stripe.Subscription; - const sanitizedUpdateDates = sanitizeStripePeriodDates(existingItem.current_period_start, existingItem.current_period_end); + const sanitizedUpdateDates = sanitizeStripePeriodDates( + existingItem.current_period_start, + existingItem.current_period_end, + { subscriptionId: subscription.stripeSubscriptionId, tenancyId: auth.tenancy.id } + ); await prisma.subscription.update({ where: { @@ -239,7 +251,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: body.to_product_id, - product: JSON.stringify(toProduct), + productVersionId, priceId: selectedPriceId, }, }); @@ -248,7 +260,11 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Stripe subscription has no items", { stripeSubscriptionId: createdSubscription.id }); } const createdItem = createdSubscription.items.data[0]; - const sanitizedCreateDates = sanitizeStripePeriodDates(createdItem.current_period_start, createdItem.current_period_end); + const sanitizedCreateDates = sanitizeStripePeriodDates( + createdItem.current_period_start, + createdItem.current_period_end, + { subscriptionId: createdSubscription.id, tenancyId: auth.tenancy.id } + ); await prisma.subscription.create({ data: { diff --git a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx index 1e412bb801..1fac08b8fc 100644 --- a/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx +++ b/apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx @@ -1,9 +1,10 @@ +import { SubscriptionStatus } from "@/generated/prisma/client"; import { getClientSecretFromStripeSubscription, validatePurchaseSession } from "@/lib/payments"; +import { upsertProductVersion } from "@/lib/product-versions"; import { getStripeForAccount } from "@/lib/stripe"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { SubscriptionStatus } from "@/generated/prisma/client"; import { KnownErrors } from "@stackframe/stack-shared"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -73,6 +74,13 @@ export const POST = createSmartRouteHandler({ throw new StackAssertionError("Price not resolved for purchase session"); } + const productVersionId = await upsertProductVersion({ + prisma, + tenancyId: tenancy.id, + productId: data.productId ?? null, + productJson: data.product, + }); + if (conflictingProductLineSubscriptions.length > 0) { const conflicting = conflictingProductLineSubscriptions[0]; if (conflicting.stripeSubscriptionId) { @@ -99,7 +107,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: data.productId ?? null, - product: JSON.stringify(data.product), + productVersionId, priceId: price_id, }, }); @@ -136,7 +144,7 @@ export const POST = createSmartRouteHandler({ automatic_payment_methods: { enabled: true }, metadata: { productId: data.productId || "", - product: JSON.stringify(data.product), + productVersionId, customerId: data.customerId, customerType: data.product.customerType, purchaseQuantity: String(quantity), @@ -175,7 +183,7 @@ export const POST = createSmartRouteHandler({ }], metadata: { productId: data.productId ?? null, - product: JSON.stringify(data.product), + productVersionId, priceId: price_id, }, }); diff --git a/apps/backend/src/lib/product-versions.tsx b/apps/backend/src/lib/product-versions.tsx new file mode 100644 index 0000000000..c730bc5477 --- /dev/null +++ b/apps/backend/src/lib/product-versions.tsx @@ -0,0 +1,177 @@ +import { PrismaClientTransaction } from "@/prisma-client"; +import { encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import crypto from "crypto"; + +/** + * Deterministically serializes an object to JSON with sorted keys. + * This ensures the same object always produces the same string regardless of property order. + */ +export function canonicalJsonStringify(obj: unknown): string { + return JSON.stringify(obj, (_, value) => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return Object.keys(value) + .sort() + .reduce((sorted: Record, key) => { + sorted[key] = value[key]; + return sorted; + }, {}); + } + return value; + }); +} + +/** + * Computes a deterministic version ID from a productId and product JSON object. + * Uses SHA-256 hash of the canonical JSON representation. + * + * Including productId ensures different products with identical JSON get separate rows. + * Inline products (null productId) with identical JSON will still share a row, + * which is acceptable since if they have the same productJson, semantically they are the same product. + */ +export function computeProductVersionId(productId: string | null, productJson: unknown): string { + const canonical = canonicalJsonStringify({ productId, productJson }); + const hash = crypto.createHash("sha256").update(canonical).digest(); + return encodeBase64(hash); +} + +/** + * Upserts a ProductVersion record and returns the productVersionId. + * If a record with the same (tenancyId, productVersionId) exists, it's a no-op. + */ +export async function upsertProductVersion(options: { + prisma: PrismaClientTransaction, + tenancyId: string, + productId: string | null, + productJson: unknown, +}): Promise { + const productVersionId = computeProductVersionId(options.productId, options.productJson); + + await options.prisma.productVersion.upsert({ + where: { + tenancyId_productVersionId: { + tenancyId: options.tenancyId, + productVersionId, + }, + }, + create: { + tenancyId: options.tenancyId, + productVersionId, + productId: options.productId, + productJson: options.productJson as object, + }, + update: {}, + }); + + return productVersionId; +} + +/** + * Retrieves a ProductVersion by tenancyId and productVersionId. + * Throws if not found. + */ +export async function getProductVersion(options: { + prisma: PrismaClientTransaction, + tenancyId: string, + productVersionId: string, +}): Promise<{ productId: string | null, productJson: unknown }> { + const version = await options.prisma.productVersion.findUnique({ + where: { + tenancyId_productVersionId: { + tenancyId: options.tenancyId, + productVersionId: options.productVersionId, + }, + }, + }); + + if (!version) { + throw new StackAssertionError( + "ProductVersion not found. This may indicate a race condition or deleted record.", + { + tenancyId: options.tenancyId, + productVersionId: options.productVersionId, + } + ); + } + + return { + productId: version.productId, + productJson: version.productJson, + }; +} + +import.meta.vitest?.describe("canonicalJsonStringify", (test) => { + test("produces same output regardless of key order", ({ expect }) => { + const obj1 = { b: 2, a: 1, c: 3 }; + const obj2 = { a: 1, b: 2, c: 3 }; + const obj3 = { c: 3, b: 2, a: 1 }; + + expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2)); + expect(canonicalJsonStringify(obj2)).toBe(canonicalJsonStringify(obj3)); + }); + + test("handles nested objects", ({ expect }) => { + const obj1 = { outer: { b: 2, a: 1 }, z: 1 }; + const obj2 = { z: 1, outer: { a: 1, b: 2 } }; + + expect(canonicalJsonStringify(obj1)).toBe(canonicalJsonStringify(obj2)); + }); + + test("preserves array order", ({ expect }) => { + const obj1 = { arr: [1, 2, 3] }; + const obj2 = { arr: [3, 2, 1] }; + + expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2)); + }); + + test("different objects produce different output", ({ expect }) => { + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + expect(canonicalJsonStringify(obj1)).not.toBe(canonicalJsonStringify(obj2)); + }); +}); + +import.meta.vitest?.describe("computeProductVersionId", (test) => { + test("produces same hash for same productId and object with different key order", ({ expect }) => { + const obj1 = { b: 2, a: 1, c: 3 }; + const obj2 = { a: 1, b: 2, c: 3 }; + + expect(computeProductVersionId("prod-1", obj1)).toBe(computeProductVersionId("prod-1", obj2)); + }); + + test("produces different hash for different objects", ({ expect }) => { + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + expect(computeProductVersionId("prod-1", obj1)).not.toBe(computeProductVersionId("prod-1", obj2)); + }); + + test("produces different hash for different productIds with same object", ({ expect }) => { + const obj = { a: 1 }; + + expect(computeProductVersionId("prod-1", obj)).not.toBe(computeProductVersionId("prod-2", obj)); + }); + + test("produces same hash for null productIds with same object", ({ expect }) => { + const obj = { a: 1 }; + + expect(computeProductVersionId(null, obj)).toBe(computeProductVersionId(null, obj)); + }); + + test("produces different hash for null productIds with different objects", ({ expect }) => { + const obj1 = { a: 1 }; + const obj2 = { a: 2 }; + + expect(computeProductVersionId(null, obj1)).not.toBe(computeProductVersionId(null, obj2)); + }); + + test("hash is deterministic", ({ expect }) => { + const obj = { foo: "bar", nested: { x: 1, y: 2 } }; + + const hash1 = computeProductVersionId("prod-1", obj); + const hash2 = computeProductVersionId("prod-1", obj); + + expect(hash1).toBe(hash2); + }); +}); diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index cf96b83914..4b26050ae5 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,10 +1,10 @@ -import { CustomerType } from "@/generated/prisma/client"; +import { CustomerType, Prisma } from "@/generated/prisma/client"; +import { getProductVersion } from "@/lib/product-versions"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; -import { InputJsonValue } from "@prisma/client/runtime/client"; import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; -import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import Stripe from "stripe"; import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy"; @@ -21,37 +21,39 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? { * Sanitizes subscription period dates from Stripe. * * The Stripe mock returns hardcoded fixture dates that are invalid (e.g., start in 2030, end in 2000). - * This function detects invalid dates and replaces them with sensible defaults. + * This function detects when end <= start and replaces with sensible defaults. + * + * We only check the ordering constraint to avoid interfering with legitimate Stripe dates + * (e.g., long trials, future billing anchors). * * @param startTimestamp - Unix timestamp in seconds for period start * @param endTimestamp - Unix timestamp in seconds for period end - * @param intervalMonths - Billing interval in months (default: 1) + * @param context - Optional context for error reporting (subscriptionId, tenancyId) * @returns Sanitized Date objects for start and end */ export function sanitizeStripePeriodDates( startTimestamp: number, endTimestamp: number, - intervalMonths: number = 1 + context?: { subscriptionId?: string, tenancyId?: string }, ): { start: Date, end: Date } { - const now = new Date(); const startDate = new Date(startTimestamp * 1000); const endDate = new Date(endTimestamp * 1000); - const tenYearsMs = 10 * 365 * 24 * 60 * 60 * 1000; - const isStartValid = startDate.getTime() > 0 && Math.abs(startDate.getTime() - now.getTime()) < tenYearsMs; - const isEndValid = endDate.getTime() > 0 && Math.abs(endDate.getTime() - now.getTime()) < tenYearsMs; - const isOrderValid = startDate < endDate; - - if (isStartValid && isEndValid && isOrderValid) { + if (startDate < endDate) { return { start: startDate, end: endDate }; } - // Dates are invalid (likely from Stripe mock), use sensible defaults - const defaultStart = now; + // Dates are invalid (likely from Stripe mock where end <= start), use sensible defaults + captureError("sanitize-stripe-period-dates", new StackAssertionError( + "Invalid Stripe period dates detected (end <= start), using fallback dates", + { startTimestamp, endTimestamp, startDate, endDate, useStripeMock, ...context } + )); + + const now = new Date(); const defaultEnd = new Date(now); - defaultEnd.setMonth(defaultEnd.getMonth() + intervalMonths); + defaultEnd.setMonth(defaultEnd.getMonth() + 1); - return { start: defaultStart, end: defaultEnd }; + return { start: now, end: defaultEnd }; } export const getStackStripe = (overrides?: StripeOverridesMap) => { @@ -129,18 +131,36 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s continue; } const item = subscription.items.data[0]; - const sanitizedDates = sanitizeStripePeriodDates(item.current_period_start, item.current_period_end); + const sanitizedDates = sanitizeStripePeriodDates( + item.current_period_start, + item.current_period_end, + { subscriptionId: subscription.id, tenancyId: tenancy.id } + ); const priceId = subscription.metadata.priceId as string | undefined; - // old subscriptions were created with offer metadata instead of product metadata - const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined; - if (!productString) { - throw new StackAssertionError("Stripe subscription metadata missing product or offer", { subscriptionId: subscription.id }); - } - let productJson: InputJsonValue; - try { - productJson = JSON.parse(productString); - } catch (error) { - throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error }); + + let productJson: Prisma.InputJsonValue; + const productVersionId = subscription.metadata.productVersionId as string | undefined; + if (productVersionId) { + const version = await getProductVersion({ + prisma, + tenancyId: tenancy.id, + productVersionId, + }); + productJson = version.productJson as Prisma.InputJsonValue; + } else { + // Backward compat: old subscriptions have product JSON directly in metadata or even older subscriptions were created with offer metadata + const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined; + if (!productString) { + throw new StackAssertionError("Stripe subscription metadata missing productVersionId, product, or offer", { + subscriptionId: subscription.id, + tenancyId: tenancy.id, + }); + } + try { + productJson = JSON.parse(productString); + } catch (error) { + throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error }); + } } await prisma.subscription.upsert({ From 68b39edccfa5ccc279396202813163cca4dcc618 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Thu, 19 Feb 2026 13:14:05 -0800 Subject: [PATCH 3/3] refactor/fix: pulling product info from metadata Some stripe webhook handlers didnt incorporate the fallback to product and even to offer when reading product info from metadata. This fallback existed because of backwards compatibility. We create one helper function as a source of truth and reuse it everywhere, with appropriate error handling and fallback. --- .../integrations/stripe/webhooks/route.tsx | 27 +-- apps/backend/src/lib/stripe.tsx | 163 +++++++++++++++--- 2 files changed, 151 insertions(+), 39 deletions(-) diff --git a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx index 5681a776fe..d70e6c3ad7 100644 --- a/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx @@ -1,7 +1,6 @@ import { sendEmailToMany, type EmailOutboxRecipient } from "@/lib/emails"; import { listPermissions } from "@/lib/permissions"; -import { getProductVersion } from "@/lib/product-versions"; -import { getStackStripe, getStripeForAccount, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; +import { getStackStripe, getStripeForAccount, resolveProductFromStripeMetadata, syncStripeSubscriptions, upsertStripeInvoice } from "@/lib/stripe"; import type { StripeOverridesMap } from "@/lib/stripe-proxy"; import { getTelegramConfig, sendTelegramMessage } from "@/lib/telegram"; import { getTenancy, type Tenancy } from "@/lib/tenancies"; @@ -185,10 +184,12 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { const tenancy = await getTenancyForStripeAccountId(accountId, mockData); const prisma = await getPrismaClientForTenancy(tenancy); - const productVersionId = metadata.productVersionId as string | undefined; - const product = productVersionId - ? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson - : JSON.parse(metadata.product || "{}"); + const product = await resolveProductFromStripeMetadata({ + prisma, + tenancyId: tenancy.id, + metadata: metadata as Record, + context: { paymentIntentId: paymentIntent.id }, + }); const qty = Math.max(1, Number(metadata.purchaseQuantity || 1)); const stripePaymentIntentId = paymentIntent.id; @@ -232,7 +233,7 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { customerId: metadata.customerId, }); const receiptLink = paymentIntent.charges?.data?.[0]?.receipt_url ?? null; - const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase"; + const productName = product.displayName ?? "Purchase"; const extraVariables: Record = { productName, quantity: qty, @@ -270,11 +271,13 @@ async function processStripeWebhookEvent(event: Stripe.Event): Promise { customerType, customerId: metadata.customerId, }); - const productVersionId = metadata.productVersionId as string | undefined; - const product = productVersionId - ? (await getProductVersion({ prisma, tenancyId: tenancy.id, productVersionId })).productJson - : JSON.parse(metadata.product || "{}"); - const productName = typeof product?.displayName === "string" ? product.displayName : "Purchase"; + const product = await resolveProductFromStripeMetadata({ + prisma, + tenancyId: tenancy.id, + metadata: metadata as Record, + context: { paymentIntentId: paymentIntent.id }, + }); + const productName = product.displayName ?? "Purchase"; const failureReason = paymentIntent.last_payment_error?.message; const extraVariables: Record = { productName, diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index 4b26050ae5..e722b45b1b 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -1,11 +1,13 @@ -import { CustomerType, Prisma } from "@/generated/prisma/client"; +import { CustomerType } from "@/generated/prisma/client"; import { getProductVersion } from "@/lib/product-versions"; import { getTenancy, Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import type { productSchema } from "@stackframe/stack-shared/dist/schema-fields"; import { typedIncludes } from "@stackframe/stack-shared/dist/utils/arrays"; import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import Stripe from "stripe"; +import type * as yup from "yup"; import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); @@ -17,6 +19,9 @@ const stripeConfig: Stripe.StripeConfig = useStripeMock ? { port: Number(`${stackPortPrefix}23`), } : {}; +/** Product type as stored in Stripe metadata (same as config product schema) */ +export type StripeMetadataProduct = yup.InferType; + /** * Sanitizes subscription period dates from Stripe. * @@ -56,6 +61,128 @@ export function sanitizeStripePeriodDates( return { start: now, end: defaultEnd }; } +/** + * Resolves product JSON from Stripe metadata with backward compatibility. + * + * Resolution order: + * 1. productVersionId - new approach, looks up ProductVersion table + * 2. product - older approach, JSON string in metadata + * 3. offer - oldest approach, JSON string in metadata (legacy naming) + * + * @throws StackAssertionError if none of the above are found + */ +export async function resolveProductFromStripeMetadata(options: { + prisma: Parameters[0]['prisma'], + tenancyId: string, + metadata: Record, + context?: { subscriptionId?: string, paymentIntentId?: string }, +}): Promise { + const productVersionId = options.metadata.productVersionId; + if (productVersionId) { + const version = await getProductVersion({ + prisma: options.prisma, + tenancyId: options.tenancyId, + productVersionId, + }); + return version.productJson as StripeMetadataProduct; + } + + const productString = options.metadata.product ?? options.metadata.offer; + if (productString) { + try { + return JSON.parse(productString) as StripeMetadataProduct; + } catch (error) { + throw new StackAssertionError( + "Failed to parse product JSON from Stripe metadata. The 'product' or 'offer' field contains invalid JSON.", + { + ...options.context, + tenancyId: options.tenancyId, + productString, + metadata: options.metadata, + error, + } + ); + } + } + + throw new StackAssertionError( + "Stripe metadata is missing product information. Expected one of: 'productVersionId' (current), 'product' (legacy), or 'offer' (oldest). This may indicate the purchase was created before product tracking was implemented, or the metadata was corrupted.", + { + ...options.context, + tenancyId: options.tenancyId, + metadata: options.metadata, + } + ); +} + +import.meta.vitest?.describe("resolveProductFromStripeMetadata", (test) => { + const mockProduct = { displayName: "Test Product", customerType: "team" as const }; + + // Note: productVersionId path is tested via E2E tests since it requires database mocking + + test("falls back to 'product' metadata (legacy format)", async ({ expect }) => { + const result = await resolveProductFromStripeMetadata({ + prisma: {} as any, + tenancyId: "tenant-1", + metadata: { product: JSON.stringify(mockProduct) }, + }); + + expect(result).toEqual(mockProduct); + }); + + test("falls back to 'offer' metadata (oldest format)", async ({ expect }) => { + const result = await resolveProductFromStripeMetadata({ + prisma: {} as any, + tenancyId: "tenant-1", + metadata: { offer: JSON.stringify(mockProduct) }, + }); + + expect(result).toEqual(mockProduct); + }); + + test("prefers 'product' over 'offer' when both present", async ({ expect }) => { + const offerProduct = { displayName: "Offer Product", customerType: "user" as const }; + + const result = await resolveProductFromStripeMetadata({ + prisma: {} as any, + tenancyId: "tenant-1", + metadata: { + product: JSON.stringify(mockProduct), + offer: JSON.stringify(offerProduct), + }, + }); + + expect(result).toEqual(mockProduct); + }); + + test("throws on invalid JSON in product field", async ({ expect }) => { + await expect(resolveProductFromStripeMetadata({ + prisma: {} as any, + tenancyId: "tenant-1", + metadata: { product: "not valid json" }, + })).rejects.toThrow("Failed to parse product JSON"); + }); + + test("throws when no product info in metadata", async ({ expect }) => { + await expect(resolveProductFromStripeMetadata({ + prisma: {} as any, + tenancyId: "tenant-1", + metadata: {}, + })).rejects.toThrow("Stripe metadata is missing product information"); + }); + + test("includes context in error when provided", async ({ expect }) => { + await expect(resolveProductFromStripeMetadata({ + prisma: {} as any, + tenancyId: "tenant-1", + metadata: {}, + context: { subscriptionId: "sub-123" }, + })).rejects.toMatchObject({ + message: expect.stringContaining("missing product information"), + }); + }); +}); + export const getStackStripe = (overrides?: StripeOverridesMap) => { if (!stripeSecretKey) { throw new StackAssertionError("STACK_STRIPE_SECRET_KEY environment variable is not set"); @@ -138,30 +265,12 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s ); const priceId = subscription.metadata.priceId as string | undefined; - let productJson: Prisma.InputJsonValue; - const productVersionId = subscription.metadata.productVersionId as string | undefined; - if (productVersionId) { - const version = await getProductVersion({ - prisma, - tenancyId: tenancy.id, - productVersionId, - }); - productJson = version.productJson as Prisma.InputJsonValue; - } else { - // Backward compat: old subscriptions have product JSON directly in metadata or even older subscriptions were created with offer metadata - const productString = subscription.metadata.product as string | undefined ?? subscription.metadata.offer as string | undefined; - if (!productString) { - throw new StackAssertionError("Stripe subscription metadata missing productVersionId, product, or offer", { - subscriptionId: subscription.id, - tenancyId: tenancy.id, - }); - } - try { - productJson = JSON.parse(productString); - } catch (error) { - throw new StackAssertionError("Invalid JSON in Stripe subscription metadata", { subscriptionId: subscription.id, productString, error }); - } - } + const product = await resolveProductFromStripeMetadata({ + prisma, + tenancyId: tenancy.id, + metadata: subscription.metadata as Record, + context: { subscriptionId: subscription.id }, + }); await prisma.subscription.upsert({ where: { @@ -172,7 +281,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s }, update: { status: subscription.status, - product: productJson, + product, quantity: item.quantity ?? 1, currentPeriodEnd: sanitizedDates.end, currentPeriodStart: sanitizedDates.start, @@ -185,7 +294,7 @@ export async function syncStripeSubscriptions(stripe: Stripe, stripeAccountId: s customerType, productId: subscription.metadata.productId as string | undefined ?? subscription.metadata.offerId, priceId: priceId ?? null, - product: productJson, + product, quantity: item.quantity ?? 1, stripeSubscriptionId: subscription.id, status: subscription.status,