Skip to content

Add workflow-based paywall resolution#6675

Merged
facumenzella merged 83 commits into
mainfrom
facu/workflow-paywall-resolution-v2
Apr 23, 2026
Merged

Add workflow-based paywall resolution#6675
facumenzella merged 83 commits into
mainfrom
facu/workflow-paywall-resolution-v2

Conversation

@facumenzella

@facumenzella facumenzella commented Apr 23, 2026

Copy link
Copy Markdown
Member

Summary

Reuses the networking layer from feat/workflows-network-layer (no duplicate code) and adds paywall resolution on top.

  • When the -EnableWorkflowsEndpoint launch argument is set, PaywallView fetches a workflow, resolves initial step → screen → offering, maps the screen component config to Offering.PaywallComponents, and falls back to standard offerings on failure
  • Networking: reuses WorkflowsAPI / GetWorkflowOperation from the base branch
  • WorkflowDataResult, PublishedWorkflow, WorkflowStep, WorkflowScreen are @_spi(Internal) public so RevenueCatUI can access them
  • WorkflowScreenMapper converts WorkflowScreen + UIConfig into Offering.PaywallComponents
  • Purchases.workflow(forOfferingIdentifier:) is @_spi(Internal) and delegates to backend.workflowsAPI
  • Offering.withPaywallComponents(_:) helper added
  • offerings(), cachedOfferings, and workflow() added to PaywallPurchasesType so PaywallViewConfiguration.Content no longer calls Purchases.shared directly — callers inject a PaywallPurchasesType instance via purchaseHandler.purchasesInstance
  • Workflow fetch and offerings fetch are parallelized with async let
  • WorkflowScreen.offeringId / offering_id removed — aligns with Android which already migrated to offering_identifier exclusively
  • Offerings with no paywall are now tappable in PaywallsTester and open directly in workflow mode (previously shown as read-only text)

Parity with Android: RevenueCat/purchases-android#3350

Waiting for #6661

Previous PR for reference: #6640

Test plan

  • WorkflowScreenMapper unit tests verify field mapping to PaywallComponentsData
  • GetWorkflowOperation tests covered by BackendGetWorkflowsTests
  • WorkflowDetailProcessor tests: inline unwrap, CDN fetch, hash verification, error cases
  • PaywallViewConfigurationTests: cached offering, offering resolution per content type
  • Integration: run PaywallsTester with -EnableWorkflowsEndpoint launch argument and verify workflow-served paywall renders correctly, including offerings with no pre-existing paywall

Note

Medium Risk
Adds a new workflow-based offering resolution path that changes how PaywallView/exit-offer prefetch fetch the offering (including parallel workflow+offerings requests) when a launch flag is enabled, which could impact paywall rendering and selection logic. Risk is mitigated by being gated behind -EnableWorkflowsEndpoint and falling back to standard offerings on errors, but it touches networking models and paywall configuration flow.

Overview
Adds workflow-based paywall resolution (opt-in via -EnableWorkflowsEndpoint). When content is .offeringIdentifier, PurchaseHandler can now fetch a workflow, follow initial step → screen → offering id, and build Offering.PaywallComponents from the workflow screen to render a workflow-served Paywalls V2 UI.

Refactors paywall offering resolution to be injected and testable. PaywallViewConfiguration.Content no longer calls Purchases.shared directly; instead PaywallPurchasesType gains offerings(), cachedOfferings, and (non-tvOS) workflow(forOfferingIdentifier:), with PaywallView, PaywallViewController, and View+PresentPaywall routing through PurchaseHandler for cached initial offering + async resolution.

Networking/model updates for workflows. Renames WorkflowFetchResultWorkflowDataResult, exposes workflow types via @_spi(Internal) for RevenueCatUI, updates workflow envelope parsing, and adds Purchases.workflow(forOfferingIdentifier:) (SPI) to call WorkflowsAPI.

Tests + sample app updates. Adds unit tests for workflow screen mapping and paywall resolution, expands workflow decoding coverage, and updates PaywallsTester to support a new Workflow presentation mode (including making offerings without paywalls tappable).

Reviewed by Cursor Bugbot for commit ce7314c. Bugbot is set up for automated code reviews on this repo. Configure here.

vegaro and others added 30 commits April 6, 2026 17:14
- Add `getWorkflows` and `getWorkflow` endpoint paths
- Add `WorkflowsListResponse` and `PublishedWorkflow` response models
- Add `WorkflowDetailProcessor` to handle `inline`/`use_cdn` response actions
- Add `WorkflowCdnFetcher` protocol and `DirectWorkflowCdnFetcher` implementation
- Add `GetWorkflowsOperation` and `GetWorkflowOperation` cacheable network operations
- Add `WorkflowsAPI` facade and wire into `Backend`
- Add unit tests for all new components

