@entitlehub/react-native 0.1.6 → 0.1.8

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,18 +1,56 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.6 2026-07-21
3
+ ## 0.1.8 (2026-09-19)
4
+
5
+ ### Fixed
6
+ - **Black screen on launch: "Requiring unknown module expo-iap".** The SDK loaded `expo-iap` with
7
+ `require()`. Metro decides what to bundle by reading literal `require("…")` / `import("…")` calls,
8
+ and the ESM build rewrote that call into esbuild's `__require` shim, which Metro cannot see. So
9
+ `expo-iap` was never put in the bundle, the first call threw, and React Native tore the UI down
10
+ with it, before the store sheet ever opened. It is now loaded with `import("expo-iap")`, which
11
+ survives the build as a literal. Verified by bundling a real Expo app: with 0.1.7 the store
12
+ library is absent from the bundle, with 0.1.8 it is there.
13
+ - The module now loads while `configureEntitleHub()` runs, so a missing or broken `expo-iap` raises
14
+ a clear error there instead of being swallowed by the store-connection catch, and handles CJS
15
+ interop (module namespace or `.default`).
16
+
17
+ ### Changed
18
+ - `configureEntitleHub({ iap })` is no longer a workaround for the bug above, just the way to pin an
19
+ exact module instance or use another OpenIAP-compatible library.
20
+
21
+ ## 0.1.7 (2026-07-22)
22
+
23
+ ### Fixed
24
+ - **A completed purchase could still be reported as failed.** After a timeout, `purchaseProduct()`
25
+ checked for confirmation exactly once. Store notifications are asynchronous, so that single
26
+ reading races the grant and sometimes loses, observed live: the entitlement was written seconds
27
+ after the check ran, and the user saw a purchase-failed error for a purchase that worked. It now
28
+ polls the store and the server with backoff (`reconcileMs`, default 30s) before giving up.
29
+ - **The timeout counted the user's own time.** The timer was armed when `purchaseProduct()` was
30
+ called: before the store's password / Face ID sheet appears, so a user who paused mid-purchase
31
+ tripped it. It now starts once the request is actually in flight, with a separate generous
32
+ backstop (`sheetTimeoutMs`, default 10m) so a stuck bridge still can't hang forever.
33
+
34
+ ### Changed
35
+ - **Rejections are now typed `PurchaseError`s: branch on `.code`, not the message.**
36
+ `purchase-cancelled`, `purchase-failed`, and the new `purchase-pending`. **`purchase-pending` is
37
+ not a failure**: the store took the money but hasn't confirmed yet. Show "still confirming", the
38
+ entitlement lands on its own and `addCustomerInfoUpdateListener` fires when it does. `.pending`
39
+ and `.customerInfo` are on the error. The old untyped `"purchase-timeout"` message is gone.
40
+
41
+ ## 0.1.6 (2026-07-21)
4
42
 
5
43
  ### Added
6
44
  - 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.
45
+ your SDK is behind a release with fixes in it, rather than you finding out by hitting the bug.
8
46
  Version only; no device or user data.
9
47
 
10
- ## 0.1.5 2026-07-21
48
+ ## 0.1.5 (2026-07-21)
11
49
 
12
50
  ### Fixed
13
51
  - **`purchaseProduct()` could hang forever after a successful purchase.** It waited only on
14
52
  `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
53
+ so the promise never settled: the paywall stayed open and features stayed locked even though the
16
54
  user had been charged and EntitleHub had already granted the entitlement server-side.
17
55
  `requestPurchase`'s resolved value was being discarded entirely; it is now honoured, so bridges
18
56
  that resolve instead of emitting an event work.
@@ -20,14 +58,14 @@
20
58
  ### Added
21
59
  - Reconciliation safety net: if no result arrives within `timeoutMs` (default 120s),
22
60
  `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.
