diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index 60407b4c907..22c26fd86a9 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.4.7 + +* Adds `SK2Transaction.unfinishedTransactions()` method to query only unfinished transactions. +* Exposes `appAccountToken` property in `SK2PurchaseDetails` for user identification. + ## 0.4.6+2 * Updates to Pigeon 26. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index da8874e0a1c..817d506558b 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -230,6 +230,30 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + /// Wrapper method around StoreKit2's Transaction.unfinished + /// https://developer.apple.com/documentation/storekit/transaction/unfinished + func unfinishedTransactions( + completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void + ) { + Task { + @MainActor in + do { + var transactionsMsgs: [SK2TransactionMessage] = [] + for await verificationResult in Transaction.unfinished { + switch verificationResult { + case .verified(let transaction): + transactionsMsgs.append( + transaction.convertToPigeon(receipt: verificationResult.jwsRepresentation) + ) + case .unverified: + break + } + } + completion(.success(transactionsMsgs)) + } + } + } + func restorePurchases(completion: @escaping (Result) -> Void) { Task { [weak self] in guard let self = self else { return } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift index 82341c6f6b0..eba2f16eabe 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.0), do not edit directly. +// Autogenerated from Pigeon (v26.1.1), do not edit directly. // See also: https://pub.dev/packages/pigeon import Foundation @@ -727,6 +727,8 @@ protocol InAppPurchase2API { func isIntroductoryOfferEligible( productId: String, completion: @escaping (Result) -> Void) func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void) + func unfinishedTransactions( + completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void) func finish(id: Int64, completion: @escaping (Result) -> Void) func startListeningToTransactions() throws func stopListeningToTransactions() throws @@ -860,6 +862,24 @@ class InAppPurchase2APISetup { } else { transactionsChannel.setMessageHandler(nil) } + let unfinishedTransactionsChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + unfinishedTransactionsChannel.setMessageHandler { _, reply in + api.unfinishedTransactions { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + unfinishedTransactionsChannel.setMessageHandler(nil) + } let finishChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart index 0d40ad3578b..9f0c9956704 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.0), do not edit directly. +// Autogenerated from Pigeon (v26.1.1), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -978,6 +978,37 @@ class InAppPurchase2API { } } + Future> unfinishedTransactions() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)! + .cast(); + } + } + Future finish(int id) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish$pigeonVar_messageChannelSuffix'; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart index bc09ff5c96f..55305cc4b4e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart @@ -27,6 +27,7 @@ class SK2Transaction { this.subscriptionGroupID, this.price, this.error, + this.receiptData, this.jsonRepresentation, }); @@ -63,7 +64,11 @@ class SK2Transaction { /// Any error returned from StoreKit final SKError? error; - /// The json representation of a transaction + /// The JWS (JSON Web Signature) representation of the transaction. + /// This is the jwsRepresentation from StoreKit used for server-side verification. + final String? receiptData; + + /// The json representation of a transaction. final String? jsonRepresentation; /// Wrapper around [Transaction.finish] @@ -76,7 +81,7 @@ class SK2Transaction { /// A wrapper around [Transaction.all] /// https://developer.apple.com/documentation/storekit/transaction/3851203-all - /// A sequence that emits all the customer’s transactions for your app. + /// A sequence that emits all the customer's transactions for your app. static Future> transactions() async { final List msgs = await hostApi2.transactions(); final List transactions = msgs @@ -85,6 +90,18 @@ class SK2Transaction { return transactions; } + /// A wrapper around [Transaction.unfinished] + /// https://developer.apple.com/documentation/storekit/transaction/unfinished + /// A sequence that emits unfinished transactions for the customer. + static Future> unfinishedTransactions() async { + final List msgs = await hostApi2 + .unfinishedTransactions(); + final List transactions = msgs + .map((SK2TransactionMessage e) => e.convertFromPigeon()) + .toList(); + return transactions; + } + /// Start listening to transactions. /// Call this as soon as you can your app to avoid missing transactions. static void startListeningToTransactions() { @@ -111,6 +128,7 @@ extension on SK2TransactionMessage { purchaseDate: purchaseDate, expirationDate: expirationDate, appAccountToken: appAccountToken, + receiptData: receiptData, jsonRepresentation: jsonRepresentation, ); } @@ -135,6 +153,7 @@ extension on SK2TransactionMessage { // Any failed transaction will simply not be returned. status: restoring ? PurchaseStatus.restored : PurchaseStatus.purchased, purchaseID: id.toString(), + appAccountToken: appAccountToken, ); } } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart index 5964137dc83..66a935dbde5 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart @@ -91,8 +91,13 @@ class SK2PurchaseDetails extends PurchaseDetails { required super.verificationData, required super.transactionDate, required super.status, + this.appAccountToken, }); + /// A UUID that associates the transaction with a user on your own service. + /// This is the value set when making the purchase via appAccountToken option. + final String? appAccountToken; + @override bool get pendingCompletePurchase => status == PurchaseStatus.purchased; } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart index ff6e42a7414..780ad63779a 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart @@ -240,6 +240,9 @@ abstract class InAppPurchase2API { @async List transactions(); + @async + List unfinishedTransactions(); + @async void finish(int id); diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml index cb642ec20ae..5882c914518 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.6+2 +version: 0.4.7 environment: sdk: ^3.9.0 @@ -31,7 +31,7 @@ dev_dependencies: flutter_test: sdk: flutter json_serializable: ^6.0.0 - pigeon: ^26.1.0 + pigeon: ^26.1.1 test: ^1.16.0 topics: diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart index 4b155205af5..9f8069b3dc5 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart @@ -450,6 +450,19 @@ class FakeStoreKit2Platform implements InAppPurchase2API { ]); } + @override + Future> unfinishedTransactions() { + return Future>.value([ + SK2TransactionMessage( + id: 123, + originalId: 123, + productId: 'product_id', + purchaseDate: '12-12', + receiptData: 'fake_jws_representation', + ), + ]); + } + @override Future startListeningToTransactions() async { isListenerRegistered = true; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart index 8ce69c30066..a9910991207 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart @@ -665,4 +665,63 @@ void main() { }, ); }); + + group('unfinished transactions', () { + test('should return unfinished transactions', () async { + final List transactions = + await SK2Transaction.unfinishedTransactions(); + + expect(transactions, isNotEmpty); + expect(transactions.first.id, '123'); + expect(transactions.first.productId, 'product_id'); + }); + + test( + 'should expose receiptData (JWS) in unfinished transactions', + () async { + final List transactions = + await SK2Transaction.unfinishedTransactions(); + + expect(transactions, isNotEmpty); + expect(transactions.first.receiptData, isNotNull); + expect(transactions.first.receiptData, 'fake_jws_representation'); + }, + ); + }); + + group('appAccountToken exposure', () { + test('should expose appAccountToken in SK2PurchaseDetails', () async { + const String testToken = 'test-uuid-12345'; + final SK2PurchaseDetails details = SK2PurchaseDetails( + productID: 'test_product', + purchaseID: '999', + verificationData: PurchaseVerificationData( + localVerificationData: '', + serverVerificationData: '', + source: 'app_store', + ), + transactionDate: '2025-11-15', + status: PurchaseStatus.purchased, + appAccountToken: testToken, + ); + + expect(details.appAccountToken, testToken); + }); + + test('should handle null appAccountToken in SK2PurchaseDetails', () async { + final SK2PurchaseDetails details = SK2PurchaseDetails( + productID: 'test_product', + purchaseID: '999', + verificationData: PurchaseVerificationData( + localVerificationData: '', + serverVerificationData: '', + source: 'app_store', + ), + transactionDate: '2025-11-15', + status: PurchaseStatus.purchased, + ); + + expect(details.appAccountToken, isNull); + }); + }); }