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
This commit is contained in:
saravanakumardb1 2026-02-15 15:01:02 -08:00
parent a699dd9073
commit b987dec92c
2 changed files with 32 additions and 7 deletions

View File

@ -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;
}

View File

@ -32,6 +32,18 @@ export async function getByUserId(
return resources[0] ?? null;
}
export async function getByStripeCustomerIdAnyProduct(
stripeCustomerId: string
): Promise<SubscriptionDoc | null> {
const { resources } = await subContainer()
.items.query<SubscriptionDoc>({
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