Skip to content

Add custom store product support#454

Merged
yusuftor merged 23 commits intodevelopfrom
yusuf/custom-store-product
Apr 17, 2026
Merged

Add custom store product support#454
yusuftor merged 23 commits intodevelopfrom
yusuf/custom-store-product

Conversation

@yusuftor
Copy link
Copy Markdown
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)
  • Routes APIStoreProduct price formatting through the shared PriceFormatterProvider so custom products render currencies consistently with real App Store products on the same paywall
  • Guards against purchasing a custom product without an external PurchaseController — logs an explicit error and returns .failed(.productUnavailable) instead of silently bailing inside the default SK1/SK2 purchasers

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
  • APIStoreProduct.swift — price formatting now goes through PriceFormatterProvider.priceFormatterForSK2 for SK2-parity currency rendering
  • TransactionManager.swift — generates custom transaction ID before purchase, creates CustomStoreTransaction on purchase completion, and fails fast with a clear error when a custom product is purchased without an external PurchaseController
  • 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 APIStoreProduct 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 adds a new .custom product type for developers using an external PurchaseController to handle purchases through non-App-Store payment systems (e.g. Stripe, web). Product data is fetched from the Superwall API and cached for paywall templating; a pre-generated UUID serves as the transaction identifier; and trial eligibility follows the same entitlement-history approach used for Stripe products.

The implementation is well-structured and comes with thorough unit test coverage. Prior review concerns (duplicate-ID crash via Dictionary(uniqueKeysWithValues:), transaction-ID reuse across retries) have been addressed in this revision.

Confidence Score: 5/5

Safe to merge; the only remaining finding is a P2 Swift compiler warning about an unused variable binding.

All previously flagged P1 concerns (duplicate-key crash, transaction-ID reuse) are resolved. The single remaining finding — subUnit bound but unused in trialPeriodPricePerUnit — will produce a Swift compiler warning but does not affect runtime behavior. Tests are comprehensive and cover the critical paths.

APIStoreProduct.swift — unused subUnit binding in trialPeriodPricePerUnit warrants a one-line fix.

Important Files Changed

Filename Overview
Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift Renamed from TestStoreProduct; adds trial price support and is now shared by both test-mode and custom products. One unused variable binding in trialPeriodPricePerUnit will generate a Swift compiler warning.
Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift Adds fetchAndCacheCustomProducts (with proper duplicate-ID logging and cache-invalidation logic) and checkCustomTrialEligibility; integrates cleanly with the existing Stripe/Paddle trial-eligibility pattern.
Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Good refactor: outer guard now covers both source and product, custom transaction ID is regenerated on every attempt, and receipt loading is correctly skipped for custom products. New private helpers improve readability.
Sources/SuperwallKit/Models/Product/CustomStoreProduct.swift New Codable/NSObject model for custom products; correctly throws on non-CUSTOM store values during decode; isEqual/hash implemented based on id and store.
Sources/SuperwallKit/StoreKit/Transactions/StoreTransaction/CustomStoreTransaction.swift Minimal StoreTransactionType implementation for custom products; sets pre-generated UUID as both storeTransactionId and originalTransactionIdentifier; SK2-specific fields are nil.
Sources/SuperwallKit/Config/ConfigLogic.swift Bug fix: replaced Dictionary(uniqueKeysWithValues:) (would crash on duplicate product IDs) with uniquingKeysWith using the priority-merging union.
Tests/SuperwallKitTests/Paywall/Request/CustomProductTests.swift Comprehensive test coverage: decoding, round-trip encoding, flag checks, attribute computation, trial eligibility, and product-variable integration using Swift Testing framework.

Sequence Diagram

sequenceDiagram
    participant App as App / Dev
    participant SDK as SuperwallKit
    participant API as Superwall API
    participant SKM as StoreKitManager
    participant PC as PurchaseController

    App->>SDK: Paywall request
    SDK->>API: fetchAndCacheCustomProducts()
    API-->>SDK: SuperwallProduct data (price, period, trial)
    SDK->>SKM: cache StoreProduct(customProduct:)
    SDK->>SDK: checkCustomTrialEligibility()
    note over SDK: Uses entitlement history,<br/>same as Stripe path
    SDK->>App: Show paywall (custom product vars populated)

    App->>SDK: User taps purchase
    SDK->>SDK: prepareToPurchase() - generate customTransactionId (UUID)
    SDK->>SDK: isCustomProductFreeTrialAvailable() - checks customerInfo entitlements
    SDK->>PC: purchase(product)
    PC-->>SDK: PurchaseResult.purchased
    SDK->>SDK: didPurchase() - build CustomStoreTransaction(customTransactionId)
    SDK->>SDK: trackTransactionDidSucceed(transaction)
    note over SDK: Receipt loading skipped<br/>for custom products
    SDK->>App: Dismiss paywall / purchased(product)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: Sources/SuperwallKit/StoreKit/Products/StoreProduct/APIStoreProduct.swift
Line: 270-275

Comment:
**Unused `subUnit` binding causes a Swift compiler warning**

`subUnit` is bound in the `guard` only to verify that `subscriptionUnit` is non-nil, but it is never referenced in the function body — the `switch` uses the `unit` parameter instead. Swift will emit `Immutable value 'subUnit' was never used; consider replacing with '_' or removing it`. Replace the optional binding with a plain non-nil check:

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

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

Reviews (6): Last reviewed commit: "Update CHANGELOG.md" | Re-trigger Greptile

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>
Comment thread Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift Outdated
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>
Comment thread Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift
Comment thread Sources/SuperwallKit/Paywall/Request/Operators/AddPaywallProducts.swift Outdated
Comment thread Sources/SuperwallKit/Models/Product/Product.swift
yusuftor and others added 6 commits April 5, 2026 14:23
…ing PurchaseController

Route APIStoreProduct price formatting through the shared
PriceFormatterProvider so custom products render currencies consistently
with real App Store products on the same paywall.

Also surface an explicit error when a custom product is purchased without
an external PurchaseController, since the default SK1/SK2 purchasers
bail silently and leave the paywall stuck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yusuftor
Copy link
Copy Markdown
Collaborator Author

@greptileai

yusuftor and others added 3 commits April 16, 2026 18:08
…urchase error

Build the APIStoreProduct price formatter locale from the SuperwallProduct
storefront so currency rendering matches what Apple returns for App Store
products — USD on any device now renders as `$3.99`, not `US$3.99`.

Introduce `PurchaseError.customProductWithoutPurchaseController` so the
failure is distinguishable from generic productUnavailable, while keeping
the user-facing message short. The developer-facing hint lives in the log.

Also fixes a stale docstring on APIStoreProduct, removes an unused
`subUnit` binding flagged in review, and silences function_body_length
warnings introduced by the diagnostic debugPrints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strip the ~12 debugPrint statements that were left in AddPaywallProducts
after local debugging, and drop the two swiftlint:disable annotations
that were only needed to keep those functions under the body-length
limit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yusuftor yusuftor merged commit 8ea9695 into develop Apr 17, 2026
3 checks passed
@yusuftor yusuftor deleted the yusuf/custom-store-product branch April 17, 2026 10:59
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