* 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
* 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
* 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>
* 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
* 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
* 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
* 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
* 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
* 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>
* 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>
* 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>
* 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
* 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>
* 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
* 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
* Add isolated Customer Center preview data and actions
* Prevent stale Customer Center preview loads from overwriting updates
* Refine Customer Center preview loading helpers
* 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
* 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
* 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>
* 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>
* 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>
* 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>