@@ -99,20 +99,49 @@ def _normalize_optional_string(value: Any) -> str | None:
9999def _get_metadata (checkout_session : Any ) -> dict [str , str ]:
100100 """Extract checkout session metadata as a plain ``str -> str`` dict.
101101
102- Works for both ``dict`` (e.g. when the metadata round-tripped through
103- JSON) and Stripe's ``StripeObject`` wrapper. Recent Stripe SDK
104- versions stopped subclassing ``dict`` for ``StripeObject``, so
105- ``isinstance(metadata, dict)`` is False and ``dict(metadata)`` falls
106- into the sequence protocol, looking up integer indices and raising
107- ``KeyError: 0``. ``.items()`` is exposed by both shapes via the
108- Mapping protocol, so we always use that.
102+ In ``stripe>=15.0`` ``StripeObject`` is no longer a ``dict`` subclass
103+ and exposes neither ``items()`` nor ``__iter__`` nor ``keys()``.
104+ ``dict(obj)`` falls into the sequence protocol and raises
105+ ``KeyError: 0``; ``obj.items()`` raises ``AttributeError``. The
106+ supported way to materialize a ``StripeObject`` as a plain dict is
107+ its ``to_dict()`` method (added in stripe-python 8.x, present in 15.x).
109108 """
110- metadata = getattr (checkout_session , "metadata" , None ) or {}
111- try :
112- items = metadata .items ()
113- except AttributeError :
109+ metadata = getattr (checkout_session , "metadata" , None )
110+ if metadata is None :
114111 return {}
115- return {str (key ): str (value ) for key , value in items }
112+
113+ # 1. Plain dict (older SDKs that subclassed dict, JSON-decoded events
114+ # in tests, etc.).
115+ if isinstance (metadata , dict ):
116+ return {str (k ): str (v ) for k , v in metadata .items ()}
117+
118+ # 2. Modern Stripe SDK: every ``StripeObject`` has ``to_dict()``.
119+ # ``recursive=False`` is correct because Stripe metadata values
120+ # are always primitive strings.
121+ to_dict = getattr (metadata , "to_dict" , None )
122+ if callable (to_dict ):
123+ try :
124+ d = to_dict (recursive = False )
125+ if isinstance (d , dict ):
126+ return {str (k ): str (v ) for k , v in d .items ()}
127+ except Exception :
128+ logger .exception (
129+ "Stripe metadata.to_dict() failed for session %s" ,
130+ getattr (checkout_session , "id" , "?" ),
131+ )
132+
133+ # 3. Last-resort: read the SDK's private ``_data`` backing dict.
134+ # Stable across stripe-python 6.x -> 15.x.
135+ inner = getattr (metadata , "_data" , None )
136+ if isinstance (inner , dict ):
137+ return {str (k ): str (v ) for k , v in inner .items ()}
138+
139+ logger .warning (
140+ "Could not extract metadata from checkout session %s (metadata type=%s)" ,
141+ getattr (checkout_session , "id" , "?" ),
142+ type (metadata ).__name__ ,
143+ )
144+ return {}
116145
117146
118147# Canonical purchase_type metadata values. ``premium_credit`` was emitted
@@ -584,6 +613,48 @@ async def finalize_checkout(
584613 payment_status = getattr (checkout_session , "payment_status" , None )
585614 session_status = getattr (checkout_session , "status" , None )
586615
616+ # Defensive fallback: if metadata can't be read for any reason
617+ # (extraction failure, manually-created session in Stripe dashboard,
618+ # SDK upgrade breaking ``to_dict``, etc.) we'd otherwise route every
619+ # purchase to the page_packs handler and get stuck. Resolve the
620+ # purchase_type by checking which table actually has the row keyed
621+ # by this Stripe session id.
622+ if not metadata :
623+ existing_token_purchase = (
624+ await db_session .execute (
625+ select (PremiumTokenPurchase .id ).where (
626+ PremiumTokenPurchase .stripe_checkout_session_id
627+ == str (checkout_session .id )
628+ )
629+ )
630+ ).scalar_one_or_none ()
631+ if existing_token_purchase is not None :
632+ is_token = True
633+ else :
634+ existing_page_purchase = (
635+ await db_session .execute (
636+ select (PagePurchase .id ).where (
637+ PagePurchase .stripe_checkout_session_id
638+ == str (checkout_session .id )
639+ )
640+ )
641+ ).scalar_one_or_none ()
642+ if existing_page_purchase is None :
643+ logger .error (
644+ "finalize_checkout: no purchase row in either table "
645+ "and metadata is empty for session=%s user=%s" ,
646+ session_id ,
647+ user .id ,
648+ )
649+ # Fall through; downstream path will short-circuit on
650+ # missing-row + empty-metadata.
651+ logger .info (
652+ "finalize_checkout: recovered purchase_type=%s for session=%s "
653+ "via DB fallback (metadata was empty)" ,
654+ "premium_tokens" if is_token else "page_packs" ,
655+ session_id ,
656+ )
657+
587658 is_paid = payment_status in {"paid" , "no_payment_required" }
588659 is_expired = session_status == "expired"
589660
0 commit comments