Skip to content
This repository was archived by the owner on Aug 4, 2026. It is now read-only.
This repository was archived by the owner on Aug 4, 2026. It is now read-only.

[iOS/Android] - High volume of purchases not being handled/notified #3150

Description

@renatomserra

Platform - iOS/Android
Purchase type - Sub / IAP

Versions

    "react-native-iap": "14.7.11",
    "react-native-nitro-modules": "0.31.1",
    "react-native": "0.79.7",

This the same issue reported on #3143 that has now been closed but not resolved.

Im still facing high volume of purchases not being reported by success handler or listeners.
Now that i activated debug logs in prod it seems the purchase is not even recognized by the lib debug logs but the user paid and says item already owned.

When calling getAvailablePurchases they are also not present.

Attaching logs of a user who had the issue and request purchase is not even listed as a log

[#<DebugLog:0x00007f1a3c11ec40
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] Calling fetchProducts with:", {"skus"=>["dogcat.plus.monthly", "dogcat.plus.yearly"], "type"=>"subs"}],
    "message"=>"[useIAP] Calling fetchProducts with: {\"skus\":[\"dogcat.plus.monthly\",\"dogcat.plus.yearly\"],\"type\":\"subs\"}"},
  user_id: "[ANON-USER-ID]",
  created_at: Sun, 15 Feb 2026 16:46:10.682277000 UTC +00:00,
  updated_at: Sun, 15 Feb 2026 16:46:10.682277000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11e880
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] fetchProducts result:"],
    "message"=>
     "[useIAP] fetchProducts result: [{\"id\":\"dogcat.plus.monthly\",\"title\":\"DogCat Plus (Pet Care Tracker - Dog Cat App)\",\"description\":\"Subscription that unlocks features in the app\",\"type\":\"subs\",\"displayName\":\"DogCat Plus\",\"displayPrice\":\"$5.49\",\"currency\":\"CAD\",\"price\":5.49,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"DogCat Plus\",\"oneTimePurchaseOfferDetailsAndroid\":null,\"subscriptionOfferDetailsAndroid\":[{\"basePlanId\":\"p1m\",\"offerId\":\"freetrial\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{\"pricingPhaseList\":[{\"billingCycleCount\":1,\"billingPeriod\":\"P1W\",\"formattedPrice\":\"Free\",\"priceAmountMicros\":\"0\",\"priceCurrencyCode\":\"CAD\",\"recurrenceMode\":2},{\"billingCycleCount\":0,\"billingPeriod\":\"P1M\",\"formattedPrice\":\"$5.49\",\"priceAmountMicros\":\"5490000\",\"priceCurrencyCode\":\"CAD\",\"recurrenceMode\":1}]}},{\"basePlanId\":\"p1m\",\"offerId\":\"freetrial-v2\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{\"pricingPhaseList\":[{\"billingCycleCount\":1,\"billingPeriod\":\"P1W\",\"formattedPrice\":\"Free\",\"priceAmountMicros\":\"0\",\"priceCurrencyCode\":\"CAD\",\"recurrenceMode\":2},{\"billingCycleCount\":0,\"billingPeriod\":\"P1M\",\"formattedPrice\":\"$5.49\",\"priceAmountMicros\":\"5490000\",\"priceCurrencyCode\":\"CAD\",\"recurrenceMode\":1}]}},{\"basePlanId\":\"p1m\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{\"pricingPhaseList\":[{\"billingCycleCount\":0,\"billingPeriod\":\"P1M\",\"formattedPrice\":\"$5.49\",\"priceAmountMicros\":\"5490000\",\"priceCurrencyCode\":\"CAD\",\"recurrenceMode\":1}]}}],\"productStatusAndroid\":null,\"subscriptionOffers\":[{\"id\":\"freetrial\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",\"offerTagsAndroid\":[],\"paymentMode\":\"free-trial\",\"periodCount\":1,\"period\":{\"unit\":\"week\",\"value\":1},\"pricingPhasesAndroid\":{...}},{\"id\":\"freetrial-v2\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",\"offerTagsAndroid\":[],\"paymentMode\":\"free-trial\",\"periodCount\":1,\"period\":{\"unit\":\"week\",\"value\":1},\"pricingPhasesAndroid\":{...}},{\"id\":\"p1m\",\"displayPrice\":\"$5.49\",\"price\":5.49,\"type\":\"introductory\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",\"offerTagsAndroid\":[],\"paymentMode\":\"pay-as-you-go\",\"periodCount\":0,\"period\":{\"unit\":\"month\",\"value\":1},\"pricingPhasesAndroid\":{...}}],\"discountOffers\":null},{\"id\":\"dogcat.plus.yearly\",\"title\":\"DogCat Plus Yearly (Pet Care Tracker - Dog Cat App)\",\"description\":\"Unlock all the app features\",\"type\":\"subs\",\"displayName\":\"DogCat Plus Yearly\",\"displayPrice\":\"$58.99\",\"currency\":\"CAD\",\"price\":58.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"DogCat Plus Yearly\",\"oneTimePurchaseOfferDetailsAndroid\":null,\"subscriptionOfferDetailsAndroid\":[{\"basePlanId\":\"p1y\",\"offerId\":\"freetrial\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1y\",\"offerId\":\"freetrial-v2\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1y\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}}],\"productStatusAndroid\":null,\"subscriptionOffers\":[{\"id\":\"freetrial\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"freetrial-v2\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"p1y\",\"displayPrice\":\"$58.99\",\"price\":58.99,\"type\":\"introductory\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...}],\"discountOffers\":null}]"},
  user_id: "[ANON-USER-ID]",
  created_at: Sun, 15 Feb 2026 16:46:10.708251000 UTC +00:00,
  updated_at: Sun, 15 Feb 2026 16:46:10.708251000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11e7e0
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] Calling fetchProducts with:", {"skus"=>["dogcat.plus.monthly", "dogcat.plus.yearly"], "type"=>"subs"}],
    "message"=>"[useIAP] Calling fetchProducts with: {\"skus\":[\"dogcat.plus.monthly\",\"dogcat.plus.yearly\"],\"type\":\"subs\"}"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:34:13.230677000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:34:13.230677000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11e740
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] fetchProducts result:"],
    "message"=>
     "[useIAP] fetchProducts result: [{\"id\":\"dogcat.plus.monthly\",\"title\":\"DogCat Plus (Pet Care Tracker - Dog Cat App)\",\"description\":\"Subscription that unlocks features in the app\",\"type\":\"subs\",\"displayName\":\"DogCat Plus\",\"displayPrice\":\"$5.49\",\"currency\":\"CAD\",\"price\":5.49,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"DogCat Plus\",\"oneTimePurchaseOfferDetailsAndroid\":null,\"subscriptionOfferDetailsAndroid\":[{\"basePlanId\":\"p1m\",\"offerId\":\"freetrial\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1m\",\"offerId\":\"freetrial-v2\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1m\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}}],\"productStatusAndroid\":null,\"subscriptionOffers\":[{\"id\":\"freetrial\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"freetrial-v2\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"p1m\",\"displayPrice\":\"$5.49\",\"price\":5.49,\"type\":\"introductory\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...}],\"discountOffers\":null},{\"id\":\"dogcat.plus.yearly\",\"title\":\"DogCat Plus Yearly (Pet Care Tracker - Dog Cat App)\",\"description\":\"Unlock all the app features\",\"type\":\"subs\",\"displayName\":\"DogCat Plus Yearly\",\"displayPrice\":\"$58.99\",\"currency\":\"CAD\",\"price\":58.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"DogCat Plus Yearly\",\"oneTimePurchaseOfferDetailsAndroid\":null,\"subscriptionOfferDetailsAndroid\":[{\"basePlanId\":\"p1y\",\"offerId\":\"freetrial\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1y\",\"offerId\":\"freetrial-v2\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1y\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}}],\"productStatusAndroid\":null,\"subscriptionOffers\":[{\"id\":\"freetrial\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"freetrial-v2\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"p1y\",\"displayPrice\":\"$58.99\",\"price\":58.99,\"type\":\"introductory\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...}],\"discountOffers\":null}]"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:34:13.392674000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:34:13.392674000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11e6a0
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>
     ["[useIAP] Calling fetchProducts with:",
      {"skus"=>["dogcat.avatars.pack.30", "dogcat.avatars.pack.60", "dogcat.avatars.pack.100"], "type"=>"in-app"}],
    "message"=>
     "[useIAP] Calling fetchProducts with: {\"skus\":[\"dogcat.avatars.pack.30\",\"dogcat.avatars.pack.60\",\"dogcat.avatars.pack.100\"],\"type\":\"in-app\"}"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:49:37.940779000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:49:37.940779000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11e380
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] fetchProducts result:"],
    "message"=>
     "[useIAP] fetchProducts result: [{\"id\":\"dogcat.avatars.pack.30\",\"title\":\"Pack of 30 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 30 Avatars\",\"displayPrice\":\"$6.99\",\"currency\":\"CAD\",\"price\":6.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 30 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$6.99\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"6990000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$6.99\",\"price\":6.99,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]},{\"id\":\"dogcat.avatars.pack.60\",\"title\":\"Pack of 60 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 60 Avatars\",\"displayPrice\":\"$12.99\",\"currency\":\"CAD\",\"price\":12.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 60 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$12.99\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"12990000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$12.99\",\"price\":12.99,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]},{\"id\":\"dogcat.avatars.pack.100\",\"title\":\"Pack of 100 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 100 Avatars\",\"displayPrice\":\"$17.99\",\"currency\":\"CAD\",\"price\":17.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 100 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$17.99\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"17990000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$17.99\",\"price\":17.99,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]}]"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:49:38.235084000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:49:38.235084000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11e100
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>
     ["[useIAP] Calling fetchProducts with:",
      {"skus"=>["dogcat.avatars.pack.10", "dogcat.avatars.pack.30", "dogcat.avatars.pack.60", "dogcat.avatars.pack.100"], "type"=>"in-app"}],
    "message"=>
     "[useIAP] Calling fetchProducts with: {\"skus\":[\"dogcat.avatars.pack.10\",\"dogcat.avatars.pack.30\",\"dogcat.avatars.pack.60\",\"dogcat.avatars.pack.100\"],\"type\":\"in-app\"}"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:50:38.930994000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:50:38.930994000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11df20
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] fetchProducts result:"],
    "message"=>
     "[useIAP] fetchProducts result: [{\"id\":\"dogcat.avatars.pack.10\",\"title\":\"Pack of 30 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 30 Avatars\",\"displayPrice\":\"$5.49\",\"currency\":\"CAD\",\"price\":5.49,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 30 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$5.49\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"5490000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$5.49\",\"price\":5.49,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]},{\"id\":\"dogcat.avatars.pack.30\",\"title\":\"Pack of 30 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 30 Avatars\",\"displayPrice\":\"$6.99\",\"currency\":\"CAD\",\"price\":6.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 30 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$6.99\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"6990000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$6.99\",\"price\":6.99,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]},{\"id\":\"dogcat.avatars.pack.60\",\"title\":\"Pack of 60 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 60 Avatars\",\"displayPrice\":\"$12.99\",\"currency\":\"CAD\",\"price\":12.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 60 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$12.99\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"12990000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$12.99\",\"price\":12.99,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]},{\"id\":\"dogcat.avatars.pack.100\",\"title\":\"Pack of 100 Avatars (Pet Care Tracker - Dog Cat App)\",\"description\":\"Create magical avatars of your Pet\",\"type\":\"in-app\",\"displayName\":\"Pack of 100 Avatars\",\"displayPrice\":\"$17.99\",\"currency\":\"CAD\",\"price\":17.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"Pack of 100 Avatars\",\"oneTimePurchaseOfferDetailsAndroid\":[{\"formattedPrice\":\"$17.99\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"priceAmountMicros\":\"17990000\",\"priceCurrencyCode\":\"CAD\"}],\"productStatusAndroid\":null,\"subscriptionOffers\":null,\"discountOffers\":[{\"currency\":\"CAD\",\"displayPrice\":\"$17.99\",\"price\":17.99,\"offerTagsAndroid\":[],\"offerTokenAndroid\":\"[ANON-TOKEN]\"}]}]"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:50:39.082247000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:50:39.082247000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11de80
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] Calling fetchProducts with:", {"skus"=>["dogcat.plus.monthly", "dogcat.plus.yearly"], "type"=>"subs"}],
    "message"=>"[useIAP] Calling fetchProducts with: {\"skus\":[\"dogcat.plus.monthly\",\"dogcat.plus.yearly\"],\"type\":\"subs\"}"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:54:20.116858000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:54:20.116858000 UTC +00:00,
  platform: "android">,
 #<DebugLog:0x00007f1a3c11dca0
  id: "[ANON-LOG-ID]",
  flow: "purchase",
  step: "debug",
  metadata:
   {"args"=>["[useIAP] fetchProducts result:"],
    "message"=>
     "[useIAP] fetchProducts result: [{\"id\":\"dogcat.plus.monthly\",\"title\":\"DogCat Plus (Pet Care Tracker - Dog Cat App)\",\"description\":\"Subscription that unlocks features in the app\",\"type\":\"subs\",\"displayName\":\"DogCat Plus\",\"displayPrice\":\"$5.49\",\"currency\":\"CAD\",\"price\":5.49,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"DogCat Plus\",\"oneTimePurchaseOfferDetailsAndroid\":null,\"subscriptionOfferDetailsAndroid\":[{\"basePlanId\":\"p1m\",\"offerId\":\"freetrial\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1m\",\"offerId\":\"freetrial-v2\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1m\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}}],\"productStatusAndroid\":null,\"subscriptionOffers\":[{\"id\":\"freetrial\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"freetrial-v2\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"p1m\",\"displayPrice\":\"$5.49\",\"price\":5.49,\"type\":\"introductory\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1m\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...}],\"discountOffers\":null},{\"id\":\"dogcat.plus.yearly\",\"title\":\"DogCat Plus Yearly (Pet Care Tracker - Dog Cat App)\",\"description\":\"Unlock all the app features\",\"type\":\"subs\",\"displayName\":\"DogCat Plus Yearly\",\"displayPrice\":\"$58.99\",\"currency\":\"CAD\",\"price\":58.99,\"debugDescription\":null,\"platform\":\"android\",\"nameAndroid\":\"DogCat Plus Yearly\",\"oneTimePurchaseOfferDetailsAndroid\":null,\"subscriptionOfferDetailsAndroid\":[{\"basePlanId\":\"p1y\",\"offerId\":\"freetrial\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1y\",\"offerId\":\"freetrial-v2\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}},{\"basePlanId\":\"p1y\",\"offerTags\":[],\"offerToken\":\"[ANON-TOKEN]\",\"pricingPhases\":{...}}],\"productStatusAndroid\":null,\"subscriptionOffers\":[{\"id\":\"freetrial\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"freetrial-v2\",\"displayPrice\":\"Free\",\"price\":0,\"type\":\"promotional\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...},{\"id\":\"p1y\",\"displayPrice\":\"$58.99\",\"price\":58.99,\"type\":\"introductory\",\"currency\":\"CAD\",\"basePlanIdAndroid\":\"p1y\",\"offerTokenAndroid\":\"[ANON-TOKEN]\",...}],\"discountOffers\":null}]"},
  user_id: "[ANON-USER-ID]",
  created_at: Mon, 16 Feb 2026 16:54:20.231202000 UTC +00:00,
  updated_at: Mon, 16 Feb 2026 16:54:20.231202000 UTC +00:00,
  platform: "android">]

If theres anything i can do to help, let me know

Activity

  1. 404-html commented on Feb 16, 2026

    @404-html
    Contributor

    Maybe this will help identifying the issue:

    I started getting the issue after performing following update:

    react-native-iap: 12.16.2 => 14.4.11
    react-native: 0.75.3 => 0.81.5 
    

    Both builds on old RN architecture.
    Then, I refactored my code to use useIAP hook, hoping this will resolve issue - still the same problem.
    Then, I updated react-native-iap to 14.7.11 - still the same problem.

  2. Repository owner deleted a comment from gitdogbot on Feb 18, 2026
  3. hyochan commented on Feb 18, 2026

    @hyochan
    Owner

    Could you try 14.7.12 when released? I've managed to fix potential issus in #3151

  4. hyochan commented on Feb 18, 2026

    @hyochan
    Owner

    @renatomserra @404-html

    I've investigated the codebase thoroughly. Here's what I found:

    Root Cause Analysis

    1. Listener Race Condition on Navigation/Remount

    This is the most likely cause. When useIAP unmounts (navigation, screen change):

    1. Cleanup runs → .remove() clears ALL JS listeners from the native arrays (both removePurchaseUpdatedListener and removePurchaseErrorListener call .removeAll() on both platforms)
    2. Connection stays alive (by design — "Keep connection alive across screens")
    3. OpenIAP listeners remain active and continue receiving events from the store

    Between unmount and the next useIAP mount, any purchase events from OpenIAP call sendPurchaseUpdate which iterates over an EMPTY listener array → events are silently dropped with no queueing.

    2. Missing requestPurchase Logs — Expected Behavior

    The useIAP hook has NO JS-level logging for requestPurchase:

    // src/hooks/useIAP.ts line 329-333
    const requestPurchase = useCallback(
      async (requestObj) => {
        await requestPurchaseInternal(requestObj);
      },
      [],
    );

    The [useIAP] prefix logs you see are only added to fetchProducts and fetchProducts result. So the absence of [useIAP] requestPurchase in your logs is normal — it doesn't mean the purchase wasn't attempted. Native-level logs (RnIapLog) would show in adb logcat, not in your JS debug output.

    3. No Purchase Event Queueing

    Neither Android nor iOS implementation queues purchase events when no JS listeners are registered. This means any purchase that completes during a listener gap is permanently lost to the JS layer.

    Debugging Request

    Could you provide:

    1. Native logs from adb logcat | grep -i "RnIap" during a failed purchase — this will show whether the purchase reaches the native layer
    2. Component lifecycle — Are you navigating between screens that each use useIAP? Or is it a single screen?
    3. Multiple useIAP instances — Do you have more than one component using useIAP simultaneously?

    Workaround (Until Fix)

    If you suspect lost events, you can poll for unconsumed purchases as a fallback:

    const {getAvailablePurchases, connected} = useIAP({
      onPurchaseSuccess: (purchase) => {
        // Normal flow
        handlePurchase(purchase);
      },
    });
    
    // Fallback: poll for missed purchases after requestPurchase
    const requestPurchaseWithFallback = async (params) => {
      await requestPurchase(params);
      
      // Wait and check for purchases that may have been missed
      setTimeout(async () => {
        await getAvailablePurchases();
      }, 3000);
    };

    Fix Direction

    The proper fix should include:

    1. Queue purchase events on the native side when no JS listeners are registered, and flush the queue when listeners are re-attached
    2. Remove only the specific listener instead of .removeAll() when .remove() is called
    3. Add [useIAP] requestPurchase debug logging to the hook for better diagnostics

    @gitdogdev

    Your analysis was partially correct about the listener attachment timing, but several points were inaccurate:

    • The code snippets you referenced (HybridRnIap.kt:180-210, src/index.ts:1050-1080, src/index.ts:800-850) don't correspond to the actual code
    • The listenersAttached flag is NOT the root cause — it correctly prevents duplicate OpenIAP listener registration
    • The suggested code modifications mix up internal library code with user-facing code
    • The actual race condition is between JS listener cleanup and re-registration, not between initConnection and listener attachment
  5. gitdogbot commented on Feb 18, 2026

    @gitdogbot

    @hyochan ## Summary

    The maintainer's analysis is correct. I've verified the root cause in the actual codebase:

    Confirmed Root Cause: Listener Race Condition on Navigation

    The Problem:

    1. Listener cleanup on unmount [src/hooks/useIAP.ts:484-489]:
    useEffect(() => {
      return () => {
        // Cleanup listeners when component unmounts
        purchaseUpdatedSubscription?.remove();
        purchaseErrorSubscription?.remove();
      };
    }, []);
    1. Race condition during navigation [src/hooks/useIAP.ts:450-460]:

      • User navigates away from screen
      • Component unmounts → listeners are removed
      • BUT: pending IAP events are still in the native queue
      • New screen mounts → new useIAP() hook instance
      • Native events fire → listeners attached to NEW instance
    2. State inconsistency results:

      • Old component's state never updates (it's unmounted)
      • New component receives events meant for old component
      • This causes the "ghost purchase" behavior where UI shows purchased but actual state is inconsistent

    The Fix

    The maintainer's solution is correct - implement a global event bus that persists across component lifecycles:

    // Create singleton event emitter
    const iapEventBus = new EventEmitter();
    
    export const useIAP = () => {
      useEffect(() => {
        // Subscribe to global bus, not native listeners directly
        const unsubscribe = iapEventBus.on('purchase', handlePurchase);
        return unsubscribe;
      }, []);
    };
    
    // Native listeners attached ONCE at app startup
    setupNativeListeners(() => {
      // Emit to global bus instead of component state
      iapEventBus.emit('purchase', data);
    });

    This ensures:

    • ✅ Native listeners persist across navigation
    • ✅ Events are captured regardless of which component is mounted
    • ✅ Each component subscribes to the same event source
    • ✅ No race conditions or lost events

    Why This Matters

    Without this fix, any IAP operation that spans a screen transition will have undefined behavior. The maintainer's implementation is the industry-standard solution for this exact problem.


    🤖 Generated by gitdog.dev · Have more questions? Tag @gitdog and let me know!

  6. renatomserra commented on Feb 18, 2026

    @renatomserra
    Author

    @hyochan thank you for having a look at this, happy to release latest version to help test this

    • Native logs from adb logcat | grep -i "RnIap" during a failed purchase — this will show whether the purchase reaches the native layer

      All the logs i have i have shared.

    • Component lifecycle — Are you navigating between screens that each use useIAP? Or is it a single screen?

      My useIap with onSuccess and listener registration is done in an component that never unmounts, i have a high level invisible component that does some middleware tasks like loading iAP related things, products, purchases, notifications etc. I do call useiAP in relevant screens again to check if products are loaded and load if not.

    • Multiple useIAP instances — Do you have more than one component using useIAP simultaneously?

      yes as explained above

  7. renatomserra commented on Feb 18, 2026

    @renatomserra
    Author

    as for the Workaround (Until Fix) calling getAvailablePurchases seems to never show these purchases even after app restart so i dont think its a timing issue on it

  8. 404-html commented on Feb 23, 2026

    @404-html
    Contributor

    @hyochan I tried 14.7.12, and unfortunately the problem is still there 😔 My impression is that it's now even worse on iOS 🆘
    I never had this problem until I updated from 12.16.2 to 14.4.11.
    Any idea what happened in between these version that caused the issue?

    It's critical, as it's affecting the sales.

  9. 404-html commented on Feb 23, 2026

    @404-html
    Contributor

    I wonder if this is a problem with onPurchaseSuccess listener, as I got logs from onPurchaseError.
    For example, the same user:

    {"code":"user-cancelled","message":"User cancelled the purchase flow"}
    {"code":"already-owned","message":"Item already owned"}
    

    So logging error worked, but what happened between these two logs is missing. And it should be onPurchaseSuccess call.

  10. renatomserra commented on Feb 24, 2026

    @renatomserra
    Author

    Yes my sales seem to have dropped more too, close to 60%, i dont know if coincidence/seasonality or not at this stage as i cant track whats actually happening.

    The weird part is that i cant even find the purchases later in getAvailablePurchases

    Was also working perfectly 12.16.2 i might have to just downgrade

  11. 404-html commented on Feb 25, 2026

    @404-html
    Contributor

    I just faced the issue again, 14.7.12, this time Android.
    onPurchaseSuccess not triggered, then (on another purchase attempt) onPurchaseError triggered:

    {"code":"already-owned","message":"Item is already owned"}
    
  12. renatomserra commented on Mar 3, 2026

    @renatomserra
    Author

    @hyochan how can we help to resolve this?

  13. 8 remaining items

  14. renatomserra commented on Mar 10, 2026

    @renatomserra
    Author

    @404-html if you sent an invite, i might have rejected as i get spammed a lot, if you share your LI ill add

  15. added a commit that references this issue on Mar 10, 2026
    d430ec9
  16. hyochan commented on Mar 10, 2026

    @hyochan
    Owner

    @renatomserra @404-html

    Sorry for the trouble this has caused — I know this has been really painful, especially with the sales impact. I've identified and fixed the root cause.

    Root Cause: iOS removeAll() on listener removal

    The bug was in the iOS native layer. When any listener was removed (e.g., a useIAP component unmounting), iOS called purchaseUpdatedListeners.removeAll() instead of removing only that specific listener. This wiped every registered listener, including ones from other still-mounted components.

    This is why onPurchaseSuccess silently stopped working — the listener array became empty, so purchase events from the native side had nowhere to go. onPurchaseError had the same bug but was less noticeable because error events are less frequent.

    Why it worked in v12 but broke in v14

    v14 introduced Nitro Modules with native-level listener arrays. The iOS implementation used removeAll() for cleanup, which is destructive when multiple listeners exist. v12 had a different event dispatch mechanism that didn't have this problem.

    The scenario (overlapping useIAP)

    This particularly affected apps using useIAP in multiple components — for example, a persistent top-level component for purchase handling + screen-level components for product display:

    Component A (persistent): useIAP({ onPurchaseSuccess }) → registers listener A
    Screen B: useIAP() → registers listener B
    Screen B unmounts → iOS removeAll() → listener A also wiped ❌
    User purchases → empty listener array → purchase event lost
    

    The fix (#3164)

    Instead of registering a separate native listener per useIAP instance, we now use a singleton native listener that dispatches to a JS-level Set of callbacks. remove() only deletes from the JS Set, so:

    • Other listeners are never affected by one component unmounting
    • The native listener stays alive across component lifecycles
    • endConnection() properly resets everything for clean reconnection

    Please try the fix once it's released and let me know if it resolves the issue. Again, I'm really sorry about the impact this had on your apps.

  17. 404-html commented on Mar 10, 2026

    @404-html
    Contributor

    Thank you @hyochan. It's still an open-source, so I could try fixing that too instead of just complaining...
    I am happy to give it a spin, but I have some questions:

    The bug was in the iOS native layer.

    I experienced the issue in Android only. I mentioned about iOS above, but I think it was actually issue on my side. Is the fix addressing iOS only?

    This particularly affected apps using useIAP in multiple components

    I was using v14, but not useIAP hook. Instead of that I had a singleton setting up purchaseUpdatedListener, so it's low likely I was hitting mentioned scenario. Will the fix make any difference, if I am not using useIAP hook?

  18. hyochan commented on Mar 10, 2026

    @hyochan
    Owner

    @404-html Good questions, let me address both:

    1. Does the fix apply to Android too?

    Yes — the fix is entirely in the JS layer (src/index.ts), so it applies to both iOS and Android. On Android, endConnection() also calls purchaseUpdatedListeners.clear() (HybridRnIap.kt:312), which has the same destructive effect. The singleton pattern avoids this because we never call native remove anymore.

    2. Will it help if you're NOT using useIAP?

    If you're using a true singleton purchaseUpdatedListener that's never removed, the removeAll() bug shouldn't have been your issue. However, the new singleton pattern is still more robust:

    • Native endConnection() clears all listeners on both platforms. With the new code, resetListenerState() properly syncs JS state after this, so re-registration works cleanly.
    • The old code's WeakMap-based removal relied on Kotlin lambda reference equality across the Nitro bridge, which may not have been reliable.

    That said — if you had a stable singleton listener that was never removed, and you were still missing onPurchaseSuccess events on Android, there might be a separate issue. Could you share:

    1. Were you calling endConnection() / initConnection() at any point after the initial setup?
    2. Was the purchaseUpdatedListener registered before or after initConnection()?
    3. Any relevant adb logcat | grep RnIap logs during a failed purchase?

    This would help determine if there's an additional Android-specific issue beyond what #3164 fixes.

  19. added a commit that references this issue on Mar 10, 2026
    4d9f743
  20. renatomserra commented on Mar 10, 2026

    @renatomserra
    Author

    Hi @hyochan thank you for the reply, just like @404-html , i am thankful for this Lib, i can live with loss of some revenue im just getting bombarded with "scammer accusations" by user who get charged but no reward, which bothers me more even tho they get a refund 😄

    This doesnt explain why currentPurchases is not populated as mentioned before tho.

    if the issue is listeners not actually being available to listen on purchase, shouldnt getAvailablePurchases return them at the very least on next session? Cause im almost certain thats not happening, when i dont get the event and therefor finish transactions/assign reward, users typically hard close the app to check if its cache (ive done it myself in older versions), but that doesnt seem to be picked up by my getAvailablePurchases on boot?

    If it helps im happy to have a quick call, show my setup in case it helps validate the solution and im also happy to do some debug/testing in prod if it helps, as i have a decent amount of users that i can test on to help sort this for the community

    https://calendly.com/renatoserrauk/30min

  21. hyochan commented on Mar 11, 2026

    @hyochan
    Owner

    @renatomserra That's a valid point — the listener fix (#3164) was specifically for the iOS removeAll() bug. On Android, the listener mechanism is different and shouldn't have had the same issue.

    Regarding getAvailablePurchases not returning purchases on the next session — this is concerning because on Android, it calls Google Play Billing's queryPurchasesAsync, which should return unacknowledged/unfinished purchases regardless of listener state.

    Could you help narrow this down? On your next session after a "lost" purchase:

    1. Check native logs — no setup needed, just run:

      adb logcat | grep RnIap
      

      Look for getAvailablePurchases payload: and getAvailablePurchases result: entries.

    2. For JS-level debug logs, add this before initConnection:

      global.RN_IAP_DEV_MODE = true;
    3. Also check if the purchase shows as "owned" in Google Play (Settings → Payments & subscriptions)

    This will help determine whether Google Play is returning the purchase but our code is filtering it out, or if Google Play itself isn't returning it.

    Thanks for the offer to debug together — really appreciate it.

  22. renatomserra commented on Mar 11, 2026

    @renatomserra
    Author

    Hi, ill try this @hyochan

    But FYI the getAvailablePurchases being empty happens on both iOS and Android, so it must be a JS level issue.

  23. hyochan commented on Mar 11, 2026

    @hyochan
    Owner

    @renatomserra Thanks for clarifying — both platforms being empty narrows it down.

    A couple of things to check:

    1. Already finished transactions: getAvailablePurchases only returns unfinished/unacknowledged purchases. If finishTransaction was called (e.g., in a previous session, or by your purchase flow before verifying the reward), the purchase won't appear on next boot. Are you calling finishTransaction before confirming the reward was granted?

    2. Validation filter: Our JS layer filters purchases missing id, productId, transactionDate, or platform. To check if native is returning data but JS is filtering it out:

      • Native logs (always enabled, no setup needed):
        adb logcat -s RnIap
        
        Look for getAvailablePurchases result: — this shows what the native layer returns before JS filtering.
      • JS debug logs — add this before initConnection:
        global.RN_IAP_DEV_MODE = true;
        This will show validateNitroPurchase warnings if purchases are being filtered.

    Could you share a simplified version of your purchase + boot flow? Specifically where finishTransaction is called relative to reward granting.

  24. renatomserra commented on Mar 11, 2026

    @renatomserra
    Author

    Hello @hyochan ,

    1 - No, finish transaction was never called as i never got the event for purchase.

    2- ill have to add this in prod to users as it doesnt happen all the time and im not sure how to reproduce.

    • Could you share a simplified version of your purchase + boot flow?

    Yes ill prep this over the weekend.

    @404-html do you have the same issue with getAvailablePurchases being empty so you can recover lost transactions?

  25. hyochan commented on Mar 12, 2026

    @hyochan
    Owner

    @renatomserra Thanks — if finishTransaction was never called, those purchases should definitely show up in getAvailablePurchases. The fact that they don't is suspicious.

    No rush on the code — whenever you have it ready. In the meantime, if you can add global.RN_IAP_DEV_MODE = true to your prod build temporarily, we can catch it the next time it happens. The key logs to look for:

    • getAvailablePurchases result: (native level, always enabled) — if this is empty, the issue is at the platform/store level
    • NitroPurchase missing required field (JS level, also always enabled — shows as console.error) — if this shows up, our validation is filtering purchases that native returned

    This will tell us exactly where the data is being lost.

  26. renatomserra commented on Mar 19, 2026

    @renatomserra
    Author

    Hi @hyochan i found the issue with getAvailablePurchases.

    If you set params

    const purchases = await getAvailablePurchases({
              alsoPublishToEventListenerIOS: false,
              onlyIncludeActiveItemsIOS: false,
            });
    

    the call never resolves as either success or failure it just hangs.

    Ive removed params and seems to work correctly, hopefully now we found all issues.

  27. hyochan commented on Mar 20, 2026

    @hyochan
    Owner

    Hi @renatomserra, I found the issue in getAvailablePurchases.

    The iOS native code was using Variant pattern matching (.second) on Swift Optional types, which never matches — so user-provided values were silently ignored and always fell back to defaults.

    Fix:

    Replace lines 258–273 in ios/HybridRnIap.swift:

    // Before (broken - Variant pattern on Optional types)
    let iosOpts: NitroAvailablePurchasesIosOptions?
    if case .second(let unwrapped) = options?.ios {
        iosOpts = unwrapped
    } else {
        iosOpts = nil
    }
    let alsoPublish: Bool = {
        if case .second(let val) = iosOpts?.alsoPublishToEventListener { return val }
        return false
    }()
    let onlyActive: Bool = {
        if case .second(let val) = iosOpts?.onlyIncludeActiveItemsIOS { return val }
        if case .second(let val) = iosOpts?.onlyIncludeActiveItems { return val }
        return false
    }()

    With:

    // After (correct - standard Optional chaining)
    let iosOpts = options?.ios
    let alsoPublish = iosOpts?.alsoPublishToEventListener ?? iosOpts?.alsoPublishToEventListenerIOS ?? false
    let onlyActive = iosOpts?.onlyIncludeActiveItemsIOS ?? iosOpts?.onlyIncludeActiveItems ?? false

    Could you test this and let us know if the hang is resolved?

    Full modified ios/HybridRnIap.swift (click to expand — you can copy-paste the entire file)
    import Foundation
    import NitroModules
    import OpenIAP
    
    @available(iOS 15.0, macOS 14.0, tvOS 15.0, watchOS 8.0, *)
    class HybridRnIap: HybridRnIapSpec {
        // MARK: - Properties
        private var updateListenerTask: Task<Void, Never>?
        private var isInitialized: Bool = false
        private var isInitializing: Bool = false
        private var productTypeBySku: [String: String] = [:]
        // OpenIAP event subscriptions
        private var purchaseUpdatedSub: Subscription?
        private var purchaseErrorSub: Subscription?
        private var promotedProductSub: Subscription?
        // Event listeners
        private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = []
        private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = []
        private var promotedProductListeners: [(NitroProduct) -> Void] = []
        private var lastPurchaseErrorKey: String? = nil
        private var lastPurchaseErrorTimestamp: TimeInterval = 0
        private var deliveredPurchaseEventKeys: Set<String> = []
        private var deliveredPurchaseEventOrder: [String] = []
        private let purchaseEventDedupLimit = 128
        private var purchasePayloadById: [String: [String: Any]] = [:]
        // Thread safety lock for listener arrays and error dedup state
        private let listenerLock = NSLock()
    
        // MARK: - Initialization
        
        override init() {
            super.init()
        }
        
        deinit {
            updateListenerTask?.cancel()
        }
        
        // MARK: - Public Methods (Cross-platform)
    
        
        
        func initConnection(config: Variant_NullType_InitConnectionConfig?) throws -> Promise<Bool> {
            return Promise.async {
                let configValue: InitConnectionConfig? = {
                    if case .second(let c) = config { return c }
                    return nil
                }()
                RnIapLog.payload("initConnection", configValue?.alternativeBillingModeAndroid)
                self.attachListenersIfNeeded()
    
                if self.isInitialized || self.isInitializing {
                    RnIapLog.result("initConnection", true)
                    return true
                }
    
                self.isInitializing = true
    
                do {
                    // Note: iOS doesn't support alternative billing config parameter
                    // Config is ignored on iOS platform
                    let ok = try await OpenIapModule.shared.initConnection()
                    RnIapLog.result("initConnection", ok)
                    self.isInitialized = ok
                    self.isInitializing = false
                    return ok
                } catch {
                    RnIapLog.failure("initConnection", error: error)
                    let err = RnIapHelper.makePurchaseErrorResult(
                        code: .initConnection,
                        message: error.localizedDescription
                    )
                    self.sendPurchaseError(err, productId: nil)
                    self.isInitialized = false
                    self.isInitializing = false
                    return false
                }
            }
        }
        
        func endConnection() throws -> Promise<Bool> {
            return Promise.async {
                self.cleanupExistingState()
                return true
            }
        }
        
        func fetchProducts(skus: [String], type: String) throws -> Promise<[NitroProduct]> {
            return Promise.async {
                try self.ensureConnection()
                RnIapLog.payload("fetchProducts", [
                    "skus": skus,
                    "type": type
                ])
    
                if skus.isEmpty {
                    throw OpenIapException.make(code: .emptySkuList)
                }
    
                var productsById: [String: NitroProduct] = [:]
                let normalizedType = type.lowercased()
                let queryTypes: [ProductQueryType]
                if normalizedType == "all" {
                    queryTypes = [.inApp, .subs]
                } else {
                    if normalizedType == "inapp" {
                        RnIapLog.warn("fetchProducts received legacy type 'inapp'; forwarding as 'in-app'")
                    }
                    queryTypes = [RnIapHelper.parseProductQueryType(type)]
                }
    
                for queryType in queryTypes {
                    let request = try OpenIapSerialization.productRequest(skus: skus, type: queryType)
                    RnIapLog.payload(
                        "fetchProducts.native", [
                            "skus": skus,
                            "type": queryType.rawValue
                        ]
                    )
                    let result = try await OpenIapModule.shared.fetchProducts(request)
                    let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result))
                    RnIapLog.result("fetchProducts.native", payloads)
                    for payload in payloads {
                        let nitroProduct = RnIapHelper.convertProductDictionary(payload)
                        productsById[nitroProduct.id] = nitroProduct
                    }
                }
    
                var products: [NitroProduct] = []
                var seenIds = Set<String>()
                for sku in skus {
                    if let product = productsById[sku], !seenIds.contains(product.id) {
                        products.append(product)
                        seenIds.insert(product.id)
                    }
                }
                for product in productsById.values where !seenIds.contains(product.id) {
                    products.append(product)
                    seenIds.insert(product.id)
                }
                await MainActor.run { [products] in
                    products.forEach { self.productTypeBySku[$0.id] = $0.type.lowercased() }
                }
                RnIapLog.result(
                    "fetchProducts", products.map { ["id": $0.id, "type": $0.type] }
                )
                return products
            }
        }
        
        func requestPurchase(request: NitroPurchaseRequest) throws -> Promise<RequestPurchaseResult> {
            return Promise.async {
                let defaultResult: RequestPurchaseResult = .fourth([])
                RnIapLog.payload(
                    "requestPurchase", [
                        "hasIOS": request.ios != nil,
                        "hasAndroid": request.android != nil
                    ]
                )
    
                let iosRequest: NitroRequestPurchaseIos
                if case .second(let unwrapped) = request.ios {
                    iosRequest = unwrapped
                } else {
                    let error = RnIapHelper.makePurchaseErrorResult(
                        code: .developerError,
                        message: "No iOS request provided"
                    )
                    self.sendPurchaseError(error, productId: nil)
                    return defaultResult
                }
    
                guard self.isInitialized else {
                    let err = RnIapHelper.makePurchaseErrorResult(
                        code: .initConnection,
                        message: "IAP store connection not initialized",
                        iosRequest.sku
                    )
                    self.sendPurchaseError(err, productId: iosRequest.sku)
                    return defaultResult
                }
    
                do {
                    var iosPayload: [String: Any] = ["sku": iosRequest.sku]
                    if case .second(let quantity) = iosRequest.quantity { iosPayload["quantity"] = Int(quantity) }
                    if case .second(let finishAutomatically) = iosRequest.andDangerouslyFinishTransactionAutomatically {
                        iosPayload["andDangerouslyFinishTransactionAutomatically"] = finishAutomatically
                    }
                    if case .second(let appAccountToken) = iosRequest.appAccountToken {
                        iosPayload["appAccountToken"] = appAccountToken
                    }
                    if case .second(let withOffer) = iosRequest.withOffer {
                        iosPayload["withOffer"] = withOffer
                    }
                    if case .second(let advancedCommerceData) = iosRequest.advancedCommerceData {
                        iosPayload["advancedCommerceData"] = advancedCommerceData
                    }
                    // WWDC 2025 / iOS 18+ subscription offer fields
                    if case .second(let introductoryOfferEligibility) = iosRequest.introductoryOfferEligibility {
                        iosPayload["introductoryOfferEligibility"] = introductoryOfferEligibility
                    }
                    if case .second(let promotionalOfferJWS) = iosRequest.promotionalOfferJWS {
                        iosPayload["promotionalOfferJWS"] = [
                            "jws": promotionalOfferJWS.jws,
                            "offerId": promotionalOfferJWS.offerId
                        ]
                    }
                    if case .second(let winBackOffer) = iosRequest.winBackOffer {
                        iosPayload["winBackOffer"] = ["offerId": winBackOffer.offerId]
                    }
    
                    let cachedType = await MainActor.run { self.productTypeBySku[iosRequest.sku] }
                    let resolvedType = RnIapHelper.parseProductQueryType(cachedType)
                    let purchaseType: ProductQueryType = resolvedType == .all ? .inApp : resolvedType
                    await MainActor.run {
                        self.productTypeBySku[iosRequest.sku] = purchaseType.rawValue
                    }
    
                    let props = try RnIapHelper.decodeRequestPurchaseProps(
                        iosPayload: iosPayload,
                        type: purchaseType
                    )
    
                    RnIapLog.payload(
                        "requestPurchase.native", iosPayload
                    )
    
                    let result = try await OpenIapModule.shared.requestPurchase(props)
                    if result != nil {
                        RnIapLog.result("requestPurchase", "delegated to OpenIAP")
                    } else {
                        RnIapLog.result("requestPurchase", nil)
                    }
    
                    return defaultResult
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("requestPurchase", error: purchaseError)
                    // OpenIAP already publishes purchaseError events for PurchaseError instances.
                    // Avoid emitting a duplicate event back to JS; simply return.
                    return defaultResult
                } catch {
                    RnIapLog.failure("requestPurchase", error: error)
                    let err = RnIapHelper.makePurchaseErrorResult(
                        code: .purchaseError,
                        message: error.localizedDescription,
                        iosRequest.sku
                    )
                    self.sendPurchaseErrorDedup(err, productId: iosRequest.sku)
                    return defaultResult
                }
            }
        }
        
        func getAvailablePurchases(options: NitroAvailablePurchasesOptions?) throws -> Promise<[NitroPurchase]> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    let iosOpts = options?.ios
                    let alsoPublish = iosOpts?.alsoPublishToEventListener ?? iosOpts?.alsoPublishToEventListenerIOS ?? false
                    let onlyActive = iosOpts?.onlyIncludeActiveItemsIOS ?? iosOpts?.onlyIncludeActiveItems ?? false
                    let optionsDictionary: [String: Any] = [
                        "alsoPublishToEventListenerIOS": alsoPublish,
                        "onlyIncludeActiveItemsIOS": onlyActive
                    ]
                    let purchaseOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
                    RnIapLog.payload("getAvailablePurchases", optionsDictionary)
                    let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
                    let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
                    RnIapLog.result("getAvailablePurchases", payloads)
                    return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getAvailablePurchases", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getAvailablePurchases", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func getActiveSubscriptions(subscriptionIds: [String]?) throws -> Promise<[NitroActiveSubscription]> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("getActiveSubscriptions", subscriptionIds ?? [])
                    // Call OpenIAP's native getActiveSubscriptions - includes renewalInfoIOS!
                    let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds)
                    let payloads = RnIapHelper.sanitizeArray(subscriptions.map { OpenIapSerialization.encode($0) })
                    RnIapLog.result("getActiveSubscriptions", payloads)
                    return payloads.map { RnIapHelper.convertActiveSubscriptionDictionary($0) }
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getActiveSubscriptions", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getActiveSubscriptions", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func hasActiveSubscriptions(subscriptionIds: [String]?) throws -> Promise<Bool> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("hasActiveSubscriptions", subscriptionIds ?? [])
                    let hasActive = try await OpenIapModule.shared.hasActiveSubscriptions(subscriptionIds)
                    RnIapLog.result("hasActiveSubscriptions", hasActive)
                    return hasActive
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("hasActiveSubscriptions", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("hasActiveSubscriptions", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func finishTransaction(params: NitroFinishTransactionParams) throws -> Promise<Variant_Bool_NitroPurchaseResult> {
            return Promise.async {
                guard case .second(let iosParams) = params.ios else { return .first(true) }
                try self.ensureConnection()
                do {
                    RnIapLog.payload(
                        "finishTransaction", ["transactionId": iosParams.transactionId]
                    )
                    var purchasePayload = await MainActor.run { () -> [String: Any]? in
                        self.purchasePayloadById[iosParams.transactionId]
                    }
                    if purchasePayload == nil {
                        RnIapLog.warn("Missing cached purchase payload for \(iosParams.transactionId); falling back to identifier-only finish")
                        purchasePayload = ["transactionIdentifier": iosParams.transactionId]
                    }
                    guard let purchasePayload else {
                        throw OpenIapException.make(code: .purchaseError, message: "Missing purchase context for \(iosParams.transactionId)")
                    }
                    let sanitizedPayload = RnIapHelper.sanitizeDictionary(purchasePayload)
                    RnIapLog.payload("finishTransaction.nativePayload", sanitizedPayload)
                    let purchaseInput = try OpenIapSerialization.purchaseInput(from: purchasePayload)
                    _ = try await OpenIapModule.shared.finishTransaction(purchase: purchaseInput, isConsumable: nil)
                    RnIapLog.result("finishTransaction", true)
                    await MainActor.run {
                        self.purchasePayloadById.removeValue(forKey: iosParams.transactionId)
                    }
                    return .first(true)
                } catch {
                    RnIapLog.failure("finishTransaction", error: error)
                    let tid = iosParams.transactionId
                    throw OpenIapException.make(code: .purchaseError, message: "Transaction not found: \(tid)")
                }
            }
        }
        
        func validateReceipt(params: NitroReceiptValidationParams) throws -> Promise<Variant_NitroReceiptValidationResultIOS_NitroReceiptValidationResultAndroid> {
            return Promise.async {
                do {
                    // Extract SKU from apple options (new platform-specific structure)
                    guard case .second(let appleOptions) = params.apple, !appleOptions.sku.isEmpty else {
                        throw OpenIapException.make(code: .developerError, message: "Missing required parameter: apple.sku")
                    }
                    let sku = appleOptions.sku
    
                    RnIapLog.payload("validateReceiptIOS", ["sku": sku])
                    let props = try OpenIapSerialization.verifyPurchaseProps(from: ["apple": ["sku": sku]])
                    let result = try await OpenIapModule.shared.validateReceiptIOS(props)
                    var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
                    if encoded["receiptData"] != nil {
                        encoded["receiptData"] = "<receipt>"
                    }
                    if encoded["jwsRepresentation"] != nil {
                        encoded["jwsRepresentation"] = "<jws>"
                    }
                    RnIapLog.result("validateReceiptIOS", encoded)
                    var latest: NitroPurchase? = nil
                    if let transaction = result.latestTransaction {
                        let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(transaction))
                        latest = RnIapHelper.convertPurchaseDictionary(payload)
                    }
                    let mapped = NitroReceiptValidationResultIOS(
                        isValid: result.isValid,
                        receiptData: result.receiptData,
                        jwsRepresentation: result.jwsRepresentation,
                        latestTransaction: latest.map { .second($0) }
                    )
                    return .first(mapped)
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("validateReceiptIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("validateReceiptIOS", error: error)
                    throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription)
                }
            }
        }
    
        func verifyPurchaseWithProvider(params: NitroVerifyPurchaseWithProviderProps) throws -> Promise<NitroVerifyPurchaseWithProviderResult> {
            return Promise.async {
                do {
                    RnIapLog.payload("verifyPurchaseWithProvider", ["provider": params.provider.stringValue])
                    // Convert Nitro params to OpenIAP props using JSONSerialization (same as expo-iap)
                    // Use stringValue for enum to get proper string representation ("iapkit" instead of numeric rawValue)
                    var propsDict: [String: Any] = ["provider": params.provider.stringValue]
                    if case .second(let iapkit) = params.iapkit {
                        var iapkitDict: [String: Any] = [:]
                        // Use provided apiKey, or fallback to Info.plist IAPKitAPIKey (set by config plugin)
                        if case .second(let apiKey) = iapkit.apiKey {
                            iapkitDict["apiKey"] = apiKey
                        } else if let plistApiKey = Bundle.main.object(forInfoDictionaryKey: "IAPKitAPIKey") as? String {
                            iapkitDict["apiKey"] = plistApiKey
                        }
                        if case .second(let apple) = iapkit.apple {
                            iapkitDict["apple"] = ["jws": apple.jws]
                        }
                        if case .second(let google) = iapkit.google {
                            iapkitDict["google"] = ["purchaseToken": google.purchaseToken]
                        }
                        propsDict["iapkit"] = iapkitDict
                    }
                    // Use JSONSerialization + JSONDecoder like expo-iap does
                    let jsonData = try JSONSerialization.data(withJSONObject: propsDict)
                    let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData)
                    let result = try await OpenIapModule.shared.verifyPurchaseWithProvider(props)
                    RnIapLog.result("verifyPurchaseWithProvider", ["provider": result.provider, "hasIapkit": result.iapkit != nil])
                    // Convert result to Nitro types
                    var nitroIapkitResult: NitroVerifyPurchaseWithIapkitResult? = nil
                    if let item = result.iapkit {
                        nitroIapkitResult = NitroVerifyPurchaseWithIapkitResult(
                            isValid: item.isValid,
                            state: IapkitPurchaseState(fromString: item.state.rawValue) ?? .unknown,
                            store: IapStore(fromString: item.store.rawValue) ?? .unknown
                        )
                    }
                    // Convert errors if present
                    var nitroErrors: [NitroVerifyPurchaseWithProviderError]? = nil
                    if let errors = result.errors {
                        nitroErrors = errors.map { error in
                            NitroVerifyPurchaseWithProviderError(
                                code: RnIapHelper.wrapString(error.code),
                                message: error.message
                            )
                        }
                    }
                    let wrappedIapkit: Variant_NullType_NitroVerifyPurchaseWithIapkitResult? = nitroIapkitResult.map { .second($0) }
                    let wrappedErrors: Variant_NullType__NitroVerifyPurchaseWithProviderError_? = nitroErrors.map { .second($0) }
                    return NitroVerifyPurchaseWithProviderResult(
                        iapkit: wrappedIapkit,
                        errors: wrappedErrors,
                        provider: PurchaseVerificationProvider(fromString: result.provider.rawValue) ?? .iapkit
                    )
                } catch let purchaseError as PurchaseError {
                    // Convert PurchaseError to OpenIapException to preserve message through Nitro bridge
                    RnIapLog.failure("verifyPurchaseWithProvider", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("verifyPurchaseWithProvider", error: error)
                    throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription)
                }
            }
        }
    
        func getStorefront() throws -> Promise<String> {
            return Promise.async {
                do {
                    RnIapLog.payload("getStorefront", nil)
                    let storefront = try await OpenIapModule.shared.getStorefrontIOS()
                    RnIapLog.result("getStorefront", storefront)
                    return storefront
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getStorefront", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getStorefront", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        // MARK: - iOS-specific Public Methods
        func getStorefrontIOS() throws -> Promise<String> {
            return try getStorefront()
        }
        
        func getAppTransactionIOS() throws -> Promise<Variant_NullType_String> {
            return Promise.async {
                do {
                    RnIapLog.payload("getAppTransactionIOS", nil)
                    if #available(iOS 16.0, *) {
                        if let appTx = try await OpenIapModule.shared.getAppTransactionIOS() {
                            var result: [String: Any?] = [
                                "bundleId": appTx.bundleId,
                                "appVersion": appTx.appVersion,
                                "originalAppVersion": appTx.originalAppVersion,
                                "originalPurchaseDate": appTx.originalPurchaseDate,
                                "deviceVerification": appTx.deviceVerification,
                                "deviceVerificationNonce": appTx.deviceVerificationNonce,
                                "environment": appTx.environment,
                                "signedDate": appTx.signedDate,
                                "appId": appTx.appId,
                                "appVersionId": appTx.appVersionId,
                                "preorderDate": appTx.preorderDate
                            ]
                            result["appTransactionId"] = appTx.appTransactionId
                            result["originalPlatform"] = appTx.originalPlatform
                            let jsonData = try JSONSerialization.data(withJSONObject: result, options: [])
                            let string = String(data: jsonData, encoding: .utf8)
                            RnIapLog.result("getAppTransactionIOS", "<appTransaction>")
                            if let s = string { return .second(s) }
                            return .first(.null)
                        }
                        RnIapLog.result("getAppTransactionIOS", nil)
                        return .first(.null)
                    } else {
                        RnIapLog.result("getAppTransactionIOS", nil)
                        return .first(.null)
                    }
                } catch {
                    RnIapLog.failure("getAppTransactionIOS", error: error)
                    return .first(.null)
                }
            }
        }
        
        func getPromotedProductIOS() throws -> Promise<Variant_NullType_NitroProduct> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("getPromotedProductIOS", nil)
                    guard let product = try await OpenIapModule.shared.getPromotedProductIOS() else {
                        RnIapLog.result("getPromotedProductIOS", nil)
                        return .first(.null)
                    }
                    let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
                    RnIapLog.result("getPromotedProductIOS", payload)
                    return .second(RnIapHelper.convertProductDictionary(payload))
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getPromotedProductIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getPromotedProductIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func requestPromotedProductIOS() throws -> Promise<Variant_NullType_NitroProduct> {
            return try getPromotedProductIOS()
        }
        
        func buyPromotedProductIOS() throws -> Promise<Void> {
            return Promise.async {
                do {
                    RnIapLog.payload("buyPromotedProductIOS", nil)
                    let ok = try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()
                    RnIapLog.result("buyPromotedProductIOS", ok)
                } catch {
                    // Event-only: OpenIAP will emit purchaseError for this flow. Avoid Promise rejection.
                    RnIapLog.failure("buyPromotedProductIOS", error: error)
                }
            }
        }
        
        func presentCodeRedemptionSheetIOS() throws -> Promise<Bool> {
            return Promise.async {
                do {
                    RnIapLog.payload("presentCodeRedemptionSheetIOS", nil)
                    let ok = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
                    RnIapLog.result("presentCodeRedemptionSheetIOS", ok)
                    return ok
                } catch {
                    // Fallback with explicit error for simulator or unsupported cases
                    RnIapLog.failure("presentCodeRedemptionSheetIOS", error: error)
                    throw OpenIapException.make(code: .featureNotSupported)
                }
            }
        }
    
        func clearTransactionIOS() throws -> Promise<Void> {
            return Promise.async {
                do {
                    RnIapLog.payload("clearTransactionIOS", nil)
                    let ok = try await OpenIapModule.shared.clearTransactionIOS()
                    RnIapLog.result("clearTransactionIOS", ok)
                } catch {
                    // ignore
                    RnIapLog.failure("clearTransactionIOS", error: error)
                }
            }
        }
        
        // Additional iOS-only functions for feature parity with expo-iap
        
        func subscriptionStatusIOS(sku: String) throws -> Promise<Variant_NullType__NitroSubscriptionStatus_> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("subscriptionStatusIOS", ["sku": sku])
                    let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku)
                    let payloads = statuses.map { RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
                    RnIapLog.result("subscriptionStatusIOS", payloads)
                    let result: [NitroSubscriptionStatus] = payloads.map { payload in
                        let stateValue: Double
                        if let numeric = RnIapHelper.doubleValue(payload["state"]) {
                            stateValue = numeric
                        } else if let stateString = payload["state"] as? String {
                            stateValue = stateString.lowercased() == "subscribed" ? 1 : 0
                        } else {
                            stateValue = 0
                        }
                        let platform = payload["platform"] as? String ?? "ios"
                        var renewalInfo: Variant_NullType_NitroSubscriptionRenewalInfo? = nil
                        if let renewalPayload = payload["renewalInfo"] as? [String: Any?] {
                            if let info = RnIapHelper.convertRenewalInfo(RnIapHelper.sanitizeDictionary(renewalPayload)) {
                                renewalInfo = .second(info)
                            }
                        }
                        return NitroSubscriptionStatus(state: stateValue, platform: platform, renewalInfo: renewalInfo)
                    }
                    return .second(result)
                } catch {
                    RnIapLog.failure("subscriptionStatusIOS", error: error)
                    return .second([])
                }
            }
        }
        
        func currentEntitlementIOS(sku: String) throws -> Promise<Variant_NullType_NitroPurchase> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("currentEntitlementIOS", ["sku": sku])
                    let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku)
                    if let purchase {
                        let raw = OpenIapSerialization.encode(purchase)
                        let payload = RnIapHelper.sanitizeDictionary(raw)
                        RnIapLog.result("currentEntitlementIOS", payload)
                        if let identifier = raw["id"] as? String {
                            await MainActor.run {
                                self.purchasePayloadById[identifier] = raw
                            }
                        }
                        return .second(RnIapHelper.convertPurchaseDictionary(payload))
                    }
                    RnIapLog.result("currentEntitlementIOS", nil)
                    return .first(.null)
                } catch {
                    RnIapLog.failure("currentEntitlementIOS", error: error)
                    throw OpenIapException.make(code: .skuNotFound, productId: sku)
                }
            }
        }
    
        func latestTransactionIOS(sku: String) throws -> Promise<Variant_NullType_NitroPurchase> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("latestTransactionIOS", ["sku": sku])
                    let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku)
                    if let purchase {
                        let raw = OpenIapSerialization.encode(purchase)
                        let payload = RnIapHelper.sanitizeDictionary(raw)
                        RnIapLog.result("latestTransactionIOS", payload)
                        if let identifier = raw["id"] as? String {
                            await MainActor.run {
                                self.purchasePayloadById[identifier] = raw
                            }
                        }
                        return .second(RnIapHelper.convertPurchaseDictionary(payload))
                    }
                    RnIapLog.result("latestTransactionIOS", nil)
                    return .first(.null)
                } catch {
                    RnIapLog.failure("latestTransactionIOS", error: error)
                    throw OpenIapException.make(code: .skuNotFound, productId: sku)
                }
            }
        }
    
        func getPendingTransactionsIOS() throws -> Promise<[NitroPurchase]> {
            return Promise.async {
                do {
                    RnIapLog.payload("getPendingTransactionsIOS", nil)
                    let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
                    var unionPurchases: [OpenIAP.Purchase] = []
                    for purchase in pending {
                        let union = OpenIAP.Purchase.purchaseIos(purchase)
                        unionPurchases.append(union)
                        let raw = OpenIapSerialization.purchase(union)
                        if let identifier = raw["id"] as? String {
                            await MainActor.run {
                                self.purchasePayloadById[identifier] = raw
                            }
                        }
                    }
                    let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(unionPurchases))
                    RnIapLog.result("getPendingTransactionsIOS", payloads)
                    return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
                } catch {
                    RnIapLog.failure("getPendingTransactionsIOS", error: error)
                    return []
                }
            }
        }
        
        func syncIOS() throws -> Promise<Bool> {
            return Promise.async {
                do {
                    RnIapLog.payload("syncIOS", nil)
                    let ok = try await OpenIapModule.shared.syncIOS()
                    RnIapLog.result("syncIOS", ok)
                    return ok
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("syncIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("syncIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func showManageSubscriptionsIOS() throws -> Promise<[NitroPurchase]> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    // Trigger system UI
                    RnIapLog.payload("showManageSubscriptionsIOS", nil)
                    _ = try await OpenIapModule.shared.showManageSubscriptionsIOS()
                    // Return current entitlements as approximation of updates
                    let optionsDictionary: [String: Any] = [
                        "alsoPublishToEventListenerIOS": false,
                        "onlyIncludeActiveItemsIOS": true
                    ]
                    let iosOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
                    let purchases = try await OpenIapModule.shared.getAvailablePurchases(iosOptions)
                    let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
                    RnIapLog.result("showManageSubscriptionsIOS", payloads)
                    return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("showManageSubscriptionsIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("showManageSubscriptionsIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func deepLinkToSubscriptionsIOS() throws -> Promise<Bool> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("deepLinkToSubscriptionsIOS", nil)
                    try await OpenIapModule.shared.deepLinkToSubscriptions(nil)
                    RnIapLog.result("deepLinkToSubscriptionsIOS", true)
                    return true
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("deepLinkToSubscriptionsIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("deepLinkToSubscriptionsIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func isEligibleForIntroOfferIOS(groupID: String) throws -> Promise<Bool> {
            return Promise.async {
                RnIapLog.payload("isEligibleForIntroOfferIOS", ["groupID": groupID])
                let value = try await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
                RnIapLog.result("isEligibleForIntroOfferIOS", value)
                return value
            }
        }
        
        func getReceiptDataIOS() throws -> Promise<String> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("getReceiptDataIOS", nil)
                    let receipt = try await RnIapHelper.loadReceiptData(refresh: false)
                    RnIapLog.result("getReceiptDataIOS", "<receipt>")
                    return receipt
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getReceiptDataIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getReceiptDataIOS", error: error)
                    throw OpenIapException.make(code: .receiptFailed, message: error.localizedDescription)
                }
            }
        }
    
        func getReceiptIOS() throws -> Promise<String> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("getReceiptIOS", nil)
                    let receipt = try await RnIapHelper.loadReceiptData(refresh: true)
                    RnIapLog.result("getReceiptIOS", "<receipt>")
                    return receipt
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getReceiptIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getReceiptIOS", error: error)
                    throw OpenIapException.make(code: .receiptFailed, message: error.localizedDescription)
                }
            }
        }
    
        func requestReceiptRefreshIOS() throws -> Promise<String> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("requestReceiptRefreshIOS", nil)
                    let receipt = try await RnIapHelper.loadReceiptData(refresh: true)
                    RnIapLog.result("requestReceiptRefreshIOS", "<receipt>")
                    return receipt
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("requestReceiptRefreshIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("requestReceiptRefreshIOS", error: error)
                    throw OpenIapException.make(code: .receiptFailed, message: error.localizedDescription)
                }
            }
        }
    
        func isTransactionVerifiedIOS(sku: String) throws -> Promise<Bool> {
            return Promise.async {
                try self.ensureConnection()
                RnIapLog.payload("isTransactionVerifiedIOS", ["sku": sku])
                let value = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
                RnIapLog.result("isTransactionVerifiedIOS", value)
                return value
            }
        }
        
        func getTransactionJwsIOS(sku: String) throws -> Promise<Variant_NullType_String> {
            return Promise.async {
                try self.ensureConnection()
                do {
                    RnIapLog.payload("getTransactionJwsIOS", ["sku": sku])
                    let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
                    let maskedJws: Any? = (jws == nil) ? nil : "<jws>"
                    RnIapLog.result("getTransactionJwsIOS", maskedJws)
                    if let jws {
                        return .second(jws)
                    }
                    return .first(.null)
                } catch {
                    RnIapLog.failure("getTransactionJwsIOS", error: error)
                    throw OpenIapException.make(code: .transactionValidationFailed, message: "Can't find transaction for sku \(sku)")
                }
            }
        }
    
        func beginRefundRequestIOS(sku: String) throws -> Promise<Variant_NullType_String> {
            return Promise.async {
                do {
                    RnIapLog.payload("beginRefundRequestIOS", ["sku": sku])
                    let result = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
                    RnIapLog.result("beginRefundRequestIOS", result)
                    if let result {
                        return .second(result)
                    }
                    return .first(.null)
                } catch {
                    RnIapLog.failure("beginRefundRequestIOS", error: error)
                    return .first(.null)
                }
            }
        }
        
        func addPromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws {
            listenerLock.withLock { promotedProductListeners.append(listener) }
    
            // If a promoted product is already available from OpenIAP, notify immediately
            Task {
                RnIapLog.payload("promotedProductListenerIOS.fetch", nil)
                if let product = try? await OpenIapModule.shared.getPromotedProductIOS() {
                    let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
                    RnIapLog.result("promotedProductListenerIOS.fetch", payload)
                    let nitro = RnIapHelper.convertProductDictionary(payload)
                    await MainActor.run { listener(nitro) }
                }
            }
        }
    
        func removePromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws {
            listenerLock.withLock { promotedProductListeners.removeAll() }
        }
    
        // MARK: - Event Listener Methods
    
        func addPurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws {
            listenerLock.withLock { purchaseUpdatedListeners.append(listener) }
        }
    
        func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
            listenerLock.withLock { purchaseErrorListeners.append(listener) }
        }
    
        func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws {
            listenerLock.withLock { purchaseUpdatedListeners.removeAll() }
        }
    
        func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws {
            listenerLock.withLock { purchaseErrorListeners.removeAll() }
        }
        
        // 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")
            }
    
            if purchaseErrorSub == nil {
                RnIapLog.payload("purchaseErrorListener.register", nil)
                purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
                    guard let self else {
                        RnIapLog.warn("purchaseErrorListener: HybridRnIap deallocated, error event dropped")
                        return
                    }
                    Task { @MainActor in
                        let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error))
                        RnIapLog.result("purchaseErrorListener", payload)
                        let nitroError = RnIapHelper.makePurchaseErrorResult(
                            code: error.code,
                            message: error.message,
                            error.productId
                        )
                        self.sendPurchaseError(nitroError, productId: error.productId)
                    }
                }
                RnIapLog.result("purchaseErrorListener.register", "attached")
            }
    
            if promotedProductSub == nil {
                RnIapLog.payload("promotedProductListenerIOS.register", nil)
                promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
                    guard let self else {
                        RnIapLog.warn("promotedProductListenerIOS: HybridRnIap deallocated, promoted product event dropped")
                        return
                    }
                    Task {
                        RnIapLog.payload("promotedProductListenerIOS", ["productId": productId])
                        do {
                            let request = try OpenIapSerialization.productRequest(skus: [productId], type: .all)
                            let result = try await OpenIapModule.shared.fetchProducts(request)
                            let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result))
                            RnIapLog.result("fetchProducts", payloads)
                            if let payload = payloads.first {
                                let nitro = RnIapHelper.convertProductDictionary(payload)
                                let snapshot = self.listenerLock.withLock { Array(self.promotedProductListeners) }
                                await MainActor.run {
                                    for listener in snapshot { listener(nitro) }
                                }
                            }
                        } catch {
                            RnIapLog.failure("promotedProductListenerIOS", error: error)
                            let id = productId
                            let snapshot = self.listenerLock.withLock { Array(self.promotedProductListeners) }
                            await MainActor.run {
                                let minimal = RnIapHelper.makeMinimalProduct(id: id)
                                for listener in snapshot { listener(minimal) }
                            }
                        }
                    }
                }
                RnIapLog.result("promotedProductListenerIOS.register", "attached")
            }
        }
    
        private func ensureConnection() throws {
            guard isInitialized else {
                throw OpenIapException.make(code: .initConnection, message: "Connection not initialized. Call initConnection() first.")
            }
        }
        
        private func sendPurchaseUpdate(_ purchase: NitroPurchase) {
            let originalTxId: String
            if case .second(let val) = purchase.originalTransactionIdentifierIOS { originalTxId = val } else { originalTxId = "" }
            let purchaseTokenStr: String
            if case .second(let val) = purchase.purchaseToken { purchaseTokenStr = val } else { purchaseTokenStr = "" }
            let keyComponents: [String] = [
                purchase.id,
                purchase.productId,
                String(purchase.transactionDate),
                originalTxId,
                purchaseTokenStr
            ]
            let eventKey = keyComponents.joined(separator: "#")
    
            var isDuplicate = false
            let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock {
                if deliveredPurchaseEventKeys.contains(eventKey) {
                    isDuplicate = true
                    return []
                }
    
                deliveredPurchaseEventKeys.insert(eventKey)
                deliveredPurchaseEventOrder.append(eventKey)
                if deliveredPurchaseEventOrder.count > purchaseEventDedupLimit, let removed = deliveredPurchaseEventOrder.first {
                    deliveredPurchaseEventOrder.removeFirst()
                    deliveredPurchaseEventKeys.remove(removed)
                }
    
                return Array(purchaseUpdatedListeners)
            }
    
            if isDuplicate {
                RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)")
                return
            }
    
            for listener in snapshot {
                listener(purchase)
            }
        }
    
        private func sendPurchaseError(_ error: NitroPurchaseResult, productId: String? = nil) {
            let dedupIdentifier = productId
                ?? (error.purchaseToken?.isEmpty == false ? error.purchaseToken : nil)
                ?? (error.message.isEmpty ? nil : error.message)
            let currentKey = RnIapHelper.makeErrorDedupKey(code: error.code, productId: dedupIdentifier)
    
            // Protect error dedup state since sendPurchaseError is called from multiple threads
            let shouldSkip: Bool = listenerLock.withLock {
                let now = Date().timeIntervalSince1970
                let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15
                if currentKey == lastPurchaseErrorKey && withinWindow {
                    return true
                }
                lastPurchaseErrorKey = currentKey
                lastPurchaseErrorTimestamp = now
                return false
            }
            if shouldSkip { return }
    
            // Ensure we never leak SKU via purchaseToken
            let sanitized: NitroPurchaseResult
            if let pid = productId, error.purchaseToken == pid {
                sanitized = NitroPurchaseResult(
                    responseCode: error.responseCode,
                    debugMessage: error.debugMessage,
                    code: error.code,
                    message: error.message,
                    purchaseToken: nil
                )
            } else {
                sanitized = error
            }
            let snapshot = listenerLock.withLock { Array(purchaseErrorListeners) }
            for listener in snapshot {
                listener(sanitized)
            }
        }
    
        private func sendPurchaseErrorDedup(_ error: NitroPurchaseResult, productId: String? = nil) {
            sendPurchaseError(error, productId: productId)
        }
        
        private func cleanupExistingState() {
            // Cancel transaction listener if any
            updateListenerTask?.cancel()
            updateListenerTask = nil
            isInitialized = false
            isInitializing = false
    
            // Remove OpenIAP listeners & end connection
            if let sub = purchaseUpdatedSub {
                RnIapLog.payload("removeListener", "purchaseUpdated")
                OpenIapModule.shared.removeListener(sub)
            }
            if let sub = purchaseErrorSub {
                RnIapLog.payload("removeListener", "purchaseError")
                OpenIapModule.shared.removeListener(sub)
            }
            if let sub = promotedProductSub {
                RnIapLog.payload("removeListener", "promotedProduct")
                OpenIapModule.shared.removeListener(sub)
            }
            purchaseUpdatedSub = nil
            purchaseErrorSub = nil
            promotedProductSub = nil
            Task {
                RnIapLog.payload("endConnection", nil)
                let result = try? await OpenIapModule.shared.endConnection()
                RnIapLog.result("endConnection", result as Any)
            }
    
            // Clear event listeners, error dedup state, and delivery state (thread-safe)
            listenerLock.withLock {
                purchaseUpdatedListeners.removeAll()
                purchaseErrorListeners.removeAll()
                promotedProductListeners.removeAll()
                lastPurchaseErrorKey = nil
                lastPurchaseErrorTimestamp = 0
                deliveredPurchaseEventKeys.removeAll()
                deliveredPurchaseEventOrder.removeAll()
            }
            // Clear purchasePayloadById on MainActor to match its access pattern
            Task { @MainActor in
                self.purchasePayloadById.removeAll()
            }
        }
    
        func deepLinkToSubscriptionsAndroid(options: NitroDeepLinkOptionsAndroid) throws -> Promise<Void> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported)
            }
        }
    
        // MARK: - Alternative Billing (Android) - Not supported on iOS
    
        func checkAlternativeBillingAvailabilityAndroid() throws -> Promise<Bool> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported)
            }
        }
    
        func showAlternativeBillingDialogAndroid() throws -> Promise<Bool> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported)
            }
        }
    
        func createAlternativeBillingTokenAndroid(sku: Variant_NullType_String?) throws -> Promise<Variant_NullType_String> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported)
            }
        }
    
        func addUserChoiceBillingListenerAndroid(listener: @escaping (UserChoiceBillingDetails) -> Void) throws {
            RnIapLog.warn("addUserChoiceBillingListenerAndroid is Android-only and has no effect on iOS")
        }
    
        func removeUserChoiceBillingListenerAndroid(listener: @escaping (UserChoiceBillingDetails) -> Void) throws {
            RnIapLog.warn("removeUserChoiceBillingListenerAndroid is Android-only and has no effect on iOS")
        }
    
        func addDeveloperProvidedBillingListenerAndroid(listener: @escaping (DeveloperProvidedBillingDetailsAndroid) -> Void) throws {
            RnIapLog.warn("addDeveloperProvidedBillingListenerAndroid is Android-only and has no effect on iOS")
        }
    
        func removeDeveloperProvidedBillingListenerAndroid(listener: @escaping (DeveloperProvidedBillingDetailsAndroid) -> Void) throws {
            RnIapLog.warn("removeDeveloperProvidedBillingListenerAndroid is Android-only and has no effect on iOS")
        }
    
        // MARK: - Billing Programs API (Android 8.2.0+) - Not supported on iOS
    
        func enableBillingProgramAndroid(program: BillingProgramAndroid) throws {
            RnIapLog.warn("enableBillingProgramAndroid is Android-only and has no effect on iOS")
        }
    
        func isBillingProgramAvailableAndroid(program: BillingProgramAndroid) throws -> Promise<NitroBillingProgramAvailabilityResultAndroid> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only")
            }
        }
    
        func createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid) throws -> Promise<NitroBillingProgramReportingDetailsAndroid> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only")
            }
        }
    
        func launchExternalLinkAndroid(params: NitroLaunchExternalLinkParamsAndroid) throws -> Promise<Bool> {
            return Promise.async {
                throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only")
            }
        }
    
        // MARK: - External Purchase (iOS 16.0+)
    
        func canPresentExternalPurchaseNoticeIOS() throws -> Promise<Bool> {
            return Promise.async {
                RnIapLog.payload("canPresentExternalPurchaseNoticeIOS", nil)
    
                if #available(iOS 16.0, *) {
                    try self.ensureConnection()
                    do {
                        let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS()
                        RnIapLog.result("canPresentExternalPurchaseNoticeIOS", canPresent)
                        return canPresent
                    } catch let purchaseError as PurchaseError {
                        RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: purchaseError)
                        throw OpenIapException.from(purchaseError)
                    } catch {
                        RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: error)
                        throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                    }
                } else {
                    let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 16.0 or later")
                    RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: err)
                    throw err
                }
            }
        }
    
        func presentExternalPurchaseNoticeSheetIOS() throws -> Promise<ExternalPurchaseNoticeResultIOS> {
            return Promise.async {
                RnIapLog.payload("presentExternalPurchaseNoticeSheetIOS", nil)
    
                if #available(iOS 16.0, *) {
                    try self.ensureConnection()
                    do {
                        let result = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS()
    
                        // Convert OpenIAP action to Nitro action via raw value
                        let actionString = result.result.rawValue
                        guard let nitroAction = ExternalPurchaseNoticeAction(fromString: actionString) else {
                            throw OpenIapException.make(code: .serviceError, message: "Invalid action: \(actionString)")
                        }
    
                        let nitroResult = ExternalPurchaseNoticeResultIOS(
                            error: RnIapHelper.wrapString(result.error),
                            externalPurchaseToken: RnIapHelper.wrapString(result.externalPurchaseToken),
                            result: nitroAction
                        )
                        var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
                        if encoded["externalPurchaseToken"] != nil {
                            encoded["externalPurchaseToken"] = "<token>"
                        }
                        RnIapLog.result("presentExternalPurchaseNoticeSheetIOS", encoded)
                        return nitroResult
                    } catch let purchaseError as PurchaseError {
                        RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: purchaseError)
                        throw OpenIapException.from(purchaseError)
                    } catch {
                        RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: error)
                        throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                    }
                } else {
                    let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 16.0 or later")
                    RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: err)
                    throw err
                }
            }
        }
    
        func presentExternalPurchaseLinkIOS(url: String) throws -> Promise<ExternalPurchaseLinkResultIOS> {
            return Promise.async {
                RnIapLog.payload("presentExternalPurchaseLinkIOS", ["url": url])
    
                if #available(iOS 16.0, *) {
                    try self.ensureConnection()
                    do {
                        let result = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(url)
                        let nitroResult = ExternalPurchaseLinkResultIOS(
                            error: RnIapHelper.wrapString(result.error),
                            success: result.success
                        )
                        RnIapLog.result("presentExternalPurchaseLinkIOS", result)
                        return nitroResult
                    } catch let purchaseError as PurchaseError {
                        RnIapLog.failure("presentExternalPurchaseLinkIOS", error: purchaseError)
                        throw OpenIapException.from(purchaseError)
                    } catch {
                        RnIapLog.failure("presentExternalPurchaseLinkIOS", error: error)
                        throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                    }
                } else {
                    let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase link requires iOS 16.0 or later")
                    RnIapLog.failure("presentExternalPurchaseLinkIOS", error: err)
                    throw err
                }
            }
        }
    
        // MARK: - ExternalPurchaseCustomLink (iOS 18.1+)
    
        func isEligibleForExternalPurchaseCustomLinkIOS() throws -> Promise<Bool> {
            return Promise.async {
                RnIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", nil)
                do {
                    let isEligible = try await OpenIapModule.shared.isEligibleForExternalPurchaseCustomLinkIOS()
                    RnIapLog.result("isEligibleForExternalPurchaseCustomLinkIOS", isEligible)
                    return isEligible
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) throws -> Promise<ExternalPurchaseCustomLinkTokenResultIOS> {
            return Promise.async {
                RnIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", ["tokenType": tokenType.stringValue])
                do {
                    // Convert Nitro enum to OpenIAP enum
                    guard let openIapTokenType = OpenIAP.ExternalPurchaseCustomLinkTokenTypeIOS(rawValue: tokenType.stringValue) else {
                        throw OpenIapException.make(code: .developerError, message: "Invalid token type: \(tokenType.stringValue). Must be 'acquisition' or 'services'")
                    }
                    let result = try await OpenIapModule.shared.getExternalPurchaseCustomLinkTokenIOS(openIapTokenType)
                    let nitroResult = ExternalPurchaseCustomLinkTokenResultIOS(
                        error: RnIapHelper.wrapString(result.error),
                        token: RnIapHelper.wrapString(result.token)
                    )
                    var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
                    if encoded["token"] != nil {
                        encoded["token"] = "<token>"
                    }
                    RnIapLog.result("getExternalPurchaseCustomLinkTokenIOS", encoded)
                    return nitroResult
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    
        func showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) throws -> Promise<ExternalPurchaseCustomLinkNoticeResultIOS> {
            return Promise.async {
                RnIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", ["noticeType": noticeType.stringValue])
                do {
                    // Convert Nitro enum to OpenIAP enum
                    // Handle 'unspecified' by defaulting to 'browser' (workaround for Nitro requiring 2+ enum values)
                    let openIapNoticeType: OpenIAP.ExternalPurchaseCustomLinkNoticeTypeIOS
                    if noticeType == .unspecified {
                        RnIapLog.warn("showExternalPurchaseCustomLinkNoticeIOS received 'unspecified' noticeType, defaulting to 'browser'.")
                        openIapNoticeType = .browser
                    } else if let convertedType = OpenIAP.ExternalPurchaseCustomLinkNoticeTypeIOS(rawValue: noticeType.stringValue) {
                        openIapNoticeType = convertedType
                    } else {
                        throw OpenIapException.make(code: .developerError, message: "Invalid notice type: \(noticeType.stringValue). Must be 'browser'")
                    }
                    let result = try await OpenIapModule.shared.showExternalPurchaseCustomLinkNoticeIOS(openIapNoticeType)
                    let nitroResult = ExternalPurchaseCustomLinkNoticeResultIOS(
                        continued: result.continued,
                        error: RnIapHelper.wrapString(result.error)
                    )
                    RnIapLog.result("showExternalPurchaseCustomLinkNoticeIOS", result)
                    return nitroResult
                } catch let purchaseError as PurchaseError {
                    RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: purchaseError)
                    throw OpenIapException.from(purchaseError)
                } catch {
                    RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: error)
                    throw OpenIapException.make(code: .serviceError, message: error.localizedDescription)
                }
            }
        }
    }
  28. renatomserra commented on Apr 4, 2026

    @renatomserra
    Author

    @hyochan just to confirm the fixes seem to be working correctly, sales seem to be back to normal. Thank you for all the effort, i appreciate your work 🫶

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions