Skip to content

Add custom store product support#454

Open
yusuftor wants to merge 14 commits intodevelopfrom
yusuf/custom-store-product
Open

Add custom store product support#454
yusuftor wants to merge 14 commits intodevelopfrom
yusuf/custom-store-product

Conversation

@yusuftor
Copy link
Collaborator

@yusuftor yusuftor commented Mar 13, 2026

Summary

  • Adds a new .custom product type enabling developers with an external PurchaseController to purchase products through their own payment system
  • Product data is fetched from the Superwall API (getSuperwallProducts endpoint) and cached for paywall templating (price, period, trial info)
  • A custom transaction ID is pre-generated before purchase and used as the originalTransactionIdentifier in transaction_complete
  • Trial eligibility for custom products uses entitlement history checks (same approach as Stripe products)

New files

  • CustomStoreProduct.swift — config model for custom products (decoded from store: "CUSTOM")
  • CustomStoreTransaction.swiftStoreTransactionType implementation with pre-generated transaction ID
  • CustomProductTests.swift — unit tests covering decoding, attribute computation, trial eligibility, and product variables

Modified files

  • Product.swift — added .custom(CustomStoreProduct) case to StoreProductType
  • ProductStore.swift — added .custom case
  • StoreProductAdapterObjc.swift — added customProduct field
  • StoreProduct.swift — added isCustomProduct flag and customTransactionId property, plus init(customProduct:)
  • StorePayment.swift — added init for custom products
  • Paywall.swift — added customProducts computed property
  • PaywallLogic.swift — added getCustomProducts(from:) filter
  • AddPaywallProducts.swift — fetches and caches custom products from API, merges into productsById, adds custom trial eligibility check
  • TransactionManager.swift — generates custom transaction ID before purchase, creates CustomStoreTransaction on purchase completion
  • FactoryProtocols.swift / DependencyContainer.swift — added makeStoreTransaction(from: CustomStoreTransaction)
  • V2ProductsResponse.swift — added .custom to SuperwallProductPlatform

Test plan

  • Unit tests for CustomStoreProduct decoding and round-trip encoding
  • Unit tests for Product with .custom type decoding
  • Unit tests for ProductStore.custom decoding
  • Unit tests for PaywallLogic.getCustomProducts filtering
  • Unit tests for StoreProduct(customProduct:) setting isCustomProduct flag
  • Unit tests for TestStoreProduct attribute computation (price, period, trial)
  • Unit tests for CustomStoreTransaction property values
  • Unit tests for custom trial eligibility (eligible, ineligible, no entitlements, placeholder entitlements)
  • Unit tests for getProductVariables with custom products
  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs on iOS.
  • Demo project builds and runs on Mac Catalyst.
  • Demo project builds and runs on visionOS.
  • I added/updated tests or detailed why my change isn't tested.
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes.
  • I have run swiftlint in the main directory and fixed any issues.
  • I have updated the SDK documentation as well as the online docs.
  • I have reviewed the contributing guide

🤖 Generated with Claude Code

Greptile Summary

This PR introduces a new .custom product type for Superwall paywalls, allowing developers using an external PurchaseController to display and purchase products from non-App Store payment systems (e.g. Stripe, web billing). Product metadata is fetched from the getSuperwallProducts API endpoint and cached in StoreKitManager, a CustomStoreTransaction carrying a pre-generated UUID is created at purchase completion, and trial eligibility is determined via entitlement history (mirroring the existing Stripe approach). TestStoreProduct is also renamed to APIStoreProduct and gains real trial-price support, fixing a data gap for test mode products.

Key changes:

  • New CustomStoreProduct model and CustomStoreTransaction type with full Codable/StoreTransactionType conformance
  • fetchAndCacheCustomProducts in AddPaywallProducts fetches API data, handles duplicate product IDs with a warning, and invalidates the cache when entitlements change
  • prepareToPurchase unconditionally regenerates customTransactionId before each attempt, addressing the previous review comment about reuse across cancellations
  • Decoder chain in Product.swift extends to four cascading try/catch blocks; any product with an unknown future store value will propagate a DecodingError rather than being silently skipped
  • subUnit is bound but never used in APIStoreProduct.trialPeriodPricePerUnit, producing a compiler warning
  • Test coverage is comprehensive: decoding, equality, filter logic, trial eligibility (all four paths), and CustomStoreTransaction property values are all exercised

