From 4df9efc8f43f990a152641f7d513ca788538fc12 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Tue, 12 May 2026 18:59:41 +0900
Subject: [PATCH 01/16] fix(apple): dedupe purchase update emissions
Track emitted iOS purchase transaction IDs for the current connection session so StoreKit replays cannot fire the same purchase update twice through requestPurchase and Transaction.updates paths.
Refs #152
---
packages/apple/Sources/Helpers/IapState.swift | 24 +++++++++++++++
packages/apple/Sources/OpenIapModule.swift | 29 +++++++++++++++----
packages/apple/Tests/OpenIapTests.swift | 28 ++++++++++++++++++
3 files changed, 75 insertions(+), 6 deletions(-)
diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift
index 310cac01..de4a92bc 100644
--- a/packages/apple/Sources/Helpers/IapState.swift
+++ b/packages/apple/Sources/Helpers/IapState.swift
@@ -8,8 +8,11 @@ import StoreKit
actor IapState {
private(set) var isInitialized: Bool = false
private var pendingTransactions: [String: Transaction] = [:]
+ private var emittedPurchaseUpdateIds: Set = []
+ private var emittedPurchaseUpdateOrder: [String] = []
private var promotedProductId: String?
private var pendingPromotedProductReplayId: String?
+ private let purchaseUpdateEmissionLimit = 512
// Event listeners
private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = []
@@ -21,6 +24,8 @@ actor IapState {
func setInitialized(_ value: Bool) { isInitialized = value }
func reset() {
pendingTransactions.removeAll()
+ emittedPurchaseUpdateIds.removeAll()
+ emittedPurchaseUpdateOrder.removeAll()
isInitialized = false
promotedProductId = nil
pendingPromotedProductReplayId = nil
@@ -31,6 +36,25 @@ actor IapState {
func getPending(id: String) -> Transaction? { pendingTransactions[id] }
func removePending(id: String) { pendingTransactions.removeValue(forKey: id) }
func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) }
+ func storePendingAndRecordPurchaseUpdateEmission(id: String, transaction: Transaction) -> Bool {
+ pendingTransactions[id] = transaction
+ return recordPurchaseUpdateEmission(id: id)
+ }
+
+ // MARK: - Purchase Update Emissions
+ func recordPurchaseUpdateEmission(id: String) -> Bool {
+ guard !emittedPurchaseUpdateIds.contains(id) else {
+ return false
+ }
+
+ emittedPurchaseUpdateIds.insert(id)
+ emittedPurchaseUpdateOrder.append(id)
+ if emittedPurchaseUpdateOrder.count > purchaseUpdateEmissionLimit {
+ let removed = emittedPurchaseUpdateOrder.removeFirst()
+ emittedPurchaseUpdateIds.remove(removed)
+ }
+ return true
+ }
// MARK: - Promoted Products
func setPromotedProductId(_ id: String?) {
diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift
index db22f686..2f054f2c 100644
--- a/packages/apple/Sources/OpenIapModule.swift
+++ b/packages/apple/Sources/OpenIapModule.swift
@@ -376,16 +376,25 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
- Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly")
""")
+ let shouldEmit: Bool
if shouldAutoFinish {
await transaction.finish()
+ shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId)
} else {
- await state.storePending(id: transactionId, transaction: transaction)
+ shouldEmit = await state.storePendingAndRecordPurchaseUpdateEmission(
+ id: transactionId,
+ transaction: transaction
+ )
}
// Emit purchase update
- // Note: Transaction.updates will NOT fire for purchases initiated via product.purchase()
- // It only fires for background events (renewals, restores, external purchases)
- emitPurchaseUpdate(purchase)
+ // StoreKit can replay unfinished transactions through multiple paths during a
+ // connection session; only emit each transaction id once.
+ if shouldEmit {
+ emitPurchaseUpdate(purchase)
+ } else {
+ OpenIapLog.debug("⏭️ Skipping duplicate purchase update: \(transactionId)")
+ }
return .purchase(purchase)
@@ -1633,8 +1642,16 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
continue
}
- // Store pending and emit
- await self.state.storePending(id: transactionId, transaction: transaction)
+ // Store pending and emit once per transaction id for this connection session.
+ let shouldEmit = await self.state.storePendingAndRecordPurchaseUpdateEmission(
+ id: transactionId,
+ transaction: transaction
+ )
+ guard shouldEmit else {
+ OpenIapLog.debug("⏭️ [TransactionListener] Skipping duplicate transaction: \(transactionId)")
+ continue
+ }
+
let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation)
OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)")
diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift
index f4a6c768..61f67b2b 100644
--- a/packages/apple/Tests/OpenIapTests.swift
+++ b/packages/apple/Tests/OpenIapTests.swift
@@ -85,6 +85,34 @@ final class OpenIapTests: XCTestCase {
XCTAssertNil(pendingSku)
}
+ @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
+ func testPurchaseUpdateEmissionDeduplicatesTransactionIds() async {
+ let state = IapState()
+
+ let firstEmission = await state.recordPurchaseUpdateEmission(id: "txn-1")
+ let duplicateEmission = await state.recordPurchaseUpdateEmission(id: "txn-1")
+ let nextEmission = await state.recordPurchaseUpdateEmission(id: "txn-2")
+
+ XCTAssertTrue(firstEmission)
+ XCTAssertFalse(duplicateEmission)
+ XCTAssertTrue(nextEmission)
+ }
+
+ @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
+ func testPurchaseUpdateEmissionResetAllowsReconnectReplay() async {
+ let state = IapState()
+ let firstEmission = await state.recordPurchaseUpdateEmission(id: "txn-1")
+ let duplicateEmission = await state.recordPurchaseUpdateEmission(id: "txn-1")
+
+ XCTAssertTrue(firstEmission)
+ XCTAssertFalse(duplicateEmission)
+
+ await state.reset()
+
+ let replayEmission = await state.recordPurchaseUpdateEmission(id: "txn-1")
+ XCTAssertTrue(replayEmission)
+ }
+
func testPurchaseIOSWithRenewalInfo() {
let renewalInfo = RenewalInfoIOS(
autoRenewPreference: "dev.hyo.premium_year",
From b9d704ad4cb67dc22873db15a16b3cb6d7de6c17 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 13 May 2026 12:17:10 +0900
Subject: [PATCH 02/16] fix(apple): avoid stale duplicate pending updates
---
packages/apple/Sources/Helpers/IapState.swift | 51 +++++++++++--------
packages/apple/Sources/OpenIapModule.swift | 20 +++-----
2 files changed, 38 insertions(+), 33 deletions(-)
diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift
index de4a92bc..3141ee33 100644
--- a/packages/apple/Sources/Helpers/IapState.swift
+++ b/packages/apple/Sources/Helpers/IapState.swift
@@ -4,15 +4,41 @@ import StoreKit
/// Thread-safe state manager for IAP transactions
/// - SeeAlso: https://developer.apple.com/documentation/storekit/transaction
+@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
+private struct PurchaseUpdateEmissionHistory {
+ private let limit: Int
+ private var ids: Set = []
+ private var order: [String] = []
+
+ init(limit: Int) {
+ self.limit = limit
+ }
+
+ mutating func record(_ id: String) -> Bool {
+ guard ids.insert(id).inserted else {
+ return false
+ }
+
+ order.append(id)
+ if order.count > limit {
+ ids.remove(order.removeFirst())
+ }
+ return true
+ }
+
+ mutating func removeAll() {
+ ids.removeAll()
+ order.removeAll()
+ }
+}
+
@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
actor IapState {
private(set) var isInitialized: Bool = false
private var pendingTransactions: [String: Transaction] = [:]
- private var emittedPurchaseUpdateIds: Set = []
- private var emittedPurchaseUpdateOrder: [String] = []
+ private var purchaseUpdateEmissionHistory = PurchaseUpdateEmissionHistory(limit: 512)
private var promotedProductId: String?
private var pendingPromotedProductReplayId: String?
- private let purchaseUpdateEmissionLimit = 512
// Event listeners
private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = []
@@ -24,8 +50,7 @@ actor IapState {
func setInitialized(_ value: Bool) { isInitialized = value }
func reset() {
pendingTransactions.removeAll()
- emittedPurchaseUpdateIds.removeAll()
- emittedPurchaseUpdateOrder.removeAll()
+ purchaseUpdateEmissionHistory.removeAll()
isInitialized = false
promotedProductId = nil
pendingPromotedProductReplayId = nil
@@ -36,24 +61,10 @@ actor IapState {
func getPending(id: String) -> Transaction? { pendingTransactions[id] }
func removePending(id: String) { pendingTransactions.removeValue(forKey: id) }
func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) }
- func storePendingAndRecordPurchaseUpdateEmission(id: String, transaction: Transaction) -> Bool {
- pendingTransactions[id] = transaction
- return recordPurchaseUpdateEmission(id: id)
- }
// MARK: - Purchase Update Emissions
func recordPurchaseUpdateEmission(id: String) -> Bool {
- guard !emittedPurchaseUpdateIds.contains(id) else {
- return false
- }
-
- emittedPurchaseUpdateIds.insert(id)
- emittedPurchaseUpdateOrder.append(id)
- if emittedPurchaseUpdateOrder.count > purchaseUpdateEmissionLimit {
- let removed = emittedPurchaseUpdateOrder.removeFirst()
- emittedPurchaseUpdateIds.remove(removed)
- }
- return true
+ purchaseUpdateEmissionHistory.record(id)
}
// MARK: - Promoted Products
diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift
index 2f054f2c..b8089472 100644
--- a/packages/apple/Sources/OpenIapModule.swift
+++ b/packages/apple/Sources/OpenIapModule.swift
@@ -376,15 +376,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
- Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly")
""")
- let shouldEmit: Bool
+ let shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId)
if shouldAutoFinish {
await transaction.finish()
- shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId)
- } else {
- shouldEmit = await state.storePendingAndRecordPurchaseUpdateEmission(
- id: transactionId,
- transaction: transaction
- )
+ await state.removePending(id: transactionId)
+ } else if shouldEmit {
+ await state.storePending(id: transactionId, transaction: transaction)
}
// Emit purchase update
@@ -1642,15 +1639,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
continue
}
- // Store pending and emit once per transaction id for this connection session.
- let shouldEmit = await self.state.storePendingAndRecordPurchaseUpdateEmission(
- id: transactionId,
- transaction: transaction
- )
- guard shouldEmit else {
+ // Emit once per transaction id for this connection session.
+ guard await self.state.recordPurchaseUpdateEmission(id: transactionId) else {
OpenIapLog.debug("⏭️ [TransactionListener] Skipping duplicate transaction: \(transactionId)")
continue
}
+ await self.state.storePending(id: transactionId, transaction: transaction)
let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation)
From f51ad6df19f942a559aaf093c91a2ca9ffd9cdca Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 13 May 2026 12:27:57 +0900
Subject: [PATCH 03/16] fix(apple): improve duplicate update logging
---
packages/apple/Sources/OpenIapModule.swift | 27 ++++++++++++++++++++--
1 file changed, 25 insertions(+), 2 deletions(-)
diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift
index b8089472..2bef865d 100644
--- a/packages/apple/Sources/OpenIapModule.swift
+++ b/packages/apple/Sources/OpenIapModule.swift
@@ -390,7 +390,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
if shouldEmit {
emitPurchaseUpdate(purchase)
} else {
- OpenIapLog.debug("⏭️ Skipping duplicate purchase update: \(transactionId)")
+ logDuplicatePurchaseUpdateSuppressed(
+ source: "requestPurchase",
+ transactionId: transactionId,
+ productId: transaction.productID
+ )
}
return .purchase(purchase)
@@ -1641,7 +1645,11 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
// Emit once per transaction id for this connection session.
guard await self.state.recordPurchaseUpdateEmission(id: transactionId) else {
- OpenIapLog.debug("⏭️ [TransactionListener] Skipping duplicate transaction: \(transactionId)")
+ self.logDuplicatePurchaseUpdateSuppressed(
+ source: "Transaction.updates",
+ transactionId: transactionId,
+ productId: transaction.productID
+ )
continue
}
await self.state.storePending(id: transactionId, transaction: transaction)
@@ -1781,6 +1789,21 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
}
}
+ private func logDuplicatePurchaseUpdateSuppressed(
+ source: String,
+ transactionId: String,
+ productId: String
+ ) {
+ OpenIapLog.warn("""
+ [PurchaseUpdateDedup] Suppressed duplicate purchase-updated listener emission.
+ - Source: \(source)
+ - Product: \(productId)
+ - Transaction ID: \(transactionId)
+ - Reason: this transaction id was already emitted during the current connection session.
+ - Scope: only identical transaction ids are suppressed; distinct StoreKit transactions still emit.
+ """)
+ }
+
private func emitPurchaseUpdate(_ purchase: Purchase) {
Task { [state] in
let listeners = await state.snapshotPurchaseUpdated()
From 4baa12489d6642334e71fd6a73ea42e729d43106 Mon Sep 17 00:00:00 2001
From: hyochan
Date: Wed, 13 May 2026 13:22:05 +0900
Subject: [PATCH 04/16] feat(spec): add purchase update listener options
---
libraries/expo-iap/ios/ExpoIapHelper.swift | 23 +++-
libraries/expo-iap/ios/ExpoIapModule.swift | 1 +
.../expo-iap/src/__tests__/index.test.ts | 12 ++
libraries/expo-iap/src/index.ts | 18 +--
libraries/expo-iap/src/types.ts | 22 +++-
libraries/expo-iap/src/useIAP.ts | 9 ++
.../Classes/FlutterInappPurchasePlugin.swift | 29 ++--
.../lib/flutter_inapp_purchase.dart | 83 ++++++++----
.../flutter_inapp_purchase/lib/types.dart | 35 ++++-
.../godot-iap/addons/godot-iap/godot_iap.gd | 26 ++++
libraries/godot-iap/addons/godot-iap/types.gd | 16 +++
.../godot-iap/ios-gdextension/Package.swift | 20 ++-
.../Sources/GodotIap/GodotIap.swift | 36 ++++-
libraries/kmp-iap/library/build.gradle.kts | 9 +-
.../hyochan/kmpiap/InAppPurchaseAndroid.kt | 6 +-
.../kotlin/io/github/hyochan/kmpiap/KmpIap.kt | 10 ++
.../io/github/hyochan/kmpiap/openiap/Types.kt | 29 +++-
.../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 39 +++++-
.../ApiDefinition.cs | 5 +
.../maui-iap/src/OpenIap.Maui/OpenIap.cs | 8 ++
.../Platforms/Android/OpenIapAndroid.cs | 2 +
.../OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs | 29 ++++
libraries/maui-iap/src/OpenIap.Maui/Types.cs | 15 ++-
.../src/OpenIap.Maui/UnsupportedOpenIap.cs | 1 +
.../java/com/margelo/nitro/iap/HybridRnIap.kt | 5 +-
.../react-native-iap/ios/HybridRnIap.swift | 108 +++++++++++----
.../src/__tests__/index.test.ts | 31 +++++
.../react-native-iap/src/hooks/useIAP.ts | 8 ++
libraries/react-native-iap/src/index.ts | 77 +++++++++--
.../react-native-iap/src/specs/RnIap.nitro.ts | 8 +-
libraries/react-native-iap/src/types.ts | 22 +++-
openiap-versions.json | 2 +-
packages/apple/Sources/Helpers/IapState.swift | 47 +++++--
packages/apple/Sources/Models/Types.swift | 21 ++-
.../apple/Sources/OpenIapModule+ObjC.swift | 15 ++-
packages/apple/Sources/OpenIapModule.swift | 91 ++++++++-----
packages/apple/Sources/OpenIapProtocol.swift | 5 +-
packages/apple/Sources/OpenIapStore.swift | 4 +-
packages/apple/Tests/OpenIapTests.swift | 24 ++++
.../OpenIapTests/VerifyPurchaseTests.swift | 5 +-
.../VerifyPurchaseWithProviderTests.swift | 5 +-
packages/docs/openiap-versions.json | 2 +-
packages/docs/src/lib/searchData.ts | 8 ++
.../docs/events/purchase-updated-listener.tsx | 85 ++++++++++--
packages/docs/src/pages/docs/index.tsx | 9 ++
packages/docs/src/pages/docs/types/index.tsx | 7 +
.../purchase-updated-listener-options.tsx | 124 ++++++++++++++++++
.../docs/src/pages/docs/updates/releases.tsx | 112 ++++++++++++++++
.../src/main/java/dev/hyo/openiap/Types.kt | 29 +++-
packages/gql/src/event.graphql | 5 +-
packages/gql/src/generated/Types.cs | 15 ++-
packages/gql/src/generated/Types.kt | 29 +++-
packages/gql/src/generated/Types.swift | 21 ++-
packages/gql/src/generated/types.dart | 35 ++++-
packages/gql/src/generated/types.gd | 16 +++
packages/gql/src/generated/types.ts | 22 +++-
packages/gql/src/type.graphql | 11 ++
57 files changed, 1322 insertions(+), 169 deletions(-)
create mode 100644 packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx
diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift
index 4d077821..7baf92b2 100644
--- a/libraries/expo-iap/ios/ExpoIapHelper.swift
+++ b/libraries/expo-iap/ios/ExpoIapHelper.swift
@@ -128,6 +128,7 @@ enum ExpoIapHelper {
static func setupListeners(
module: ExpoIapModule,
purchaseUpdated: @escaping (Purchase) -> Void,
+ purchaseUpdatedDuplicatesIOS: @escaping (Purchase) -> Void,
purchaseError: @escaping (PurchaseError) -> Void,
promotedProduct: @escaping (String) async -> Void,
subscriptionBillingIssue: @escaping (Purchase) -> Void
@@ -141,6 +142,15 @@ enum ExpoIapHelper {
}
}
+ let duplicateOptions = PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: true
+ )
+ let purchaseUpdatedDuplicatesSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in
+ Task { @MainActor in
+ purchaseUpdatedDuplicatesIOS(purchase)
+ }
+ }, options: duplicateOptions)
+
let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in
Task { @MainActor in
purchaseError(error)
@@ -159,7 +169,13 @@ enum ExpoIapHelper {
}
}
- listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub, billingIssueSub]
+ listeners = [
+ purchaseUpdatedSub,
+ purchaseUpdatedDuplicatesSub,
+ purchaseErrorSub,
+ promotedProductSub,
+ billingIssueSub,
+ ]
}
static func cleanupListeners() {
@@ -176,6 +192,11 @@ enum ExpoIapHelper {
let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase))
module.sendEvent(OpenIapEvent.purchaseUpdated.rawValue, payload)
},
+ purchaseUpdatedDuplicatesIOS: { [weak module] purchase in
+ guard let module else { return }
+ let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase))
+ module.sendEvent("purchase-updated-duplicates-ios", payload)
+ },
purchaseError: { [weak module] error in
guard let module else { return }
let payload = sanitizeDictionary(OpenIapSerialization.encode(error))
diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift
index c220f614..8fb88476 100644
--- a/libraries/expo-iap/ios/ExpoIapModule.swift
+++ b/libraries/expo-iap/ios/ExpoIapModule.swift
@@ -20,6 +20,7 @@ public final class ExpoIapModule: Module {
Events(
OpenIapEvent.purchaseUpdated.rawValue,
+ "purchase-updated-duplicates-ios",
OpenIapEvent.purchaseError.rawValue,
OpenIapEvent.promotedProductIos.rawValue,
OpenIapEvent.subscriptionBillingIssue.rawValue
diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts
index 3b822f45..1fba955d 100644
--- a/libraries/expo-iap/src/__tests__/index.test.ts
+++ b/libraries/expo-iap/src/__tests__/index.test.ts
@@ -71,6 +71,18 @@ describe('Public API (index.ts)', () => {
expect(fn).toHaveBeenCalledWith({...event, platform: 'ios'});
});
+ it('registers duplicate-enabled purchase updated listener on iOS', () => {
+ const addListener = (ExpoIapModule as any).addListener as jest.Mock;
+ const fn = jest.fn();
+ purchaseUpdatedListener(fn, {
+ includeDuplicateTransactionUpdatesIOS: true,
+ });
+ expect(addListener).toHaveBeenCalledWith(
+ OpenIapEvent.PurchaseUpdatedDuplicateIOS,
+ expect.any(Function),
+ );
+ });
+
it('registers purchase error listener', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const fn = jest.fn();
diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts
index bf37bf84..e015633c 100644
--- a/libraries/expo-iap/src/index.ts
+++ b/libraries/expo-iap/src/index.ts
@@ -30,6 +30,7 @@ import type {
ProductSubscription,
Purchase,
PurchaseOptions,
+ PurchaseUpdatedListenerOptions,
QueryField,
RequestPurchasePropsByPlatforms,
RequestPurchaseAndroidProps,
@@ -51,6 +52,7 @@ export * from './onside';
// Get the native constant value
export enum OpenIapEvent {
PurchaseUpdated = 'purchase-updated',
+ PurchaseUpdatedDuplicateIOS = 'purchase-updated-duplicates-ios',
PurchaseError = 'purchase-error',
PromotedProductIOS = 'promoted-product-ios',
UserChoiceBillingAndroid = 'user-choice-billing-android',
@@ -69,6 +71,7 @@ export enum OpenIapEvent {
type ExpoIapEventPayloads = {
[OpenIapEvent.PurchaseUpdated]: Purchase;
+ [OpenIapEvent.PurchaseUpdatedDuplicateIOS]: Purchase;
[OpenIapEvent.PurchaseError]: PurchaseError;
[OpenIapEvent.PromotedProductIOS]:
| Product
@@ -159,15 +162,17 @@ const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] =>
export const purchaseUpdatedListener = (
listener: (event: Purchase) => void,
+ options?: PurchaseUpdatedListenerOptions | null,
) => {
const wrappedListener = (event: Purchase) => {
const normalized = normalizePurchasePlatform(event);
listener(normalized);
};
- const emitterSubscription = emitter.addListener(
- OpenIapEvent.PurchaseUpdated,
- wrappedListener,
- );
+ const eventName =
+ Platform.OS === 'ios' && options?.includeDuplicateTransactionUpdatesIOS
+ ? OpenIapEvent.PurchaseUpdatedDuplicateIOS
+ : OpenIapEvent.PurchaseUpdated;
+ const emitterSubscription = emitter.addListener(eventName, wrappedListener);
return emitterSubscription;
};
@@ -1099,10 +1104,7 @@ export type {
UseWebhookEventsOptions,
UseWebhookEventsResult,
} from './useWebhookEvents';
-export {
- connectWebhookStream,
- parseWebhookEventData,
-} from './webhook-client';
+export {connectWebhookStream, parseWebhookEventData} from './webhook-client';
export type {
WebhookEventPayload,
WebhookEventStream,
diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts
index 7eb00860..4432d3fe 100644
--- a/libraries/expo-iap/src/types.ts
+++ b/libraries/expo-iap/src/types.ts
@@ -1292,6 +1292,16 @@ export interface PurchaseOptions {
export type PurchaseState = 'pending' | 'purchased' | 'unknown';
+export interface PurchaseUpdatedListenerOptions {
+ /**
+ * iOS only. When true, listener callbacks also receive StoreKit replay events
+ * for a transaction ID that was already emitted during the current connection
+ * session. Defaults to false so purchase success handlers run once per
+ * transaction ID.
+ */
+ includeDuplicateTransactionUpdatesIOS?: (boolean | null);
+}
+
export type PurchaseVerificationProvider = 'iapkit';
export interface Query {
@@ -1734,7 +1744,12 @@ export interface Subscription {
promotedProductIOS: string;
/** Fires when a purchase fails or is cancelled */
purchaseError: PurchaseError;
- /** Fires when a purchase completes successfully or a pending purchase resolves */
+ /**
+ * Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
+ */
purchaseUpdated: Purchase;
/**
* Fires when an active subscription enters a billing-issue state that needs user action
@@ -1758,6 +1773,9 @@ export interface Subscription {
}
+
+export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined;
+
export interface SubscriptionInfoIOS {
introductoryOffer?: (SubscriptionOfferIOS | null);
promotionalOffers?: (SubscriptionOfferIOS[] | null);
@@ -2218,7 +2236,7 @@ export type SubscriptionArgsMap = {
developerProvidedBillingAndroid: never;
promotedProductIOS: never;
purchaseError: never;
- purchaseUpdated: never;
+ purchaseUpdated: SubscriptionPurchaseUpdatedArgs;
subscriptionBillingIssue: never;
userChoiceBillingAndroid: never;
};
diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts
index 90853a68..4110eefb 100644
--- a/libraries/expo-iap/src/useIAP.ts
+++ b/libraries/expo-iap/src/useIAP.ts
@@ -43,6 +43,7 @@ import type {
Purchase,
MutationRequestPurchaseArgs,
PurchaseInput,
+ PurchaseUpdatedListenerOptions,
VerifyPurchaseProps,
VerifyPurchaseResult,
VerifyPurchaseWithProviderProps,
@@ -115,6 +116,12 @@ type UseIap = {
export interface UseIAPOptions {
onPurchaseSuccess?: (purchase: Purchase) => void;
onPurchaseError?: (error: PurchaseError) => void;
+ /**
+ * iOS only. When enabled, the purchase success listener also receives
+ * StoreKit replay events for a transaction ID already delivered during the
+ * current connection session. Defaults to false.
+ */
+ purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null;
/**
* Callback for general errors from hook methods like fetchProducts,
* getAvailablePurchases, getActiveSubscriptions, restorePurchases, etc.
@@ -628,6 +635,7 @@ export function useIAP(options?: UseIAPOptions): UseIap {
optionsRef.current.onPurchaseSuccess(purchase);
}
},
+ optionsRef.current?.purchaseUpdatedListenerOptions,
);
// Register purchase error listener EARLY. Ignore init-related errors until connected.
@@ -709,6 +717,7 @@ export function useIAP(options?: UseIAPOptions): UseIap {
optionsRef.current.onPurchaseSuccess(purchase);
}
},
+ optionsRef.current?.purchaseUpdatedListenerOptions,
);
}
diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
index 166d072d..b7613144 100644
--- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
+++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift
@@ -14,6 +14,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
private var updateListenerTask: Task?
// OpenIAP listener tokens
private var purchaseUpdatedToken: OpenIAP.Subscription?
+ private var purchaseUpdatedDuplicateToken: OpenIAP.Subscription?
private var purchaseErrorToken: OpenIAP.Subscription?
private var promotedProductToken: OpenIAP.Subscription?
private var subscriptionBillingIssueToken: OpenIAP.Subscription?
@@ -403,16 +404,16 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
FlutterIapLog.debug("Setting up OpenIAP listeners")
purchaseUpdatedToken = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in
- Task { @MainActor in
- guard let self else { return }
- FlutterIapLog.debug("purchaseUpdatedListener fired for \(purchase.productId)")
- let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
- if let jsonString = FlutterIapHelper.jsonString(from: payload) {
- self.channel?.invokeMethod("purchase-updated", arguments: jsonString)
- }
- }
+ self?.emitPurchaseUpdated(purchase, method: "purchase-updated")
}
+ let duplicateOptions = PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: true
+ )
+ purchaseUpdatedDuplicateToken = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] purchase in
+ self?.emitPurchaseUpdated(purchase, method: "purchase-updated-duplicates-ios")
+ }, options: duplicateOptions)
+
purchaseErrorToken = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
Task { @MainActor in
guard let self else { return }
@@ -456,14 +457,26 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin {
private func removeOpenIapListeners() {
if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) }
+ if let token = purchaseUpdatedDuplicateToken { OpenIapModule.shared.removeListener(token) }
if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) }
if let token = promotedProductToken { OpenIapModule.shared.removeListener(token) }
if let token = subscriptionBillingIssueToken { OpenIapModule.shared.removeListener(token) }
purchaseUpdatedToken = nil
+ purchaseUpdatedDuplicateToken = nil
purchaseErrorToken = nil
promotedProductToken = nil
subscriptionBillingIssueToken = nil
}
+
+ private func emitPurchaseUpdated(_ purchase: Purchase, method: String) {
+ Task { @MainActor in
+ FlutterIapLog.debug("\(method) fired for \(purchase.productId)")
+ let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
+ if let jsonString = FlutterIapHelper.jsonString(from: payload) {
+ self.channel?.invokeMethod(method, arguments: jsonString)
+ }
+ }
+ }
// All transaction event handling is routed via OpenIapModule listeners
diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
index 27d418a0..f0ca37e4 100644
--- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
+++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart
@@ -89,6 +89,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
// Purchase event streams
final StreamController _purchaseUpdatedListener =
StreamController.broadcast();
+ final StreamController _purchaseUpdatedDuplicateListener =
+ StreamController.broadcast();
final StreamController _purchaseErrorListener =
StreamController.broadcast();
final StreamController
@@ -104,6 +106,20 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
Stream get purchaseUpdatedListener =>
_purchaseUpdatedListener.stream;
+ /// Purchase updated event stream with listener options.
+ ///
+ /// On iOS, set [PurchaseUpdatedListenerOptions.includeDuplicateTransactionUpdatesIOS]
+ /// to true to also receive StoreKit replay events for transaction IDs already
+ /// delivered during the current connection session. Android ignores this flag.
+ Stream purchaseUpdatedListenerWithOptions(
+ gentype.PurchaseUpdatedListenerOptions? options,
+ ) {
+ if (isIOS && options?.includeDuplicateTransactionUpdatesIOS == true) {
+ return _purchaseUpdatedDuplicateListener.stream;
+ }
+ return purchaseUpdatedListener;
+ }
+
/// Purchase error event stream
Stream get purchaseErrorListener =>
_purchaseErrorListener.stream;
@@ -138,28 +154,14 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
_channel.setMethodCallHandler((MethodCall call) async {
switch (call.method) {
case 'purchase-updated':
- try {
- Map result =
- jsonDecode(call.arguments as String) as Map;
-
- // Convert directly to Purchase without intermediate PurchasedItem
- final purchase = convertToPurchase(
- result,
- originalJson: result,
- platformIsAndroid: _platform.isAndroid,
- platformIsIOS: _platform.isIOS || _platform.isMacOS,
- acknowledgedAndroidPurchaseTokens:
- _acknowledgedAndroidPurchaseTokens,
- );
-
- _purchaseController!.add(purchase);
- _purchaseUpdatedListener.add(purchase);
- } catch (e, stackTrace) {
- debugPrint(
- '[flutter_inapp_purchase] ERROR in purchase-updated: $e',
- );
- debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace');
- }
+ _handlePurchaseUpdatedCall(
+ call,
+ _purchaseUpdatedListener,
+ publishToLegacyStream: true,
+ );
+ break;
+ case 'purchase-updated-duplicates-ios':
+ _handlePurchaseUpdatedCall(call, _purchaseUpdatedDuplicateListener);
break;
case 'purchase-error':
debugPrint(
@@ -246,6 +248,35 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
});
}
+ void _handlePurchaseUpdatedCall(
+ MethodCall call,
+ StreamController controller, {
+ bool publishToLegacyStream = false,
+ }) {
+ try {
+ final result =
+ jsonDecode(call.arguments as String) as Map;
+
+ final purchase = convertToPurchase(
+ result,
+ originalJson: result,
+ platformIsAndroid: _platform.isAndroid,
+ platformIsIOS: _platform.isIOS || _platform.isMacOS,
+ acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens,
+ );
+
+ if (publishToLegacyStream) {
+ _purchaseController!.add(purchase);
+ }
+ controller.add(purchase);
+ } catch (e, stackTrace) {
+ debugPrint(
+ '[flutter_inapp_purchase] ERROR in ${call.method}: $e',
+ );
+ debugPrint('[flutter_inapp_purchase] Stack trace: $stackTrace');
+ }
+ }
+
/// Initialize the store connection. Must be called before any other IAP API.
///
/// Parameters:
@@ -2619,7 +2650,13 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
},
purchaseError: () async =>
await purchaseErrorListener.first as gentype.PurchaseError,
- purchaseUpdated: () async => await purchaseUpdatedListener.first,
+ purchaseUpdated: ({bool? includeDuplicateTransactionUpdatesIOS}) =>
+ purchaseUpdatedListenerWithOptions(
+ gentype.PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS:
+ includeDuplicateTransactionUpdatesIOS,
+ ),
+ ).first,
subscriptionBillingIssue: () async =>
await subscriptionBillingIssueListener.first,
userChoiceBillingAndroid: () async =>
diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart
index 93997a8c..23f81af9 100644
--- a/libraries/flutter_inapp_purchase/lib/types.dart
+++ b/libraries/flutter_inapp_purchase/lib/types.dart
@@ -4347,6 +4347,30 @@ class PurchaseOptions {
}
}
+class PurchaseUpdatedListenerOptions {
+ const PurchaseUpdatedListenerOptions({
+ this.includeDuplicateTransactionUpdatesIOS,
+ });
+
+ /// iOS only. When true, listener callbacks also receive StoreKit replay events
+ /// for a transaction ID that was already emitted during the current connection
+ /// session. Defaults to false so purchase success handlers run once per
+ /// transaction ID.
+ final bool? includeDuplicateTransactionUpdatesIOS;
+
+ factory PurchaseUpdatedListenerOptions.fromJson(Map json) {
+ return PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: json['includeDuplicateTransactionUpdatesIOS'] as bool?,
+ );
+ }
+
+ Map toJson() {
+ return {
+ 'includeDuplicateTransactionUpdatesIOS': includeDuplicateTransactionUpdatesIOS,
+ };
+ }
+}
+
class RequestPurchaseAndroidProps {
const RequestPurchaseAndroidProps({
this.developerBillingOption,
@@ -5452,7 +5476,12 @@ abstract class SubscriptionResolver {
/// Fires when a purchase fails or is cancelled
Future purchaseError();
/// Fires when a purchase completes successfully or a pending purchase resolves
- Future purchaseUpdated();
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ Future purchaseUpdated({
+ bool? includeDuplicateTransactionUpdatesIOS,
+ });
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
///
@@ -5672,7 +5701,9 @@ class QueryHandlers {
typedef SubscriptionDeveloperProvidedBillingAndroidHandler = Future Function();
typedef SubscriptionPromotedProductIOSHandler = Future Function();
typedef SubscriptionPurchaseErrorHandler = Future Function();
-typedef SubscriptionPurchaseUpdatedHandler = Future Function();
+typedef SubscriptionPurchaseUpdatedHandler = Future Function({
+ bool? includeDuplicateTransactionUpdatesIOS,
+});
typedef SubscriptionSubscriptionBillingIssueHandler = Future Function();
typedef SubscriptionUserChoiceBillingAndroidHandler = Future Function();
diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd
index db5145a9..c350724b 100644
--- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd
+++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd
@@ -36,6 +36,7 @@ signal subscription_billing_issue(purchase: Dictionary)
var _native_plugin: Object = null
var _is_connected: bool = false
static var _is_initialized: bool = false
+var _purchase_updated_listener_options: Dictionary = {}
# Platform detection
var _platform: String = ""
@@ -225,6 +226,7 @@ func init_connection() -> bool:
print("[GodotIap] initConnection result: ", _is_connected)
elif _platform == "iOS":
print("[GodotIap] Calling iOS initConnection...")
+ _apply_purchase_updated_listener_options_ios()
_is_connected = _native_plugin.call("initConnection")
if not _is_connected:
print("[GodotIap] ERROR: initConnection failed. Check StoreKit configuration.")
@@ -257,6 +259,30 @@ func end_connection() -> bool:
func is_store_connected() -> bool:
return _is_connected
+## Configure purchase update listener options.
+##
+## On iOS, set [code]include_duplicate_transaction_updates_ios[/code] to true
+## to also receive StoreKit replay events for transaction IDs already delivered
+## during the current connection session. Android ignores this flag.
+func set_purchase_updated_listener_options(options = null) -> void:
+ if typeof(options) == TYPE_OBJECT and options.has_method("to_dict"):
+ _purchase_updated_listener_options = options.to_dict()
+ elif options is Dictionary:
+ _purchase_updated_listener_options = options
+ else:
+ _purchase_updated_listener_options = {}
+ _apply_purchase_updated_listener_options_ios()
+
+func _apply_purchase_updated_listener_options_ios() -> void:
+ if _platform != "iOS" or not _native_plugin:
+ return
+ if not _native_plugin.has_method("setPurchaseUpdatedListenerOptions"):
+ return
+ _native_plugin.call(
+ "setPurchaseUpdatedListenerOptions",
+ JSON.stringify(_purchase_updated_listener_options)
+ )
+
# ==========================================
# Products (OpenIAP Query)
# ==========================================
diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd
index 29d37286..7809a85f 100644
--- a/libraries/godot-iap/addons/godot-iap/types.gd
+++ b/libraries/godot-iap/addons/godot-iap/types.gd
@@ -3850,6 +3850,22 @@ class PurchaseOptions:
dict["includeSuspendedAndroid"] = include_suspended_android
return dict
+class PurchaseUpdatedListenerOptions:
+ ## iOS only. When true, listener callbacks also receive StoreKit replay events
+ var include_duplicate_transaction_updates_ios: Variant = null
+
+ static func from_dict(data: Dictionary) -> PurchaseUpdatedListenerOptions:
+ var obj = PurchaseUpdatedListenerOptions.new()
+ if data.has("includeDuplicateTransactionUpdatesIOS") and data["includeDuplicateTransactionUpdatesIOS"] != null:
+ obj.include_duplicate_transaction_updates_ios = data["includeDuplicateTransactionUpdatesIOS"]
+ return obj
+
+ func to_dict() -> Dictionary:
+ var dict = {}
+ if include_duplicate_transaction_updates_ios != null:
+ dict["includeDuplicateTransactionUpdatesIOS"] = include_duplicate_transaction_updates_ios
+ return dict
+
class RequestPurchaseAndroidProps:
## List of product SKUs
var skus: Array[String] = []
diff --git a/libraries/godot-iap/ios-gdextension/Package.swift b/libraries/godot-iap/ios-gdextension/Package.swift
index 9ef2d02b..f300cd21 100644
--- a/libraries/godot-iap/ios-gdextension/Package.swift
+++ b/libraries/godot-iap/ios-gdextension/Package.swift
@@ -27,6 +27,24 @@ let linkerSettings: [LinkerSetting] = [
.unsafeFlags(["-Xlinker", "-dead_strip"])
]
+let packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
+let repoRoot = packageDir
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+let localOpenIapPackage = repoRoot.appendingPathComponent("packages/apple")
+let openIapDependency: Package.Dependency
+if FileManager.default.fileExists(
+ atPath: localOpenIapPackage.appendingPathComponent("Package.swift").path
+) {
+ openIapDependency = .package(name: "openiap", path: localOpenIapPackage.path)
+} else {
+ openIapDependency = .package(
+ url: "https://github.com/hyodotdev/openiap.git",
+ .upToNextMinor(from: Version(stringLiteral: openIapVersion))
+ )
+}
+
let package = Package(
name: "GodotIap",
platforms: [
@@ -38,7 +56,7 @@ let package = Package(
],
dependencies: [
.package(name: "SwiftGodot", path: "../SwiftGodot"),
- .package(url: "https://github.com/hyodotdev/openiap.git", .upToNextMinor(from: Version(stringLiteral: openIapVersion))),
+ openIapDependency,
],
targets: [
.target(
diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift
index c9fb4ffa..49bdb4a1 100644
--- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift
+++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift
@@ -65,6 +65,7 @@ public class GodotIap: RefCounted, @unchecked Sendable {
private var purchaseErrorSubscription: Subscription?
private var promotedProductSubscription: Subscription?
private var subscriptionBillingIssueSubscription: Subscription?
+ private var includeDuplicatePurchaseUpdatesIOS = false
// MARK: - Initialization
required init(_ context: InitContext) {
@@ -113,6 +114,24 @@ public class GodotIap: RefCounted, @unchecked Sendable {
return true // Return optimistically, actual result via signal
}
+ @Callable
+ public func setPurchaseUpdatedListenerOptions(optionsJson: String) -> Bool {
+ let data = Data(optionsJson.utf8)
+ let decoded = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
+ includeDuplicatePurchaseUpdatesIOS =
+ decoded?["includeDuplicateTransactionUpdatesIOS"] as? Bool == true
+
+ if isConnected {
+ if let sub = purchaseUpdateSubscription {
+ openIap.removeListener(sub)
+ purchaseUpdateSubscription = nil
+ }
+ setupPurchaseUpdatedListener()
+ }
+
+ return true
+ }
+
@Callable
public func endConnection() -> Bool {
GodotIapLog.payload("endConnection", payload: nil)
@@ -1301,11 +1320,7 @@ public class GodotIap: RefCounted, @unchecked Sendable {
// MARK: - Private Helpers
private func setupListeners() {
- purchaseUpdateSubscription = openIap.purchaseUpdatedListener { [weak self] purchase in
- Task { @MainActor in
- self?.emitPurchaseUpdated(purchase: purchase)
- }
- }
+ setupPurchaseUpdatedListener()
purchaseErrorSubscription = openIap.purchaseErrorListener { [weak self] error in
Task { @MainActor in
@@ -1329,6 +1344,17 @@ public class GodotIap: RefCounted, @unchecked Sendable {
}
}
+ private func setupPurchaseUpdatedListener() {
+ let options = PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: includeDuplicatePurchaseUpdatesIOS
+ )
+ purchaseUpdateSubscription = openIap.purchaseUpdatedListener({ [weak self] purchase in
+ Task { @MainActor in
+ self?.emitPurchaseUpdated(purchase: purchase)
+ }
+ }, options: options)
+ }
+
private func removeListeners() {
if let sub = purchaseUpdateSubscription {
openIap.removeListener(sub)
diff --git a/libraries/kmp-iap/library/build.gradle.kts b/libraries/kmp-iap/library/build.gradle.kts
index 21de5b0d..ce50cb22 100644
--- a/libraries/kmp-iap/library/build.gradle.kts
+++ b/libraries/kmp-iap/library/build.gradle.kts
@@ -23,6 +23,7 @@ val googleVersion = if (openIapVersionsFile.exists()) {
} else {
"1.2.10"
}
+val localApplePodspecDir = rootProject.file("../../packages/apple")
println("DEBUG: OpenIAP versions loaded - Apple: $appleVersion, Google: $googleVersion")
@@ -149,8 +150,12 @@ kotlin {
pod("openiap") {
version = appleVersion
- source = git("https://github.com/hyodotdev/openiap.git") {
- tag = appleVersion
+ source = if (localApplePodspecDir.resolve("openiap.podspec").exists()) {
+ path(localApplePodspecDir)
+ } else {
+ git("https://github.com/hyodotdev/openiap.git") {
+ tag = appleVersion
+ }
}
moduleName = "OpenIAP"
}
diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt
index 66437930..39b1ca6b 100644
--- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt
+++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt
@@ -54,6 +54,7 @@ import io.github.hyochan.kmpiap.openiap.IapPlatform
import io.github.hyochan.kmpiap.openiap.ProductIOS
import io.github.hyochan.kmpiap.openiap.PurchaseError
import io.github.hyochan.kmpiap.openiap.PurchaseOptions
+import io.github.hyochan.kmpiap.openiap.PurchaseUpdatedListenerOptions
import io.github.hyochan.kmpiap.openiap.QueryFetchProductsHandler
import io.github.hyochan.kmpiap.openiap.QueryGetActiveSubscriptionsHandler
import io.github.hyochan.kmpiap.openiap.QueryGetAvailablePurchasesHandler
@@ -125,6 +126,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife
// ---------------------------------------------------------------------
private val _purchaseUpdatedListener = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val purchaseUpdatedListener: Flow = _purchaseUpdatedListener.asSharedFlow()
+ override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow =
+ purchaseUpdatedListener
private val _purchaseErrorListener = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val purchaseErrorListener: Flow = _purchaseErrorListener.asSharedFlow()
@@ -708,7 +711,8 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife
override suspend fun purchaseError(): PurchaseError = purchaseErrorListener.first()
- override suspend fun purchaseUpdated(): Purchase = purchaseUpdatedListener.first()
+ override suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions?): Purchase =
+ purchaseUpdatedListener(options).first()
override suspend fun subscriptionBillingIssue(): Purchase = subscriptionBillingIssueListener.first()
diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt
index 760b3199..e30e7528 100644
--- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt
+++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/KmpIap.kt
@@ -65,6 +65,7 @@ typealias RequestPurchaseIosProps = io.github.hyochan.kmpiap.openiap.RequestPurc
typealias RequestPurchaseAndroidProps = io.github.hyochan.kmpiap.openiap.RequestPurchaseAndroidProps
typealias RequestSubscriptionAndroidProps = io.github.hyochan.kmpiap.openiap.RequestSubscriptionAndroidProps
typealias PurchaseOptions = io.github.hyochan.kmpiap.openiap.PurchaseOptions
+typealias PurchaseUpdatedListenerOptions = io.github.hyochan.kmpiap.openiap.PurchaseUpdatedListenerOptions
typealias DeepLinkOptions = io.github.hyochan.kmpiap.openiap.DeepLinkOptions
typealias ValidationOptions = io.github.hyochan.kmpiap.openiap.VerifyPurchaseProps
typealias ValidationResult = io.github.hyochan.kmpiap.openiap.VerifyPurchaseResult
@@ -105,6 +106,15 @@ interface KmpInAppPurchase : MutationResolver, QueryResolver, SubscriptionResolv
*/
val purchaseUpdatedListener: Flow
+ /**
+ * Listener for observing purchase updates with subscription options.
+ *
+ * On iOS, set [PurchaseUpdatedListenerOptions.includeDuplicateTransactionUpdatesIOS]
+ * to true to also receive StoreKit replay events for transaction IDs already
+ * delivered during the current connection session. Android ignores this flag.
+ */
+ fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions? = null): Flow
+
/**
* Listener for observing purchase errors
* Collect this Flow to receive error events
diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt
index 9f4afca3..e637dfdd 100644
--- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt
+++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt
@@ -4415,6 +4415,28 @@ public data class PurchaseOptions(
)
}
+public data class PurchaseUpdatedListenerOptions(
+ /**
+ * iOS only. When true, listener callbacks also receive StoreKit replay events
+ * for a transaction ID that was already emitted during the current connection
+ * session. Defaults to false so purchase success handlers run once per
+ * transaction ID.
+ */
+ val includeDuplicateTransactionUpdatesIOS: Boolean? = null
+) {
+ companion object {
+ fun fromJson(json: Map): PurchaseUpdatedListenerOptions {
+ return PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS = json["includeDuplicateTransactionUpdatesIOS"] as? Boolean,
+ )
+ }
+ }
+
+ fun toJson(): Map = mapOf(
+ "includeDuplicateTransactionUpdatesIOS" to includeDuplicateTransactionUpdatesIOS,
+ )
+}
+
public data class RequestPurchaseAndroidProps(
/**
* Developer billing option parameters for external payments flow (8.3.0+).
@@ -5562,8 +5584,11 @@ public interface SubscriptionResolver {
suspend fun purchaseError(): PurchaseError
/**
* Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
*/
- suspend fun purchaseUpdated(): Purchase
+ suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions? = null): Purchase
/**
* Fires when an active subscription enters a billing-issue state that needs user action
* (payment method failed, card expired, etc.). Cross-platform unification:
@@ -5698,7 +5723,7 @@ public data class QueryHandlers(
public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = suspend () -> DeveloperProvidedBillingDetailsAndroid
public typealias SubscriptionPromotedProductIOSHandler = suspend () -> String
public typealias SubscriptionPurchaseErrorHandler = suspend () -> PurchaseError
-public typealias SubscriptionPurchaseUpdatedHandler = suspend () -> Purchase
+public typealias SubscriptionPurchaseUpdatedHandler = suspend (options: PurchaseUpdatedListenerOptions?) -> Purchase
public typealias SubscriptionSubscriptionBillingIssueHandler = suspend () -> Purchase
public typealias SubscriptionUserChoiceBillingAndroidHandler = suspend () -> UserChoiceBillingDetails
diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
index 6ee42735..c8bdba52 100644
--- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
+++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt
@@ -29,6 +29,22 @@ internal class InAppPurchaseIOS : KmpInAppPurchase {
)
override val purchaseUpdatedListener: Flow = _purchaseUpdatedFlow.asSharedFlow()
+ private val _purchaseUpdatedDuplicateFlow = MutableSharedFlow(
+ replay = 0,
+ extraBufferCapacity = 64,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST
+ )
+ private val purchaseUpdatedDuplicateListener: Flow =
+ _purchaseUpdatedDuplicateFlow.asSharedFlow()
+
+ override fun purchaseUpdatedListener(options: PurchaseUpdatedListenerOptions?): Flow {
+ return if (options?.includeDuplicateTransactionUpdatesIOS == true) {
+ purchaseUpdatedDuplicateListener
+ } else {
+ purchaseUpdatedListener
+ }
+ }
+
private val _purchaseErrorFlow = MutableSharedFlow(
replay = 0,
extraBufferCapacity = 64,
@@ -59,6 +75,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase {
// Listener subscriptions
private var purchaseSubscription: NSObject? = null
+ private var purchaseDuplicateSubscription: NSObject? = null
private var errorSubscription: NSObject? = null
private var promotedProductSubscription: NSObject? = null
private var subscriptionBillingIssueSubscription: NSObject? = null
@@ -84,6 +101,19 @@ internal class InAppPurchaseIOS : KmpInAppPurchase {
}
}
+ purchaseDuplicateSubscription = openIapModule.addPurchaseUpdatedListener(
+ { dictionary ->
+ println("[KMP-IAP iOS] Duplicate-enabled purchase updated received: $dictionary")
+ val purchase = convertAnyToPurchase(dictionary)
+ if (purchase != null) {
+ coroutineScope.launch {
+ _purchaseUpdatedDuplicateFlow.emit(purchase)
+ }
+ }
+ },
+ true
+ )
+
// Purchase error listener
errorSubscription = openIapModule.addPurchaseErrorListener { dictionary ->
println("[KMP-IAP iOS] Purchase error received: $dictionary")
@@ -172,6 +202,8 @@ internal class InAppPurchaseIOS : KmpInAppPurchase {
// can freshly re-register without orphaning the previous subscriptions.
purchaseSubscription?.let { openIapModule.removeListener(it) }
purchaseSubscription = null
+ purchaseDuplicateSubscription?.let { openIapModule.removeListener(it) }
+ purchaseDuplicateSubscription = null
errorSubscription?.let { openIapModule.removeListener(it) }
errorSubscription = null
promotedProductSubscription?.let { openIapModule.removeListener(it) }
@@ -1011,9 +1043,8 @@ internal class InAppPurchaseIOS : KmpInAppPurchase {
// SubscriptionResolver Implementation
// -------------------------------------------------------------------------
- override suspend fun purchaseUpdated(): Purchase {
- throw UnsupportedOperationException("Use purchaseUpdatedListener Flow instead")
- }
+ override suspend fun purchaseUpdated(options: PurchaseUpdatedListenerOptions?): Purchase =
+ purchaseUpdatedListener(options).first()
override suspend fun purchaseError(): PurchaseError {
throw UnsupportedOperationException("Use purchaseErrorListener Flow instead")
@@ -1526,4 +1557,4 @@ internal class InAppPurchaseIOS : KmpInAppPurchase {
emptyList()
}
}
-}
\ No newline at end of file
+}
diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs
index 98685c5c..681960cb 100644
--- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs
+++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs
@@ -217,6 +217,11 @@ void VerifyPurchaseWithProvider(
[Export("addPurchaseUpdatedListener:")]
NSObject AddPurchaseUpdatedListener(Action callback);
+ [Export("addPurchaseUpdatedListener:includeDuplicateTransactionUpdatesIOS:")]
+ NSObject AddPurchaseUpdatedListener(
+ Action callback,
+ bool includeDuplicateTransactionUpdatesIOS);
+
[Export("addPurchaseErrorListener:")]
NSObject AddPurchaseErrorListener(Action callback);
diff --git a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs
index 51237849..f36b2471 100644
--- a/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs
+++ b/libraries/maui-iap/src/OpenIap.Maui/OpenIap.cs
@@ -47,6 +47,14 @@ public interface IOpenIap
///
IObservable PurchaseUpdated { get; }
+ ///
+ /// Stream of successful purchase updates with listener options. On iOS,
+ ///
+ /// also emits StoreKit replay events for transaction IDs already delivered
+ /// during the current connection session. Android ignores this flag.
+ ///
+ IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null);
+
///
/// Stream of purchase failures. Mirrors
/// SubscriptionResolver.purchaseError.
diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs
index ab8e5505..5060715f 100644
--- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs
+++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.cs
@@ -62,6 +62,8 @@ private void RefreshCurrentActivity()
}
public IObservable PurchaseUpdated => _purchaseUpdated;
+ public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) =>
+ PurchaseUpdated;
public IObservable PurchaseError => _purchaseError;
public IObservable PromotedProductIOS => _promotedProductIOS;
public IObservable SubscriptionBillingIssue => _subscriptionBillingIssue;
diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs
index 87764316..50f835e9 100644
--- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs
+++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs
@@ -39,15 +39,18 @@ internal class OpenIapIOS : IOpenIap, QueryResolver, MutationResolver, IDisposab
private readonly OpenIapModule _module = OpenIapModule.SharedInstance();
private readonly Subject _purchaseUpdated = new();
+ private readonly Subject _purchaseUpdatedDuplicate = new();
private readonly Subject _purchaseError = new();
private readonly Subject _promotedProductIOS = new();
private readonly Subject _subscriptionBillingIssue = new();
private NSObject? _purchaseUpdatedToken;
+ private NSObject? _purchaseUpdatedDuplicateToken;
private NSObject? _purchaseErrorToken;
private NSObject? _promotedProductToken;
private NSObject? _billingIssueToken;
private readonly Action _purchaseUpdatedCallback;
+ private readonly Action _purchaseUpdatedDuplicateCallback;
private readonly Action _purchaseErrorCallback;
private readonly Action _promotedProductCallback;
private readonly Action _billingIssueCallback;
@@ -71,6 +74,21 @@ public OpenIapIOS()
}
};
+ _purchaseUpdatedDuplicateCallback = dict =>
+ {
+ try
+ {
+ var node = NSObjectJsonBridge.DictToObject(dict);
+ if (node is null) return;
+ var p = node.Deserialize(JsonOptions.Default);
+ if (p is not null) _purchaseUpdatedDuplicate.OnNext(p);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[OpenIapIOS] duplicate purchaseUpdated listener failed: {ex.Message}");
+ }
+ };
+
_purchaseErrorCallback = dict =>
{
try
@@ -116,6 +134,10 @@ public OpenIapIOS()
}
public IObservable PurchaseUpdated => _purchaseUpdated;
+ public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) =>
+ options?.IncludeDuplicateTransactionUpdatesIOS == true
+ ? _purchaseUpdatedDuplicate
+ : _purchaseUpdated;
public IObservable PurchaseError => _purchaseError;
public IObservable PromotedProductIOS => _promotedProductIOS;
public IObservable SubscriptionBillingIssue => _subscriptionBillingIssue;
@@ -134,6 +156,9 @@ private void WireListeners()
// can never escape into mono's native unwind path — that path has no
// managed handler and aborts the process with SIGABRT.
_purchaseUpdatedToken = _module.AddPurchaseUpdatedListener(_purchaseUpdatedCallback);
+ _purchaseUpdatedDuplicateToken = _module.AddPurchaseUpdatedListener(
+ _purchaseUpdatedDuplicateCallback,
+ includeDuplicateTransactionUpdatesIOS: true);
_purchaseErrorToken = _module.AddPurchaseErrorListener(_purchaseErrorCallback);
_promotedProductToken = _module.AddPromotedProductListener(_promotedProductCallback);
_billingIssueToken = _module.AddSubscriptionBillingIssueListener(_billingIssueCallback);
@@ -143,6 +168,7 @@ private void WireListeners()
public void Dispose()
{
NSObject? purchaseUpdatedToken;
+ NSObject? purchaseUpdatedDuplicateToken;
NSObject? purchaseErrorToken;
NSObject? promotedProductToken;
NSObject? billingIssueToken;
@@ -153,17 +179,20 @@ public void Dispose()
_disposed = true;
purchaseUpdatedToken = _purchaseUpdatedToken;
+ purchaseUpdatedDuplicateToken = _purchaseUpdatedDuplicateToken;
purchaseErrorToken = _purchaseErrorToken;
promotedProductToken = _promotedProductToken;
billingIssueToken = _billingIssueToken;
_purchaseUpdatedToken = null;
+ _purchaseUpdatedDuplicateToken = null;
_purchaseErrorToken = null;
_promotedProductToken = null;
_billingIssueToken = null;
}
RemoveListener(purchaseUpdatedToken, nameof(_purchaseUpdatedToken));
+ RemoveListener(purchaseUpdatedDuplicateToken, nameof(_purchaseUpdatedDuplicateToken));
RemoveListener(purchaseErrorToken, nameof(_purchaseErrorToken));
RemoveListener(promotedProductToken, nameof(_promotedProductToken));
RemoveListener(billingIssueToken, nameof(_billingIssueToken));
diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs
index 91e82bf4..1562711d 100644
--- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs
+++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs
@@ -3762,6 +3762,16 @@ public sealed record PurchaseOptions
public bool? IncludeSuspendedAndroid { get; init; }
}
+public sealed record PurchaseUpdatedListenerOptions
+{
+ /// iOS only. When true, listener callbacks also receive StoreKit replay events
+ /// for a transaction ID that was already emitted during the current connection
+ /// session. Defaults to false so purchase success handlers run once per
+ /// transaction ID.
+ [JsonPropertyName("includeDuplicateTransactionUpdatesIOS")]
+ public bool? IncludeDuplicateTransactionUpdatesIOS { get; init; }
+}
+
public sealed record RequestPurchaseAndroidProps
{
/// List of product SKUs
@@ -4347,7 +4357,10 @@ public interface SubscriptionResolver
Task PurchaseErrorAsync();
/// Fires when a purchase completes successfully or a pending purchase resolves
- Task PurchaseUpdatedAsync();
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ Task PurchaseUpdatedAsync(PurchaseUpdatedListenerOptions? options = null);
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
diff --git a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs
index 5a3cddac..9afae7f3 100644
--- a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs
+++ b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs
@@ -13,6 +13,7 @@ namespace OpenIap.Maui;
internal sealed class UnsupportedOpenIap : IOpenIap
{
public IObservable PurchaseUpdated => EmptyObservable.Instance;
+ public IObservable PurchaseUpdatedWithOptions(PurchaseUpdatedListenerOptions? options = null) => PurchaseUpdated;
public IObservable PurchaseError => EmptyObservable.Instance;
public IObservable PromotedProductIOS => EmptyObservable.Instance;
public IObservable SubscriptionBillingIssue => EmptyObservable.Instance;
diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
index 4b960d8b..bef88d5a 100644
--- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
+++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt
@@ -783,7 +783,10 @@ class HybridRnIap : HybridRnIapSpec() {
get() = 0L
// Event listener methods
- override fun addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) {
+ override fun addPurchaseUpdatedListener(
+ listener: (purchase: NitroPurchase) -> Unit,
+ options: NitroPurchaseUpdatedListenerOptions?
+ ) {
synchronized(purchaseUpdatedListeners) {
purchaseUpdatedListeners.add(listener)
}
diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift
index 1aac6e47..70867290 100644
--- a/libraries/react-native-iap/ios/HybridRnIap.swift
+++ b/libraries/react-native-iap/ios/HybridRnIap.swift
@@ -11,10 +11,12 @@ class HybridRnIap: HybridRnIapSpec {
private var productTypeBySku: [String: String] = [:]
// OpenIAP event subscriptions
private var purchaseUpdatedSub: Subscription?
+ private var purchaseUpdatedDuplicateSub: Subscription?
private var purchaseErrorSub: Subscription?
private var promotedProductSub: Subscription?
// Event listeners
private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = []
+ private var purchaseUpdatedDuplicateListeners: [(NitroPurchase) -> Void] = []
private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = []
private var promotedProductListeners: [(NitroProduct) -> Void] = []
private var subscriptionBillingIssueListeners: [(NitroPurchase) -> Void] = []
@@ -942,8 +944,28 @@ class HybridRnIap: HybridRnIapSpec {
// MARK: - Event Listener Methods
- func addPurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws {
- listenerLock.withLock { purchaseUpdatedListeners.append(listener) }
+ func addPurchaseUpdatedListener(
+ listener: @escaping (NitroPurchase) -> Void,
+ options: NitroPurchaseUpdatedListenerOptions?
+ ) throws {
+ let includeDuplicateTransactionUpdatesIOS: Bool = {
+ if case .second(let enabled) = options?.includeDuplicateTransactionUpdatesIOS {
+ return enabled
+ }
+ return false
+ }()
+ listenerLock.withLock {
+ if includeDuplicateTransactionUpdatesIOS {
+ purchaseUpdatedDuplicateListeners.append(listener)
+ } else {
+ purchaseUpdatedListeners.append(listener)
+ }
+ }
+ if includeDuplicateTransactionUpdatesIOS {
+ attachDuplicatePurchaseUpdatedSubIfNeeded()
+ } else {
+ attachPurchaseUpdatedSubIfNeeded()
+ }
}
func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
@@ -951,7 +973,10 @@ class HybridRnIap: HybridRnIapSpec {
}
func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws {
- listenerLock.withLock { purchaseUpdatedListeners.removeAll() }
+ listenerLock.withLock {
+ purchaseUpdatedListeners.removeAll()
+ purchaseUpdatedDuplicateListeners.removeAll()
+ }
}
func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
@@ -970,26 +995,7 @@ class HybridRnIap: HybridRnIapSpec {
// MARK: - Private Helper Methods
private func attachListenersIfNeeded() {
- if purchaseUpdatedSub == nil {
- RnIapLog.payload("purchaseUpdatedListener.register", nil)
- purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
- guard let self else {
- RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped")
- return
- }
- Task { @MainActor in
- let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
- let payload = RnIapHelper.sanitizeDictionary(rawPayload)
- RnIapLog.result("purchaseUpdatedListener", payload)
- if let identifier = rawPayload["id"] as? String {
- self.purchasePayloadById[identifier] = rawPayload
- }
- let nitro = RnIapHelper.convertPurchaseDictionary(payload)
- self.sendPurchaseUpdate(nitro)
- }
- }
- RnIapLog.result("purchaseUpdatedListener.register", "attached")
- }
+ attachPurchaseUpdatedSubIfNeeded()
if purchaseErrorSub == nil {
RnIapLog.payload("purchaseErrorListener.register", nil)
@@ -1048,6 +1054,55 @@ class HybridRnIap: HybridRnIapSpec {
}
}
+ private func attachPurchaseUpdatedSubIfNeeded() {
+ if purchaseUpdatedSub == nil {
+ RnIapLog.payload("purchaseUpdatedListener.register", nil)
+ purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
+ guard let self else {
+ RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped")
+ return
+ }
+ Task { @MainActor in
+ let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
+ let payload = RnIapHelper.sanitizeDictionary(rawPayload)
+ RnIapLog.result("purchaseUpdatedListener", payload)
+ if let identifier = rawPayload["id"] as? String {
+ self.purchasePayloadById[identifier] = rawPayload
+ }
+ let nitro = RnIapHelper.convertPurchaseDictionary(payload)
+ self.sendPurchaseUpdate(nitro, includeDuplicateListeners: false)
+ }
+ }
+ RnIapLog.result("purchaseUpdatedListener.register", "attached")
+ }
+ }
+
+ private func attachDuplicatePurchaseUpdatedSubIfNeeded() {
+ if purchaseUpdatedDuplicateSub == nil {
+ RnIapLog.payload("purchaseUpdatedListener.register.duplicates", nil)
+ let options = PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: true
+ )
+ purchaseUpdatedDuplicateSub = OpenIapModule.shared.purchaseUpdatedListener({ [weak self] openIapPurchase in
+ guard let self else {
+ RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, duplicate-enabled purchase event dropped")
+ return
+ }
+ Task { @MainActor in
+ let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
+ let payload = RnIapHelper.sanitizeDictionary(rawPayload)
+ RnIapLog.result("purchaseUpdatedListener.duplicates", payload)
+ if let identifier = rawPayload["id"] as? String {
+ self.purchasePayloadById[identifier] = rawPayload
+ }
+ let nitro = RnIapHelper.convertPurchaseDictionary(payload)
+ self.sendPurchaseUpdate(nitro, includeDuplicateListeners: true)
+ }
+ }, options: options)
+ RnIapLog.result("purchaseUpdatedListener.register.duplicates", "attached")
+ }
+ }
+
private func attachSubscriptionBillingIssueSubIfNeeded() {
guard subscriptionBillingIssueSub == nil else { return }
RnIapLog.payload("subscriptionBillingIssueListener.register", nil)
@@ -1075,7 +1130,7 @@ class HybridRnIap: HybridRnIapSpec {
}
}
- private func sendPurchaseUpdate(_ purchase: NitroPurchase) {
+ private func sendPurchaseUpdate(_ purchase: NitroPurchase, includeDuplicateListeners: Bool) {
let originalTxId: String
if case .second(let val) = purchase.originalTransactionIdentifierIOS { originalTxId = val } else { originalTxId = "" }
let purchaseTokenStr: String
@@ -1091,6 +1146,10 @@ class HybridRnIap: HybridRnIapSpec {
var isDuplicate = false
let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock {
+ if includeDuplicateListeners {
+ return Array(purchaseUpdatedDuplicateListeners)
+ }
+
if deliveredPurchaseEventKeys.contains(eventKey) {
isDuplicate = true
return []
@@ -1203,6 +1262,7 @@ class HybridRnIap: HybridRnIapSpec {
// Clear event listeners, error dedup state, and delivery state (thread-safe)
listenerLock.withLock {
purchaseUpdatedListeners.removeAll()
+ purchaseUpdatedDuplicateListeners.removeAll()
purchaseErrorListeners.removeAll()
promotedProductListeners.removeAll()
subscriptionBillingIssueListeners.removeAll()
diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts
index 8771602c..153435c4 100644
--- a/libraries/react-native-iap/src/__tests__/index.test.ts
+++ b/libraries/react-native-iap/src/__tests__/index.test.ts
@@ -162,6 +162,37 @@ describe('Public API (src/index.ts)', () => {
expect(listener).not.toHaveBeenCalled();
});
+ it('routes duplicate-enabled purchaseUpdatedListener through opt-in native listener', () => {
+ const defaultListener = jest.fn();
+ const duplicateListener = jest.fn();
+ IAP.purchaseUpdatedListener(defaultListener);
+ IAP.purchaseUpdatedListener(duplicateListener, {
+ includeDuplicateTransactionUpdatesIOS: true,
+ });
+
+ expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2);
+ expect(mockIap.addPurchaseUpdatedListener.mock.calls[1][1]).toEqual({
+ includeDuplicateTransactionUpdatesIOS: true,
+ });
+
+ const nitroPurchase = {
+ id: 't1',
+ productId: 'p1',
+ transactionDate: Date.now(),
+ platform: 'ios',
+ quantity: 1,
+ purchaseState: 'purchased',
+ isAutoRenewing: false,
+ };
+ mockIap.addPurchaseUpdatedListener.mock.calls[0][0](nitroPurchase);
+ expect(defaultListener).toHaveBeenCalledTimes(1);
+ expect(duplicateListener).not.toHaveBeenCalled();
+
+ mockIap.addPurchaseUpdatedListener.mock.calls[1][0](nitroPurchase);
+ expect(defaultListener).toHaveBeenCalledTimes(1);
+ expect(duplicateListener).toHaveBeenCalledTimes(1);
+ });
+
it('purchaseErrorListener forwards error objects and supports removal', () => {
const listener = jest.fn();
const sub = IAP.purchaseErrorListener(listener);
diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts
index cf47efd5..1624f0fa 100644
--- a/libraries/react-native-iap/src/hooks/useIAP.ts
+++ b/libraries/react-native-iap/src/hooks/useIAP.ts
@@ -48,6 +48,7 @@ import type {
Product,
Purchase,
PurchaseError,
+ PurchaseUpdatedListenerOptions,
ProductSubscription,
} from '../types';
import type {MutationFinishTransactionArgs} from '../types';
@@ -268,6 +269,12 @@ type UseIap = {
export interface UseIapOptions {
onPurchaseSuccess?: (purchase: Purchase) => void;
+ /**
+ * Options for the purchase success listener. iOS defaults to suppressing
+ * StoreKit replay events for the same transaction ID; set
+ * `includeDuplicateTransactionUpdatesIOS` to true only for diagnostics.
+ */
+ purchaseUpdatedListenerOptions?: PurchaseUpdatedListenerOptions | null;
onPurchaseError?: (error: PurchaseError) => void;
/** Callback for non-purchase errors (fetchProducts, getAvailablePurchases, etc.) */
onError?: (error: Error) => void;
@@ -593,6 +600,7 @@ export function useIAP(options?: UseIapOptions): UseIap {
optionsRef.current.onPurchaseSuccess(purchase);
}
},
+ optionsRef.current?.purchaseUpdatedListenerOptions,
);
}
diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts
index b0be229c..2da5ae2d 100644
--- a/libraries/react-native-iap/src/index.ts
+++ b/libraries/react-native-iap/src/index.ts
@@ -10,6 +10,7 @@ import type {
NitroReceiptValidationParams,
NitroReceiptValidationResultIOS,
NitroReceiptValidationResultAndroid,
+ NitroPurchaseUpdatedListenerOptions,
NitroSubscriptionStatus,
RnIap,
} from './specs/RnIap.nitro';
@@ -30,6 +31,7 @@ import type {
ProductSubscription,
Purchase,
PurchaseError,
+ PurchaseUpdatedListenerOptions,
PurchaseIOS,
QueryField,
AppTransaction,
@@ -97,6 +99,9 @@ type NitroFinishTransactionParamsInternal = Parameters<
RnIap['finishTransaction']
>[0];
type NitroPurchaseListener = Parameters[0];
+type NitroPurchaseUpdatedListenerOptionsParam = NonNullable<
+ Parameters[1]
+>;
type NitroPurchaseErrorListener = Parameters<
RnIap['addPurchaseErrorListener']
>[0];
@@ -129,10 +134,7 @@ export type {
UseWebhookEventsOptions,
UseWebhookEventsResult,
} from './hooks/useWebhookEvents';
-export {
- connectWebhookStream,
- parseWebhookEventData,
-} from './webhook-client';
+export {connectWebhookStream, parseWebhookEventData} from './webhook-client';
export type {
WebhookEventPayload,
WebhookEventStream,
@@ -232,11 +234,18 @@ const IAP = {
// ============================================================================
const purchaseUpdateJsListeners = new Set<(purchase: Purchase) => void>();
+const purchaseUpdateDuplicateJsListeners = new Set<
+ (purchase: Purchase) => void
+>();
let purchaseUpdateNativeAttached = false;
-const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => {
+let purchaseUpdateDuplicateNativeAttached = false;
+const emitPurchaseUpdateToListeners = (
+ nitroPurchase: Parameters[0],
+ listeners: Set<(purchase: Purchase) => void>,
+) => {
if (validateNitroPurchase(nitroPurchase)) {
const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase);
- for (const listener of purchaseUpdateJsListeners) {
+ for (const listener of listeners) {
try {
listener(convertedPurchase);
} catch (e) {
@@ -250,6 +259,17 @@ const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => {
);
}
};
+const purchaseUpdateNativeHandler: NitroPurchaseListener = (nitroPurchase) => {
+ emitPurchaseUpdateToListeners(nitroPurchase, purchaseUpdateJsListeners);
+};
+const purchaseUpdateDuplicateNativeHandler: NitroPurchaseListener = (
+ nitroPurchase,
+) => {
+ emitPurchaseUpdateToListeners(
+ nitroPurchase,
+ purchaseUpdateDuplicateJsListeners,
+ );
+};
const purchaseErrorJsListeners = new Set<(error: PurchaseError) => void>();
let purchaseErrorNativeAttached = false;
@@ -299,6 +319,7 @@ const promotedProductNativeHandler: NitroPromotedProductListener = (
*/
export const resetListenerState = (): void => {
purchaseUpdateNativeAttached = false;
+ purchaseUpdateDuplicateNativeAttached = false;
purchaseErrorNativeAttached = false;
promotedProductNativeAttached = false;
userChoiceBillingNativeAttached = false;
@@ -306,6 +327,7 @@ export const resetListenerState = (): void => {
subscriptionBillingIssueNativeAttached = false;
// Clear all JS listeners since native side clears them in endConnection
purchaseUpdateJsListeners.clear();
+ purchaseUpdateDuplicateJsListeners.clear();
purchaseErrorJsListeners.clear();
promotedProductJsListeners.clear();
userChoiceBillingJsListeners.clear();
@@ -315,10 +337,17 @@ export const resetListenerState = (): void => {
export const purchaseUpdatedListener = (
listener: (purchase: Purchase) => void,
+ options?: PurchaseUpdatedListenerOptions | null,
): EventSubscription => {
- purchaseUpdateJsListeners.add(listener);
+ const includeDuplicateTransactionUpdatesIOS =
+ options?.includeDuplicateTransactionUpdatesIOS === true;
+ const listeners = includeDuplicateTransactionUpdatesIOS
+ ? purchaseUpdateDuplicateJsListeners
+ : purchaseUpdateJsListeners;
+
+ listeners.add(listener);
- if (!purchaseUpdateNativeAttached) {
+ if (!purchaseUpdateNativeAttached && !includeDuplicateTransactionUpdatesIOS) {
try {
IAP.instance.addPurchaseUpdatedListener(purchaseUpdateNativeHandler);
purchaseUpdateNativeAttached = true;
@@ -334,9 +363,35 @@ export const purchaseUpdatedListener = (
}
}
+ if (
+ !purchaseUpdateDuplicateNativeAttached &&
+ includeDuplicateTransactionUpdatesIOS
+ ) {
+ try {
+ const nativeOptions: NitroPurchaseUpdatedListenerOptions &
+ NitroPurchaseUpdatedListenerOptionsParam = {
+ includeDuplicateTransactionUpdatesIOS: true,
+ };
+ IAP.instance.addPurchaseUpdatedListener(
+ purchaseUpdateDuplicateNativeHandler,
+ nativeOptions,
+ );
+ purchaseUpdateDuplicateNativeAttached = true;
+ } catch (e) {
+ const msg = toErrorMessage(e);
+ if (msg.includes('Nitro runtime not installed')) {
+ RnIapConsole.warn(
+ '[purchaseUpdatedListener] Nitro not ready yet; listener inert until initConnection()',
+ );
+ } else {
+ throw e;
+ }
+ }
+ }
+
return {
remove: () => {
- purchaseUpdateJsListeners.delete(listener);
+ listeners.delete(listener);
},
};
};
@@ -619,7 +674,9 @@ type NitroSubscriptionBillingIssueListener = Parameters<
RnIap['addSubscriptionBillingIssueListener']
>[0];
-const subscriptionBillingIssueJsListeners = new Set<(purchase: Purchase) => void>();
+const subscriptionBillingIssueJsListeners = new Set<
+ (purchase: Purchase) => void
+>();
let subscriptionBillingIssueNativeAttached = false;
const subscriptionBillingIssueNativeHandler: NitroSubscriptionBillingIssueListener =
(nitroPurchase) => {
diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts
index 94643735..fa927fd9 100644
--- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts
+++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts
@@ -40,6 +40,7 @@ import type {
PromotionalOfferJwsInputIOS,
PurchaseCommon,
PurchaseOptions,
+ PurchaseUpdatedListenerOptions,
VerifyPurchaseAppleOptions,
VerifyPurchaseGoogleOptions,
VerifyPurchaseHorizonOptions,
@@ -146,6 +147,8 @@ export interface NitroReceiptValidationHorizonOptions {
userId: VerifyPurchaseHorizonOptions['userId'];
}
+export interface NitroPurchaseUpdatedListenerOptions extends PurchaseUpdatedListenerOptions {}
+
export interface NitroReceiptValidationParams {
apple?: NitroReceiptValidationAppleOptions | null;
google?: NitroReceiptValidationGoogleOptions | null;
@@ -725,7 +728,10 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> {
* Add a listener for purchase updates
* @param listener - Function to call when a purchase is updated
*/
- addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) => void): void;
+ addPurchaseUpdatedListener(
+ listener: (purchase: NitroPurchase) => void,
+ options?: NitroPurchaseUpdatedListenerOptions,
+ ): void;
/**
* Add a listener for purchase errors
diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts
index 7eb00860..4432d3fe 100644
--- a/libraries/react-native-iap/src/types.ts
+++ b/libraries/react-native-iap/src/types.ts
@@ -1292,6 +1292,16 @@ export interface PurchaseOptions {
export type PurchaseState = 'pending' | 'purchased' | 'unknown';
+export interface PurchaseUpdatedListenerOptions {
+ /**
+ * iOS only. When true, listener callbacks also receive StoreKit replay events
+ * for a transaction ID that was already emitted during the current connection
+ * session. Defaults to false so purchase success handlers run once per
+ * transaction ID.
+ */
+ includeDuplicateTransactionUpdatesIOS?: (boolean | null);
+}
+
export type PurchaseVerificationProvider = 'iapkit';
export interface Query {
@@ -1734,7 +1744,12 @@ export interface Subscription {
promotedProductIOS: string;
/** Fires when a purchase fails or is cancelled */
purchaseError: PurchaseError;
- /** Fires when a purchase completes successfully or a pending purchase resolves */
+ /**
+ * Fires when a purchase completes successfully or a pending purchase resolves
+ * Options can opt iOS listeners into duplicate StoreKit transaction replays
+ * for diagnostics; default listeners receive one event per transaction ID
+ * during a single connection session.
+ */
purchaseUpdated: Purchase;
/**
* Fires when an active subscription enters a billing-issue state that needs user action
@@ -1758,6 +1773,9 @@ export interface Subscription {
}
+
+export type SubscriptionPurchaseUpdatedArgs = (PurchaseUpdatedListenerOptions | null) | undefined;
+
export interface SubscriptionInfoIOS {
introductoryOffer?: (SubscriptionOfferIOS | null);
promotionalOffers?: (SubscriptionOfferIOS[] | null);
@@ -2218,7 +2236,7 @@ export type SubscriptionArgsMap = {
developerProvidedBillingAndroid: never;
promotedProductIOS: never;
purchaseError: never;
- purchaseUpdated: never;
+ purchaseUpdated: SubscriptionPurchaseUpdatedArgs;
subscriptionBillingIssue: never;
userChoiceBillingAndroid: never;
};
diff --git a/openiap-versions.json b/openiap-versions.json
index 6721d2c1..998c5884 100644
--- a/openiap-versions.json
+++ b/openiap-versions.json
@@ -1,5 +1,5 @@
{
- "spec": "2.0.1",
+ "spec": "2.0.2",
"google": "2.1.4",
"apple": "2.1.8"
}
diff --git a/packages/apple/Sources/Helpers/IapState.swift b/packages/apple/Sources/Helpers/IapState.swift
index 3141ee33..c55fd292 100644
--- a/packages/apple/Sources/Helpers/IapState.swift
+++ b/packages/apple/Sources/Helpers/IapState.swift
@@ -32,16 +32,27 @@ private struct PurchaseUpdateEmissionHistory {
}
}
+@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
+private struct PurchaseUpdatedListenerRegistration {
+ let id: UUID
+ let listener: PurchaseUpdatedListener
+ let includeDuplicateTransactionUpdatesIOS: Bool
+}
+
@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
actor IapState {
+ private static let purchaseUpdateEmissionHistoryLimit = 512
+
private(set) var isInitialized: Bool = false
private var pendingTransactions: [String: Transaction] = [:]
- private var purchaseUpdateEmissionHistory = PurchaseUpdateEmissionHistory(limit: 512)
+ private var purchaseUpdateEmissionHistory = PurchaseUpdateEmissionHistory(
+ limit: purchaseUpdateEmissionHistoryLimit
+ )
private var promotedProductId: String?
private var pendingPromotedProductReplayId: String?
// Event listeners
- private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = []
+ private var purchaseUpdatedListeners: [PurchaseUpdatedListenerRegistration] = []
private var purchaseErrorListeners: [(id: UUID, listener: PurchaseErrorListener)] = []
private var promotedProductListeners: [(id: UUID, listener: PromotedProductListener)] = []
private var subscriptionBillingIssueListeners: [(id: UUID, listener: SubscriptionBillingIssueListener)] = []
@@ -63,8 +74,15 @@ actor IapState {
func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) }
// MARK: - Purchase Update Emissions
- func recordPurchaseUpdateEmission(id: String) -> Bool {
- purchaseUpdateEmissionHistory.record(id)
+ func recordPurchaseUpdateEmission(
+ id: String,
+ pendingTransaction: Transaction? = nil
+ ) -> Bool {
+ let shouldEmit = purchaseUpdateEmissionHistory.record(id)
+ if shouldEmit, let pendingTransaction {
+ pendingTransactions[id] = pendingTransaction
+ }
+ return shouldEmit
}
// MARK: - Promoted Products
@@ -83,8 +101,16 @@ actor IapState {
}
// MARK: - Listeners
- func addPurchaseUpdatedListener(_ pair: (UUID, PurchaseUpdatedListener)) {
- purchaseUpdatedListeners.append((id: pair.0, listener: pair.1))
+ func addPurchaseUpdatedListener(
+ id: UUID,
+ listener: @escaping PurchaseUpdatedListener,
+ options: PurchaseUpdatedListenerOptions?
+ ) {
+ purchaseUpdatedListeners.append(PurchaseUpdatedListenerRegistration(
+ id: id,
+ listener: listener,
+ includeDuplicateTransactionUpdatesIOS: options?.includeDuplicateTransactionUpdatesIOS == true
+ ))
}
func addPurchaseErrorListener(_ pair: (UUID, PurchaseErrorListener)) {
purchaseErrorListeners.append((id: pair.0, listener: pair.1))
@@ -127,8 +153,13 @@ actor IapState {
subscriptionBillingIssueListeners.removeAll()
}
- func snapshotPurchaseUpdated() -> [PurchaseUpdatedListener] {
- purchaseUpdatedListeners.map { $0.listener }
+ func snapshotPurchaseUpdated(isDuplicate: Bool = false) -> [PurchaseUpdatedListener] {
+ purchaseUpdatedListeners.compactMap { registration in
+ guard !isDuplicate || registration.includeDuplicateTransactionUpdatesIOS else {
+ return nil
+ }
+ return registration.listener
+ }
}
func snapshotPurchaseError() -> [PurchaseErrorListener] {
purchaseErrorListeners.map { $0.listener }
diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift
index 4b6a09da..b42ddce2 100644
--- a/packages/apple/Sources/Models/Types.swift
+++ b/packages/apple/Sources/Models/Types.swift
@@ -1687,6 +1687,20 @@ public struct PurchaseOptions: Codable {
}
}
+public struct PurchaseUpdatedListenerOptions: Codable {
+ /// iOS only. When true, listener callbacks also receive StoreKit replay events
+ /// for a transaction ID that was already emitted during the current connection
+ /// session. Defaults to false so purchase success handlers run once per
+ /// transaction ID.
+ public var includeDuplicateTransactionUpdatesIOS: Bool?
+
+ public init(
+ includeDuplicateTransactionUpdatesIOS: Bool? = nil
+ ) {
+ self.includeDuplicateTransactionUpdatesIOS = includeDuplicateTransactionUpdatesIOS
+ }
+}
+
public struct RequestPurchaseAndroidProps: Codable {
/// Developer billing option parameters for external payments flow (8.3.0+).
/// When provided, the purchase flow will show a side-by-side choice between
@@ -2695,7 +2709,10 @@ public protocol SubscriptionResolver {
/// Fires when a purchase fails or is cancelled
func purchaseError() async throws -> PurchaseError
/// Fires when a purchase completes successfully or a pending purchase resolves
- func purchaseUpdated() async throws -> Purchase
+ /// Options can opt iOS listeners into duplicate StoreKit transaction replays
+ /// for diagnostics; default listeners receive one event per transaction ID
+ /// during a single connection session.
+ func purchaseUpdated(_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase
/// Fires when an active subscription enters a billing-issue state that needs user action
/// (payment method failed, card expired, etc.). Cross-platform unification:
///
@@ -2928,7 +2945,7 @@ public struct QueryHandlers {
public typealias SubscriptionDeveloperProvidedBillingAndroidHandler = () async throws -> DeveloperProvidedBillingDetailsAndroid
public typealias SubscriptionPromotedProductIOSHandler = () async throws -> String
public typealias SubscriptionPurchaseErrorHandler = () async throws -> PurchaseError
-public typealias SubscriptionPurchaseUpdatedHandler = () async throws -> Purchase
+public typealias SubscriptionPurchaseUpdatedHandler = (_ options: PurchaseUpdatedListenerOptions?) async throws -> Purchase
public typealias SubscriptionSubscriptionBillingIssueHandler = () async throws -> Purchase
public typealias SubscriptionUserChoiceBillingAndroidHandler = () async throws -> UserChoiceBillingDetails
diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift
index b88b65da..44881391 100644
--- a/packages/apple/Sources/OpenIapModule+ObjC.swift
+++ b/packages/apple/Sources/OpenIapModule+ObjC.swift
@@ -801,10 +801,21 @@ import StoreKit
// MARK: - Event Listeners
@objc func addPurchaseUpdatedListener(_ callback: @escaping (NSDictionary) -> Void) -> NSObject {
- let subscription = purchaseUpdatedListener { purchase in
+ addPurchaseUpdatedListener(callback, includeDuplicateTransactionUpdatesIOS: false)
+ }
+
+ @objc(addPurchaseUpdatedListener:includeDuplicateTransactionUpdatesIOS:)
+ func addPurchaseUpdatedListener(
+ _ callback: @escaping (NSDictionary) -> Void,
+ includeDuplicateTransactionUpdatesIOS: Bool
+ ) -> NSObject {
+ let options = PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: includeDuplicateTransactionUpdatesIOS
+ )
+ let subscription = purchaseUpdatedListener({ purchase in
let dictionary = OpenIapSerialization.purchase(purchase)
callback(dictionary as NSDictionary)
- }
+ }, options: options)
return subscription as NSObject
}
diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift
index 2bef865d..f813d6e0 100644
--- a/packages/apple/Sources/OpenIapModule.swift
+++ b/packages/apple/Sources/OpenIapModule.swift
@@ -376,26 +376,24 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
- Note: \(isSubscription ? "Subscription transactions will be emitted via Transaction.updates" : "Emitting directly")
""")
- let shouldEmit = await state.recordPurchaseUpdateEmission(id: transactionId)
+ let shouldEmit = await state.recordPurchaseUpdateEmission(
+ id: transactionId,
+ pendingTransaction: shouldAutoFinish ? nil : transaction
+ )
if shouldAutoFinish {
await transaction.finish()
await state.removePending(id: transactionId)
- } else if shouldEmit {
- await state.storePending(id: transactionId, transaction: transaction)
}
- // Emit purchase update
// StoreKit can replay unfinished transactions through multiple paths during a
- // connection session; only emit each transaction id once.
- if shouldEmit {
- emitPurchaseUpdate(purchase)
- } else {
- logDuplicatePurchaseUpdateSuppressed(
- source: "requestPurchase",
- transactionId: transactionId,
- productId: transaction.productID
- )
- }
+ // connection session. Default listeners receive each transaction id once;
+ // duplicate-enabled listeners can opt into the replay for diagnostics.
+ emitPurchaseUpdate(
+ purchase,
+ isDuplicate: !shouldEmit,
+ duplicateSource: "requestPurchase",
+ duplicateTransactionId: transactionId
+ )
return .purchase(purchase)
@@ -1379,9 +1377,18 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
// MARK: - Event Listener Registration
- public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription {
+ public func purchaseUpdatedListener(
+ _ listener: @escaping PurchaseUpdatedListener,
+ options: PurchaseUpdatedListenerOptions? = nil
+ ) -> Subscription {
let subscription = Subscription(eventType: .purchaseUpdated)
- Task { await state.addPurchaseUpdatedListener((subscription.id, listener)) }
+ Task {
+ await state.addPurchaseUpdatedListener(
+ id: subscription.id,
+ listener: listener,
+ options: options
+ )
+ }
return subscription
}
@@ -1643,19 +1650,22 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
continue
}
- // Emit once per transaction id for this connection session.
- guard await self.state.recordPurchaseUpdateEmission(id: transactionId) else {
- self.logDuplicatePurchaseUpdateSuppressed(
- source: "Transaction.updates",
- transactionId: transactionId,
- productId: transaction.productID
+ let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation)
+
+ // Default listeners receive each transaction id once per connection
+ // session. Duplicate-enabled listeners can opt into StoreKit replays.
+ guard await self.state.recordPurchaseUpdateEmission(
+ id: transactionId,
+ pendingTransaction: transaction
+ ) else {
+ self.emitPurchaseUpdate(
+ purchase,
+ isDuplicate: true,
+ duplicateSource: "Transaction.updates",
+ duplicateTransactionId: transactionId
)
continue
}
- await self.state.storePending(id: transactionId, transaction: transaction)
-
- let purchase = await StoreKitTypesBridge.purchase(from: transaction, jwsRepresentation: verification.jwsRepresentation)
-
OpenIapLog.debug("✅ [TransactionListener] Emitting transaction: \(transactionId) for product: \(transaction.productID)")
self.emitPurchaseUpdate(purchase)
} catch {
@@ -1789,24 +1799,41 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol {
}
}
- private func logDuplicatePurchaseUpdateSuppressed(
+ private func logDuplicatePurchaseUpdate(
source: String,
transactionId: String,
- productId: String
+ productId: String,
+ listenerCount: Int
) {
+ let action = listenerCount > 0
+ ? "Delivered duplicate purchase-updated event to \(listenerCount) duplicate-enabled listener(s)."
+ : "Suppressed duplicate purchase-updated listener emission."
OpenIapLog.warn("""
- [PurchaseUpdateDedup] Suppressed duplicate purchase-updated listener emission.
+ [PurchaseUpdateDedup] \(action)
- Source: \(source)
- Product: \(productId)
- Transaction ID: \(transactionId)
- Reason: this transaction id was already emitted during the current connection session.
- - Scope: only identical transaction ids are suppressed; distinct StoreKit transactions still emit.
+ - Scope: default listeners receive one event per transaction id; listeners registered with includeDuplicateTransactionUpdatesIOS receive StoreKit replays.
""")
}
- private func emitPurchaseUpdate(_ purchase: Purchase) {
+ private func emitPurchaseUpdate(
+ _ purchase: Purchase,
+ isDuplicate: Bool = false,
+ duplicateSource: String? = nil,
+ duplicateTransactionId: String? = nil
+ ) {
Task { [state] in
- let listeners = await state.snapshotPurchaseUpdated()
+ let listeners = await state.snapshotPurchaseUpdated(isDuplicate: isDuplicate)
+ if isDuplicate {
+ self.logDuplicatePurchaseUpdate(
+ source: duplicateSource ?? "unknown",
+ transactionId: duplicateTransactionId ?? purchase.id,
+ productId: purchase.productId,
+ listenerCount: listeners.count
+ )
+ }
OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)")
await MainActor.run {
listeners.forEach { $0(purchase) }
diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift
index cf4c0dca..1a67292c 100644
--- a/packages/apple/Sources/OpenIapProtocol.swift
+++ b/packages/apple/Sources/OpenIapProtocol.swift
@@ -93,7 +93,10 @@ public protocol OpenIapModuleProtocol {
func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void
// Event Listeners
- func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription
+ func purchaseUpdatedListener(
+ _ listener: @escaping PurchaseUpdatedListener,
+ options: PurchaseUpdatedListenerOptions?
+ ) -> Subscription
func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription
func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription
/// Listener for subscription billing-issue events (iOS 18+).
diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift
index 82f97172..c7229a69 100644
--- a/packages/apple/Sources/OpenIapStore.swift
+++ b/packages/apple/Sources/OpenIapStore.swift
@@ -60,9 +60,9 @@ public final class OpenIapStore: ObservableObject {
// MARK: - Listener Management
private func setupListeners() {
- let purchaseUpdate = module.purchaseUpdatedListener { [weak self] purchase in
+ let purchaseUpdate = module.purchaseUpdatedListener({ [weak self] purchase in
Task { @MainActor in self?.handlePurchaseUpdate(purchase) }
- }
+ }, options: nil)
listenerTokens.append(purchaseUpdate)
let purchaseError = module.purchaseErrorListener { [weak self] error in
diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift
index 61f67b2b..9fa959e9 100644
--- a/packages/apple/Tests/OpenIapTests.swift
+++ b/packages/apple/Tests/OpenIapTests.swift
@@ -113,6 +113,30 @@ final class OpenIapTests: XCTestCase {
XCTAssertTrue(replayEmission)
}
+ @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *)
+ func testPurchaseUpdateDuplicateSnapshotOnlyIncludesOptInListeners() async {
+ let state = IapState()
+
+ await state.addPurchaseUpdatedListener(
+ id: UUID(),
+ listener: { _ in },
+ options: nil
+ )
+ await state.addPurchaseUpdatedListener(
+ id: UUID(),
+ listener: { _ in },
+ options: PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: true
+ )
+ )
+
+ let normalListeners = await state.snapshotPurchaseUpdated()
+ let duplicateListeners = await state.snapshotPurchaseUpdated(isDuplicate: true)
+
+ XCTAssertEqual(normalListeners.count, 2)
+ XCTAssertEqual(duplicateListeners.count, 1)
+ }
+
func testPurchaseIOSWithRenewalInfo() {
let renewalInfo = RenewalInfoIOS(
autoRenewPreference: "dev.hyo.premium_year",
diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift
index 0726ecff..3563d95f 100644
--- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift
+++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseTests.swift
@@ -170,7 +170,10 @@ private final class FakeOpenIapModule: OpenIapModuleProtocol {
func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { () }
// MARK: - Event Listeners
- func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription {
+ func purchaseUpdatedListener(
+ _ listener: @escaping PurchaseUpdatedListener,
+ options: PurchaseUpdatedListenerOptions?
+ ) -> Subscription {
Subscription(eventType: .purchaseUpdated)
}
diff --git a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift
index 2daf5efa..fd2d523c 100644
--- a/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift
+++ b/packages/apple/Tests/OpenIapTests/VerifyPurchaseWithProviderTests.swift
@@ -155,7 +155,10 @@ private final class FakeVerifyPurchaseModule: OpenIapModuleProtocol {
func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void { () }
// MARK: - Event Listeners
- func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener) -> Subscription {
+ func purchaseUpdatedListener(
+ _ listener: @escaping PurchaseUpdatedListener,
+ options: PurchaseUpdatedListenerOptions?
+ ) -> Subscription {
Subscription(eventType: .purchaseUpdated)
}
diff --git a/packages/docs/openiap-versions.json b/packages/docs/openiap-versions.json
index 6721d2c1..998c5884 100644
--- a/packages/docs/openiap-versions.json
+++ b/packages/docs/openiap-versions.json
@@ -1,5 +1,5 @@
{
- "spec": "2.0.1",
+ "spec": "2.0.2",
"google": "2.1.4",
"apple": "2.1.8"
}
diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts
index 1ec9bdc0..29616240 100644
--- a/packages/docs/src/lib/searchData.ts
+++ b/packages/docs/src/lib/searchData.ts
@@ -656,6 +656,14 @@ export const apiData: ApiItem[] = [
'ProductRequest, RequestPurchaseProps, platform-specific request types',
path: '/docs/types/request-purchase-props',
},
+ {
+ id: 'purchase-updated-listener-options',
+ title: 'PurchaseUpdatedListenerOptions',
+ category: 'Types',
+ description:
+ 'Options for purchaseUpdatedListener, including iOS duplicate StoreKit replay delivery',
+ path: '/docs/types/purchase-updated-listener-options',
+ },
{
id: 'types-verification',
title: 'Verification Types',
diff --git a/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx b/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx
index 2e020d5d..cd94c670 100644
--- a/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx
+++ b/packages/docs/src/pages/docs/events/purchase-updated-listener.tsx
@@ -21,20 +21,38 @@ function PurchaseUpdatedListener() {
completed.
+
Duplicate Transaction Replays on iOS
+
+ StoreKit can replay the same unfinished transaction through more than
+ one native path during a single connection session. By default, OpenIAP
+ delivers one purchaseUpdated event per iOS transaction ID
+ to purchase-success listeners, while still keeping distinct transactions
+ separate. This prevents entitlement delivery from running twice for the
+ same purchase.
+
+
+ For diagnostics, register the purchase update listener with{' '}
+ includeDuplicateTransactionUpdatesIOS: true. The flag
+ belongs to the purchase update listener only; purchase error listeners
+ do not receive successful StoreKit transactions.
+
Registers a listener for successful purchase events.
+
Opt In to iOS StoreKit Replays
+
+ {{
+ typescript: (
+ {`const subscription = purchaseUpdatedListener(
+ (purchase) => {
+ console.log('StoreKit replay or first delivery:', purchase.id);
+ },
+ { includeDuplicateTransactionUpdatesIOS: true }
+);`}
+ ),
+ swift: (
+ {`let subscription = OpenIapModule.shared.purchaseUpdatedListener(
+ { purchase in
+ print("StoreKit replay or first delivery: \\(purchase.id)")
+ },
+ options: PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: true
+ )
+)`}
+ ),
+ kmp: (
+ {`val updates = kmpIAP.purchaseUpdatedListener(
+ PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS = true
+ )
+)`}
+ ),
+ dart: (
+ {`final updates = FlutterInappPurchase.instance
+ .purchaseUpdatedListenerWithOptions(
+ const PurchaseUpdatedListenerOptions(
+ includeDuplicateTransactionUpdatesIOS: true,
+ ),
+);`}
+ ),
+ csharp: (
+ {`var updates = Iap.Instance.PurchaseUpdatedWithOptions(
+ new PurchaseUpdatedListenerOptions
+ {
+ IncludeDuplicateTransactionUpdatesIOS = true,
+ });`}
+ ),
+ gdscript: (
+ {`var options = Types.PurchaseUpdatedListenerOptions.new()
+options.include_duplicate_transaction_updates_ios = true
+iap.set_purchase_updated_listener_options(options)`}
+ ),
+ }}
+
+
{{
typescript: (
diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx
index cc88b34e..24888e39 100644
--- a/packages/docs/src/pages/docs/index.tsx
+++ b/packages/docs/src/pages/docs/index.tsx
@@ -20,6 +20,7 @@ import TypesStorefront from './types/storefront';
import TypesPurchase from './types/purchase';
import TypesActiveSubscription from './types/active-subscription';
import TypesProductRequest from './types/product-request';
+import TypesPurchaseUpdatedListenerOptions from './types/purchase-updated-listener-options';
import TypesRequestPurchaseProps from './types/request-purchase-props';
import TypesAlternativeBillingTypes from './types/alternative-billing-types';
import TypesBillingPrograms from './types/billing-programs';
@@ -256,6 +257,10 @@ function Docs() {
to: '/docs/types/request-purchase-props',
label: 'RequestPurchaseProps',
},
+ {
+ to: '/docs/types/purchase-updated-listener-options',
+ label: 'PurchaseUpdatedListenerOptions',
+ },
{
to: '/docs/types/discount-offer',
label: 'DiscountOffer',
@@ -819,6 +824,10 @@ function Docs() {
path="types/request-purchase-props"
element={}
/>
+ }
+ />
}
diff --git a/packages/docs/src/pages/docs/types/index.tsx b/packages/docs/src/pages/docs/types/index.tsx
index c18bff1d..276c8535 100644
--- a/packages/docs/src/pages/docs/types/index.tsx
+++ b/packages/docs/src/pages/docs/types/index.tsx
@@ -18,6 +18,8 @@ const LEGACY_ANCHOR_REDIRECTS: Record = {
'active-subscription': '/docs/types/active-subscription',
'product-request': '/docs/types/product-request',
'request-purchase-props': '/docs/types/request-purchase-props',
+ 'purchase-updated-listener-options':
+ '/docs/types/purchase-updated-listener-options',
'discount-offer': '/docs/types/discount-offer',
'subscription-offer': '/docs/types/subscription-offer',
'verify-purchase': '/docs/types/verify-purchase',
@@ -121,6 +123,11 @@ const COMMON_TYPES: TypeRow[] = [
name: 'RequestPurchaseProps',
description: 'Discriminated union for one-time purchases or subscriptions.',
},
+ {
+ to: '/docs/types/purchase-updated-listener-options',
+ name: 'PurchaseUpdatedListenerOptions',
+ description: 'Options for purchase update listener replay behavior.',
+ },
{
to: '/docs/types/discount-offer',
name: 'DiscountOffer',
diff --git a/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx
new file mode 100644
index 00000000..da7bafb0
--- /dev/null
+++ b/packages/docs/src/pages/docs/types/purchase-updated-listener-options.tsx
@@ -0,0 +1,124 @@
+import { Link } from 'react-router-dom';
+import AnchorLink from '../../../components/AnchorLink';
+import CodeBlock from '../../../components/CodeBlock';
+import LanguageTabs from '../../../components/LanguageTabs';
+import SEO from '../../../components/SEO';
+import { useScrollToHash } from '../../../hooks/useScrollToHash';
+
+function PurchaseUpdatedListenerOptions() {
+ useScrollToHash();
+
+ return (
+
+
+
PurchaseUpdatedListenerOptions
+
+
+
+ PurchaseUpdatedListenerOptions
+
+
+ Options passed when registering{' '}
+
+ purchaseUpdatedListener
+
+ . The current option is iOS-only and controls whether a listener
+ receives StoreKit replay events for transaction IDs already delivered
+ during the current connection session.
+
+
+
+ Fields
+
+
+
+
+
Name
+
Type
+
Summary
+
+
+
+
+
+ includeDuplicateTransactionUpdatesIOS
+
+
+ boolean?
+
+
+ iOS only. Defaults to false. When true, the
+ listener also receives StoreKit replay events for a transaction
+ ID already emitted during the current connection session.
+
+
+
+
+
+
+ Default Behavior
+
+
+ The default is designed for entitlement safety: purchase success
+ handlers run once per iOS transaction ID during one connection
+ session. Enable the flag only when you need to inspect native StoreKit
+ replay behavior or build your own duplicate handling.
+
+ Publishes OpenIAP Spec 2.0.2 with{' '}
+ PurchaseUpdatedListenerOptions and an iOS-only{' '}
+ includeDuplicateTransactionUpdatesIOS flag. StoreKit
+ can replay the same unfinished transaction through request and
+ transaction-update paths during a single connection session. The
+ default listener behavior remains entitlement-safe: one purchase
+ success event per iOS transaction ID. Diagnostics can opt into the
+ StoreKit replay stream explicitly. Track the fix in{' '}
+
+ issue #152
+ {' '}
+ and{' '}
+
+ PR #153
+
+ .
+
+
+
+
+ Listener-level opt-in — React Native and Expo
+ accept the flag on purchaseUpdatedListener; Flutter,
+ KMP, MAUI, and Godot expose equivalent stream or signal-level
+ options without changing default purchase success handling.
+
+
+ Native debugging preserved — openiap-apple no
+ longer drops duplicate StoreKit updates before framework bridges
+ can observe them. Default listeners suppress duplicates, while
+ duplicate-enabled listeners receive the replay.
+
+
+ Docs and type sync — the generated GQL types now
+ include PurchaseUpdatedListenerOptions across Swift,
+ Kotlin, TypeScript, Dart, GDScript, and C#.
+
+
+ Usage guide — see{' '}
+
+ purchaseUpdatedListener
+ {' '}
+ for the default behavior and opt-in examples.
+
- iOS only. Defaults to false. When true, the
+ iOS only. Defaults to true. When false, the
listener also receives StoreKit replay events for a transaction
ID already emitted during the current connection session.
+ Android ignores this option.
@@ -66,8 +71,9 @@ function PurchaseUpdatedListenerOptions() {
The default is designed for entitlement safety: purchase success
handlers run once per iOS transaction ID during one connection
- session. Enable the flag only when you need to inspect native StoreKit
- replay behavior or build your own duplicate handling.
+ session. Set the flag to false only when you need to
+ inspect native StoreKit replay behavior or build your own duplicate
+ handling.
@@ -77,28 +83,28 @@ function PurchaseUpdatedListenerOptions() {
{{
typescript: (
{`purchaseUpdatedListener(onPurchase, {
- includeDuplicateTransactionUpdatesIOS: true,
+ dedupeTransactionIOS: false,
});`}
),
swift: (
{`OpenIapModule.shared.purchaseUpdatedListener(
onPurchase,
options: PurchaseUpdatedListenerOptions(
- includeDuplicateTransactionUpdatesIOS: true
+ dedupeTransactionIOS: false
)
)`}
),
kmp: (
{`kmpIAP.purchaseUpdatedListener(
PurchaseUpdatedListenerOptions(
- includeDuplicateTransactionUpdatesIOS = true
+ dedupeTransactionIOS = false
)
)`}
),
dart: (
{`FlutterInappPurchase.instance.purchaseUpdatedListenerWithOptions(
const PurchaseUpdatedListenerOptions(
- includeDuplicateTransactionUpdatesIOS: true,
+ dedupeTransactionIOS: false,
),
);`}
),
@@ -106,12 +112,12 @@ function PurchaseUpdatedListenerOptions() {
{`Iap.Instance.PurchaseUpdatedWithOptions(
new PurchaseUpdatedListenerOptions
{
- IncludeDuplicateTransactionUpdatesIOS = true,
+ DedupeTransactionIOS = false,
});`}
),
gdscript: (
{`var options = Types.PurchaseUpdatedListenerOptions.new()
-options.include_duplicate_transaction_updates_ios = true
+options.dedupe_transaction_ios = false
iap.set_purchase_updated_listener_options(options)`}
),
}}
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index b078abef..3f2aee13 100644
--- a/packages/docs/src/pages/docs/updates/releases.tsx
+++ b/packages/docs/src/pages/docs/updates/releases.tsx
@@ -50,12 +50,13 @@ function Releases() {
>
Publishes OpenIAP Spec 2.0.2 with{' '}
PurchaseUpdatedListenerOptions and an iOS-only{' '}
- includeDuplicateTransactionUpdatesIOS flag. StoreKit
- can replay the same unfinished transaction through request and
- transaction-update paths during a single connection session. The
- default listener behavior remains entitlement-safe: one purchase
- success event per iOS transaction ID. Diagnostics can opt into
- StoreKit replay events explicitly. Track the fix in{' '}
+ dedupeTransactionIOS flag. StoreKit can replay the same
+ unfinished transaction through request and transaction-update paths
+ during a single connection session. The default listener behavior
+ remains entitlement-safe: one purchase success event per iOS
+ transaction ID because the flag defaults to true. Diagnostics can
+ opt into StoreKit replay events by setting it to false. Track the
+ fix in{' '}
Native debugging preserved — openiap-apple no
longer drops duplicate StoreKit updates before framework bridges
can observe them. Default listeners suppress duplicates, while
- duplicate-enabled listeners receive the replay.
+ listeners with dedupeTransactionIOS: false receive
+ the replay.