Skip to content
Open
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## 0.4.8+1

* Fixes StoreKit 2 purchase flow to send cancelled/pending/unverified results to `purchaseStream`.
## 0.4.8

* Fixes an issue causing StoreKit2 purchases to be reported as `restored` and left in an
Expand All @@ -20,7 +23,7 @@
## 0.4.6

* Adds a new case `.unverified` to enum `SK2ProductPurchaseResult`
* Fixes the StoreKit2 implementation throwing `PlatformException`s instead of returning the corresponding
* Fixes the StoreKit2 implementation throwing `PlatformException`s instead of returning the corresponding
`SK2ProductPurchaseResult` when a purchase is cancelled / unverified / pending.

## 0.4.5
Expand Down Expand Up @@ -49,7 +52,7 @@

* Updates minimum supported SDK version to Flutter 3.27/Dart 3.6.
* Adds **Win Back Offers** support for StoreKit2:
- Includes new `isWinBackOfferEligible` function for eligibility verification
* Includes new `isWinBackOfferEligible` function for eligibility verification
* Adds **Promotional Offers** support in StoreKit2 purchases
* Fixes introductory pricing handling in promotional offers list in StoreKit2
* Ensures proper `appAccountToken` handling for StoreKit2 purchases
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,15 @@ extension InAppPurchasePlugin: InAppPurchase2API {
switch result {
case .success(let verification):
sendTransactionUpdate(
transaction: verification.unsafePayloadValue, receipt: verification.jwsRepresentation)
case .pending, .userCancelled:
break
productId: id,
transaction: verification.unsafePayloadValue,
receipt: verification.jwsRepresentation,
status: .purchased
)
case .pending:
sendTransactionUpdate(productId: id, status: .pending)
case .userCancelled:
sendTransactionUpdate(productId: id, status: .cancelled)
@unknown default:
fatalError("An unknown StoreKit PurchaseResult has been encountered.")
}
Expand Down Expand Up @@ -223,7 +229,7 @@ extension InAppPurchasePlugin: InAppPurchase2API {
@MainActor in
do {
let transactionsMsgs = await rawTransactions().map {
$0.convertToPigeon(receipt: nil)
$0.convertToPigeon(receipt: nil, status: .purchased)
}
completion(.success(transactionsMsgs))
}
Expand All @@ -242,7 +248,8 @@ extension InAppPurchasePlugin: InAppPurchase2API {
switch verificationResult {
case .verified(let transaction):
transactionsMsgs.append(
transaction.convertToPigeon(receipt: verificationResult.jwsRepresentation)
transaction.convertToPigeon(
receipt: verificationResult.jwsRepresentation, status: .purchased)
)
case .unverified:
break
Expand All @@ -261,8 +268,11 @@ extension InAppPurchasePlugin: InAppPurchase2API {
switch completedPurchase {
case .verified(let purchase):
self.sendTransactionUpdate(
transaction: purchase, receipt: "\(completedPurchase.jwsRepresentation)",
restoring: true)
productId: purchase.productID,
transaction: purchase,
receipt: "\(completedPurchase.jwsRepresentation)",
status: .restored
)
case .unverified(let failedPurchase, let error):
unverifiedPurchases[failedPurchase.id] = (
receipt: completedPurchase.jwsRepresentation, error: error
Expand Down Expand Up @@ -341,7 +351,11 @@ extension InAppPurchasePlugin: InAppPurchase2API {
switch verificationResult {
case .verified(let transaction):
self?.sendTransactionUpdate(
transaction: transaction, receipt: verificationResult.jwsRepresentation)
productId: transaction.productID,
transaction: transaction,
receipt: verificationResult.jwsRepresentation,
status: .purchased
)
case .unverified:
break
}
Expand All @@ -354,18 +368,41 @@ extension InAppPurchasePlugin: InAppPurchase2API {
updateListenerTask.cancel()
}

/// Sends an transaction back to Dart. Access these transactions with `purchaseStream`
/// Sends a transaction or status update back to Dart. Access these transactions with `purchaseStream`
/// - Parameters:
/// - productId: The product ID (required)
/// - transaction: The transaction object (for success cases, nil for pending/cancelled)
/// - receipt: The JWS receipt data
/// - status: The purchase status
private func sendTransactionUpdate(
transaction: Transaction, receipt: String? = nil, restoring: Bool = false
productId: String,
transaction: Transaction? = nil,
receipt: String? = nil,
status: SK2PurchaseStatusMessage
) {
let transactionMessage = transaction.convertToPigeon(receipt: receipt, restoring: restoring)
let transactionMessage: SK2TransactionMessage

if let transaction = transaction {
// Has real transaction: use transaction info
transactionMessage = transaction.convertToPigeon(receipt: receipt, status: status)
} else {
// No transaction (pending/cancelled): create minimal message without purchaseDate
transactionMessage = SK2TransactionMessage(
id: 0,
originalId: 0,
productId: productId,
purchasedQuantity: 1,
status: status
)
}

Task { @MainActor in
self.transactionCallbackAPI?.onTransactionsUpdated(newTransactions: [transactionMessage]) {
result in
switch result {
case .success: break
case .failure(let error):
print("Failed to send transaction updates: \(error)")
print("Failed to send transaction update: \(error)")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.1), do not edit directly.
// Autogenerated from Pigeon (v26.1.5), do not edit directly.
// See also: https://pub.dev/packages/pigeon

import Foundation
Expand Down Expand Up @@ -172,6 +172,19 @@ enum SK2ProductPurchaseResultMessage: Int {
case pending = 3
}

/// The status of a purchase transaction.
/// Used to communicate the result state to Dart layer via purchaseStream.
enum SK2PurchaseStatusMessage: Int {
/// Purchase completed successfully.
case purchased = 0
/// Purchase is pending (e.g., Ask to Buy).
case pending = 1
/// Purchase was cancelled by the user.
case cancelled = 2
/// Purchase was restored.
case restored = 3
}

/// Generated class from Pigeon that represents data sent in messages.
struct SK2SubscriptionOfferMessage: Hashable {
var id: String? = nil
Expand Down Expand Up @@ -494,28 +507,30 @@ struct SK2TransactionMessage: Hashable {
var id: Int64
var originalId: Int64
var productId: String
var purchaseDate: String
var purchaseDate: String? = nil
var expirationDate: String? = nil
var purchasedQuantity: Int64
var appAccountToken: String? = nil
var restoring: Bool
var receiptData: String? = nil
var error: SK2ErrorMessage? = nil
var jsonRepresentation: String? = nil
/// The status of this purchase transaction.
/// Set by native side to communicate the result state to Dart layer.
var status: SK2PurchaseStatusMessage

// swift-format-ignore: AlwaysUseLowerCamelCase
static func fromList(_ pigeonVar_list: [Any?]) -> SK2TransactionMessage? {
let id = pigeonVar_list[0] as! Int64
let originalId = pigeonVar_list[1] as! Int64
let productId = pigeonVar_list[2] as! String
let purchaseDate = pigeonVar_list[3] as! String
let purchaseDate: String? = nilOrValue(pigeonVar_list[3])
let expirationDate: String? = nilOrValue(pigeonVar_list[4])
let purchasedQuantity = pigeonVar_list[5] as! Int64
let appAccountToken: String? = nilOrValue(pigeonVar_list[6])
let restoring = pigeonVar_list[7] as! Bool
let receiptData: String? = nilOrValue(pigeonVar_list[8])
let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[9])
let jsonRepresentation: String? = nilOrValue(pigeonVar_list[10])
let receiptData: String? = nilOrValue(pigeonVar_list[7])
let error: SK2ErrorMessage? = nilOrValue(pigeonVar_list[8])
let jsonRepresentation: String? = nilOrValue(pigeonVar_list[9])
let status = pigeonVar_list[10] as! SK2PurchaseStatusMessage

return SK2TransactionMessage(
id: id,
Expand All @@ -525,10 +540,10 @@ struct SK2TransactionMessage: Hashable {
expirationDate: expirationDate,
purchasedQuantity: purchasedQuantity,
appAccountToken: appAccountToken,
restoring: restoring,
receiptData: receiptData,
error: error,
jsonRepresentation: jsonRepresentation
jsonRepresentation: jsonRepresentation,
status: status
)
}
func toList() -> [Any?] {
Expand All @@ -540,10 +555,10 @@ struct SK2TransactionMessage: Hashable {
expirationDate,
purchasedQuantity,
appAccountToken,
restoring,
receiptData,
error,
jsonRepresentation,
status,
]
}
static func == (lhs: SK2TransactionMessage, rhs: SK2TransactionMessage) -> Bool {
Expand Down Expand Up @@ -621,24 +636,30 @@ private class StoreKit2MessagesPigeonCodecReader: FlutterStandardReader {
}
return nil
case 134:
return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?])
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
if let enumResultAsInt = enumResultAsInt {
return SK2PurchaseStatusMessage(rawValue: enumResultAsInt)
}
return nil
case 135:
return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?])
return SK2SubscriptionOfferMessage.fromList(self.readValue() as! [Any?])
case 136:
return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?])
return SK2SubscriptionPeriodMessage.fromList(self.readValue() as! [Any?])
case 137:
return SK2ProductMessage.fromList(self.readValue() as! [Any?])
return SK2SubscriptionInfoMessage.fromList(self.readValue() as! [Any?])
case 138:
return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?])
return SK2ProductMessage.fromList(self.readValue() as! [Any?])
case 139:
return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?])
return SK2PriceLocaleMessage.fromList(self.readValue() as! [Any?])
case 140:
return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?])
return SK2SubscriptionOfferSignatureMessage.fromList(self.readValue() as! [Any?])
case 141:
return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?])
return SK2SubscriptionOfferPurchaseMessage.fromList(self.readValue() as! [Any?])
case 142:
return SK2TransactionMessage.fromList(self.readValue() as! [Any?])
return SK2ProductPurchaseOptionsMessage.fromList(self.readValue() as! [Any?])
case 143:
return SK2TransactionMessage.fromList(self.readValue() as! [Any?])
case 144:
return SK2ErrorMessage.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
Expand All @@ -663,35 +684,38 @@ private class StoreKit2MessagesPigeonCodecWriter: FlutterStandardWriter {
} else if let value = value as? SK2ProductPurchaseResultMessage {
super.writeByte(133)
super.writeValue(value.rawValue)
} else if let value = value as? SK2SubscriptionOfferMessage {
} else if let value = value as? SK2PurchaseStatusMessage {
super.writeByte(134)
super.writeValue(value.rawValue)
} else if let value = value as? SK2SubscriptionOfferMessage {
super.writeByte(135)
super.writeValue(value.toList())
} else if let value = value as? SK2SubscriptionPeriodMessage {
super.writeByte(135)
super.writeByte(136)
super.writeValue(value.toList())
} else if let value = value as? SK2SubscriptionInfoMessage {
super.writeByte(136)
super.writeByte(137)
super.writeValue(value.toList())
} else if let value = value as? SK2ProductMessage {
super.writeByte(137)
super.writeByte(138)
super.writeValue(value.toList())
} else if let value = value as? SK2PriceLocaleMessage {
super.writeByte(138)
super.writeByte(139)
super.writeValue(value.toList())
} else if let value = value as? SK2SubscriptionOfferSignatureMessage {
super.writeByte(139)
super.writeByte(140)
super.writeValue(value.toList())
} else if let value = value as? SK2SubscriptionOfferPurchaseMessage {
super.writeByte(140)
super.writeByte(141)
super.writeValue(value.toList())
} else if let value = value as? SK2ProductPurchaseOptionsMessage {
super.writeByte(141)
super.writeByte(142)
super.writeValue(value.toList())
} else if let value = value as? SK2TransactionMessage {
super.writeByte(142)
super.writeByte(143)
super.writeValue(value.toList())
} else if let value = value as? SK2ErrorMessage {
super.writeByte(143)
super.writeByte(144)
super.writeValue(value.toList())
} else {
super.writeValue(value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ extension Product.PurchaseResult {

@available(iOS 15.0, macOS 12.0, *)
extension Transaction {
func convertToPigeon(receipt: String?, restoring: Bool = false) -> SK2TransactionMessage {
func convertToPigeon(receipt: String?, status: SK2PurchaseStatusMessage) -> SK2TransactionMessage
{

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
Expand All @@ -205,9 +206,9 @@ extension Transaction {
expirationDate: expirationDate.map { dateFormatter.string(from: $0) },
purchasedQuantity: Int64(purchasedQuantity),
appAccountToken: appAccountToken?.uuidString,
restoring: restoring,
receiptData: receipt,
jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self)
jsonRepresentation: String(decoding: jsonRepresentation, as: UTF8.self),
status: status
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,15 @@ final class InAppPurchase2PluginTests: XCTestCase {

XCTAssert(callback.lastUpdate.count == 1)
XCTAssert(
callback.lastUpdate.first?.restoring == false,
callback.lastUpdate.first?.status != .restored,
"Ordinary purchase updates should not be marked as restoring")

plugin.transactions {
result in
switch result {
case .success(let transactions):
XCTAssert(transactions.count == 1)
XCTAssert(transactions.first?.restoring == false)
XCTAssert(transactions.first?.status != .restored)
transactionExpectation.fulfill()
case .failure(let error):
XCTFail("Getting transactions should NOT fail. Failed with \(error)")
Expand Down Expand Up @@ -388,7 +388,7 @@ final class InAppPurchase2PluginTests: XCTestCase {
await fulfillment(of: [purchaseExpectation], timeout: 5)

XCTAssert(callback.lastUpdate.count == 1)
XCTAssert(callback.lastUpdate.first?.restoring == false)
XCTAssert(callback.lastUpdate.first?.status != .restored)

plugin.restorePurchases { result in
switch result {
Expand All @@ -401,7 +401,7 @@ final class InAppPurchase2PluginTests: XCTestCase {
await fulfillment(of: [restoreExpectation], timeout: 5)

XCTAssert(callback.lastUpdate.count == 1)
XCTAssert(callback.lastUpdate.first?.restoring == true)
XCTAssert(callback.lastUpdate.first?.status == .restored)
}

func testFinishTransaction() async throws {
Expand Down
Loading