@entitlehub/react-native 0.1.6 → 0.1.7

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7 — 2026-07-22
4
+
5
+ ### Fixed
6
+ - **A completed purchase could still be reported as failed.** After a timeout, `purchaseProduct()`
7
+ checked for confirmation exactly once. Store notifications are asynchronous, so that single
8
+ reading races the grant and sometimes loses — observed live: the entitlement was written seconds
9
+ after the check ran, and the user saw a purchase-failed error for a purchase that worked. It now
10
+ polls the store and the server with backoff (`reconcileMs`, default 30s) before giving up.
11
+ - **The timeout counted the user's own time.** The timer was armed when `purchaseProduct()` was
12
+ called — before the store's password / Face ID sheet appears — so a user who paused mid-purchase
13
+ tripped it. It now starts once the request is actually in flight, with a separate generous
14
+ backstop (`sheetTimeoutMs`, default 10m) so a stuck bridge still can't hang forever.
15
+
16
+ ### Changed
17
+ - **Rejections are now typed `PurchaseError`s — branch on `.code`, not the message.**
18
+ `purchase-cancelled`, `purchase-failed`, and the new `purchase-pending`. **`purchase-pending` is
19
+ not a failure**: the store took the money but hasn't confirmed yet. Show "still confirming" — the
20
+ entitlement lands on its own and `addCustomerInfoUpdateListener` fires when it does. `.pending`
21
+ and `.customerInfo` are on the error. The old untyped `"purchase-timeout"` message is gone.
22
+
3
23
  ## 0.1.6 — 2026-07-21
4
24
 
5
25
  ### Added
package/dist/index.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ PurchaseError: () => PurchaseError,
23
24
  addCustomerInfoUpdateListener: () => addCustomerInfoUpdateListener,
24
25
  configureEntitleHub: () => configureEntitleHub,
25
26
  getCustomerInfo: () => getCustomerInfo,
@@ -42,7 +43,7 @@ function iap() {
42
43
  }
43
44
  return _iap;
44
45
  }
45
- var RN_SDK_VERSION = "0.1.6";
46
+ var RN_SDK_VERSION = "0.1.7";
46
47
  var client;
47
48
  function eh() {
48
49
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -77,11 +78,25 @@ async function getProducts(productIds, type = "all") {
77
78
  return iap().fetchProducts({ skus: productIds, type });
78
79
  }
79
80
  var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
81
+ var DEFAULT_SHEET_TIMEOUT_MS = 6e5;
82
+ var DEFAULT_RECONCILE_MS = 3e4;
83
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+ var PurchaseError = class extends Error {
85
+ constructor(code, message, customerInfo) {
86
+ super(message);
87
+ this.name = "PurchaseError";
88
+ this.code = code;
89
+ this.pending = code === "purchase-pending";
90
+ this.customerInfo = customerInfo;
91
+ }
92
+ };
80
93
  async function purchaseProduct(productId, opts = {}) {
81
94
  const I = iap();
82
95
  const isSub = Boolean(opts.isSubscription);
83
96
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
84
97
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
98
+ const sheetTimeoutMs = opts.sheetTimeoutMs ?? DEFAULT_SHEET_TIMEOUT_MS;
99
+ const reconcileMs = opts.reconcileMs ?? DEFAULT_RECONCILE_MS;
85
100
  let entitlementsBefore = [];
86
101
  try {
87
102
  entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
@@ -136,26 +151,43 @@ async function purchaseProduct(productId, opts = {}) {
136
151
  };
137
152
  const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
138
153
  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;
154
+ const deadline = Date.now() + reconcileMs;
155
+ let wait = 2e3;
156
+ let lastInfo;
157
+ while (!settled) {
158
+ try {
159
+ const owned = await I.getAvailablePurchases() || [];
160
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
161
+ if (match) return settleWithPurchase(match);
162
+ } catch {
155
163
  }
156
- } catch {
164
+ try {
165
+ lastInfo = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
166
+ if (lastInfo.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id))) {
167
+ if (settled) return;
168
+ settled = true;
169
+ cleanup();
170
+ resolve(lastInfo);
171
+ return;
172
+ }
173
+ } catch {
174
+ }
175
+ const remaining = deadline - Date.now();
176
+ if (remaining <= 0) break;
177
+ await sleep(Math.min(wait, remaining));
178
+ wait = Math.min(wait * 1.5, 8e3);
157
179
  }
158
- fail(new Error("purchase-timeout"));
180
+ fail(new PurchaseError(
181
+ "purchase-pending",
182
+ "The store hasn't confirmed this purchase yet. It may still complete \u2014 show a pending state, not a failure.",
183
+ lastInfo
184
+ ));
185
+ };
186
+ const armTimer = (ms) => {
187
+ if (timer) clearTimeout(timer);
188
+ timer = setTimeout(() => {
189
+ void reconcile();
190
+ }, ms);
159
191
  };