61
+ from the server: resolving successfully if the entitlement appeared while the client was waiting.
24
62
  Only rejects (`"purchase-timeout"`) when nothing was actually granted.
25
63
  - `purchaseProduct(id, { timeoutMs })` to tune that window.
26
64
 
27
- ## 0.1.2 2026-07-18
65
+ ## 0.1.2 (2026-07-18)
28
66
 
29
67
  ### Added
30
- - `configureEntitleHub({ iap })` inject the OpenIAP-compatible store module (pinned expo-iap
68
+ - `configureEntitleHub({ iap })`: inject the OpenIAP-compatible store module (pinned expo-iap
31
69
  version, or react-native-iap) instead of the hard-coded `expo-iap`. Decouples this SDK from a
32
70
  single store-library version so you can work around a native issue without changing the SDK.
33
71
 
@@ -36,17 +74,17 @@
36
74
  thread) are a store-library-version concern; how to pin a version, inject a module, or fall back
37
75
  to the fully-decoupled `@entitlehub/sdk` + your own billing library.
38
76
 
39
- ## 0.1.1 2026-07-18
77
+ ## 0.1.1 (2026-07-18)
40
78
 
41
79
  ### Fixed
42
- - **`purchaseProduct` crashed** 0.1.0 was written against expo-iap's old flat `requestPurchase`
80
+ - **`purchaseProduct` crashed**: 0.1.0 was written against expo-iap's old flat `requestPurchase`
43
81
  API and read the purchase from the return value. `expo-iap@4.x` is **event-based** with a
44
82
  discriminated request. Now: `requestPurchase({ request: { apple: { sku }, google: { skus } },
45
83
  type })` with the result bridged from `purchaseUpdatedListener` / `purchaseErrorListener`.
46
84
  - Android subscriptions now pass the required `offerToken` (fetched from the product).
47
85
  - `getProducts` uses expo-iap's `fetchProducts` (the old `getProducts` no longer exists).
48
86
 
49
- ## 0.1.0 2026-07-18
87
+ ## 0.1.0 (2026-07-18)
50
88
 
51
89
  Initial release.
