The Pro sheet's package list came straight from the monthly/yearly
RevenueCat offerings (with an unfiltered current-offering fallback),
trusting dashboard config to keep tiers separate. It hasn't: a 1.0.0
user bought bookplayer.lite.yearly from the Pro-branded sheet, then
spent a day re-importing books whose audio a LITE tier can never
upload or stream (server gates S3 to PRO; every library row stuck at
synced=false).
resolvePackages now hard-filters by PRODUCT id keyword ("pro"/"lite")
after every branch — the tier a sheet sells no longer depends on
offering contents. Pinned by unit tests against a mixed offering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp
The user report behind this
A user (rprimus@) bought a subscription and "can't stream their books." Debug file + prod data tell the story:
bookplayer.lite.yearlyunder an anonymous RC ID, from the Android 1.0.0 Pro-branded paywall: that build listed every package of the monthly/yearly offerings, and the dashboard offerings carry lite packages alongside pro — so the "cheap yearly" they picked was LITE.requireS3Upload= PRO), and their 1.0.0 build predates the client-sideisPro()upload gate, so uploads queued and stalled forever. Every one of their 12 library rows sits atsynced=false— no audio ever reached the cloud, so nothing can stream.What this PR fixes
resolvePackagestrusted offerings to be tier-pure — dashboard config the code doesn't control (and which is mixed today). It now hard-filters by product id keyword after every branch: the Pro sheet can only sell*pro*products,LitePaywallSheetonly*lite*, regardless of what the offerings contain. Unit tests pin both directions against a mixed offering, plus the Googleproduct:basePlanid form.Not in this PR
🤖 Generated with Claude Code
https://claude.ai/code/session_01YNhXd8EkXcrjBVYLHm5ZSp