Confidence Score: 4/5

  • Safe to merge with minor cleanup; no runtime crashes in the happy path, and previously flagged issues have been addressed.
  • The core transaction flow is correct: customTransactionId is regenerated before each purchase attempt (fixing the prior review comment), the cache invalidation logic handles duplicates, and trial eligibility correctly falls back to entitlement history. The one forward-compatibility concern in Product.swift (unknown future store types causing decode failure rather than graceful skip) is a pre-existing structural pattern now extended to four types, which is worth tracking but not a blocker. The unused subUnit binding is a trivial compiler warning. Test coverage is thorough.
  • Sources/SuperwallKit/Models/Product/Product.swift — the four-deep decode chain means unknown future store values will propagate errors upward rather than being skipped gracefully.

Important Files Changed

Filename Overview
Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift New model for custom products; correctly validates store == "CUSTOM" during decoding, implements isEqual/hash for NSObject, and is Sendable.
Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift New StoreTransactionType that carries a pre-generated UUID as the transaction ID; all StoreKit-specific fields are correctly stubbed to nil.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift Renamed from TestStoreProduct; adds real trial price support. Minor issues: stale docstring still refers to "test store products", and subUnit is bound but unused in trialPeriodPricePerUnit causing a compiler warning.
Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift Adds fetchAndCacheCustomProducts to pre-populate custom product data from the Superwall API, with duplicate-ID handling, cache invalidation on entitlement changes, and custom trial eligibility checking. Previously flagged issues (silent missing-product failure, duplicate crash) are now resolved.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Integrates custom transaction flow; customTransactionId is regenerated unconditionally at the start of prepareToPurchase (before any early returns), and latestTransaction uses it to build a CustomStoreTransaction. Transaction tracking and receipt-loading are correctly bypassed for custom products.
Sources/SuperwallKit/Models/Product/Product.swift Adds .custom case to the decoder chain as the final fallback; because CustomStoreProduct validates store == "CUSTOM" and throws otherwise, any product with an unknown future store type will propagate an error upward instead of being gracefully skipped.
Sources/SuperwallKit/StoreKit/Products/StoreProduct/Entitlement.swift Adds a formUnion override that delegates to the existing priority-merging union function, ensuring in-place set mutations respect entitlement priority logic.
Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift Comprehensive test file covering decoding round-trips, equality, PaywallLogic filtering, StoreProduct flag setting, attribute computation, CustomStoreTransaction values, and all four trial eligibility paths.
Sources/SuperwallKit/Network/V2ProductsResponse.swift Adds .custom to SuperwallProductPlatform and trialPeriodPrice to SuperwallProductSubscription, both with correct CodingKeys.

Sequence Diagram

sequenceDiagram
    participant PVC as PaywallViewController
    participant TM as TransactionManager
    participant APR as AddPaywallProducts
    participant NW as Network
    participant SKM as StoreKitManager
    participant PC as PurchaseController

    Note over APR,SKM: Paywall Load Phase
    APR->>APR: fetchAndCacheCustomProducts(customProducts)
    APR->>NW: getSuperwallProducts()
    NW-->>APR: SuperwallProductsResponse
    APR->>SKM: setProduct(StoreProduct(customProduct:), forIdentifier:)
    APR->>APR: mergeCustomProducts into productsById
    APR->>APR: checkCustomTrialEligibility(productsById:)

    Note over PVC,PC: Purchase Phase
    PVC->>TM: prepareToPurchase(product:, source: .internal)
    TM->>TM: product.customTransactionId = UUID().uuidString
    TM->>TM: isFreeTrialAvailable → isCustomProductFreeTrialAvailable
    TM->>PC: purchase(product)
    PC-->>TM: .purchased

    TM->>TM: didPurchase()
    TM->>TM: latestTransaction(for: product, purchaseDate:)
    TM->>TM: CustomStoreTransaction(customTransactionId:, productIdentifier:, purchaseDate:)
    TM->>TM: factory.makeStoreTransaction(from: customTransaction)
    TM->>TM: trackTransactionDidSucceed(transaction)
    TM->>TM: finalizeInternalPurchase → dismiss paywall