52
90
 
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@entitlehub/react-native)](https://www.npmjs.com/package/@entitlehub/react-native)
4
4
  [![license MIT](https://img.shields.io/npm/l/@entitlehub/react-native)](./LICENSE)
5
5
 
6
- EntitleHub for **React Native & Expo** one call to purchase and unlock entitlements. It opens the
6
+ EntitleHub for **React Native & Expo**: one call to purchase and unlock entitlements. It opens the
7
7
  native store sheet (via [`expo-iap`](https://github.com/hyochan/expo-iap)), validates the receipt
8
8
  with EntitleHub server-side, and hands you the updated entitlements. The RevenueCat developer
9
9
  experience, on EntitleHub.
@@ -26,7 +26,7 @@ await configureEntitleHub({ apiKey: "pk_live_…", appUserId: user.id });
26
26
  // gate a feature:
27
27
  if (await isEntitled("pro")) unlockPro();
28
28
 
29
- // buy opens the native sheet, validates, returns entitlements:
29
+ // buy: opens the native sheet, validates, returns entitlements:
30
30
  const info = await purchaseProduct("app_pro_monthly", { isSubscription: true });
31
31
  if (info.isActive("pro")) unlockPro();
32
32
 
@@ -45,7 +45,7 @@ const restored = await restorePurchases();
45
45
  | `addCustomerInfoUpdateListener(fn)` | React to entitlement changes. |
46
46
 
47
47
  Only your **publishable** key (`pk_`) ships in the app. EntitleHub validates the Apple/Google
48
- receipt server-side, so a forged one is rejected no secret key on the device.
48
+ receipt server-side, so a forged one is rejected, no secret key on the device.
49
49
 
50
50
  ## How it works
51
51
 
@@ -60,22 +60,25 @@ Full guide: **[entitlehub.com/docs/purchases](https://entitlehub.com/docs/purcha
60
60
  ## Choosing / pinning the store library
61
61
 
62
62
  The on-device purchase runs through **expo-iap** (OpenIAP). That native layer is the store
63
- library's responsibility, not EntitleHub's and a given version can have platform-specific native
63
+ library's responsibility, not EntitleHub's, and a given version can have platform-specific native
64
64
  bugs. If you hit a **native IAP crash** (e.g. an `EXC_BAD_ACCESS` in the store module), it's the
65
65
  store library, not this SDK or your app code, and no JS change can catch a native exception thrown
66
66
  off the JS thread.
67
67
 
68
68
  Two levers, both without changing this SDK:
69
69
 
70
- - **Pin a working version:** `npm install expo-iap@<version>` this package uses whatever `expo-iap`
70
+ - **Pin a working version:** `npm install expo-iap@<version>`, this package uses whatever `expo-iap`
71
71
  is installed (peer dependency).
72
- - **Supply your own module:** pass any OpenIAP-compatible library to `configureEntitleHub`:
72
+ - **Supply your own module:** pass any OpenIAP-compatible library to `configureEntitleHub`. You do
73
+ not need this to use `expo-iap`, which is loaded for you; it is for pinning an exact instance or
74
+ using a different library:
73
75
  ```ts
74
- await configureEntitleHub({ apiKey, appUserId, iap: require("expo-iap") }); // or react-native-iap
76
+ import * as iap from "expo-iap"; // or react-native-iap
77
+ await configureEntitleHub({ apiKey, appUserId, iap });
75
78
  ```
76
79
 
77
80
  Fully decoupled fallback: skip this package and use **[`@entitlehub/sdk`](https://www.npmjs.com/package/@entitlehub/sdk)**
78
- directly open the purchase with *any* billing library you've verified on your target OS, then call
81
+ directly: open the purchase with *any* billing library you've verified on your target OS, then call
79
82
  `eh.reportPurchase({ signedTransaction | purchaseToken })`. See
80
83
  [Purchases → manual](https://entitlehub.com/docs/purchases).
81
84
 
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,11 +17,20 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
21
31
  var index_exports = {};
22
32
  __export(index_exports, {
33
+ PurchaseError: () => PurchaseError,
23
34
  addCustomerInfoUpdateListener: () => addCustomerInfoUpdateListener,
24
35
  configureEntitleHub: () => configureEntitleHub,
25
36
  getCustomerInfo: () => getCustomerInfo,
@@ -33,16 +44,25 @@ __export(index_exports, {
33
44
  module.exports = __toCommonJS(index_exports);
34
45
  var import_sdk = require("@entitlehub/sdk");
35
46
  var _iap;
36
- function iap() {
47
+ async function loadIap() {
37
48
  if (_iap) return _iap;
49
+ let mod;
38
50
  try {
39
- _iap = require("expo-iap");
51
+ mod = await import("expo-iap");
40
52
  } catch {
41
53
  throw new Error("@entitlehub/react-native needs 'expo-iap'. Install it with: npx expo install expo-iap");
42
54
  }
55
+ _iap = typeof mod?.initConnection === "function" ? mod : mod?.default ?? mod;
56
+ if (typeof _iap?.initConnection !== "function") {
57
+ throw new Error("@entitlehub/react-native: the 'expo-iap' module did not load correctly (no initConnection). Check that expo-iap is installed and its version supports the OpenIAP API.");
58
+ }
59
+ return _iap;
60
+ }
61
+ function iap() {
62
+ if (!_iap) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub purchases.");
43
63
  return _iap;
44
64
  }
45
- var RN_SDK_VERSION = "0.1.6";
65
+ var RN_SDK_VERSION = "0.1.8";
46
66
  var client;
47
67
  function eh() {
48
68
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -50,6 +70,7 @@ function eh() {
50
70
  }
51
71
  async function configureEntitleHub(opts) {
52
72
  if (opts.iap) _iap = opts.iap;
73
+ else await loadIap();
53
74
  client = new import_sdk.EntitleHub({
54
75
  apiKey: opts.apiKey,
55
76
  appUserId: opts.appUserId,
@@ -77,11 +98,25 @@ async function getProducts(productIds, type = "all") {
77
98
  return iap().fetchProducts({ skus: productIds, type });
78
99
  }
79
100
  var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
101
+ var DEFAULT_SHEET_TIMEOUT_MS = 6e5;
102
+ var DEFAULT_RECONCILE_MS = 3e4;
103
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
104
+ var PurchaseError = class extends Error {
105
+ constructor(code, message, customerInfo) {
106
+ super(message);
107
+ this.name = "PurchaseError";
108
+ this.code = code;
109
+ this.pending = code === "purchase-pending";
110
+ this.customerInfo = customerInfo;
111
+ }
112
+ };
80
113
  async function purchaseProduct(productId, opts = {}) {
81
114
  const I = iap();
82
115
  const isSub = Boolean(opts.isSubscription);
83
116
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
84
117
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
118
+ const sheetTimeoutMs = opts.sheetTimeoutMs ?? DEFAULT_SHEET_TIMEOUT_MS;
119
+ const reconcileMs = opts.reconcileMs ?? DEFAULT_RECONCILE_MS;
85
120
  let entitlementsBefore = [];
86
121
  try {
87
122
  entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
@@ -136,26 +171,43 @@ async function purchaseProduct(productId, opts = {}) {
136
171
  };
137
172
  const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
138
173
  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;
174
+ const deadline = Date.now() + reconcileMs;
175
+ let wait = 2e3;
176
+ let lastInfo;
177
+ while (!settled) {
178
+ try {
179
+ const owned = await I.getAvailablePurchases() || [];
180
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
181
+ if (match) return settleWithPurchase(match);
182
+ } catch {
155
183
  }
156
- } catch {
184
+ try {
185
+ lastInfo = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
186
+ if (lastInfo.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id))) {
187
+ if (settled) return;
188
+ settled = true;
189
+ cleanup();
190
+ resolve(lastInfo);
191
+ return;
192
+ }
193
+ } catch {
194
+ }
195
+ const remaining = deadline - Date.now();
196
+ if (remaining <= 0) break;
197
+ await sleep(Math.min(wait, remaining));
198
+ wait = Math.min(wait * 1.5, 8e3);
157
199
  }
158
- fail(new Error("purchase-timeout"));
200
+ fail(new PurchaseError(
201
+ "purchase-pending",
202
+ "The store hasn't confirmed this purchase yet. It may still complete, show a pending state, not a failure.",
203
+ lastInfo
204
+ ));
205
+ };
206
+ const armTimer = (ms) => {
207
+ if (timer) clearTimeout(timer);
208
+ timer = setTimeout(() => {
209
+ void reconcile();
210
+ }, ms);
159
211
  };
160
212
  const updSub = I.purchaseUpdatedListener((purchase) => {
161
213
  void settleWithPurchase(purchase);
@@ -163,17 +215,21 @@ async function purchaseProduct(productId, opts = {}) {
163
215
  const errSub = I.purchaseErrorListener((error) => {
164
216
  const code = error?.code;
165
217
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
166
- fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
218
+ fail(cancelled ? new PurchaseError("purchase-cancelled", "The user cancelled the purchase.") : new PurchaseError("purchase-failed", error?.message || "The purchase failed."));
167
219
  });
168
220
  Promise.resolve(
169
221
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
170
222
  ).then((res) => {
171
223
  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);
224
+ if (isPurchase(p)) {
225
+ void settleWithPurchase(p);
226
+ return;
227
+ }
228
+ armTimer(timeoutMs);
229
+ }).catch((e) => fail(
230
+ e instanceof PurchaseError ? e : new PurchaseError("purchase-failed", e?.message || "The purchase failed.")
231
+ ));
232
+ armTimer(sheetTimeoutMs);
177
233
  });
178
234
  }
179
235
  async function restorePurchases() {
@@ -204,6 +260,7 @@ async function reportToEntitleHub(purchase, productId, isSubscription) {
204
260
  }
205
261
  // Annotate the CommonJS export names for ESM import in node:
206
262
  0 && (module.exports = {
263
+ PurchaseError,
207
264
  addCustomerInfoUpdateListener,
208
265
  configureEntitleHub,
209
266
  getCustomerInfo,
package/dist/index.d.cts CHANGED
@@ -13,9 +13,8 @@ interface ConfigureOptions {
13
13
  * `fetchProducts`, `requestPurchase`, `purchaseUpdatedListener`, `purchaseErrorListener`,
14
14
  * `finishTransaction`, `getAvailablePurchases`). Defaults to `expo-iap`.
15
15
  *
16
- * Pass your own if you need a specific version or a different OpenIAP-compatible library, e.g.
17
- * `iap: require("expo-iap")` with a pinned version, or `react-native-iap`. Useful to work around
18
- * a native issue in a particular store-library version without changing this SDK.
16
+ * Optional. Pass your own only to pin a specific version or to use a different OpenIAP-compatible
17
+ * library (e.g. `react-native-iap`); expo-iap is loaded for you otherwise.
19
18
  */
20
19
  iap?: unknown;
21
20
  }
@@ -31,30 +30,55 @@ declare function isEntitled(entitlementId: string): Promise<boolean>;
31
30
  declare function getOfferings(): Promise<Offerings>;
32
31
  /** Live store products (prices, localized titles) from the native store (expo-iap `fetchProducts`). */
33
32
  declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "all"): Promise<any[]>;
33
+ /** Why a purchase didn't complete. Check `code`, never match on the message. */
34
+ type PurchaseErrorCode = "purchase-cancelled" | "purchase-pending" | "purchase-failed";
35
+ /**
36
+ * A purchase that didn't return entitlements.
37
+ *
38
+ * `code === "purchase-pending"` is NOT a failure: the store took the money but confirmation hasn't
39
+ * reached us yet (store notifications are asynchronous). Show "still confirming", not an error,
40
+ * the entitlement will arrive on its own, and `addCustomerInfoUpdateListener` will fire when it does.
41
+ */
42
+ declare class PurchaseError extends Error {
43
+ readonly code: PurchaseErrorCode;
44
+ /** True when the purchase probably succeeded and is just unconfirmed. */
45
+ readonly pending: boolean;
46
+ /** Last known entitlements, if we managed to read them. */
47
+ readonly customerInfo?: CustomerInfo;
48
+ constructor(code: PurchaseErrorCode, message: string, customerInfo?: CustomerInfo);
49
+ }
34
50
  /**
35
51
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
52
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
37
53
  *
38
54
  * Store bridges disagree about how the result comes back, so we accept it from any source and never
39
55
  * 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
56
+ * 1. `purchaseUpdatedListener`: expo-iap's event-based flow.
57
+ * 2. `requestPurchase`'s resolved value: react-native-iap 15+ / Nitro resolves with the purchase
42
58
  * and may never emit the event.
43
- * 3. Reconciliation after `timeoutMs` re-read `getAvailablePurchases()`, and failing that check
59
+ * 3. Reconciliation after `timeoutMs`: re-read `getAvailablePurchases()`, and failing that check
44
60
  * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
61
  * grants it independently of the client).
46
62
  *
47
- * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
- * and nothing was granted.
63
+ * Rejects with a `PurchaseError`; branch on `.code`, never the message:
64
+ * - `purchase-cancelled`: the user backed out.
65
+ * - `purchase-pending` : the store took the money but hasn't confirmed. NOT a failure; show a
66
+ * pending state. `.pending` is true and the entitlement will land on its own.
67
+ * - `purchase-failed` : a real failure.
49
68
  */
50
69
  declare function purchaseProduct(productId: string, opts?: {
51
70
  isSubscription?: boolean;
52
71
  isConsumable?: boolean;
72
+ /** Wait for the store's result once the request is in flight (default 120s). */
53
73
  timeoutMs?: number;
74
+ /** Absolute backstop from the call, covering time spent in the store's own sheet (default 10m). */
75
+ sheetTimeoutMs?: number;
76
+ /** How long to poll for confirmation before reporting pending (default 30s). */
77
+ reconcileMs?: number;
54
78
  }): Promise<CustomerInfo>;
55
79
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
56
80
  declare function restorePurchases(): Promise<CustomerInfo>;
57
81
  /** Subscribe to entitlement changes (e.g. after a purchase). Returns an unsubscribe function. */
58
82
  declare function addCustomerInfoUpdateListener(fn: (info: CustomerInfo) => void): () => void;
59
83
 
60
- export { type ConfigureOptions, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
84
+ export { type ConfigureOptions, PurchaseError, type PurchaseErrorCode, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
package/dist/index.d.ts CHANGED
@@ -13,9 +13,8 @@ interface ConfigureOptions {
13
13
  * `fetchProducts`, `requestPurchase`, `purchaseUpdatedListener`, `purchaseErrorListener`,
14
14
  * `finishTransaction`, `getAvailablePurchases`). Defaults to `expo-iap`.
15
15
  *
16
- * Pass your own if you need a specific version or a different OpenIAP-compatible library, e.g.
17
- * `iap: require("expo-iap")` with a pinned version, or `react-native-iap`. Useful to work around
18
- * a native issue in a particular store-library version without changing this SDK.
16
+ * Optional. Pass your own only to pin a specific version or to use a different OpenIAP-compatible
17
+ * library (e.g. `react-native-iap`); expo-iap is loaded for you otherwise.
19
18
  */
20
19
  iap?: unknown;
21
20
  }
@@ -31,30 +30,55 @@ declare function isEntitled(entitlementId: string): Promise<boolean>;
31
30
  declare function getOfferings(): Promise<Offerings>;
32
31
  /** Live store products (prices, localized titles) from the native store (expo-iap `fetchProducts`). */
33
32
  declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "all"): Promise<any[]>;
33
+ /** Why a purchase didn't complete. Check `code`, never match on the message. */
34
+ type PurchaseErrorCode = "purchase-cancelled" | "purchase-pending" | "purchase-failed";
35
+ /**
36
+ * A purchase that didn't return entitlements.
37
+ *
38
+ * `code === "purchase-pending"` is NOT a failure: the store took the money but confirmation hasn't
39
+ * reached us yet (store notifications are asynchronous). Show "still confirming", not an error,
40
+ * the entitlement will arrive on its own, and `addCustomerInfoUpdateListener` will fire when it does.
41
+ */
42
+ declare class PurchaseError extends Error {
43
+ readonly code: PurchaseErrorCode;
44
+ /** True when the purchase probably succeeded and is just unconfirmed. */
45
+ readonly pending: boolean;
46
+ /** Last known entitlements, if we managed to read them. */
47
+ readonly customerInfo?: CustomerInfo;
48
+ constructor(code: PurchaseErrorCode, message: string, customerInfo?: CustomerInfo);
49
+ }
34
50
  /**
35
51
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
52
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
37
53
  *
38
54
  * Store bridges disagree about how the result comes back, so we accept it from any source and never
39
55
  * 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
56
+ * 1. `purchaseUpdatedListener`: expo-iap's event-based flow.
57
+ * 2. `requestPurchase`'s resolved value: react-native-iap 15+ / Nitro resolves with the purchase
42
58
  * and may never emit the event.
43
- * 3. Reconciliation after `timeoutMs` re-read `getAvailablePurchases()`, and failing that check
59
+ * 3. Reconciliation after `timeoutMs`: re-read `getAvailablePurchases()`, and failing that check
44
60
  * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
61
  * grants it independently of the client).
46
62
  *
47
- * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
- * and nothing was granted.
63
+ * Rejects with a `PurchaseError`; branch on `.code`, never the message:
64
+ * - `purchase-cancelled`: the user backed out.
65
+ * - `purchase-pending` : the store took the money but hasn't confirmed. NOT a failure; show a
66
+ * pending state. `.pending` is true and the entitlement will land on its own.
67
+ * - `purchase-failed` : a real failure.
49
68
  */
50
69
  declare function purchaseProduct(productId: string, opts?: {
51
70
  isSubscription?: boolean;
52
71
  isConsumable?: boolean;
72
+ /** Wait for the store's result once the request is in flight (default 120s). */
53
73
  timeoutMs?: number;
74
+ /** Absolute backstop from the call, covering time spent in the store's own sheet (default 10m). */
75
+ sheetTimeoutMs?: number;
76
+ /** How long to poll for confirmation before reporting pending (default 30s). */
77
+ reconcileMs?: number;
54
78
  }): Promise<CustomerInfo>;
55
79
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
56
80
  declare function restorePurchases(): Promise<CustomerInfo>;
57
81
  /** Subscribe to entitlement changes (e.g. after a purchase). Returns an unsubscribe function. */
58
82
  declare function addCustomerInfoUpdateListener(fn: (info: CustomerInfo) => void): () => void;
59
83
 
60
- export { type ConfigureOptions, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
84
+ export { type ConfigureOptions, PurchaseError, type PurchaseErrorCode, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
package/dist/index.js CHANGED
@@ -1,23 +1,25 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
7
-
8
1
  // src/index.ts
9
2
  import { EntitleHub } from "@entitlehub/sdk";
10
3
  var _iap;
11
- function iap() {
4
+ async function loadIap() {
12
5
  if (_iap) return _iap;
6
+ let mod;
13
7
  try {
14
- _iap = __require("expo-iap");
8
+ mod = await import("expo-iap");
15
9
  } catch {
16
10
  throw new Error("@entitlehub/react-native needs 'expo-iap'. Install it with: npx expo install expo-iap");
17
11
  }
12
+ _iap = typeof mod?.initConnection === "function" ? mod : mod?.default ?? mod;
13
+ if (typeof _iap?.initConnection !== "function") {
14
+ throw new Error("@entitlehub/react-native: the 'expo-iap' module did not load correctly (no initConnection). Check that expo-iap is installed and its version supports the OpenIAP API.");
15
+ }
16
+ return _iap;
17
+ }
18
+ function iap() {
19
+ if (!_iap) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub purchases.");
18
20
  return _iap;
19
21
  }
20
- var RN_SDK_VERSION = "0.1.6";
22
+ var RN_SDK_VERSION = "0.1.8";
21
23
  var client;
22
24
  function eh() {
23
25
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -25,6 +27,7 @@ function eh() {
25
27
  }
26
28
  async function configureEntitleHub(opts) {
27
29
  if (opts.iap) _iap = opts.iap;
30
+ else await loadIap();
28
31
  client = new EntitleHub({
29
32
  apiKey: opts.apiKey,
30
33
  appUserId: opts.appUserId,
@@ -52,11 +55,25 @@ async function getProducts(productIds, type = "all") {
52
55
  return iap().fetchProducts({ skus: productIds, type });
53
56
  }
54
57
  var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
58
+ var DEFAULT_SHEET_TIMEOUT_MS = 6e5;
59
+ var DEFAULT_RECONCILE_MS = 3e4;
60
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
61
+ var PurchaseError = class extends Error {
62
+ constructor(code, message, customerInfo) {
63
+ super(message);
64
+ this.name = "PurchaseError";
65
+ this.code = code;
66
+ this.pending = code === "purchase-pending";
67
+ this.customerInfo = customerInfo;
68
+ }
69
+ };
55
70
  async function purchaseProduct(productId, opts = {}) {
56
71
  const I = iap();
57
72
  const isSub = Boolean(opts.isSubscription);
58
73
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
59
74
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
75
+ const sheetTimeoutMs = opts.sheetTimeoutMs ?? DEFAULT_SHEET_TIMEOUT_MS;
76
+ const reconcileMs = opts.reconcileMs ?? DEFAULT_RECONCILE_MS;
60
77
  let entitlementsBefore = [];
61
78
  try {
62
79
  entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
@@ -111,26 +128,43 @@ async function purchaseProduct(productId, opts = {}) {
111
128
  };
112
129
  const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
113
130
  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;
131
+ const deadline = Date.now() + reconcileMs;
132
+ let wait = 2e3;
133
+ let lastInfo;
134
+ while (!settled) {
135
+ try {
136
+ const owned = await I.getAvailablePurchases() || [];
137
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
138
+ if (match) return settleWithPurchase(match);
139
+ } catch {
130
140
  }
131
- } catch {
141
+ try {
142
+ lastInfo = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
143
+ if (lastInfo.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id))) {
144
+ if (settled) return;
145
+ settled = true;
146
+ cleanup();
147
+ resolve(lastInfo);
148
+ return;
149
+ }
150
+ } catch {
151
+ }
152
+ const remaining = deadline - Date.now();
153
+ if (remaining <= 0) break;
154
+ await sleep(Math.min(wait, remaining));
155
+ wait = Math.min(wait * 1.5, 8e3);
132
156
  }
133
- fail(new Error("purchase-timeout"));
157
+ fail(new PurchaseError(
158
+ "purchase-pending",
159
+ "The store hasn't confirmed this purchase yet. It may still complete, show a pending state, not a failure.",
160
+ lastInfo
161
+ ));
162
+ };
163
+ const armTimer = (ms) => {
164
+ if (timer) clearTimeout(timer);
165
+ timer = setTimeout(() => {
166
+ void reconcile();
167
+ }, ms);
134
168
  };
135
169
  const updSub = I.purchaseUpdatedListener((purchase) => {
136
170
  void settleWithPurchase(purchase);
@@ -138,17 +172,21 @@ async function purchaseProduct(productId, opts = {}) {
138
172
  const errSub = I.purchaseErrorListener((error) => {
139
173
  const code = error?.code;
140
174
  const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
141
- fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
175
+ fail(cancelled ? new PurchaseError("purchase-cancelled", "The user cancelled the purchase.") : new PurchaseError("purchase-failed", error?.message || "The purchase failed."));
142
176
  });
143
177
  Promise.resolve(
144
178
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
145
179
  ).then((res) => {
146
180
  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);
181
+ if (isPurchase(p)) {
182
+ void settleWithPurchase(p);
183
+ return;
184
+ }
185
+ armTimer(timeoutMs);
186
+ }).catch((e) => fail(
187
+ e instanceof PurchaseError ? e : new PurchaseError("purchase-failed", e?.message || "The purchase failed.")
188
+ ));
189
+ armTimer(sheetTimeoutMs);
152
190
  });
153
191
  }
154
192
  async function restorePurchases() {
@@ -178,6 +216,7 @@ async function reportToEntitleHub(purchase, productId, isSubscription) {
178
216
  throw new Error("Could not read a receipt from the purchase (no iOS JWS or Android purchase token).");
179
217
  }
180
218
  export {
219
+ PurchaseError,
181
220
  addCustomerInfoUpdateListener,
182
221
  configureEntitleHub,
183
222
  getCustomerInfo,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@entitlehub/react-native",
3
- "version": "0.1.6",
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.",
3
+ "version": "0.1.8",
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",