Skip to content

Commit 9576d1f

Browse files
authored
Merge pull request #1347 from MODSetter/fix/stripe-routes
fix: metadata extraction in Stripe checkout session
2 parents 3c0f318 + dd8c503 commit 9576d1f

1 file changed

Lines changed: 83 additions & 12 deletions

File tree

surfsense_backend/app/routes/stripe_routes.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,49 @@ def _normalize_optional_string(value: Any) -> str | None:
9999
def _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

Comments
 (0)