diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml new file mode 100644 index 000000000..273600586 --- /dev/null +++ b/.github/workflows/publish-package.yml @@ -0,0 +1,34 @@ +name: Publish main package + +on: + release: + types: [created] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + cache: 'yarn' + registry-url: 'https://registry.npmjs.org' + + - name: Remove example code + run: rm -rf IapExample + + - name: Install dependencies + run: yarn install --immutable + + - name: Run lint scripts + run: yarn lint:ci + + - name: Verify no files have changed after auto-fix + run: git diff -- ":(exclude)IapExample/*" --exit-code HEAD + + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.swiftlint.yml b/.swiftlint.yml index ad9f471c7..cb1d5094d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -6,6 +6,5 @@ opt_in_rules: - operator_usage_whitespace - redundant_type_annotation -indentation: 2 vertical_whitespace_closing_braces: true vertical_whitespace_opening_braces: true diff --git a/IapExample/ios/Podfile b/IapExample/ios/Podfile index 96cfc5e38..7728e563c 100644 --- a/IapExample/ios/Podfile +++ b/IapExample/ios/Podfile @@ -1,7 +1,7 @@ require_relative '../node_modules/react-native/scripts/react_native_pods' require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' -platform :ios, '12.4' +platform :ios, '15.0' install! 'cocoapods', :deterministic_uuids => false target 'IapExample' do diff --git a/IapExample/ios/Podfile.lock b/IapExample/ios/Podfile.lock index 9bbd26596..f4f43016b 100644 --- a/IapExample/ios/Podfile.lock +++ b/IapExample/ios/Podfile.lock @@ -360,7 +360,7 @@ PODS: - React-Core - RNGestureHandler (2.5.0): - React-Core - - RNIap (8.6.5): + - RNIap (9.0.0): - React-Core - RNScreens (3.15.0): - React-Core @@ -575,12 +575,12 @@ SPEC CHECKSUMS: ReactCommon: e30ec17dfb1d4c4f3419eac254350d6abca6d5a2 RNCMaskedView: cb9670ea9239998340eaab21df13fa12a1f9de15 RNGestureHandler: bad495418bcbd3ab47017a38d93d290ebd406f50 - RNIap: e9f648d00e693913f80bbe5b661a5257fb72fe93 + RNIap: 95084640290f4d68559dc37f1c8424b3f7dbf5d8 RNScreens: 4a1af06327774490d97342c00aee0c2bafb497b7 SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608 Yoga: 7ab6e3ee4ce47d7b789d1cb520163833e515f452 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: cfed50b11ea421296640e72457dcf62114e957f6 +PODFILE CHECKSUM: 47f330ba4aa0808e88cd2a085debf346af3dc659 COCOAPODS: 1.11.3 diff --git a/RNIap.podspec b/RNIap.podspec index 3e24142b2..e2a09726f 100644 --- a/RNIap.podspec +++ b/RNIap.podspec @@ -12,7 +12,7 @@ Pod::Spec.new do |s| s.license = package["license"] s.authors = package["author"] - s.platforms = { :ios => "10.0" , :tvos => "10.0"} + s.platforms = { :ios => "15.0" , :tvos => "15.0"} s.source = { :git => "https://github.com/dooboolab/react-native-iap.git", :tag => "#{s.version}" } s.source_files = "ios/*.{h,m,mm,swift}" diff --git a/docs/docs/usage_instructions/receipt_validation.md b/docs/docs/usage_instructions/receipt_validation.md index 5110f7bf5..f2c218c4b 100644 --- a/docs/docs/usage_instructions/receipt_validation.md +++ b/docs/docs/usage_instructions/receipt_validation.md @@ -57,28 +57,6 @@ flow) or unstable internet connections. For these cases we have a convenience method `getReceiptIOS()` which gets the latest receipt for the app at any given time. The response is base64 encoded. -### iOS Purchasing process right way. - -Issue regarding `valid products` - -- In iOS, generally you are fetching valid products at App launching process. - - If you fetch again, or fetch valid subscription, the products are added to - the array object in iOS side (Objective-C `NSMutableArray`). - - This makes unexpected behavior when you fetch with a part of product lists. - - For example, if you have products of `[A, B, C]`, and you call fetch function - with only `[A]`, this module returns `[A, B, C]`). - - This is weird, but it works. - -- But, weird result is weird, so we made a new method which remove all valid products. - - If you need to clear all products, subscriptions in that array, just call - `clearProductsIOS()`, and do the fetching job again, and you will receive what - you expected. - ### Example backend (Node.js) [Here](https://github.com/mifi/in-app-subscription-example) you can find an example backend for idempotent validating of receipts on both iOS/Android and storing and serving subscription state to the client. diff --git a/ios/IapSerializationUtils.swift b/ios/IapSerializationUtils.swift new file mode 100644 index 000000000..170b31f10 --- /dev/null +++ b/ios/IapSerializationUtils.swift @@ -0,0 +1,112 @@ +// +// IapSerializationUtils.swift +// RNIap +// +// Created by Aguilar Andres on 8/18/22. +// + +import Foundation +import StoreKit + +func serialize(_ p: Product) -> [String: Any?] { + return ["displayName": p.displayName, + "description": p.description, + "id": p.id, + "displayPrice": p.displayPrice, + "price": p.price, + "isFamilyShareable": p.isFamilyShareable, + "subscription": p.subscription?.subscriptionGroupID, + "jsonRepresentation": p.jsonRepresentation, + "debugDescription": p.debugDescription, + "subscription": serialize(p.subscription), + "type": serialize(p.type) + ] +} + +func serialize(_ e: Error?) -> [String: Any?]? { + guard let e = e else {return nil} + return ["localizedDescription": e.localizedDescription] +} + +func serialize(_ si: Product.SubscriptionInfo?) -> [String: Any?]? { + guard let si = si else {return nil} + return [ + "subscriptionGroupID": si.subscriptionGroupID, + "promotionalOffers": si.promotionalOffers.map {(offer: Product.SubscriptionOffer) in serialize(offer)}, + "introductoryOffer": serialize(si.introductoryOffer), + "subscriptionPeriod": si.subscriptionPeriod + ] +} + +func serialize(_ so: Product.SubscriptionOffer?) -> [String: Any?]? { + guard let so = so else {return nil} + return [ + "id": so.id, + "price": so.price, + "displayPrice": so.displayPrice, + "type": so.type, + "paymentMode": so.paymentMode, + "period": so.period, + "periodCount": so.periodCount + ] +} + +// Transaction +func serialize(_ t: Transaction) -> [String: Any?] { + return ["id": t.id, + "appBundleID": t.appBundleID, + "offerID": t.offerID, + "subscriptionGroupID": t.subscriptionGroupID, + "appAccountToken": t.appAccountToken, + "debugDescription": t.debugDescription, + "deviceVerification": t.deviceVerification, + "deviceVerificationNonce": t.deviceVerificationNonce, + "expirationDate": t.expirationDate, + "isUpgraded": t.isUpgraded, + "jsonRepresentation": t.jsonRepresentation, + "offerType": serialize(t.offerType), + "expirationDate": t.expirationDate, + "originalID": t.originalID, + "originalPurchaseDate": t.originalPurchaseDate, + "ownershipType": serialize(t.ownershipType), + "productType": serialize(t.productType), + "productID": t.productID, + "purchasedQuantity": t.purchasedQuantity, + "revocationDate": t.revocationDate, + "revocationReason": t.revocationReason, + "purchaseDate": t.purchaseDate, + "signedDate": t.signedDate, + "webOrderLineItemID": t.webOrderLineItemID + ] +} + +func serialize(_ ot: Transaction.OfferType?) -> String? { + guard let ot = ot else {return nil} + switch ot { + case .promotional: return "promotional" + case .introductory: return "introductory" + case .code: return "code" + default: + return nil + } +} +func serialize(_ ot: Transaction.OwnershipType?) -> String? { + guard let ot = ot else {return nil} + switch ot { + case .purchased: return "purchased" + case .familyShared: return "familyShared" + default: + return nil + } +} +func serialize(_ pt: Product.ProductType?) -> String? { + guard let pt = pt else {return nil} + switch pt { + case .autoRenewable: return "autoRenewable" + case .consumable: return "consumable" + case .nonConsumable: return "nonConsumable" + case .nonRenewable: return "nonRenewable" + default: + return nil + } +} diff --git a/ios/IapTypes.swift b/ios/IapTypes.swift new file mode 100644 index 000000000..93e6e752a --- /dev/null +++ b/ios/IapTypes.swift @@ -0,0 +1,39 @@ +// +// IapTypes.swift +// RNIap +// +// Created by Aguilar Andres on 8/18/22. +// + +import Foundation +import StoreKit + +typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) + +struct ProductOrError { + let product: Product? + let error: Error? +} + +public enum StoreError: Error { + case failedVerification +} + +enum IapErrors: String, CaseIterable { + case E_UNKNOWN = "E_UNKNOWN" + case E_SERVICE_ERROR = "E_SERVICE_ERROR" + case E_USER_CANCELLED = "E_USER_CANCELLED" + case E_USER_ERROR = "E_USER_ERROR" + case E_ITEM_UNAVAILABLE = "E_ITEM_UNAVAILABLE" + case E_REMOTE_ERROR = "E_REMOTE_ERROR" + case E_NETWORK_ERROR = "E_NETWORK_ERROR" + case E_RECEIPT_FAILED = "E_RECEIPT_FAILED" + case E_RECEIPT_FINISHED_FAILED = "E_RECEIPT_FINISHED_FAILED" + case E_DEVELOPER_ERROR = "E_DEVELOPER_ERROR" + case E_PURCHASE_ERROR = "E_PURCHASE_ERROR" + case E_SYNC_ERROR = "E_SYNC_ERROR" + case E_DEFERRED_PAYMENT = "E_DEFERRED_PAYMENT" + func asInt() -> Int { + return IapErrors.allCases.firstIndex(of: self)! + } +} diff --git a/ios/IapUtils.swift b/ios/IapUtils.swift new file mode 100644 index 000000000..e5fbbdab8 --- /dev/null +++ b/ios/IapUtils.swift @@ -0,0 +1,30 @@ +// +// IapUtils.swift +// RNIap +// +// Created by Aguilar Andres on 8/15/22. +// + +import Foundation +import StoreKit + +public func debugMessage(_ object: Any...) { + #if DEBUG + for item in object { + print("[react-native-iap] \(item)") + } + #endif +} + +func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe + } +} diff --git a/ios/RNIapIos.m b/ios/RNIapIos.m index f7882219e..f50038e32 100644 --- a/ios/RNIapIos.m +++ b/ios/RNIapIos.m @@ -12,58 +12,49 @@ @interface RCT_EXTERN_MODULE (RNIapIos, NSObject) (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getItems: +RCT_EXTERN_METHOD(products: (NSArray*)skus resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getAvailableItems: +RCT_EXTERN_METHOD(currentEntitlements: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyProduct: +RCT_EXTERN_METHOD(purchase: (NSString*)sku andDangerouslyFinishTransactionAutomatically:(BOOL)andDangerouslyFinishTransactionAutomatically - applicationUsername:(NSString*)applicationUsername - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyProductWithOffer: - (NSString*)sku - forUser:(NSString*)usernameHash - withOffer:(NSDictionary*)discountOffer - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyProductWithQuantityIOS: - (NSString*)sku + appAccountToken:(NSString*)appAccountToken quantity:(NSInteger)quantity + withOffer:(NSDictionary*)discountOffer resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(clearTransaction: - (RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(clearProducts: - (RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(promotedProduct: - (RCTPromiseResolveBlock)resolve +RCT_EXTERN_METHOD(isEligibleForIntroOffer: + (NSString*)groupID + resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(buyPromotedProduct:(RCTPromiseResolveBlock)resolve +RCT_EXTERN_METHOD(currentEntitlement: + (NSString*)sku + resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(requestReceipt: - (BOOL)refresh +RCT_EXTERN_METHOD(latestTransaction: + (NSString*)sku resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) + RCT_EXTERN_METHOD(finishTransaction: (NSString*)transactionIdentifier resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(getPendingTransactions: +RCT_EXTERN_METHOD(pendingTransactions: + (RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(sync: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/ios/RNIapIos.swift b/ios/RNIapIos.swift index ed9c075f3..49461121a 100644 --- a/ios/RNIapIos.swift +++ b/ios/RNIapIos.swift @@ -1,970 +1,398 @@ +import Foundation import React import StoreKit -typealias RNIapIosPromise = (RCTPromiseResolveBlock, RCTPromiseRejectBlock) - -public func debugMessage(_ object: Any...) { - #if DEBUG - for item in object { - print("[react-native-iap] \(item)") - } - #endif -} - -// Based on https://stackoverflow.com/a/40135192/570612 -extension Date { - var millisecondsSince1970: Int64 { - return Int64((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - var millisecondsSince1970String: String { - return String((self.timeIntervalSince1970 * 1000.0).rounded()) - } - - init(milliseconds: Int64) { - self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) - } -} - extension SKProductsRequest { - var key: String { - return String(self.hashValue) - } + var key: String { + return String(self.hashValue) + } } @objc(RNIapIos) -class RNIapIos: RCTEventEmitter, SKRequestDelegate, SKPaymentTransactionObserver, SKProductsRequestDelegate { - private var promisesByKey: [String: [RNIapIosPromise]] - private var myQueue: DispatchQueue - private var hasListeners = false - private var pendingTransactionWithAutoFinish = false - private var receiptBlock: ((Data?, Error?) -> Void)? // Block to handle request the receipt async from delegate - private var validProducts: [SKProduct] - private var promotedPayment: SKPayment? - private var promotedProduct: SKProduct? - private var productsRequest: SKProductsRequest? - private var countPendingTransaction: Int = 0 - private var hasTransactionObserver = false - - override init() { - promisesByKey = [String: [RNIapIosPromise]]() - pendingTransactionWithAutoFinish = false - myQueue = DispatchQueue(label: "reject") - validProducts = [SKProduct]() - super.init() - addTransactionObserver() - } - - deinit { - removeTransactionObserver() - } - - override class func requiresMainQueueSetup() -> Bool { - return true - } - - func addTransactionObserver() { - if !hasTransactionObserver { - hasTransactionObserver = true - SKPaymentQueue.default().add(self) - } - } - - func removeTransactionObserver() { - if hasTransactionObserver { - hasTransactionObserver = false - SKPaymentQueue.default().remove(self) - } - } - - func flushUnheardEvents() { - paymentQueue(SKPaymentQueue.default(), updatedTransactions: SKPaymentQueue.default().transactions) - } - - override func startObserving() { - hasListeners = true - flushUnheardEvents() - } +class RNIapIos: RCTEventEmitter, SKRequestDelegate { + private var hasListeners = false + private var products: [String: Product] + private var transactions: [String: Transaction] + private var updateListenerTask: Task? - override func stopObserving() { - hasListeners = false - } - - override func addListener(_ eventName: String?) { - super.addListener(eventName) - - if (eventName == "iap-promoted-product") && promotedPayment != nil { - sendEvent(withName: "iap-promoted-product", body: promotedPayment?.productIdentifier) + override init() { + products = [String: Product]() + transactions = [String: Transaction]() + super.init() + addTransactionObserver() } - } - - func addPromise(forKey key: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { - var promises: [RNIapIosPromise]? = promisesByKey[key] - - if promises == nil { - promises = [] - } - - promises?.append((resolve, reject)) - promisesByKey[key] = promises - } - - func resolvePromises(forKey key: String?, value: Any?) { - let promises: [RNIapIosPromise]? = promisesByKey[key ?? ""] - if let promises = promises { - for tuple in promises { - let resolveBlck = tuple.0 - resolveBlck(value) - } - promisesByKey[key ?? ""] = nil + deinit { + removeTransactionObserver() } - } - func rejectPromises(forKey key: String, code: String?, message: String?, error: Error?) { - let promises = promisesByKey[key] - - if let promises = promises { - for tuple in promises { - let reject = tuple.1 - reject(code, message, error) - } - promisesByKey[key] = nil - } - } - - func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool { - promotedProduct = product - promotedPayment = payment - - if hasListeners { - sendEvent(withName: "iap-promoted-product", body: product.productIdentifier) - } - return false - } - - override func supportedEvents() -> [String]? { - return ["iap-promoted-product", "purchase-updated", "purchase-error"] - } - - @objc public func initConnection( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - addTransactionObserver() - let canMakePayments = SKPaymentQueue.canMakePayments() - resolve(NSNumber(value: canMakePayments)) - } - @objc public func endConnection( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - removeTransactionObserver() - resolve(nil) - } - @objc public func getItems( - _ skus: [String], - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - let productIdentifiers = Set(skus) - if let productIdentifiers = productIdentifiers as? Set { - productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers) - if let productsRequest = productsRequest { - productsRequest.delegate = self - let key: String = productsRequest.key - addPromise(forKey: key, resolve: resolve, reject: reject) - productsRequest.start() - } - } - } - @objc public func getAvailableItems( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - addPromise(forKey: "availableItems", resolve: resolve, reject: reject) - SKPaymentQueue.default().restoreCompletedTransactions() - } - - @objc public func buyProduct( - _ sku: String, - andDangerouslyFinishTransactionAutomatically: Bool, - applicationUsername: String?, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - pendingTransactionWithAutoFinish = andDangerouslyFinishTransactionAutomatically - var product: SKProduct? - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for p in validProducts { - if sku == p.productIdentifier { - product = p - break - } - } - } - if let prod = product { - addPromise(forKey: prod.productIdentifier, resolve: resolve, reject: reject) - - let payment = SKMutablePayment(product: prod) - - if let applicationUsername = applicationUsername { - payment.applicationUsername = applicationUsername - } - SKPaymentQueue.default().add(payment) - } else { - if hasListeners { - let err = [ - "debugMessage": "Invalid product ID.", - "code": "E_DEVELOPER_ERROR", - "message": "Invalid product ID.", - "productId": sku - ] - - sendEvent(withName: "purchase-error", body: err) - } - - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) + override class func requiresMainQueueSetup() -> Bool { + return true } - } - - @objc public func buyProductWithOffer( - _ sku: String, - forUser usernameHash: String, - withOffer discountOffer: [String: String], - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - var product: SKProduct? - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for p in validProducts { - if sku == p.productIdentifier { - product = p - break + func addTransactionObserver() { + if updateListenerTask == nil { + updateListenerTask = listenForTransactions() } - } } - if let prod = product { - addPromise(forKey: prod.productIdentifier, resolve: resolve, reject: reject) - - let payment = SKMutablePayment(product: prod) - - if #available(iOS 12.2, tvOS 12.2, *) { - let discount = SKPaymentDiscount( - identifier: discountOffer["identifier"]!, - keyIdentifier: discountOffer["keyIdentifier"]!, - nonce: UUID(uuidString: discountOffer["nonce"]!)!, - signature: discountOffer["signature"]!, - timestamp: NSNumber(value: Int(discountOffer["timestamp"]!)!)) - payment.paymentDiscount = discount - } - payment.applicationUsername = usernameHash - SKPaymentQueue.default().add(payment) - } else { - if hasListeners { - let err = [ - "debugMessage": "Invalid product ID.", - "message": "Invalid product ID.", - "code": "E_DEVELOPER_ERROR", - "productId": sku - ] - sendEvent(withName: "purchase-error", body: err) - } - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) - } - } - - @objc public func buyProductWithQuantityIOS( - _ sku: String, - quantity: Int, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - debugMessage("buyProductWithQuantityIOS") - var product: SKProduct? - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for p in validProducts { - if sku == p.productIdentifier { - product = p - break + func removeTransactionObserver() { + updateListenerTask?.cancel() + updateListenerTask = nil + } + + func addTransaction(_ transaction: Transaction) { + let transactionId = String(transaction.id) + self.transactions[transactionId] = transaction + } + + func listenForTransactions() -> Task { + return Task.detached { + // Iterate through any transactions that don't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + let transaction = try checkVerified(result) + self.addTransaction(transaction) + // Deliver products to the user. + // await self.updateCustomerProductStatus() + + if self.hasListeners { + self.sendEvent(withName: "transaction-updated", body: ["transaction": serialize(transaction)]) + } + // Always finish a transaction. + // await transaction.finish() //TODO: Document + } catch { + // StoreKit has a transaction that fails verification. Don't deliver content to the user. + debugMessage("Transaction failed verification") + if self.hasListeners { + let err = [ + "responseCode": "-1", + "debugMessage": error.localizedDescription, + "code": "E_RECEIPT_FINISHED_FAILED", + "message": error.localizedDescription + ] + + self.sendEvent(withName: "transaction-updated", body: ["error": err]) + } + } + } } - } - } - if let prod = product { - let payment = SKMutablePayment(product: prod) - payment.quantity = quantity - SKPaymentQueue.default().add(payment) - } else { - if hasListeners { - let err = [ - "debugMessage": "Invalid product ID.", - "message": "Invalid product ID.", - "code": "E_DEVELOPER_ERROR", - "productId": sku - ] - sendEvent(withName: "purchase-error", body: err) - } - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } - } - - @objc public func clearTransaction( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - let pendingTrans = SKPaymentQueue.default().transactions - countPendingTransaction = pendingTrans.count - - debugMessage("clear remaining Transactions (\(countPendingTransaction)). Call this before make a new transaction") - - if countPendingTransaction > 0 { - addPromise(forKey: "cleaningTransactions", resolve: resolve, reject: reject) - for transaction in pendingTrans { - SKPaymentQueue.default().finishTransaction(transaction) - } - } else { - resolve(nil) - } - } - - @objc public func clearProducts( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - debugMessage("clear valid products") - - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - validProducts.removeAll() + override func startObserving() { + hasListeners = true } - resolve(nil) - } - - @objc public func promotedProduct( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - debugMessage("get promoted product") - resolve((promotedProduct != nil) ? getProductObject(promotedProduct!) : nil) - } - - @objc public func buyPromotedProduct( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - if let promoPayment = promotedPayment { - debugMessage("buy promoted product") - SKPaymentQueue.default().add(promoPayment) - } else { - reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) + override func stopObserving() { + hasListeners = false } - } - - @objc public func requestReceipt( - _ refresh: Bool, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - requestReceiptData(withBlock: refresh) { [self] receiptData, error in - if error == nil { - resolve(receiptData?.base64EncodedString(options: [])) - } else { - reject(standardErrorCode(9), "Invalid receipt", nil) - } + override func addListener(_ eventName: String?) { + super.addListener(eventName) } - } - - @objc public func finishTransaction( - _ transactionIdentifier: String, - resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - finishTransaction(withIdentifier: transactionIdentifier) - resolve(nil) - } - - @objc public func getPendingTransactions ( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - requestReceiptData(withBlock: false) { receiptData, _ in - var output: [AnyHashable] = [] - - if let receipt = receiptData { - let transactions = SKPaymentQueue.default().transactions - - for item in transactions { - let timestamp = item.transactionDate?.millisecondsSince1970 == nil ? nil : String(item.transactionDate!.millisecondsSince1970) - let purchase = [ - "transactionDate": timestamp, - "transactionId": item.transactionIdentifier, - "productId": item.payment.productIdentifier, - "quantity": "\(item.payment.quantity)", - "transactionReceipt": receipt.base64EncodedString(options: []) - ] - - output.append(purchase) - } - } - - resolve(output) - } - } - - @objc public func presentCodeRedemptionSheet( - _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, - reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } - ) { - #if !os(tvOS) - if #available(iOS 14.0, tvOS 14.0, *) { - SKPaymentQueue.default().presentCodeRedemptionSheet() - resolve(nil) - } else { - reject(standardErrorCode(2), "This method only available above iOS 14", nil) - } - #else - reject(standardErrorCode(2), "This method is not available on tvOS", nil) - #endif - } - - // StoreKitDelegate - func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { - for prod in response.products { - add(prod) - } - - var items: [[String: Any?]] = [[:]] - let lockQueue = DispatchQueue(label: "validProducts") - lockQueue.sync { - for product in validProducts { - items.append(getProductObject(product)) - } + override func supportedEvents() -> [String]? { + return [ "transaction-updated"] } - resolvePromises(forKey: request.key, value: items) - } - - // Add to valid products from Apple server response. Allowing getProducts, getSubscriptions call several times. - // Doesn't allow duplication. Replace new product. - func add(_ aProd: SKProduct) { - let lockQueue = DispatchQueue(label: "validProducts") - - lockQueue.sync { - debugMessage("Add new object: \(aProd.productIdentifier)") - var delTar = -1 - - for k in 0..= 0 { - validProducts.remove(at: delTar) - } - - validProducts.append(aProd) + @objc public func initConnection( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + addTransactionObserver() + resolve(AppStore.canMakePayments) } - } - - func request(_ request: SKRequest, didFailWithError error: Error) { - let nsError = error as NSError - - if request is SKReceiptRefreshRequest { - if let unwrappedReceiptBlock = receiptBlock { - let standardError = NSError(domain: nsError.domain, code: 9, userInfo: nsError.userInfo) - unwrappedReceiptBlock(nil, standardError) - receiptBlock = nil - return - } else { - if let key: String = productsRequest?.key { - myQueue.sync(execute: { [self] in - rejectPromises( - forKey: key, - code: standardErrorCode(nsError.code), - message: error.localizedDescription, - error: error)} - ) - } - } + @objc public func endConnection( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + updateListenerTask?.cancel() + updateListenerTask = nil + resolve(nil) } - } - - func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { - for transaction in transactions { - switch transaction.transactionState { - case .purchasing: - debugMessage("Purchase Started") - break - - case .purchased: - debugMessage("Purchase Successful") - purchaseProcess(transaction) - break - - case .restored: - debugMessage("Restored") - SKPaymentQueue.default().finishTransaction(transaction) - break - - case .deferred: - debugMessage("Deferred (awaiting approval via parental controls, etc.)") - - myQueue.sync(execute: { [self] in - if hasListeners { - let err = [ - "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", - "code": "E_DEFERRED_PAYMENT", - "message": "The payment was deferred (awaiting approval via parental controls for instance)", - "productId": transaction.payment.productIdentifier, - "quantity": "\(transaction.payment.quantity)" - ] - - sendEvent(withName: "purchase-error", body: err) - } - - rejectPromises( - forKey: transaction.payment.productIdentifier, - code: "E_DEFERRED_PAYMENT", - message: "The payment was deferred (awaiting approval via parental controls for instance)", - error: nil) - }) - - case .failed: - debugMessage("Purchase Failed") - - SKPaymentQueue.default().finishTransaction(transaction) - - myQueue.sync(execute: { [self] in - let nsError = transaction.error as NSError? - - if hasListeners { - let code = nsError?.code - let responseCode = String(code ?? 0) - let err = [ - "responseCode": responseCode, - "debugMessage": transaction.error?.localizedDescription, - "code": standardErrorCode(code), - "message": transaction.error?.localizedDescription, - "productId": transaction.payment.productIdentifier - ] - - sendEvent(withName: "purchase-error", body: err) - } - - rejectPromises( - forKey: transaction.payment.productIdentifier, - code: standardErrorCode(nsError?.code), - message: nsError?.localizedDescription, - error: nsError) - }) - - break - } - } - } - - func finishTransaction(withIdentifier transactionIdentifier: String?) { - let queue = SKPaymentQueue.default() - - for transaction in queue.transactions { - if transaction.transactionIdentifier == transactionIdentifier { - SKPaymentQueue.default().finishTransaction(transaction) - } - } - } - - func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { - debugMessage("PaymentQueueRestoreCompletedTransactionsFinished") - var items = [[String: Any?]]() - - for transaction in queue.transactions { - if transaction.transactionState == .restored || transaction.transactionState == .purchased { - getPurchaseData(transaction) { restored in - if let restored = restored { - items.append(restored) - } - SKPaymentQueue.default().finishTransaction(transaction) + @objc public func products( // TODO: renamed from getItems + _ skus: [String], + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + do { + let products: [[String: Any?]] = try await Product.products(for: skus).map({ (prod: Product) -> [String: Any?]? in + return serialize(prod) + }).compactMap({$0}) + resolve(products) + } catch { + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching items", error) } - } } - resolvePromises(forKey: "availableItems", value: items) - } - - func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { - myQueue.sync(execute: { [self] in - rejectPromises( - forKey: "availableItems", - code: standardErrorCode((error as NSError).code), - message: error.localizedDescription, - error: error) - }) - - debugMessage("restoreCompletedTransactionsFailedWithError") - } - - func purchaseProcess(_ transaction: SKPaymentTransaction) { - if pendingTransactionWithAutoFinish { - SKPaymentQueue.default().finishTransaction(transaction) - pendingTransactionWithAutoFinish = false - } - - getPurchaseData(transaction) { [self] purchase in - resolvePromises(forKey: transaction.payment.productIdentifier, value: purchase) - - // additionally send event - if hasListeners { - sendEvent(withName: "purchase-updated", body: purchase) - } - } - } - - func standardErrorCode(_ code: Int?) -> String? { - let descriptions = [ - "E_UNKNOWN", - "E_SERVICE_ERROR", - "E_USER_CANCELLED", - "E_USER_ERROR", - "E_USER_ERROR", - "E_ITEM_UNAVAILABLE", - "E_REMOTE_ERROR", - "E_NETWORK_ERROR", - "E_SERVICE_ERROR", - "E_RECEIPT_FAILED", - "E_RECEIPT_FINISHED_FAILED" - ] - - guard let code = code else { - return descriptions[0] - } - - if code > descriptions.count - 1 || code < 0 { // Fix crash app without internet connection - return descriptions[0] - } - - return descriptions[code] - } - - func getProductObject(_ product: SKProduct) -> [String: Any?] { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.locale = product.priceLocale - - let localizedPrice = formatter.string(from: product.price) - var introductoryPrice = localizedPrice - var introductoryPriceAsAmountIOS = "\(product.price)" - - var introductoryPricePaymentMode = "" - var introductoryPriceNumberOfPeriods = "" - - var introductoryPriceSubscriptionPeriod = "" - - var currencyCode: String? = "" - var countryCode: String? = "" - var periodNumberIOS = "0" - var periodUnitIOS = "" - var itemType = "iap" - - if #available(iOS 11.2, tvOS 11.2, *) { - let numOfUnits = UInt(product.subscriptionPeriod?.numberOfUnits ?? 0) - let unit = product.subscriptionPeriod?.unit - - if unit == .year { - periodUnitIOS = "YEAR" - } else if unit == .month { - periodUnitIOS = "MONTH" - } else if unit == .week { - periodUnitIOS = "WEEK" - } else if unit == .day { - periodUnitIOS = "DAY" - } - - periodNumberIOS = String(format: "%lu", numOfUnits) - if numOfUnits != 0 { - itemType = "subs" - } - - // subscriptionPeriod = product.subscriptionPeriod ? [product.subscriptionPeriod stringValue] : @""; - // introductoryPrice = product.introductoryPrice != nil ? [NSString stringWithFormat:@"%@", product.introductoryPrice] : @""; - if product.introductoryPrice != nil { - formatter.locale = product.introductoryPrice?.priceLocale - - if let price = product.introductoryPrice?.price { - introductoryPrice = formatter.string(from: price) - } - - introductoryPriceAsAmountIOS = product.introductoryPrice?.price.stringValue ?? "" - - switch product.introductoryPrice?.paymentMode { - case .freeTrial: - introductoryPricePaymentMode = "FREETRIAL" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - case .payAsYouGo: - introductoryPricePaymentMode = "PAYASYOUGO" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.numberOfPeriods ?? 0).stringValue - - case .payUpFront: - introductoryPricePaymentMode = "PAYUPFRONT" - introductoryPriceNumberOfPeriods = NSNumber(value: product.introductoryPrice?.subscriptionPeriod.numberOfUnits ?? 0).stringValue - - default: - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "0" + @objc public func currentEntitlements( // TODO: renamed from getAvailableItems + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + var purchasedItems: [ProductOrError] = [] + // Iterate through all of the user's purchased products. + for await result in Transaction.currentEntitlements { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + // Check the `productType` of the transaction and get the corresponding product from the store. + switch transaction.productType { + case .nonConsumable: + if let product = products[transaction.productID] { + purchasedItems.append(ProductOrError(product: product, error: nil)) + } + + case .nonRenewable: + if let nonRenewable = products[transaction.productID] { + // Non-renewing subscriptions have no inherent expiration date, so they're always + // contained in `Transaction.currentEntitlements` after the user purchases them. + // This app defines this non-renewing subscription's expiration date to be one year after purchase. + // If the current date is within one year of the `purchaseDate`, the user is still entitled to this + // product. + let currentDate = Date() + let expirationDate = Calendar(identifier: .gregorian).date(byAdding: DateComponents(year: 1), + to: transaction.purchaseDate)! + + if currentDate < expirationDate { + purchasedItems.append(ProductOrError(product: nonRenewable, error: nil)) + } + } + + case .autoRenewable: + if let subscription = products[transaction.productID] { + purchasedItems.append(ProductOrError(product: subscription, error: nil)) + } + + default: + break + } + } catch StoreError.failedVerification { + purchasedItems.append(ProductOrError(product: nil, error: StoreError.failedVerification)) + } catch { + debugMessage(error) + purchasedItems.append(ProductOrError(product: nil, error: error)) + } } - if product.introductoryPrice?.subscriptionPeriod.unit == .day { - introductoryPriceSubscriptionPeriod = "DAY" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .week { - introductoryPriceSubscriptionPeriod = "WEEK" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .month { - introductoryPriceSubscriptionPeriod = "MONTH" - } else if product.introductoryPrice?.subscriptionPeriod.unit == .year { - introductoryPriceSubscriptionPeriod = "YEAR" + // Check the `subscriptionGroupStatus` to learn the auto-renewable subscription state to determine whether the customer + // is new (never subscribed), active, or inactive (expired subscription). This app has only one subscription + // group, so products in the subscriptions array all belong to the same group. The statuses that + // `product.subscription.status` returns apply to the entire subscription group. + // subscriptionGroupStatus = try? await subscriptions.first?.subscription?.status.first?.state + resolve(purchasedItems.map({(p: ProductOrError) in ["product": p.product.flatMap { serialize($0)}, "error": serialize(p.error)]})) + } + + @objc public func purchase( // TODO: renamed from buyProduct + _ sku: String, + andDangerouslyFinishTransactionAutomatically: Bool, + appAccountToken: String?, + quantity: Int, + withOffer discountOffer: [String: String], + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + let product: Product? = products[sku] + + if let product = product { + do { + var options: Set = [] + if quantity > -1 { + options.insert(.quantity(quantity)) + } + + let offerID = discountOffer["offerID"] // TODO: Adjust JS to match these new names + let keyID = discountOffer["keyID"] + let nonce = discountOffer["nonce"] + let signature = discountOffer["signature"] + let timestamp = discountOffer["timestamp"] + + if let offerID = offerID, let keyID = keyID, let nonce = nonce, let nonce = UUID(uuidString: nonce), let signature = signature, let signature = signature.data(using: .utf8), let timestamp = timestamp, let timestamp = Int(timestamp) { + options.insert(.promotionalOffer(offerID: offerID, keyID: keyID, nonce: nonce, signature: signature, timestamp: timestamp )) + } + if let appAccountToken = appAccountToken, let appAccountToken = UUID(uuidString: appAccountToken) { + options.insert(.appAccountToken(appAccountToken)) + } + debugMessage("Purchase Started") + + let result = try await product.purchase(options: options) + switch result { + case .success(let verification): + debugMessage("Purchase Successful") + + // Check whether the transaction is verified. If it isn't, + // this function rethrows the verification error. + let transaction = try checkVerified(verification) + + // The transaction is verified. Deliver content to the user. + // Do on JS :await updateCustomerProductStatus() + + // Always finish a transaction. + if andDangerouslyFinishTransactionAutomatically { + await transaction.finish() + resolve(nil) + } else { + self.addTransaction(transaction) + resolve(serialize(transaction)) + } + return + + case .userCancelled, .pending: + debugMessage("Deferred (awaiting approval via parental controls, etc.)") + + let err = [ + "debugMessage": "The payment was deferred (awaiting approval via parental controls for instance)", + "code": IapErrors.E_DEFERRED_PAYMENT.rawValue, + "message": "The payment was deferred (awaiting approval via parental controls for instance)", + "productId": sku, + "quantity": "\(quantity)" + ] + debugMessage(err) + + reject( + IapErrors.E_DEFERRED_PAYMENT.rawValue, + "The payment was deferred for \(sku) (awaiting approval via parental controls for instance)", + nil) + + return + + default: + reject(IapErrors.E_UNKNOWN.rawValue, "Unknown response from purchase", nil) + return + } + } catch { + debugMessage("Purchase Failed") + + let err = [ + "responseCode": IapErrors.E_PURCHASE_ERROR.rawValue, + "debugMessage": error.localizedDescription, + "message": error.localizedDescription, + "productId": sku + ] + print(err) + + reject( + IapErrors.E_UNKNOWN.rawValue, + "Purchased failed for sku:\(sku): \(error.localizedDescription)", + error) + } } else { - introductoryPriceSubscriptionPeriod = "" + reject("E_DEVELOPER_ERROR", "Invalid product ID.", nil) } - } else { - introductoryPrice = "" - introductoryPriceAsAmountIOS = "" - introductoryPricePaymentMode = "" - introductoryPriceNumberOfPeriods = "" - introductoryPriceSubscriptionPeriod = "" - } - } - - if #available(iOS 10.0, tvOS 10.0, *) { - currencyCode = product.priceLocale.currencyCode } - if #available(iOS 13.0, tvOS 13.0, *) { - countryCode = SKPaymentQueue.default().storefront?.countryCode - } else if #available(iOS 10.0, tvOS 10.0, *) { - countryCode = product.priceLocale.regionCode - } - - var discounts: [[String: String?]]? - - if #available(iOS 12.2, tvOS 12.2, *) { - discounts = getDiscountData(product) - } - - let obj: [String: Any?] = [ - "productId": product.productIdentifier, - "price": "\(product.price)", - "currency": currencyCode, - "countryCode": countryCode ?? "", - "type": itemType, - "title": product.localizedTitle != "" ? product.localizedTitle : "", - "description": product.localizedDescription != "" ? product.localizedDescription : "", - "localizedPrice": localizedPrice, - "subscriptionPeriodNumberIOS": periodNumberIOS, - "subscriptionPeriodUnitIOS": periodUnitIOS, - "introductoryPrice": introductoryPrice, - "introductoryPriceAsAmountIOS": introductoryPriceAsAmountIOS, - "introductoryPricePaymentModeIOS": introductoryPricePaymentMode, - "introductoryPriceNumberOfPeriodsIOS": introductoryPriceNumberOfPeriods, - "introductoryPriceSubscriptionPeriodIOS": introductoryPriceSubscriptionPeriod, - "discounts": discounts - ] - - return obj - } - - func getDiscountData(_ product: SKProduct) -> [[String: String?]]? { - if #available(iOS 12.2, tvOS 12.2, *) { - var mappedDiscounts: [[String: String?]] = [] - var localizedPrice: String? - var paymendMode: String? - var subscriptionPeriods: String? - var discountType: String? - - for discount in product.discounts { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - let priceLocale: Locale? = discount.priceLocale - if let pLocale = priceLocale { - formatter.locale = pLocale - } - localizedPrice = formatter.string(from: discount.price) - var numberOfPeriods: String? - - switch discount.paymentMode { - case .freeTrial: - paymendMode = "FREETRIAL" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - case .payAsYouGo: - paymendMode = "PAYASYOUGO" - numberOfPeriods = NSNumber(value: discount.numberOfPeriods).stringValue - break - - case .payUpFront: - paymendMode = "PAYUPFRONT" - numberOfPeriods = NSNumber(value: discount.subscriptionPeriod.numberOfUnits ).stringValue - break - - default: - paymendMode = "" - numberOfPeriods = "0" - break + @objc public func isEligibleForIntroOffer( // TODO: new method + _ groupID: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + let isEligibleForIntroOffer = await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID) + resolve(isEligibleForIntroOffer) + } + + @objc public func subscriptionStatus( // TODO: new method + _ sku: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + do { + let status = try await products[sku]?.subscription?.status + resolve(status) + } catch { + reject("", "", error) } - - switch discount.subscriptionPeriod.unit { - case .day: - subscriptionPeriods = "DAY" - - case .week: - subscriptionPeriods = "WEEK" - - case .month: - subscriptionPeriods = "MONTH" - - case .year: - subscriptionPeriods = "YEAR" - - default: - subscriptionPeriods = "" - } - - let discountIdentifier = discount.identifier - switch discount.type { - case SKProductDiscount.Type.introductory: - discountType = "INTRODUCTORY" - break - - case SKProductDiscount.Type.subscription: - discountType = "SUBSCRIPTION" - break - - default: - discountType = "" - break - } - - let discountObj = [ - "identifier": discountIdentifier, - "type": discountType, - "numberOfPeriods": numberOfPeriods, - "price": "\(discount.price)", - "localizedPrice": localizedPrice, - "paymentMode": paymendMode, - "subscriptionPeriod": subscriptionPeriods - ] - - mappedDiscounts.append(discountObj) - } - - return mappedDiscounts } - return nil - } - - func getPurchaseData(_ transaction: SKPaymentTransaction, withBlock block: @escaping (_ transactionDict: [String: Any]?) -> Void) { - requestReceiptData(withBlock: false) { receiptData, _ in - if receiptData == nil { - block(nil) - } else { - var purchase = [ - "transactionDate": transaction.transactionDate?.millisecondsSince1970String, - "transactionId": transaction.transactionIdentifier, - "productId": transaction.payment.productIdentifier, - "transactionReceipt": receiptData?.base64EncodedString(options: []) - ] - - // originalTransaction is available for restore purchase and purchase of cancelled/expired subscriptions - if let originalTransaction = transaction.original { - purchase["originalTransactionDateIOS"] = originalTransaction.transactionDate?.millisecondsSince1970String - purchase["originalTransactionIdentifierIOS"] = originalTransaction.transactionIdentifier + @objc public func currentEntitlement( // TODO: new method + _ sku: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + if let product = products[sku] { + if let result = await product.currentEntitlement { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + resolve(serialize(transaction)) + } catch StoreError.failedVerification { + reject(IapErrors.E_UNKNOWN.rawValue, "Failed to verify transaction for sku \(sku)", StoreError.failedVerification) + } catch { + debugMessage(error) + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching entitlement for sku \(sku)", error) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find entitlement for sku \(sku)", nil) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find product for sku \(sku)", nil) } - - block(purchase as [String: Any]) - } - } - } - - func requestReceiptData(withBlock forceRefresh: Bool, withBlock block: @escaping (_ data: Data?, _ error: Error?) -> Void) { - debugMessage("requestReceiptDataWithBlock with force refresh: \(forceRefresh ? "YES" : "NO")") - - if forceRefresh || isReceiptPresent() == false { - let refreshRequest = SKReceiptRefreshRequest() - refreshRequest.delegate = self - refreshRequest.start() - receiptBlock = block - } else { - receiptBlock = nil - block(receiptData(), nil) } - } - - func isReceiptPresent() -> Bool { - let receiptURL = Bundle.main.appStoreReceiptURL - var canReachError: Error? - do { - try _ = receiptURL?.checkResourceIsReachable() - } catch let error { - canReachError = error - } - - return canReachError == nil - } - - func receiptData() -> Data? { - let receiptURL = Bundle.main.appStoreReceiptURL - var receiptData: Data? - - if let receiptURL = receiptURL { - do { - try receiptData = Data(contentsOf: receiptURL) - } catch _ { - } + @objc public func latestTransaction( // TODO: new method + _ sku: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + if let product = products[sku] { + if let result = await product.latestTransaction { + do { + // Check whether the transaction is verified. If it isn’t, catch `failedVerification` error. + let transaction = try checkVerified(result) + resolve(serialize(transaction)) + } catch StoreError.failedVerification { + reject(IapErrors.E_UNKNOWN.rawValue, "Failed to verify transaction for sku \(sku)", StoreError.failedVerification) + } catch { + debugMessage(error) + reject(IapErrors.E_UNKNOWN.rawValue, "Error fetching latest transaction for sku \(sku)", error) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find latest transaction for sku \(sku)", nil) + } + } else { + reject(IapErrors.E_DEVELOPER_ERROR.rawValue, "Can't find product for sku \(sku)", nil) + } } - return receiptData - } - - func requestDidFinish(_ request: SKRequest) { - if request is SKReceiptRefreshRequest { - if isReceiptPresent() == true { - debugMessage("Receipt refreshed success") - - if let receiptBlock = receiptBlock { - receiptBlock(receiptData(), nil) + @objc public func finishTransaction( + _ transactionIdentifier: String, + resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) async { + await transactions[transactionIdentifier]?.finish() + transactions.removeValue(forKey: transactionIdentifier) + resolve(nil) + } + + @objc public func pendingTransactions ( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + resolve(transactions.values.map({(t: Transaction) in serialize(t)})) + } + + // TODO: New method + @objc public func sync( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in}, + reject: @escaping RCTPromiseRejectBlock = {_, _, _ in} + ) async { + do { + try await AppStore.sync() + } catch { + reject(IapErrors.E_SYNC_ERROR.rawValue, "Error synchronizing with the AppStore", error) } - } else if let receiptBlock = receiptBlock { - debugMessage("Finished but receipt refreshed failed") - - let error = NSError(domain: "Receipt request finished but it failed!", code: 10, userInfo: nil) - receiptBlock(nil, error) - } - - receiptBlock = nil } - } - - func paymentQueue(_ queue: SKPaymentQueue, removedTransactions transactions: [SKPaymentTransaction]) { - debugMessage("removedTransactions - countPendingTransactions \(countPendingTransaction)") - - if countPendingTransaction > 0 { - countPendingTransaction -= transactions.count - if countPendingTransaction <= 0 { - resolvePromises(forKey: "cleaningTransactions", value: nil) - countPendingTransaction = 0 - } + /** + Should remain the same according to: + https://stackoverflow.com/a/72789651/570612 + */ + @objc public func presentCodeRedemptionSheet( + _ resolve: @escaping RCTPromiseResolveBlock = { _ in }, + reject: @escaping RCTPromiseRejectBlock = { _, _, _ in } + ) { + #if !os(tvOS) + SKPaymentQueue.default().presentCodeRedemptionSheet() + resolve(nil) + #else + reject(standardErrorCode(2), "This method is not available on tvOS", nil) + #endif } - } } diff --git a/src/iap.ts b/src/iap.ts index 3076e5b79..ab6d3a77a 100644 --- a/src/iap.ts +++ b/src/iap.ts @@ -13,11 +13,11 @@ import { import type { Product, ProductPurchase, - ProrationModesAndroid, PurchaseResult, + RequestPurchase, + RequestSubscription, Sku, Subscription, - SubscriptionOffer, SubscriptionPurchase, } from './types'; import {InstallSourceAndroid, PurchaseStateAndroid} from './types'; @@ -224,7 +224,7 @@ export const getAvailablePurchases = (): Promise< /** * Request a purchase for product. This will be received in `PurchaseUpdatedListener`. * @param {string} sku The product's sku/ID - * @param {string} [applicationUsername] The purchaser's user ID + * @param {string} [appAccountToken] UUID representing the purchaser * @param {boolean} [andDangerouslyFinishTransactionAutomaticallyIOS] You should set this to false and call finishTransaction manually when you have delivered the purchased goods to the user. It defaults to true to provide backwards compatibility. Will default to false in version 4.0.0. * @param {string} [obfuscatedAccountIdAndroid] Specifies an optional obfuscated string that is uniquely associated with the user's account in your app. * @param {string} [obfuscatedProfileIdAndroid] Specifies an optional obfuscated string that is uniquely associated with the user's profile in your app. @@ -236,21 +236,14 @@ export const getAvailablePurchases = (): Promise< export const requestPurchase = ({ sku, andDangerouslyFinishTransactionAutomaticallyIOS = false, - applicationUsername, obfuscatedAccountIdAndroid, obfuscatedProfileIdAndroid, - skus, - isOfferPersonalized, -}: { - sku?: Sku; - andDangerouslyFinishTransactionAutomaticallyIOS?: boolean; - applicationUsername?: string; - obfuscatedAccountIdAndroid?: string; - obfuscatedProfileIdAndroid?: string; - /** For Google Play Billing Library 5 https://developer.android.com/google/play/billing/integrate#personalized-price */ - skus?: Sku[]; - isOfferPersonalized?: boolean; -}): Promise => + appAccountToken, + skus, // Android Billing V5 + isOfferPersonalized = undefined, // Android Billing V5 + quantity, + withOffer, +}: RequestPurchase): Promise => ( Platform.select({ ios: async () => { @@ -263,7 +256,9 @@ export const requestPurchase = ({ return getIosModule().buyProduct( sku, andDangerouslyFinishTransactionAutomaticallyIOS, - applicationUsername, + appAccountToken, + quantity, + withOffer, ); }, android: async () => { @@ -288,7 +283,7 @@ export const requestPurchase = ({ /** * Request a purchase for product. This will be received in `PurchaseUpdatedListener`. * @param {string} [sku] The product's sku/ID - * @param {string} [applicationUsername] The purchaser's user ID + * @param {string} [appAccountToken] The purchaser's user ID * @param {boolean} [andDangerouslyFinishTransactionAutomaticallyIOS] You should set this to false and call finishTransaction manually when you have delivered the purchased goods to the user. It defaults to true to provide backwards compatibility. Will default to false in version 4.0.0. * @param {string} [purchaseTokenAndroid] purchaseToken that the user is upgrading or downgrading from (Android). * @param {ProrationModesAndroid} [prorationModeAndroid] UNKNOWN_SUBSCRIPTION_UPGRADE_DOWNGRADE_POLICY, IMMEDIATE_WITH_TIME_PRORATION, IMMEDIATE_AND_CHARGE_PRORATED_PRICE, IMMEDIATE_WITHOUT_PRORATION, DEFERRED @@ -300,26 +295,14 @@ export const requestPurchase = ({ export const requestSubscription = ({ sku, andDangerouslyFinishTransactionAutomaticallyIOS = false, - applicationUsername, purchaseTokenAndroid, prorationModeAndroid = -1, - subscriptionOffers, obfuscatedAccountIdAndroid, obfuscatedProfileIdAndroid, - isOfferPersonalized = undefined, -}: { - sku?: Sku; - andDangerouslyFinishTransactionAutomaticallyIOS?: boolean; - applicationUsername?: string; - purchaseTokenAndroid?: string; - prorationModeAndroid?: ProrationModesAndroid; - /** For Google Play Billing Library 5 */ - subscriptionOffers?: SubscriptionOffer[]; - obfuscatedAccountIdAndroid?: string; - obfuscatedProfileIdAndroid?: string; - /** For Google Play Billing Library 5 https://developer.android.com/google/play/billing/integrate#personalized-price */ - isOfferPersonalized?: boolean; -}): Promise => + subscriptionOffers = undefined, // Android Billing V5 + isOfferPersonalized = undefined, // Android Billing V5 + appAccountToken, +}: RequestSubscription): Promise => ( Platform.select({ ios: async () => { @@ -332,7 +315,7 @@ export const requestSubscription = ({ return getIosModule().buyProduct( sku, andDangerouslyFinishTransactionAutomaticallyIOS, - applicationUsername, + appAccountToken, ); }, android: async () => { @@ -360,20 +343,6 @@ export const requestSubscription = ({ }) || Promise.resolve )(); -/** - * Request a purchase for product. This will be received in `PurchaseUpdatedListener`. - * @param {string} sku The product's sku/ID - * @returns {Promise} - */ -export const requestPurchaseWithQuantityIOS = ({ - sku, - quantity, -}: { - sku: Sku; - quantity: number; -}): Promise => - getIosModule().buyProductWithQuantityIOS(sku, quantity); - /** * Finish Transaction (both platforms) * Abstracts Finish Transaction @@ -391,7 +360,7 @@ export const finishTransaction = ({ isConsumable, developerPayloadAndroid, }: { - purchase: ProductPurchase | ProductPurchase; + purchase: ProductPurchase | SubscriptionPurchase; isConsumable?: boolean; developerPayloadAndroid?: string; }): Promise => { @@ -437,14 +406,6 @@ export const finishTransaction = ({ export const clearTransactionIOS = (): Promise => getIosModule().clearTransaction(); -/** - * Clear valid Products (iOS only) - * Remove all products which are validated by Apple server. - * @returns {void} - */ -export const clearProductsIOS = (): Promise => - getIosModule().clearProducts(); - /** * Acknowledge a product (on Android.) No-op on iOS. * @param {string} token The product's token (on Android) @@ -493,15 +454,34 @@ export const getPromotedProductIOS = (): Promise => export const buyPromotedProductIOS = (): Promise => getIosModule().buyPromotedProduct(); +const fetchJsonOrThrow = async ( + url: string, + receiptBody: Record, +): Promise => { + const response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(receiptBody), + }); + + if (!response.ok) { + throw Object.assign(new Error(response.statusText), { + statusCode: response.status, + }); + } + + return response.json(); +}; + const requestAgnosticReceiptValidationIos = async ( receiptBody: Record, ): Promise => { - const response = await enhancedFetch( + const response = await fetchJsonOrThrow( 'https://buy.itunes.apple.com/verifyReceipt', - { - method: 'POST', - body: receiptBody, - }, + receiptBody, ); // Best practice is to check for test receipt and check sandbox instead @@ -521,32 +501,6 @@ const requestAgnosticReceiptValidationIos = async ( return response; }; -/** - * Buy products or subscriptions with offers (iOS only) - * - * Runs the payment process with some info you must fetch - * from your server. - * @param {string} sku The product identifier - * @param {string} forUser An user identifier on you system - * @param {Apple.PaymentDiscount} withOffer The offer information - * @param {string} withOffer.identifier The offer identifier - * @param {string} withOffer.keyIdentifier Key identifier that it uses to generate the signature - * @param {string} withOffer.nonce An UUID returned from the server - * @param {string} withOffer.signature The actual signature returned from the server - * @param {number} withOffer.timestamp The timestamp of the signature - * @returns {Promise} - */ -export const requestPurchaseWithOfferIOS = ({ - sku, - forUser, - withOffer, -}: { - sku: Sku; - forUser: string; - withOffer: Apple.PaymentDiscount; -}): Promise => - getIosModule().buyProductWithOffer(sku, forUser, withOffer); - /** * Validate receipt for iOS. * @param {object} receiptBody the receipt body to send to apple server. diff --git a/src/types/index.ts b/src/types/index.ts index ba1afe691..fb321c9dd 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ +import type * as Apple from './apple'; export type Sku = string; export enum ProrationModesAndroid { @@ -146,6 +147,28 @@ export interface SubscriptionIOS extends ProductCommon { } export type Subscription = SubscriptionAndroid & SubscriptionIOS; +export interface RequestPurchaseBaseAndroid { + obfuscatedAccountIdAndroid?: string; + obfuscatedProfileIdAndroid?: string; + isOfferPersonalized?: boolean; // For AndroidBilling V5 https://developer.android.com/google/play/billing/integrate#personalized-price +} + +export interface RequestPurchaseAndroid extends RequestPurchaseBaseAndroid { + skus?: Sku[]; +} + +export interface RequestPurchaseIOS { + sku?: Sku; + andDangerouslyFinishTransactionAutomaticallyIOS?: boolean; + /** + * UUID representing user account + */ + appAccountToken?: string; + quantity?: number; + withOffer?: Apple.PaymentDiscount; +} + +export type RequestPurchase = RequestPurchaseAndroid & RequestPurchaseIOS; /** * In order to purchase a new subscription, every sku must have a selected offerToken @@ -155,3 +178,14 @@ export interface SubscriptionOffer { sku: Sku; offerToken: string; } + +export interface RequestSubscriptionAndroid extends RequestPurchaseBaseAndroid { + purchaseTokenAndroid?: string; + prorationModeAndroid?: ProrationModesAndroid; + subscriptionOffers?: SubscriptionOffer[]; // For AndroidBilling V5 +} + +export type RequestSubscriptionIOS = RequestPurchaseIOS; + +export type RequestSubscription = RequestSubscriptionAndroid & + RequestSubscriptionIOS;