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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-03 18:05:55 +01:00
parent 72c4de91b0
commit 7da6a4737d
2 changed files with 33 additions and 4 deletions

View File

@@ -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.

View File

@@ -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",