160
192
  const updSub = I.purchaseUpdatedListener((purchase) => {
161
193
  void settleWithPurchase(purchase);
@@ -163,17 +195,21 @@ async function purchaseProduct(productId, opts = {}) {
163
195
  const errSub = I.purchaseErrorListener((error) => {
164
196
  const code = error?.code;
165
197
  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"));
198
+ fail(cancelled ? new PurchaseError("purchase-cancelled", "The user cancelled the purchase.") : new PurchaseError("purchase-failed", error?.message || "The purchase failed."));
167
199
  });
168
200
  Promise.resolve(
169
201
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
170
202
  ).then((res) => {
171
203
  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);
204
+ if (isPurchase(p)) {
205
+ void settleWithPurchase(p);
206
+ return;
207
+ }
208
+ armTimer(timeoutMs);
209
+ }).catch((e) => fail(
210
+ e instanceof PurchaseError ? e : new PurchaseError("purchase-failed", e?.message || "The purchase failed.")
211
+ ));
212
+ armTimer(sheetTimeoutMs);
177
213
  });
178
214
  }
179
215
  async function restorePurchases() {
@@ -204,6 +240,7 @@ async function reportToEntitleHub(purchase, productId, isSubscription) {
204
240
  }
205
241
  // Annotate the CommonJS export names for ESM import in node:
206
242
  0 && (module.exports = {
243
+ PurchaseError,
207
244
  addCustomerInfoUpdateListener,
208
245
  configureEntitleHub,
209
246
  getCustomerInfo,
package/dist/index.d.cts CHANGED
@@ -31,6 +31,23 @@ declare function isEntitled(entitlementId: string): Promise<boolean>;
31
31
  declare function getOfferings(): Promise<Offerings>;
32
32
  /** Live store products (prices, localized titles) from the native store (expo-iap `fetchProducts`). */
33
33
  declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "all"): Promise<any[]>;
34
+ /** Why a purchase didn't complete. Check `code` — never match on the message. */
35
+ type PurchaseErrorCode = "purchase-cancelled" | "purchase-pending" | "purchase-failed";
36
+ /**
37
+ * A purchase that didn't return entitlements.
38
+ *
39
+ * `code === "purchase-pending"` is NOT a failure: the store took the money but confirmation hasn't
40
+ * reached us yet (store notifications are asynchronous). Show "still confirming", not an error —
41
+ * the entitlement will arrive on its own, and `addCustomerInfoUpdateListener` will fire when it does.
42
+ */
43
+ declare class PurchaseError extends Error {
44
+ readonly code: PurchaseErrorCode;
45
+ /** True when the purchase probably succeeded and is just unconfirmed. */
46
+ readonly pending: boolean;
47
+ /** Last known entitlements, if we managed to read them. */
48
+ readonly customerInfo?: CustomerInfo;
49
+ constructor(code: PurchaseErrorCode, message: string, customerInfo?: CustomerInfo);
50
+ }
34
51
  /**
35
52
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
53
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
@@ -44,17 +61,25 @@ declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "a
44
61
  * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
62
  * grants it independently of the client).
46
63
  *
47
- * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
- * and nothing was granted.
64
+ * Rejects with a `PurchaseError`; branch on `.code`, never the message:
65
+ * - `purchase-cancelled` the user backed out.
66
+ * - `purchase-pending` — the store took the money but hasn't confirmed. NOT a failure; show a
67
+ * pending state. `.pending` is true and the entitlement will land on its own.
68
+ * - `purchase-failed` — a real failure.
49
69
  */
50
70
  declare function purchaseProduct(productId: string, opts?: {
51
71
  isSubscription?: boolean;
52
72
  isConsumable?: boolean;
73
+ /** Wait for the store's result once the request is in flight (default 120s). */
53
74
  timeoutMs?: number;
75
+ /** Absolute backstop from the call, covering time spent in the store's own sheet (default 10m). */
76
+ sheetTimeoutMs?: number;
77
+ /** How long to poll for confirmation before reporting pending (default 30s). */
78
+ reconcileMs?: number;
54
79
  }): Promise<CustomerInfo>;
55
80
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
56
81
  declare function restorePurchases(): Promise<CustomerInfo>;
57
82
  /** Subscribe to entitlement changes (e.g. after a purchase). Returns an unsubscribe function. */
58
83
  declare function addCustomerInfoUpdateListener(fn: (info: CustomerInfo) => void): () => void;
59
84
 
60
- export { type ConfigureOptions, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
85
+ export { type ConfigureOptions, PurchaseError, type PurchaseErrorCode, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
package/dist/index.d.ts CHANGED
@@ -31,6 +31,23 @@ declare function isEntitled(entitlementId: string): Promise<boolean>;
31
31
  declare function getOfferings(): Promise<Offerings>;
32
32
  /** Live store products (prices, localized titles) from the native store (expo-iap `fetchProducts`). */
33
33
  declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "all"): Promise<any[]>;
34
+ /** Why a purchase didn't complete. Check `code` — never match on the message. */
35
+ type PurchaseErrorCode = "purchase-cancelled" | "purchase-pending" | "purchase-failed";
36
+ /**
37
+ * A purchase that didn't return entitlements.
38
+ *
39
+ * `code === "purchase-pending"` is NOT a failure: the store took the money but confirmation hasn't
40
+ * reached us yet (store notifications are asynchronous). Show "still confirming", not an error —
41
+ * the entitlement will arrive on its own, and `addCustomerInfoUpdateListener` will fire when it does.
42
+ */
43
+ declare class PurchaseError extends Error {
44
+ readonly code: PurchaseErrorCode;
45
+ /** True when the purchase probably succeeded and is just unconfirmed. */
46
+ readonly pending: boolean;
47
+ /** Last known entitlements, if we managed to read them. */
48
+ readonly customerInfo?: CustomerInfo;
49
+ constructor(code: PurchaseErrorCode, message: string, customerInfo?: CustomerInfo);
50
+ }
34
51
  /**
35
52
  * Buy a product in one call: open the native purchase sheet, validate the receipt with EntitleHub,
36
53
  * and return the updated entitlements. Purchase + entitlement sync, the RevenueCat way.
@@ -44,17 +61,25 @@ declare function getProducts(productIds: string[], type?: "in-app" | "subs" | "a
44
61
  * whether the entitlement appeared server-side anyway (EntitleHub's store-notification webhook
45
62
  * grants it independently of the client).
46
63
  *
47
- * Rejects with "purchase-cancelled" if the user backs out, "purchase-timeout" if nothing arrived
48
- * and nothing was granted.
64
+ * Rejects with a `PurchaseError`; branch on `.code`, never the message:
65
+ * - `purchase-cancelled` the user backed out.
66
+ * - `purchase-pending` — the store took the money but hasn't confirmed. NOT a failure; show a
67
+ * pending state. `.pending` is true and the entitlement will land on its own.
68
+ * - `purchase-failed` — a real failure.
49
69
  */
50
70
  declare function purchaseProduct(productId: string, opts?: {
51
71
  isSubscription?: boolean;
52
72
  isConsumable?: boolean;
73
+ /** Wait for the store's result once the request is in flight (default 120s). */
53
74
  timeoutMs?: number;
75
+ /** Absolute backstop from the call, covering time spent in the store's own sheet (default 10m). */
76
+ sheetTimeoutMs?: number;
77
+ /** How long to poll for confirmation before reporting pending (default 30s). */
78
+ reconcileMs?: number;
54
79
  }): Promise<CustomerInfo>;
55
80
  /** Restore the user's purchases: re-validate each with EntitleHub, then return entitlements. */
56
81
  declare function restorePurchases(): Promise<CustomerInfo>;
57
82
  /** Subscribe to entitlement changes (e.g. after a purchase). Returns an unsubscribe function. */
58
83
  declare function addCustomerInfoUpdateListener(fn: (info: CustomerInfo) => void): () => void;
59
84
 
60
- export { type ConfigureOptions, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
85
+ export { type ConfigureOptions, PurchaseError, type PurchaseErrorCode, addCustomerInfoUpdateListener, configureEntitleHub, getCustomerInfo, getOfferings, getProducts, isEntitled, logIn, purchaseProduct, restorePurchases };
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ function iap() {
17
17
  }
18
18
  return _iap;
19
19
  }
20
- var RN_SDK_VERSION = "0.1.6";
20
+ var RN_SDK_VERSION = "0.1.7";
21
21
  var client;
22
22
  function eh() {
23
23
  if (!client) throw new Error("Call configureEntitleHub({ apiKey, appUserId }) before using EntitleHub.");
@@ -52,11 +52,25 @@ async function getProducts(productIds, type = "all") {
52
52
  return iap().fetchProducts({ skus: productIds, type });
53
53
  }
54
54
  var DEFAULT_PURCHASE_TIMEOUT_MS = 12e4;
55
+ var DEFAULT_SHEET_TIMEOUT_MS = 6e5;
56
+ var DEFAULT_RECONCILE_MS = 3e4;
57
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+ var PurchaseError = class extends Error {
59
+ constructor(code, message, customerInfo) {
60
+ super(message);
61
+ this.name = "PurchaseError";
62
+ this.code = code;
63
+ this.pending = code === "purchase-pending";
64
+ this.customerInfo = customerInfo;
65
+ }
66
+ };
55
67
  async function purchaseProduct(productId, opts = {}) {
56
68
  const I = iap();
57
69
  const isSub = Boolean(opts.isSubscription);
58
70
  const isConsumable = Boolean(opts.isConsumable) && !isSub;
59
71
  const timeoutMs = opts.timeoutMs ?? DEFAULT_PURCHASE_TIMEOUT_MS;
72
+ const sheetTimeoutMs = opts.sheetTimeoutMs ?? DEFAULT_SHEET_TIMEOUT_MS;
73
+ const reconcileMs = opts.reconcileMs ?? DEFAULT_RECONCILE_MS;
60
74
  let entitlementsBefore = [];
61
75
  try {
62
76
  entitlementsBefore = (await eh().getCustomerInfo()).activeEntitlementIds;
@@ -111,26 +125,43 @@ async function purchaseProduct(productId, opts = {}) {
111
125
  };
112
126
  const isPurchase = (p) => Boolean(p) && Boolean(p.purchaseToken || p.jwsRepresentation || p.jwsRepresentationIos || p.purchaseTokenAndroid || p.transactionId || p.transactionReceipt || p.id);
113
127
  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;
128
+ const deadline = Date.now() + reconcileMs;
129
+ let wait = 2e3;
130
+ let lastInfo;
131
+ while (!settled) {
132
+ try {
133
+ const owned = await I.getAvailablePurchases() || [];
134
+ const match = owned.find((p) => (p?.productId ?? p?.id ?? p?.sku) === productId);
135
+ if (match) return settleWithPurchase(match);
136
+ } catch {
130
137
  }
131
- } catch {
138
+ try {
139
+ lastInfo = await eh().getCustomerInfo({ fetchPolicy: "network-only" });
140
+ if (lastInfo.activeEntitlementIds.some((id) => !entitlementsBefore.includes(id))) {
141
+ if (settled) return;
142
+ settled = true;
143
+ cleanup();
144
+ resolve(lastInfo);
145
+ return;
146
+ }
147
+ } catch {
148
+ }
149
+ const remaining = deadline - Date.now();
150
+ if (remaining <= 0) break;
151
+ await sleep(Math.min(wait, remaining));
152
+ wait = Math.min(wait * 1.5, 8e3);
132
153
  }
133
- fail(new Error("purchase-timeout"));
154
+ fail(new PurchaseError(
155
+ "purchase-pending",
156
+ "The store hasn't confirmed this purchase yet. It may still complete \u2014 show a pending state, not a failure.",
157
+ lastInfo
158
+ ));
159
+ };
160
+ const armTimer = (ms) => {
161
+ if (timer) clearTimeout(timer);
162
+ timer = setTimeout(() => {
163
+ void reconcile();
164
+ }, ms);
134
165
  };
135
166
  const updSub = I.purchaseUpdatedListener((purchase) => {
136
167
  void settleWithPurchase(purchase);
@@ -138,17 +169,21 @@ async function purchaseProduct(productId, opts = {}) {
138
169
  const errSub = I.purchaseErrorListener((error) => {
139
170
  const code = error?.code;
140
171
  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"));
172
+ fail(cancelled ? new PurchaseError("purchase-cancelled", "The user cancelled the purchase.") : new PurchaseError("purchase-failed", error?.message || "The purchase failed."));
142
173
  });
143
174
  Promise.resolve(
144
175
  I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
145
176
  ).then((res) => {
146
177
  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);
178
+ if (isPurchase(p)) {
179
+ void settleWithPurchase(p);
180
+ return;
181
+ }
182
+ armTimer(timeoutMs);
183
+ }).catch((e) => fail(
184
+ e instanceof PurchaseError ? e : new PurchaseError("purchase-failed", e?.message || "The purchase failed.")
185
+ ));
186
+ armTimer(sheetTimeoutMs);
152
187
  });
153
188
  }
154
189
  async function restorePurchases() {
@@ -178,6 +213,7 @@ async function reportToEntitleHub(purchase, productId, isSubscription) {
178
213
  throw new Error("Could not read a receipt from the purchase (no iOS JWS or Android purchase token).");
179
214
  }
180
215
  export {
216
+ PurchaseError,
181
217
  addCustomerInfoUpdateListener,
182
218
  configureEntitleHub,
183
219
  getCustomerInfo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entitlehub/react-native",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",