Skip to content

[iOS] Undocumented breaking change when moving from Cordova (Storekit 1) to Capacitor version (Storekit 2) with consumables #1714

Description

@cmaas

My app uses consumables / non-renewing subscriptions. The Cordova version uses Storekit 1, the Capacitor version uses Storekit 2. While the README says the API is the same, there's actually a breaking change when migrating to SK2.

When initializing the store, SK1 checks only for unconsumed / unfinished transactions. SK2 checks for currentEntitlements.

So, if this is my app code:

 CdvPurchase.store.register({ id: 'my.product', type: CdvPurchase.ProductType.CONSUMABLE, platform: Platform.APPLE_APPSTORE });
 CdvPurchase.store.when().approved(t => { deliverToServer(t); t.finish(); });
 CdvPurchase.store.initialize();

In SK1, this won't send it to the server when starting the app. In SK2, it will send already consumed transactions to the server.

So, not sure what your goal is @j3k0 but for maximum compatibility, I gonna monkey-patch PurchasePlugin.swift.

Edit: Ok I found another bug, a race condition: Unfinished consumables are put into self.sk2.processedTransactionIds after they were send to JS listeners. If your JS-side store setup is a little slow or you delay this, there is no listener catching that transaction. So later, if your JS store starts to listen, they will receive all FINISHED entitlements and the UNFINISHED ones hang in limbo, because they're marked as processed in the Swift code.

Anyways, Fable 5 wrote the code below, which fixes both at once, if anyone else needs it:

import Foundation
import Capacitor
import StoreKit

@available(iOS 15.0, *)
private class SK2State {
    var products: [String: Product] = [:]
    var unfinishedTransactions: [String: Transaction] = [:]
    var transactionObserverTask: Task<Void, Never>?
    /// Transaction IDs already emitted to JS. Prevents duplicate delivery when
    /// both init() (via currentEntitlements) and Transaction.updates deliver
    /// the same transaction. Access must be serialized on the main thread.
    var processedTransactionIds: Set<UInt64> = []
}

@objc(PurchasePlugin)
public class PurchasePlugin: CAPPlugin, CAPBridgedPlugin {

