Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.4.6+3

* Fixes `finish()` method to always call its completion handler in StoreKit2.
* `finish()` now returns success when the transaction is not found in unfinished transactions (since it's already complete).
* Adds `fetchUnfinishedTransaction()` helper for looking up transactions that need to be completed.

## 0.4.6+2

* Updates to Pigeon 26.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,21 @@ extension InAppPurchasePlugin: InAppPurchase2API {
/// Wrapper method around StoreKit2's finish() method https://developer.apple.com/documentation/storekit/transaction/3749694-finish
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void) {
Task {
let transaction = try await fetchTransaction(by: UInt64(id))
if let transaction = transaction {
await transaction.finish()
do {
let transaction = try await fetchUnfinishedTransaction(by: UInt64(id))
if let transaction = transaction {
await transaction.finish()
}
// If transaction is not found, it means it's already been finished.
// This is a success case - the transaction is complete.
completion(.success(Void()))
} catch {
completion(
.failure(
PigeonError(
code: "storekit2_finish_transaction_failed",
message: "Failed to finish transaction: \(error.localizedDescription)",
details: "Transaction ID: \(id)")))
}
}
}
Expand Down Expand Up @@ -362,7 +373,24 @@ extension InAppPurchasePlugin: InAppPurchase2API {
return transactions
}

/// Helper function to fetch specific transaction
/// Helper function to fetch specific transaction by ID from unfinished transactions.
/// Used by finish() to find transactions that need to be completed.
func fetchUnfinishedTransaction(by id: UInt64) async throws -> Transaction? {
for await result in Transaction.unfinished {
switch result {
case .verified(let transaction):
if transaction.id == id {
return transaction
}
case .unverified:
continue
}
}
return nil
}

/// Helper function to fetch specific transaction by ID from all transactions.
/// Used for general transaction lookups.
func fetchTransaction(by id: UInt64) async throws -> Transaction? {
for await result in Transaction.all {
switch result {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,67 @@ final class InAppPurchase2PluginTests: XCTestCase {
await fulfillment(of: [finishExpectation], timeout: 5)
}

func testFinishNonExistentTransactionReturnsSuccess() async throws {
// Test that finishing a non-existent transaction returns success
// (since if it's not in unfinished transactions, it's already complete)
let finishExpectation = self.expectation(
description: "Finishing non-existent transaction should return success")

plugin.finish(id: 999999) { result in
switch result {
case .success():
finishExpectation.fulfill()
case .failure(let error):
XCTFail("Finish should succeed for non-existent transaction: \(error)")
}
}

await fulfillment(of: [finishExpectation], timeout: 5)
}

func testConsumableCanBeRepurchasedAfterFinish() async throws {
// Test that a consumable can be purchased again after finishing
let firstPurchaseExpectation = self.expectation(description: "First purchase should succeed")
let finishExpectation = self.expectation(description: "Finishing purchase should succeed")
let secondPurchaseExpectation = self.expectation(description: "Second purchase should succeed")

// First purchase
plugin.purchase(id: "consumable", options: nil) { result in
switch result {
case .success:
firstPurchaseExpectation.fulfill()
case .failure(let error):
XCTFail("First purchase should NOT fail. Failed with \(error)")
}
}

await fulfillment(of: [firstPurchaseExpectation], timeout: 5)

// Finish the transaction
plugin.finish(id: 0) { result in
switch result {
case .success():
finishExpectation.fulfill()
case .failure(let error):
XCTFail("Finish should NOT fail. Failed with \(error)")
}
}

await fulfillment(of: [finishExpectation], timeout: 5)
Comment on lines +454 to +464

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a hardcoded transaction ID 0 makes this test fragile and dependent on the specific behavior of the test environment. A more robust approach would be to dynamically fetch the transaction created by the first purchase and use its actual ID. This ensures the test is more reliable and less likely to break with changes to the test session implementation.

    let transactionsExpectation = self.expectation(description: "Get transactions")
    var transactionId: Int64?
    plugin.transactions { result in
      switch result {
      case .success(let transactions):
        transactionId = transactions.first?.id
        transactionsExpectation.fulfill()
      case .failure(let error):
        XCTFail("Getting transactions should NOT fail. Failed with \(error)")
        transactionsExpectation.fulfill()
      }
    }
    await fulfillment(of: [transactionsExpectation], timeout: 5)

    guard let idToFinish = transactionId else {
      XCTFail("Could not get transaction ID to finish")
      return
    }

    // Finish the transaction
    plugin.finish(id: idToFinish) { result in
      switch result {
      case .success():
        finishExpectation.fulfill()
      case .failure(let error):
        XCTFail("Finish should NOT fail. Failed with \(error)")
      }
    }

    await fulfillment(of: [finishExpectation], timeout: 5)


// Second purchase - this should also succeed
plugin.purchase(id: "consumable", options: nil) { result in
switch result {
case .success:
secondPurchaseExpectation.fulfill()
case .failure(let error):
XCTFail("Second purchase should NOT fail. Failed with \(error)")
}
}

await fulfillment(of: [secondPurchaseExpectation], timeout: 5)
}

@available(iOS 18.0, macOS 15.0, *)
func testIsWinBackOfferEligibleEligible() async throws {
let purchaseExpectation = self.expectation(description: "Purchase should succeed")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.6+3

environment:
sdk: ^3.9.0
Expand Down