@entitlehub/react-native 0.1.4 → 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 +17 -0
- package/dist/index.cjs +50 -11
- package/dist/index.d.cts +12 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.js +50 -11
- package/package.json +1 -1
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,10 +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);
|
|
76
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
|
+
}
|
|
77
84
|
const request = { apple: { sku: productId }, google: { skus: [productId] } };
|
|
78
85
|
if (isSub) {
|
|
79
86
|
try {
|
|
@@ -88,6 +95,7 @@ async function purchaseProduct(productId, opts = {}) {
|
|
|
88
95
|
}
|
|
89
96
|
return new Promise((resolve, reject) => {
|
|
90
97
|
let settled = false;
|
|
98
|
+
let timer;
|
|
91
99
|
const cleanup = () => {
|
|
92
100
|
try {
|
|
93
101
|
updSub?.remove();
|
|
@@ -97,8 +105,15 @@ async function purchaseProduct(productId, opts = {}) {
|
|
|
97
105
|
errSub?.remove();
|
|
98
106
|
} catch {
|
|
99
107
|
}
|
|
108
|
+
if (timer) clearTimeout(timer);
|
|
100
109
|
};
|
|
101
|
-
const
|
|
110
|
+
const fail = (e) => {
|
|
111
|
+
if (settled) return;
|
|
112
|
+
settled = true;
|
|
113
|
+
cleanup();
|
|
114
|
+
reject(e);
|
|
115
|
+
};
|
|
116
|
+
const settleWithPurchase = async (purchase) => {
|
|
102
117
|
if (settled) return;
|
|
103
118
|
settled = true;
|
|
104
119
|
cleanup();
|
|
@@ -112,23 +127,47 @@ async function purchaseProduct(productId, opts = {}) {
|
|
|
112
127
|
} catch (e) {
|
|
113
128
|
reject(e);
|
|
114
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);
|
|
115
156
|
});
|
|
116
157
|
const errSub = I.purchaseErrorListener((error) => {
|
|
117
|
-
if (settled) return;
|
|
118
|
-
settled = true;
|
|
119
|
-
cleanup();
|
|
120
158
|
const code = error?.code;
|
|
121
159
|
const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
|
|
122
|
-
|
|
160
|
+
fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
|
|
123
161
|
});
|
|
124
162
|
Promise.resolve(
|
|
125
163
|
I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
|
|
126
|
-
).
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
|
132
171
|
});
|
|
133
172
|
}
|
|
134
173
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
@@ -45,10 +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);
|
|
51
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
|
+
}
|
|
52
59
|
const request = { apple: { sku: productId }, google: { skus: [productId] } };
|
|
53
60
|
if (isSub) {
|
|
54
61
|
try {
|
|
@@ -63,6 +70,7 @@ async function purchaseProduct(productId, opts = {}) {
|
|
|
63
70
|
}
|
|
64
71
|
return new Promise((resolve, reject) => {
|
|
65
72
|
let settled = false;
|
|
73
|
+
let timer;
|
|
66
74
|
const cleanup = () => {
|
|
67
75
|
try {
|
|
68
76
|
updSub?.remove();
|
|
@@ -72,8 +80,15 @@ async function purchaseProduct(productId, opts = {}) {
|
|
|
72
80
|
errSub?.remove();
|
|
73
81
|
} catch {
|
|
74
82
|
}
|
|
83
|
+
if (timer) clearTimeout(timer);
|
|
75
84
|
};
|
|
76
|
-
const
|
|
85
|
+
const fail = (e) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
cleanup();
|
|
89
|
+
reject(e);
|
|
90
|
+
};
|
|
91
|
+
const settleWithPurchase = async (purchase) => {
|
|
77
92
|
if (settled) return;
|
|
78
93
|
settled = true;
|
|
79
94
|
cleanup();
|
|
@@ -87,23 +102,47 @@ async function purchaseProduct(productId, opts = {}) {
|
|
|
87
102
|
} catch (e) {
|
|
88
103
|
reject(e);
|
|
89
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);
|
|
90
131
|
});
|
|
91
132
|
const errSub = I.purchaseErrorListener((error) => {
|
|
92
|
-
if (settled) return;
|
|
93
|
-
settled = true;
|
|
94
|
-
cleanup();
|
|
95
133
|
const code = error?.code;
|
|
96
134
|
const cancelled = code === "E_USER_CANCELLED" || code === "user-cancelled" || /cancel/i.test(String(error?.message));
|
|
97
|
-
|
|
135
|
+
fail(new Error(cancelled ? "purchase-cancelled" : error?.message || "purchase-failed"));
|
|
98
136
|
});
|
|
99
137
|
Promise.resolve(
|
|
100
138
|
I.requestPurchase({ request, type: isSub ? "subs" : "in-app" })
|
|
101
|
-
).
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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);
|
|
107
146
|
});
|
|
108
147
|
}
|
|
109
148
|
async function restorePurchases() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entitlehub/react-native",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|