[in_app_purchase_storekit] Add Transaction.unfinished API and expose appAccountToken (#10439)
## Summary
Adds two new StoreKit 2 features to `in_app_purchase_storekit`:
- `SK2Transaction.unfinishedTransactions()` - Queries only unfinished transactions for better performance
- `SK2PurchaseDetails.appAccountToken` - Exposes user UUID for backend integration
## Motivation
1. **Performance:** Developers often only need unfinished transactions to complete them, not all historical transactions. This mirrors Apple's official `Transaction.unfinished` API.
2. **User Identification:** The ability to set `appAccountToken` already exists when making purchases, but reading it back from transaction details was missing.
## Changes
- Added pigeon interface method for `unfinishedTransactions()`
- Implemented Swift native code using Apple's `Transaction.unfinished` API
- Exposed `appAccountToken` property in `SK2PurchaseDetails`
- Added unit tests for both features
## Breaking Changes
None. Both features are additive and maintain full backward compatibility.
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 60407b4..22c26fd 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 da8874e..f6d7299 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,28 @@
}
}
+ /// 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
+ 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, Error>) -> 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 82341c6..eba2f16 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 @@
func isIntroductoryOfferEligible(
productId: String, completion: @escaping (Result<Bool, Error>) -> 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, Error>) -> Void)
func startListeningToTransactions() throws
func stopListeningToTransactions() throws
@@ -860,6 +862,24 @@
} 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 0d40ad3..9f0c995 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 @@
}
}
+ Future<List<SK2TransactionMessage>> unfinishedTransactions() async {
+ final String pigeonVar_channelName =
+ 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions$pigeonVar_messageChannelSuffix';
+ final BasicMessageChannel<Object?> pigeonVar_channel =
+ BasicMessageChannel<Object?>(
+ pigeonVar_channelName,
+ pigeonChannelCodec,
+ binaryMessenger: pigeonVar_binaryMessenger,
+ );
+ final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
+ final List<Object?>? pigeonVar_replyList =
+ await pigeonVar_sendFuture as List<Object?>?;
+ 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<Object?>?)!
+ .cast<SK2TransactionMessage>();
+ }
+ }
+
Future<void> 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 bc09ff5..55305cc 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 @@
this.subscriptionGroupID,
this.price,
this.error,
+ this.receiptData,
this.jsonRepresentation,
});
@@ -63,7 +64,11 @@
/// 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 @@
/// 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<List<SK2Transaction>> transactions() async {
final List<SK2TransactionMessage> msgs = await hostApi2.transactions();
final List<SK2Transaction> transactions = msgs
@@ -85,6 +90,18 @@
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<List<SK2Transaction>> unfinishedTransactions() async {
+ final List<SK2TransactionMessage> msgs = await hostApi2
+ .unfinishedTransactions();
+ final List<SK2Transaction> 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 @@
purchaseDate: purchaseDate,
expirationDate: expirationDate,
appAccountToken: appAccountToken,
+ receiptData: receiptData,
jsonRepresentation: jsonRepresentation,
);
}
@@ -135,6 +153,7 @@
// 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 d55ca6c..8d6fdff 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 @@
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 ff6e42a..780ad63 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
@@ -241,6 +241,9 @@
List<SK2TransactionMessage> transactions();
@async
+ List<SK2TransactionMessage> unfinishedTransactions();
+
+ @async
void finish(int id);
void startListeningToTransactions();
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 cb642ec..5882c91 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 @@
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 @@
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 6a4e1e9..a188d3f 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
@@ -451,6 +451,20 @@
}
@override
+ Future<List<SK2TransactionMessage>> unfinishedTransactions() {
+ return Future<List<SK2TransactionMessage>>.value(<SK2TransactionMessage>[
+ SK2TransactionMessage(
+ id: 123,
+ originalId: 123,
+ productId: 'product_id',
+ purchaseDate: '12-12',
+ receiptData: 'fake_jws_representation',
+ appAccountToken: 'fake_app_account_token',
+ ),
+ ]);
+ }
+
+ @override
Future<void> 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 3127d15..35191b9 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
@@ -172,7 +172,7 @@
});
test(
- 'buying consumable, should get PurchaseVerificationData with serverVerificationData and localVerificationData',
+ 'buying consumable, should get PurchaseVerificationData with serverVerificationData, localVerificationData, and appAccountToken',
() async {
final details = <PurchaseDetails>[];
final completer = Completer<List<PurchaseDetails>>();
@@ -208,6 +208,10 @@
result.first.verificationData.localVerificationData,
'jsonRepresentation',
);
+ expect(
+ (result.first as SK2PurchaseDetails).appAccountToken,
+ 'appAccountToken',
+ );
},
);
@@ -658,4 +662,36 @@
},
);
});
+
+ group('unfinished transactions', () {
+ test('should return unfinished transactions', () async {
+ final List<SK2Transaction> 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<SK2Transaction> transactions =
+ await SK2Transaction.unfinishedTransactions();
+
+ expect(transactions, isNotEmpty);
+ expect(transactions.first.receiptData, isNotNull);
+ expect(transactions.first.receiptData, 'fake_jws_representation');
+ },
+ );
+
+ test('should expose appAccountToken in unfinished transactions', () async {
+ final List<SK2Transaction> transactions =
+ await SK2Transaction.unfinishedTransactions();
+
+ expect(transactions, isNotEmpty);
+ expect(transactions.first.appAccountToken, isNotNull);
+ expect(transactions.first.appAccountToken, 'fake_app_account_token');
+ });
+ });
}