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:
parent
a699dd9073
commit
b987dec92c
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user