Skip to content

fix(billing): drop over-deep Stripe expand in /sync, fetch product per-sub#3853

Merged
bokelley merged 1 commit into
mainfrom
bokelley/sync-product-fetch-fallback
May 2, 2026
Merged

fix(billing): drop over-deep Stripe expand in /sync, fetch product per-sub#3853
bokelley merged 1 commit into
mainfrom
bokelley/sync-product-fetch-fallback

Conversation

@bokelley

@bokelley bokelley commented May 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Hotfix on top of #3850. The previous attempt expanded data.items.data.price.product on subscriptions.list, which is 5 levels deep and still over Stripe's 4-level expansion limit. /sync continued to fail with "Failed to sync from Stripe" for every org.

What changed

New approach in pickMembershipSubWithProductFetch (async, server/src/billing/membership-prices.ts):

  1. Try the lookup_key fast path first via the existing pickMembershipSub — covers the modern cohort with zero extra Stripe round-trips.
  2. When no sub has a recognized aao_membership_* lookup_key, fall back to retrieving the product per candidate via stripe.products.retrieve and checking metadata.category === "membership". Founding-era subs (Adzymic, Advertible, Bidcliq, Equativ — May 2026) recover through this path.

/sync (server/src/routes/admin/accounts-billing.ts) lists subs without any deep expand and uses the new helper.

Synchronous pickMembershipSub is unchanged — invariant callers that walk every sub on the AAO Stripe account can't afford per-sub round-trips.

Test plan

  • 35 unit tests pass (added 5 new cases for pickMembershipSubWithProductFetch: fast-path, metadata-fallback, neither-matches, transient-failure resilience, already-expanded product)
  • Integration test for /sync revenue backfill updated to mock stripe.subscriptions.list and stripe.products.retrieve
  • Typecheck clean
  • After merge: re-run /sync against Adzymic — row should now show populated stripe_subscription_id, subscription_price_lookup_key, membership_tier
  • After merge: hit GET /api/admin/integrity/check/every-entitled-org-has-resolvable-tier to confirm Adzymic clears

🤖 Generated with Claude Code

…r-sub

Hotfix on top of #3850. The previous attempt expanded
'data.items.data.price.product' on subscriptions.list, which is 5 levels
deep and still over Stripe's 4-level expansion limit. /sync kept failing.

New approach: list subs without product expansion. The lookup_key fast
path covers the modern cohort with zero extra round-trips. When no sub
has a recognized lookup_key, fall back to retrieving the product per
candidate via stripe.products.retrieve to check metadata.category=
membership — Adzymic/Advertible/Bidcliq/Equativ-shape recovers through
this path.

New pickMembershipSubWithProductFetch async helper. Sync pickMembershipSub
is unchanged for invariant callers that walk every sub on the account
and can't afford per-sub round-trips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit 629b0b0 into main May 2, 2026
13 checks passed
@bokelley bokelley deleted the bokelley/sync-product-fetch-fallback branch May 2, 2026 17:33
bokelley added a commit that referenced this pull request May 2, 2026
…sync (#3872)

* fix(billing): drop over-deep Stripe expand at three sister sites of /sync

The /sync hotfix (#3853) replaced its over-deep `data.items.data.price.product`
expand with a separate `subscriptions.list` + per-product fetch. Three sister
sites kept the broken pattern:

- `dedup-on-subscription-created.ts`: 5-level expand on `subscriptions.list`.
  Catch swallowed the Stripe error and fell through to `no_duplicate`,
  silently disabling the cross-path duplicate-subscription guard from #3245.
- `stripe-sub-reflected-in-org-row` invariant: 5-level expand on
  `subscriptions.list`. Threw on every run, so Lina/Adzymic-class detection
  never fired.
- `/api/admin/backfill-revenue`: 6-level
  `subscriptions.data.items.data.price.product` on `customers.retrieve`.
  Whole loop crashed per customer.

Drop expand at all three; classify by lookup_key with a per-product
`products.retrieve` fallback (cached) for founding-era prices. Tests stay
green: dedup tier-label fallback to `lookup_key` was already present, and
the invariant tests pass `product` as an inline object that
`isMembershipProductByMetadata` already accepts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(billing): address review — backfill cap, customer expand, founding-era test fidelity

Code/security review on PR #3872 surfaced three real issues:

- /backfill-revenue used limit:10 on subscriptions.list — could silently
  truncate the membership sub on customers with many lifetime subs.
  Bumped to 100, matching the dedup helper.
- The invariant's orphan-customer violation reads sub.customer.email,
  but the new code path doesn't expand customer, so the field always
  serialized null and admins lost the triage hint. Added
  expand: ['data.customer'] (2 levels, well under the limit).
- Founding-era invariant tests (Bidcliq, Equativ) passed `product` as
  an inline object, masking the real Stripe response shape. Rewrote to
  pass product as a string id, mock products.retrieve, and assert it
  was called — actually exercises the new fetch fallback path. Added
  a cache-coalescing test and a fetch-failure swallow test.

Also adds direct unit tests for the new isMembershipSubWithProductFetch
helper covering lookup_key fast path, metadata fallback, cache reuse,
and the silent-false-on-fetch-error path that's easy to misuse.

Test count: 145 passing (+34 from prior commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant