@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.
Files changed (43) hide show
  1. package/README.md +121 -0
  2. package/android/build.gradle +46 -0
  3. package/android/gradle.properties +1 -0
  4. package/android/libs/.gitkeep +0 -0
  5. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackend.kt +427 -0
  6. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonProductMapper.kt +119 -0
  7. package/android/src/amazon/java/ml/nami/expo/iap/amazon/AmazonPurchasingListener.kt +35 -0
  8. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazon.kt +46 -0
  9. package/android/src/amazon/java/ml/nami/expo/iap/amazon/ExpoNamiIapAmazonReceiver.kt +48 -0
  10. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingBackend.kt +446 -0
  11. package/android/src/google/java/ml/nami/expo/iap/google/PlayBillingConnection.kt +104 -0
  12. package/android/src/main/AndroidManifest.xml +1 -0
  13. package/android/src/main/java/ml/nami/expo/iap/Errors.kt +94 -0
  14. package/android/src/main/java/ml/nami/expo/iap/ExpoNamiIapModule.kt +56 -0
  15. package/android/src/main/java/ml/nami/expo/iap/IapBackend.kt +72 -0
  16. package/android/src/main/java/ml/nami/expo/iap/ProductMapper.kt +191 -0
  17. package/android/src/test/amazon/java/ml/nami/expo/iap/amazon/AmazonIapBackendTest.kt +974 -0
  18. package/android/src/test/java/ml/nami/expo/iap/ErrorsTest.kt +184 -0
  19. package/android/src/test/java/ml/nami/expo/iap/ExpoNamiIapModuleTest.kt +86 -0
  20. package/android/src/test/java/ml/nami/expo/iap/PlaceholderTest.kt +8 -0
  21. package/android/src/test/java/ml/nami/expo/iap/ProductMapperTest.kt +335 -0
  22. package/android/src/test/java/ml/nami/expo/iap/google/PlayBillingBackendTest.kt +841 -0
  23. package/android/src/testAmazon/java/ml/nami/expo/iap/ExpoNamiIapModuleAmazonFlavorTest.kt +71 -0
  24. package/dist/index.cjs +56 -0
  25. package/dist/index.d.ts +62 -0
  26. package/dist/index.mjs +45 -0
  27. package/expo-module.config.json +9 -0
  28. package/ios/Errors.swift +178 -0
  29. package/ios/ExpoNamiIap.podspec +14 -0
  30. package/ios/ExpoNamiIapModule.swift +81 -0
  31. package/ios/ProductMapper.swift +130 -0
  32. package/ios/PromotionalOfferSigner.swift +78 -0
  33. package/ios/StoreKit2Helper.swift +252 -0
  34. package/ios/Tests/ErrorsTests.swift +189 -0
  35. package/ios/Tests/ProductMapperTests.swift +165 -0
  36. package/ios/Tests/Resources/Products.storekit +210 -0
  37. package/ios/Tests/StoreKit2HelperTests.swift +360 -0
  38. package/ios/TransactionObserver.swift +54 -0
  39. package/package.json +64 -0
  40. package/src/ExpoNamiIapModule.kepler.ts +200 -0
  41. package/src/ExpoNamiIapModule.ts +18 -0
  42. package/src/index.ts +63 -0
  43. 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
+ }