Add custom store products, new purchase controller method - #407
Merged
Conversation
ianrumac
force-pushed
the
ir/feat/custom-store-products
branch
from
July 24, 2026 13:42
5ec8ffc to
8c69af0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Changes in this pull request
Adds custom store products — products configured on a custom store in the Superwall dashboard (e.g. Stripe or your own payment backend) that can be attached to paywalls and purchased through the developer's
PurchaseController, bypassing Google Play Billing entirely. Android port of Superwall-iOS #454.Custom product pipeline
Store.CUSTOM,CustomStoreProduct,ProductItem.StoreProductType.Custom, andCrossplatformProduct.StoreProduct.Custom, handled across all serialization paths.store == "OTHER"so that old SDKs (which predateCUSTOM) ignore them. Deserializers therefore try the custom product shape first forOTHERproducts and fall back toOtherif the fields don't match.Storenow decodes through a custom serializer that maps any unknown store name toOTHERinstead of throwing, so future store types can't break config/paywall/entitlement parsing regardless of the decodingJsonconfiguration./productsendpoint (fetchAndCacheCustomProducts) and caches them asApiStoreProduct-backedStoreProducts, so prices/periods/trials template into the paywall like any other product. A/productsfailure for declared custom products is surfaced as a product-load failure instead of presenting a broken paywall.StoreProduct.isCustomProductis inferred from the backing product (ApiStoreProductwithplatform == custom) rather than stored as a flag.Unified purchase API
PurchaseController.purchase(activity, product: StoreProduct, basePlanId, offerId)method handles both Google Play and custom products — the old signature withProductDetailsswapped for theStoreProductwrapper. Implementers branch onproduct.isCustomProductto route custom products through their own payment flow, and must grant entitlements themselves viasetSubscriptionStatus(...)(the SDK does not grant entitlements for custom products, matching iOS).purchase(activity, productDetails, basePlanId, offerId)is deprecated but fully backward compatible: the new method's default implementation bridges Play products to it, so existing controllers work unchanged and only see a deprecation warning. Custom products through an unmigrated controller fail loudly with a message saying exactly what to override.PurchaseControllerJavais untouched; Play purchases bridge to it internally, custom products fail with a clear error there.Transaction flow
TransactionManager.handleCustomProductPurchaseshort-circuits Google Play Billing for custom products: pre-generates a UUID transaction id (exposed asStoreProduct.customTransactionId), delegates to the purchase controller, and builds a syntheticStoreTransactionfor analytics —transaction_start/transaction_completeandsubscriptionStart/freeTrialStartfire as usual.freeTrialStartevents.PurchaseController(same contract as iOS). An earlier hybridCustomProductPurchaseControllerwas removed: it classified as an internal controller, so the automatic Play sync would recompute subscription status from Play purchases alone and wipe dev-granted custom entitlements.Rename
TestStoreProduct→ApiStoreProduct(moved tostore/abstractions/product), now shared between test mode and custom products.Checklist
CHANGELOG.mdfor any breaking changes, enhancements, or bug fixes.ktlintin the main directory and fixed any issues.Greptile Summary
This PR introduces support for custom store products (products sourced from the Superwall API rather than Google Play Billing), adds a new
PurchaseController.purchase(customProduct: StoreProduct)interface method for routing these purchases through an external payment system (e.g. Stripe), and renamesTestStoreProduct→ApiStoreProductto share the implementation between test mode and custom products.Store.CUSTOM/CustomStoreProduct/ProductItem.StoreProductType.Custommodel types, aCrossplatformProduct.StoreProduct.Customvariant with serializer, free-trial eligibility via the existingisWebTrialAvailablepath, and afetchAndCacheCustomProductspre-fetch step inPaywallRequestManager.TransactionManager.handleCustomProductPurchaseshort-circuits Google Play Billing for CUSTOM products, delegates to the externalPurchaseController, and builds a syntheticStoreTransactionfor analytics using a pre-generated UUID.TestStoreProduct→ApiStoreProduct(moved tostore/abstractions/product); call sites inTestModeupdated; tests renamed accordingly.Confidence Score: 3/5
Merging as-is will silently break custom-product purchases for any developer using PurchaseControllerJava, and introduces a data-race on a cached StoreProduct field that affects analytics correctness under concurrent access.
Two independent defects affect the core custom-product purchase path: Java-based PurchaseController implementations will always receive a Failed result despite passing the external-controller guard, and the mutable customTransactionId field written on a shared cached instance can be overwritten by a concurrent purchase, corrupting analytics identifiers.
InternalPurchaseController.kt (Java controller dispatch gap) and StoreProduct.kt + TransactionManager.kt (shared mutable customTransactionId written on a cached instance).
Important Files Changed
Sequence Diagram
sequenceDiagram participant PRM as PaywallRequestManager participant NET as Network (/products) participant SM as StoreManager (cache) participant TM as TransactionManager participant PC as PurchaseController (external) participant FA as StoreTransactionFactory PRM->>PRM: addProducts(paywall) PRM->>PRM: fetchAndCacheCustomProducts(paywall) PRM->>NET: getSuperwallProducts() NET-->>PRM: List SuperwallProduct PRM->>SM: cacheProduct(id, StoreProduct.custom(ApiStoreProduct)) PRM->>SM: getProducts(paywall, request) SM-->>PRM: paywall with resolved products Note over TM: User taps purchase on CUSTOM product TM->>TM: handleCustomProductPurchase(product, source, dismiss) TM->>TM: "product.customTransactionId = UUID" TM->>PC: "purchase(customProduct = product)" PC-->>TM: PurchaseResult.Purchased TM->>FA: makeStoreTransaction(customTransactionId, productId, date) FA-->>TM: StoreTransaction (no Play receipt) TM->>TM: trackTransactionDidSucceed(transaction, product, ...)Prompt To Fix All With AI
Reviews (1): Last reviewed commit: "Add custom store products, new purchase ..." | Re-trigger Greptile