Loading

Comments Outside Diff (3)

  1. Sources/SuperwallKit/Models/Product/ProductStore.swift, line 267-270 (link)

    Int raw-value shift may break external consumers

    ProductStore is a public Int-backed enum. Inserting .custom before .other silently shifts .other's implicit raw value from 5 to 6. The SDK's own Codable path uses string keys (so it is not affected), but any consumer that:

    • persists store.rawValue as an Int to disk/database, or
    • constructs a ProductStore via ProductStore(rawValue: 5), or
    • uses the numeric value in ObjC via the bridged integer constant

    …will silently misinterpret .other as .custom (or fail to round-trip at all) after this update.

    Consider appending .custom after .other (before case none / end of enum), or explicitly assign stable raw values to all cases to make ordering irrelevant.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/Models/Product/ProductStore.swift
    Line: 267-270
    
    Comment:
    **Int raw-value shift may break external consumers**
    
    `ProductStore` is a public `Int`-backed enum. Inserting `.custom` *before* `.other` silently shifts `.other`'s implicit raw value from `5` to `6`. The SDK's own Codable path uses string keys (so it is not affected), but any consumer that:
    
    - persists `store.rawValue` as an `Int` to disk/database, or  
    - constructs a `ProductStore` via `ProductStore(rawValue: 5)`, or  
    - uses the numeric value in ObjC via the bridged integer constant
    
    …will silently misinterpret `.other` as `.custom` (or fail to round-trip at all) after this update.
    
    Consider appending `.custom` *after* `.other` (before `case none` / end of enum), or explicitly assign stable raw values to all cases to make ordering irrelevant.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift, line 522-528 (link)

    nonisolated(unsafe) on mutable properties of a Sendable class

    Both isCustomProduct and customTransactionId are declared as nonisolated(unsafe) var on a final class that conforms to Sendable. These properties are mutated in async contexts (TransactionManager.prepareToPurchase and didPurchase), which means concurrent access is possible without any synchronization, making this technically a data race.

    The existing introOfferToken uses the same pattern, so this is consistent with the current codebase style — but each new mutable nonisolated(unsafe) property expands the unsynchronized mutation surface. If StoreProduct instances can ever be accessed from multiple tasks concurrently (e.g., retry logic while a purchase is in-flight), consider protecting these with an actor or os_unfair_lock.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift
    Line: 522-528
    
    Comment:
    **`nonisolated(unsafe)` on mutable properties of a `Sendable` class**
    
    Both `isCustomProduct` and `customTransactionId` are declared as `nonisolated(unsafe) var` on a `final class` that conforms to `Sendable`. These properties are mutated in async contexts (`TransactionManager.prepareToPurchase` and `didPurchase`), which means concurrent access is possible without any synchronization, making this technically a data race.
    
    The existing `introOfferToken` uses the same pattern, so this is consistent with the current codebase style — but each new mutable `nonisolated(unsafe)` property expands the unsynchronized mutation surface. If `StoreProduct` instances can ever be accessed from multiple tasks concurrently (e.g., retry logic while a purchase is in-flight), consider protecting these with an actor or `os_unfair_lock`.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift, line 697-703 (link)

    P2 nonisolated(unsafe) var on Sendable class introduces data-race risk

    Both isCustomProduct and customTransactionId are declared nonisolated(unsafe) var on a Sendable final class. Swift's Sendable conformance is satisfied here only because the developer promises no concurrent mutation, but the SDK's async actors make this easy to violate accidentally.

    customTransactionId is written in prepareToPurchase and read in latestTransaction, both of which run in async contexts. While the current call order is sequential (prepare before purchase), any future code path that calls either function concurrently on the same StoreProduct instance would produce an undetected data race.

    The existing introOfferToken property already uses the same pattern, so changing the overall approach is out of scope here, but it's worth considering actor-isolated storage or a dedicated PurchaseState actor for these custom-product fields to make the isolation contract explicit.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: Sources/SuperwallKit/StoreKit/Products/StoreProduct/StoreProduct.swift
    Line: 697-703
    
    Comment:
    **`nonisolated(unsafe) var` on `Sendable` class introduces data-race risk**
    
    Both `isCustomProduct` and `customTransactionId` are declared `nonisolated(unsafe) var` on a `Sendable` final class. Swift's `Sendable` conformance is satisfied here only because the developer promises no concurrent mutation, but the SDK's async actors make this easy to violate accidentally.
    
    `customTransactionId` is written in `prepareToPurchase` and read in `latestTransaction`, both of which run in async contexts. While the current call order is sequential (prepare before purchase), any future code path that calls either function concurrently on the same `StoreProduct` instance would produce an undetected data race.
    
    The existing `introOfferToken` property already uses the same pattern, so changing the overall approach is out of scope here, but it's worth considering actor-isolated storage or a dedicated `PurchaseState` actor for these custom-product fields to make the isolation contract explicit.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift
Line: 276

Comment:
**Bound variable `subUnit` is never used**

`subUnit` is bound via `let subUnit = subscriptionUnit` in the guard but is never referenced in the function body — the `unit` parameter is what drives the `multiplier` switch, not `subUnit`. This will produce a Swift compiler warning ("value 'subUnit' was defined but never used").

The guard condition is semantically useful (it ensures we have a known subscription period before computing a per-unit price), but the binding itself should be discarded:

```suggestion
    guard rawTrialPeriodPrice != 0, subscriptionUnit != nil else {
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift
Line: 13-14

Comment:
**Stale doc comment after rename**

The inline documentation still refers exclusively to test-mode usage after the file was renamed from `TestStoreProduct.swift` to `APIStoreProduct.swift` and its responsibility extended to cover custom products:

```suggestion
/// A `StoreProductType` backed by a `SuperwallProduct` from the Superwall API.
///
/// Used for API-backed products such as test store products and custom store products.
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: Sources/SuperwallKit/Models/Product/Product.swift
Line: 175-179

Comment:
**Unknown future store types will cause complete product decode failure**

`CustomStoreProduct` is now the final fallback in a four-deep `try/catch` chain. `CustomStoreProduct.init(from:)` throws `DecodingError.dataCorrupted` whenever `store != "CUSTOM"`. This means any product the server sends with a new, unknown `store` value (e.g. `"AMAZON"` in a future server-side deployment) will propagate the error all the way up and fail to decode the entire `Product`, rather than being silently skipped. Depending on how `Paywall` handles failed product items (via `ThrowableDecodable`), this could silently drop the product or crash product loading.

Since the existing chain already had this property for 3 types, this adds a fourth and makes a future forward-compatibility break more likely. Consider adding an explicit `other` catch-all at the end (e.g. using a `RawStoreProduct` that only reads `productIdentifier` and `store`) so unknown store types don't fail the entire decode.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "Update AddPaywallPro..."

Greptile also left 1 inline comment on this PR.

yusuftor and others added 3 commits February 24, 2026 16:40
Introduces a new `.custom` product type that allows developers using an
external PurchaseController to purchase products through their own payment
system. Product data is fetched from the Superwall API and templated into
paywalls. A custom transaction ID is generated before purchase and used as
the original transaction identifier in transaction_complete.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
yusuftor and others added 2 commits March 13, 2026 15:05
Fixes a bug where cancelling and retrying a purchase would reuse the same
transaction ID, causing potential duplicate-ID collisions in analytics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Avoids shifting .other's implicit Int raw value from 5 to 6, which could
break external consumers persisting rawValue or using ObjC bridged constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant