879 Commits
Author SHA1 Message Date
Facundo Menzella 3d8461686f ONC-34: Fix tab-button discount package context (#8187)
* Fix tab button discount context

* Fix tab discount tests in hostless CI

* Test shared discount comparison updates for empty tabs

* test: remove diagnostic output from tab discount tests
2026-11-09 15:24:00 +00:00
Rick f4bfee9f68 fix: centralize presentation context resolution (#7977)
* refactor: generalize purchase presentation context

* fix: ignore non-window presentation scenes

* test: cover non-window scene filtering

* refactor: generalize presentation context naming

* fix: mark presentation context initializers as SPI

* refactor: use checked Sendable conformance
2026-11-05 12:10:56 +01:00
Jacob RakidzichandJosh Holtz 3a4779279b Fix(Paywalls) Package Selection/Resolution (#8145)
* sheet resolution

* pass resolver around in buttons

* tab memory

* finish sheet resolution

* tests

* add missing file

* move tests to fixture tests for ci

* Reconcile manual package selection when its containing stack is hidden

* Respect tabs visibility when resolving package selection

---------

Co-authored-by: Josh Holtz <me@joshholtz.com>
2026-11-04 17:05:52 +00:00
Cesar de la VegaandClaude Sonnet 5.5 c8aa5951ce feat(checkpoints): Add internal error dialog factory for checkpoints (#8134)
* Add internal error dialog factory for checkpoints

Lets a hybrid bridge supply the content of the checkpoint error dialog while the SDK keeps presenting it. Internal only (`@_spi(Internal)`).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix: address error dialog factory review feedback

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor: Drop error dialog factory timeout

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor: Reuse DefaultErrorPresenter.makeAlert for dialog factory content

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Add explicit checkpoint error dialog outcomes

* fix(checkpoints): Require explicit error dialog factory in call params

* fix(checkpoints): Address error dialog factory review feedback

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 13:02:47 +01:00
Rosie Watson 40447f4551 WFL-800 send step analytics for all step types in a flow (#8160)
* chore: track non-ui flow steps

* refactor: clean up code style

* chore: update project

* chore: fix up comments

* fix: make sure a failure uses the correct trace ID

* chore: remove reference to a single step flow

* refactor: clean up array threading

* refactor: inline function

* chore: remove unused offerings
2026-11-02 08:24:26 +00:00
Antonio Pallares e5a85d0421 Detect Carthage source installations (#8144)
* Detect Carthage source installations

* Clarify Carthage source installation reporting

* Remove Carthage detection comment
2026-10-30 10:20:17 +01:00
Peter Porfy 7da3bd8d9c feat(checkpoints): ad checkpoint presenter (#7783)
* feat(checkpoints): ad presenter layer

Resolves checkpoint workflows whose only step is an ad step to a
registered AdPresenter, adapted to the CheckpointResult API: a shown or
rewarded ad passes with the latest customer info, a failed ad or a
missing presenter passes with no flow.

* refactor(checkpoints): fold the ad outcome into the checkpoint result

* refactor(checkpoints): fail the checkpoint when an ad could not be shown

* refactor: moved to file private and added explanation to null customerinfo

* fix(checkpoints): suppress the checkpoint when nothing was presented
2026-10-29 15:15:07 +01:00
Rick bea6ba8efa fix(flows) share in-flight flow prewarming decodes (#8130)
* fix(flows): share and limit prewarming decodes

* fix(flows): avoid repeated web bundle decodes

* fix(flows): track web bundle publishing by config generation

* refactor(flows): split prewarming decode coordinator

* fix(flows): clamp prewarming decode limit

* fix(flows): prevent duplicate web bundle publishing

* chore(flows): gate test decode counter to debug

* fix(flows): skip inline assets for workflow offerings
2026-10-29 12:27:24 +01:00
Jacob Rakidzich 4b78e9742b Fix(Paywalls) Correct Hero Safe Area detection (#8126)
* Determine when the safe area should be ignored

* add files to project

* Match hero visibility to component rendering context

* Match hero text visibility to signed promotional offers

* Trim overlapping hero layout regression tests

* Keep horizontal hero rows with visible siblings inside safe area
2026-10-28 08:20:43 -05:00
Cesar de la Vega f38cd5c475 Parse paywall gating in checkpoint workflows (#8121)
* Parse paywall gating in checkpoint workflows

* Test invalid gating on terminal offering workflows
2026-10-26 16:01:46 +01:00
Antonio PallaresandCursor addf52a93c test(remote-config): add backend integration tests for API sources (#8061)
* test(remote-config): add backend integration tests for API sources

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add APISourcesIntegrationTests.swift to RevenueCat.xcodeproj

Co-authored-by: Cursor <cursoragent@cursor.com>

* Assert API source hosts and cover backup, exhaustion and badURL failover in tests

* Test failover to the backup source through Purchases with a simulated primary outage

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use HealthCheckAction in the simulated outage strategy

Co-authored-by: Cursor <cursoragent@cursor.com>

* Skip the API source outage tests in the LoadShedder, Offline, SK1 and SK2 plans

Co-authored-by: Cursor <cursoragent@cursor.com>

* Run every API source integration test through a normal Purchases instance

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 12:33:04 +01:00
Antonio Pallares 0b23d2bca3 Share StoreKit 2 intro eligibility checks by subscription group (#8109)
* Share StoreKit 2 intro eligibility checks by subscription group

* Keep pending group eligibility checks shared across invalidation

* Keep group eligibility caching in the caching checker

* Make the eligibility cache dependency explicit

* Require an explicit group eligibility checker argument

* Preserve eligibility cache behavior and diagnostics while sharing group checks

* Invalidate pending subscription group eligibility checks

* Preserve priority propagation through intro eligibility requests

* Preserve eligibility delivery timing and cover cross-product priority

* Declare StoreKit 2 availability for priority tests

* Keep eligibility checker protocol async-only

* Account for older runtime priority propagation limits

* Clarify subscription group cache is for intro eligibility

* Remove redundant group eligibility cache test

* Remove subscription group eligibility reuse logging

* Remove unused eligibility callback and redundant tests
2026-10-26 10:16:16 +01:00
Dave DeLong 6ed515865a Simplify HTTP path types (#7987) 2026-10-23 11:11:52 -06:00
Antonio Pallares 34c54a1351 Improve intro eligibility debug logging (#8104)
* Improve intro eligibility debug logging

* Measure eligibility delivery before dispatch and fix sendability
2026-10-22 15:02:34 +02:00
Josh Holtz 6668d44381 Share child-stack traversal between paywall assets and native navigation (#8080) 2026-10-22 07:19:09 -05:00
Josh Holtz a98e0deda8 6 of 6: Support native Back and Close in paywall workflows (#8025)
* Scope native Back and Close to the active workflow page

* Wait for workflow controls to become interactive before checking layout

* Use compatible workflow observation and retain navigation layout evidence

* Verify caller edge-swipe navigation around workflow history

* Assert exactly one accessible native control per workflow role

* Verify workflow content clears native navigation without padding

* Verify inline sheets cannot restore caller Back in workflows
2026-10-22 07:19:08 -05:00
Josh Holtz 8ec122cfa8 5 of 6: Show native paywall buttons in UIKit (#8024)
* Support native paywall controls in UIKit presentations

* Keep configured navigation controls in embedded UIKit paywalls

* Observe UIKit ownership readiness and restore caller navigation items

* Attach component regression windows to the hosted app scene

* Lay out hosted windows before measuring component geometry

* Restore caller back supplementation after workflow navigation

* Reject stale UIKit hosts after native navigation replacement

* Collect hosted UI results only when those tests run

* Update native preflight test for eligibility loading override
2026-10-22 07:19:07 -05:00
Josh Holtz 2f6b719a12 4 of 6: Show native paywall buttons in SwiftUI (#8023)
* Render native paywall controls in SwiftUI navigation

* Preserve purchase and restore callbacks through owned navigation

* Only show native toolbar slots and owned bars when needed

* Use an observable platform control in caller toolbar coverage

* Compare empty native hosts against a caller toolbar control

* Use real native controls for detection and support iOS 15 toolbar groups

* Format native control attachment updates

* Mount navigation detection inside native button labels

* Verify native toolbar controls through accessibility across SwiftUI renderers

* Preserve default Close controls in known navigation hosts

* Compare toolbar icon positions independently of system glass grouping

* Allow compact system grouping while rejecting extra toolbar space

* Preserve the system Close appearance during automatic navigation detection

* Keep detected Close in its own system toolbar group

* Capture the full window when comparing native toolbar spacing

* Compare native toolbar spacing along its rendered axis

* Refresh native paywall stack checks
2026-10-22 07:19:07 -05:00
Josh Holtz 53b83051cf 3 of 6: Connect native paywall buttons to existing actions (#8022)
* Connect configured paywall buttons to native action registration

* Preserve native placeholders while page and sheet actions are inactive

* Exclude collapsed native-only headers from interaction and accessibility

* Collapse native headers using rendered sibling visibility

* Register native stack actions outside the content layout

* Preserve header badges and measure collapsed content at its ideal size

* Exclude native controls from flex stack spacer allocation

* Collapse native header geometry and bound off-layout registrations

* Keep collapsed header insets out of content accessibility bounds

* Scope header visibility measurements to header content only

* Exercise native callbacks in rendered looping and finite carousels

* Collapse native header layout only after controls mount

* Preserve configured Close actions and sibling state during native promotion

* Keep native badge registrations outside parent layout filtering

* Verify badge navigation through the workflow Back handler

* Fix native paywall header and nested button layout

* Cover native badge actions inside configured buttons

* Bound stack layout types for Release compilation

* Keep native navigation disabled while an inline sheet is open

* Wrap inline sheet test setup to satisfy SwiftLint

* Fix native paywall layout after visibility rules

* Verify eligibility loading preserves native navigation and carousel state

* Fix native registration test state availability
2026-10-22 07:19:06 -05:00
Josh Holtz ebe97271db 2 of 6: Register native paywall button actions (#8027)
* Register native paywall controls while retaining configured fallbacks

* Use compatible change observation for native registrations

* Refresh native registrations without retaining a layout placeholder

* Retain registration lifecycle outside native layout placement

* Limit native carousel actions to the active slide and ancestors

* Refresh callback closures without publishing during view updates

* Keep configured buttons until native controls actually mount

* Observe native control attachment without a dedicated toolbar slot

* Report native availability after control attachment changes
2026-10-22 07:19:06 -05:00
Josh Holtz 1b93a0dae5 1 of 6: Add native paywall button preferences (#8021)
* Add native paywall button preference decoding and resolution

* Resolve native button eligibility and action ownership without changing rendering

* Register native preference tests in the Xcode project

* Keep installed native navigation callbacks current

* Ignore removal of native actions that are already unregistered

* Validate held native callbacks against current page and enabled state
2026-10-22 07:19:05 -05:00
Will Taylor d8be1825b2 other: handle assigned ownership type in Customer Center and winback offers (#8072)
* handle assigned ownership type in customer center + winback eligibility calculator

* add assignedThroughOrganization CC string

* xcproj fix

* string update

* hide cancel button for assigned purchases

* better switch

* add testAssignedSubscriptionHiddenReasonExplainsOrganizationOwnership

* enhance test
2026-10-21 11:29:25 -05:00
Pol Miro 4f3b82defe Load configured ad rewards from remote config (#7912)
* feat: load configured ad rewards from remote config

* feat: decode configured reward durations in seconds

* feat: enable configured ad rewards loading by default

* fix: add configured rewards provider to Xcode project

* feat: load configured ad rewards from blob

* refactor: remove obsolete inline config lookup

* refactor: distinguish empty and unavailable ad reward configuration

* refactor: warm configured ad rewards cache

* refactor: adopt configured reward naming

* feat: finish configured ad rewards loading

* feat: expose configured ad rewards API

* feat: represent missing configured ad rewards explicitly

* refactor: resolve empty ad reward configurations explicitly

* refactor: expose configured reward duration idiomatically

* Handle malformed ad reward entries independently

* Clarify app-scoped ad rewards caching

* fix: fetch unavailable configured ad rewards

* fix: reject configured rewards without a primary

* refactor: decode configured currency rewards separately

* refactor: remove cached ad rewards implementation

* Avoid reloading unavailable ad rewards

* refactor: resolve configured ad rewards on demand

* fix: remove duplicate API availability annotations

* fix: ignore empty additional ad rewards

* fix: log malformed configured ad rewards

* fix: fetch ad rewards blobs on demand

* fix: align configured ad rewards API baselines

* test: check virtual currency reward configuration API
2026-10-21 11:46:21 +02:00
Pol Miro 4da527ee5c Add configured ad rewards API (#7911)
* feat: add configured ad rewards API

* feat: support seconds in configured reward durations

* fix: add configured reward files to Xcode project

* feat: expose configured ad rewards API by default

* chore: update configured ad rewards API baselines

* refactor: keep ad reward duration storage private

* refactor: allow configured rewards without a primary reward

* refactor: expose cached ad rewards synchronously

* refactor: simplify configured reward naming

* feat: add async configured ad rewards API

* refactor: expose entitlement reward duration in seconds

* refactor: keep configured ad rewards API internal

* refactor: reuse virtual currency reward configuration

* refactor: expose reward duration as TimeInterval

* refactor: require explicit configured rewards values

* feat: represent missing configured ad rewards explicitly

* docs: clarify missing configured ad rewards

* refactor: simplify entitlement reward duration naming

* docs: clarify configured ad rewards loading

* docs: clarify verified reward invariant

* docs: clarify ad rewards refresh behavior

* refactor: separate configured currency rewards

* docs: distinguish unavailable ad rewards

* refactor: separate reward configuration API section

* docs: add configured reward prompt example

* refactor: remove cached ad rewards accessor

* docs: clarify unavailable ad reward configuration

* docs: clarify configured reward example
2026-10-21 11:29:44 +02:00
f4fe1ef435 feat(checkpoints): expand CheckpointResult with customer and purchase details (#8002)
* feat(checkpoints): expand flow results with customer and purchase details

* refactor(checkpoints): name the delivered customer info `customer`

Matches the Android checkpoint result. Also removes a duplicate API tester
function and wraps a doc line so the module lints.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(checkpoints): keep CheckpointsManager under the type length limit

Moves the error presentation handlers, unchanged, into a same-file
extension so swiftlint's type_body_length passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(checkpoints): identify obtained products by their billing plan

Purchase dates are keyed by the product's id, which carries the billing plan for installment plans, so matching
and deduplicating on the plain product identifier missed installment purchases and merged the plans of a product.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(checkpoints): cover the products a completed workflow reports

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(checkpoints): require both obtained sets when building a FlowResult

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(checkpoints): drop the unchecked Sendable conformance from the result types

Their members already conform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(checkpoints): register ObtainedProductsTests in the Xcode project

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(checkpoints): look obtained products up by their plain store identifier too

Apps cannot read the billing-plan form of a product's id, so a lookup by the store identifier must find an
installment purchase. An exact billing-plan match still wins when both plans were obtained.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(checkpoints): drop the unchecked Sendable conformance from the obtained types

StoreProduct and EntitlementInfo already conform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-20 19:36:14 +02:00
Josh Holtz 261cf08171 Fix relative discounts calculated from rounded monthly prices (#8062)
* Fix relative discounts calculated from rounded monthly prices

* Preserve discount precision through percentage rounding
2026-10-20 12:23:32 -05:00
andsolleiroandCursor 60c7b5396b feat(paywalls): add discount variables (#8038)
* feat(paywalls): add discount variables

Paywalls can show a relative discount against the most expensive product
in the tab, but not the amount saved, and nothing that reflects an intro
or promo offer.

Adds five variables, all measured over the product's first billing
period, with any offer prorated into that period:

- product.absolute_discount: amount saved vs the most expensive product
- product.offer_relative_discount / product.offer_absolute_discount:
  the same comparison with the offer applied
- product.offer_relative_discount_vs_standard /
  product.offer_absolute_discount_vs_standard: the offer vs the
  product's own regular price

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(paywalls): align discount variable names with the spec and add the offer-term pair

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(paywalls): cover offer discount for a short offer

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(paywalls): cover discount variables with an offer without periods

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compute discount percentages before rounding and use the exact most expensive monthly price

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-20 15:43:04 +00:00
Jacob RakidzichandCursor c0d5377331 Fix(Paywalls) Adjust loading state during eligibility check (#8055)
* redact only the text that depends on the eligibility check

* segregate the loading states

* comment for agents just in case we have some strange naming later

* correct loading state + add tests

* add missing snapshots

* update previews for snapshot tests

* redact text on all stack children if it depends on intro offer eligibility

* Add tests for stack text redaction during eligibility load.

Co-authored-by: Cursor <cursoragent@cursor.com>

* add file to xcode project

* ensure the check only waits for what is needed

* Keep the stack's redaction modifier in place so eligibility finishing does not recreate its children

* naming clarity

* finish the implementation -> add to buttons and packages

* fill composite component gap

* shared modifier

* consolidate loading check

* optimize calculations in text components

* consolidate whisker check

* prevent initial result from being permanent

* cleanup -> reuse code

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 17:34:32 +00:00
Monika Mateska 3907fe4f53 feat(customer-center): Add stateful Customer Center preview actions and diagnostics (#7951)
* Add stateful Customer Center preview actions and diagnostics

* Isolate preview diagnostics to the main actor and reuse the shared purchase-cancellation check

* Drop the duplicated paywall purchase test, weaken the action task's self capture, and read the fixture launch environment in one place

* Tie preview diagnostics to view lifecycle

* Tie preview diagnostics to screen lifecycle
2026-10-19 12:15:27 +02:00
c9e25b75dd fix(paywalls): Match Android swipe behaviour for Paywalls V2 carousel (#7857)
* Match Compose Pager swipe paging in the Paywalls V2 carousel

The carousel only advanced when a drag exceeded 20% of the card width, ignoring release speed, so a quick flick that pages on Android snapped back on iOS. Page decisions now follow Compose HorizontalPager defaults: a release at or above 400 pt/s settles on the neighbouring page in the velocity direction, otherwise the nearest page (50% threshold), moving at most one page per gesture. The slow-drag threshold intentionally moves from 20% to 50%.

The UIKit pan path passes the recognizer's release velocity (zero when cancelled); the DragGesture path (native macOS, watchOS) estimates velocity from its change samples.

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

* Address carousel paging review feedback

- Resolve flings against the full page stride (card + spacing), which is
  the distance between snap positions.
- Seed the DragGesture velocity average from its first sample so short
  flicks aren't underestimated on native macOS and watchOS.
- Add CarouselPagingTests.swift to RevenueCat.xcodeproj.

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

* Use DragGesture velocity instead of a custom tracker

DragGesture.Value.velocity is @_alwaysEmitIntoClient and only reads
predictedEndLocation, so it works on macOS 10.15 and watchOS 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Josh Holtz <me@joshholtz.com>
2026-10-16 15:25:07 -05:00
Monika Mateska b5410778a8 feat(customer-center): Render isolated Customer Center preview paywalls (#8015)
* Render isolated Customer Center preview paywalls

Use the injected provider for paywall purchases, restores, workflows,
checkout context, and simulated eligibility without using the configured SDK.

Route preview links through the provider, preserve checkout dismissal,
and cover interactions with the SDK configured and unconfigured.

* Fix Customer Center preview checkout handling

* Address Customer Center preview paywall review feedback

* Stack unavailable preview offering UI and use provider storefront country

* Call restoreInitiated in preview paywall and read intro eligibility from provider
2026-10-16 16:40:34 +02:00
Rick f11a6bc146 feat(checkpoints): add CheckpointResult result container (#8035)
* feat(checkpoints): add checkpoint result container

* fix(checkpoints): satisfy line length lint

* fix(checkpoints): align checkpoint result parity

* test(checkpoints): preserve flow result API coverage
2026-10-16 11:26:17 +02:00
Rick 97a183587d fix(checkpoints): route all errors through error presenter (#7994)
* fix(checkpoints): route pre-presentation errors through error presenter

* fix(checkpoints): propagate workflow startup errors

* fix(checkpoints): route all errors through error presenter

* test(checkpoints): shorten terminal error presenter name

* fix(checkpoints): route default paywall startup errors

* test(checkpoints): clarify custom paywall error coverage

* refactor(checkpoints): use failure kinds for routed errors

* refactor(checkpoints): satisfy error routing lint rules

* fix(checkpoints): retain presentation slot for startup errors

* fix(checkpoints): serialize error presentation

* fix(checkpoints): defer queued error presentation

* docs(checkpoints): clarify error routing
2026-10-15 18:36:10 +02:00
Rick 41bcb58726 fix(checkpoints): suppress callbacks for terminal checkpoint errors (#7984)
* fix(checkpoints): suppress callbacks after resolution errors

* test(checkpoints): centralize outcome behavior coverage

* test(checkpoints): add outcome tests to Xcode project

* fix(checkpoints): align callbacks for terminal errors

* docs(checkpoints): clarify internal paywall failure result

* refactor(checkpoints): simplify callback result mapping

* fix(checkpoints): complete terminal errors after dismissal

* fix(checkpoints): restore tester error result

* test(checkpoints): expect workflow presentation failure kind

* docs(checkpoints): clarify callback outcomes
2026-10-15 18:36:09 +02:00
Rick 02a4c3edac refactor(checkpoints): simplify error presenter completion (#8012)
* refactor(checkpoints): simplify error presenter completion

* fix(checkpoints): update test apps for simplified error presenter

* test(checkpoints): update error presenter API coverage

* refactor(checkpoints): describe error presenter failure kinds

* fix(checkpoints): classify workflow presentation errors

* fix(checkpoints): resolve global error presenter lazily

* docs(checkpoints): clarify error presentation API
2026-10-15 18:36:08 +02:00
Monika Mateska de98a9118d feat(customer-center): Add isolated Customer Center preview data and actions (#8014)
* Add isolated Customer Center preview data and actions

* Prevent stale Customer Center preview loads from overwriting updates

* Refine Customer Center preview loading helpers
2026-10-15 17:17:51 +02:00
Monika Mateska 97db4d8330 Fix Customer Center purchase details and action state (#7948)
* Fix Customer Center purchase details and action state

* Include CustomerCenterDataStateTests

* Dismiss Customer Center detail when the selected purchase disappears

* Use the purchase selection binding to dismiss Customer Center details

* Fix lint

* Fix transaction identifier assertion after offline purchase restart
2026-10-14 14:12:45 +02:00
Antonio Pallares 5559249c92 Show hosted checkout alerts from the paywall instead of the purchase button (#7928)
* Tell the customer how a hosted checkout settled from the paywall, not the button

The button that started a checkout can be gone by the time a closed checkout is confirmed, which lost the alert and with it the purchase report that waits on the customer acknowledging it.

* Drop the note on where the hosted checkout resolution is held

* Drop the note on the already-purchased alert title

* Keep only the acknowledgement note on resolving a hosted checkout

* Test a hosted checkout purchase is reported once acknowledged, through the acknowledgement only
2026-10-09 12:57:15 +02:00
Sufi Gaffar f8a4ad456c feat(paywalls): prewarm videos for current locale (#7915)
* pre warm all videos for current locale

* don't introduce new pre-warming for offerings
2026-10-07 16:02:49 +01:00
Toni RicoandClaude Fable 5.1 6b2133ee05 Resolve workflow offerings through a single WorkflowOfferings wrapper (#7938)
* Resolve workflow offerings through a single WorkflowOfferings wrapper

WorkflowContext now holds one WorkflowOfferings value instead of the
fetched bundle and the developer-provided offering side by side, so every
lookup, including exit offers, honors the developer's instance.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Add WorkflowOfferings.swift to RevenueCat.xcodeproj

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:23:52 +02:00
Josh Holtz f9d0e4642a Honor explicit no-scroll on root z-layer paywalls (#7870)
* Honor explicit no-scroll on root z-layer paywalls

* Keep z-layer scroll helper within lint limits

* Preserve scroll defaults and apply overflow rules with layout regression coverage

* Register overflow rule tests in the Xcode project

* Host root scroll layout snapshots in Emerge

* Verify snapshot uploads and resolve PR metadata on demand

* Use CI branch metadata for snapshot PR lookup

* Isolate newer Emerge client for reliable snapshot uploads

* Keep snapshot migration on the existing Emerge client

* Reuse existing snapshot recording plan and upload lane

* Cover root scroll layouts with automatic Emerge previews

* Fix fit-size scrolling stack alignment
2026-10-06 21:19:42 -05:00
Rick aaf5d0f02f feat(checkpoints): resolve subscriber dimensions from config and receipts endpoints (#7919)
* feat(checkpoints): resolve subscriber dimensions from config and receipts

* fix(checkpoints): address subscriber dimension review feedback

* fix(checkpoints): provide user defaults in integration tests

* fix(checkpoints): resolve integration test lint errors
2026-10-06 14:36:58 +02:00
Rick 8353559d13 feat(checkpoints): read subscriber dimensions from remote config (#7902) 2026-10-06 14:36:58 +02:00
Rick 8144cedfd8 feat(checkpoints): add error presenter API (#7901)
* feat: add checkpoint error presenter types

* feat: expose checkpoint error presenter configuration

* feat: select checkpoint error presenter

* feat: route checkpoint errors to presenters

* Align checkpoint error handling with Android

* Handle checkpoint error presentation results

* Add default checkpoint error presenter

* Add error presenter checkpoint demo

* Add default error presenter to Xcode target

* Keep workflow errors active after retry

* Queue checkpoint back navigation during transitions

* Fix checkpoint error presenter lint violations

* Add default error presenter tests to Xcode project

* Group active paywall presentation state

* Simplify workflow back navigation requests

* Rename workflow back navigation bridge

* Preserve terminal workflow error presentations

* test(checkpoints): cover purchase error after restore

* Fix checkpoint parameter forwarding

* Fix default error presenter actor isolation
2026-10-06 10:51:20 +02:00
Rick d43036f334 feat(checkpoints): add presentation modes to checkpoint flows (#7894)
* feat(checkpoints): add presentation modes

* test(checkpoints): add presentation mode controls

* fix: include checkpoint presentation mode source

* fix: support checkpoint sheets on tvOS

* fix: register checkpoint presentation source

* fix: exclude checkpoint modal style on watchOS

* refactor(checkpoints): rename flow presentation mode

* test(checkpoints): rename tester presentation modes
2026-10-05 13:36:34 +00:00
Antonio Pallares 7b8b2b7534 fix(paywalls): report purchase cancellations without fetching CustomerInfo (#7895)
A cancellation only needed CustomerInfo to fill PurchaseResultData, so a failed fetch surfaced a dismissed hosted checkout as a purchase failure.
2026-10-05 11:59:39 +02:00
Antonio PallaresandCursor 8cbceb8858 Poll the hosted checkout session before settling an in-app web purchase (#7886)
* Poll the hosted checkout session through the SDK

Restores the poller, HostedCheckoutManager.pollCheckout and Purchases.pollHostedCheckout on top of the network layer they were split from.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound the hosted checkout poll with an overall timeout

* Cover that a failed post-checkout refresh marks cached CustomerInfo stale

* Drop the unknown hosted checkout status from the poller

The status response reads an unrecognized status as pending, so there is no unknown case left to poll through.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop waiting on a hosted checkout status request once the poll times out

A request started just before the deadline could otherwise keep the customer waiting for its whole network timeout. Also quiets the poll logs that repeat or restate what is already logged elsewhere.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Bound any hosted checkout request by the poll's deadline, not just the status

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wait no time at all on a hosted checkout deadline already past

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let a cancelled hosted checkout deadline timer end by throwing

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say plainly why a confirmed hosted checkout fetches CustomerInfo

Co-authored-by: Cursor <cursoragent@cursor.com>

* Share the CustomerInfo refetch between hosted checkout and reward entitlement grants

Co-authored-by: Cursor <cursoragent@cursor.com>

* Log the hosted checkout CustomerInfo refetch where it happens

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fetch CustomerInfo when a hosted checkout finds the product already owned

* Clear the cached CustomerInfo when a confirmed hosted checkout cannot refresh it

A stale cache is still served, so the paywall would report the purchase with entitlements from before it.

* Keep a hosted checkout poll on the customer who started it

Reading the app user ID once means a log in during the poll no longer sends the status check, the CustomerInfo fetch or the cache clear to a different customer.

* Reword the hosted checkout poll logs

* Stop waiting on a hosted checkout request once the poll is cancelled

Also moves the sleeper to a shared AsyncSleeper so other pollers can use it.

* Say hosted checkout polling has no caller with custom entitlement computation

* Poll a hosted checkout for the customer it was created for

The session now records the app user ID it was created with, and the poll takes the session. Reading the current app user ID when the poll starts would miss a log in between starting the checkout and polling it. Also rewords the poll's doc.

* Cover that a hosted checkout poll keeps the buyer's CustomerInfo from another customer's listeners

CustomerInfoManager already drops updates for a customer who is no longer logged in; this pins that the post-checkout refresh goes through that check.

* Say what the hosted checkout poller asks for in plainer words

* Build the hosted checkout poll test session without a cancel URL

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 17:08:30 +02:00
Antonio PallaresandCursor eac1656a40 Add the hosted checkout session status endpoint (#7885)
* Decode the in-app web checkout open method

Marks a web purchase button as opening its checkout in the in-app bottom
sheet. Nothing reads it yet: routing lands with the button wiring.

Where the method reaches Browser.navigateTo there is no sheet to present,
so it takes the in-app browser rather than opening nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Log the in-app browser fallback for the checkout open method

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Hold the checkout status until a host is listening

Detents without `presentationContentInteraction`, iOS 16.4, turn a drag
on the page into a sheet resize, so 16.0 to 16.3 gets the full-height
sheet rather than an unscrollable medium one.

The sheet assigns `onFinished` on appearance, and loading can start
before that, so a page reaching a return URL first would drop it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout sheet presentation

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the hosted checkout entry point the paywall can call

Composes the external purchase preparation and the hosted checkout call
behind an @_spi(Internal) entry point on Purchases, so RevenueCatUI can
start a checkout with a single call.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Decode the hosted web checkout purchase button method

Co-authored-by: Cursor <cursoragent@cursor.com>

* Give the checkout sheet its own object ID in the Xcode project

Co-authored-by: Cursor <cursoragent@cursor.com>

* Resolve the hosted checkout open method in the model

Co-authored-by: Cursor <cursoragent@cursor.com>

* Leave the open method default to the caller

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take the hosted checkout method without an open method

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Leave the Test Store out of the hosted checkout

A test_ key stops at the eligibility gate and reports external purchases
as unavailable, so the caller buys through StoreKit rather than opening a
checkout the Test Store does not support yet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Follow the external purchase outcomes the manager reports now

The manager tells payment authorization apart from eligibility and stops
a preparation asked for while another is under way, neither of which
existed when the checkout was written. It also reuses the instance the
link-out flow uses, so a single preparation runs at a time however the
customer is taken to the checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Open the checkout the purchase button asks for in a sheet

A purchase button configured for the in-app checkout now runs Apple's
flow, creates the session and presents it, rather than buying through
StoreKit. The page reporting success or a cancellation takes the sheet
down and settles the paywall; a customer who closes the sheet without
the page saying anything is left for the backend to settle, which is
not wired up yet.

A device that cannot take part in Apple's programme still buys through
StoreKit, as an SDK that does not know this purchase method would.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Read what the checkout returned off the view model

The status was pushed into SwiftUI state in the same breath as the
dismissal that reads it, and the view model handed it over and forgot
it, leaving nothing to fall back on. A page that returned before the
sheet was on screen also asked to be dismissed from inside its own
presentation, which SwiftUI is in no position to honour.

The status now stays on the view model, the sheet keeps what it
presented so it can ask at any time, and a checkout that returned
early waits a hop before asking to come down.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the hosted checkout manager

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the remaining hosted checkout result comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Send the paywall session without a paywall identifier

khepri#24938 made `paywall_id` optional precisely so that iOS could
send the session identifier, which is the key the paywall events are
joined on. Dropping the whole paywall object for a paywall that has no
identifier of its own threw that session away.

Also trims the last of the result comments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim the hosted checkout session comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the remaining hosted checkout session comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Silence missing docs for the whole session file

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add the checkout web view host

Owns the WKWebView that presents a payment provider's checkout page, detects the
return URL that ends the flow, and reports success or cancellation to its host.

Navigations are not restricted: the checkout URL comes from RevenueCat's backend
and the page it opens is free to reach the provider's own domains and any bank's
3DS challenge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Take both return URLs from the checkout start response

The backend now returns success_url and cancel_url alongside checkout_url, so
the outcome is told by which one the page navigates to rather than by a query
parameter the SDK knows the name of.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Compare the root return path consistently

URL.path strips trailing slashes for every path but the root, so the length
guard was the only thing keeping https://host/ and https://host from matching.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the unused reload from the checkout view model

Nothing calls it until something presents a retry, so leave it to whichever
change needs it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments in the checkout web view

Co-authored-by: Cursor <cursoragent@cursor.com>

* Model the checkout web view lifecycle as a single state

The three booleans tracking the web view's progress could combine into
states that cannot happen, and `hasFinished` guarded only the callback:
a navigation still in flight after the return URL could set `.failed`
and blank the page while the host was dismissing the sheet.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the checkout page reach the bottom edge

Stopping above the home indicator leaves a strip of the host's
background under a page that fills its sheet. The web view insets its
own content by the safe area, so nothing the page puts at the bottom
goes out of reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the checkout sheet's callbacks on the main actor

The status the sheet reports is read off the view model, which is main
actor isolated, so the methods doing the reading have to say they run
there. Swift 5.10 does not infer it from the ViewModifier conformance
the way newer compilers do, and the iOS 16 job builds with Xcode 15.4.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout with a Test Store API key

The backend creates a sandbox checkout session for a test_ key, so there is no
reason to leave the caller to buy through StoreKit instead of presenting the
checkout, which is what a web purchase in the browser already does.

Apple's programme has no App Store to apply to behind such a key, so the
checkout is presented with no notice shown and no token minted.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Run Apple's external purchase flow with a Test Store key

Eligibility, the notice and the token follow the app's entitlement and the
customer's storefront, none of which has anything to do with the key the SDK was
configured with, and the purchase behind such a token is a sandbox one either
way. Skipping the flow left a Test Store key, the shortest path a developer has
to trying the checkout out, as the one key that could not exercise it.

Reverts the carve-out added in #7643.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Route the hosted web checkout method to the checkout it asks for

The merge left the placeholder branch that sent hosted_web_checkout to StoreKit
alongside the one that opens the checkout, and the checkout no longer reports an
external purchase being unavailable: it presents itself in that case too.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tell the customer when the in-app checkout is refused for a product they own

The backend answers 409/7772 for a product the customer already has, which
the SDK decoded as an unknown error and reported as a plain failure: the
sheet never opened and the tap looked like it did nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Word the already-owned alert as Apple does, by product category

Only the title carries over: Apple's own alert also names the renewal date
and offers to manage the subscription, both of which describe a purchase
made on the App Store rather than this one.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ask for the checkout through the purchase handler

The handler is the paywall's way to the SDK, so reaching for the singleton
meant checking whether it was configured at all. It never had to: an
unconfigured paywall is never rendered, and the handler already stands in for
a purchase that cannot be made.

Drops the StoreKit fallback that only that check could reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the mapping speak for the outcome it produces

The init lists what turns into nothing, so the case did not need to repeat it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* perf: add checkout request lane and generalize backend lanes

Checkout tap-path requests now bypass the shared backend serialization
stack via a dedicated lane, using the same generalized lane factory as
remote config.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address review feedback on backend lanes

Restore misplaced comments, add BackendLanesTests, and use plain Sendable
on BackendLanes now that the justification matches the type.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify timeoutManager sharing comment wording

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim comments around the already-owned alert

Co-authored-by: Cursor <cursoragent@cursor.com>

* State where the hosted checkout is presented from

Presenting the sheet is main actor work, and the await in front of it said the
opposite: nothing suspends there, which `pod lib lint` counts as a warning and
so refuses the podspec.

Annotating the caller rather than dropping the await alone keeps this from
depending on whether a given compiler infers a View's methods to be isolated,
which is the same disagreement that put the sheet's callbacks on the main actor.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Test production checkout lane wiring

Build Backend through the convenience initializer in the parallel
checkout test so removing .checkout from dedicatedLanes fails the suite.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restrict RequestLane to canonical constants

Replace the synthesized memberwise initializer with a private init so
lane values cannot be built ad hoc and miss dedicatedConfigurations keys.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clarify apiSourceFailover is shared across lanes

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove RequestLane private init doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add checkout lane tests for shared lane and token refresh dedupe.

Pin that both checkout endpoints share one lane in a single backend, and that concurrent 401s across lanes trigger exactly one token refresh when HTTPClients share a TokenManager.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Clear HTTP client caches on every backend lane.

Backend now retains its BackendLanes and delegates cache clearing to all lane configurations, with a test that asserts each lane's HTTPClient is cleared.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Cover external purchase token in checkout lane parallel test.

Extend the production wiring test so both checkout tap-path requests complete while offerings hangs on the default lane.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Derive Backend default config from lanes.

Remove the redundant backendConfig parameter from Backend's required initializer so config always matches lanes[.default].

Co-authored-by: Cursor <cursoragent@cursor.com>

* Warn when a backend lane falls back to the default configuration.

Log a warning when a BackendLanes instance has dedicated lanes but the requested lane is missing, so miswired APIs are visible in production without noisy warnings for single-lane bundles.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move test-only Backend and BackendLanes initializers to UnitTests.

Single-lane construction helpers now live in the test target so production code only exposes the multi-lane factory path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Register BackendTestInitializers in StoreKitUnitTests.

MockBackend is compiled into both UnitTests and StoreKitUnitTests, so the
test-only Backend initializers helper must be in both targets' Sources phases.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ask the backend what became of a checkout session

The checkout page returning to its success URL says the customer finished paying,
not that the purchase has landed, so the session is polled until the backend
settles it one way or the other.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Settle a checkout on the customer and the reason it ended

The session belongs to the customer who started it, so the poll asks about that
one rather than about whoever is current when an attempt goes out: a customer
change mid-poll would otherwise turn a landed purchase into no answer at all.

A confirmed purchase is fetched before it is reported, so a caller acting on the
outcome finds the entitlement on the customer instead of the state from before
the checkout. A fetch that does not land leaves the purchase standing.

The failure the backend gives now travels with the outcome, which tells a
declined payment from a checkout that never got going.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Let the view showing the checkout hand its links to the browser

The button set onOpenExternalURL on the view model it was about to present, and
WebCheckoutView assigns that same closure when it appears. Nothing could reach
the button's: the page only starts loading on that appearance, a line after.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the note on who hands the checkout's links to the browser

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Call what the paywall does with a checkout tap an action

Start read as a verb or an event, so HostedCheckout.Start(.failed) looked like it
began something. The cases are things the paywall does: present, or nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Name the already-owned mapping test's type after the rename

Co-authored-by: Cursor <cursoragent@cursor.com>

* Ask the purchase interceptor before starting a hosted checkout

Apps that gate purchases with onPurchaseInitiated (e.g. behind sign in) could be bypassed by the hosted checkout, and Apple's flow may mint a token that must be reported, so the interceptor now runs first.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop the token note from HostedCheckout.start's docs

Co-authored-by: Cursor <cursoragent@cursor.com>

* Track hosted checkouts as purchase initiated

The event was only used for its data to attribute the checkout session, so these purchases never showed up as initiated in paywall analytics, unlike the StoreKit and performPurchase paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move the checkout poller to its own PR

Keeps this PR to the network layer, so it can be reviewed on its own. The poller, HostedCheckoutManager.pollCheckout and Purchases.pollHostedCheckout follow in a stacked PR.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Share the purchase tap checks between the in-app and hosted checkout paths

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim packageForPurchaseTap's doc comment

Co-authored-by: Cursor <cursoragent@cursor.com>

* Present the in-app web checkout outside Apple's external purchase programme

An ineligible customer, or an app that has not opted into the programme, now
gets the checkout presented with no external purchase token behind it, the same
way a web purchase in the browser is opened today, instead of being routed to a
StoreKit purchase.

A Test Store API key still leaves the caller to buy through StoreKit, since no
checkout session can be created for it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the console quiet for apps outside Apple's programme

The external purchase side already reports why no external purchase was
prepared, and says nothing at all where the app takes no part in the programme,
so the checkout has nothing to add.

Also covers the window before the notice comes up, where the one checkout at a
time rule now decides between a checkout that opens and one that is dropped.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say what the outcome does rather than what it used to do

Co-authored-by: Cursor <cursoragent@cursor.com>

* Say that Apple's custom link does not apply rather than that nothing can be bought

An ineligible customer is not a customer who cannot buy outside the App Store:
the custom link may simply not apply where they are, and the purchase goes ahead
without it. Dropped to debug, since that is the normal case anywhere the link
does not reach.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Drop a note on an outcome no caller acts on

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove two conveniences on the preparation result that no caller uses

Co-authored-by: Cursor <cursoragent@cursor.com>

* Restore the test dependencies dropped from Package.resolved

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add hosted checkout status snapshots and fix the email escaping expectation

* Read the hosted checkout status at the top level of the response

The backend now returns the operation's fields directly instead of nesting them under `operation`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Read an unrecognized hosted checkout status as pending

The backend only ever sends started, in_progress, succeeded or failed, so an unknown case was a status it never returns.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Stop reading in_progress as a hosted checkout status

The backend no longer sends it. Should it still arrive, it reads as pending like any status the SDK does not recognize.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Trim hosted checkout status comments

Co-authored-by: Cursor <cursoragent@cursor.com>

* Move the hosted checkout query escaping next to the path escaping

Co-authored-by: Cursor <cursoragent@cursor.com>

* Read an in-progress hosted checkout session as still under way

purchases-js polls on it too, and the SDK was warning about it as unrecognized.

* Add request snapshots for the in-progress hosted checkout status test

* Say where hosted checkout failure codes come from

* Join the hosted checkout status cache key with a hyphen, like other operations

* Say what the hosted checkout status endpoint gives in plainer words

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 17:08:30 +02:00
Facundo Menzella 9644d736bf other(paywalls): extract prototype skeleton UI (#7873)
* other(paywalls): add prototype skeleton transform

* other(paywalls): add skeleton rendering support

* fix(paywalls): include skeleton files in Xcode project

* refactor(paywalls): simplify skeleton styling
2026-10-01 10:01:19 +02:00
Facundo MenzellaandClaude Opus 5 af9cc0f8f0 other(workflows): route a branch trigger action to the step its audiences pick (#7853)
* other(workflows): route a branch trigger action to the step its audiences pick

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

* other(workflows): cut the concurrency comment to the load-bearing line

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

* other(workflows): drop the comment hasBranchExit already says

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

* other(workflows): expose resolveBranches as an operation, not the resolver

Review: handing out the resolver leaked a concrete internal dependency through
Purchases, where resolveCheckpoint and the workflow methods expose operations.
The resolver is private again, Purchases gets an spi resolveBranches(in:), and
the navigator takes that as a closure so it stays injectable.

Also says "branch action" rather than "branch exit", which was a term this repo
does not use anywhere else.

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

* other(workflows): stop calling them exits, and tighten two comments

Review: "exit" collides with exit offers and was mine, so resolveStepExits is
resolveCurrentStepBranches and hasBranchExit is hasBranchAction.

The fallback comment listed two cases that both end the same way, and
neverMatches sat in a trailing extension away from the walk it serves.

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

* other(workflows): name them routes, since a branch cannot have branches

The elements were already typed Route, and that is what the spec calls them.
The wire key stays `branches` through an explicit CodingKey, and the decode test
fails without it: the action falls back to .unknown, which is the dead button.

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

* other(workflows): name the two sides of the map instead of commenting them

[String: String] said nothing about which String was which, so the property
needed a comment to explain itself. WorkflowActionID and WorkflowStepID put it
in the signature, matching LocaleID and ProductID elsewhere in the repo, and the
comment goes away.

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

* other(workflows): say plainly that waitForBranchResolution is test only

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

* other(workflows): drop the second comment about cancelling

The method name and the cancel() call say it, and the guard below already
explains the part that is not obvious.

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

* other(workflows): plainer wording on the fallback

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

* other(workflows): the switch already says a branch navigates

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 13:09:55 +02:00