Skip to content

[in_app_purchase_storekit] purchase() and Transaction.updates both call sendTransactionUpdate(.purchased) causing duplicate “purchased” events and early purchased state #183583

Closed

Description

@LatCui

Steps to reproduce

in_app_purchase_storekit: ^0.4.8+1 and in_app_purchase: ^3.2.3

1.Enable StoreKit2 path
2.Start listening to the purchase stream in Dart

InAppPurchase.instance.purchaseStream.listen((purchases) {
  // log or handle purchase events here
});

3.Trigger a StoreKit2 purchase

final result = await InAppPurchase.instance.buyNonConsumable(
  purchaseParam: purchaseParam,
);

Expected results

Expected single-purchase flow:

After calling purchase(id:options:):
You’ll receive only one “purchase successful” event. The business side can safely proceed with shipping based on this event (e.g., awarding in-game coins).
Transaction.updates is used for:
Handling scenarios involving purchases made outside the app, on other devices, or through restore functionality;
or handling historical/unfinished transactions.
It will not send another .purchased event for the same purchase within the current session that duplicates the one sent by purchase().
Expected status semantics:

status: .purchased indicates:
“This transaction has already been completed on the Store side and can be considered final at the game’s business layer, allowing for safe shipment.”
If certain callbacks merely indicate that “the transaction has been created/updated on the Store side but still requires a secondary confirmation UI logic within the game,” then these callbacks should have distinct semantics (or clearly documented instructions requiring the business layer to handle idempotency themselves).

Actual results

Under the StoreKit2 mode, the same purchase will trigger two calls to sendTransactionUpdate(..., status: .purchased):

  1. First time: Inside the function "purchase(id:options:)"
let result = try await product.purchase(options: purchaseOptions)

switch result {
case .success(let verification):
  sendTransactionUpdate(
    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.")
}
  1. Second time: Within the Transaction.updates event listened for in the startListeningToTransactions() method
func startListeningToTransactions() throws {
  self.setListenerTaskAsTask(
    task: Task { [weak self] in
      for await verificationResult in Transaction.updates {
        switch verificationResult {
        case .verified(let transaction):
          self?.sendTransactionUpdate(
            productId: transaction.productID,
            transaction: transaction,
            receipt: verificationResult.jwsRepresentation,
            status: .purchased
          )
        case .unverified:
          break
        }
      }
    })
}

The purchaseStream on the Dart side (or the upper-level IAP management logic) will receive two nearly identical "purchased" events:
If the business logic is "ship the goods and add gold whenever status == purchased", then the same transaction will be shipped twice.
A more crucial point is:
In actual experience, the second .purchased callback from Transaction.updates sometimes triggers before the "final confirmation dialog" of our own game:
From a business perspective, this event is more similar to the "purchasing" or "pending transactions" in SK1;
However, it is currently uniformly labeled as ".purchased", and it is difficult for the business side to distinguish between "final success for shipment" and "updates during the intermediate process" solely based on the "status".

Code sample

Code sample
// lib/src/in_app_purchase_storekit_platform.dart

if (_useStoreKit2) {
  final updateController2 =
      StreamController<List<PurchaseDetails>>.broadcast(
        onListen: () => SK2Transaction.startListeningToTransactions(),
        onCancel: () => SK2Transaction.stopListeningToTransactions(),
      );
  _sk2transactionObserver = SK2TransactionObserverWrapper(
    transactionsCreatedController: updateController2,
  );
  InAppPurchase2CallbackAPI.setUp(_sk2transactionObserver);
} else {
  // SK1 ...
}

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.32.7, on macOS 26.3.1 25D2128 darwin-arm64, locale zh-Hans-CN)
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.1)
[✓] Xcode - develop for iOS and macOS (Xcode 26.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2025.2)
[✓] VS Code (version 1.111.0)
[✓] Connected device (7 available)
    ! Error: Browsing on the local area network for Ricky’s iPhone. Ensure the device is unlocked and attached with a cable or
      associated with the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
    ! Error: Browsing on the local area network for iPhone. Ensure the device is unlocked and attached with a cable or associated with
      the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
    ! Error: Browsing on the local area network for 文涵Team ipad. Ensure the device is unlocked and attached with a cable or associated
      with the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
    ! Error: Browsing on the local area network for chuanzhe2’s iPhone. Ensure the device is unlocked and attached with a cable or
      associated with the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
    ! Error: Browsing on the local area network for xuebin000. Ensure the device is unlocked and attached with a cable or associated
      with the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
    ! Error: Browsing on the local area network for chuanzhe. Ensure the device is unlocked and attached with a cable or associated with
      the same local area network as this Mac.
      The device must be opted into Developer Mode to connect wirelessly. (code -27)
[✓] Network resources

• No issues found!

Activity

  1. changed the title [-][StoreKit2] purchase() and Transaction.updates both call sendTransactionUpdate(.purchased) causing duplicate “purchased” events and early purchased state[/-] [+][in_app_purchase_storekit] purchase() and Transaction.updates both call sendTransactionUpdate(.purchased) causing duplicate “purchased” events and early purchased state[/+] on Mar 12, 2026
  2. saurabh-mirajkar commented on Mar 16, 2026

    @saurabh-mirajkar
    Contributor

    @LatCui Thanks for the report.

    To help investigate further, could you please provide:

    • The Flutter version (flutter doctor -v)
    • The in_app_purchase plugin version
    • Whether the transaction identifiers for the two events are identical
    • A minimal reproducible example

    Also, parts of the issue description are not in English. Per the Flutter issue hygiene guidelines, please consider updating the report in English so more contributors can help investigate:
    https://github.com/flutter/flutter/blob/master/docs/contributing/issue_hygiene/README.md#consider-posting-issues-in-english

  3. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    and removed
    in triagePresently being triaged by the triage team
    on Mar 16, 2026
  4. github-actions commented on Apr 6, 2026

    @github-actions

    Without additional information, we are unfortunately not sure how to resolve this issue. We are therefore reluctantly going to close this bug for now.
    If you find this problem please file a new issue with the same description, what happens, logs and the output of 'flutter doctor -v'. All system setups can be slightly different so it's always better to open new issues and reference the related ones.
    Thanks for your contribution.

  5. github-actions commented on Apr 20, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  6. locked as resolved and limited conversation to collaborators on Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions