@entitlehub/react-native 0.1.4 → 0.1.6

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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6 — 2026-07-21
4
+
5
+ ### Added
6
+ - Reports `react-native/<version>` to EntitleHub on each call, so the dashboard can tell you when
7
+ your SDK is behind a release with fixes in it — rather than you finding out by hitting the bug.
8
+ Version only; no device or user data.
9
+
10
+ ## 0.1.5 — 2026-07-21
11
+
12
+ ### Fixed
13
+ - **`purchaseProduct()` could hang forever after a successful purchase.** It waited only on
14
+ `purchaseUpdatedListener`; on the react-native-iap 15+ / Nitro bridge that callback never fires,
15
+ so the promise never settled — the paywall stayed open and features stayed locked even though the
16
+ user had been charged and EntitleHub had already granted the entitlement server-side.
17
+ `requestPurchase`'s resolved value was being discarded entirely; it is now honoured, so bridges
18
+ that resolve instead of emitting an event work.
19
+
20
+ ### Added
21
+ - Reconciliation safety net: if no result arrives within `timeoutMs` (default 120s),
22
+ `purchaseProduct()` re-reads `getAvailablePurchases()` and, failing that, re-checks entitlements
23
+ from the server — resolving successfully if the entitlement appeared while the client was waiting.
24
+ Only rejects (`"purchase-timeout"`) when nothing was actually granted.
25
+ - `purchaseProduct(id, { timeoutMs })` to tune that window.
26
+
3
27
  ## 0.1.2 — 2026-07-18
4
28
 
5
29
  ### Added
package/dist/index.cjs CHANGED
@@ -42,6 +42,7 @@ function iap() {
42
42
  }
43
43
  return _iap;
44
44
  }
45
+ var RN_SDK_VERSION = "0.1.6";
45
46
  var client;
