fix(webhooks): read invoice.subscription in both API shapes (acacia + dahlia)
The live Stripe webhook endpoint has NO pinned api_version, so it follows the account default (currently 2024-12-18.acacia), which delivers the subscription link as the top-level invoice.subscription. The code only read the new 2026-03-25.dahlia shape (invoice.parent.subscription_details.subscription), so recurring renewal/payment-failed events would have returned a null subscription id and silently failed to fulfill once the events were enabled. invoiceSubscriptionId() now reads the modern shape first, then falls back to the legacy top-level field. All other invoice fields used by the handlers (amount_due, attempt_count, hosted_invoice_url, id) are stable across both versions. +5 tests (legacy string/object, modern-preferred-over-legacy).
This commit is contained in:
parent
cf021e2f91
commit
8af2685d07
2 changed files with 28 additions and 10 deletions
|
|
@ -799,14 +799,24 @@ async function handlePaymentFailure(orderId: string, reason: string): Promise<vo
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the Stripe Subscription id from an Invoice. As of API 2026-03-25 the
|
* Extract the Stripe Subscription id from an Invoice, tolerant of API version.
|
||||||
* subscription link moved under invoice.parent.subscription_details.subscription
|
* As of API 2026-03-25 the subscription link moved under
|
||||||
* (the top-level invoice.subscription field was removed).
|
* invoice.parent.subscription_details.subscription and the top-level
|
||||||
|
* invoice.subscription field was removed; older versions (incl. the account
|
||||||
|
* default 2024-12-18.acacia that the unpinned live endpoint follows) still send
|
||||||
|
* the top-level field. We read whichever is present.
|
||||||
*/
|
*/
|
||||||
function invoiceSubscriptionId(inv: Stripe.Invoice): string | null {
|
function invoiceSubscriptionId(inv: Stripe.Invoice): string | null {
|
||||||
const sub = inv.parent?.subscription_details?.subscription;
|
// New API (>= 2026-03-25.dahlia): invoice.parent.subscription_details.subscription
|
||||||
if (!sub) return null;
|
const modern = inv.parent?.subscription_details?.subscription;
|
||||||
return typeof sub === "string" ? sub : sub.id;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 keeps $79 unit_amount", built[0].price_data.unit_amount === 7900);
|
||||||
check("recurring line item defaults quantity=1", built[0].quantity === 1);
|
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 {
|
function invoiceSubscriptionId(inv: any): string | null {
|
||||||
const sub = inv?.parent?.subscription_details?.subscription;
|
// New API (>= 2026-03-25.dahlia)
|
||||||
if (!sub) return null;
|
const modern = inv?.parent?.subscription_details?.subscription;
|
||||||
return typeof sub === "string" ? sub : sub.id;
|
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 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 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 when no parent", invoiceSubscriptionId({ id: "in_1" }) === null);
|
||||||
check("subId null for one-time invoice", invoiceSubscriptionId({ parent: { type: "quote_details" } }) === null);
|
check("subId null for one-time invoice", invoiceSubscriptionId({ parent: { type: "quote_details" } }) === null);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue