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': {
|
case 'checkout.session.completed': {
|
||||||
const session = event.data.object as Stripe.Checkout.Session;
|
const session = event.data.object as Stripe.Checkout.Session;
|
||||||
const userId = session.metadata?.userId;
|
const userId = session.metadata?.userId;
|
||||||
const plan = session.metadata?.plan || 'pro';
|
const plan = normalizePlan(session.metadata?.plan) ?? 'pro';
|
||||||
if (userId && session.customer) {
|
if (userId && session.customer) {
|
||||||
const existing = await subRepo.getByUserId(userId, eventProductId);
|
const existing = await subRepo.getByUserId(userId, eventProductId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -177,8 +177,11 @@ export async function stripeRoutes(app: FastifyInstance) {
|
|||||||
case 'customer.subscription.updated': {
|
case 'customer.subscription.updated': {
|
||||||
const sub = event.data.object as Stripe.Subscription;
|
const sub = event.data.object as Stripe.Subscription;
|
||||||
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
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) {
|
if (existing) {
|
||||||
|
const effectiveProductId = existing.productId;
|
||||||
const updatedPlan = getPlanFromSubscription(sub) ?? existing.plan;
|
const updatedPlan = getPlanFromSubscription(sub) ?? existing.plan;
|
||||||
const newStatus = sub.cancel_at_period_end
|
const newStatus = sub.cancel_at_period_end
|
||||||
? 'cancelled'
|
? 'cancelled'
|
||||||
@ -191,7 +194,7 @@ export async function stripeRoutes(app: FastifyInstance) {
|
|||||||
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
||||||
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
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
|
// Sync plan to backend users container
|
||||||
await syncUserPlan(existing.userId, updatedPlan, app.log);
|
await syncUserPlan(existing.userId, updatedPlan, app.log);
|
||||||
}
|
}
|
||||||
@ -201,14 +204,17 @@ export async function stripeRoutes(app: FastifyInstance) {
|
|||||||
case 'customer.subscription.deleted': {
|
case 'customer.subscription.deleted': {
|
||||||
const sub = event.data.object as Stripe.Subscription;
|
const sub = event.data.object as Stripe.Subscription;
|
||||||
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
|
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) {
|
if (existing) {
|
||||||
|
const effectiveProductId = existing.productId;
|
||||||
await subRepo.updateSubscription(existing.id, existing.userId, {
|
await subRepo.updateSubscription(existing.id, existing.userId, {
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
plan: 'free',
|
plan: 'free',
|
||||||
cancelAtPeriodEnd: false,
|
cancelAtPeriodEnd: false,
|
||||||
});
|
});
|
||||||
await authRepo.updatePlan(existing.userId, eventProductId, 'free');
|
await authRepo.updatePlan(existing.userId, effectiveProductId, 'free');
|
||||||
// Sync plan downgrade to backend users container
|
// Sync plan downgrade to backend users container
|
||||||
await syncUserPlan(existing.userId, 'free', app.log);
|
await syncUserPlan(existing.userId, 'free', app.log);
|
||||||
}
|
}
|
||||||
@ -220,11 +226,13 @@ export async function stripeRoutes(app: FastifyInstance) {
|
|||||||
const customerId =
|
const customerId =
|
||||||
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
|
||||||
if (customerId) {
|
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) {
|
if (existing && invoice.amount_paid > 0) {
|
||||||
await subRepo.createPayment({
|
await subRepo.createPayment({
|
||||||
id: `pay_${randomUUID()}`,
|
id: `pay_${randomUUID()}`,
|
||||||
productId: eventProductId,
|
productId: existing.productId,
|
||||||
userId: existing.userId,
|
userId: existing.userId,
|
||||||
amount: invoice.amount_paid,
|
amount: invoice.amount_paid,
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
@ -286,3 +294,8 @@ function getPlanFromSubscription(sub: Stripe.Subscription): SubscriptionDoc['pla
|
|||||||
if (priceId === priceIds.pro) return 'pro';
|
if (priceId === priceIds.pro) return 'pro';
|
||||||
return null;
|
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;
|
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(
|
export async function getByStripeCustomerId(
|
||||||
stripeCustomerId: string,
|
stripeCustomerId: string,
|
||||||
productId: string
|
productId: string
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user