From b987dec92c3a4930ab3cf0faf898c8b7555b619a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 15 Feb 2026 15:01:02 -0800 Subject: [PATCH] fix(platform-service): make Stripe webhook product routing resilient - Added cross-product fallback lookup by stripeCustomerId when metadata lacks productId - Ensure invoice payments are stored under the resolved subscription productId - Normalize checkout metadata plan value before persistence/sync - Keep auth plan sync aligned with resolved product context - Verified: tsc --noEmit clean, 20 test files / 183 tests passing --- .../src/modules/stripe/routes.ts | 27 ++++++++++++++----- .../src/modules/subscriptions/repository.ts | 12 +++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/services/platform-service/src/modules/stripe/routes.ts b/services/platform-service/src/modules/stripe/routes.ts index 22b77c32..6fe61739 100644 --- a/services/platform-service/src/modules/stripe/routes.ts +++ b/services/platform-service/src/modules/stripe/routes.ts @@ -115,7 +115,7 @@ export async function stripeRoutes(app: FastifyInstance) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; const userId = session.metadata?.userId; - const plan = session.metadata?.plan || 'pro'; + const plan = normalizePlan(session.metadata?.plan) ?? 'pro'; if (userId && session.customer) { const existing = await subRepo.getByUserId(userId, eventProductId); const now = new Date(); @@ -177,8 +177,11 @@ export async function stripeRoutes(app: FastifyInstance) { case 'customer.subscription.updated': { const sub = event.data.object as Stripe.Subscription; const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; - const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId); + const existing = metadata?.productId + ? await subRepo.getByStripeCustomerId(customerId, eventProductId) + : await subRepo.getByStripeCustomerIdAnyProduct(customerId); if (existing) { + const effectiveProductId = existing.productId; const updatedPlan = getPlanFromSubscription(sub) ?? existing.plan; const newStatus = sub.cancel_at_period_end ? 'cancelled' @@ -191,7 +194,7 @@ export async function stripeRoutes(app: FastifyInstance) { cancelAtPeriodEnd: sub.cancel_at_period_end, currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(), }); - await authRepo.updatePlan(existing.userId, eventProductId, updatedPlan); + await authRepo.updatePlan(existing.userId, effectiveProductId, updatedPlan); // Sync plan to backend users container await syncUserPlan(existing.userId, updatedPlan, app.log); } @@ -201,14 +204,17 @@ export async function stripeRoutes(app: FastifyInstance) { case 'customer.subscription.deleted': { const sub = event.data.object as Stripe.Subscription; const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id; - const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId); + const existing = metadata?.productId + ? await subRepo.getByStripeCustomerId(customerId, eventProductId) + : await subRepo.getByStripeCustomerIdAnyProduct(customerId); if (existing) { + const effectiveProductId = existing.productId; await subRepo.updateSubscription(existing.id, existing.userId, { status: 'cancelled', plan: 'free', cancelAtPeriodEnd: false, }); - await authRepo.updatePlan(existing.userId, eventProductId, 'free'); + await authRepo.updatePlan(existing.userId, effectiveProductId, 'free'); // Sync plan downgrade to backend users container await syncUserPlan(existing.userId, 'free', app.log); } @@ -220,11 +226,13 @@ export async function stripeRoutes(app: FastifyInstance) { const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id; if (customerId) { - const existing = await subRepo.getByStripeCustomerId(customerId, eventProductId); + const existing = metadata?.productId + ? await subRepo.getByStripeCustomerId(customerId, eventProductId) + : await subRepo.getByStripeCustomerIdAnyProduct(customerId); if (existing && invoice.amount_paid > 0) { await subRepo.createPayment({ id: `pay_${randomUUID()}`, - productId: eventProductId, + productId: existing.productId, userId: existing.userId, amount: invoice.amount_paid, currency: invoice.currency, @@ -286,3 +294,8 @@ function getPlanFromSubscription(sub: Stripe.Subscription): SubscriptionDoc['pla if (priceId === priceIds.pro) return 'pro'; return null; } + +function normalizePlan(plan: string | undefined): SubscriptionDoc['plan'] | null { + if (plan === 'free' || plan === 'pro' || plan === 'enterprise') return plan; + return null; +} diff --git a/services/platform-service/src/modules/subscriptions/repository.ts b/services/platform-service/src/modules/subscriptions/repository.ts index 42e76d65..cec8df62 100644 --- a/services/platform-service/src/modules/subscriptions/repository.ts +++ b/services/platform-service/src/modules/subscriptions/repository.ts @@ -32,6 +32,18 @@ export async function getByUserId( return resources[0] ?? null; } +export async function getByStripeCustomerIdAnyProduct( + stripeCustomerId: string +): Promise { + const { resources } = await subContainer() + .items.query({ + query: 'SELECT * FROM c WHERE c.stripeCustomerId = @cid ORDER BY c.createdAt DESC', + parameters: [{ name: '@cid', value: stripeCustomerId }], + }) + .fetchAll(); + return resources[0] ?? null; +} + export async function getByStripeCustomerId( stripeCustomerId: string, productId: string