iOS equivalent of RevenueCat/purchases-android#3300

Made-with: Cursor
- Add WorkflowTrigger struct (name, type, action_id, component_id)
- Add triggers, outputs, and metadata fields to WorkflowStep
- Add metadata field to PublishedWorkflow
- Remove value field from WorkflowTriggerAction (backend uses step_id only)

Made-with: Cursor
- Replace value/resolvedTargetStepId assertions with stepId
- Remove testDecodeWorkflowTriggerActionValueTakesPrecedence (value field removed)
- Add testDecodeWorkflowTrigger for new WorkflowTrigger struct
- Add testDecodePublishedWorkflowWithMetadata
- Update testDecodeWorkflowStepDefaults to cover triggers, outputs, metadata
- Add testDecodeWorkflowStepMatchingActualBackendResponse with real backend payload

Made-with: Cursor
…o callbacks

CDN fetch and JSON decoding were running once per deduplicated callback.
Compute the Result<WorkflowFetchResult, BackendError> once outside the
performOnAllItemsAndRemoveFromCache loop, matching the pattern used by
GetOfferingsOperation and other operations in the codebase.

Made-with: Cursor
…purchases-ios into feat/workflows-network-layer
…y errors correctly

- Replace Data(contentsOf:) with URLSession.shared.dataTask + DispatchSemaphore
  in DirectWorkflowCdnFetcher; gets URLSession timeout, HTTP status validation,
  and proper network stack semantics
- Add WorkflowDetailProcessingError.cdnFetchFailed typed error so CDN I/O
  failures are distinguishable from envelope parsing failures
- Catch cdnFetchFailed in GetWorkflowOperation and map to NetworkError.networkError
  instead of NetworkError.decoding, fixing misleading error classification
- Update WorkflowDetailProcessorTests to assert the typed error is thrown

Made-with: Cursor
…gContinuation

- Make WorkflowCdnFetcher.fetchCompiledWorkflowData async throws; use
  withCheckedThrowingContinuation to bridge URLSession.dataTask into async,
  avoiding any thread-blocking
- Make WorkflowDetailProcessor.process async throws to propagate async
- Bridge into async in GetWorkflowOperation via Task {}; completion() is
  called inside the Task after CDN fetch and decoding complete
- Update WorkflowDetailProcessorTests to async throws with await

Made-with: Cursor
Avoids Task{} in GetWorkflowOperation and keeps all operations calling
completion() synchronously from within the HTTP callback, matching every
other operation in the codebase.

- WorkflowCdnFetcher.fetchCompiledWorkflowData now takes a completion handler;
  DirectWorkflowCdnFetcher uses URLSession.dataTask (non-blocking, no semaphore)
- WorkflowDetailProcessor.process now takes a completion handler; inline action
  completes synchronously, use_cdn fans out to the fetcher callback
- GetWorkflowOperation splits into getWorkflow/handleResponse/backendResult/
  distribute helpers to stay within line-length limits
- WorkflowDetailProcessorTests updated to use waitUntilValue pattern

Made-with: Cursor
Space-separated appUserID+workflowId could collide (e.g. user 'a b' + workflow 'c'
== user 'a' + workflow 'b c'). Use newline as delimiter, matching the precedent
set by GetWebBillingProductsOperation.

Made-with: Cursor
facumenzella and others added 12 commits April 21, 2026 20:59
WorkflowResponseTests already had @_spi(Internal) @testable import RevenueCat;
WorkflowDetailProcessorTests was missing the @_spi annotation, causing
'id', 'steps', and 'initialStepId' to be inaccessible on PublishedWorkflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ts targets

The PBXBuildFile entries for DB7EA77F2F9764F700BCC082 and DB7EA7802F9764F700BCC082
were referenced in the StoreKitUnitTests and UnitTests source phases but never
defined, causing MockWorkflowsAPI to be missing at compile time and
MockBackend.swift to fail with 'cannot find MockWorkflowsAPI in scope'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Android PR #3350 split offering_id (internal ID) from offering_identifier
(human-readable identifier used for lookup). iOS was only decoding
offering_id and using it for the offering lookup, which would fail if the
workflow response sends the public identifier in offering_identifier.

- Add offeringIdentifier (from offering_identifier) to WorkflowScreen
- Update PurchaseHandler to prefer offeringIdentifier, falling back to offeringId
- Fix typo: workflow.screelns -> workflow.screens
- Add decode tests for both offering fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nt fixtures

Stack component decoding requires a padding field; test JSON fixtures were
missing it causing keyNotFound decode errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t fixtures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…check