46
47
  function eh() {
47
48
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -49,7 +50,12 @@ function eh() {
49
50
  }
50
51
  async function configureEntitleHub(opts) {
51
52
  if (opts.iap) _iap = opts.iap;
52
- client = new import_sdk.EntitleHub({ apiKey: opts.apiKey, appUserId: opts.appUserId, baseUrl: opts.baseUrl });
53
+ client = new import_sdk.EntitleHub({
54
+ apiKey: opts.apiKey,
55
+ appUserId: opts.appUserId,
56
+ baseUrl: opts.baseUrl,
57
+ client: `react-native/${RN_SDK_VERSION}`
58
+ });
53
59
  try {
54
60
  await iap().initConnection();
55
61
  } catch {
@@ -70,10 +76,17 @@ async function getOfferings() {
70
76
  async function getProducts(productIds, type = "all") {
71
77
  return iap().fetchProducts({ skus: productIds, type });
72
78
  }
79
+ var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
73
80
  async function purchaseProduct(productId, opts = {}) {
74
81
  const I = iap();
75
82
  const isSub = Boolean(opts.isSubscription);
76
83
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
84
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
85
+ let entitlementsBefore = [];
86
+ try {
87
+ entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
88
+ } catch {
89
+ }
77
90
  const request = { apple: { sku: productId }, google: { skus: [productId] } };
78
91
  if (isSub) {
79
92
  try {
@@ -88,6 +101,7 @@ async function purchaseProduct(productId, opts = {}) {
88
101
  }
89
102
  return new Promise((resolve, reject) => {
90
103
  let settled = false;
104
+ let timer;
91
105
  const cleanup = () => {
92
106
  try {
93
107
  updSub?.remove();
@@ -97,8 +111,15 @@ async function purchaseProduct(productId, opts = {}) {
97
111
  errSub?.remove();
98
112
  } catch {
99
113
  }
114
+ if (timer) clearTimeout(timer);
115
+ };
116
+ const fail = (e) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ cleanup();
120
+ reject(e);
100
121
  };
101
- const updSub = I.purchaseUpdatedListener(async (purchase) => {
122
+ const settleWithPurchase = async (purchase) => {
102
123
  if (settled) return;
103
124
  settled = true;
104
125
  cleanup();
@@ -112,23 +133,47 @@ async function purchaseProduct(productId, opts = {}) {
112
133
  } catch (e) {
113
134
  reject(e);
114
135
  }
136
+ };
137
+ const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
138
+ const reconcile = async () => {
139
+ if (settled) return;
140
+ try {
141
+ const owned = await I.getAvailablePurchases() || [];
142
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
143
+ if (match) return settleWithPurchase(match);
144
+ } catch {
145
+ }
146
+ try {
147
+ const info = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
148
+ const grew = info.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id));
149
+ if (grew) {
150
+ if (settled) return;
151
+ settled = true;
152
+ cleanup();
153
+ resolve(info);
154
+ return;
155
+ }
156
+ } catch {
157
+ }
158
+ fail(new Error("purchase-timeout"));
159
+ };
160
+ const updSub = I.purchaseUpdatedListener((purchase) => {
161
+ void settleWithPurchase(purchase);
115
162
  });
116
163
  const errSub = I.purchaseErrorListener((error) => {
117
- if (settled) return;
118
- settled = true;
119
- cleanup();
120
164
  const code = error?.code;
121
165
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
122
- reject(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
166
+ fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
123
167
  });
124
168
  Promise.resolve(
125
169
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
126
- ).catch((e) => {
127
- if (settled) return;
128
- settled = true;
129
- cleanup();
130
- reject(e);
131
- });
170
+ ).then((res) => {
171
+ const p = Array.isArray(res) ? res.find(isPurchase) : res;
172
+ if (isPurchase(p)) void settleWithPurchase(p);
173
+ }).catch((e) => fail(e));
174
+ timer = setTimeout(() => {
175
+ void reconcile();
176
+ }, timeoutMs);
132
177
  });
133
178
  }
134
179
  async function restorePurchases() {
package/dist/index.d.cts CHANGED
@@ -35,13 +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;
44
52
  isConsumable?: boolean;
53
+ timeoutMs?: number;
45
54
  }): Promise<CustomerInfo>;
46
55
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
47
56
  declare function restorePurchases(): Promise<CustomerInfo>;
package/dist/index.d.ts CHANGED
@@ -35,13 +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;
44
52
  isConsumable?: boolean;
53
+ timeoutMs?: number;
45
54
  }): Promise<CustomerInfo>;
46
55
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
47
56
  declare function restorePurchases(): Promise<CustomerInfo>;
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ function iap() {
17
17
  }
18
18
  return _iap;
19
19
  }
20
+ var RN_SDK_VERSION = "0.1.6";
20
21
  var client;
21
22
  function eh() {
22
23
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -24,7 +25,12 @@ function eh() {
24
25
  }
25
26
  async function configureEntitleHub(opts) {
26
27
  if (opts.iap) _iap = opts.iap;
27
- client = new EntitleHub({ apiKey: opts.apiKey, appUserId: opts.appUserId, baseUrl: opts.baseUrl });
28
+ client = new EntitleHub({
29
+ apiKey: opts.apiKey,
30
+ appUserId: opts.appUserId,
31
+ baseUrl: opts.baseUrl,
32
+ client: `react-native/${RN_SDK_VERSION}`
33
+ });
28
34
  try {
29
35
  await iap().initConnection();
30
36
  } catch {
@@ -45,10 +51,17 @@ async function getOfferings() {
45
51
  async function getProducts(productIds, type = "all") {
46
52
  return iap().fetchProducts({ skus: productIds, type });
47
53
  }
54
+ var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
48
55
  async function purchaseProduct(productId, opts = {}) {
49
56
  const I = iap();
50
57
  const isSub = Boolean(opts.isSubscription);
51
58
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
59
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
60
+ let entitlementsBefore = [];
61
+ try {
62
+ entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
63
+ } catch {
64
+ }
52
65
  const request = { apple: { sku: productId }, google: { skus: [productId] } };
53
66
  if (isSub) {
54
67
  try {
@@ -63,6 +76,7 @@ async function purchaseProduct(productId, opts = {}) {
63
76
  }
64
77
  return new Promise((resolve, reject) => {
65
78
  let settled = false;
79
+ let timer;
66
80
  const cleanup = () => {
67
81
  try {
68
82
  updSub?.remove();
@@ -72,8 +86,15 @@ async function purchaseProduct(productId, opts = {}) {
72
86
  errSub?.remove();
73
87
  } catch {
74
88
  }
89
+ if (timer) clearTimeout(timer);
90
+ };
91
+ const fail = (e) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ cleanup();
95
+ reject(e);
75
96
  };
76
- const updSub = I.purchaseUpdatedListener(async (purchase) => {
97
+ const settleWithPurchase = async (purchase) => {
77
98
  if (settled) return;
78
99
  settled = true;
79
100
  cleanup();
@@ -87,23 +108,47 @@ async function purchaseProduct(productId, opts = {}) {
87
108
  } catch (e) {
88
109
  reject(e);
89
110
  }
111
+ };
112
+ const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
113
+ const reconcile = async () => {
114
+ if (settled) return;
115
+ try {
116
+ const owned = await I.getAvailablePurchases() || [];
117
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
118
+ if (match) return settleWithPurchase(match);
119
+ } catch {
120
+ }
121
+ try {
122
+ const info = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
123
+ const grew = info.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id));
124
+ if (grew) {
125
+ if (settled) return;
126
+ settled = true;
127
+ cleanup();
128
+ resolve(info);
129
+ return;
130
+ }
131
+ } catch {
132
+ }
133
+ fail(new Error("purchase-timeout"));
134
+ };
135
+ const updSub = I.purchaseUpdatedListener((purchase) => {
136
+ void settleWithPurchase(purchase);
90
137
  });
91
138
  const errSub = I.purchaseErrorListener((error) => {
92
- if (settled) return;
93
- settled = true;
94
- cleanup();
95
139
  const code = error?.code;
96
140
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
97
- reject(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
141
+ fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
98
142
  });
99
143
  Promise.resolve(
100
144
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
101
- ).catch((e) => {
102
- if (settled) return;
103
- settled = true;
104
- cleanup();
105
- reject(e);
106
- });
145
+ ).then((res) => {
146
+ const p = Array.isArray(res) ? res.find(isPurchase) : res;
147
+ if (isPurchase(p)) void settleWithPurchase(p);
148
+ }).catch((e) => fail(e));
149
+ timer = setTimeout(() => {
150
+ void reconcile();
151
+ }, timeoutMs);
107
152
  });
108
153
  }
109
154
  async function restorePurchases() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entitlehub/react-native",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
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",