@@ -46,20 +46,21 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
4646
4747 // MARK: - Lifecycle
4848
49- override public func load( ) {
50- if #available( iOS 15 . 0 , * ) {
51- startTransactionObserver ( )
52- }
53- }
54-
5549 deinit {
5650 if #available( iOS 15 . 0 , * ) {
5751 sk2. transactionObserverTask? . cancel ( )
5852 }
5953 }
6054
55+ /// Start listening to Transaction.updates. Called at the end of init(),
56+ /// NOT at plugin load: Transaction.updates delivers unfinished
57+ /// transactions as soon as iteration begins, and before JS called init()
58+ /// no listener is attached — the events would be lost while their ids
59+ /// still land in processedTransactionIds, hiding unfinished purchases
60+ /// for the rest of the session.
6161 @available ( iOS 15 . 0 , * )
6262 private func startTransactionObserver( ) {
63+ guard sk2. transactionObserverTask == nil else { return }
6364 sk2. transactionObserverTask = Task . detached { [ weak self] in
6465 for await result in Transaction . updates {
6566 guard let self = self else { return }
@@ -73,18 +74,10 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
7374 let jwsRepresentation = result. jwsRepresentation
7475 switch result {
7576 case . verified( let transaction) :
76- // Serialize access to processedTransactionIds on the main thread
77- // since this method runs from a detached Task while init()/purchase()
78- // also mutate the set.
79- let shouldEmit : Bool = await MainActor . run {
80- if sk2. processedTransactionIds. contains ( transaction. id) {
81- debugLog ( " Transaction.updates: skipping duplicate id= \( transaction. id) " )
82- return false
83- }
84- sk2. processedTransactionIds. insert ( transaction. id)
85- return true
77+ guard await markEmittedToJs ( transaction. id) else {
78+ debugLog ( " Transaction.updates: skipping duplicate id= \( transaction. id) " )
79+ return
8680 }
87- guard shouldEmit else { return }
8881 await emitTransactionUpdate ( transaction, state: " PaymentTransactionStatePurchased " ,
8982 jwsRepresentation: jwsRepresentation)
9083 case . unverified( let transaction, _) :
@@ -104,8 +97,9 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
10497 debugEnabled = call. getBool ( " debug " , false )
10598 debugLog ( " init " )
10699
107- // Load current entitlements and emit to JS so existing subscriptions
108- // are visible immediately on app launch (without requiring a manual restore).
100+ // Load current entitlements and unfinished transactions, and emit them
101+ // to JS so existing purchases are visible immediately on app launch
102+ // (without requiring a manual restore).
109103 Task {
110104 for await result in Transaction . currentEntitlements {
111105 switch result {
@@ -114,19 +108,37 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
114108 debugLog ( " init: skipping upgraded entitlement id= \( transaction. id) product= \( transaction. productID) " )
115109 continue
116110 }
117- if self . sk2 . processedTransactionIds . contains ( transaction. id) {
118- debugLog ( " init: skipping already-processed entitlement id= \( transaction. id) " )
111+ guard await self . markEmittedToJs ( transaction. id) else {
112+ debugLog ( " init: skipping already-emitted entitlement id= \( transaction. id) " )
119113 continue
120114 }
121- self . sk2. processedTransactionIds. insert ( transaction. id)
122- self . sk2. unfinishedTransactions [ String ( transaction. id) ] = transaction
123115 await self . emitTransactionUpdate ( transaction,
124116 state: " PaymentTransactionStateRestored " ,
125117 jwsRepresentation: result. jwsRepresentation)
126118 case . unverified( let transaction, let error) :
127119 debugLog ( " init: unverified entitlement id= \( transaction. id) product= \( transaction. productID) error= \( error) " )
128120 }
129121 }
122+ // Emit every transaction the app hasn't finished yet. This is the
123+ // only reliable source for unfinished consumables: they never
124+ // appear in currentEntitlements, so without this pass an
125+ // unfinished consumable would hang in limbo for the session.
126+ for await result in Transaction . unfinished {
127+ switch result {
128+ case . verified( let transaction) :
129+ guard await self . markEmittedToJs ( transaction. id) else { continue }
130+ debugLog ( " init: emitting unfinished transaction id= \( transaction. id) product= \( transaction. productID) " )
131+ await self . emitTransactionUpdate ( transaction,
132+ state: " PaymentTransactionStateRestored " ,
133+ jwsRepresentation: result. jwsRepresentation)
134+ case . unverified( let transaction, let error) :
135+ debugLog ( " init: unverified unfinished transaction id= \( transaction. id) product= \( transaction. productID) error= \( error) " )
136+ }
137+ }
138+ // Only now start observing Transaction.updates — see the comment
139+ // on startTransactionObserver() for why starting earlier loses
140+ // unfinished transactions.
141+ self . startTransactionObserver ( )
130142 call. resolve ( )
131143 }
132144 }
@@ -206,7 +218,7 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
206218 let jwsRepresentation = verification. jwsRepresentation
207219 switch verification {
208220 case . verified( let transaction) :
209- sk2 . processedTransactionIds . insert ( transaction. id)
221+ _ = await markEmittedToJs ( transaction. id)
210222 await emitTransactionUpdate ( transaction,
211223 state: " PaymentTransactionStatePurchased " ,
212224 jwsRepresentation: jwsRepresentation)
@@ -296,7 +308,9 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
296308 debugLog ( " restore: skipping upgraded entitlement id= \( transaction. id) product= \( transaction. productID) " )
297309 continue
298310 }
299- sk2. processedTransactionIds. insert ( transaction. id)
311+ // Restore deliberately re-emits transactions already
312+ // delivered this session — mark, but ignore the result.
313+ _ = await markEmittedToJs ( transaction. id)
300314 await emitTransactionUpdate ( transaction,
301315 state: " PaymentTransactionStateRestored " ,
302316 jwsRepresentation: result. jwsRepresentation)
@@ -414,6 +428,21 @@ public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {
414428 }
415429 }
416430
431+ /// Atomically check-and-mark a transaction id as emitted to the JS layer.
432+ /// Returns false when it was already emitted this session. "Emitted" is
433+ /// unrelated to Apple's finished state — it only prevents duplicate
434+ /// delivery of the same transaction within one app session. Serialized on
435+ /// the main thread because the Transaction.updates observer runs in a
436+ /// detached Task while init()/restore()/purchase() run in their own Tasks.
437+ @available ( iOS 15 . 0 , * )
438+ private func markEmittedToJs( _ id: UInt64 ) async -> Bool {
439+ return await MainActor . run {
440+ if sk2. processedTransactionIds. contains ( id) { return false }
441+ sk2. processedTransactionIds. insert ( id)
442+ return true
443+ }
444+ }
445+
417446 @available ( iOS 15 . 0 , * )
418447 private func emitTransactionUpdate( _ transaction: Transaction , state: String ,
419448 errorCode: Int ? = nil , errorText: String ? = nil ,
0 commit comments