The -EnableWorkflowsEndpoint launch argument replaces the compile-time flag,
allowing workflow resolution to be toggled without rebuilding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop WorkflowScreen.offeringId / offering_id CodingKey (Android already
  uses offering_identifier exclusively — aligns both platforms)
- Simplify PurchaseHandler to use screen.offeringIdentifier directly
- No-paywall offerings in PaywallsTester now render as tappable rows
  (workflow mode) instead of plain read-only text, with a context menu
  restricted to the Workflow option

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… extension

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@facumenzella facumenzella requested review from a team as code owners April 23, 2026 09:48
@emerge-tools

emerge-tools Bot commented Apr 23, 2026

Copy link
Copy Markdown

4 builds increased size

Name Version Download Change Install Change Approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 17.7 MB ⬆️ 28.9 kB (0.16%) 63.6 MB ⬆️ 108.5 kB (0.17%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.local-source
1.0 (1) 4.0 MB ⬆️ 6.2 kB (0.16%) 12.1 MB ⬆️ 21.4 kB (0.18%) N/A
BinarySizeTest
com.revenuecat.binary-size-test.cocoapods
1.0 (1) 6.1 MB ⬆️ 10.5 kB (0.17%) 26.8 MB ⬆️ 46.5 kB (0.18%) ⏳ Needs approval
BinarySizeTest
com.revenuecat.binary-size-test.spm
1.0 (1) 4.1 MB ⬆️ 7.4 kB (0.18%) 10.5 MB ⬆️ 17.2 kB (0.16%) N/A

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 108.5 kB (0.17%)
Total download size change: ⬆️ 28.9 kB (0.16%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 39.5 kB
Code Signature ⬆️ 3.2 kB
DYLD.Exports ⬆️ 1.1 kB
Other ⬆️ 64.6 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.local-source

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 21.4 kB (0.18%)
Total download size change: ⬆️ 6.2 kB (0.16%)

Largest size changes

Item Install Size Change
DYLD.Exports ⬆️ 3.0 kB
🗑 RevenueCat.WorkflowFetchResult ⬇️ -1.6 kB
RevenueCat.DefaultValue.value witness ⬇️ -1.4 kB
RevenueCat.IgnoreDecodeErrors.value witness ⬆️ 1.4 kB
📝 RevenueCat.WorkflowDataResult.value witness ⬆️ 1.2 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.cocoapods

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 46.5 kB (0.18%)
Total download size change: ⬆️ 10.5 kB (0.17%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 15.3 kB
DYLD.String Table ⬆️ 6.5 kB
RevenueCatUI.Template2View.iconImage ⬇️ -3.7 kB
RevenueCatUI.TabsComponentViewModel.init(component,controlStackVi... ⬆️ 3.5 kB
DYLD.Exports ⬆️ 3.0 kB
View Treemap

Image of diff

BinarySizeTest 1.0 (1)
com.revenuecat.binary-size-test.spm

⚖️ Compare build
📦 Install build
⏱️ Analyze build performance

Total install size change: ⬆️ 17.2 kB (0.16%)
Total download size change: ⬆️ 7.4 kB (0.18%)

Largest size changes

Item Install Size Change
DYLD.Exports ⬆️ 3.0 kB
🗑 RevenueCat.WorkflowFetchResult ⬇️ -1.6 kB
RevenueCat.DefaultValue.value witness ⬇️ -1.4 kB
RevenueCat.IgnoreDecodeErrors.value witness ⬆️ 1.4 kB
📝 RevenueCat.WorkflowDataResult.value witness ⬆️ 1.2 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

Comment trigger: Size diff threshold of 100.00kB exceeded

@vegaro vegaro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

reapproving as #6640 was already approved

@facumenzella facumenzella enabled auto-merge (squash) April 23, 2026 09:51

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit ce7314c. Configure here.

identifier: identifier,
presentedOfferingContext: presentedOfferingContext
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Workflow resolution doesn't fall back to standard offerings

High Severity

The PR description states the workflow path "falls back to standard offerings on failure," but resolveOfferingIdentifier uses return try await for the workflow branch, meaning any error from resolveWorkflowOfferingIdentifier propagates immediately without ever reaching the standard offerings code path below the #if block. When the -EnableWorkflowsEndpoint flag is active and the workflow call fails (network error, missing step/screen, etc.), the user sees an error instead of the standard offering paywall. A do/catch wrapping the workflow call is needed so failures can fall through to standard resolution.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ce7314c. Configure here.

@facumenzella facumenzella merged commit 23d012a into main Apr 23, 2026
14 of 17 checks passed
@facumenzella facumenzella deleted the facu/workflow-paywall-resolution-v2 branch April 23, 2026 10:01
This was referenced Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants