Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.

Commit b5e78b0

Browse files
committed
support proper subscription plan change
- Refactor requestPurchase to handle subscription replacement with platform-specific props - Use ProductRequest object in fetchProducts for consistency - Replace restorePurchases with getAvailablePurchases(null) - Disallow type=All for purchases to enforce InApp or Subs only
1 parent aac634c commit b5e78b0

File tree

1 file changed

+44
-4
lines changed

1 file changed

+44
-4
lines changed

openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
236236
if (androidArgs.type == ProductQueryType.Subs) {
237237
androidArgs.subscriptionOffers.orEmpty().forEach { offer ->
238238
if (offer.offerToken.isNotEmpty()) {
239+
OpenIapLog.d("Adding offer token for SKU ${offer.sku}: ${offer.offerToken}", TAG)
239240
val queue = requestedOffersBySku.getOrPut(offer.sku) { mutableListOf() }
240241
queue.add(offer.offerToken)
241242
}
@@ -247,14 +248,22 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
247248
.setProductDetails(productDetails)
248249

249250
if (androidArgs.type == ProductQueryType.Subs) {
251+
val availableOffers = productDetails.subscriptionOfferDetails?.map {
252+
"${it.basePlanId}:${it.offerToken}"
253+
} ?: emptyList()
254+
OpenIapLog.d("Available offers for ${productDetails.productId}: $availableOffers", TAG)
255+
250256
val availableTokens = productDetails.subscriptionOfferDetails?.map { it.offerToken } ?: emptyList()
251257
val fromQueue = requestedOffersBySku[productDetails.productId]?.let { queue ->
252258
if (queue.isNotEmpty()) queue.removeAt(0) else null
253259
}
254260
val fromIndex = androidArgs.subscriptionOffers?.getOrNull(index)?.takeIf { it.sku == productDetails.productId }?.offerToken
255261
val resolved = fromQueue ?: fromIndex ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken
256262

263+
OpenIapLog.d("Resolved offer token for ${productDetails.productId}: $resolved", TAG)
264+
257265
if (resolved.isNullOrEmpty() || (availableTokens.isNotEmpty() && !availableTokens.contains(resolved))) {
266+
OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG)
258267
val err = OpenIapError.SkuOfferMismatch
259268
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
260269
currentPurchaseCallback?.invoke(Result.success(emptyList()))
@@ -272,18 +281,49 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener {
272281
.setIsOfferPersonalized(androidArgs.isOfferPersonalized == true)
273282

274283
androidArgs.obfuscatedAccountId?.let { flowBuilder.setObfuscatedAccountId(it) }
275-
androidArgs.obfuscatedProfileId?.let { flowBuilder.setObfuscatedProfileId(it) }
276284

285+
// For subscription upgrades/downgrades, purchaseToken and obfuscatedProfileId are mutually exclusive
277286
if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseTokenAndroid.isNullOrBlank()) {
287+
// This is a subscription upgrade/downgrade - do not set obfuscatedProfileId
288+
OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG)
289+
OpenIapLog.d(" - Old Token: ${androidArgs.purchaseTokenAndroid.take(10)}...", TAG)
290+
OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG)
291+
OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementModeAndroid}", TAG)
292+
OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG)
293+
paramsList.forEachIndexed { index, params ->
294+
OpenIapLog.d(" - Product[$index]: SKU=${details[index].productId}, offerToken=...", TAG)
295+
}
296+
278297
val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
279298
.setOldPurchaseToken(androidArgs.purchaseTokenAndroid)
280-
androidArgs.replacementModeAndroid?.let { updateParamsBuilder.setSubscriptionReplacementMode(it) }
281-
flowBuilder.setSubscriptionUpdateParams(updateParamsBuilder.build())
299+
300+
// Set replacement mode - this is critical for upgrades
301+
val replacementMode = androidArgs.replacementModeAndroid ?: 5 // Default to CHARGE_FULL_PRICE
302+
updateParamsBuilder.setSubscriptionReplacementMode(replacementMode)
303+
OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG)
304+
305+
val updateParams = updateParamsBuilder.build()
306+
flowBuilder.setSubscriptionUpdateParams(updateParams)
307+
OpenIapLog.d("=== Subscription Update Params Set ===", TAG)
308+
} else {
309+
// Only set obfuscatedProfileId for new purchases, not upgrades
310+
androidArgs.obfuscatedProfileId?.let {
311+
OpenIapLog.d("Setting obfuscatedProfileId for new purchase", TAG)
312+
flowBuilder.setObfuscatedProfileId(it)
313+
}
282314
}
283315

284316
val result = client.launchBillingFlow(activity, flowBuilder.build())
317+
OpenIapLog.d("launchBillingFlow result: ${result.responseCode} - ${result.debugMessage}", TAG)
285318
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
286-
val err = OpenIapError.PurchaseFailed
319+
val err = when (result.responseCode) {
320+
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
321+
OpenIapLog.w("DEVELOPER_ERROR: Invalid arguments. Check if subscriptions are in the same group.", TAG)
322+
OpenIapError.PurchaseFailed
323+
}
324+
BillingClient.BillingResponseCode.USER_CANCELED -> OpenIapError.UserCancelled
325+
else -> OpenIapError.PurchaseFailed
326+
}
287327
purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
288328
currentPurchaseCallback?.invoke(Result.success(emptyList()))
289329
}

0 commit comments

Comments
 (0)