From 7da6a4737d48924f5b181f6fa9f89ce4a7a060d3 Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 3 Mar 2026 18:05:55 +0100 Subject: [PATCH] fix(billing): extract current_period_end from Stripe subscription items Stripe API 2026-02+ moved current_period_end from subscription to subscription items. Add _get_period_end() helper that falls back to items[0].current_period_end when the subscription-level field is None. Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/billing/stripe.py | 25 +++++++++++++++++++++---- web/tests/test_billing_webhooks.py | 12 ++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/web/src/padelnomics/billing/stripe.py b/web/src/padelnomics/billing/stripe.py index 38f4177..0f217c5 100644 --- a/web/src/padelnomics/billing/stripe.py +++ b/web/src/padelnomics/billing/stripe.py @@ -186,7 +186,11 @@ def parse_webhook(payload: bytes) -> dict: ) - period_end = _unix_to_iso(sub.current_period_end) + # Stripe API 2026-02+ moved period_end to items + ts = sub.current_period_end + if not ts and sub.get("items", {}).get("data"): + ts = sub["items"]["data"][0].get("current_period_end") + period_end = _unix_to_iso(ts) except Exception: logger.warning("Failed to fetch subscription %s for period_end", subscription_id) @@ -230,7 +234,7 @@ def parse_webhook(payload: bytes) -> dict: "supplier_id": supplier_id, "plan": plan, "status": status, - "current_period_end": _unix_to_iso(obj.get("current_period_end")), + "current_period_end": _get_period_end(obj), "data": obj, "items": _extract_sub_items(obj), "custom_data": metadata, @@ -246,7 +250,7 @@ def parse_webhook(payload: bytes) -> dict: "supplier_id": supplier_id, "plan": plan, "status": status, - "current_period_end": _unix_to_iso(obj.get("current_period_end")), + "current_period_end": _get_period_end(obj), "data": obj, "items": _extract_sub_items(obj), "custom_data": metadata, @@ -261,7 +265,7 @@ def parse_webhook(payload: bytes) -> dict: "supplier_id": supplier_id, "plan": plan, "status": "cancelled", - "current_period_end": _unix_to_iso(obj.get("current_period_end")), + "current_period_end": _get_period_end(obj), "data": obj, "items": _extract_sub_items(obj), "custom_data": metadata, @@ -326,6 +330,19 @@ def _unix_to_iso(ts) -> str | None: return datetime.fromtimestamp(int(ts), tz=UTC).strftime("%Y-%m-%dT%H:%M:%S.000000Z") +def _get_period_end(obj: dict) -> str | None: + """Extract current_period_end from subscription or its first item. + + Stripe API 2026-02+ moved period fields from subscription to subscription items. + """ + ts = obj.get("current_period_end") + if not ts: + items = obj.get("items", {}).get("data", []) + if items: + ts = items[0].get("current_period_end") + return _unix_to_iso(ts) + + def _extract_line_items(session_obj: dict) -> list[dict]: """Extract line items from a Checkout Session in Paddle-compatible format. diff --git a/web/tests/test_billing_webhooks.py b/web/tests/test_billing_webhooks.py index 9b1b0cb..5d7360b 100644 --- a/web/tests/test_billing_webhooks.py +++ b/web/tests/test_billing_webhooks.py @@ -393,6 +393,18 @@ class TestStripeParseWebhook: ev = stripe_parse_webhook(payload) assert ev["event_type"] == "" + def test_period_end_from_items_fallback(self): + """Stripe API 2026-02+ puts current_period_end on items, not subscription.""" + payload = _stripe_event( + "customer.subscription.created", + {"id": "sub_123", "customer": "cus_456", "status": "active", + "items": {"data": [{"price": {"id": "price_abc"}, "current_period_end": 1740000000}]}}, + metadata={"user_id": "42"}, + ) + ev = stripe_parse_webhook(payload) + assert ev["current_period_end"] is not None + assert "2025-02-19" in ev["current_period_end"] + def test_trialing_status_maps_to_on_trial(self): payload = _stripe_event( "customer.subscription.created",