@entitlehub/react-native 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 — 2026-07-21
4
+
5
+ ### Fixed
6
+ - **`purchaseProduct()` could hang forever after a successful purchase.** It waited only on
7
+ `purchaseUpdatedListener`; on the react-native-iap 15+ / Nitro bridge that callback never fires,
8
+ so the promise never settled — the paywall stayed open and features stayed locked even though the
9
+ user had been charged and EntitleHub had already granted the entitlement server-side.
10
+ `requestPurchase`'s resolved value was being discarded entirely; it is now honoured, so bridges
11
+ that resolve instead of emitting an event work.
12
+
13
+ ### Added
14
+ - Reconciliation safety net: if no result arrives within `timeoutMs` (default 120s),
15
+ `purchaseProduct()` re-reads `getAvailablePurchases()` and, failing that, re-checks entitlements
16
+ from the server — resolving successfully if the entitlement appeared while the client was waiting.
17
+ Only rejects (`"purchase-timeout"`) when nothing was actually granted.
18
+ - `purchaseProduct(id, { timeoutMs })` to tune that window.
19
+
3
20
  ## 0.1.2 — 2026-07-18
4
21
 
5
22
  ### Added
package/dist/index.cjs CHANGED
@@ -70,9 +70,17 @@ async function getOfferings() {
70
70
  async function getProducts(productIds, type = "all") {
71
71
  return iap().fetchProducts({ skus: productIds, type });
72
72
  }
73
+ var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
73
74
  async function purchaseProduct(productId, opts = {}) {
74
75
  const I = iap();
75
76
  const isSub = Boolean(opts.isSubscription);
77
+ const isConsumable = Boolean(opts.isConsumable) && !isSub;
78
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
79
+ let entitlementsBefore = [];
80
+ try {
81
+ entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
82
+ } catch {
83
+ }
76
84
  const request = { apple: { sku: productId }, google: { skus: [productId] } };
77
85
  if (isSub) {
78
86
  try {
@@ -87,6 +95,7 @@ async function purchaseProduct(productId, opts = {}) {
87
95
  }
88
96
  return new Promise((resolve, reject) => {
89
97
  let settled = false;
98
+ let timer;
90
99
  const cleanup = () => {
91
100
  try {
92
101
  updSub?.remove();
@@ -96,38 +105,69 @@ async function purchaseProduct(productId, opts = {}) {
96
105
  errSub?.remove();
97
106
  } catch {
98
107
  }
108
+ if (timer) clearTimeout(timer);
99
109
  };
100
- const updSub = I.purchaseUpdatedListener(async (purchase) => {
110
+ const fail = (e) => {
111
+ if (settled) return;
112
+ settled = true;
113
+ cleanup();
114
+ reject(e);
115
+ };
116
+ const settleWithPurchase = async (purchase) => {
101
117
  if (settled) return;
102
118
  settled = true;
103
119
  cleanup();
104
120
  try {
105
121
  const info = await reportToEntitleHub(purchase, productId, isSub);
106
122
  try {
107
- await I.finishTransaction({ purchase, isConsumable: false });
123
+ await I.finishTransaction({ purchase, isConsumable });
108
124
  } catch {
109
125
  }
110
126
  resolve(info);
111
127
  } catch (e) {
112
128
  reject(e);
113
129
  }
130
+ };
131
+ const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
132
+ const reconcile = async () => {
133
+ if (settled) return;
134
+ try {
135
+ const owned = await I.getAvailablePurchases() || [];
136
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
137
+ if (match) return settleWithPurchase(match);
138
+ } catch {
139
+ }
140
+ try {
141
+ const info = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
142
+ const grew = info.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id));
143
+ if (grew) {
144
+ if (settled) return;
145
+ settled = true;
146
+ cleanup();
147
+ resolve(info);
148
+ return;
149
+ }
150
+ } catch {
151
+ }
152
+ fail(new Error("purchase-timeout"));
153
+ };
154
+ const updSub = I.purchaseUpdatedListener((purchase) => {
155
+ void settleWithPurchase(purchase);
114
156
  });
115
157
  const errSub = I.purchaseErrorListener((error) => {
116
- if (settled) return;
117
- settled = true;
118
- cleanup();
119
158
  const code = error?.code;
120
159
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
121
- reject(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
160
+ fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
122
161
  });
123
162
  Promise.resolve(
124
163
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
125
- ).catch((e) => {
126
- if (settled) return;
127
- settled = true;
128
- cleanup();
129
- reject(e);
130
- });
164
+ ).then((res) => {
165
+ const p = Array.isArray(res) ? res.find(isPurchase) : res;
166
+ if (isPurchase(p)) void settleWithPurchase(p);
167
+ }).catch((e) => fail(e));
168
+ timer = setTimeout(() => {
169
+ void reconcile();
170
+ }, timeoutMs);
131
171
  });
132
172
  }
133
173
  async function restorePurchases() {
package/dist/index.d.cts CHANGED
@@ -35,12 +35,22 @@ declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "a
35
35
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
36
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
37
37
  *
38
- * expo-iap's purchase flow is event-based the result arrives on `purchaseUpdatedListener`, not the
39
- * `requestPurchase` return value so we bridge that back into a promise here.
40
- * Rejects with "purchase-cancelled" if the user backs out.
38
+ * Store bridges disagree about how the result comes back, so we accept it from any source and never
39
+ * hang waiting for one of them:
40
+ * 1. `purchaseUpdatedListener` expo-iap's event-based flow.
41
+ * 2. `requestPurchase`'s resolved value — react-native-iap 15+ / Nitro resolves with the purchase
42
+ * and may never emit the event.
43
+ * 3. Reconciliation after `timeoutMs` — re-read `getAvailablePurchases()`, and failing that check
44
+ * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
+ * grants it independently of the client).
46
+ *
47
+ * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
+ * and nothing was granted.
41
49
  */
42
50
  declare function purchaseProduct(productId: string, opts?: {
43
51
  isSubscription?: boolean;
52
+ isConsumable?: boolean;
53
+ timeoutMs?: number;
44
54
  }): Promise<CustomerInfo>;
45
55
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
46
56
  declare function restorePurchases(): Promise<CustomerInfo>;
package/dist/index.d.ts CHANGED
@@ -35,12 +35,22 @@ declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "a
35
35
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
36
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
37
37
  *
38
- * expo-iap's purchase flow is event-based the result arrives on `purchaseUpdatedListener`, not the
39
- * `requestPurchase` return value so we bridge that back into a promise here.
40
- * Rejects with "purchase-cancelled" if the user backs out.
38
+ * Store bridges disagree about how the result comes back, so we accept it from any source and never
39
+ * hang waiting for one of them:
40
+ * 1. `purchaseUpdatedListener` expo-iap's event-based flow.
41
+ * 2. `requestPurchase`'s resolved value — react-native-iap 15+ / Nitro resolves with the purchase
42
+ * and may never emit the event.
43
+ * 3. Reconciliation after `timeoutMs` — re-read `getAvailablePurchases()`, and failing that check
44
+ * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
+ * grants it independently of the client).
46
+ *
47
+ * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
+ * and nothing was granted.
41
49
  */
42
50
  declare function purchaseProduct(productId: string, opts?: {
43
51
  isSubscription?: boolean;
52
+ isConsumable?: boolean;
53
+ timeoutMs?: number;
44
54
  }): Promise<CustomerInfo>;
45
55
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
46
56
  declare function restorePurchases(): Promise<CustomerInfo>;
package/dist/index.js CHANGED
@@ -45,9 +45,17 @@ async function getOfferings() {
45
45
  async function getProducts(productIds, type = "all") {
46
46
  return iap().fetchProducts({ skus: productIds, type });
47
47
  }
48
+ var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
48
49
  async function purchaseProduct(productId, opts = {}) {
49
50
  const I = iap();
50
51
  const isSub = Boolean(opts.isSubscription);
52
+ const isConsumable = Boolean(opts.isConsumable) && !isSub;
53
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
54
+ let entitlementsBefore = [];
55
+ try {
56
+ entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
57
+ } catch {
58
+ }
51
59
  const request = { apple: { sku: productId }, google: { skus: [productId] } };
52
60
  if (isSub) {
53
61
  try {
@@ -62,6 +70,7 @@ async function purchaseProduct(productId, opts = {}) {
62
70
  }
63
71
  return new Promise((resolve, reject) => {
64
72
  let settled = false;
73
+ let timer;
65
74
  const cleanup = () => {
66
75
  try {
67
76
  updSub?.remove();
@@ -71,38 +80,69 @@ async function purchaseProduct(productId, opts = {}) {
71
80
  errSub?.remove();
72
81
  } catch {
73
82
  }
83
+ if (timer) clearTimeout(timer);
74
84
  };
75
- const updSub = I.purchaseUpdatedListener(async (purchase) => {
85
+ const fail = (e) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ cleanup();
89
+ reject(e);
90
+ };
91
+ const settleWithPurchase = async (purchase) => {
76
92
  if (settled) return;
77
93
  settled = true;
78
94
  cleanup();
79
95
  try {
80
96
  const info = await reportToEntitleHub(purchase, productId, isSub);
81
97
  try {
82
- await I.finishTransaction({ purchase, isConsumable: false });
98
+ await I.finishTransaction({ purchase, isConsumable });
83
99
  } catch {
84
100
  }
85
101
  resolve(info);
86
102
  } catch (e) {
87
103
  reject(e);
88
104
  }
105
+ };
106
+ const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
107
+ const reconcile = async () => {
108
+ if (settled) return;
109
+ try {
110
+ const owned = await I.getAvailablePurchases() || [];
111
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
112
+ if (match) return settleWithPurchase(match);
113
+ } catch {
114
+ }
115
+ try {
116
+ const info = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
117
+ const grew = info.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id));
118
+ if (grew) {
119
+ if (settled) return;
120
+ settled = true;
121
+ cleanup();
122
+ resolve(info);
123
+ return;
124
+ }
125
+ } catch {
126
+ }
127
+ fail(new Error("purchase-timeout"));
128
+ };
129
+ const updSub = I.purchaseUpdatedListener((purchase) => {
130
+ void settleWithPurchase(purchase);
89
131
  });
90
132
  const errSub = I.purchaseErrorListener((error) => {
91
- if (settled) return;
92
- settled = true;
93
- cleanup();
94
133
  const code = error?.code;
95
134
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
96
- reject(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
135
+ fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
97
136
  });
98
137
  Promise.resolve(
99
138
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
100
- ).catch((e) => {
101
- if (settled) return;
102
- settled = true;
103
- cleanup();
104
- reject(e);
105
- });
139
+ ).then((res) => {
140
+ const p = Array.isArray(res) ? res.find(isPurchase) : res;
141
+ if (isPurchase(p)) void settleWithPurchase(p);
142
+ }).catch((e) => fail(e));
143
+ timer = setTimeout(() => {
144
+ void reconcile();
145
+ }, timeoutMs);
106
146
  });
107
147
  }
108
148
  async function restorePurchases() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@entitlehub/react-native",
3
- "version": "0.1.3",
4
- "description": "EntitleHub for React Native & Expo \u2014 one call to purchase and unlock entitlements. Wraps expo-iap for the native store sheet and validates receipts via EntitleHub.",
3
+ "version": "0.1.5",
4
+ "description": "EntitleHub for React Native & Expo one call to purchase and unlock entitlements. Wraps expo-iap for the native store sheet and validates receipts via EntitleHub.",
5
5
  "keywords": [
6
6
  "entitlehub",
7
7
  "react-native",
@@ -62,4 +62,4 @@
62
62
  "tsup": "^8.0.0",
63
63
  "typescript": "^5.9.3"
64
64
  }
65
- }
65
+ }