@namiml/expo-nami-iap 3.4.0-dev.202605060437
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -0
- package/android/build.gradle +46 -0
- package/android/gradle.properties +1 -0
- package/android/libs/.gitkeep +0 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
- package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
- package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
- package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
- package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
- package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
- package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
- package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
- package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
- package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
- package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
- package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
- package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
- package/dist/index.cjs +56 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.mjs +45 -0
- package/expo-module.config.json +9 -0
- package/ios/Errors.swift +178 -0
- package/ios/ExpoNamiIap.podspec +14 -0
- package/ios/ExpoNamiIapModule.swift +81 -0
- package/ios/ProductMapper.swift +130 -0
- package/ios/PromotionalOfferSigner.swift +78 -0
- package/ios/StoreKit2Helper.swift +252 -0
- package/ios/Tests/ErrorsTests.swift +189 -0
- package/ios/Tests/ProductMapperTests.swift +165 -0
- package/ios/Tests/Resources/Products.storekit +210 -0
- package/ios/Tests/StoreKit2HelperTests.swift +360 -0
- package/ios/TransactionObserver.swift +54 -0
- package/package.json +64 -0
- package/src/ExpoNamiIapModule.kepler.ts +200 -0
- package/src/ExpoNamiIapModule.ts +18 -0
- package/src/index.ts +63 -0
- package/src/types.ts +46 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package ml.nami.expo.iap.google
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import com.android.billingclient.api.BillingClient
|
|
5
|
+
import com.android.billingclient.api.BillingClientStateListener
|
|
6
|
+
import com.android.billingclient.api.BillingResult
|
|
7
|
+
import com.android.billingclient.api.PendingPurchasesParams
|
|
8
|
+
import com.android.billingclient.api.PurchasesUpdatedListener
|
|
9
|
+
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
10
|
+
import kotlin.coroutines.resume
|
|
11
|
+
import kotlin.coroutines.resumeWithException
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wraps [BillingClient] construction and connection lifecycle.
|
|
15
|
+
*
|
|
16
|
+
* [ensureConnected] suspends until the client is connected. If the client
|
|
17
|
+
* disconnects with [BillingClient.BillingResponseCode.SERVICE_DISCONNECTED],
|
|
18
|
+
* it retries the connection once automatically.
|
|
19
|
+
*/
|
|
20
|
+
internal class PlayBillingConnection(
|
|
21
|
+
context: Context,
|
|
22
|
+
builder: BillingClient.Builder,
|
|
23
|
+
) {
|
|
24
|
+
/**
|
|
25
|
+
* Convenience constructor that builds a default [BillingClient.Builder].
|
|
26
|
+
*/
|
|
27
|
+
constructor(
|
|
28
|
+
context: Context,
|
|
29
|
+
purchasesUpdatedListener: PurchasesUpdatedListener,
|
|
30
|
+
) : this(
|
|
31
|
+
context,
|
|
32
|
+
BillingClient
|
|
33
|
+
.newBuilder(context)
|
|
34
|
+
.setListener(purchasesUpdatedListener)
|
|
35
|
+
.enablePendingPurchases(
|
|
36
|
+
PendingPurchasesParams
|
|
37
|
+
.newBuilder()
|
|
38
|
+
.enableOneTimeProducts()
|
|
39
|
+
.build(),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
internal val billingClient: BillingClient = builder.build()
|
|
44
|
+
|
|
45
|
+
/** Whether we have already spent our single automatic retry. */
|
|
46
|
+
private var hasRetried = false
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Suspend until the [BillingClient] is connected and ready.
|
|
50
|
+
*
|
|
51
|
+
* If the client is already ready, returns immediately.
|
|
52
|
+
* On [BillingClient.BillingResponseCode.SERVICE_DISCONNECTED], retries once.
|
|
53
|
+
*
|
|
54
|
+
* @return the connected [BillingClient] instance.
|
|
55
|
+
* @throws IllegalStateException if the connection fails after retry.
|
|
56
|
+
*/
|
|
57
|
+
suspend fun ensureConnected(): BillingClient {
|
|
58
|
+
if (billingClient.isReady) return billingClient
|
|
59
|
+
return connect()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Disconnect the underlying [BillingClient] and release resources.
|
|
64
|
+
*/
|
|
65
|
+
fun close() {
|
|
66
|
+
billingClient.endConnection()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Internals ──────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
private suspend fun connect(): BillingClient =
|
|
72
|
+
suspendCancellableCoroutine { cont ->
|
|
73
|
+
billingClient.startConnection(
|
|
74
|
+
object : BillingClientStateListener {
|
|
75
|
+
override fun onBillingSetupFinished(result: BillingResult) {
|
|
76
|
+
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
|
|
77
|
+
if (cont.isActive) cont.resume(billingClient)
|
|
78
|
+
} else if (
|
|
79
|
+
result.responseCode ==
|
|
80
|
+
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED &&
|
|
81
|
+
!hasRetried
|
|
82
|
+
) {
|
|
83
|
+
// Retry once
|
|
84
|
+
hasRetried = true
|
|
85
|
+
billingClient.startConnection(this)
|
|
86
|
+
} else {
|
|
87
|
+
if (cont.isActive) {
|
|
88
|
+
cont.resumeWithException(
|
|
89
|
+
IllegalStateException(
|
|
90
|
+
"BillingClient connection failed: " +
|
|
91
|
+
"${result.responseCode} ${result.debugMessage}",
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override fun onBillingServiceDisconnected() {
|
|
99
|
+
// No-op: reconnection is handled by ensureConnected on next call
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest />
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import com.android.billingclient.api.BillingClient.BillingResponseCode
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Maps Google Play Billing `BillingResponseCode` integers to normalized error
|
|
7
|
+
* codes that match the cross-platform `ExpoNamiIapException` hierarchy defined
|
|
8
|
+
* in [IapBackend.kt].
|
|
9
|
+
*
|
|
10
|
+
* Two projections are provided:
|
|
11
|
+
* - [billingResponseToCode]: normalized code string (`"user_cancelled"`, `"network"`, etc.)
|
|
12
|
+
* - [billingResponseToNativeCode]: the Play Billing constant name (e.g. `"USER_CANCELED"`)
|
|
13
|
+
*/
|
|
14
|
+
object Errors {
|
|
15
|
+
// ── Normalized code mapping ─────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Convert a `BillingResponseCode` integer to a normalized string code.
|
|
19
|
+
*
|
|
20
|
+
* | Normalized code | BillingResponseCode constants |
|
|
21
|
+
* |--------------------|----------------------------------------------------------|
|
|
22
|
+
* | user_cancelled | USER_CANCELED |
|
|
23
|
+
* | network | SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE, NETWORK_ERROR |
|
|
24
|
+
* | item_unavailable | ITEM_UNAVAILABLE |
|
|
25
|
+
* | already_owned | ITEM_ALREADY_OWNED |
|
|
26
|
+
* | not_allowed | FEATURE_NOT_SUPPORTED, DEVELOPER_ERROR, BILLING_UNAVAILABLE |
|
|
27
|
+
* | unknown | ERROR, ITEM_NOT_OWNED, anything else |
|
|
28
|
+
*/
|
|
29
|
+
fun billingResponseToCode(code: Int): String =
|
|
30
|
+
when (code) {
|
|
31
|
+
BillingResponseCode.USER_CANCELED -> "user_cancelled"
|
|
32
|
+
BillingResponseCode.SERVICE_DISCONNECTED,
|
|
33
|
+
BillingResponseCode.SERVICE_UNAVAILABLE,
|
|
34
|
+
BillingResponseCode.NETWORK_ERROR,
|
|
35
|
+
-> "network"
|
|
36
|
+
BillingResponseCode.ITEM_UNAVAILABLE -> "item_unavailable"
|
|
37
|
+
BillingResponseCode.ITEM_ALREADY_OWNED -> "already_owned"
|
|
38
|
+
BillingResponseCode.FEATURE_NOT_SUPPORTED,
|
|
39
|
+
BillingResponseCode.DEVELOPER_ERROR,
|
|
40
|
+
BillingResponseCode.BILLING_UNAVAILABLE,
|
|
41
|
+
-> "not_allowed"
|
|
42
|
+
else -> "unknown"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Native code (constant name) mapping ─────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Map from `BillingResponseCode` integer to the constant name.
|
|
49
|
+
* Used to populate `nativeCode` in `ExpoNamiIapException`.
|
|
50
|
+
*/
|
|
51
|
+
private val nativeCodeMap: Map<Int, String> =
|
|
52
|
+
mapOf(
|
|
53
|
+
BillingResponseCode.OK to "OK",
|
|
54
|
+
BillingResponseCode.USER_CANCELED to "USER_CANCELED",
|
|
55
|
+
BillingResponseCode.SERVICE_UNAVAILABLE to "SERVICE_UNAVAILABLE",
|
|
56
|
+
BillingResponseCode.BILLING_UNAVAILABLE to "BILLING_UNAVAILABLE",
|
|
57
|
+
BillingResponseCode.ITEM_UNAVAILABLE to "ITEM_UNAVAILABLE",
|
|
58
|
+
BillingResponseCode.DEVELOPER_ERROR to "DEVELOPER_ERROR",
|
|
59
|
+
BillingResponseCode.ERROR to "ERROR",
|
|
60
|
+
BillingResponseCode.ITEM_ALREADY_OWNED to "ITEM_ALREADY_OWNED",
|
|
61
|
+
BillingResponseCode.ITEM_NOT_OWNED to "ITEM_NOT_OWNED",
|
|
62
|
+
BillingResponseCode.SERVICE_DISCONNECTED to "SERVICE_DISCONNECTED",
|
|
63
|
+
BillingResponseCode.FEATURE_NOT_SUPPORTED to "FEATURE_NOT_SUPPORTED",
|
|
64
|
+
BillingResponseCode.NETWORK_ERROR to "NETWORK_ERROR",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Return the constant name for a `BillingResponseCode` integer.
|
|
69
|
+
* Falls back to `"UNKNOWN(<code>)"` for unmapped values.
|
|
70
|
+
*/
|
|
71
|
+
fun billingResponseToNativeCode(code: Int): String = nativeCodeMap[code] ?: "UNKNOWN($code)"
|
|
72
|
+
|
|
73
|
+
// ── Factory helper ──────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convert a `BillingResponseCode` integer into the appropriate
|
|
77
|
+
* [ExpoNamiIapException] subclass.
|
|
78
|
+
*/
|
|
79
|
+
fun toException(
|
|
80
|
+
code: Int,
|
|
81
|
+
message: String? = null,
|
|
82
|
+
): ExpoNamiIapException {
|
|
83
|
+
val nativeCode = billingResponseToNativeCode(code)
|
|
84
|
+
val msg = message ?: "Billing error: $nativeCode"
|
|
85
|
+
return when (billingResponseToCode(code)) {
|
|
86
|
+
"user_cancelled" -> ExpoNamiIapException.UserCancelled(nativeCode)
|
|
87
|
+
"network" -> ExpoNamiIapException.Network(msg, nativeCode)
|
|
88
|
+
"item_unavailable" -> ExpoNamiIapException.ItemUnavailable(msg, nativeCode)
|
|
89
|
+
"already_owned" -> ExpoNamiIapException.AlreadyOwned(nativeCode)
|
|
90
|
+
"not_allowed" -> ExpoNamiIapException.NotAllowed(msg, nativeCode)
|
|
91
|
+
else -> ExpoNamiIapException.Unknown(msg, nativeCode)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
|
|
6
|
+
class ExpoNamiIapModule : Module() {
|
|
7
|
+
internal val backend: IapBackend by lazy { buildBackend() }
|
|
8
|
+
|
|
9
|
+
override fun definition() =
|
|
10
|
+
ModuleDefinition {
|
|
11
|
+
Name("ExpoNamiIapModule")
|
|
12
|
+
Events("onTransactionUpdate", "onError")
|
|
13
|
+
|
|
14
|
+
OnCreate {
|
|
15
|
+
backend.onTransactionUpdate { dict ->
|
|
16
|
+
sendEvent("onTransactionUpdate", dict)
|
|
17
|
+
}
|
|
18
|
+
backend.onError { dict ->
|
|
19
|
+
sendEvent("onError", dict)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
Function("configure") { options: Map<String, Any?> -> backend.configure(options) }
|
|
24
|
+
|
|
25
|
+
AsyncFunction("initConnection") {
|
|
26
|
+
backend.initConnection(appContext.reactContext ?: error("No React context"))
|
|
27
|
+
}
|
|
28
|
+
AsyncFunction("endConnection") { backend.endConnection() }
|
|
29
|
+
AsyncFunction("getProducts") { skuIds: List<String> -> backend.getProducts(skuIds) }
|
|
30
|
+
AsyncFunction("checkEligibility") { skuIds: List<String> -> backend.checkEligibility(skuIds) }
|
|
31
|
+
AsyncFunction("purchase") { options: Map<String, Any?> ->
|
|
32
|
+
val skuId = options["skuId"] as String
|
|
33
|
+
val activity = appContext.currentActivity ?: error("No current activity")
|
|
34
|
+
backend.purchase(
|
|
35
|
+
activity,
|
|
36
|
+
skuId,
|
|
37
|
+
options["offerId"] as String?,
|
|
38
|
+
options["offerSignature"] as String?,
|
|
39
|
+
options["appAccountToken"] as String?,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
AsyncFunction("restorePurchases") { backend.restorePurchases() }
|
|
43
|
+
AsyncFunction("finishTransaction") { id: String -> backend.finishTransaction(id) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
internal fun buildBackend(): IapBackend {
|
|
47
|
+
// Flavor-specific implementation resolved by classpath
|
|
48
|
+
val cls =
|
|
49
|
+
try {
|
|
50
|
+
Class.forName("ml.nami.expo.iap.google.PlayBillingBackend")
|
|
51
|
+
} catch (_: ClassNotFoundException) {
|
|
52
|
+
Class.forName("ml.nami.expo.iap.amazon.AmazonIapBackend")
|
|
53
|
+
}
|
|
54
|
+
return cls.getDeclaredConstructor().newInstance() as IapBackend
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
|
|
6
|
+
interface IapBackend {
|
|
7
|
+
suspend fun initConnection(context: Context)
|
|
8
|
+
|
|
9
|
+
suspend fun endConnection()
|
|
10
|
+
|
|
11
|
+
suspend fun getProducts(skuIds: List<String>): List<Map<String, Any?>>
|
|
12
|
+
|
|
13
|
+
suspend fun checkEligibility(skuIds: List<String>): List<Map<String, Any?>>
|
|
14
|
+
|
|
15
|
+
suspend fun purchase(
|
|
16
|
+
activity: Activity,
|
|
17
|
+
skuId: String,
|
|
18
|
+
offerId: String?,
|
|
19
|
+
offerSignature: String?,
|
|
20
|
+
appAccountToken: String?,
|
|
21
|
+
): Map<String, Any?>
|
|
22
|
+
|
|
23
|
+
suspend fun restorePurchases(): List<Map<String, Any?>>
|
|
24
|
+
|
|
25
|
+
suspend fun finishTransaction(transactionId: String)
|
|
26
|
+
|
|
27
|
+
fun onTransactionUpdate(callback: (Map<String, Any?>) -> Unit)
|
|
28
|
+
|
|
29
|
+
fun onError(callback: (Map<String, Any?>) -> Unit)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Apply store-specific configuration options.
|
|
33
|
+
*
|
|
34
|
+
* Called from `ExpoNamiIapModule.configure(...)`. The default
|
|
35
|
+
* implementation is a no-op; store backends override as needed.
|
|
36
|
+
*/
|
|
37
|
+
fun configure(options: Map<String, Any?>) {}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
sealed class ExpoNamiIapException(
|
|
41
|
+
message: String,
|
|
42
|
+
val code: String,
|
|
43
|
+
val nativeCode: String?,
|
|
44
|
+
) : Exception(message) {
|
|
45
|
+
class UserCancelled(
|
|
46
|
+
nativeCode: String? = null,
|
|
47
|
+
) : ExpoNamiIapException("User cancelled", "user_cancelled", nativeCode)
|
|
48
|
+
|
|
49
|
+
class Network(
|
|
50
|
+
msg: String,
|
|
51
|
+
nativeCode: String? = null,
|
|
52
|
+
) : ExpoNamiIapException(msg, "network", nativeCode)
|
|
53
|
+
|
|
54
|
+
class ItemUnavailable(
|
|
55
|
+
msg: String,
|
|
56
|
+
nativeCode: String? = null,
|
|
57
|
+
) : ExpoNamiIapException(msg, "item_unavailable", nativeCode)
|
|
58
|
+
|
|
59
|
+
class AlreadyOwned(
|
|
60
|
+
nativeCode: String? = null,
|
|
61
|
+
) : ExpoNamiIapException("Already owned", "already_owned", nativeCode)
|
|
62
|
+
|
|
63
|
+
class NotAllowed(
|
|
64
|
+
msg: String,
|
|
65
|
+
nativeCode: String? = null,
|
|
66
|
+
) : ExpoNamiIapException(msg, "not_allowed", nativeCode)
|
|
67
|
+
|
|
68
|
+
class Unknown(
|
|
69
|
+
msg: String,
|
|
70
|
+
nativeCode: String? = null,
|
|
71
|
+
) : ExpoNamiIapException(msg, "unknown", nativeCode)
|
|
72
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
package ml.nami.expo.iap
|
|
2
|
+
|
|
3
|
+
import com.android.billingclient.api.BillingClient.ProductType
|
|
4
|
+
import com.android.billingclient.api.ProductDetails
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Maps a Google Play Billing [ProductDetails] into a [Map] matching the
|
|
8
|
+
* `NamiProductDetails` / `ExpoNamiIapProduct` TypeScript type.
|
|
9
|
+
*
|
|
10
|
+
* Output keys:
|
|
11
|
+
* - `product_ref_id` (String)
|
|
12
|
+
* - `name` (String)
|
|
13
|
+
* - `description` (String)
|
|
14
|
+
* - `product_type` (`"one_time_purchase"` | `"subscription"`)
|
|
15
|
+
* - `offers` (List<Map<String, Any?>>)
|
|
16
|
+
* - `subscription_period` (Map<String, Any>) — only for subscriptions
|
|
17
|
+
*/
|
|
18
|
+
object ProductMapper {
|
|
19
|
+
/**
|
|
20
|
+
* Convert a [ProductDetails] to a dictionary suitable for bridging to TypeScript.
|
|
21
|
+
*/
|
|
22
|
+
fun toMap(pd: ProductDetails): Map<String, Any?> {
|
|
23
|
+
val result =
|
|
24
|
+
mutableMapOf<String, Any?>(
|
|
25
|
+
"product_ref_id" to pd.productId,
|
|
26
|
+
"name" to pd.name,
|
|
27
|
+
"description" to pd.description,
|
|
28
|
+
"product_type" to mapProductType(pd),
|
|
29
|
+
"offers" to buildOffers(pd),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// subscription_period — only for subscriptions
|
|
33
|
+
if (pd.productType == ProductType.SUBS) {
|
|
34
|
+
basePlanPeriod(pd)?.let { result["subscription_period"] = it }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Product type ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
private fun mapProductType(pd: ProductDetails): String =
|
|
43
|
+
when (pd.productType) {
|
|
44
|
+
ProductType.INAPP -> "one_time_purchase"
|
|
45
|
+
ProductType.SUBS -> "subscription"
|
|
46
|
+
else -> "unknown"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Offers ──────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
private fun buildOffers(pd: ProductDetails): List<Map<String, Any?>> {
|
|
52
|
+
val offers = mutableListOf<Map<String, Any?>>()
|
|
53
|
+
|
|
54
|
+
// INAPP — single default offer from OneTimePurchaseOfferDetails
|
|
55
|
+
pd.oneTimePurchaseOfferDetails?.let { otp ->
|
|
56
|
+
offers.add(
|
|
57
|
+
mapOf(
|
|
58
|
+
"offer_type" to "default",
|
|
59
|
+
"price" to (otp.priceAmountMicros / 1_000_000.0),
|
|
60
|
+
"currency" to otp.priceCurrencyCode,
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// SUBS — derive default + promotional offers from SubscriptionOfferDetails
|
|
66
|
+
pd.subscriptionOfferDetails?.let { subOffers ->
|
|
67
|
+
// The base plan is the entry without an offerId
|
|
68
|
+
val basePlan = subOffers.firstOrNull { it.offerId == null }
|
|
69
|
+
val basePricingPhase =
|
|
70
|
+
basePlan
|
|
71
|
+
?.pricingPhases
|
|
72
|
+
?.pricingPhaseList
|
|
73
|
+
?.firstOrNull { it.priceAmountMicros > 0 }
|
|
74
|
+
|
|
75
|
+
// Default offer from base plan
|
|
76
|
+
if (basePricingPhase != null) {
|
|
77
|
+
offers.add(
|
|
78
|
+
mapOf(
|
|
79
|
+
"offer_type" to "default",
|
|
80
|
+
"price" to (basePricingPhase.priceAmountMicros / 1_000_000.0),
|
|
81
|
+
"currency" to basePricingPhase.priceCurrencyCode,
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Promotional / intro / free-trial offers from other subscription offers
|
|
87
|
+
for (subOffer in subOffers) {
|
|
88
|
+
if (subOffer.offerId == null) continue // skip the base plan
|
|
89
|
+
for (phase in subOffer.pricingPhases.pricingPhaseList) {
|
|
90
|
+
if (phase.priceAmountMicros == 0L) {
|
|
91
|
+
// Free trial phase
|
|
92
|
+
offers.add(
|
|
93
|
+
buildTrialOffer(phase, subOffer.offerId),
|
|
94
|
+
)
|
|
95
|
+
} else if (isIntroPhase(phase, basePricingPhase)) {
|
|
96
|
+
// Intro price phase (lower than base price)
|
|
97
|
+
offers.add(
|
|
98
|
+
buildIntroOffer(phase, subOffer.offerId),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return offers
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private fun buildTrialOffer(
|
|
109
|
+
phase: ProductDetails.PricingPhase,
|
|
110
|
+
offerId: String?,
|
|
111
|
+
): Map<String, Any?> =
|
|
112
|
+
mapOf(
|
|
113
|
+
"offer_type" to "free_trial",
|
|
114
|
+
"offer_ref_id" to offerId,
|
|
115
|
+
"price" to 0.0,
|
|
116
|
+
"currency" to phase.priceCurrencyCode,
|
|
117
|
+
"promo_period" to parsePeriod(phase.billingPeriod),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
private fun buildIntroOffer(
|
|
121
|
+
phase: ProductDetails.PricingPhase,
|
|
122
|
+
offerId: String?,
|
|
123
|
+
): Map<String, Any?> =
|
|
124
|
+
mapOf(
|
|
125
|
+
"offer_type" to "intro",
|
|
126
|
+
"offer_ref_id" to offerId,
|
|
127
|
+
"price" to (phase.priceAmountMicros / 1_000_000.0),
|
|
128
|
+
"currency" to phase.priceCurrencyCode,
|
|
129
|
+
"promo_period" to parsePeriod(phase.billingPeriod),
|
|
130
|
+
"promo_price" to
|
|
131
|
+
mapOf(
|
|
132
|
+
"price" to (phase.priceAmountMicros / 1_000_000.0),
|
|
133
|
+
"currency" to phase.priceCurrencyCode,
|
|
134
|
+
),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Determine whether a pricing phase is an intro (discounted) phase.
|
|
139
|
+
* An intro phase has a price > 0 but lower than the base plan price.
|
|
140
|
+
*/
|
|
141
|
+
private fun isIntroPhase(
|
|
142
|
+
phase: ProductDetails.PricingPhase,
|
|
143
|
+
basePricingPhase: ProductDetails.PricingPhase?,
|
|
144
|
+
): Boolean {
|
|
145
|
+
if (basePricingPhase == null) return false
|
|
146
|
+
return phase.priceAmountMicros in 1 until basePricingPhase.priceAmountMicros
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Period parsing ──────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The base plan's billing period, derived from the first non-free pricing
|
|
153
|
+
* phase of the base-plan subscription offer (the entry with offerId == null).
|
|
154
|
+
*/
|
|
155
|
+
private fun basePlanPeriod(pd: ProductDetails): Map<String, Any>? {
|
|
156
|
+
val basePlan =
|
|
157
|
+
pd.subscriptionOfferDetails
|
|
158
|
+
?.firstOrNull { it.offerId == null }
|
|
159
|
+
val phase =
|
|
160
|
+
basePlan
|
|
161
|
+
?.pricingPhases
|
|
162
|
+
?.pricingPhaseList
|
|
163
|
+
?.firstOrNull { it.priceAmountMicros > 0 }
|
|
164
|
+
return phase?.let { parsePeriod(it.billingPeriod) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Parse an ISO 8601 duration string from Play Billing into a period dict.
|
|
169
|
+
*
|
|
170
|
+
* Play Billing uses simplified durations: `P1D`, `P1W`, `P1M`, `P3M`, `P1Y`.
|
|
171
|
+
*
|
|
172
|
+
* Returns `{ "type": "month", "count": 1 }` etc.
|
|
173
|
+
*/
|
|
174
|
+
internal fun parsePeriod(iso8601: String): Map<String, Any> {
|
|
175
|
+
val match =
|
|
176
|
+
PERIOD_REGEX.find(iso8601)
|
|
177
|
+
?: return mapOf("type" to "unknown", "count" to 0)
|
|
178
|
+
val count = match.groupValues[1].toIntOrNull() ?: 0
|
|
179
|
+
val unit =
|
|
180
|
+
when (match.groupValues[2]) {
|
|
181
|
+
"D" -> "day"
|
|
182
|
+
"W" -> "week"
|
|
183
|
+
"M" -> "month"
|
|
184
|
+
"Y" -> "year"
|
|
185
|
+
else -> "unknown"
|
|
186
|
+
}
|
|
187
|
+
return mapOf("type" to unit, "count" to count)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private val PERIOD_REGEX = Regex("""P(\d+)([DWMY])""")
|
|
191
|
+
}
|