diff --git a/docs/docs/api/methods/core-methods.md b/docs/docs/api/methods/core-methods.md index d80e4cf28..5c22857cb 100644 --- a/docs/docs/api/methods/core-methods.md +++ b/docs/docs/api/methods/core-methods.md @@ -853,6 +853,46 @@ const status = await beginRefundRequestIOS('your_sku'); Returns: `Promise<'success' | 'userCancelled'>` +### requestPurchaseWithAdvancedCommerce() + +Request a purchase with advanced commerce data using StoreKit 2's `Product.PurchaseOption.custom` API. This allows passing custom data (e.g., campaign tokens, affiliate IDs) during the purchase flow. Requires iOS 15+. + +Unlike `requestPurchase()`, this method returns the purchase result directly via Promise rather than using event listeners. + +```ts +import {requestPurchaseWithAdvancedCommerce, ErrorCode} from 'react-native-iap'; + +try { + const result = await requestPurchaseWithAdvancedCommerce( + 'com.example.premium', + 'campaign_token_12345' + ); + console.log('Purchase successful:', result.transactionId); + // result: { success: true, transactionId: string, productId: string, purchaseDate: number } +} catch (error) { + if (error.code === ErrorCode.UserCancelled) { + console.log('User cancelled purchase'); + } else { + console.error('Purchase failed:', error.message); + } +} +``` + +**Parameters:** +- `productId` (string): The product identifier to purchase +- `advancedCommerceData` (string): The advanced commerce token/data to pass to StoreKit + +**Returns:** `Promise<{success: boolean, transactionId: string, productId: string, purchaseDate: number}>` + +**Note:** The advanced commerce data is passed to StoreKit as JSON: +```json +{ + "signatureInfo": { + "token": "" + } +} +``` + ### isTransactionVerifiedIOS() Verifies the latest transaction for a given SKU using StoreKit 2. Requires iOS 15+. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ef07b4bb3..b1c2b021e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,7 +8,7 @@ PODS: - hermes-engine (0.81.1): - hermes-engine/Pre-built (= 0.81.1) - hermes-engine/Pre-built (0.81.1) - - NitroIap (14.5.0): + - NitroIap (14.6.0): - boost - DoubleConversion - fast_float @@ -16,7 +16,7 @@ PODS: - glog - hermes-engine - NitroModules - - openiap (= 1.3.2) + - openiap (= 1.3.5) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -68,7 +68,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - openiap (1.3.2) + - openiap (1.3.5) - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2501,7 +2501,7 @@ DEPENDENCIES: - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - NitroIap (from `../..`) - NitroModules (from `../node_modules/react-native-nitro-modules`) - - openiap (from `https://github.com/hyodotdev/openiap.git`, tag `apple-v1.3.2`) + - openiap (from `https://github.com/hyodotdev/openiap.git`, tag `1.3.5`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - RCTRequired (from `../node_modules/react-native/Libraries/Required`) @@ -2598,7 +2598,7 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-nitro-modules" openiap: :git: https://github.com/hyodotdev/openiap.git - :tag: apple-v1.3.2 + :tag: 1.3.5 RCT-Folly: :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: @@ -2737,7 +2737,7 @@ EXTERNAL SOURCES: CHECKOUT OPTIONS: openiap: :git: https://github.com/hyodotdev/openiap.git - :tag: apple-v1.3.2 + :tag: 1.3.5 SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 @@ -2747,78 +2747,78 @@ SPEC CHECKSUMS: fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 4f8246b1f6d79f625e0d99472d1f3a71da4d28ca - NitroIap: bf950f4cb7a0f869dbff1c0301e66bb9bc1ce4f9 - NitroModules: 0ba3a58906a86566ea83abc016f8692374c19761 - openiap: 3d0c63fe703297471eab770fb07d60e016143e41 + NitroIap: 879113fba304f9bd60ebf3d3b4e9522eebc9edfd + NitroModules: ca08efed5e586152f2a40dac4d875e165c553905 + openiap: 8e6a58d311215bde63d36bdd61c475291f6b889a RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077 RCTRequired: e97dd5dafc1db8094e63bc5031e0371f092ae92a RCTTypeSafety: 720403058b7c1380c6a3ae5706981d6362962c89 React: f1486d005993b0af01943af1850d3d4f3b597545 React-callinvoker: 133f69368c8559e744efa345223625d412f5dfbe - React-Core: 559823921b4f294c2840fa8238ca958a29ddc211 - React-CoreModules: c41e7bbfabbc420783bb926f45837a0d5e53341e - React-cxxreact: 9cb9fa738274a1b36b97ede09c8a6717dec1a20b + React-Core: d6d8c1fd33697cec596d33b820456505ee305686 + React-CoreModules: 81ab751a7668ba161440f9623b994e1a6a3019fe + React-cxxreact: 16f2a2751d0dce8b569f23c1914edc90f655b01b React-debug: e01581e1589f329e61c95b332bf7f4969b10564b - React-defaultsnativemodule: bbb39447caa6b6cf9405fa0099f828c083640faa - React-domnativemodule: 03744d12b6d56d098531a933730bf1d4cb79bdfb - React-Fabric: 530b3993a12a96e8a7cdb9f0ef48e605277b572e - React-FabricComponents: 271ec2a9b2c00ac66fd6d1fd24e9e964d907751d - React-FabricImage: d0af66e976dbab7f8b81e36dd369fc70727d2695 - React-featureflags: 269704c8eff86e0485c9d384e286350fcda6eb70 - React-featureflagsnativemodule: db1e5d88a912fb08a5ece33fcf64e1b732da8467 - React-graphics: b19d03a01b0722b4dc82f47acb56dc3ed41937e7 - React-hermes: 811606c0aca5a3f9c6fa8e4994e02ca8f677e68e - React-idlecallbacksnativemodule: 3a3df629cd50046c7e4354f9025aefe8f2c84601 - React-ImageManager: 0d53866c63132791e37bb2373f93044fdef14aa3 - React-jserrorhandler: d5700d6ab7162fd575287502a3c5d601d98e7f09 - React-jsi: ece95417fedbed0e7153a855cb8342b7c72ab75e - React-jsiexecutor: 2b0bb644b533df2f5c0cd6ade9a4560d0bf1dd84 - React-jsinspector: 0c160f8510a8852bdf2dac12f0b1949efc18200b - React-jsinspectorcdp: f4b84409f453f61ddd8614ad45139bc594ec6bb5 - React-jsinspectornetwork: 8f2f0ca8c871ca19b571f426002c0012e7fb2aee - React-jsinspectortracing: 33f6b977eb8a4bc1e3d1a4b948809aca083143f9 - React-jsitooling: 2c61529b589e17229a9f0a4a4fc35aa7ad495850 - React-jsitracing: 838a7b0c013c4aff7d382d7fdc78cf442013ba1d - React-logger: 7aef4d74123e5e3d267e5af1fbf5135b5a0d8381 - React-Mapbuffer: 91e0eab42a6ae7f3e34091a126d70fc53bd3823e - React-microtasksnativemodule: 1ead4fe154df3b1ba34b5a9e35ef3c4bdfa72ccb - react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616 - React-NativeModulesApple: eff2eba56030eb0d107b1642b8f853bc36a833ac + React-defaultsnativemodule: e956b1d8fe15cc79d23061db229bf88170565f2f + React-domnativemodule: a18b0f7a31b9c75f12fa369baece5542d1265b36 + React-Fabric: c0237a32c3c0dbea2d2b294c8e95605e1dfe2f57 + React-FabricComponents: 65b03884bd5d9f24c79a631d7d26f0fa079bc4aa + React-FabricImage: de1ea2f2a0b32ad02e5cbb64827d1eec0439cf0d + React-featureflags: 02de9c35256cc624269b01d670d99e1fd706ea8d + React-featureflagsnativemodule: 8b84e67edbaa7b9318390c5bd3ae19790a74f356 + React-graphics: 004b40c1b236ea3bb8de6693439bef9797922ba9 + React-hermes: 2179a018b2f86652f6f33ef23efd9e5ac284b247 + React-idlecallbacksnativemodule: f54ea68f984b12e42feed1e7110623b2c38df4d1 + React-ImageManager: 9dd04b7b62bc5397f876ca5fb1b712e700ce390c + React-jserrorhandler: 2f90bf50fffea1d012e7f3d717c6adf748b1813d + React-jsi: b27208f8866e53238534f65f304903e4eff25e05 + React-jsiexecutor: 1d3e827797f592c393860dea91aaa6d53c7715e7 + React-jsinspector: bda319277ae779bc476b736fe3a497c6aed304cd + React-jsinspectorcdp: 69e1736edfd5420037680b7b4557fa748c3c8216 + React-jsinspectornetwork: 7aa707b057c6129b4af59e0c9160436bbab25022 + React-jsinspectortracing: b4a8a328ad2697f9638daa4b7cc054e0303fa47f + React-jsitooling: a6c7e2829437b28665e97a398b3374d443125e24 + React-jsitracing: d87ae17dd0eef7844e605945da926c5433fe2b51 + React-logger: d27dd2000f520bf891d24f6e141cde34df41f0ee + React-Mapbuffer: 0746ffab5ac0f49b7c9347338e3d0c1d9dd634c8 + React-microtasksnativemodule: b0fb3f97372df39bda3e657536039f1af227cc29 + react-native-safe-area-context: 6d8a7b750e496e37bda47c938320bf2c734d441f + React-NativeModulesApple: 9ec9240159974c94886ebbe4caec18e3395f6aef React-oscompat: b12c633e9c00f1f99467b1e0e0b8038895dae436 - React-perflogger: 58d12c4e5df1403030c97b9c621375c312cca454 - React-performancetimeline: 0ee0a3236c77a4ee6d8a6189089e41e4003d292e + React-perflogger: ccf4fd2664b00818645e588623c7531a8b32d114 + React-performancetimeline: a866ba759d8e06e9ba174b4421677edcae487baf React-RCTActionSheet: 3f741a3712653611a6bfc5abceb8260af9d0b218 - React-RCTAnimation: 408ad69ea136e99a463dd33eadecc29e586b3d72 - React-RCTAppDelegate: f03b46e80b8a3dbfa84b35abfe123e02f3ceef83 - React-RCTBlob: bd42e92a00ad22eaab92ffe5c137e7a2f725887a - React-RCTFabric: b99ab638c73cf2d57b886eafdbfb2e4909b0eb9a - React-RCTFBReactNativeSpec: 7ad9aba0e0655e3f29be0a1c3fd4a888fab04dcf - React-RCTImage: 0f1c74f7cd20027f8c34976a211b35d4263a0add - React-RCTLinking: 6d7dfc3a74110df56c3a73cc7626bf4415656542 - React-RCTNetwork: 6a25d8645a80d5b86098675ca39bf8fcf1afa08b - React-RCTRuntime: 38bfe9766565ae3293ca230bc51c9c020a8bc98a - React-RCTSettings: 651d9ae2cdd32f547ad0d225a2c13886d6ad2358 - React-RCTText: 9bc66cd288478e23195e01f5cb45eba79986b2b4 - React-RCTVibration: 371226f5667a00c76d792dcdb5c2e0fcbcde0c3b + React-RCTAnimation: 2edeebfba175cc2e937e2752209ab605d3c48f21 + React-RCTAppDelegate: e292321e83ee966897244a032216a70930b758d6 + React-RCTBlob: 8dfb24b6dd4a5af45e1e59e2fd925b2df1e44d08 + React-RCTFabric: b25b02a2016f5cb15926a60c77a8d75865aa3558 + React-RCTFBReactNativeSpec: 20338571a1ed853d01da6c68576aa6e8e107b6f6 + React-RCTImage: c7fe8c2f2ae8bad98ab4d944d5d50a889da4b652 + React-RCTLinking: 9ac21ce9f1af914bb01c06af3752db2ec840d0ee + React-RCTNetwork: 09a5de71d757dbad4b3fe3615839290200b932aa + React-RCTRuntime: da3f1e0ce088c20350044cdf1efcd7f8d9b9b40c + React-RCTSettings: fee112652ac7569ea9abe910206e1685f5f9adba + React-RCTText: 7ee9d0bc16b3a8149f8df6d70c48e724d0db1d89 + React-RCTVibration: 619d613abaeb05f6fbc2b2e5e33f724f05df8eb8 React-rendererconsistency: a05f6c37f9389c53213d1e28798e441fa6fbdbcd - React-renderercss: 6e4febfa014b0f53bc171a62b0f713ddbdbb9860 - React-rendererdebug: e94bf27b9d55ef2795caa8e43aa92abc4a373b8b - React-RuntimeApple: 723be5159519eba1cd92449acb29436d21571b82 - React-RuntimeCore: f58eb0f01065c9d27d91de10b2e4ab4c76d83b0e - React-runtimeexecutor: f615ec8742d0b5820170f7c8b4d2c7cb75d93ac9 - React-RuntimeHermes: fddb258e03d330d1132bb19e78fe51ac2f3f41ac - React-runtimescheduler: e92a31460e654ced8587debeec37553315e1b6a5 - React-timing: 97ada2c47b4c5932e7f773c7d239c52b90d6ca68 - React-utils: f0949d247a46b4c09f03e5a3cb1167602d0b729a - ReactAppDependencyProvider: 3eb9096cb139eb433965693bbe541d96eb3d3ec9 - ReactCodegen: 4d203eddf6f977caa324640a20f92e70408d648b - ReactCommon: ce5d4226dfaf9d5dacbef57b4528819e39d3a120 - RNCClipboard: 4b58c780f63676367640f23c8e114e9bd0cf86ac - RNScreens: 0bbf16c074ae6bb1058a7bf2d1ae017f4306797c + React-renderercss: 3decb27a81648fcdee837c59994b51fff5be5a67 + React-rendererdebug: 3b9a92d36932af52e1b473f2a89ea4b05dbdecdf + React-RuntimeApple: 4e35fb74be4b721c2e1fd6d54ec66456fa7043e9 + React-RuntimeCore: 0fd7ac6e3e9dd20cb47e87c6b9f35832dd445d5e + React-runtimeexecutor: 7680156c9f3a5a49c688bc33f9ec5ea1b00527f5 + React-RuntimeHermes: 435b7104a3c749af6251353dcb7317a8e53cbd73 + React-runtimescheduler: 8056b916168e446ea44531883928034e62e76a81 + React-timing: 36da85e32e53008ce73f87528818191e7f2508ba + React-utils: 71e53d55ce778c6e7c7c9db4b1b9d63ef8f55289 + ReactAppDependencyProvider: 448b422f8af1dedf81374eacc90a15439a0ed7f5 + ReactCodegen: 3baedb0c33f963250c866151b825a3c5194b12f1 + ReactCommon: e897f9a1b4afab370cfefaaf5fb3c80371bc3937 + RNCClipboard: 962296f7af77f6c039b683e21c2e2255af9c05df + RNScreens: 74985ca8e102294a60cec7513fa84c936fa0b20b SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - Yoga: 11c9686a21e2cd82a094a723649d9f4507200fb0 + Yoga: fa23995c18b65978347b096d0836f4f5093df545 -PODFILE CHECKSUM: eb8fe83b9f06318862c66edeb5c2ec70936f9f70 +PODFILE CHECKSUM: d4f0f62c69ca882f7d13d835f50ce30ea38ab5e2 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/ios/HybridRnIap.swift b/ios/HybridRnIap.swift index 73a469549..7c661bc45 100644 --- a/ios/HybridRnIap.swift +++ b/ios/HybridRnIap.swift @@ -1,6 +1,7 @@ import Foundation import NitroModules import OpenIAP +import StoreKit @available(iOS 15.0, *) class HybridRnIap: HybridRnIapSpec { @@ -800,6 +801,109 @@ class HybridRnIap: HybridRnIapSpec { } } + /// Request a purchase with advanced commerce data using StoreKit 2's Product.PurchaseOption.custom API. + /// + /// This method allows passing custom advanced commerce data (e.g., campaign tokens, affiliate IDs) + /// during the purchase flow. Unlike the standard `requestPurchase()` method, this returns the + /// purchase result directly via Promise rather than using event listeners. + /// + /// - Parameters: + /// - productId: The product identifier to purchase + /// - advancedCommerceData: The advanced commerce token/data to pass to StoreKit as JSON + /// - andDangerouslyFinishTransactionAutomatically: Whether to automatically finish the transaction. Defaults to `true`. + /// Set to `false` if you need to verify the transaction on your server before finishing. + /// - Returns: A Promise resolving to `NitroAdvancedCommercePurchaseResult` with transaction details + /// - Throws: `PurchaseError` if the purchase fails, product is not found, user cancels, or iOS version is insufficient + /// - Requires: iOS 15.0+ (StoreKit 2) + /// + /// The advanced commerce data is formatted as JSON: + /// ```json + /// { + /// "signatureInfo": { + /// "token": "" + /// } + /// } + /// ``` + func requestPurchaseWithAdvancedCommerceIOS(productId: String, advancedCommerceData: String, andDangerouslyFinishTransactionAutomatically: Bool?) throws -> Promise { + return Promise.async { + guard #available(iOS 15.0, *) else { + throw PurchaseError.make(code: .featureNotSupported, message: "StoreKit 2 requires iOS 15.0 or later") + } + + try self.ensureConnection() + + let shouldFinishAutomatically = andDangerouslyFinishTransactionAutomatically ?? true + + RnIapLog.payload("requestPurchaseWithAdvancedCommerceIOS", [ + "productId": productId, + "hasAdvancedCommerceData": !advancedCommerceData.isEmpty, + "andDangerouslyFinishTransactionAutomatically": shouldFinishAutomatically + ]) + + do { + let storeProducts = try await Product.products(for: [productId]) + guard let product = storeProducts.first else { + throw PurchaseError.make(code: .skuNotFound, productId: productId, message: "Product with id \(productId) not found") + } + + let payload: [String: Any] = ["signatureInfo": ["token": advancedCommerceData]] + let advancedCommerceRequestData = try JSONSerialization.data(withJSONObject: payload) + + let purchaseResult = try await product.purchase( + options: [ + Product.PurchaseOption.custom( + key: "advancedCommerceData", + value: advancedCommerceRequestData + ) + ] + ) + + switch purchaseResult { + case .success(let verification): + switch verification { + case .verified(let transaction): + let result = NitroAdvancedCommercePurchaseResult( + success: true, + transactionId: String(transaction.id), + productId: transaction.productID, + purchaseDate: transaction.purchaseDate.timeIntervalSince1970 * 1000 + ) + RnIapLog.result("requestPurchaseWithAdvancedCommerceIOS", [ + "success": true, + "transactionId": result.transactionId, + "productId": result.productId + ]) + if shouldFinishAutomatically { + Task { + await transaction.finish() + } + } + return result + case .unverified(_, let error): + RnIapLog.failure("requestPurchaseWithAdvancedCommerceIOS", error: error) + throw PurchaseError.make(code: .transactionValidationFailed, message: "Transaction verification failed: \(error.localizedDescription)") + } + case .userCancelled: + RnIapLog.result("requestPurchaseWithAdvancedCommerceIOS", "userCancelled") + throw PurchaseError.make(code: .userCancelled, message: "User cancelled the purchase") + case .pending: + RnIapLog.result("requestPurchaseWithAdvancedCommerceIOS", "pending") + throw PurchaseError.make(code: .pending, message: "Purchase is pending") + @unknown default: + let err = PurchaseError.make(code: .purchaseError, message: "Unknown purchase result") + RnIapLog.failure("requestPurchaseWithAdvancedCommerceIOS", error: err) + throw err + } + } catch let purchaseError as PurchaseError { + RnIapLog.failure("requestPurchaseWithAdvancedCommerceIOS", error: purchaseError) + throw purchaseError + } catch { + RnIapLog.failure("requestPurchaseWithAdvancedCommerceIOS", error: error) + throw PurchaseError.make(code: .purchaseError, productId: productId, message: error.localizedDescription) + } + } + } + func addPromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws { promotedProductListeners.append(listener) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 0862570f7..34fb151bb 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -36,6 +36,12 @@ const mockIap: any = { requestPromotedProductIOS: jest.fn(async () => null), buyPromotedProductIOS: jest.fn(async () => undefined), presentCodeRedemptionSheetIOS: jest.fn(async () => true), + requestPurchaseWithAdvancedCommerceIOS: jest.fn(async () => ({ + success: true, + transactionId: 'tx-123', + productId: 'product-1', + purchaseDate: Date.now(), + })), // Unified storefront getStorefront: jest.fn(async () => 'USA'), @@ -677,6 +683,112 @@ describe('Public API (src/index.ts)', () => { await expect(IAP.beginRefundRequestIOS('sku')).resolves.toBe('success'); }); + describe('requestPurchaseWithAdvancedCommerce', () => { + it('returns purchase result on iOS', async () => { + (Platform as any).OS = 'ios'; + const mockResult = { + success: true, + transactionId: 'tx-123', + productId: 'product-1', + purchaseDate: 1234567890, + }; + mockIap.requestPurchaseWithAdvancedCommerceIOS = jest.fn( + async () => mockResult, + ); + const result = await IAP.requestPurchaseWithAdvancedCommerce( + 'product-1', + 'campaign-token-123', + ); + expect(result).toEqual(mockResult); + expect(result.success).toBe(true); + expect(result.transactionId).toBe('tx-123'); + expect(result.productId).toBe('product-1'); + expect( + mockIap.requestPurchaseWithAdvancedCommerceIOS, + ).toHaveBeenCalledWith('product-1', 'campaign-token-123', true); + }); + + it('throws error on non-iOS platforms', async () => { + (Platform as any).OS = 'android'; + await expect( + IAP.requestPurchaseWithAdvancedCommerce('product-1', 'token'), + ).rejects.toThrow(/only available on iOS/); + }); + + it('handles purchase errors and normalizes error codes', async () => { + (Platform as any).OS = 'ios'; + const mockError = new Error('User cancelled the purchase'); + mockIap.requestPurchaseWithAdvancedCommerceIOS = jest + .fn() + .mockRejectedValue(mockError); + await expect( + IAP.requestPurchaseWithAdvancedCommerce('product-1', 'token'), + ).rejects.toThrow(); + expect( + mockIap.requestPurchaseWithAdvancedCommerceIOS, + ).toHaveBeenCalledWith('product-1', 'token', true); + }); + + it('includes productId in error when purchase fails', async () => { + (Platform as any).OS = 'ios'; + const mockError = new Error('Purchase failed'); + mockIap.requestPurchaseWithAdvancedCommerceIOS = jest + .fn() + .mockRejectedValue(mockError); + try { + await IAP.requestPurchaseWithAdvancedCommerce('product-1', 'token'); + fail('Should have thrown'); + } catch (error: any) { + expect(error).toBeDefined(); + } + expect( + mockIap.requestPurchaseWithAdvancedCommerceIOS, + ).toHaveBeenCalledWith('product-1', 'token', true); + }); + + it('maps result properties correctly', async () => { + (Platform as any).OS = 'ios'; + const mockResult = { + success: true, + transactionId: 'transaction-456', + productId: 'premium-monthly', + purchaseDate: 9876543210, + }; + mockIap.requestPurchaseWithAdvancedCommerceIOS = jest.fn( + async () => mockResult, + ); + const result = await IAP.requestPurchaseWithAdvancedCommerce( + 'premium-monthly', + 'affiliate-id-789', + ); + expect(result.success).toBe(mockResult.success); + expect(result.transactionId).toBe(mockResult.transactionId); + expect(result.productId).toBe(mockResult.productId); + expect(result.purchaseDate).toBe(mockResult.purchaseDate); + }); + + it('passes andDangerouslyFinishTransactionAutomatically parameter correctly', async () => { + (Platform as any).OS = 'ios'; + const mockResult = { + success: true, + transactionId: 'tx-789', + productId: 'product-2', + purchaseDate: 1111111111, + }; + mockIap.requestPurchaseWithAdvancedCommerceIOS = jest.fn( + async () => mockResult, + ); + await IAP.requestPurchaseWithAdvancedCommerce( + 'product-2', + 'token-456', + false, + ); + expect( + mockIap.requestPurchaseWithAdvancedCommerceIOS, + ).toHaveBeenCalledWith('product-2', 'token-456', false); + }); + }); + it('subscriptionStatusIOS converts items', async () => { (Platform as any).OS = 'ios'; mockIap.subscriptionStatusIOS = jest.fn(async () => [ diff --git a/src/index.ts b/src/index.ts index 6fcade822..2dae19a23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1694,6 +1694,107 @@ export const beginRefundRequestIOS: MutationField< } }; +/** + * Result of a purchase with advanced commerce data + */ +export interface AdvancedCommercePurchaseResult { + /** Whether the purchase completed successfully */ + success: boolean; + /** Unique transaction identifier from StoreKit */ + transactionId: string; + /** Product identifier that was purchased */ + productId: string; + /** Purchase timestamp in milliseconds since Unix epoch */ + purchaseDate: number; +} + +/** + * Request a purchase with advanced commerce data (iOS 15+ only) + * + * Uses StoreKit 2's Product.PurchaseOption.custom API to pass custom advanced commerce data + * (e.g., campaign tokens, affiliate IDs) during the purchase flow. Unlike the standard + * requestPurchase() method, this returns the purchase result directly via Promise rather + * than using event listeners. + * + * The advanced commerce data is formatted as JSON and passed to StoreKit: + * ```json + * { + * "signatureInfo": { + * "token": "" + * } + * } + * ``` + * + * @param {string} productId - The product identifier (SKU) to purchase. Must match a product configured in App Store Connect. + * @param {string} advancedCommerceData - Advanced commerce token or data string to pass to StoreKit. This is typically a campaign token, affiliate ID, or other attribution data that will be included in the purchase transaction. + * @param {boolean} [andDangerouslyFinishTransactionAutomatically=true] - Whether to automatically finish the transaction. Defaults to `true`. + * Set to `false` if you need to verify the transaction on your server before finishing. When `false`, you must manually call `finishTransaction()` after verification. + * @returns {Promise} Promise that resolves to a purchase result object containing transaction details when successful. + * @throws {PurchaseError} Throws a PurchaseError if: + * - The purchase fails (network error, service unavailable, etc.) + * - The product is not found in the store + * - The user cancels the purchase (ErrorCode.UserCancelled) + * - iOS version is insufficient (requires iOS 15.0+) + * - Transaction verification fails + * @platform iOS + * @requires iOS 15.0+ (StoreKit 2) + * + * @example + * ```typescript + * import {requestPurchaseWithAdvancedCommerce, ErrorCode} from 'react-native-iap'; + * + * try { + * const result = await requestPurchaseWithAdvancedCommerce( + * 'com.example.premium', + * 'campaign_token_12345' + * ); + * console.log('Purchase successful:', result.transactionId); + * // result: { success: true, transactionId: string, productId: string, purchaseDate: number } + * } catch (error) { + * if (error.code === ErrorCode.UserCancelled) { + * console.log('User cancelled purchase'); + * } else { + * console.error('Purchase failed:', error.message); + * } + * } + * ``` + */ +export const requestPurchaseWithAdvancedCommerce = async ( + productId: string, + advancedCommerceData: string, + andDangerouslyFinishTransactionAutomatically: boolean = true, +): Promise => { + if (Platform.OS !== 'ios') { + throw new Error( + 'requestPurchaseWithAdvancedCommerce is only available on iOS', + ); + } + + try { + const result = await IAP.instance.requestPurchaseWithAdvancedCommerceIOS( + productId, + advancedCommerceData, + andDangerouslyFinishTransactionAutomatically, + ); + return { + success: result.success, + transactionId: result.transactionId, + productId: result.productId, + purchaseDate: result.purchaseDate, + }; + } catch (error) { + RnIapConsole.error('[requestPurchaseWithAdvancedCommerce] Failed:', error); + const parsedError = parseErrorStringToJsonObj(error); + throw createPurchaseError({ + code: parsedError.code, + message: parsedError.message, + responseCode: parsedError.responseCode, + debugMessage: parsedError.debugMessage, + productId, + }); + } +}; + /** * Get subscription status for a product (iOS only) * @param sku - The product SKU diff --git a/src/specs/RnIap.nitro.ts b/src/specs/RnIap.nitro.ts index ebc710f25..ea5f7a747 100644 --- a/src/specs/RnIap.nitro.ts +++ b/src/specs/RnIap.nitro.ts @@ -226,6 +226,21 @@ export interface NitroPurchaseResult { purchaseToken?: string; } +/** + * Result of an advanced commerce purchase (iOS only) + * Returned by requestPurchaseWithAdvancedCommerceIOS when a purchase completes successfully. + */ +export interface NitroAdvancedCommercePurchaseResult { + /** Whether the purchase was successful */ + success: boolean; + /** Transaction identifier from StoreKit */ + transactionId: string; + /** Product identifier that was purchased */ + productId: string; + /** Purchase timestamp in milliseconds since epoch */ + purchaseDate: number; +} + export interface NitroReceiptValidationResultIOS { isValid: boolean; receiptData: string; @@ -692,6 +707,38 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { */ beginRefundRequestIOS(sku: string): Promise; + /** + * Request a purchase with advanced commerce data (iOS 15+ only) + * + * Uses StoreKit 2's Product.PurchaseOption.custom API to pass custom advanced commerce data + * (e.g., campaign tokens, affiliate IDs) during the purchase flow. Unlike the standard + * requestPurchase() method, this returns the purchase result directly via Promise rather + * than using event listeners. + * + * The advanced commerce data is formatted as JSON and passed to StoreKit: + * ```json + * { + * "signatureInfo": { + * "token": "" + * } + * } + * ``` + * + * @param productId - Product identifier to purchase + * @param advancedCommerceData - Advanced commerce token/data to pass to StoreKit + * @param andDangerouslyFinishTransactionAutomatically - Whether to automatically finish the transaction. Defaults to `true`. + * Set to `false` if you need to verify the transaction on your server before finishing. + * @returns Promise resolving to purchase result with transaction details + * @throws {PurchaseError} If purchase fails, product not found, user cancels, or iOS version is insufficient + * @platform iOS + * @requires iOS 15.0+ (StoreKit 2) + */ + requestPurchaseWithAdvancedCommerceIOS( + productId: string, + advancedCommerceData: string, + andDangerouslyFinishTransactionAutomatically?: boolean, + ): Promise; + /** * Get subscription status for a product (iOS only) * @param sku - The product SKU