Skip to content

Enable product replacement#384

Open
ianrumac wants to merge 1 commit intodevelopfrom
ir/feat/replacement_proration
Open

Enable product replacement#384
ianrumac wants to merge 1 commit intodevelopfrom
ir/feat/replacement_proration

Conversation

@ianrumac
Copy link
Collaborator

@ianrumac ianrumac commented Mar 16, 2026

Changes in this pull request

  • Add product change ability

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • 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 ktlint 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

Greptile Summary

This PR adds subscription product replacement (upgrade/downgrade) support to the Superwall Android SDK via Google Play Billing's SubscriptionUpdateParams. A new replace_product message from the paywall webview flows through the existing message handling pipeline and ultimately calls BillingClient.launchBillingFlow() with subscription update parameters.

  • Adds ReplacementMode enum mapping Superwall mode names (default, charge_later, charge_now, charge_difference, charge_on_expire) to Play Billing SubscriptionUpdateParams.ReplacementMode integer constants
  • Introduces ReplaceProduct paywall message and InitiateReplacement web event through the existing message pipeline
  • Adds replaceProduct() to AutomaticPurchaseController which builds SubscriptionUpdateParams and launches the billing flow
  • findActiveSubscriptionToken() picks the first active subscription token for replacement, which could select the wrong subscription when a user has multiple active subscriptions
  • Only works with the internal (automatic) purchase controller; external purchase controllers silently fall back to the normal purchase path without replacement semantics

Confidence Score: 3/5

  • The feature implementation is structurally sound but the subscription token selection logic could replace the wrong subscription for users with multiple active subscriptions.
  • The PR introduces a well-structured feature following existing patterns in the codebase. However, findActiveSubscriptionToken() arbitrarily picks the first active subscription, which is a correctness concern in multi-subscription scenarios. The ReplacementMode enum values correctly map to Play Billing constants. No tests are included for the new functionality.
  • Pay close attention to AutomaticPurchaseController.kt — specifically findActiveSubscriptionToken() and replaceProduct() for multi-subscription edge cases.

Important Files Changed

Filename Overview
superwall/src/main/java/com/superwall/sdk/store/ReplacementMode.kt New enum mapping replacement mode names to Google Play Billing SubscriptionUpdateParams.ReplacementMode integer constants. All values match the official API correctly.
superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessage.kt Adds ReplaceProduct message variant and its JSON parser for the replace_product event from the webview. Parsing includes proper validation of replacement_mode.
superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallWebEvent.kt Adds InitiateReplacement web event carrying productId, replacementMode, and shouldDismiss. Clean addition following existing patterns.
superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt Routes ReplaceProduct messages to InitiateReplacement events. Correctly includes hidden paywall detection and haptic feedback, consistent with other purchase flows.
superwall/src/main/java/com/superwall/sdk/Superwall.kt Handles InitiateReplacement event by delegating to TransactionManager.purchase() with replacementMode. Follows the same guard/loading/task pattern as InitiatePurchase.
superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt Adds replaceProduct() and findActiveSubscriptionToken(). The token lookup picks the first active subscription arbitrarily which could replace the wrong subscription when multiple are active.
superwall/src/main/java/com/superwall/sdk/store/InternalPurchaseController.kt Exposes automaticPurchaseController property via safe cast. Simple, correct addition.
superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt Adds optional replacementMode to PurchaseSource.Internal and branches purchase logic to call replaceProduct() when replacement mode is set and internal controller is active. Falls back to normal purchase path otherwise.

Sequence Diagram

sequenceDiagram
    participant WV as PaywallWebView
    participant PMH as PaywallMessageHandler
    participant SW as Superwall
    participant TM as TransactionManager
    participant APC as AutomaticPurchaseController
    participant GPB as Google Play Billing

    WV->>PMH: replace_product message
    PMH->>PMH: Parse ReplaceProduct (productId, replacementMode, shouldDismiss)
    PMH->>SW: InitiateReplacement event
    SW->>SW: Guard: purchaseTask == null
    SW->>SW: Set LoadingPurchase state
    SW->>TM: purchase(Internal(productId, state, replacementMode))
    TM->>TM: Check: replacementMode != null && hasInternalPurchaseController
    TM->>APC: replaceProduct(activity, productDetails, basePlanId, offerId, replacementMode)
    APC->>APC: findActiveSubscriptionToken()
    APC->>GPB: queryPurchasesAsync(SUBS)
    GPB-->>APC: List of active purchases
    APC->>APC: Build SubscriptionUpdateParams with oldToken + replacementMode
    APC->>GPB: launchBillingFlow(flowParams)
    GPB-->>APC: PurchaseResult via onPurchasesUpdated
    APC-->>TM: PurchaseResult
    TM-->>SW: Handle result (success/fail/cancel)
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
Line: 350-357

Comment:
**Picks arbitrary subscription for replacement**

`findActiveSubscriptionToken()` returns the first subscription with `PURCHASED` state. When a user has multiple active subscriptions, this may select the wrong one to replace. The `replaceProduct` method receives `productDetails` for the *new* product but has no information about which *existing* subscription should be replaced.

Consider accepting a target product ID (or the old subscription's product ID) and filtering the purchases to find the matching subscription token. For example:

```suggestion
    private suspend fun findActiveSubscriptionToken(targetProductId: String? = null): String? {
        isConnected.first { it }
        val purchases =
            queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
        return purchases
            .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
            .let { active ->
                if (targetProductId != null) {
                    active.firstOrNull { it.products.contains(targetProductId) }
                } else {
                    active.firstOrNull()
                }
            }
            ?.purchaseToken
    }
```

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

Last reviewed commit: b95aa07

Greptile also left 1 inline comment on this PR.

@ianrumac ianrumac marked this pull request as ready for review March 16, 2026 13:37
Comment on lines +350 to +357
private suspend fun findActiveSubscriptionToken(): String? {
isConnected.first { it }
val purchases =
queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
return purchases
.firstOrNull { it.purchaseState == Purchase.PurchaseState.PURCHASED }
?.purchaseToken
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Picks arbitrary subscription for replacement

findActiveSubscriptionToken() returns the first subscription with PURCHASED state. When a user has multiple active subscriptions, this may select the wrong one to replace. The replaceProduct method receives productDetails for the new product but has no information about which existing subscription should be replaced.

Consider accepting a target product ID (or the old subscription's product ID) and filtering the purchases to find the matching subscription token. For example:

Suggested change
private suspend fun findActiveSubscriptionToken(): String? {
isConnected.first { it }
val purchases =
queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
return purchases
.firstOrNull { it.purchaseState == Purchase.PurchaseState.PURCHASED }
?.purchaseToken
}
private suspend fun findActiveSubscriptionToken(targetProductId: String? = null): String? {
isConnected.first { it }
val purchases =
queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
return purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
.let { active ->
if (targetProductId != null) {
active.firstOrNull { it.products.contains(targetProductId) }
} else {
active.firstOrNull()
}
}
?.purchaseToken
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt
Line: 350-357

Comment:
**Picks arbitrary subscription for replacement**

`findActiveSubscriptionToken()` returns the first subscription with `PURCHASED` state. When a user has multiple active subscriptions, this may select the wrong one to replace. The `replaceProduct` method receives `productDetails` for the *new* product but has no information about which *existing* subscription should be replaced.

Consider accepting a target product ID (or the old subscription's product ID) and filtering the purchases to find the matching subscription token. For example:

```suggestion
    private suspend fun findActiveSubscriptionToken(targetProductId: String? = null): String? {
        isConnected.first { it }
        val purchases =
            queryPurchasesOfType(BillingClient.ProductType.SUBS).getOrNull() ?: return null
        return purchases
            .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
            .let { active ->
                if (targetProductId != null) {
                    active.firstOrNull { it.products.contains(targetProductId) }
                } else {
                    active.firstOrNull()
                }
            }
            ?.purchaseToken
    }
```

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

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