diff --git a/api/src/routes/webhooks.ts b/api/src/routes/webhooks.ts index 7029050..0391300 100644 --- a/api/src/routes/webhooks.ts +++ b/api/src/routes/webhooks.ts @@ -799,14 +799,24 @@ async function handlePaymentFailure(orderId: string, reason: string): Promise= 2026-03-25.dahlia): invoice.parent.subscription_details.subscription + const modern = inv.parent?.subscription_details?.subscription; + if (modern) return typeof modern === "string" ? modern : modern.id; + // Legacy API (<= 2025): top-level invoice.subscription. The live webhook + // endpoint has NO pinned api_version, so it follows the account default + // (currently 2024-12-18.acacia) which delivers this older shape. We must read + // both or recurring renewals silently fail to map back to an order. + const legacy = (inv as unknown as { subscription?: string | { id: string } }).subscription; + if (legacy) return typeof legacy === "string" ? legacy : legacy.id; + return null; } /** diff --git a/api/tests/recurring-subscription.test.ts b/api/tests/recurring-subscription.test.ts index 32545c3..76d4725 100644 --- a/api/tests/recurring-subscription.test.ts +++ b/api/tests/recurring-subscription.test.ts @@ -80,14 +80,22 @@ check("recurring line item has recurring.interval=month", built[0].price_data.re check("recurring line item keeps $79 unit_amount", built[0].price_data.unit_amount === 7900); check("recurring line item defaults quantity=1", built[0].quantity === 1); -// ── 7. invoiceSubscriptionId extraction (mirrors webhooks.ts; API 2026-03-25) +// ── 7. invoiceSubscriptionId extraction (mirrors webhooks.ts; version-tolerant) function invoiceSubscriptionId(inv: any): string | null { - const sub = inv?.parent?.subscription_details?.subscription; - if (!sub) return null; - return typeof sub === "string" ? sub : sub.id; + // New API (>= 2026-03-25.dahlia) + const modern = inv?.parent?.subscription_details?.subscription; + if (modern) return typeof modern === "string" ? modern : modern.id; + // Legacy API (<= 2025, incl. account default 2024-12-18.acacia on the + // unpinned live endpoint): top-level invoice.subscription + const legacy = inv?.subscription; + if (legacy) return typeof legacy === "string" ? legacy : legacy.id; + return null; } check("subId from string field", invoiceSubscriptionId({ parent: { subscription_details: { subscription: "sub_123" } } }) === "sub_123"); check("subId from object field", invoiceSubscriptionId({ parent: { subscription_details: { subscription: { id: "sub_456" } } } }) === "sub_456"); +check("subId from legacy top-level string (acacia)", invoiceSubscriptionId({ subscription: "sub_legacy" }) === "sub_legacy"); +check("subId from legacy top-level object", invoiceSubscriptionId({ subscription: { id: "sub_legacy2" } }) === "sub_legacy2"); +check("subId prefers modern over legacy", invoiceSubscriptionId({ subscription: "sub_old", parent: { subscription_details: { subscription: "sub_new" } } }) === "sub_new"); check("subId null when no parent", invoiceSubscriptionId({ id: "in_1" }) === null); check("subId null for one-time invoice", invoiceSubscriptionId({ parent: { type: "quote_details" } }) === null);