    public let identifier = "PurchasePlugin"
    public let jsName = "PurchasePlugin"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "init", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "load", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "purchase", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "finish", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "canMakePayments", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "restore", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "manageSubscriptions", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "manageBilling", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "presentCodeRedemptionSheet", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "refreshReceipts", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "loadReceipts", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "getStorefront", returnType: CAPPluginReturnPromise),
    ]

    private var _sk2State: AnyObject?
    private var debugEnabled = false

    @available(iOS 15.0, *)
    private var sk2: SK2State {
        if let s = _sk2State as? SK2State { return s }
        let s = SK2State()
        _sk2State = s
        return s
    }

    // MARK: - Lifecycle

    deinit {
        if #available(iOS 15.0, *) {
            sk2.transactionObserverTask?.cancel()
        }
    }

    /// Start listening to Transaction.updates. Called at the end of init(),
    /// NOT at plugin load: Transaction.updates delivers unfinished
    /// transactions as soon as iteration begins, and before JS called init()
    /// no listener is attached — the events would be lost while their ids
    /// still land in processedTransactionIds, hiding unfinished purchases
    /// for the rest of the session.
    @available(iOS 15.0, *)
    private func startTransactionObserver() {
        guard sk2.transactionObserverTask == nil else { return }
        sk2.transactionObserverTask = Task.detached { [weak self] in
            for await result in Transaction.updates {
                guard let self = self else { return }
                await self.handleTransactionUpdate(result)
            }
        }
    }

    @available(iOS 15.0, *)
    private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async {
        let jwsRepresentation = result.jwsRepresentation
        switch result {
        case .verified(let transaction):
            guard await markEmittedToJs(transaction.id) else {
                debugLog("Transaction.updates: skipping duplicate id=\(transaction.id)")
                return
            }
            await emitTransactionUpdate(transaction, state: "PaymentTransactionStatePurchased",
                                        jwsRepresentation: jwsRepresentation)
        case .unverified(let transaction, _):
            // Emit as Purchased — let JS layer handle verification
            await emitTransactionUpdate(transaction, state: "PaymentTransactionStatePurchased",
                                        jwsRepresentation: jwsRepresentation)
        }
    }

    // MARK: - Plugin Methods

    @objc func `init`(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.reject("In-app purchases require iOS 15.0 or later")
            return
        }
        debugEnabled = call.getBool("debug", false)
        debugLog("init")

        Task {
            // Pass 1: emit current entitlements for product types where
            // currentEntitlements is authoritative (non-consumables and
            // auto-renewable subscriptions), so existing purchases are visible
            // immediately on app launch without a manual restore.
            //
            // Consumables and non-renewing subscriptions are deliberately NOT
            // sourced from currentEntitlements: the App Store keeps their
            // latest transaction in there forever (finished or not), so every
            // launch would re-trigger "approved" for long-consumed purchases.
            // They are covered by the unfinished pass below instead — matching
            // StoreKit 1, where only unfinished transactions are re-delivered.
            for await result in Transaction.currentEntitlements {
                switch result {
                case .verified(let transaction):
                    if transaction.isUpgraded {
                        debugLog("init: skipping upgraded entitlement id=\(transaction.id) product=\(transaction.productID)")
                        continue
                    }
                    if !self.isEntitlementAuthoritative(transaction) {
                        debugLog("init: \(transaction.productType.rawValue) id=\(transaction.id) product=\(transaction.productID) deferred to unfinished pass")
                        continue
                    }
                    guard await self.markEmittedToJs(transaction.id) else {
                        debugLog("init: skipping already-emitted entitlement id=\(transaction.id)")
                        continue
                    }
                    await self.emitTransactionUpdate(transaction,
                        state: "PaymentTransactionStateRestored",
                        jwsRepresentation: result.jwsRepresentation)
                case .unverified(let transaction, let error):
                    debugLog("init: unverified entitlement id=\(transaction.id) product=\(transaction.productID) error=\(error)")
                }
            }
            // Pass 2: emit every transaction the app hasn't finished yet,
            // regardless of product type. This is the only reliable source for
            // unfinished consumables and non-renewing subscriptions (on
            // iOS < 18 they never appear in currentEntitlements).
            for await result in Transaction.unfinished {
                switch result {
                case .verified(let transaction):
                    guard await self.markEmittedToJs(transaction.id) else { continue }
                    debugLog("init: emitting unfinished transaction id=\(transaction.id) product=\(transaction.productID)")
                    await self.emitTransactionUpdate(transaction,
                        state: "PaymentTransactionStateRestored",
                        jwsRepresentation: result.jwsRepresentation)
                case .unverified(let transaction, let error):
                    debugLog("init: unverified unfinished transaction id=\(transaction.id) product=\(transaction.productID) error=\(error)")
                }
            }
            // Only now start observing Transaction.updates — see the comment
            // on startTransactionObserver() for why starting earlier loses
            // unfinished transactions.
            self.startTransactionObserver()
            call.resolve()
        }
    }

    @objc func load(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.reject("In-app purchases require iOS 15.0 or later")
            return
        }
        guard let productIds = call.getArray("productIds") as? [String] else {
            call.reject("productIds is required")
            return
        }
        debugLog("load: \(productIds)")

        Task {
            do {
                let storeProducts = try await Product.products(for: Set(productIds))
                var validProducts: [[String: Any]] = []
                var validIds = Set<String>()

                for product in storeProducts {
                    sk2.products[product.id] = product
                    validProducts.append(await productToDict(product))
                    validIds.insert(product.id)
                }

                let invalidIds = productIds.filter { !validIds.contains($0) }

                call.resolve([
                    "validProducts": validProducts,
                    "invalidProductIds": invalidIds
                ])
            } catch {
                call.reject("Failed to load products: \(error.localizedDescription)")
            }
        }
    }

    @objc func purchase(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.reject("In-app purchases require iOS 15.0 or later")
            return
        }
        guard let productId = call.getString("productId") else {
            call.reject("productId is required")
            return
        }
        let quantity = call.getInt("quantity") ?? 1
        debugLog("purchase: \(productId) quantity:\(quantity)")

        guard let product = sk2.products[productId] else {
            call.reject("Product not loaded: \(productId)")
            return
        }

        Task {
            do {
                var options: Set<Product.PurchaseOption> = []
                if let username = call.getString("applicationUsername"), !username.isEmpty {
                    if let uuid = UUID(uuidString: username) {
                        options.insert(.appAccountToken(uuid))
                    } else {
                        debugLog("applicationUsername is not a valid UUID, appAccountToken will not be set: \(username)")
                    }
                }
                if quantity > 1 {
                    options.insert(.quantity(quantity))
                }

                // Clear expired unfinished transactions that could block the purchase
                await clearExpiredUnfinishedTransactions()

                let result = try await product.purchase(options: options)
                switch result {
                case .success(let verification):
                    let jwsRepresentation = verification.jwsRepresentation
                    switch verification {
                    case .verified(let transaction):
                        _ = await markEmittedToJs(transaction.id)
                        await emitTransactionUpdate(transaction,
                                                    state: "PaymentTransactionStatePurchased",
                                                    jwsRepresentation: jwsRepresentation)
                    case .unverified(let transaction, _):
                        // Emit as Purchased — let JS handle verification
                        await emitTransactionUpdate(transaction,
                                                    state: "PaymentTransactionStatePurchased",
                                                    jwsRepresentation: jwsRepresentation)
                    }
                    call.resolve()
                case .userCancelled:
                    notifyListeners("transactionUpdated", data: [
                        "state": "PaymentTransactionStateFailed",
                        "errorCode": 6777006,
                        "errorText": "Payment cancelled",
                        "productId": productId,
                        "transactionIdentifier": "",
                    ])
                    call.resolve()
                case .pending:
                    notifyListeners("transactionUpdated", data: [
                        "state": "PaymentTransactionStateDeferred",
                        "productId": productId,
                        "transactionIdentifier": "",
                    ])
                    call.resolve()
                @unknown default:
                    call.reject("Unknown purchase result")
                }
            } catch {
                call.reject("Purchase failed: \(error.localizedDescription)")
            }
        }
    }

    @objc func finish(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.reject("In-app purchases require iOS 15.0 or later")
            return
        }
        guard let transactionId = call.getString("transactionId") else {
            call.reject("transactionId is required")
            return
        }
        debugLog("finish: \(transactionId)")

        Task {
            if let transaction = sk2.unfinishedTransactions[transactionId] {
                await transaction.finish()
                sk2.unfinishedTransactions.removeValue(forKey: transactionId)
                // Note: do NOT remove from processedTransactionIds here.
                // Once a transaction ID has been emitted to JS, it must stay in the set
                // for the lifetime of the session to prevent Transaction.updates from
                // re-delivering it.
                notifyListeners("transactionUpdated", data: [
                    "state": "PaymentTransactionStateFinished",
                    "transactionIdentifier": transactionId,
                    "productId": transaction.productID,
                    "quantity": transaction.purchasedQuantity,
                ])
            }
            call.resolve()
        }
    }

    @objc func canMakePayments(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.resolve(["canMakePayments": false])
            return
        }
        call.resolve(["canMakePayments": AppStore.canMakePayments])
    }

    @objc func restore(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.reject("In-app purchases require iOS 15.0 or later")
            return
        }
        debugLog("restore")
        Task {
            do {
                try await AppStore.sync()
                // Same two-pass structure as init(). Restore deliberately
                // re-emits transactions already delivered earlier in the
                // session (that's its purpose), so only dedupe within this
                // call: an unfinished auto-renewable can show up in both
                // passes.
                var emitted = Set<UInt64>()
                for await result in Transaction.currentEntitlements {
                    switch result {
                    case .verified(let transaction):
                        if transaction.isUpgraded {
                            debugLog("restore: skipping upgraded entitlement id=\(transaction.id) product=\(transaction.productID)")
                            continue
                        }
                        if !self.isEntitlementAuthoritative(transaction) {
                            debugLog("restore: \(transaction.productType.rawValue) id=\(transaction.id) product=\(transaction.productID) deferred to unfinished pass")
                            continue
                        }
                        emitted.insert(transaction.id)
                        _ = await self.markEmittedToJs(transaction.id)
                        await emitTransactionUpdate(transaction,
                                                    state: "PaymentTransactionStateRestored",
                                                    jwsRepresentation: result.jwsRepresentation)
                    case .unverified(let transaction, let error):
                        debugLog("restore: unverified entitlement id=\(transaction.id) product=\(transaction.productID) error=\(error)")
                    }
                }
                for await result in Transaction.unfinished {
                    switch result {
                    case .verified(let transaction):
                        guard !emitted.contains(transaction.id) else { continue }
                        emitted.insert(transaction.id)
                        _ = await self.markEmittedToJs(transaction.id)
                        debugLog("restore: emitting unfinished transaction id=\(transaction.id) product=\(transaction.productID)")
                        await emitTransactionUpdate(transaction,
                                                    state: "PaymentTransactionStateRestored",
                                                    jwsRepresentation: result.jwsRepresentation)
                    case .unverified(let transaction, let error):
                        debugLog("restore: unverified unfinished transaction id=\(transaction.id) product=\(transaction.productID) error=\(error)")
                    }
                }
                notifyListeners("restoreCompleted", data: [:])
                call.resolve()
            } catch {
                notifyListeners("restoreFailed", data: ["errorCode": 0])
                call.reject("Restore failed: \(error.localizedDescription)")
            }
        }
    }

    @objc func manageSubscriptions(_ call: CAPPluginCall) {
        guard #available(iOS 15.0, *) else {
            call.reject("Managing subscriptions requires iOS 15.0 or later")
            return
        }
        Task { @MainActor in
            if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
                try? await AppStore.showManageSubscriptions(in: scene)
            }
            call.resolve()
        }
    }

    @objc func manageBilling(_ call: CAPPluginCall) {
        // No direct equivalent in SK2; resolve silently
        call.resolve()
    }

    @objc func presentCodeRedemptionSheet(_ call: CAPPluginCall) {
        Task { @MainActor in
            if #available(iOS 16.0, *) {
                if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
                    try? await AppStore.presentOfferCodeRedeemSheet(in: scene)
                }
            }
            call.resolve()
        }
    }

    @objc func refreshReceipts(_ call: CAPPluginCall) {
        guard #available(iOS 16.0, *) else {
            call.reject("refreshReceipts requires iOS 16.0 or later")
            return
        }
        Task {
            do {
                let appTransaction = try await AppTransaction.shared
                switch appTransaction {
                case .verified(let transaction):
                    call.resolve(["receipt": [
                        "bundleIdentifier": transaction.bundleID,
                        "appVersion": transaction.appVersion,
                    ]])
                case .unverified(_, _):
                    call.reject("App transaction verification failed")
                }
            } catch {
                call.reject("Failed to refresh receipts: \(error.localizedDescription)")
            }
        }
    }

    @objc func loadReceipts(_ call: CAPPluginCall) {
        // Same as refreshReceipts for SK2
        refreshReceipts(call)
    }

    @objc func getStorefront(_ call: CAPPluginCall) {
        // Try StoreKit 1 first (available from iOS 13).
        if let storefront = SKPaymentQueue.default().storefront {
            debugLog("getStorefront: \(storefront.countryCode)")
            call.resolve(["countryCode": storefront.countryCode])
            return
        }
        // Fallback to StoreKit 2's Storefront.current (available iOS 15+).
        // This works on Mac Catalyst where SK1's storefront is nil.
        if #available(iOS 15.0, macOS 12.0, *) {
            Task {
                if let storefront = await Storefront.current {
                    debugLog("getStorefront (SK2 fallback): \(storefront.countryCode)")
                    call.resolve(["countryCode": storefront.countryCode])
                } else {
                    debugLog("getStorefront: storefront not available")
                    call.reject("Storefront not available")
                }
            }
            return
        }
        debugLog("getStorefront: storefront not available")
        call.reject("Storefront not available")
    }

    // MARK: - Helpers

    /// Finish any unfinished transactions whose subscription has already expired.
    /// Stale unfinished transactions can block product.purchase() from initiating
    /// a new purchase flow (confirmed on Apple Developer Forums).
    @available(iOS 15.0, *)
    private func clearExpiredUnfinishedTransactions() async {
        for await result in Transaction.unfinished {
            guard case .verified(let transaction) = result else { continue }
            if let expirationDate = transaction.expirationDate, expirationDate < Date() {
                debugLog("clearExpired: finishing expired transaction id=\(transaction.id) product=\(transaction.productID) expired=\(expirationDate)")
                await transaction.finish()
                sk2.unfinishedTransactions.removeValue(forKey: String(transaction.id))
                // Note: do NOT remove from processedTransactionIds.
                // The set must grow monotonically to prevent re-delivery via Transaction.updates.
            }
        }
    }

    /// True when membership in Transaction.currentEntitlements is authoritative
    /// for the product type. For non-consumables and auto-renewable
    /// subscriptions, the App Store knows whether the user is entitled, so
    /// re-emitting the entitlement on every launch is correct. For consumables
    /// and non-renewing subscriptions the App Store cannot know when they are
    /// consumed or expired — their latest transaction stays in
    /// currentEntitlements indefinitely, even after finish() — so entitlement
    /// membership says nothing about whether the app still needs to process
    /// them; only their unfinished state does.
    @available(iOS 15.0, *)
    private func isEntitlementAuthoritative(_ transaction: Transaction) -> Bool {
        return transaction.productType != .consumable
            && transaction.productType != .nonRenewable
    }

    /// Atomically check-and-mark a transaction id as emitted to the JS layer.
    /// Returns false when it was already emitted this session. "Emitted" is
    /// unrelated to Apple's finished state — it only prevents duplicate
    /// delivery of the same transaction within one app session. Serialized on
    /// the main thread because the Transaction.updates observer runs in a
    /// detached Task while init()/restore()/purchase() run in their own Tasks.
    @available(iOS 15.0, *)
    private func markEmittedToJs(_ id: UInt64) async -> Bool {
        return await MainActor.run {
            if sk2.processedTransactionIds.contains(id) { return false }
            sk2.processedTransactionIds.insert(id)
            return true
        }
    }

    @available(iOS 15.0, *)
    private func emitTransactionUpdate(_ transaction: Transaction, state: String,
                                       errorCode: Int? = nil, errorText: String? = nil,
                                       jwsRepresentation: String? = nil) async {
        let transactionId = String(transaction.id)
        sk2.unfinishedTransactions[transactionId] = transaction

        var data: [String: Any] = [
            "state": state,
            "transactionIdentifier": transactionId,
            "productId": transaction.productID,
            "quantity": transaction.purchasedQuantity,
        ]

        if let errorCode = errorCode { data["errorCode"] = errorCode }
        if let errorText = errorText { data["errorText"] = errorText }
        // Only set originalTransactionIdentifier when it differs from the current ID
        if transaction.originalID != transaction.id {
            data["originalTransactionIdentifier"] = String(transaction.originalID)
        }
        // Use milliseconds-since-epoch to match SK2 bridge expectations
        data["transactionDate"] = String(Int(transaction.purchaseDate.timeIntervalSince1970 * 1000))

        if let expirationDate = transaction.expirationDate {
            data["expirationDate"] = String(Int(expirationDate.timeIntervalSince1970 * 1000))
        }
        if let jws = jwsRepresentation {
            data["jwsRepresentation"] = jws
        }

        notifyListeners("transactionUpdated", data: data)
    }

    @available(iOS 15.0, *)
    private func productToDict(_ product: Product) async -> [String: Any] {
        var dict: [String: Any] = [
            "id": product.id,
            "title": product.displayName,
            "description": product.description,
            "price": product.displayPrice,
            "priceMicros": NSDecimalNumber(decimal: product.price)
                .multiplying(by: 1000000).int64Value,
            "currency": product.priceFormatStyle.currencyCode,
            "countryCode": {
                if #available(iOS 16.0, *) {
                    return product.priceFormatStyle.locale.region?.identifier ?? ""
                } else {
                    return Locale.current.regionCode ?? ""
                }
            }(),
        ]

        if let subscription = product.subscription {
            let unit = subscription.subscriptionPeriod.unit
            let value = subscription.subscriptionPeriod.value
            dict["billingPeriod"] = value
            dict["billingPeriodUnit"] = periodUnitToString(unit)
            dict["group"] = subscription.subscriptionGroupID

            // Introductory offer
            if let intro = subscription.introductoryOffer {
                dict["introPrice"] = intro.displayPrice
                dict["introPriceMicros"] = NSDecimalNumber(decimal: intro.price)
                    .multiplying(by: 1000000).int64Value
                dict["introPricePeriod"] = intro.period.value
                dict["introPricePeriodUnit"] = periodUnitToString(intro.period.unit)
                dict["introPricePaymentMode"] = paymentModeToString(intro.paymentMode)
                // StoreKit 2 eligibility — honors the user's prior subscription/trial history.
                // Only meaningful when an intro offer exists; omitted otherwise so older TS
                // builds that don't know about this field keep their SK1-era behavior.
                dict["introPriceEligible"] = await subscription.isEligibleForIntroOffer
            }

            // Promotional offers (discounts)
            var discounts: [[String: Any]] = []
            for offer in subscription.promotionalOffers {
                discounts.append([
                    "id": offer.id ?? "",
                    "type": "Subscription",
                    "price": offer.displayPrice,
                    "priceMicros": NSDecimalNumber(decimal: offer.price)
                        .multiplying(by: 1000000).int64Value,
                    "period": offer.period.value,
                    "periodUnit": periodUnitToString(offer.period.unit),
                    "paymentMode": paymentModeToString(offer.paymentMode),
                ])
            }
            if !discounts.isEmpty {
                dict["discounts"] = discounts
            }
        }

        return dict
    }

    @available(iOS 15.0, *)
    private func periodUnitToString(_ unit: Product.SubscriptionPeriod.Unit) -> String {
        switch unit {
        case .day: return "Day"
        case .week: return "Week"
        case .month: return "Month"
        case .year: return "Year"
        @unknown default: return "Day"
        }
    }

    @available(iOS 15.0, *)
    private func paymentModeToString(_ mode: Product.SubscriptionOffer.PaymentMode) -> String {
        switch mode {
        case .payAsYouGo: return "PayAsYouGo"
        case .payUpFront: return "PayUpFront"
        case .freeTrial: return "FreeTrial"
        default: return "PayAsYouGo"
        }
    }

    private func debugLog(_ msg: String) {
        if debugEnabled {
            print("PurchasePlugin[debug]: \(msg)")
        }
    }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions