Skip to content

feat: simulate promo offer eligibility in paywall previews#6994

Merged
facumenzella merged 3 commits into
mainfrom
facu/paywall-simulate-promo-eligible
Jun 16, 2026
Merged

feat: simulate promo offer eligibility in paywall previews#6994
facumenzella merged 3 commits into
mainfrom
facu/paywall-simulate-promo-eligible

Conversation

@facumenzella

@facumenzella facumenzella commented Jun 15, 2026

Copy link
Copy Markdown
Member

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

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 simulatePromoEligible to PaywallView's @_spi(Internal) init, mirroring the existing intro simulateEligible seam. When on, PaywallPromoOfferCache runs an internal simulate mode that fabricates dummy signed offers locally (via the existing StoreProductDiscount.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 returns nil in simulate mode. Display paths still read get(for:). That keeps the fabricated sentinel-signed offer display-only and out of any real purchase.

SDK piece only. App-side work (mock .promotional discount, the preview toggle, renaming the mislabeled includePromoOfferInPaywall) lands separately in revenuecat-mobile-ios, blocked on this + a version bump.

AI session context

AI Context

Metadata

  • PR: this PR
  • Branch: facu/paywall-simulate-promo-eligible
  • Author / human owner: facumenzella
  • Agent(s): Claude Code (Opus 4.8), with codex review (/codex:review, /codex:adversarial-review)
  • Session source: current conversation
  • Generated: 2026-06-15
  • Context document version: 1

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): give PaywallView a promo-eligibility injection seam mirroring the intro simulateEligible path, so promo components are visible and promo pricing resolves with no real subscription and no network.

Important Follow-up Prompts

  • Scope to the SDK seam only (Piece 1); app piece is a later follow-up.
  • Run /codex-review-fix-loop to converge.
  • Decision: do NOT gate simulatePromoEligible behind #if DEBUG.

Agent Contribution

  • Verified the spec against the code, then designed Approach A (simulatePromoEligible Bool flag) over Approach B (publishing the cache type).
  • Implemented test-first: PaywallPromoOfferCacheTests (RED → GREEN), simulate mode, purchasableOffer(for:), and the PaywallView flag.
  • Drove a 2-pass codex review-fix loop; applied a purchase-leak fix and an efficiency fix; added an end-to-end promo-price render test.

Human Decisions

  • Chose Approach A (flag) and SDK-only scope.
  • Overrode the adversarial #if DEBUG recommendation: the seam stays ungated, matching the intro seam (RC Mobile preview ships in release builds).

Key Implementation Decisions

  • Decision: simulatePromoEligible: Bool on the @_spi(Internal) init; cache stays internal.
    • Rationale: smallest surface, mirrors intro simulateEligible, no new public type/enum, reuses the existing computeEligibility flow so product-code matching is config-faithful.
    • Rejected: making PaywallPromoOfferCache @_spi public with a seeding factory (larger surface, pushes tracker/seeding to the app).
  • Decision: purchase-safety guard via purchasableOffer(for:) returning nil in simulate mode.
    • Rationale: keeps fabricated sentinel-signed offers out of real purchases while display still resolves via get(for:).
    • Rejected: making 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).
  • Decision: simulate mode constructs no SubscriptionHistoryTracker (designated init's tracker made optional).
    • Rationale: its StoreKit work is never consulted in simulate mode; avoids a wasted persistent Transaction.updates subscription.

Files / Symbols Touched

  • RevenueCatUI/Templates/V2/EnvironmentObjects/PaywallPromoOfferCache.swift
    • Why: add simulate mode + purchase-safe accessor.
    • Symbols: init(subscriptionHistoryTracker:simulateEligible:), init(simulateEligible:), purchasableOffer(for:), checkSignedEligibility, makeSimulatedOffer(for:).
    • Review relevance: the simulate branch and that display vs purchase use different accessors.
  • RevenueCatUI/PaywallView.swift
    • Why: surface the flag on the @_spi(Internal) init.
    • Symbols: init(offering:…simulatePromoEligible:…).
  • RevenueCatUI/Templates/V2/Components/Packages/PurchaseButton/PurchaseButtonComponentView.swift
    • Why: route purchase through purchasableOffer(for:).
  • Tests/RevenueCatUITests/PaywallsV2/PaywallPromoOfferCacheTests.swift
    • Why: cover seeding, no-match, purchase-safety, and end-to-end promo-price rendering.

Dependencies / Config / Migrations

  • None. API-safe: @_spi(Internal) members are excluded from api/*.swiftinterface, no regeneration needed.

Validation

  • Commands run:
    • xcodebuild test -workspace . -scheme RevenueCatUI -only-testing:RevenueCatUITests/PaywallPromoOfferCacheTests: 4/4 passed.
    • swiftlint on changed files: no violations.
  • Manual verification: Not run (no device run this session).
  • CI: Not captured (pre-merge).

Validation Gaps

  • No PaywallView-level snapshot of simulate mode; the rendered-pricing chain is covered via VariableHandlerV2 instead.
  • Simulate seeding is not exercised in Xcode Previews / Emerge snapshots (the view's eligibility .task is skipped there); it runs in a normally-launched preview app.

Review Focus

  • Is keeping simulatePromoEligible ungated (no #if DEBUG) acceptable, given it matches the intro seam and the purchase path fails safe?
  • Confirm no other call site forwards paywallPromoOfferCache.get(for:) into a purchase (only PurchaseButtonComponentView does today).

Risks / Reviewer Notes

  • Risk: a fabricated offer could reach a real purchase.
    • Evidence: PurchaseButtonComponentView now uses purchasableOffer(for:) which returns nil in simulate mode; covered by testSimulateEligibleDoesNotExposeOfferToPurchasePath.
    • Mitigation: app-side preview should use a non-completing purchase handler.

Non-goals / Out of Scope

  • App-side changes in revenuecat-mobile-ios (mock .promotional discount, preview toggle, renaming includePromoOfferInPaywall).
  • Paywalls V1 (no promo display logic).

Omitted Context

  • Raw transcript, unrelated exploration, and tool logs were omitted.

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 simulatePromoEligible flag on PaywallView'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, PaywallPromoOfferCache runs in simulate mode: it skips SubscriptionHistoryTracker / network signing and seeds dummy signed offers for packages whose StoreKit discount matches the configured promo product code. Display still uses get(for:); purchases now go through purchasableOffer(for:), which returns nil in simulate mode so sentinel signing data never reaches StoreKit. PurchaseButtonComponentView was 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.

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>
@facumenzella facumenzella marked this pull request as ready for review June 16, 2026 09:32
@facumenzella facumenzella requested review from a team as code owners June 16, 2026 09:32

@MonikaMateska MonikaMateska left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks solid, thanks for introducing this!

@facumenzella facumenzella enabled auto-merge (squash) June 16, 2026 09:40
@facumenzella facumenzella merged commit 81feb1d into main Jun 16, 2026
19 of 21 checks passed
@facumenzella facumenzella deleted the facu/paywall-simulate-promo-eligible branch June 16, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants