Skip to content

Commit 0ac7a03

Browse files
authored
[in_app_purchase_storekit] Add Transaction.unfinished API and expose appAccountToken (flutter#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.
1 parent 36383d6 commit 0ac7a03

File tree

10 files changed

+162
-7
lines changed

10 files changed

+162
-7
lines changed

packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.4.7
2+
3+
* Adds `SK2Transaction.unfinishedTransactions()` method to query only unfinished transactions.
4+
* Exposes `appAccountToken` property in `SK2PurchaseDetails` for user identification.
5+
16
## 0.4.6+2
27

38
* Updates to Pigeon 26.

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,28 @@ extension InAppPurchasePlugin: InAppPurchase2API {
230230
}
231231
}
232232

233+
/// Wrapper method around StoreKit2's Transaction.unfinished
234+
/// https://developer.apple.com/documentation/storekit/transaction/unfinished
235+
func unfinishedTransactions(
236+
completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void
237+
) {
238+
Task {
239+
@MainActor in
240+
var transactionsMsgs: [SK2TransactionMessage] = []
241+
for await verificationResult in Transaction.unfinished {
242+
switch verificationResult {
243+
case .verified(let transaction):
244+
transactionsMsgs.append(
245+
transaction.convertToPigeon(receipt: verificationResult.jwsRepresentation)
246+
)
247+
case .unverified:
248+
break
249+
}
250+
}
251+
completion(.success(transactionsMsgs))
252+
}
253+
}
254+
233255
func restorePurchases(completion: @escaping (Result<Void, Error>) -> Void) {
234256
Task { [weak self] in
235257
guard let self = self else { return }

packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/StoreKit2Messages.g.swift

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v26.1.0), do not edit directly.
4+
// Autogenerated from Pigeon (v26.1.1), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66

77
import Foundation
@@ -727,6 +727,8 @@ protocol InAppPurchase2API {
727727
func isIntroductoryOfferEligible(
728728
productId: String, completion: @escaping (Result<Bool, Error>) -> Void)
729729
func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void)
730+
func unfinishedTransactions(
731+
completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void)
730732
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void)
731733
func startListeningToTransactions() throws
732734
func stopListeningToTransactions() throws
@@ -860,6 +862,24 @@ class InAppPurchase2APISetup {
860862
} else {
861863
transactionsChannel.setMessageHandler(nil)
862864
}
865+
let unfinishedTransactionsChannel = FlutterBasicMessageChannel(
866+
name:
867+
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions\(channelSuffix)",
868+
binaryMessenger: binaryMessenger, codec: codec)
869+
if let api = api {
870+
unfinishedTransactionsChannel.setMessageHandler { _, reply in
871+
api.unfinishedTransactions { result in
872+
switch result {
873+
case .success(let res):
874+
reply(wrapResult(res))
875+
case .failure(let error):
876+
reply(wrapError(error))
877+
}
878+
}
879+
}
880+
} else {
881+
unfinishedTransactionsChannel.setMessageHandler(nil)
882+
}
863883
let finishChannel = FlutterBasicMessageChannel(
864884
name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish\(channelSuffix)",
865885
binaryMessenger: binaryMessenger, codec: codec)

packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v26.1.0), do not edit directly.
4+
// Autogenerated from Pigeon (v26.1.1), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66
// 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
77

@@ -978,6 +978,37 @@ class InAppPurchase2API {
978978
}
979979
}
980980

981+
Future<List<SK2TransactionMessage>> unfinishedTransactions() async {
982+
final String pigeonVar_channelName =
983+
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.unfinishedTransactions$pigeonVar_messageChannelSuffix';
984+
final BasicMessageChannel<Object?> pigeonVar_channel =
985+
BasicMessageChannel<Object?>(
986+
pigeonVar_channelName,
987+
pigeonChannelCodec,
988+
binaryMessenger: pigeonVar_binaryMessenger,
989+
);
990+
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
991+
final List<Object?>? pigeonVar_replyList =
992+
await pigeonVar_sendFuture as List<Object?>?;
993+
if (pigeonVar_replyList == null) {
994+
throw _createConnectionError(pigeonVar_channelName);
995+
} else if (pigeonVar_replyList.length > 1) {
996+
throw PlatformException(
997+
code: pigeonVar_replyList[0]! as String,
998+
message: pigeonVar_replyList[1] as String?,
999+
details: pigeonVar_replyList[2],
1000+
);
1001+
} else if (pigeonVar_replyList[0] == null) {
1002+
throw PlatformException(
1003+
code: 'null-error',
1004+
message: 'Host platform returned null value for non-null return value.',
1005+
);
1006+
} else {
1007+
return (pigeonVar_replyList[0] as List<Object?>?)!
1008+
.cast<SK2TransactionMessage>();
1009+
}
1010+
}
1011+
9811012
Future<void> finish(int id) async {
9821013
final String pigeonVar_channelName =
9831014
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.finish$pigeonVar_messageChannelSuffix';

packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_transaction_wrapper.dart

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class SK2Transaction {
2727
this.subscriptionGroupID,
2828
this.price,
2929
this.error,
30+
this.receiptData,
3031
this.jsonRepresentation,
3132
});
3233

@@ -63,7 +64,11 @@ class SK2Transaction {
6364
/// Any error returned from StoreKit
6465
final SKError? error;
6566

66-
/// The json representation of a transaction
67+
/// The JWS (JSON Web Signature) representation of the transaction.
68+
/// This is the jwsRepresentation from StoreKit used for server-side verification.
69+
final String? receiptData;
70+
71+
/// The json representation of a transaction.
6772
final String? jsonRepresentation;
6873

6974
/// Wrapper around [Transaction.finish]
@@ -76,7 +81,7 @@ class SK2Transaction {
7681

7782
/// A wrapper around [Transaction.all]
7883
/// https://developer.apple.com/documentation/storekit/transaction/3851203-all
79-
/// A sequence that emits all the customers transactions for your app.
84+
/// A sequence that emits all the customer's transactions for your app.
8085
static Future<List<SK2Transaction>> transactions() async {
8186
final List<SK2TransactionMessage> msgs = await hostApi2.transactions();
8287
final List<SK2Transaction> transactions = msgs
@@ -85,6 +90,18 @@ class SK2Transaction {
8590
return transactions;
8691
}
8792

93+
/// A wrapper around [Transaction.unfinished]
94+
/// https://developer.apple.com/documentation/storekit/transaction/unfinished
95+
/// A sequence that emits unfinished transactions for the customer.
96+
static Future<List<SK2Transaction>> unfinishedTransactions() async {
97+
final List<SK2TransactionMessage> msgs = await hostApi2
98+
.unfinishedTransactions();
99+
final List<SK2Transaction> transactions = msgs
100+
.map((SK2TransactionMessage e) => e.convertFromPigeon())
101+
.toList();
102+
return transactions;
103+
}
104+
88105
/// Start listening to transactions.
89106
/// Call this as soon as you can your app to avoid missing transactions.
90107
static void startListeningToTransactions() {
@@ -111,6 +128,7 @@ extension on SK2TransactionMessage {
111128
purchaseDate: purchaseDate,
112129
expirationDate: expirationDate,
113130
appAccountToken: appAccountToken,
131+
receiptData: receiptData,
114132
jsonRepresentation: jsonRepresentation,
115133
);
116134
}
@@ -135,6 +153,7 @@ extension on SK2TransactionMessage {
135153
// Any failed transaction will simply not be returned.
136154
status: restoring ? PurchaseStatus.restored : PurchaseStatus.purchased,
137155
purchaseID: id.toString(),
156+
appAccountToken: appAccountToken,
138157
);
139158
}
140159
}

packages/in_app_purchase/in_app_purchase_storekit/lib/src/types/app_store_purchase_details.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,13 @@ class SK2PurchaseDetails extends PurchaseDetails {
9191
required super.verificationData,
9292
required super.transactionDate,
9393
required super.status,
94+
this.appAccountToken,
9495
});
9596

97+
/// A UUID that associates the transaction with a user on your own service.
98+
/// This is the value set when making the purchase via appAccountToken option.
99+
final String? appAccountToken;
100+
96101
@override
97102
bool get pendingCompletePurchase => status == PurchaseStatus.purchased;
98103
}

packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,9 @@ abstract class InAppPurchase2API {
240240
@async
241241
List<SK2TransactionMessage> transactions();
242242

243+
@async
244+
List<SK2TransactionMessage> unfinishedTransactions();
245+
243246
@async
244247
void finish(int id);
245248

packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: in_app_purchase_storekit
22
description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework.
33
repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22
5-
version: 0.4.6+2
5+
version: 0.4.7
66

77
environment:
88
sdk: ^3.9.0
@@ -31,7 +31,7 @@ dev_dependencies:
3131
flutter_test:
3232
sdk: flutter
3333
json_serializable: ^6.0.0
34-
pigeon: ^26.1.0
34+
pigeon: ^26.1.1
3535
test: ^1.16.0
3636

3737
topics:

packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,20 @@ class FakeStoreKit2Platform implements InAppPurchase2API {
450450
]);
451451
}
452452

453+
@override
454+
Future<List<SK2TransactionMessage>> unfinishedTransactions() {
455+
return Future<List<SK2TransactionMessage>>.value(<SK2TransactionMessage>[
456+
SK2TransactionMessage(
457+
id: 123,
458+
originalId: 123,
459+
productId: 'product_id',
460+
purchaseDate: '12-12',
461+
receiptData: 'fake_jws_representation',
462+
appAccountToken: 'fake_app_account_token',
463+
),
464+
]);
465+
}
466+
453467
@override
454468
Future<void> startListeningToTransactions() async {
455469
isListenerRegistered = true;

packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ void main() {
172172
});
173173

174174
test(
175-
'buying consumable, should get PurchaseVerificationData with serverVerificationData and localVerificationData',
175+
'buying consumable, should get PurchaseVerificationData with serverVerificationData, localVerificationData, and appAccountToken',
176176
() async {
177177
final details = <PurchaseDetails>[];
178178
final completer = Completer<List<PurchaseDetails>>();
@@ -208,6 +208,10 @@ void main() {
208208
result.first.verificationData.localVerificationData,
209209
'jsonRepresentation',
210210
);
211+
expect(
212+
(result.first as SK2PurchaseDetails).appAccountToken,
213+
'appAccountToken',
214+
);
211215
},
212216
);
213217

@@ -658,4 +662,36 @@ void main() {
658662
},
659663
);
660664
});
665+
666+
group('unfinished transactions', () {
667+
test('should return unfinished transactions', () async {
668+
final List<SK2Transaction> transactions =
669+
await SK2Transaction.unfinishedTransactions();
670+
671+
expect(transactions, isNotEmpty);
672+
expect(transactions.first.id, '123');
673+
expect(transactions.first.productId, 'product_id');
674+
});
675+
676+
test(
677+
'should expose receiptData (JWS) in unfinished transactions',
678+
() async {
679+
final List<SK2Transaction> transactions =
680+
await SK2Transaction.unfinishedTransactions();
681+
682+
expect(transactions, isNotEmpty);
683+
expect(transactions.first.receiptData, isNotNull);
684+
expect(transactions.first.receiptData, 'fake_jws_representation');
685+
},
686+
);
687+
688+
test('should expose appAccountToken in unfinished transactions', () async {
689+
final List<SK2Transaction> transactions =
690+
await SK2Transaction.unfinishedTransactions();
691+
692+
expect(transactions, isNotEmpty);
693+
expect(transactions.first.appAccountToken, isNotNull);
694+
expect(transactions.first.appAccountToken, 'fake_app_account_token');
695+
});
696+
});
661697
}

0 commit comments

Comments
 (0)