@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,210 @@
|
|
|
1
|
+
{
|
|
2
|
+
"appPolicies" : {
|
|
3
|
+
"eula" : "",
|
|
4
|
+
"policies" : [
|
|
5
|
+
{
|
|
6
|
+
"locale" : "en_US",
|
|
7
|
+
"policyText" : "",
|
|
8
|
+
"policyURL" : ""
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"identifier" : "A1B2C3D4",
|
|
13
|
+
"nonRenewingSubscriptions" : [
|
|
14
|
+
|
|
15
|
+
],
|
|
16
|
+
"products" : [
|
|
17
|
+
{
|
|
18
|
+
"displayPrice" : "1.99",
|
|
19
|
+
"familyShareable" : false,
|
|
20
|
+
"internalID" : "100000001",
|
|
21
|
+
"localizations" : [
|
|
22
|
+
{
|
|
23
|
+
"description" : "A test consumable product",
|
|
24
|
+
"displayName" : "Test Consumable",
|
|
25
|
+
"locale" : "en_US"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"productID" : "com.nami.test.consumable",
|
|
29
|
+
"referenceName" : "Test Consumable",
|
|
30
|
+
"type" : "Consumable"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"displayPrice" : "49.99",
|
|
34
|
+
"familyShareable" : false,
|
|
35
|
+
"internalID" : "100000002",
|
|
36
|
+
"localizations" : [
|
|
37
|
+
{
|
|
38
|
+
"description" : "A lifetime non-consumable purchase",
|
|
39
|
+
"displayName" : "Lifetime Access",
|
|
40
|
+
"locale" : "en_US"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"productID" : "com.nami.test.lifetime",
|
|
44
|
+
"referenceName" : "Lifetime Access",
|
|
45
|
+
"type" : "NonConsumable"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"settings" : {
|
|
49
|
+
"_failTransactionsEnabled" : false,
|
|
50
|
+
"_locale" : "en_US",
|
|
51
|
+
"_storefront" : "USA",
|
|
52
|
+
"_storeKitErrors" : [
|
|
53
|
+
{
|
|
54
|
+
"current" : null,
|
|
55
|
+
"enabled" : false,
|
|
56
|
+
"name" : "Load Products"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"current" : null,
|
|
60
|
+
"enabled" : false,
|
|
61
|
+
"name" : "Purchase"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"current" : null,
|
|
65
|
+
"enabled" : false,
|
|
66
|
+
"name" : "Verification"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"current" : null,
|
|
70
|
+
"enabled" : false,
|
|
71
|
+
"name" : "App Store Sync"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"current" : null,
|
|
75
|
+
"enabled" : false,
|
|
76
|
+
"name" : "Subscription Status"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"current" : null,
|
|
80
|
+
"enabled" : false,
|
|
81
|
+
"name" : "App Transaction"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"current" : null,
|
|
85
|
+
"enabled" : false,
|
|
86
|
+
"name" : "Manage Subscriptions Sheet"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"current" : null,
|
|
90
|
+
"enabled" : false,
|
|
91
|
+
"name" : "Refund Request Sheet"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"current" : null,
|
|
95
|
+
"enabled" : false,
|
|
96
|
+
"name" : "Offer Code Redeem Sheet"
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
"subscriptionGroups" : [
|
|
101
|
+
{
|
|
102
|
+
"id" : "90000001",
|
|
103
|
+
"localizations" : [
|
|
104
|
+
|
|
105
|
+
],
|
|
106
|
+
"name" : "test_subscriptions",
|
|
107
|
+
"subscriptions" : [
|
|
108
|
+
{
|
|
109
|
+
"adHocOffers" : [
|
|
110
|
+
|
|
111
|
+
],
|
|
112
|
+
"codeOffers" : [
|
|
113
|
+
|
|
114
|
+
],
|
|
115
|
+
"displayPrice" : "9.99",
|
|
116
|
+
"familyShareable" : false,
|
|
117
|
+
"groupNumber" : 1,
|
|
118
|
+
"internalID" : "200000001",
|
|
119
|
+
"introductoryOffer" : null,
|
|
120
|
+
"localizations" : [
|
|
121
|
+
{
|
|
122
|
+
"description" : "Weekly auto-renewable subscription",
|
|
123
|
+
"displayName" : "Weekly Plan",
|
|
124
|
+
"locale" : "en_US"
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
"productID" : "com.nami.test.weekly",
|
|
128
|
+
"recurringSubscriptionPeriod" : "P1W",
|
|
129
|
+
"referenceName" : "Weekly Plan",
|
|
130
|
+
"subscriptionGroupID" : "90000001",
|
|
131
|
+
"type" : "RecurringSubscription",
|
|
132
|
+
"winbackOffers" : [
|
|
133
|
+
|
|
134
|
+
]
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
"adHocOffers" : [
|
|
138
|
+
|
|
139
|
+
],
|
|
140
|
+
"codeOffers" : [
|
|
141
|
+
|
|
142
|
+
],
|
|
143
|
+
"displayPrice" : "12.99",
|
|
144
|
+
"familyShareable" : false,
|
|
145
|
+
"groupNumber" : 2,
|
|
146
|
+
"internalID" : "200000002",
|
|
147
|
+
"introductoryOffer" : {
|
|
148
|
+
"internalID" : "300000001",
|
|
149
|
+
"numberOfPeriods" : 1,
|
|
150
|
+
"paymentMode" : "free",
|
|
151
|
+
"subscriptionPeriod" : "P1W"
|
|
152
|
+
},
|
|
153
|
+
"localizations" : [
|
|
154
|
+
{
|
|
155
|
+
"description" : "Monthly subscription with free trial",
|
|
156
|
+
"displayName" : "Monthly with Trial",
|
|
157
|
+
"locale" : "en_US"
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
"productID" : "com.nami.test.monthly.trial",
|
|
161
|
+
"recurringSubscriptionPeriod" : "P1M",
|
|
162
|
+
"referenceName" : "Monthly with Trial",
|
|
163
|
+
"subscriptionGroupID" : "90000001",
|
|
164
|
+
"type" : "RecurringSubscription",
|
|
165
|
+
"winbackOffers" : [
|
|
166
|
+
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
"adHocOffers" : [
|
|
171
|
+
|
|
172
|
+
],
|
|
173
|
+
"codeOffers" : [
|
|
174
|
+
|
|
175
|
+
],
|
|
176
|
+
"displayPrice" : "14.99",
|
|
177
|
+
"familyShareable" : false,
|
|
178
|
+
"groupNumber" : 3,
|
|
179
|
+
"internalID" : "200000003",
|
|
180
|
+
"introductoryOffer" : {
|
|
181
|
+
"displayPrice" : "0.99",
|
|
182
|
+
"internalID" : "300000002",
|
|
183
|
+
"numberOfPeriods" : 1,
|
|
184
|
+
"paymentMode" : "payAsYouGo",
|
|
185
|
+
"subscriptionPeriod" : "P1W"
|
|
186
|
+
},
|
|
187
|
+
"localizations" : [
|
|
188
|
+
{
|
|
189
|
+
"description" : "Monthly subscription with intro price",
|
|
190
|
+
"displayName" : "Monthly with Intro",
|
|
191
|
+
"locale" : "en_US"
|
|
192
|
+
}
|
|
193
|
+
],
|
|
194
|
+
"productID" : "com.nami.test.monthly.intro",
|
|
195
|
+
"recurringSubscriptionPeriod" : "P1M",
|
|
196
|
+
"referenceName" : "Monthly with Intro",
|
|
197
|
+
"subscriptionGroupID" : "90000001",
|
|
198
|
+
"type" : "RecurringSubscription",
|
|
199
|
+
"winbackOffers" : [
|
|
200
|
+
|
|
201
|
+
]
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
}
|
|
205
|
+
],
|
|
206
|
+
"version" : {
|
|
207
|
+
"major" : 4,
|
|
208
|
+
"minor" : 0
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
//
|
|
2
|
+
// StoreKit2HelperTests.swift
|
|
3
|
+
// ExpoNamiIap
|
|
4
|
+
//
|
|
5
|
+
// Copyright (c) 2024 Nami ML Inc. All rights reserved.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import XCTest
|
|
9
|
+
import StoreKit
|
|
10
|
+
@testable import ExpoNamiIap
|
|
11
|
+
|
|
12
|
+
@available(iOS 15.0, tvOS 15.0, *)
|
|
13
|
+
final class StoreKit2HelperTests: XCTestCase {
|
|
14
|
+
|
|
15
|
+
private var session: SKTestSession!
|
|
16
|
+
private var helper: StoreKit2Helper!
|
|
17
|
+
|
|
18
|
+
override func setUp() async throws {
|
|
19
|
+
try await super.setUp()
|
|
20
|
+
session = try SKTestSession(configurationFileNamed: "Products")
|
|
21
|
+
session.disableDialogs = true
|
|
22
|
+
session.clearTransactions()
|
|
23
|
+
helper = StoreKit2Helper()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
override func tearDown() async throws {
|
|
27
|
+
session = nil
|
|
28
|
+
helper = nil
|
|
29
|
+
try await super.tearDown()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// MARK: - getProducts
|
|
33
|
+
|
|
34
|
+
func testGetProductsReturnsMappedProductsInInputOrder() async throws {
|
|
35
|
+
let skuIds = ["com.nami.test.lifetime", "com.nami.test.consumable"]
|
|
36
|
+
let products = try await helper.getProducts(skuIds)
|
|
37
|
+
|
|
38
|
+
XCTAssertEqual(products.count, 2, "Should return two mapped products")
|
|
39
|
+
|
|
40
|
+
// Verify order matches input — lifetime first, consumable second
|
|
41
|
+
let firstId = try XCTUnwrap(products[0]["product_ref_id"] as? String)
|
|
42
|
+
let secondId = try XCTUnwrap(products[1]["product_ref_id"] as? String)
|
|
43
|
+
XCTAssertEqual(firstId, "com.nami.test.lifetime")
|
|
44
|
+
XCTAssertEqual(secondId, "com.nami.test.consumable")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testGetProductsThrowsItemUnavailableForUnknownSku() async throws {
|
|
48
|
+
do {
|
|
49
|
+
_ = try await helper.getProducts(["com.nami.test.nonexistent"])
|
|
50
|
+
XCTFail("Expected itemUnavailable error")
|
|
51
|
+
} catch let error as ExpoNamiIapError {
|
|
52
|
+
XCTAssertEqual(error.code, "item_unavailable")
|
|
53
|
+
} catch {
|
|
54
|
+
XCTFail("Unexpected error type: \(error)")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func testGetProductsCachesProducts() async throws {
|
|
59
|
+
// First call populates cache
|
|
60
|
+
let skuIds = ["com.nami.test.consumable"]
|
|
61
|
+
_ = try await helper.getProducts(skuIds)
|
|
62
|
+
|
|
63
|
+
// Second call should still work (from cache or fresh — behavior is transparent)
|
|
64
|
+
let products = try await helper.getProducts(skuIds)
|
|
65
|
+
XCTAssertEqual(products.count, 1)
|
|
66
|
+
XCTAssertEqual(products[0]["product_ref_id"] as? String, "com.nami.test.consumable")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testGetProductsPreservesOrderWithMixedTypes() async throws {
|
|
70
|
+
let skuIds = [
|
|
71
|
+
"com.nami.test.weekly",
|
|
72
|
+
"com.nami.test.consumable",
|
|
73
|
+
"com.nami.test.monthly.trial",
|
|
74
|
+
]
|
|
75
|
+
let products = try await helper.getProducts(skuIds)
|
|
76
|
+
|
|
77
|
+
XCTAssertEqual(products.count, 3)
|
|
78
|
+
XCTAssertEqual(products[0]["product_ref_id"] as? String, "com.nami.test.weekly")
|
|
79
|
+
XCTAssertEqual(products[1]["product_ref_id"] as? String, "com.nami.test.consumable")
|
|
80
|
+
XCTAssertEqual(products[2]["product_ref_id"] as? String, "com.nami.test.monthly.trial")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - checkEligibility
|
|
84
|
+
|
|
85
|
+
func testCheckEligibilityReturnsEligibleWhenNoPriorPurchase() async throws {
|
|
86
|
+
let results = try await helper.checkEligibility(["com.nami.test.monthly.trial"])
|
|
87
|
+
|
|
88
|
+
XCTAssertEqual(results.count, 1)
|
|
89
|
+
|
|
90
|
+
let entry = results[0]
|
|
91
|
+
XCTAssertEqual(entry["skuId"] as? String, "com.nami.test.monthly.trial")
|
|
92
|
+
XCTAssertEqual(entry["introEligible"] as? Bool, true)
|
|
93
|
+
XCTAssertEqual(entry["promoEligible"] as? Bool, true)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func testCheckEligibilityReturnsNotEligibleAfterPurchase() async throws {
|
|
97
|
+
// Simulate a purchase of the monthly trial product
|
|
98
|
+
try session.buyProduct(productIdentifier: "com.nami.test.monthly.trial")
|
|
99
|
+
|
|
100
|
+
let results = try await helper.checkEligibility(["com.nami.test.monthly.trial"])
|
|
101
|
+
|
|
102
|
+
XCTAssertEqual(results.count, 1)
|
|
103
|
+
|
|
104
|
+
let entry = results[0]
|
|
105
|
+
XCTAssertEqual(entry["skuId"] as? String, "com.nami.test.monthly.trial")
|
|
106
|
+
// After a purchase in the same subscription group, intro offer should no longer be eligible
|
|
107
|
+
XCTAssertEqual(entry["introEligible"] as? Bool, false)
|
|
108
|
+
// After a purchase in the same subscription group, promo should not be eligible
|
|
109
|
+
XCTAssertEqual(entry["promoEligible"] as? Bool, false)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func testCheckEligibilityNonSubscriptionReturnsFalse() async throws {
|
|
113
|
+
let results = try await helper.checkEligibility(["com.nami.test.consumable"])
|
|
114
|
+
|
|
115
|
+
XCTAssertEqual(results.count, 1)
|
|
116
|
+
|
|
117
|
+
let entry = results[0]
|
|
118
|
+
XCTAssertEqual(entry["skuId"] as? String, "com.nami.test.consumable")
|
|
119
|
+
XCTAssertEqual(entry["introEligible"] as? Bool, false)
|
|
120
|
+
XCTAssertEqual(entry["promoEligible"] as? Bool, false)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
func testCheckEligibilityThrowsForUnknownSku() async throws {
|
|
124
|
+
do {
|
|
125
|
+
_ = try await helper.checkEligibility(["com.nami.test.nonexistent"])
|
|
126
|
+
XCTFail("Expected itemUnavailable error")
|
|
127
|
+
} catch let error as ExpoNamiIapError {
|
|
128
|
+
XCTAssertEqual(error.code, "item_unavailable")
|
|
129
|
+
} catch {
|
|
130
|
+
XCTFail("Unexpected error type: \(error)")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func testCheckEligibilityMultipleSkus() async throws {
|
|
135
|
+
let results = try await helper.checkEligibility([
|
|
136
|
+
"com.nami.test.monthly.trial",
|
|
137
|
+
"com.nami.test.monthly.intro",
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
XCTAssertEqual(results.count, 2)
|
|
141
|
+
XCTAssertEqual(results[0]["skuId"] as? String, "com.nami.test.monthly.trial")
|
|
142
|
+
XCTAssertEqual(results[1]["skuId"] as? String, "com.nami.test.monthly.intro")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// MARK: - purchase
|
|
146
|
+
|
|
147
|
+
func testPurchaseReturnsTransactionDict() async throws {
|
|
148
|
+
let result = try await helper.purchase(
|
|
149
|
+
skuId: "com.nami.test.consumable",
|
|
150
|
+
offerId: nil,
|
|
151
|
+
offerSignature: nil,
|
|
152
|
+
appAccountToken: nil
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
let skuId = try XCTUnwrap(result["skuId"] as? String)
|
|
156
|
+
XCTAssertEqual(skuId, "com.nami.test.consumable")
|
|
157
|
+
|
|
158
|
+
let transactionId = try XCTUnwrap(result["transactionId"] as? String)
|
|
159
|
+
XCTAssertFalse(transactionId.isEmpty, "transactionId should be non-empty")
|
|
160
|
+
|
|
161
|
+
let originalTransactionId = try XCTUnwrap(result["originalTransactionId"] as? String)
|
|
162
|
+
XCTAssertFalse(originalTransactionId.isEmpty, "originalTransactionId should be non-empty")
|
|
163
|
+
|
|
164
|
+
let receipt = try XCTUnwrap(result["receipt"] as? String)
|
|
165
|
+
XCTAssertFalse(receipt.isEmpty, "receipt (JWS) should be non-empty")
|
|
166
|
+
|
|
167
|
+
let purchaseTime = try XCTUnwrap(result["purchaseTime"] as? Int)
|
|
168
|
+
XCTAssertGreaterThan(purchaseTime, 0, "purchaseTime should be a positive epoch millis")
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func testPurchaseWithAppAccountToken() async throws {
|
|
172
|
+
let tokenUUID = "550e8400-e29b-41d4-a716-446655440000"
|
|
173
|
+
let result = try await helper.purchase(
|
|
174
|
+
skuId: "com.nami.test.consumable",
|
|
175
|
+
offerId: nil,
|
|
176
|
+
offerSignature: nil,
|
|
177
|
+
appAccountToken: tokenUUID
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
// Verify the purchase succeeded — the appAccountToken is embedded in
|
|
181
|
+
// the transaction but not surfaced in our dictionary. We verify success
|
|
182
|
+
// and that the token did not cause a failure.
|
|
183
|
+
let skuId = try XCTUnwrap(result["skuId"] as? String)
|
|
184
|
+
XCTAssertEqual(skuId, "com.nami.test.consumable")
|
|
185
|
+
|
|
186
|
+
let transactionId = try XCTUnwrap(result["transactionId"] as? String)
|
|
187
|
+
XCTAssertFalse(transactionId.isEmpty)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func testPurchaseWithInvalidOfferSignatureIgnoresPromo() async throws {
|
|
191
|
+
// When an invalid (non-Base64 / malformed) offerSignature is provided,
|
|
192
|
+
// PromotionalOfferSigner.option returns nil and the purchase proceeds
|
|
193
|
+
// without a promotional offer applied.
|
|
194
|
+
let result = try await helper.purchase(
|
|
195
|
+
skuId: "com.nami.test.consumable",
|
|
196
|
+
offerId: "some-offer",
|
|
197
|
+
offerSignature: "not-valid-base64!!!",
|
|
198
|
+
appAccountToken: nil
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
let skuId = try XCTUnwrap(result["skuId"] as? String)
|
|
202
|
+
XCTAssertEqual(skuId, "com.nami.test.consumable")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func testPurchaseUserCancelledThrowsError() async throws {
|
|
206
|
+
// Enable transaction failures to simulate a cancellation scenario.
|
|
207
|
+
// When failTransactionsEnabled is true, StoreKit testing returns
|
|
208
|
+
// an error instead of completing the purchase.
|
|
209
|
+
session.failTransactionsEnabled = true
|
|
210
|
+
|
|
211
|
+
do {
|
|
212
|
+
_ = try await helper.purchase(
|
|
213
|
+
skuId: "com.nami.test.consumable",
|
|
214
|
+
offerId: nil,
|
|
215
|
+
offerSignature: nil,
|
|
216
|
+
appAccountToken: nil
|
|
217
|
+
)
|
|
218
|
+
XCTFail("Expected an error when transactions are set to fail")
|
|
219
|
+
} catch {
|
|
220
|
+
// The transaction failed — this verifies our error handling paths
|
|
221
|
+
// are exercised. The exact error type depends on StoreKit testing
|
|
222
|
+
// internals (may be .unknown wrapping a StoreKitError).
|
|
223
|
+
XCTAssertTrue(error is ExpoNamiIapError || error is StoreKitError,
|
|
224
|
+
"Error should be ExpoNamiIapError or StoreKitError, got \(type(of: error))")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: - finishTransaction
|
|
229
|
+
|
|
230
|
+
func testFinishTransactionRemovesFromUnfinished() async throws {
|
|
231
|
+
// Purchase a product — the transaction is held unfinished
|
|
232
|
+
let result = try await helper.purchase(
|
|
233
|
+
skuId: "com.nami.test.lifetime",
|
|
234
|
+
offerId: nil,
|
|
235
|
+
offerSignature: nil,
|
|
236
|
+
appAccountToken: nil
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
let transactionId = try XCTUnwrap(result["transactionId"] as? String)
|
|
240
|
+
|
|
241
|
+
// Verify the transaction appears in Transaction.unfinished
|
|
242
|
+
var foundBefore = false
|
|
243
|
+
for await verificationResult in Transaction.unfinished {
|
|
244
|
+
if case .verified(let txn) = verificationResult, String(txn.id) == transactionId {
|
|
245
|
+
foundBefore = true
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
XCTAssertTrue(foundBefore, "Transaction should be in unfinished before finishing")
|
|
250
|
+
|
|
251
|
+
// Finish the transaction
|
|
252
|
+
try await helper.finishTransaction(transactionId: transactionId)
|
|
253
|
+
|
|
254
|
+
// Verify the transaction no longer appears in Transaction.unfinished
|
|
255
|
+
var foundAfter = false
|
|
256
|
+
for await verificationResult in Transaction.unfinished {
|
|
257
|
+
if case .verified(let txn) = verificationResult, String(txn.id) == transactionId {
|
|
258
|
+
foundAfter = true
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
XCTAssertFalse(foundAfter, "Transaction should not be in unfinished after finishing")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func testFinishTransactionNoOpForUnknownId() async throws {
|
|
266
|
+
// Finishing a transaction that was never purchased should be a no-op
|
|
267
|
+
try await helper.finishTransaction(transactionId: "999999999")
|
|
268
|
+
// No error thrown — success
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// MARK: - restorePurchases
|
|
272
|
+
|
|
273
|
+
func testRestorePurchasesReturnsActiveEntitlements() async throws {
|
|
274
|
+
// Simulate a purchase so there is an active entitlement
|
|
275
|
+
try session.buyProduct(productIdentifier: "com.nami.test.lifetime")
|
|
276
|
+
|
|
277
|
+
let restored = try await helper.restorePurchases()
|
|
278
|
+
|
|
279
|
+
XCTAssertGreaterThanOrEqual(restored.count, 1,
|
|
280
|
+
"Should return at least the lifetime entitlement")
|
|
281
|
+
|
|
282
|
+
// Find the lifetime transaction in the results
|
|
283
|
+
let lifetime = restored.first { ($0["skuId"] as? String) == "com.nami.test.lifetime" }
|
|
284
|
+
let entry = try XCTUnwrap(lifetime, "Restored results should contain the lifetime product")
|
|
285
|
+
|
|
286
|
+
let transactionId = try XCTUnwrap(entry["transactionId"] as? String)
|
|
287
|
+
XCTAssertFalse(transactionId.isEmpty, "transactionId should be non-empty")
|
|
288
|
+
|
|
289
|
+
let receipt = try XCTUnwrap(entry["receipt"] as? String)
|
|
290
|
+
XCTAssertFalse(receipt.isEmpty, "receipt (JWS) should be non-empty")
|
|
291
|
+
|
|
292
|
+
let purchaseTime = try XCTUnwrap(entry["purchaseTime"] as? Int)
|
|
293
|
+
XCTAssertGreaterThan(purchaseTime, 0, "purchaseTime should be a positive epoch millis")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
func testRestorePurchasesEmptyWhenNoEntitlements() async throws {
|
|
297
|
+
// Fresh session with no purchases — should return an empty array
|
|
298
|
+
let restored = try await helper.restorePurchases()
|
|
299
|
+
XCTAssertTrue(restored.isEmpty, "Should return empty array when there are no entitlements")
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// MARK: - TransactionObserver (initConnection / endConnection)
|
|
303
|
+
|
|
304
|
+
func testInitConnectionFiresCallbackOnPurchase() async throws {
|
|
305
|
+
let expectation = XCTestExpectation(description: "onUpdate callback fires with transaction dict")
|
|
306
|
+
|
|
307
|
+
var receivedDict: [String: Any]?
|
|
308
|
+
await helper.initConnection { dict in
|
|
309
|
+
receivedDict = dict
|
|
310
|
+
expectation.fulfill()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Trigger a purchase via SKTestSession — this produces a Transaction.updates event
|
|
314
|
+
try session.buyProduct(productIdentifier: "com.nami.test.consumable")
|
|
315
|
+
|
|
316
|
+
await fulfillment(of: [expectation], timeout: 5.0)
|
|
317
|
+
|
|
318
|
+
let dict = try XCTUnwrap(receivedDict, "Callback should have received a transaction dict")
|
|
319
|
+
let skuId = try XCTUnwrap(dict["skuId"] as? String)
|
|
320
|
+
XCTAssertEqual(skuId, "com.nami.test.consumable")
|
|
321
|
+
|
|
322
|
+
let transactionId = try XCTUnwrap(dict["transactionId"] as? String)
|
|
323
|
+
XCTAssertFalse(transactionId.isEmpty, "transactionId should be non-empty")
|
|
324
|
+
|
|
325
|
+
let receipt = try XCTUnwrap(dict["receipt"] as? String)
|
|
326
|
+
XCTAssertFalse(receipt.isEmpty, "receipt (JWS) should be non-empty")
|
|
327
|
+
|
|
328
|
+
// Clean up
|
|
329
|
+
await helper.endConnection()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func testEndConnectionStopsCallbackFiring() async throws {
|
|
333
|
+
let firstExpectation = XCTestExpectation(description: "First purchase fires callback")
|
|
334
|
+
let secondExpectation = XCTestExpectation(description: "Second purchase should NOT fire callback")
|
|
335
|
+
secondExpectation.isInverted = true
|
|
336
|
+
|
|
337
|
+
var callCount = 0
|
|
338
|
+
await helper.initConnection { _ in
|
|
339
|
+
callCount += 1
|
|
340
|
+
if callCount == 1 {
|
|
341
|
+
firstExpectation.fulfill()
|
|
342
|
+
} else {
|
|
343
|
+
secondExpectation.fulfill()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// First purchase — should fire callback
|
|
348
|
+
try session.buyProduct(productIdentifier: "com.nami.test.consumable")
|
|
349
|
+
await fulfillment(of: [firstExpectation], timeout: 5.0)
|
|
350
|
+
|
|
351
|
+
// Stop the observer
|
|
352
|
+
await helper.endConnection()
|
|
353
|
+
|
|
354
|
+
// Second purchase — should NOT fire callback (inverted expectation)
|
|
355
|
+
try session.buyProduct(productIdentifier: "com.nami.test.lifetime")
|
|
356
|
+
await fulfillment(of: [secondExpectation], timeout: 2.0)
|
|
357
|
+
|
|
358
|
+
XCTAssertEqual(callCount, 1, "Callback should have fired exactly once (before endConnection)")
|
|
359
|
+
}
|
|
360
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//
|
|
2
|
+
// TransactionObserver.swift
|
|
3
|
+
// ExpoNamiIap
|
|
4
|
+
//
|
|
5
|
+
// Copyright (c) 2024 Nami ML Inc. All rights reserved.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import StoreKit
|
|
9
|
+
|
|
10
|
+
/// Actor that listens for `Transaction.updates` and forwards verified
|
|
11
|
+
/// transactions to a registered callback as bridgeable dictionaries.
|
|
12
|
+
///
|
|
13
|
+
/// Used by `StoreKit2Helper.initConnection(onUpdate:)` /
|
|
14
|
+
/// `StoreKit2Helper.endConnection()` to provide the JavaScript layer
|
|
15
|
+
/// with real-time transaction events.
|
|
16
|
+
@available(iOS 15.0, tvOS 15.0, *)
|
|
17
|
+
actor TransactionObserver {
|
|
18
|
+
|
|
19
|
+
private var task: Task<Void, Never>?
|
|
20
|
+
private var onUpdate: (@Sendable ([String: Any]) -> Void)?
|
|
21
|
+
|
|
22
|
+
/// Begin observing `Transaction.updates`.
|
|
23
|
+
///
|
|
24
|
+
/// Idempotent — calling `start` while already observing cancels the
|
|
25
|
+
/// previous listener and creates a new one.
|
|
26
|
+
///
|
|
27
|
+
/// - Parameter onUpdate: Closure invoked for each verified transaction,
|
|
28
|
+
/// receiving a dictionary matching the `ExpoNamiIapTransaction` shape.
|
|
29
|
+
func start(onUpdate: @escaping @Sendable ([String: Any]) -> Void) {
|
|
30
|
+
self.onUpdate = onUpdate
|
|
31
|
+
task?.cancel()
|
|
32
|
+
task = Task.detached { [weak self] in
|
|
33
|
+
for await result in Transaction.updates {
|
|
34
|
+
if Task.isCancelled { break }
|
|
35
|
+
if case .verified(let txn) = result {
|
|
36
|
+
await self?.notify(txn, jws: result.jwsRepresentation)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Stop observing `Transaction.updates` and discard the callback.
|
|
43
|
+
func stop() {
|
|
44
|
+
task?.cancel()
|
|
45
|
+
task = nil
|
|
46
|
+
onUpdate = nil
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Private
|
|
50
|
+
|
|
51
|
+
private func notify(_ txn: Transaction, jws: String?) {
|
|
52
|
+
onUpdate?(StoreKit2Helper.txnToDict(txn, jwsRepresentation: jws))
|
|
53
|
+
}
|
|
54
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@namiml/expo-nami-iap",
|
|
3
|
+
"version": "3.4.0-dev.202605060437",
|
|
4
|
+
"description": "Nami Expo native IAP module — StoreKit 2, Google Play Billing, Amazon IAP, and Kepler",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"react-native": "./src/index.ts",
|
|
9
|
+
"source": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.mjs",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src",
|
|
20
|
+
"ios",
|
|
21
|
+
"android",
|
|
22
|
+
"expo-module.config.json"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "rollup -c rollup.config.mjs",
|
|
26
|
+
"build:prod": "NODE_ENV=production rollup -c rollup.config.mjs",
|
|
27
|
+
"test": "jest --coverage",
|
|
28
|
+
"lint": "eslint src/",
|
|
29
|
+
"clean": "rm -rf dist",
|
|
30
|
+
"dev": "rollup -c rollup.config.mjs -w",
|
|
31
|
+
"prepublishOnly": "yarn clean && yarn build:prod"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@namiml/sdk-core": "3.4.0-dev.202605060437",
|
|
35
|
+
"expo": "*",
|
|
36
|
+
"react": ">=18.0.0",
|
|
37
|
+
"react-native": ">=0.72.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"@amazon-devices/keplerscript-appstore-iap-lib": {
|
|
41
|
+
"optional": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^10.0.1",
|
|
46
|
+
"@namiml/sdk-core": "3.4.0-dev.202605060437",
|
|
47
|
+
"@rollup/plugin-commonjs": "^28.0.0",
|
|
48
|
+
"@rollup/plugin-json": "^6.1.0",
|
|
49
|
+
"@rollup/plugin-node-resolve": "^16.0.0",
|
|
50
|
+
"@rollup/plugin-typescript": "^12.1.2",
|
|
51
|
+
"@types/jest": "^29.5.14",
|
|
52
|
+
"@types/react": "^18.3.12",
|
|
53
|
+
"eslint": "^10.2.0",
|
|
54
|
+
"jest": "^29.7.0",
|
|
55
|
+
"react": "^18.3.1",
|
|
56
|
+
"react-native": "^0.75.0",
|
|
57
|
+
"rollup": "^4.18.0",
|
|
58
|
+
"rollup-plugin-dts": "^6.1.0",
|
|
59
|
+
"ts-jest": "^29.2.0",
|
|
60
|
+
"typescript": "^5.4.5",
|
|
61
|
+
"typescript-eslint": "^8.58.0"
|
|
62
|
+
},
|
|
63
|
+
"packageManager": "yarn@4.4.0"
|
|
64
|
+
}
|