feat: simulate promo offer eligibility in paywall previews#6994
Merged
Conversation
Adds a `simulatePromoEligible` flag to PaywallView's @_spi(Internal) init, mirroring the intro `simulateEligible` seam, so previews/mocks can render promo components and pricing without a real subscriber or a backend-signed offer. PaywallPromoOfferCache gains an internal simulate mode that fabricates dummy signed offers locally (no network, no SubscriptionHistoryTracker). The purchase path reads a new `purchasableOffer(for:)` that returns nil in simulate mode, so fabricated sentinel-signed offers never reach a real purchase while display paths still resolve via `get(for:)`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MonikaMateska
approved these changes
Jun 16, 2026
MonikaMateska
left a comment
Member
There was a problem hiding this comment.
Looks solid, thanks for introducing this!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Checklist
purchases-androidand hybridsMotivation
We want the paywall preview in RC Mobile to mock promotional offers the same way it already mocks intro offers, so designers/PMs can see promo components and pricing without a real subscriber or a backend-signed offer. This is the SDK half (the injection seam); the app side is a separate follow-up.
Description
Adds
simulatePromoEligibletoPaywallView's@_spi(Internal)init, mirroring the existing introsimulateEligibleseam. When on,PaywallPromoOfferCacheruns an internal simulate mode that fabricates dummy signed offers locally (via the existingStoreProductDiscount.promotionalOffer(withSignedDataIdentifier:…)SPI helper) instead of hitting the backend, so promo visibility/pricing resolve in a preview.Promo offers (unlike intro) carry a signed token that flows into purchase, so the purchase path now reads a new
purchasableOffer(for:)that returnsnilin simulate mode. Display paths still readget(for:). That keeps the fabricated sentinel-signed offer display-only and out of any real purchase.SDK piece only. App-side work (mock
.promotionaldiscount, the preview toggle, renaming the mislabeledincludePromoOfferInPaywall) lands separately inrevenuecat-mobile-ios, blocked on this + a version bump.AI session context
AI Context
Metadata
facu/paywall-simulate-promo-eligible/codex:review,/codex:adversarial-review)Goal
Add an SDK injection seam so paywall previews can mock promotional offers (visibility + pricing) without a real subscriber or backend signature, mirroring the existing intro-offer mock.
Initial Prompt
Implement the SDK piece described in
mock-promotional-offers-paywall-preview.md(Piece 1): givePaywallViewa promo-eligibility injection seam mirroring the introsimulateEligiblepath, so promo components are visible and promo pricing resolves with no real subscription and no network.Important Follow-up Prompts
/codex-review-fix-loopto converge.simulatePromoEligiblebehind#if DEBUG.Agent Contribution
simulatePromoEligibleBool flag) over Approach B (publishing the cache type).PaywallPromoOfferCacheTests(RED → GREEN), simulate mode,purchasableOffer(for:), and thePaywallViewflag.Human Decisions
#if DEBUGrecommendation: the seam stays ungated, matching the intro seam (RC Mobile preview ships in release builds).Key Implementation Decisions
simulatePromoEligible: Boolon the@_spi(Internal)init; cache staysinternal.simulateEligible, no new public type/enum, reuses the existingcomputeEligibilityflow so product-code matching is config-faithful.PaywallPromoOfferCache@_spi publicwith a seeding factory (larger surface, pushes tracker/seeding to the app).purchasableOffer(for:)returningnilin simulate mode.get(for:).get(for:)return nil for simulated (would break display) and#if DEBUG/fail-closed gating (would break the release-build preview, diverges from the intro seam).SubscriptionHistoryTracker(designated init's tracker made optional).Transaction.updatessubscription.Files / Symbols Touched
RevenueCatUI/Templates/V2/EnvironmentObjects/PaywallPromoOfferCache.swiftinit(subscriptionHistoryTracker:simulateEligible:),init(simulateEligible:),purchasableOffer(for:),checkSignedEligibility,makeSimulatedOffer(for:).RevenueCatUI/PaywallView.swift@_spi(Internal)init.init(offering:…simulatePromoEligible:…).RevenueCatUI/Templates/V2/Components/Packages/PurchaseButton/PurchaseButtonComponentView.swiftpurchasableOffer(for:).Tests/RevenueCatUITests/PaywallsV2/PaywallPromoOfferCacheTests.swiftDependencies / Config / Migrations
@_spi(Internal)members are excluded fromapi/*.swiftinterface, no regeneration needed.Validation
xcodebuild test -workspace . -scheme RevenueCatUI -only-testing:RevenueCatUITests/PaywallPromoOfferCacheTests: 4/4 passed.swiftlinton changed files: no violations.Validation Gaps
PaywallView-level snapshot of simulate mode; the rendered-pricing chain is covered viaVariableHandlerV2instead..taskis skipped there); it runs in a normally-launched preview app.Review Focus
simulatePromoEligibleungated (no#if DEBUG) acceptable, given it matches the intro seam and the purchase path fails safe?paywallPromoOfferCache.get(for:)into a purchase (onlyPurchaseButtonComponentViewdoes today).Risks / Reviewer Notes
PurchaseButtonComponentViewnow usespurchasableOffer(for:)which returns nil in simulate mode; covered bytestSimulateEligibleDoesNotExposeOfferToPurchasePath.Non-goals / Out of Scope
revenuecat-mobile-ios(mock.promotionaldiscount, preview toggle, renamingincludePromoOfferInPaywall).Omitted Context
Note
Medium Risk
Touches the purchase path for real promotional offers via a new accessor; simulate mode is designed to block fabricated offers from purchases, but any missed call site using
get(for:)for checkout would be risky.Overview
Adds a
simulatePromoEligibleflag onPaywallView's@_spi(Internal)initializer (parallel to intro-offer mocking) so paywall previews can show promo UI and{{ product.offer_price }}without a real subscriber or backend-signed offer.When enabled,
PaywallPromoOfferCacheruns in simulate mode: it skipsSubscriptionHistoryTracker/ network signing and seeds dummy signed offers for packages whose StoreKit discount matches the configured promo product code. Display still usesget(for:); purchases now go throughpurchasableOffer(for:), which returnsnilin simulate mode so sentinel signing data never reaches StoreKit.PurchaseButtonComponentViewwas updated to use that purchase-safe accessor.New unit tests cover seeding, purchase isolation, promo price rendering, and non-matching discounts.
Reviewed by Cursor Bugbot for commit 2fc0d6e. Bugbot is set up for automated code reviews on this repo. Configure here.