@nosslabs/iap 8.0.0 → 8.2.0
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 +533 -1
- package/README.md +34 -6
- package/dist/index.cjs +2084 -507
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +910 -217
- package/dist/index.d.ts +910 -217
- package/dist/index.js +2083 -508
- package/dist/index.js.map +1 -1
- package/package.json +8 -4
package/dist/index.cjs
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
var core = require('@capacitor/core');
|
|
4
4
|
var nativePurchases = require('@capgo/native-purchases');
|
|
5
5
|
var zod = require('zod');
|
|
6
|
-
var preferences = require('@capacitor/preferences');
|
|
7
6
|
|
|
8
7
|
var __defProp = Object.defineProperty;
|
|
9
8
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -62,8 +61,9 @@ var init_errors = __esm({
|
|
|
62
61
|
// appUserId pre-attach
|
|
63
62
|
/**
|
|
64
63
|
* The supplied `appUserId` (literal or fetcher-returned) is not a valid
|
|
65
|
-
* UUID
|
|
66
|
-
* same constraint cross-platform for a consistent contract.
|
|
64
|
+
* RFC 9562 UUID. Apple requires a UUID for `appAccountToken`; we enforce
|
|
65
|
+
* the same constraint cross-platform for a consistent contract. Any
|
|
66
|
+
* version is accepted — see `isValidUuid` for what is still refused.
|
|
67
67
|
*/
|
|
68
68
|
INVALID_APP_USER_ID: "INVALID_APP_USER_ID",
|
|
69
69
|
/**
|
|
@@ -103,7 +103,7 @@ var init_errors = __esm({
|
|
|
103
103
|
// Storage
|
|
104
104
|
STORAGE_ERROR: "Capacitor Preferences write failed. Check device storage availability; the in-memory state is still updated, only persistence failed.",
|
|
105
105
|
// appUserId pre-attach
|
|
106
|
-
INVALID_APP_USER_ID: "appUserId must be
|
|
106
|
+
INVALID_APP_USER_ID: "appUserId must be an RFC 9562 UUID of any version (e.g. crypto.randomUUID(), or a UUIDv5 over a stable name). Apple requires a UUID for appAccountToken; we enforce the same on Android for consistency. Refused: the nil and max UUIDs, and non-RFC version/variant nibbles \u2014 so an id that merely looks like a UUID (a hyphen-formatted hash) will not pass.",
|
|
107
107
|
APP_USER_ID_FETCH_FAILED: "The async appUserId fetcher threw or rejected. Inspect the cause field for the underlying error (network failure, backend non-2xx, parse failure)."
|
|
108
108
|
};
|
|
109
109
|
exports.IAPError = class _IAPError extends Error {
|
|
@@ -139,6 +139,280 @@ var init_platform = __esm({
|
|
|
139
139
|
}
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
// src/adapters/native/capgo/map-purchase-error.ts
|
|
143
|
+
function mapPurchaseError(error, productId) {
|
|
144
|
+
if (error instanceof exports.IAPError) return error;
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
146
|
+
const lower = message.toLowerCase();
|
|
147
|
+
if (lower.includes("cancel")) {
|
|
148
|
+
return new exports.IAPError({
|
|
149
|
+
code: exports.IAPErrorCode.USER_CANCELLED,
|
|
150
|
+
message: `Purchase of "${productId}" was cancelled.`,
|
|
151
|
+
cause: error
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
if (lower.includes("pending")) {
|
|
155
|
+
return new exports.IAPError({
|
|
156
|
+
code: exports.IAPErrorCode.PURCHASE_PENDING,
|
|
157
|
+
message: `Purchase of "${productId}" is pending external clearance.`,
|
|
158
|
+
cause: error
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (lower.includes("product not found") || lower.includes("cannot find product")) {
|
|
162
|
+
return new exports.IAPError({
|
|
163
|
+
code: exports.IAPErrorCode.PRODUCT_NOT_FOUND,
|
|
164
|
+
message: `Product "${productId}" was not found in the store catalog.`,
|
|
165
|
+
cause: error
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return new exports.IAPError({
|
|
169
|
+
code: exports.IAPErrorCode.STORE_ERROR,
|
|
170
|
+
message: `Native purchase of "${productId}" failed.`,
|
|
171
|
+
cause: error
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
var init_map_purchase_error = __esm({
|
|
175
|
+
"src/adapters/native/capgo/map-purchase-error.ts"() {
|
|
176
|
+
init_errors();
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// src/lib/subscription-period.ts
|
|
181
|
+
function toIso8601Duration(period) {
|
|
182
|
+
if (!period || typeof period !== "object" || Array.isArray(period)) return null;
|
|
183
|
+
const { numberOfUnits, unit, unitString } = period;
|
|
184
|
+
if (typeof numberOfUnits !== "number" || !Number.isInteger(numberOfUnits) || numberOfUnits <= 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
const designator = (typeof unitString === "string" ? UNIT_BY_NAME.get(unitString) : void 0) ?? (typeof unit === "number" ? UNIT_BY_INT.get(unit) : void 0);
|
|
188
|
+
if (!designator) return null;
|
|
189
|
+
return `P${numberOfUnits}${designator}`;
|
|
190
|
+
}
|
|
191
|
+
var UNIT_BY_INT, UNIT_BY_NAME;
|
|
192
|
+
var init_subscription_period = __esm({
|
|
193
|
+
"src/lib/subscription-period.ts"() {
|
|
194
|
+
UNIT_BY_INT = /* @__PURE__ */ new Map([
|
|
195
|
+
[0, "D"],
|
|
196
|
+
[1, "W"],
|
|
197
|
+
[2, "M"],
|
|
198
|
+
[3, "Y"]
|
|
199
|
+
]);
|
|
200
|
+
UNIT_BY_NAME = /* @__PURE__ */ new Map([
|
|
201
|
+
["day", "D"],
|
|
202
|
+
["week", "W"],
|
|
203
|
+
["month", "M"],
|
|
204
|
+
["year", "Y"]
|
|
205
|
+
]);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// src/adapters/native/capgo/normalize-product.ts
|
|
210
|
+
function resolveProducts(payloads, requests) {
|
|
211
|
+
const products = [];
|
|
212
|
+
for (const request of requests) {
|
|
213
|
+
const candidates = payloads.filter((payload) => {
|
|
214
|
+
const p = payload;
|
|
215
|
+
return p.identifier === request.id || p.planIdentifier === request.id;
|
|
216
|
+
});
|
|
217
|
+
if (candidates.length === 0) continue;
|
|
218
|
+
products.push(normalizeProduct(pickCandidate(candidates, request.androidPlanId), request));
|
|
219
|
+
}
|
|
220
|
+
return products;
|
|
221
|
+
}
|
|
222
|
+
function pickCandidate(candidates, androidPlanId) {
|
|
223
|
+
const named = androidPlanId !== void 0 ? candidates.find((c) => c.identifier === androidPlanId) : void 0;
|
|
224
|
+
const basePlan = candidates.find((c) => (c.offerId ?? null) === null);
|
|
225
|
+
return named ?? basePlan ?? candidates[0];
|
|
226
|
+
}
|
|
227
|
+
function normalizeProduct(raw, request) {
|
|
228
|
+
const p = raw;
|
|
229
|
+
return {
|
|
230
|
+
// The id the consumer configured, so a Product round-trips to
|
|
231
|
+
// `purchase({ productId })`. On Android `p.identifier` is a base plan,
|
|
232
|
+
// which is reported separately as `androidBasePlanId`.
|
|
233
|
+
id: request.id,
|
|
234
|
+
type: request.type,
|
|
235
|
+
title: p.title,
|
|
236
|
+
description: p.description,
|
|
237
|
+
priceString: p.priceString,
|
|
238
|
+
priceMicros: toMicros(p.price),
|
|
239
|
+
currency: p.currencyCode,
|
|
240
|
+
// `toIso8601Duration` returns null for the `{ numberOfUnits: 0, unit: 0 }`
|
|
241
|
+
// placeholder a non-subscription carries, and for Android's absent value.
|
|
242
|
+
subscriptionPeriod: toIso8601Duration(p.subscriptionPeriod),
|
|
243
|
+
// capgo sends "" rather than null for a product with no group.
|
|
244
|
+
subscriptionGroupId: p.subscriptionGroupIdentifier || null,
|
|
245
|
+
introductoryOffer: p.introductoryPrice ? normalizeOffer(p.introductoryPrice) : null,
|
|
246
|
+
promotionalOffers: (p.discounts ?? []).map(normalizeOffer),
|
|
247
|
+
androidOfferToken: p.offerToken ?? null,
|
|
248
|
+
androidOfferId: p.offerId ?? null,
|
|
249
|
+
// Android only: the base plan this entry describes. Null on iOS, where
|
|
250
|
+
// `identifier` is the product id and there is no base-plan concept.
|
|
251
|
+
androidBasePlanId: p.planIdentifier !== void 0 ? p.identifier : null
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function toMicros(price) {
|
|
255
|
+
if (typeof price !== "number" || !Number.isFinite(price)) return "0";
|
|
256
|
+
return Math.round(price * 1e6).toString();
|
|
257
|
+
}
|
|
258
|
+
function normalizeOffer(discount) {
|
|
259
|
+
return {
|
|
260
|
+
// An iOS introductory offer and an Android base-plan intro phase both
|
|
261
|
+
// arrive with an empty identifier; neither is named by its store.
|
|
262
|
+
id: discount.identifier || null,
|
|
263
|
+
type: mapOfferType(discount.type),
|
|
264
|
+
payment: mapOfferPayment(discount.paymentMode),
|
|
265
|
+
priceString: discount.priceString,
|
|
266
|
+
priceMicros: toMicros(discount.price),
|
|
267
|
+
currency: discount.currencyCode,
|
|
268
|
+
period: toIso8601Duration(discount.subscriptionPeriod),
|
|
269
|
+
periodCount: discount.numberOfPeriods
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function mapOfferType(type) {
|
|
273
|
+
switch (type) {
|
|
274
|
+
case 0:
|
|
275
|
+
return "introductory";
|
|
276
|
+
case 1:
|
|
277
|
+
return "promotional";
|
|
278
|
+
case 2:
|
|
279
|
+
return "win-back";
|
|
280
|
+
default:
|
|
281
|
+
return "unknown";
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function mapOfferPayment(paymentMode) {
|
|
285
|
+
switch (paymentMode) {
|
|
286
|
+
case 0:
|
|
287
|
+
return "free-trial";
|
|
288
|
+
case 1:
|
|
289
|
+
return "pay-up-front";
|
|
290
|
+
case 2:
|
|
291
|
+
return "pay-as-you-go";
|
|
292
|
+
default:
|
|
293
|
+
return "unknown";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
var init_normalize_product = __esm({
|
|
297
|
+
"src/adapters/native/capgo/normalize-product.ts"() {
|
|
298
|
+
init_subscription_period();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// src/lib/jws.ts
|
|
303
|
+
function decodeBase64UrlSegment(segment) {
|
|
304
|
+
const base64 = segment.replace(/-/g, "+").replace(/_/g, "/");
|
|
305
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
306
|
+
let binary;
|
|
307
|
+
try {
|
|
308
|
+
binary = atob(padded);
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
314
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
315
|
+
} catch {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function readJwsClaims(jws) {
|
|
320
|
+
if (typeof jws !== "string" || jws.length === 0) return null;
|
|
321
|
+
const segments = jws.split(".");
|
|
322
|
+
if (segments.length !== 3) return null;
|
|
323
|
+
const payload = segments[1];
|
|
324
|
+
if (!payload) return null;
|
|
325
|
+
const json = decodeBase64UrlSegment(payload);
|
|
326
|
+
if (json === null) return null;
|
|
327
|
+
let parsed;
|
|
328
|
+
try {
|
|
329
|
+
parsed = JSON.parse(json);
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
334
|
+
return parsed;
|
|
335
|
+
}
|
|
336
|
+
var init_jws = __esm({
|
|
337
|
+
"src/lib/jws.ts"() {
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// src/adapters/native/capgo/normalize-transaction.ts
|
|
342
|
+
function normalizeTransaction(tx, productType, packageName) {
|
|
343
|
+
const platform = inferPlatform(tx);
|
|
344
|
+
const token = platform === "google" ? tx.purchaseToken ?? tx.transactionId : tx.transactionId;
|
|
345
|
+
const native = {
|
|
346
|
+
platform,
|
|
347
|
+
productId: tx.productIdentifier,
|
|
348
|
+
token,
|
|
349
|
+
purchaseReference: purchaseReferenceFor(tx, platform, token),
|
|
350
|
+
productType,
|
|
351
|
+
// Lower-cased so it compares equal to what the caller supplied:
|
|
352
|
+
// StoreKit returns `UUID.uuidString`, which Swift renders uppercase,
|
|
353
|
+
// while `crypto.randomUUID()` and iap's case-insensitive validator both
|
|
354
|
+
// deal in lowercase. Returned raw, the binding would never `===` the
|
|
355
|
+
// value that was sent — the one comparison this field exists for.
|
|
356
|
+
appUserId: tx.appAccountToken?.toLowerCase() ?? null,
|
|
357
|
+
subscription: normalizeSubscription(tx, productType),
|
|
358
|
+
raw: tx
|
|
359
|
+
};
|
|
360
|
+
if (native.platform === "google" && packageName) {
|
|
361
|
+
native.packageName = packageName;
|
|
362
|
+
}
|
|
363
|
+
return native;
|
|
364
|
+
}
|
|
365
|
+
function purchaseReferenceFor(tx, platform, token) {
|
|
366
|
+
if (platform === "google" || !tx.jwsRepresentation) return token;
|
|
367
|
+
const claims = readJwsClaims(tx.jwsRepresentation);
|
|
368
|
+
const original = claims?.originalTransactionId;
|
|
369
|
+
return typeof original === "string" && original.length > 0 ? original : token;
|
|
370
|
+
}
|
|
371
|
+
function normalizeSubscription(tx, productType) {
|
|
372
|
+
if (productType !== "subscription") return null;
|
|
373
|
+
const renewalInfo = tx.renewalInfo;
|
|
374
|
+
const state = tx.subscriptionState ? SUBSCRIPTION_STATES.get(tx.subscriptionState) ?? "unknown" : null;
|
|
375
|
+
const autoRenewing = renewalInfo?.willAutoRenew ?? (typeof tx.willCancel === "boolean" ? !tx.willCancel : null);
|
|
376
|
+
const renewal = {
|
|
377
|
+
state,
|
|
378
|
+
autoRenewing,
|
|
379
|
+
renewsAt: renewalInfo?.renewalDate ?? null,
|
|
380
|
+
renewalProductId: renewalInfo?.autoRenewProductId ?? null,
|
|
381
|
+
expiresAt: tx.expirationDate ?? null
|
|
382
|
+
};
|
|
383
|
+
const reportedNothing = renewal.state === null && renewal.autoRenewing === null && renewal.renewsAt === null && renewal.renewalProductId === null && renewal.expiresAt === null;
|
|
384
|
+
return reportedNothing ? null : renewal;
|
|
385
|
+
}
|
|
386
|
+
function inferPlatform(tx) {
|
|
387
|
+
if (tx.purchaseToken !== void 0 || tx.purchaseState !== void 0 || tx.orderId !== void 0) {
|
|
388
|
+
return "google";
|
|
389
|
+
}
|
|
390
|
+
if (tx.receipt !== void 0 || tx.jwsRepresentation !== void 0) {
|
|
391
|
+
return "apple";
|
|
392
|
+
}
|
|
393
|
+
return getPlatform() === "android" ? "google" : "apple";
|
|
394
|
+
}
|
|
395
|
+
function inferProductType(tx, configured) {
|
|
396
|
+
if (tx.productType === "subs") return "subscription";
|
|
397
|
+
const declared = configured?.find((p) => p.id === tx.productIdentifier)?.type;
|
|
398
|
+
return declared === "consumable" ? "consumable" : "product";
|
|
399
|
+
}
|
|
400
|
+
var SUBSCRIPTION_STATES;
|
|
401
|
+
var init_normalize_transaction = __esm({
|
|
402
|
+
"src/adapters/native/capgo/normalize-transaction.ts"() {
|
|
403
|
+
init_jws();
|
|
404
|
+
init_platform();
|
|
405
|
+
SUBSCRIPTION_STATES = /* @__PURE__ */ new Map([
|
|
406
|
+
["subscribed", "subscribed"],
|
|
407
|
+
["expired", "expired"],
|
|
408
|
+
["revoked", "revoked"],
|
|
409
|
+
["inGracePeriod", "in-grace-period"],
|
|
410
|
+
["inBillingRetryPeriod", "in-billing-retry"],
|
|
411
|
+
["unknown", "unknown"]
|
|
412
|
+
]);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
142
416
|
// src/lib/iso-country.ts
|
|
143
417
|
function toAlpha2(code) {
|
|
144
418
|
if (!code) return null;
|
|
@@ -403,12 +677,6 @@ var init_iso_country = __esm({
|
|
|
403
677
|
};
|
|
404
678
|
}
|
|
405
679
|
});
|
|
406
|
-
|
|
407
|
-
// src/adapters/native/capgo/native-adapter.ts
|
|
408
|
-
var native_adapter_exports = {};
|
|
409
|
-
__export(native_adapter_exports, {
|
|
410
|
-
CapgoNativeAdapter: () => CapgoNativeAdapter
|
|
411
|
-
});
|
|
412
680
|
function nativeStorefrontRegistered() {
|
|
413
681
|
const headers = core.Capacitor.PluginHeaders;
|
|
414
682
|
return headers?.find((h) => h.name === "NativePurchases")?.methods?.some((m) => m.name === "getStorefront") ?? false;
|
|
@@ -426,84 +694,139 @@ function normalizeStorefront(raw) {
|
|
|
426
694
|
platform
|
|
427
695
|
};
|
|
428
696
|
}
|
|
429
|
-
function
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
currency: p.currencyCode
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
function normalizeTransaction(tx, productType) {
|
|
442
|
-
const platform = inferPlatform(tx);
|
|
443
|
-
const token = platform === "google" ? tx.purchaseToken ?? tx.transactionId : tx.transactionId;
|
|
444
|
-
const native = {
|
|
445
|
-
platform,
|
|
446
|
-
productId: tx.productIdentifier,
|
|
447
|
-
token,
|
|
448
|
-
productType,
|
|
449
|
-
raw: tx
|
|
450
|
-
};
|
|
451
|
-
return native;
|
|
697
|
+
async function readStorefront() {
|
|
698
|
+
if (!nativeStorefrontRegistered()) return null;
|
|
699
|
+
const np = nativePurchases.NativePurchases;
|
|
700
|
+
try {
|
|
701
|
+
const raw = await np.getStorefront?.();
|
|
702
|
+
return raw ? normalizeStorefront(raw) : null;
|
|
703
|
+
} catch {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
452
706
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
707
|
+
var init_storefront = __esm({
|
|
708
|
+
"src/adapters/native/capgo/storefront.ts"() {
|
|
709
|
+
init_iso_country();
|
|
710
|
+
init_platform();
|
|
456
711
|
}
|
|
457
|
-
|
|
458
|
-
|
|
712
|
+
});
|
|
713
|
+
async function watchCapgoTransactions(onUpdate, logger, packageName, getConfiguredProducts) {
|
|
714
|
+
const handles = [];
|
|
715
|
+
try {
|
|
716
|
+
handles.push(
|
|
717
|
+
await nativePurchases.NativePurchases.addListener("transactionUpdated", (tx) => {
|
|
718
|
+
try {
|
|
719
|
+
onUpdate(
|
|
720
|
+
normalizeTransaction(tx, inferProductType(tx, getConfiguredProducts?.()), packageName)
|
|
721
|
+
);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
logger.warn("transactionUpdated handler threw; ignoring the update.", error);
|
|
724
|
+
}
|
|
725
|
+
})
|
|
726
|
+
);
|
|
727
|
+
} catch (error) {
|
|
728
|
+
logger.warn(
|
|
729
|
+
"Could not subscribe to native transaction updates; renewals and refunds will be picked up at the next launch or resume instead.",
|
|
730
|
+
error
|
|
731
|
+
);
|
|
732
|
+
return null;
|
|
459
733
|
}
|
|
460
|
-
|
|
734
|
+
try {
|
|
735
|
+
handles.push(
|
|
736
|
+
await nativePurchases.NativePurchases.addListener("transactionVerificationFailed", (payload) => {
|
|
737
|
+
logger.warn(
|
|
738
|
+
`The store reported a transaction that failed Apple's verification; ignoring it. Reason: ${payload.error}`
|
|
739
|
+
);
|
|
740
|
+
})
|
|
741
|
+
);
|
|
742
|
+
} catch (error) {
|
|
743
|
+
logger.debug("Could not subscribe to transactionVerificationFailed.", error);
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
async remove() {
|
|
747
|
+
for (const handle of handles) {
|
|
748
|
+
try {
|
|
749
|
+
await handle.remove();
|
|
750
|
+
} catch (error) {
|
|
751
|
+
logger.warn("Failed to remove a native transaction listener.", error);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
};
|
|
461
756
|
}
|
|
462
|
-
function
|
|
463
|
-
if (
|
|
464
|
-
|
|
757
|
+
async function getCapgoPendingProductIds(logger) {
|
|
758
|
+
if (getPlatform() !== "android") return null;
|
|
759
|
+
try {
|
|
760
|
+
const result = await nativePurchases.NativePurchases.getPurchases();
|
|
761
|
+
return result.purchases.filter((tx) => tx.purchaseState !== void 0 && tx.purchaseState !== "1").map((tx) => tx.productIdentifier);
|
|
762
|
+
} catch (error) {
|
|
763
|
+
logger.warn("Could not read pending purchases from the store.", error);
|
|
764
|
+
return null;
|
|
765
|
+
}
|
|
465
766
|
}
|
|
767
|
+
var init_watch_transactions = __esm({
|
|
768
|
+
"src/adapters/native/capgo/watch-transactions.ts"() {
|
|
769
|
+
init_platform();
|
|
770
|
+
init_normalize_transaction();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// src/adapters/native/capgo/native-adapter.ts
|
|
775
|
+
var native_adapter_exports = {};
|
|
776
|
+
__export(native_adapter_exports, {
|
|
777
|
+
CapgoNativeAdapter: () => CapgoNativeAdapter
|
|
778
|
+
});
|
|
466
779
|
function mapToPluginPurchaseType(type) {
|
|
467
780
|
return type === "subscription" ? nativePurchases.PURCHASE_TYPE.SUBS : nativePurchases.PURCHASE_TYPE.INAPP;
|
|
468
781
|
}
|
|
469
|
-
function mapPurchaseError(error, productId) {
|
|
470
|
-
if (error instanceof exports.IAPError) return error;
|
|
471
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
472
|
-
const lower = message.toLowerCase();
|
|
473
|
-
if (lower.includes("cancel")) {
|
|
474
|
-
return new exports.IAPError({
|
|
475
|
-
code: exports.IAPErrorCode.USER_CANCELLED,
|
|
476
|
-
message: `Purchase of "${productId}" was cancelled.`,
|
|
477
|
-
cause: error
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
if (lower.includes("pending")) {
|
|
481
|
-
return new exports.IAPError({
|
|
482
|
-
code: exports.IAPErrorCode.PURCHASE_PENDING,
|
|
483
|
-
message: `Purchase of "${productId}" is pending external clearance.`,
|
|
484
|
-
cause: error
|
|
485
|
-
});
|
|
486
|
-
}
|
|
487
|
-
if (lower.includes("product not found") || lower.includes("cannot find product")) {
|
|
488
|
-
return new exports.IAPError({
|
|
489
|
-
code: exports.IAPErrorCode.PRODUCT_NOT_FOUND,
|
|
490
|
-
message: `Product "${productId}" was not found in the store catalog.`,
|
|
491
|
-
cause: error
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
return new exports.IAPError({
|
|
495
|
-
code: exports.IAPErrorCode.STORE_ERROR,
|
|
496
|
-
message: `Native purchase of "${productId}" failed.`,
|
|
497
|
-
cause: error
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
782
|
var CapgoNativeAdapter;
|
|
501
783
|
var init_native_adapter = __esm({
|
|
502
784
|
"src/adapters/native/capgo/native-adapter.ts"() {
|
|
503
785
|
init_errors();
|
|
504
|
-
|
|
505
|
-
|
|
786
|
+
init_map_purchase_error();
|
|
787
|
+
init_normalize_product();
|
|
788
|
+
init_normalize_transaction();
|
|
789
|
+
init_storefront();
|
|
790
|
+
init_watch_transactions();
|
|
506
791
|
CapgoNativeAdapter = class {
|
|
792
|
+
/**
|
|
793
|
+
* @param logger used by the best-effort paths that swallow their failures.
|
|
794
|
+
* @param packageName the app's Android applicationId, from
|
|
795
|
+
* `config.android.packageName`. Stamped onto every Google transaction
|
|
796
|
+
* because the plugin does not report it and Google's verification
|
|
797
|
+
* APIs are keyed on it.
|
|
798
|
+
* @param getConfiguredProducts the catalog, for typing a transaction the
|
|
799
|
+
* store reported — the plugin's payload cannot distinguish a consumable
|
|
800
|
+
* from a one-time purchase, since Play calls both `'inapp'`.
|
|
801
|
+
*/
|
|
802
|
+
constructor(logger, packageName, getConfiguredProducts) {
|
|
803
|
+
this.logger = logger;
|
|
804
|
+
this.packageName = packageName;
|
|
805
|
+
this.getConfiguredProducts = getConfiguredProducts;
|
|
806
|
+
}
|
|
807
|
+
logger;
|
|
808
|
+
packageName;
|
|
809
|
+
getConfiguredProducts;
|
|
810
|
+
/**
|
|
811
|
+
* The configured catalog, for typing a transaction the store reported.
|
|
812
|
+
*
|
|
813
|
+
* An empty one is not benign: every store-reported consumable would type
|
|
814
|
+
* as `'product'`, be acknowledged rather than consumed, and stay owned —
|
|
815
|
+
* which is the bug the catalog exists to prevent, reachable by
|
|
816
|
+
* misconfiguration rather than by code. Warned once, since
|
|
817
|
+
* `getOwnedTransactions` runs on every refresh.
|
|
818
|
+
*/
|
|
819
|
+
configured() {
|
|
820
|
+
const products = this.getConfiguredProducts?.() ?? [];
|
|
821
|
+
if (products.length === 0 && !this.warnedAboutEmptyCatalog) {
|
|
822
|
+
this.warnedAboutEmptyCatalog = true;
|
|
823
|
+
this.logger.warn(
|
|
824
|
+
"The native adapter has no configured products, so a purchase the store reports cannot be recognised as a consumable and will be acknowledged rather than consumed \u2014 leaving it owned and unbuyable. This is a wiring problem inside iap; please report it."
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
return products;
|
|
828
|
+
}
|
|
829
|
+
warnedAboutEmptyCatalog = false;
|
|
507
830
|
async isAvailable() {
|
|
508
831
|
try {
|
|
509
832
|
const result = await nativePurchases.NativePurchases.isBillingSupported();
|
|
@@ -516,9 +839,7 @@ var init_native_adapter = __esm({
|
|
|
516
839
|
if (requests.length === 0) return [];
|
|
517
840
|
const inappIds = [];
|
|
518
841
|
const subsIds = [];
|
|
519
|
-
const requestById = /* @__PURE__ */ new Map();
|
|
520
842
|
for (const req of requests) {
|
|
521
|
-
requestById.set(req.id, req.type);
|
|
522
843
|
if (req.type === "subscription") {
|
|
523
844
|
subsIds.push(req.id);
|
|
524
845
|
} else {
|
|
@@ -536,11 +857,10 @@ var init_native_adapter = __esm({
|
|
|
536
857
|
}) : Promise.resolve({ products: [] })
|
|
537
858
|
]);
|
|
538
859
|
const all = [...inapp.products, ...subs.products];
|
|
539
|
-
return all
|
|
860
|
+
return resolveProducts(all, requests);
|
|
540
861
|
}
|
|
541
862
|
async purchaseProduct(opts) {
|
|
542
863
|
const purchaseType = mapToPluginPurchaseType(opts.productType);
|
|
543
|
-
const isConsumable = opts.productType === "consumable";
|
|
544
864
|
let tx;
|
|
545
865
|
try {
|
|
546
866
|
tx = await nativePurchases.NativePurchases.purchaseProduct({
|
|
@@ -548,17 +868,35 @@ var init_native_adapter = __esm({
|
|
|
548
868
|
productType: purchaseType,
|
|
549
869
|
planIdentifier: opts.androidPlanId,
|
|
550
870
|
appAccountToken: opts.appAccountToken,
|
|
551
|
-
|
|
871
|
+
// `false` even for a consumable, and this is load-bearing.
|
|
872
|
+
//
|
|
873
|
+
// `autoAcknowledgePurchases: false` does **not** cover the
|
|
874
|
+
// consumable case: the plugin's `PurchaseActionDecider` returns
|
|
875
|
+
// CONSUME whenever `isConsumable` is set, and its caller consumes
|
|
876
|
+
// without consulting `autoAcknowledgePurchases` — that flag is only
|
|
877
|
+
// read on the ACKNOWLEDGE branch. (Checked in the plugin's Java,
|
|
878
|
+
// not its docs, and guarded by a test in
|
|
879
|
+
// `tests/unit/native-plugin-contract.test.ts`.)
|
|
880
|
+
//
|
|
881
|
+
// So passing `true` here would have the plugin consume inside the
|
|
882
|
+
// purchase callback, before iap has verified anything — breaking
|
|
883
|
+
// acknowledge-only-after-verification for exactly the product type
|
|
884
|
+
// where it cannot be undone, since Billing 8.x drops a consumed
|
|
885
|
+
// purchase from `getPurchases()` entirely. iap consumes itself,
|
|
886
|
+
// after verification, via {@link CapgoNativeAdapter.consume}.
|
|
887
|
+
isConsumable: false,
|
|
552
888
|
autoAcknowledgePurchases: false
|
|
553
889
|
});
|
|
554
890
|
} catch (error) {
|
|
555
891
|
throw mapPurchaseError(error, opts.productId);
|
|
556
892
|
}
|
|
557
|
-
return normalizeTransaction(tx, opts.productType);
|
|
893
|
+
return normalizeTransaction(tx, opts.productType, this.packageName);
|
|
558
894
|
}
|
|
559
895
|
async getOwnedTransactions() {
|
|
560
896
|
const result = await nativePurchases.NativePurchases.getPurchases();
|
|
561
|
-
return result.purchases.filter((tx) => tx.purchaseState === void 0 || tx.purchaseState === "1").map(
|
|
897
|
+
return result.purchases.filter((tx) => tx.purchaseState === void 0 || tx.purchaseState === "1").map(
|
|
898
|
+
(tx) => normalizeTransaction(tx, inferProductType(tx, this.configured()), this.packageName)
|
|
899
|
+
);
|
|
562
900
|
}
|
|
563
901
|
async acknowledge(transaction) {
|
|
564
902
|
try {
|
|
@@ -574,6 +912,31 @@ var init_native_adapter = __esm({
|
|
|
574
912
|
});
|
|
575
913
|
}
|
|
576
914
|
}
|
|
915
|
+
/**
|
|
916
|
+
* Consume an Android consumable, releasing ownership so it can be bought
|
|
917
|
+
* again. See {@link NativeAdapter.consume} for why acknowledging is not
|
|
918
|
+
* enough.
|
|
919
|
+
*
|
|
920
|
+
* The plugin rejects this on iOS and web, so the core calls it only for
|
|
921
|
+
* Google transactions.
|
|
922
|
+
*/
|
|
923
|
+
async consume(transaction) {
|
|
924
|
+
try {
|
|
925
|
+
await nativePurchases.NativePurchases.consumePurchase({
|
|
926
|
+
purchaseToken: transaction.token
|
|
927
|
+
});
|
|
928
|
+
} catch (error) {
|
|
929
|
+
throw new exports.IAPError({
|
|
930
|
+
code: exports.IAPErrorCode.STORE_ERROR,
|
|
931
|
+
message: `Failed to consume transaction for ${transaction.productId}.`,
|
|
932
|
+
cause: error,
|
|
933
|
+
// Retried by the recovery loop like a failed acknowledge: until it
|
|
934
|
+
// succeeds, Play still reports the product owned and the user
|
|
935
|
+
// cannot buy it again.
|
|
936
|
+
recoverable: true
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
577
940
|
async manageSubscriptions() {
|
|
578
941
|
try {
|
|
579
942
|
await nativePurchases.NativePurchases.manageSubscriptions();
|
|
@@ -594,14 +957,20 @@ var init_native_adapter = __esm({
|
|
|
594
957
|
* rejection, or empty country — resolves to `null` rather than throwing.
|
|
595
958
|
*/
|
|
596
959
|
async getStorefront() {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
960
|
+
return readStorefront();
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Subscribe to Apple's `Transaction.updates`. See
|
|
964
|
+
* {@link watchCapgoTransactions} — iOS only in practice, and silent
|
|
965
|
+
* about its own failures, since a missing listener degrades to
|
|
966
|
+
* reconciliation rather than breaking anything.
|
|
967
|
+
*/
|
|
968
|
+
async watchTransactions(onUpdate) {
|
|
969
|
+
return watchCapgoTransactions(onUpdate, this.logger, this.packageName, () => this.configured());
|
|
970
|
+
}
|
|
971
|
+
/** Android only; `null` on iOS. See {@link getCapgoPendingProductIds}. */
|
|
972
|
+
async getPendingProductIds() {
|
|
973
|
+
return getCapgoPendingProductIds(this.logger);
|
|
605
974
|
}
|
|
606
975
|
async dispose() {
|
|
607
976
|
}
|
|
@@ -791,6 +1160,14 @@ var HttpClient = class {
|
|
|
791
1160
|
function sleep(ms) {
|
|
792
1161
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
793
1162
|
}
|
|
1163
|
+
|
|
1164
|
+
// src/lib/uuid.ts
|
|
1165
|
+
var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
1166
|
+
function isValidUuid(value) {
|
|
1167
|
+
return typeof value === "string" && UUID_REGEX.test(value);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/types/config.ts
|
|
794
1171
|
var productTypeSchema = zod.z.enum(["subscription", "product", "consumable"]);
|
|
795
1172
|
var configuredProductSchema = zod.z.object({
|
|
796
1173
|
id: zod.z.string().min(1),
|
|
@@ -890,6 +1267,38 @@ var backendConfigSchema = zod.z.object({
|
|
|
890
1267
|
});
|
|
891
1268
|
}
|
|
892
1269
|
});
|
|
1270
|
+
var verificationConfigSchema = zod.z.object({
|
|
1271
|
+
mode: zod.z.literal("local"),
|
|
1272
|
+
/**
|
|
1273
|
+
* Maps a verified purchase to the entitlements it grants. Required: iap
|
|
1274
|
+
* does not know what a product entitles anyone to, and in local mode
|
|
1275
|
+
* there is no backend to ask. Type is checked at runtime via a shape
|
|
1276
|
+
* guard, not zod — see {@link VerificationConfig}.
|
|
1277
|
+
*/
|
|
1278
|
+
entitlements: zod.z.unknown(),
|
|
1279
|
+
/**
|
|
1280
|
+
* The binding every purchase must carry, as passed to
|
|
1281
|
+
* `purchase({ appUserId })`. A purchase bound to something else is
|
|
1282
|
+
* refused. Omit it if the app does not bind purchases.
|
|
1283
|
+
*
|
|
1284
|
+
* Either the id itself, or a function returning it — in which case it is
|
|
1285
|
+
* read **at the time of each check**, so the binding follows the session.
|
|
1286
|
+
* A fixed string is captured at `createIAP()` time and cannot track a
|
|
1287
|
+
* login change, which in a multi-user app means every purchase after a
|
|
1288
|
+
* switch is refused as the previous user's.
|
|
1289
|
+
*
|
|
1290
|
+
* The function may return `null` for "nobody is signed in right now".
|
|
1291
|
+
* That is **not** the same as using no binding: iap refuses the purchase
|
|
1292
|
+
* rather than dropping the check. See `LocalVerificationAdapter`.
|
|
1293
|
+
*
|
|
1294
|
+
* Typed via the overlay on {@link VerificationConfig}; validated in the
|
|
1295
|
+
* schema's `superRefine`, the same way the backend's functions are.
|
|
1296
|
+
*/
|
|
1297
|
+
appUserId: zod.z.unknown().optional()
|
|
1298
|
+
});
|
|
1299
|
+
var androidConfigSchema = zod.z.object({
|
|
1300
|
+
packageName: zod.z.string().min(1).optional()
|
|
1301
|
+
});
|
|
893
1302
|
var storageConfigSchema = zod.z.object({
|
|
894
1303
|
type: zod.z.enum(["preferences", "memory", "custom"]).default("preferences"),
|
|
895
1304
|
namespace: zod.z.string().min(1).default("nosslabs_iap"),
|
|
@@ -898,6 +1307,21 @@ var storageConfigSchema = zod.z.object({
|
|
|
898
1307
|
var optionsConfigSchema = zod.z.object({
|
|
899
1308
|
refreshOnResume: zod.z.boolean().default(true),
|
|
900
1309
|
entitlementCacheTtlMs: zod.z.number().int().positive().default(60 * 60 * 1e3),
|
|
1310
|
+
/**
|
|
1311
|
+
* Extra time past an entitlement's `expiresAt` during which it still
|
|
1312
|
+
* counts as active, by the **device** clock.
|
|
1313
|
+
*
|
|
1314
|
+
* Exists because a renewal is not instantaneous: a subscription that
|
|
1315
|
+
* renews at midnight may not have a fresh receipt on the device for
|
|
1316
|
+
* minutes or hours, and locking the user out in that window is a worse
|
|
1317
|
+
* error than letting a lapsed subscription run slightly long. Store
|
|
1318
|
+
* grace periods and billing retry can stretch much further — on Android
|
|
1319
|
+
* those are server-side only, so a backendless app cannot see them at all
|
|
1320
|
+
* and this window is the only cushion it has.
|
|
1321
|
+
*
|
|
1322
|
+
* Defaults to 0: no grace, which is the strict reading of `expiresAt`.
|
|
1323
|
+
*/
|
|
1324
|
+
entitlementGraceMs: zod.z.number().int().nonnegative().default(0),
|
|
901
1325
|
recoverUnfinishedTransactions: zod.z.boolean().default(true),
|
|
902
1326
|
/**
|
|
903
1327
|
* Cap on how many unfinished transactions recovery inspects per launch.
|
|
@@ -931,7 +1355,16 @@ var iapConfigSchema = zod.z.object({
|
|
|
931
1355
|
* without either path throw `INVALID_CONFIG` at parse time.
|
|
932
1356
|
*/
|
|
933
1357
|
products: zod.z.array(configuredProductSchema).min(1).optional(),
|
|
934
|
-
|
|
1358
|
+
/**
|
|
1359
|
+
* The consumer's backend, which calls Attesto. Optional **only** when
|
|
1360
|
+
* `verification` is set instead — one of the two has to answer
|
|
1361
|
+
* whether a purchase is real.
|
|
1362
|
+
*/
|
|
1363
|
+
backend: backendConfigSchema.optional(),
|
|
1364
|
+
/** On-device verification, for an app with no backend. */
|
|
1365
|
+
verification: verificationConfigSchema.optional(),
|
|
1366
|
+
/** Android-specific settings. See {@link androidConfigSchema}. */
|
|
1367
|
+
android: androidConfigSchema.default({}),
|
|
935
1368
|
storage: storageConfigSchema.default({ type: "preferences", namespace: "nosslabs_iap" }),
|
|
936
1369
|
options: optionsConfigSchema.default({
|
|
937
1370
|
refreshOnResume: true,
|
|
@@ -942,10 +1375,49 @@ var iapConfigSchema = zod.z.object({
|
|
|
942
1375
|
logLevel: "info"
|
|
943
1376
|
})
|
|
944
1377
|
}).superRefine((data, ctx) => {
|
|
1378
|
+
if (data.backend && data.verification) {
|
|
1379
|
+
ctx.addIssue({
|
|
1380
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1381
|
+
message: "Set either backend or verification, not both \u2014 they are two answers to the same question.",
|
|
1382
|
+
path: []
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
if (!data.backend && !data.verification) {
|
|
1386
|
+
ctx.addIssue({
|
|
1387
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1388
|
+
message: 'One of backend or verification is required: something has to verify a purchase before an entitlement is granted. Use verification: { mode: "local", entitlements } for an app with no backend.',
|
|
1389
|
+
path: []
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
if (data.verification && typeof data.verification.entitlements !== "function") {
|
|
1393
|
+
ctx.addIssue({
|
|
1394
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1395
|
+
message: "verification.entitlements must be a function ((purchase) => TEntitlement[]) \u2014 iap cannot know what a product entitles a user to.",
|
|
1396
|
+
path: ["verification", "entitlements"]
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
const binding = data.verification?.appUserId;
|
|
1400
|
+
if (data.verification && binding !== void 0 && typeof binding !== "function" && !(typeof binding === "string" && isValidUuid(binding))) {
|
|
1401
|
+
ctx.addIssue({
|
|
1402
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1403
|
+
message: "verification.appUserId must be an RFC 9562 UUID, or a function returning one (or null when nobody is signed in) \u2014 the same constraint purchase({ appUserId }) enforces.",
|
|
1404
|
+
path: ["verification", "appUserId"]
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
if (data.verification && data.products === void 0) {
|
|
1408
|
+
ctx.addIssue({
|
|
1409
|
+
code: zod.z.ZodIssueCode.custom,
|
|
1410
|
+
message: "products is required in local verification mode: there is no backend to supply the manifest.",
|
|
1411
|
+
path: ["products"]
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
945
1414
|
if (data.products !== void 0) return;
|
|
946
|
-
|
|
1415
|
+
if (data.verification) return;
|
|
1416
|
+
const backend = data.backend;
|
|
1417
|
+
if (!backend) return;
|
|
1418
|
+
const adapter = backend.adapter;
|
|
947
1419
|
const adapterCanList = adapter && typeof adapter.listProducts === "function";
|
|
948
|
-
const httpCanList = !
|
|
1420
|
+
const httpCanList = !backend.adapter && backend.endpoints?.products;
|
|
949
1421
|
if (!adapterCanList && !httpCanList) {
|
|
950
1422
|
ctx.addIssue({
|
|
951
1423
|
code: zod.z.ZodIssueCode.custom,
|
|
@@ -986,7 +1458,8 @@ var verifyResponseSchema = zod.z.discriminatedUnion("valid", [
|
|
|
986
1458
|
]);
|
|
987
1459
|
var restoreSuccessSchema = zod.z.object({
|
|
988
1460
|
valid: zod.z.literal(true),
|
|
989
|
-
entitlements: zod.z.array(passthroughEntitlementSchema)
|
|
1461
|
+
entitlements: zod.z.array(passthroughEntitlementSchema),
|
|
1462
|
+
verifiedTokens: zod.z.array(zod.z.string()).optional()
|
|
990
1463
|
}).passthrough();
|
|
991
1464
|
var restoreResponseSchema = zod.z.discriminatedUnion("valid", [
|
|
992
1465
|
restoreSuccessSchema,
|
|
@@ -997,7 +1470,10 @@ var productManifestResponseSchema = zod.z.object({ products: configuredProductsA
|
|
|
997
1470
|
function isBackendAdapter(value) {
|
|
998
1471
|
if (!value || typeof value !== "object") return false;
|
|
999
1472
|
const candidate = value;
|
|
1000
|
-
|
|
1473
|
+
if (typeof candidate.getEntitlements !== "function") return false;
|
|
1474
|
+
const hasExtracted = typeof candidate.verifyApple === "function" && typeof candidate.verifyGoogle === "function" && typeof candidate.restore === "function";
|
|
1475
|
+
const hasNative = typeof candidate.verifyNative === "function" && (typeof candidate.restore === "function" || typeof candidate.restoreNative === "function");
|
|
1476
|
+
return hasExtracted || hasNative;
|
|
1001
1477
|
}
|
|
1002
1478
|
|
|
1003
1479
|
// src/adapters/backend/http-adapter.ts
|
|
@@ -1073,9 +1549,373 @@ var HttpBackendAdapter = class {
|
|
|
1073
1549
|
}
|
|
1074
1550
|
};
|
|
1075
1551
|
|
|
1552
|
+
// src/core/local-verification.ts
|
|
1553
|
+
function isExpired(expiresAt, now, graceMs = 0) {
|
|
1554
|
+
if (!expiresAt) return false;
|
|
1555
|
+
const expiry = Date.parse(expiresAt);
|
|
1556
|
+
if (Number.isNaN(expiry)) return false;
|
|
1557
|
+
const grace = Number.isFinite(graceMs) ? graceMs : 0;
|
|
1558
|
+
return now > expiry + grace;
|
|
1559
|
+
}
|
|
1560
|
+
function verifyPurchaseOnDevice(transaction, options) {
|
|
1561
|
+
const now = (options.now ?? Date.now)();
|
|
1562
|
+
if (!options.productIds.includes(transaction.productId)) {
|
|
1563
|
+
return {
|
|
1564
|
+
valid: false,
|
|
1565
|
+
error: "PRODUCT_NOT_CONFIGURED",
|
|
1566
|
+
message: `The store reported a purchase of "${transaction.productId}", which is not in the configured product list.`
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
const expected = options.appUserId;
|
|
1570
|
+
if (expected) {
|
|
1571
|
+
const actual = transaction.appUserId ?? null;
|
|
1572
|
+
if (actual === null || actual.toLowerCase() !== expected.toLowerCase()) {
|
|
1573
|
+
return {
|
|
1574
|
+
valid: false,
|
|
1575
|
+
error: "BINDING_MISMATCH",
|
|
1576
|
+
message: `The purchase of "${transaction.productId}" is not bound to the expected account.`
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
if (transaction.platform === "google") {
|
|
1581
|
+
const state = transaction.raw?.purchaseState;
|
|
1582
|
+
if (state !== void 0 && state !== null && String(state) !== "1") {
|
|
1583
|
+
return {
|
|
1584
|
+
valid: false,
|
|
1585
|
+
error: "NOT_PURCHASED",
|
|
1586
|
+
message: `The purchase of "${transaction.productId}" is not in Play's purchased state.`
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
const revocationDate = transaction.raw?.revocationDate;
|
|
1591
|
+
if (transaction.subscription?.state === "revoked" || typeof revocationDate === "string") {
|
|
1592
|
+
return {
|
|
1593
|
+
valid: false,
|
|
1594
|
+
error: "REVOKED",
|
|
1595
|
+
message: `The purchase of "${transaction.productId}" has been revoked or refunded.`
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
const expiresAt = transaction.subscription?.expiresAt ?? null;
|
|
1599
|
+
if (isExpired(expiresAt, now, options.graceMs)) {
|
|
1600
|
+
return {
|
|
1601
|
+
valid: false,
|
|
1602
|
+
error: "SUBSCRIPTION_EXPIRED",
|
|
1603
|
+
message: `The subscription "${transaction.productId}" expired at ${expiresAt}.`
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
return {
|
|
1607
|
+
valid: true,
|
|
1608
|
+
purchase: {
|
|
1609
|
+
productId: transaction.productId,
|
|
1610
|
+
platform: transaction.platform,
|
|
1611
|
+
productType: transaction.productType,
|
|
1612
|
+
purchaseReference: transaction.purchaseReference,
|
|
1613
|
+
appUserId: transaction.appUserId ?? null,
|
|
1614
|
+
expiresAt,
|
|
1615
|
+
subscription: transaction.subscription ?? null,
|
|
1616
|
+
// iOS transactions are signature-checked by StoreKit in native code
|
|
1617
|
+
// before they cross the bridge; Android has no equivalent available.
|
|
1618
|
+
assurance: transaction.platform === "apple" ? "store-signed" : "store-reported"
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/adapters/backend/local-adapter.ts
|
|
1624
|
+
init_errors();
|
|
1625
|
+
var LocalVerificationAdapter = class {
|
|
1626
|
+
constructor(opts) {
|
|
1627
|
+
this.opts = opts;
|
|
1628
|
+
}
|
|
1629
|
+
opts;
|
|
1630
|
+
/**
|
|
1631
|
+
* The whole transaction, not the extracted fields the HTTP contract sends.
|
|
1632
|
+
*
|
|
1633
|
+
* A local check needs the binding, the subscription state and the expiry,
|
|
1634
|
+
* and `VerifyAppleRequest` carries none of them — it is shaped for what a
|
|
1635
|
+
* server needs to look a receipt up. The router prefers this method when
|
|
1636
|
+
* an adapter has it, so the HTTP path is unaffected.
|
|
1637
|
+
*/
|
|
1638
|
+
async verifyNative(transaction) {
|
|
1639
|
+
const checked = this.check(transaction);
|
|
1640
|
+
if (!checked.valid) return checked;
|
|
1641
|
+
return {
|
|
1642
|
+
valid: true,
|
|
1643
|
+
entitlements: await this.consolidated(checked.purchase),
|
|
1644
|
+
transaction: checked.transaction
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
/**
|
|
1648
|
+
* Unreachable in practice: the router prefers {@link verifyNative}. Kept
|
|
1649
|
+
* because `BackendAdapter` requires them, and failing loudly is better
|
|
1650
|
+
* than silently granting if a future caller bypasses the router.
|
|
1651
|
+
*/
|
|
1652
|
+
async verifyApple() {
|
|
1653
|
+
throw this.unsupported("verifyApple");
|
|
1654
|
+
}
|
|
1655
|
+
async verifyGoogle() {
|
|
1656
|
+
throw this.unsupported("verifyGoogle");
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* In local mode the store **is** the source of truth, so a refresh asks
|
|
1660
|
+
* it what it owns and re-derives entitlements from that.
|
|
1661
|
+
*
|
|
1662
|
+
* This is why `refreshOnResume` keeps working with no network: there is
|
|
1663
|
+
* nothing to reach. A purchase that expired while the app was backgrounded
|
|
1664
|
+
* drops out here, which is the only downgrade local mode performs.
|
|
1665
|
+
*/
|
|
1666
|
+
async getEntitlements() {
|
|
1667
|
+
const binding = this.binding();
|
|
1668
|
+
if (!binding.ok) {
|
|
1669
|
+
this.opts.logger.debug(
|
|
1670
|
+
`Keeping the current entitlements: ${binding.error}. ${binding.message}`
|
|
1671
|
+
);
|
|
1672
|
+
return this.opts.getCurrentEntitlements();
|
|
1673
|
+
}
|
|
1674
|
+
const owned = await this.ownedTransactions();
|
|
1675
|
+
if (owned.length === 0) {
|
|
1676
|
+
const current = this.opts.getCurrentEntitlements();
|
|
1677
|
+
if (current.length > 0) {
|
|
1678
|
+
this.opts.logger.debug(
|
|
1679
|
+
"The store reported nothing owned. Keeping the current entitlements, since an empty answer is indistinguishable from a failed query."
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
return current;
|
|
1683
|
+
}
|
|
1684
|
+
return this.consolidated();
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Restore is the same question as a refresh here — "what does this store
|
|
1688
|
+
* account own" — answered from the transactions the orchestrator already
|
|
1689
|
+
* read, so the store is not asked twice.
|
|
1690
|
+
*/
|
|
1691
|
+
async restoreNative(owned) {
|
|
1692
|
+
const binding = this.binding();
|
|
1693
|
+
if (!binding.ok) {
|
|
1694
|
+
return { valid: false, error: binding.error, message: binding.message };
|
|
1695
|
+
}
|
|
1696
|
+
const { entitlements, verifiedTokens } = this.evaluate(owned);
|
|
1697
|
+
if (verifiedTokens.length === 0) {
|
|
1698
|
+
return {
|
|
1699
|
+
valid: false,
|
|
1700
|
+
error: "NO_VERIFIED_PURCHASES",
|
|
1701
|
+
message: "The store reported purchases, but none passed the on-device checks."
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
return { valid: true, entitlements, verifiedTokens };
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Unreachable via the orchestrator, which prefers {@link restoreNative}.
|
|
1708
|
+
* Kept so the adapter satisfies a consumer holding it as a plain
|
|
1709
|
+
* `BackendAdapter`; it re-reads the store rather than trusting an
|
|
1710
|
+
* extracted request a local check cannot verify from.
|
|
1711
|
+
*/
|
|
1712
|
+
async restore(_req) {
|
|
1713
|
+
return this.restoreNative(await this.ownedTransactions());
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* The binding to check against, or a refusal.
|
|
1717
|
+
*
|
|
1718
|
+
* Read per check rather than once at construction, because a
|
|
1719
|
+
* `verification.appUserId` getter exists precisely so the binding can
|
|
1720
|
+
* follow the session: in a multi-user app a value captured at
|
|
1721
|
+
* `createIAP()` time is the previous user's after a switch, and every
|
|
1722
|
+
* purchase is then refused as a mismatch.
|
|
1723
|
+
*
|
|
1724
|
+
* The thing it will not do is treat "no account right now" as "this app
|
|
1725
|
+
* uses no binding". That would turn a signed-out session into an
|
|
1726
|
+
* unguarded one, accepting a purchase bound to **someone else** — a
|
|
1727
|
+
* weaker check than the app asked for, silently. Refusing is the safe
|
|
1728
|
+
* direction: the purchase stays unacknowledged and the store retries it.
|
|
1729
|
+
*/
|
|
1730
|
+
binding() {
|
|
1731
|
+
const resolve = this.opts.getAppUserId;
|
|
1732
|
+
if (!resolve) return { ok: true, appUserId: null };
|
|
1733
|
+
let value;
|
|
1734
|
+
try {
|
|
1735
|
+
value = resolve();
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
this.opts.logger.warn(
|
|
1738
|
+
"verification.appUserId threw. Refusing purchases rather than dropping the binding check.",
|
|
1739
|
+
error
|
|
1740
|
+
);
|
|
1741
|
+
return {
|
|
1742
|
+
ok: false,
|
|
1743
|
+
error: "BINDING_UNAVAILABLE",
|
|
1744
|
+
message: "The expected account binding could not be read."
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
if (value === null || value === void 0 || value === "") {
|
|
1748
|
+
return {
|
|
1749
|
+
ok: false,
|
|
1750
|
+
error: "BINDING_UNAVAILABLE",
|
|
1751
|
+
message: "This app binds purchases to an account, and verification.appUserId resolved to none. Nothing can be verified until someone is signed in."
|
|
1752
|
+
};
|
|
1753
|
+
}
|
|
1754
|
+
if (!isValidUuid(value) && !this.warnedAboutBinding) {
|
|
1755
|
+
this.warnedAboutBinding = true;
|
|
1756
|
+
this.opts.logger.warn(
|
|
1757
|
+
"verification.appUserId returned a value that is not an RFC 9562 UUID. purchase({ appUserId }) only accepts UUIDs, so no purchase can ever match it."
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
return { ok: true, appUserId: value };
|
|
1761
|
+
}
|
|
1762
|
+
/** Warned once per instance, not once per check — a refresh runs this. */
|
|
1763
|
+
warnedAboutBinding = false;
|
|
1764
|
+
checkOptions(appUserId) {
|
|
1765
|
+
return {
|
|
1766
|
+
productIds: this.opts.getProducts().map((p) => p.id),
|
|
1767
|
+
appUserId,
|
|
1768
|
+
graceMs: this.opts.graceMs
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
check(transaction) {
|
|
1772
|
+
const binding = this.binding();
|
|
1773
|
+
if (!binding.ok) {
|
|
1774
|
+
this.opts.logger.debug(
|
|
1775
|
+
`Local verification refused "${transaction.productId}": ${binding.error}.`
|
|
1776
|
+
);
|
|
1777
|
+
return { valid: false, error: binding.error, message: binding.message };
|
|
1778
|
+
}
|
|
1779
|
+
const result = verifyPurchaseOnDevice(transaction, this.checkOptions(binding.appUserId));
|
|
1780
|
+
if (!result.valid) {
|
|
1781
|
+
this.opts.logger.debug(
|
|
1782
|
+
`Local verification refused "${transaction.productId}": ${result.error}.`
|
|
1783
|
+
);
|
|
1784
|
+
return { valid: false, error: result.error, message: result.message };
|
|
1785
|
+
}
|
|
1786
|
+
return {
|
|
1787
|
+
valid: true,
|
|
1788
|
+
purchase: result.purchase,
|
|
1789
|
+
transaction: {
|
|
1790
|
+
// The purchase's own identity, not a server-assigned id — there is
|
|
1791
|
+
// no server to assign one. Note it is stable across renewals, so it
|
|
1792
|
+
// is not a per-transaction key.
|
|
1793
|
+
id: result.purchase.purchaseReference,
|
|
1794
|
+
productId: result.purchase.productId,
|
|
1795
|
+
expiresAt: result.purchase.expiresAt
|
|
1796
|
+
}
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Run the consumer's mapper and validate what it returns.
|
|
1801
|
+
*
|
|
1802
|
+
* Unlike a backend response, this value never went through a schema — it
|
|
1803
|
+
* comes straight from consumer code into iap's state and cache. An
|
|
1804
|
+
* unvalidated mapper could cache an object with no `key` (so
|
|
1805
|
+
* `hasEntitlement` could never match it, and the next launch would drop it
|
|
1806
|
+
* from the cache, making entitlements differ between sessions), or return
|
|
1807
|
+
* a non-array and throw a raw `TypeError` out of `purchase()` *after* the
|
|
1808
|
+
* transaction was acknowledged.
|
|
1809
|
+
*/
|
|
1810
|
+
map(purchase) {
|
|
1811
|
+
let mapped;
|
|
1812
|
+
try {
|
|
1813
|
+
mapped = this.opts.entitlements(purchase);
|
|
1814
|
+
} catch (error) {
|
|
1815
|
+
this.opts.logger.warn(
|
|
1816
|
+
`verification.entitlements threw for "${purchase.productId}"; granting nothing for it.`,
|
|
1817
|
+
error
|
|
1818
|
+
);
|
|
1819
|
+
return [];
|
|
1820
|
+
}
|
|
1821
|
+
const parsed = zod.z.array(entitlementBaseSchema.passthrough()).safeParse(mapped);
|
|
1822
|
+
if (!parsed.success) {
|
|
1823
|
+
this.opts.logger.warn(
|
|
1824
|
+
`verification.entitlements returned something that is not a list of entitlements for "${purchase.productId}"; granting nothing for it. Each entry needs a key, a productId and an expiresAt (which may be null).`
|
|
1825
|
+
);
|
|
1826
|
+
return [];
|
|
1827
|
+
}
|
|
1828
|
+
return parsed.data;
|
|
1829
|
+
}
|
|
1830
|
+
/** The consolidated entitlements, optionally merging a just-verified purchase. */
|
|
1831
|
+
async consolidated(extra) {
|
|
1832
|
+
const owned = await this.ownedTransactions();
|
|
1833
|
+
const { entitlements } = this.evaluate(owned, extra);
|
|
1834
|
+
return entitlements;
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Evaluate an owned set: which transactions passed, and what they grant.
|
|
1838
|
+
*
|
|
1839
|
+
* Returns the tokens that passed as well as the entitlements, because the
|
|
1840
|
+
* caller needs to know *which* transactions may be acknowledged — a
|
|
1841
|
+
* single consolidated list cannot say.
|
|
1842
|
+
*/
|
|
1843
|
+
evaluate(owned, extra) {
|
|
1844
|
+
const binding = this.binding();
|
|
1845
|
+
if (!binding.ok) return { entitlements: [], verifiedTokens: [] };
|
|
1846
|
+
const options = this.checkOptions(binding.appUserId);
|
|
1847
|
+
const verifiedTokens = [];
|
|
1848
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1849
|
+
const absorb = (purchase) => {
|
|
1850
|
+
for (const entitlement of this.map(purchase)) {
|
|
1851
|
+
const existing = byKey.get(entitlement.key);
|
|
1852
|
+
if (!existing || outlasts(entitlement.expiresAt, existing.expiresAt)) {
|
|
1853
|
+
byKey.set(entitlement.key, entitlement);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
for (const transaction of owned) {
|
|
1858
|
+
const result = verifyPurchaseOnDevice(transaction, options);
|
|
1859
|
+
if (!result.valid) {
|
|
1860
|
+
this.opts.logger.debug(
|
|
1861
|
+
`Local verification refused "${transaction.productId}": ${result.error}.`
|
|
1862
|
+
);
|
|
1863
|
+
continue;
|
|
1864
|
+
}
|
|
1865
|
+
verifiedTokens.push(transaction.token);
|
|
1866
|
+
absorb(result.purchase);
|
|
1867
|
+
}
|
|
1868
|
+
if (extra) absorb(extra);
|
|
1869
|
+
return { entitlements: [...byKey.values()], verifiedTokens };
|
|
1870
|
+
}
|
|
1871
|
+
async ownedTransactions() {
|
|
1872
|
+
const native = this.opts.getNativeAdapter();
|
|
1873
|
+
if (!native) {
|
|
1874
|
+
throw new exports.IAPError({
|
|
1875
|
+
code: exports.IAPErrorCode.NOT_INITIALIZED,
|
|
1876
|
+
message: "Local verification needs the native adapter; call iap.initialize() first."
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
return native.getOwnedTransactions();
|
|
1880
|
+
}
|
|
1881
|
+
unsupported(method) {
|
|
1882
|
+
return new exports.IAPError({
|
|
1883
|
+
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
1884
|
+
message: `${method}() cannot verify locally: it receives only the extracted request fields, and an on-device check needs the binding, the subscription state and the expiry. Call verifyNative(transaction) instead \u2014 which is what iap's own router does.`
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
};
|
|
1888
|
+
function outlasts(a, b) {
|
|
1889
|
+
if (a === null) return true;
|
|
1890
|
+
if (b === null) return false;
|
|
1891
|
+
return Date.parse(a) > Date.parse(b);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1076
1894
|
// src/adapters/backend/index.ts
|
|
1077
1895
|
function selectBackendAdapter(options) {
|
|
1078
|
-
const { config, logger, fetch: fetchImpl } = options;
|
|
1896
|
+
const { config: iapConfig, logger, fetch: fetchImpl } = options;
|
|
1897
|
+
if (iapConfig.verification) {
|
|
1898
|
+
const verification = iapConfig.verification;
|
|
1899
|
+
return new LocalVerificationAdapter({
|
|
1900
|
+
getNativeAdapter: options.getNativeAdapter ?? (() => null),
|
|
1901
|
+
getProducts: options.getProducts ?? (() => iapConfig.products ?? []),
|
|
1902
|
+
entitlements: verification.entitlements,
|
|
1903
|
+
// Passed only when the app actually binds purchases: the adapter
|
|
1904
|
+
// reads "no resolver" as "no binding required", and a resolver
|
|
1905
|
+
// returning null as "required, but nobody is signed in".
|
|
1906
|
+
...verification.appUserId === void 0 ? {} : { getAppUserId: resolveAppUserId(verification.appUserId) },
|
|
1907
|
+
getCurrentEntitlements: () => options.getCurrentEntitlements?.() ?? [],
|
|
1908
|
+
graceMs: iapConfig.options.entitlementGraceMs,
|
|
1909
|
+
logger
|
|
1910
|
+
});
|
|
1911
|
+
}
|
|
1912
|
+
const config = iapConfig.backend;
|
|
1913
|
+
if (!config) {
|
|
1914
|
+
throw new exports.IAPError({
|
|
1915
|
+
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
1916
|
+
message: "Neither backend nor verification is configured; the schema should have caught this. This is a library bug."
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1079
1919
|
if (config.adapter !== void 0) {
|
|
1080
1920
|
if (!isBackendAdapter(config.adapter)) {
|
|
1081
1921
|
throw new exports.IAPError({
|
|
@@ -1103,49 +1943,8 @@ function selectBackendAdapter(options) {
|
|
|
1103
1943
|
...fetchImpl ? { fetch: fetchImpl } : {}
|
|
1104
1944
|
});
|
|
1105
1945
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
init_platform();
|
|
1109
|
-
|
|
1110
|
-
// src/adapters/native/web/web-stub.ts
|
|
1111
|
-
init_errors();
|
|
1112
|
-
var WebStubAdapter = class {
|
|
1113
|
-
async isAvailable() {
|
|
1114
|
-
return false;
|
|
1115
|
-
}
|
|
1116
|
-
async getProducts(_requests) {
|
|
1117
|
-
return [];
|
|
1118
|
-
}
|
|
1119
|
-
async purchaseProduct(_opts) {
|
|
1120
|
-
throw new exports.IAPError({
|
|
1121
|
-
code: exports.IAPErrorCode.PLATFORM_NOT_SUPPORTED,
|
|
1122
|
-
message: "In-app purchases are not supported on the web platform."
|
|
1123
|
-
});
|
|
1124
|
-
}
|
|
1125
|
-
async getOwnedTransactions() {
|
|
1126
|
-
return [];
|
|
1127
|
-
}
|
|
1128
|
-
async acknowledge(_transaction) {
|
|
1129
|
-
}
|
|
1130
|
-
async manageSubscriptions() {
|
|
1131
|
-
throw new exports.IAPError({
|
|
1132
|
-
code: exports.IAPErrorCode.PLATFORM_NOT_SUPPORTED,
|
|
1133
|
-
message: "Subscription management is not supported on the web platform."
|
|
1134
|
-
});
|
|
1135
|
-
}
|
|
1136
|
-
async getStorefront() {
|
|
1137
|
-
return null;
|
|
1138
|
-
}
|
|
1139
|
-
};
|
|
1140
|
-
|
|
1141
|
-
// src/adapters/native/index.ts
|
|
1142
|
-
async function selectNativeAdapter() {
|
|
1143
|
-
const platform = getPlatform();
|
|
1144
|
-
if (platform === "ios" || platform === "android") {
|
|
1145
|
-
const mod = await Promise.resolve().then(() => (init_native_adapter(), native_adapter_exports));
|
|
1146
|
-
return new mod.CapgoNativeAdapter();
|
|
1147
|
-
}
|
|
1148
|
-
return new WebStubAdapter();
|
|
1946
|
+
function resolveAppUserId(appUserId) {
|
|
1947
|
+
return typeof appUserId === "function" ? appUserId : () => appUserId;
|
|
1149
1948
|
}
|
|
1150
1949
|
|
|
1151
1950
|
// src/adapters/storage/index.ts
|
|
@@ -1179,39 +1978,122 @@ init_errors();
|
|
|
1179
1978
|
var PreferencesAdapter = class {
|
|
1180
1979
|
prefix;
|
|
1181
1980
|
knownKeys = /* @__PURE__ */ new Set();
|
|
1981
|
+
/**
|
|
1982
|
+
* Boxed, and that is not incidental — see {@link PreferencesAdapter.preferences}.
|
|
1983
|
+
*/
|
|
1984
|
+
plugin = null;
|
|
1182
1985
|
constructor(namespace) {
|
|
1183
1986
|
this.prefix = `${namespace}.`;
|
|
1184
1987
|
}
|
|
1988
|
+
/**
|
|
1989
|
+
* The plugin, inside a box.
|
|
1990
|
+
*
|
|
1991
|
+
* The box is load-bearing. `registerPlugin()` hands back a **Proxy** that
|
|
1992
|
+
* answers any property, `then` included — so resolving a promise *with*
|
|
1993
|
+
* the plugin makes it look like a thenable, and promise assimilation
|
|
1994
|
+
* calls `Preferences.then()`. On web that rejects with
|
|
1995
|
+
* `"Preferences.then()" is not implemented on web`, from inside
|
|
1996
|
+
* `await this.preferences()`, for every storage operation.
|
|
1997
|
+
*
|
|
1998
|
+
* Wrapping it in a plain object means the promise resolves with something
|
|
1999
|
+
* that has no `then`, so nothing is assimilated.
|
|
2000
|
+
*/
|
|
2001
|
+
preferences() {
|
|
2002
|
+
this.plugin ??= import('@capacitor/preferences').then((mod) => ({ preferences: mod.Preferences })).catch((cause) => {
|
|
2003
|
+
if (isModuleNotFound(cause)) throw notInstalled(cause);
|
|
2004
|
+
this.plugin = null;
|
|
2005
|
+
throw new exports.IAPError({
|
|
2006
|
+
code: exports.IAPErrorCode.STORAGE_ERROR,
|
|
2007
|
+
message: "Could not load @capacitor/preferences. The package is installed, so this is most likely a chunk that failed to load; it is worth retrying.",
|
|
2008
|
+
cause,
|
|
2009
|
+
recoverable: true
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
return this.plugin;
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* Resolve the plugin now, so a missing one is reported at `initialize()`.
|
|
2016
|
+
*
|
|
2017
|
+
* See the note on lazy loading above for why this is not left to the first
|
|
2018
|
+
* `get()`.
|
|
2019
|
+
*/
|
|
2020
|
+
async ensureAvailable() {
|
|
2021
|
+
await this.preferences();
|
|
2022
|
+
}
|
|
1185
2023
|
async get(key) {
|
|
2024
|
+
const { preferences } = await this.preferences();
|
|
1186
2025
|
try {
|
|
1187
|
-
const result = await preferences.
|
|
2026
|
+
const result = await preferences.get({ key: this.prefix + key });
|
|
1188
2027
|
return result.value;
|
|
1189
2028
|
} catch (cause) {
|
|
1190
2029
|
throw wrap(cause, `Preferences.get failed for "${key}".`);
|
|
1191
2030
|
}
|
|
1192
2031
|
}
|
|
1193
2032
|
async set(key, value) {
|
|
2033
|
+
const { preferences } = await this.preferences();
|
|
1194
2034
|
try {
|
|
1195
|
-
await preferences.
|
|
2035
|
+
await preferences.set({ key: this.prefix + key, value });
|
|
1196
2036
|
this.knownKeys.add(key);
|
|
1197
2037
|
} catch (cause) {
|
|
1198
2038
|
throw wrap(cause, `Preferences.set failed for "${key}".`);
|
|
1199
2039
|
}
|
|
1200
2040
|
}
|
|
1201
2041
|
async remove(key) {
|
|
2042
|
+
const { preferences } = await this.preferences();
|
|
1202
2043
|
try {
|
|
1203
|
-
await preferences.
|
|
2044
|
+
await preferences.remove({ key: this.prefix + key });
|
|
1204
2045
|
this.knownKeys.delete(key);
|
|
1205
2046
|
} catch (cause) {
|
|
1206
2047
|
throw wrap(cause, `Preferences.remove failed for "${key}".`);
|
|
1207
2048
|
}
|
|
1208
2049
|
}
|
|
1209
2050
|
async clear() {
|
|
2051
|
+
const { preferences } = await this.preferences();
|
|
1210
2052
|
const keys = [...this.knownKeys];
|
|
1211
|
-
|
|
1212
|
-
|
|
2053
|
+
const failed = [];
|
|
2054
|
+
const errors = [];
|
|
2055
|
+
await Promise.all(
|
|
2056
|
+
keys.map(async (key) => {
|
|
2057
|
+
try {
|
|
2058
|
+
await preferences.remove({ key: this.prefix + key });
|
|
2059
|
+
this.knownKeys.delete(key);
|
|
2060
|
+
} catch (cause) {
|
|
2061
|
+
failed.push(key);
|
|
2062
|
+
errors.push(cause);
|
|
2063
|
+
}
|
|
2064
|
+
})
|
|
2065
|
+
);
|
|
2066
|
+
if (failed.length > 0) {
|
|
2067
|
+
throw wrap(errors[0], `Preferences.clear failed for ${failed.length} key(s).`);
|
|
2068
|
+
}
|
|
1213
2069
|
}
|
|
1214
2070
|
};
|
|
2071
|
+
function isModuleNotFound(cause) {
|
|
2072
|
+
let current = cause;
|
|
2073
|
+
for (let depth = 0; depth < 5 && current; depth += 1) {
|
|
2074
|
+
const code = current.code;
|
|
2075
|
+
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") return true;
|
|
2076
|
+
const message = current instanceof Error ? current.message : String(current);
|
|
2077
|
+
if (/cannot find (module|package)|could not resolve|failed to resolve/i.test(message)) {
|
|
2078
|
+
return true;
|
|
2079
|
+
}
|
|
2080
|
+
const next = current.cause;
|
|
2081
|
+
if (next === current) break;
|
|
2082
|
+
current = next;
|
|
2083
|
+
}
|
|
2084
|
+
return false;
|
|
2085
|
+
}
|
|
2086
|
+
function notInstalled(cause) {
|
|
2087
|
+
return new exports.IAPError({
|
|
2088
|
+
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
2089
|
+
message: 'storage.type is "preferences" (the default) but @capacitor/preferences is not installed. Install it (npm i @capacitor/preferences && npx cap sync), or choose storage that needs no plugin: storage.type "memory", or "custom" with your own adapter.',
|
|
2090
|
+
// The per-code hint for INVALID_CONFIG sends the reader to check field
|
|
2091
|
+
// paths against the config schema. There are none to check, and no
|
|
2092
|
+
// config change fixes this — the message above already says what to do.
|
|
2093
|
+
includeHint: false,
|
|
2094
|
+
cause
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
1215
2097
|
function wrap(cause, message) {
|
|
1216
2098
|
return new exports.IAPError({
|
|
1217
2099
|
code: exports.IAPErrorCode.STORAGE_ERROR,
|
|
@@ -1243,51 +2125,28 @@ function isStorageAdapter(value) {
|
|
|
1243
2125
|
return typeof candidate.get === "function" && typeof candidate.set === "function" && typeof candidate.remove === "function" && typeof candidate.clear === "function";
|
|
1244
2126
|
}
|
|
1245
2127
|
|
|
1246
|
-
// src/core/
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
"Failed to attach App appStateChange listener; resume refresh disabled.",
|
|
1269
|
-
error
|
|
1270
|
-
);
|
|
1271
|
-
return null;
|
|
1272
|
-
}
|
|
1273
|
-
return {
|
|
1274
|
-
async remove() {
|
|
1275
|
-
try {
|
|
1276
|
-
await handle.remove();
|
|
1277
|
-
} catch (error) {
|
|
1278
|
-
opts.logger.warn("Failed to remove App appStateChange listener.", error);
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
};
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
// src/core/entitlement-cache.ts
|
|
1285
|
-
init_errors();
|
|
1286
|
-
var ENTITLEMENTS_KEY = "entitlements";
|
|
1287
|
-
var EntitlementCache = class {
|
|
1288
|
-
constructor(storage, logger) {
|
|
1289
|
-
this.storage = storage;
|
|
1290
|
-
this.logger = logger;
|
|
2128
|
+
// src/core/entitlement-cache.ts
|
|
2129
|
+
init_errors();
|
|
2130
|
+
|
|
2131
|
+
// src/core/storage-keys.ts
|
|
2132
|
+
var IAP_STORAGE_KEYS = {
|
|
2133
|
+
/** The entitlement cache envelope. See `EntitlementCache`. */
|
|
2134
|
+
entitlements: "entitlements",
|
|
2135
|
+
/** Purchases awaiting backend confirmation. See `UnfinishedTransactionsStore`. */
|
|
2136
|
+
unfinishedTransactions: "unfinished_transactions",
|
|
2137
|
+
/** Products reported as awaiting clearance. See `PendingPurchasesStore`. */
|
|
2138
|
+
pendingPurchases: "pending_purchases"
|
|
2139
|
+
};
|
|
2140
|
+
var ALL_IAP_STORAGE_KEYS = Object.freeze(
|
|
2141
|
+
Object.values(IAP_STORAGE_KEYS)
|
|
2142
|
+
);
|
|
2143
|
+
|
|
2144
|
+
// src/core/entitlement-cache.ts
|
|
2145
|
+
var ENTITLEMENTS_KEY = IAP_STORAGE_KEYS.entitlements;
|
|
2146
|
+
var EntitlementCache = class {
|
|
2147
|
+
constructor(storage, logger) {
|
|
2148
|
+
this.storage = storage;
|
|
2149
|
+
this.logger = logger;
|
|
1291
2150
|
}
|
|
1292
2151
|
storage;
|
|
1293
2152
|
logger;
|
|
@@ -1335,12 +2194,7 @@ var EntitlementCache = class {
|
|
|
1335
2194
|
try {
|
|
1336
2195
|
await this.storage.set(ENTITLEMENTS_KEY, JSON.stringify(envelope));
|
|
1337
2196
|
} catch (cause) {
|
|
1338
|
-
throw
|
|
1339
|
-
code: exports.IAPErrorCode.STORAGE_ERROR,
|
|
1340
|
-
message: "Failed to persist entitlement cache.",
|
|
1341
|
-
cause,
|
|
1342
|
-
recoverable: true
|
|
1343
|
-
});
|
|
2197
|
+
throw toIAPError(cause, "Failed to persist entitlement cache.", exports.IAPErrorCode.STORAGE_ERROR);
|
|
1344
2198
|
}
|
|
1345
2199
|
return cachedAt;
|
|
1346
2200
|
}
|
|
@@ -1355,6 +2209,9 @@ var EntitlementCache = class {
|
|
|
1355
2209
|
}
|
|
1356
2210
|
}
|
|
1357
2211
|
};
|
|
2212
|
+
function activeEntitlements(entitlements, now, graceMs = 0) {
|
|
2213
|
+
return entitlements.filter((entitlement) => !isExpired(entitlement.expiresAt, now, graceMs));
|
|
2214
|
+
}
|
|
1358
2215
|
function entitlementsEqual(a, b) {
|
|
1359
2216
|
if (a === b) return true;
|
|
1360
2217
|
if (a.length !== b.length) return false;
|
|
@@ -1369,18 +2226,195 @@ function entitlementsEqual(a, b) {
|
|
|
1369
2226
|
return true;
|
|
1370
2227
|
}
|
|
1371
2228
|
|
|
2229
|
+
// src/core/iap-state.ts
|
|
2230
|
+
init_errors();
|
|
2231
|
+
function freezeAll(items) {
|
|
2232
|
+
return items.map((item) => Object.freeze({ ...item }));
|
|
2233
|
+
}
|
|
2234
|
+
function ensureUniqueProductIds(products) {
|
|
2235
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2236
|
+
for (const product of products) {
|
|
2237
|
+
if (seen.has(product.id)) {
|
|
2238
|
+
throw new exports.IAPError({
|
|
2239
|
+
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
2240
|
+
message: `Duplicate product id "${product.id}" in product manifest.`
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
seen.add(product.id);
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
function requireInitialized(state) {
|
|
2247
|
+
if (!state.initialized) {
|
|
2248
|
+
throw new exports.IAPError({
|
|
2249
|
+
code: exports.IAPErrorCode.NOT_INITIALIZED,
|
|
2250
|
+
message: "Call iap.initialize() before this method."
|
|
2251
|
+
});
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// src/adapters/native/index.ts
|
|
2256
|
+
init_platform();
|
|
2257
|
+
|
|
2258
|
+
// src/adapters/native/web/web-stub.ts
|
|
2259
|
+
init_errors();
|
|
2260
|
+
var WebStubAdapter = class {
|
|
2261
|
+
async isAvailable() {
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
async getProducts(_requests) {
|
|
2265
|
+
return [];
|
|
2266
|
+
}
|
|
2267
|
+
async purchaseProduct(_opts) {
|
|
2268
|
+
throw new exports.IAPError({
|
|
2269
|
+
code: exports.IAPErrorCode.PLATFORM_NOT_SUPPORTED,
|
|
2270
|
+
message: "In-app purchases are not supported on the web platform."
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
async getOwnedTransactions() {
|
|
2274
|
+
return [];
|
|
2275
|
+
}
|
|
2276
|
+
async acknowledge(_transaction) {
|
|
2277
|
+
}
|
|
2278
|
+
async manageSubscriptions() {
|
|
2279
|
+
throw new exports.IAPError({
|
|
2280
|
+
code: exports.IAPErrorCode.PLATFORM_NOT_SUPPORTED,
|
|
2281
|
+
message: "Subscription management is not supported on the web platform."
|
|
2282
|
+
});
|
|
2283
|
+
}
|
|
2284
|
+
async getStorefront() {
|
|
2285
|
+
return null;
|
|
2286
|
+
}
|
|
2287
|
+
};
|
|
2288
|
+
|
|
2289
|
+
// src/adapters/native/index.ts
|
|
2290
|
+
async function selectNativeAdapter(logger, packageName, getConfiguredProducts) {
|
|
2291
|
+
const platform = getPlatform();
|
|
2292
|
+
if (platform === "ios" || platform === "android") {
|
|
2293
|
+
const mod = await Promise.resolve().then(() => (init_native_adapter(), native_adapter_exports));
|
|
2294
|
+
return new mod.CapgoNativeAdapter(logger, packageName, getConfiguredProducts);
|
|
2295
|
+
}
|
|
2296
|
+
return new WebStubAdapter();
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// src/core/initialize.ts
|
|
2300
|
+
init_errors();
|
|
2301
|
+
init_platform();
|
|
2302
|
+
|
|
2303
|
+
// src/core/app-resume-listener.ts
|
|
2304
|
+
async function attachAppResumeListener(opts) {
|
|
2305
|
+
let mod;
|
|
2306
|
+
try {
|
|
2307
|
+
mod = await import('@capacitor/app');
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
opts.logger.warn(
|
|
2310
|
+
"refreshOnResume requested but @capacitor/app is not installed; resume listener disabled.",
|
|
2311
|
+
error
|
|
2312
|
+
);
|
|
2313
|
+
return null;
|
|
2314
|
+
}
|
|
2315
|
+
let handle;
|
|
2316
|
+
try {
|
|
2317
|
+
handle = await mod.App.addListener("appStateChange", ({ isActive }) => {
|
|
2318
|
+
if (!isActive) return;
|
|
2319
|
+
void Promise.resolve(opts.onResume()).catch((error) => {
|
|
2320
|
+
opts.logger.warn("refreshOnResume handler threw.", error);
|
|
2321
|
+
});
|
|
2322
|
+
});
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
opts.logger.warn(
|
|
2325
|
+
"Failed to attach App appStateChange listener; resume refresh disabled.",
|
|
2326
|
+
error
|
|
2327
|
+
);
|
|
2328
|
+
return null;
|
|
2329
|
+
}
|
|
2330
|
+
return {
|
|
2331
|
+
async remove() {
|
|
2332
|
+
try {
|
|
2333
|
+
await handle.remove();
|
|
2334
|
+
} catch (error) {
|
|
2335
|
+
opts.logger.warn("Failed to remove App appStateChange listener.", error);
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
};
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// src/core/attach-transaction-watch.ts
|
|
2342
|
+
async function attachTransactionWatch(opts) {
|
|
2343
|
+
const { adapter, updates, logger } = opts;
|
|
2344
|
+
let handle;
|
|
2345
|
+
try {
|
|
2346
|
+
handle = await adapter.watchTransactions?.((transaction) => {
|
|
2347
|
+
void updates.handle(transaction);
|
|
2348
|
+
}) ?? null;
|
|
2349
|
+
} catch (error) {
|
|
2350
|
+
logger.warn(
|
|
2351
|
+
"Failed to subscribe to native transaction updates; renewals and refunds will be picked up at the next launch or resume instead.",
|
|
2352
|
+
error
|
|
2353
|
+
);
|
|
2354
|
+
return null;
|
|
2355
|
+
}
|
|
2356
|
+
if (handle === null) {
|
|
2357
|
+
logger.debug(
|
|
2358
|
+
"No native transaction feed on this platform; relying on recovery at launch and refresh on resume."
|
|
2359
|
+
);
|
|
2360
|
+
}
|
|
2361
|
+
return handle;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
1372
2364
|
// src/core/purchase-flow.ts
|
|
1373
2365
|
init_errors();
|
|
1374
2366
|
|
|
1375
|
-
// src/
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
2367
|
+
// src/core/finalize-transaction.ts
|
|
2368
|
+
async function finalizeTransaction(opts) {
|
|
2369
|
+
const { nativeAdapter, transaction, logger } = opts;
|
|
2370
|
+
if (transaction.platform !== "google" || transaction.productType !== "consumable") {
|
|
2371
|
+
await nativeAdapter.acknowledge(transaction);
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
if (!nativeAdapter.consume) {
|
|
2375
|
+
logger.warn(
|
|
2376
|
+
`The native adapter cannot consume, so "${transaction.productId}" was only acknowledged. Google will keep reporting it owned, and buying it again will fail with ALREADY_PURCHASED.`
|
|
2377
|
+
);
|
|
2378
|
+
await nativeAdapter.acknowledge(transaction);
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
try {
|
|
2382
|
+
await nativeAdapter.consume(transaction);
|
|
2383
|
+
} catch (error) {
|
|
2384
|
+
try {
|
|
2385
|
+
await nativeAdapter.acknowledge(transaction);
|
|
2386
|
+
logger.debug(
|
|
2387
|
+
`Consuming "${transaction.productId}" failed; acknowledged it instead so Google does not auto-refund, and the consume will be retried.`
|
|
2388
|
+
);
|
|
2389
|
+
} catch {
|
|
2390
|
+
}
|
|
2391
|
+
throw error;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// src/core/resolve-pending.ts
|
|
2396
|
+
async function resolvePendingPurchase(deps, productId, status) {
|
|
2397
|
+
if (!await deps.pending.takeIfPresent(productId)) return;
|
|
2398
|
+
deps.emitter.emit("purchase-pending-resolved", { productId, status });
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
// src/core/store-purchase.ts
|
|
2402
|
+
function toStorePurchase(tx) {
|
|
2403
|
+
return {
|
|
2404
|
+
platform: tx.platform,
|
|
2405
|
+
productId: tx.productId,
|
|
2406
|
+
purchaseReference: tx.purchaseReference,
|
|
2407
|
+
appUserId: tx.appUserId ?? null,
|
|
2408
|
+
subscription: tx.subscription ?? null
|
|
2409
|
+
};
|
|
1379
2410
|
}
|
|
1380
2411
|
|
|
1381
2412
|
// src/core/verify-helpers.ts
|
|
1382
2413
|
init_errors();
|
|
1383
2414
|
async function verifyNativeTransaction(backend, tx) {
|
|
2415
|
+
if (typeof backend.verifyNative === "function") {
|
|
2416
|
+
return backend.verifyNative(tx);
|
|
2417
|
+
}
|
|
1384
2418
|
if (tx.platform === "apple") {
|
|
1385
2419
|
return backend.verifyApple({
|
|
1386
2420
|
productId: tx.productId,
|
|
@@ -1391,7 +2425,7 @@ async function verifyNativeTransaction(backend, tx) {
|
|
|
1391
2425
|
if (!tx.packageName) {
|
|
1392
2426
|
throw new exports.IAPError({
|
|
1393
2427
|
code: exports.IAPErrorCode.STORE_ERROR,
|
|
1394
|
-
message: `Google transaction for "${tx.productId}" has no packageName; cannot verify.`
|
|
2428
|
+
message: `Google transaction for "${tx.productId}" has no packageName; cannot verify. Set config.android.packageName to your app's applicationId \u2014 the native plugin does not report it, so iap cannot derive it from the purchase.`
|
|
1395
2429
|
});
|
|
1396
2430
|
}
|
|
1397
2431
|
return backend.verifyGoogle({
|
|
@@ -1409,6 +2443,35 @@ var PurchaseOrchestrator = class {
|
|
|
1409
2443
|
}
|
|
1410
2444
|
deps;
|
|
1411
2445
|
inFlight = /* @__PURE__ */ new Set();
|
|
2446
|
+
/**
|
|
2447
|
+
* Whether this orchestrator is currently handling `productId`.
|
|
2448
|
+
*
|
|
2449
|
+
* Read by the transaction-update orchestrator: StoreKit delivers the
|
|
2450
|
+
* transaction a purchase produced to `Transaction.updates` as well, and
|
|
2451
|
+
* whichever path started it owns settling it.
|
|
2452
|
+
*/
|
|
2453
|
+
isInFlight(productId) {
|
|
2454
|
+
return this.inFlight.has(productId);
|
|
2455
|
+
}
|
|
2456
|
+
/**
|
|
2457
|
+
* The products currently being purchased.
|
|
2458
|
+
*
|
|
2459
|
+
* Read by `reset()`, which refuses to run while one is in flight. The
|
|
2460
|
+
* reason is the unfinished-transaction entry, not the cache: that entry is
|
|
2461
|
+
* written *before* verification precisely so a purchase survives a crash,
|
|
2462
|
+
* and deleting it mid-flight leaves a purchase that is neither
|
|
2463
|
+
* acknowledged nor tracked — which Play auto-refunds in three days. The
|
|
2464
|
+
* cache coming back is harmless by comparison, since `reset()` is not a
|
|
2465
|
+
* revocation.
|
|
2466
|
+
*
|
|
2467
|
+
* Marked from the moment the purchase is committed to running, before any
|
|
2468
|
+
* consumer-supplied `appUserId` fetcher is awaited — that await is a
|
|
2469
|
+
* network round-trip, and a window in which this said "nothing in flight"
|
|
2470
|
+
* while a purchase was already under way.
|
|
2471
|
+
*/
|
|
2472
|
+
inFlightProducts() {
|
|
2473
|
+
return [...this.inFlight];
|
|
2474
|
+
}
|
|
1412
2475
|
async purchase(opts) {
|
|
1413
2476
|
const { productId, appUserId } = opts;
|
|
1414
2477
|
if (this.inFlight.has(productId)) {
|
|
@@ -1424,14 +2487,14 @@ var PurchaseOrchestrator = class {
|
|
|
1424
2487
|
message: `Product "${productId}" is not in the configured catalog.`
|
|
1425
2488
|
});
|
|
1426
2489
|
}
|
|
1427
|
-
let resolvedAppUserId;
|
|
1428
|
-
if (appUserId !== void 0) {
|
|
1429
|
-
const ctx = typeof appUserId === "function" ? { authHeaders: await this.deps.getAuthHeaders() } : { authHeaders: {} };
|
|
1430
|
-
resolvedAppUserId = await resolveAppUserId(appUserId, ctx);
|
|
1431
|
-
}
|
|
1432
2490
|
this.inFlight.add(productId);
|
|
1433
|
-
this.deps.emitter.emit("purchase-started", { productId });
|
|
1434
2491
|
try {
|
|
2492
|
+
let resolvedAppUserId;
|
|
2493
|
+
if (appUserId !== void 0) {
|
|
2494
|
+
const ctx = typeof appUserId === "function" ? { authHeaders: await this.deps.getAuthHeaders() } : { authHeaders: {} };
|
|
2495
|
+
resolvedAppUserId = await resolveAppUserId2(appUserId, ctx);
|
|
2496
|
+
}
|
|
2497
|
+
this.deps.emitter.emit("purchase-started", { productId });
|
|
1435
2498
|
return await this.runFlow(product, resolvedAppUserId);
|
|
1436
2499
|
} finally {
|
|
1437
2500
|
this.inFlight.delete(productId);
|
|
@@ -1473,10 +2536,15 @@ var PurchaseOrchestrator = class {
|
|
|
1473
2536
|
const { nativeAdapter, cache, unfinished, emitter, logger } = this.deps;
|
|
1474
2537
|
const transaction = response.transaction;
|
|
1475
2538
|
const entitlements = response.entitlements;
|
|
2539
|
+
let acknowledged = true;
|
|
1476
2540
|
try {
|
|
1477
|
-
await nativeAdapter
|
|
2541
|
+
await finalizeTransaction({ nativeAdapter, transaction: nativeTx, logger });
|
|
1478
2542
|
} catch (error) {
|
|
1479
|
-
|
|
2543
|
+
acknowledged = false;
|
|
2544
|
+
logger.warn(
|
|
2545
|
+
`Settling "${nativeTx.productId}" with the store failed; the entitlement is granted, and the transaction is retained for recovery to retry on the next launch.`,
|
|
2546
|
+
error
|
|
2547
|
+
);
|
|
1480
2548
|
}
|
|
1481
2549
|
const previous = this.deps.getCurrentEntitlements();
|
|
1482
2550
|
try {
|
|
@@ -1489,14 +2557,17 @@ var PurchaseOrchestrator = class {
|
|
|
1489
2557
|
);
|
|
1490
2558
|
}
|
|
1491
2559
|
this.deps.setEntitlements(entitlements);
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
2560
|
+
if (acknowledged) {
|
|
2561
|
+
try {
|
|
2562
|
+
await unfinished.remove(nativeTx.token);
|
|
2563
|
+
} catch (error) {
|
|
2564
|
+
logger.warn(
|
|
2565
|
+
`Failed to remove "${productId}" from unfinished list; will be skipped on next recovery.`,
|
|
2566
|
+
error
|
|
2567
|
+
);
|
|
2568
|
+
}
|
|
1499
2569
|
}
|
|
2570
|
+
await resolvePendingPurchase(this.deps, productId, "purchased");
|
|
1500
2571
|
emitter.emit("purchase-success", { productId, transaction });
|
|
1501
2572
|
emitter.emit("entitlements-changed", {
|
|
1502
2573
|
entitlements: this.deps.getCurrentEntitlements(),
|
|
@@ -1506,6 +2577,7 @@ var PurchaseOrchestrator = class {
|
|
|
1506
2577
|
status: "success",
|
|
1507
2578
|
productId,
|
|
1508
2579
|
transaction,
|
|
2580
|
+
purchase: toStorePurchase(nativeTx),
|
|
1509
2581
|
entitlements: this.deps.getCurrentEntitlements()
|
|
1510
2582
|
};
|
|
1511
2583
|
}
|
|
@@ -1518,7 +2590,7 @@ var PurchaseOrchestrator = class {
|
|
|
1518
2590
|
this.deps.emitter.emit("verification-failed", { productId, error });
|
|
1519
2591
|
return { status: "verification_failed", productId, error };
|
|
1520
2592
|
}
|
|
1521
|
-
handleNativeError(productId, error) {
|
|
2593
|
+
async handleNativeError(productId, error) {
|
|
1522
2594
|
const iapError = toIAPError(
|
|
1523
2595
|
error,
|
|
1524
2596
|
`Native purchase of "${productId}" failed.`,
|
|
@@ -1529,6 +2601,7 @@ var PurchaseOrchestrator = class {
|
|
|
1529
2601
|
return { status: "cancelled", productId };
|
|
1530
2602
|
}
|
|
1531
2603
|
if (iapError.code === exports.IAPErrorCode.PURCHASE_PENDING) {
|
|
2604
|
+
await this.deps.pending.add(productId);
|
|
1532
2605
|
this.deps.emitter.emit("purchase-pending", { productId });
|
|
1533
2606
|
return { status: "pending", productId };
|
|
1534
2607
|
}
|
|
@@ -1545,7 +2618,7 @@ var PurchaseOrchestrator = class {
|
|
|
1545
2618
|
return { status: "verification_failed", productId, error: iapError };
|
|
1546
2619
|
}
|
|
1547
2620
|
};
|
|
1548
|
-
async function
|
|
2621
|
+
async function resolveAppUserId2(supply, ctx) {
|
|
1549
2622
|
let resolved;
|
|
1550
2623
|
if (typeof supply === "function") {
|
|
1551
2624
|
try {
|
|
@@ -1560,10 +2633,10 @@ async function resolveAppUserId(supply, ctx) {
|
|
|
1560
2633
|
} else {
|
|
1561
2634
|
resolved = supply;
|
|
1562
2635
|
}
|
|
1563
|
-
if (
|
|
2636
|
+
if (!isValidUuid(resolved)) {
|
|
1564
2637
|
throw new exports.IAPError({
|
|
1565
2638
|
code: exports.IAPErrorCode.INVALID_APP_USER_ID,
|
|
1566
|
-
message: `appUserId must be
|
|
2639
|
+
message: `appUserId must be an RFC 9562 UUID (any version); received ${typeof resolved === "string" ? `"${resolved}"` : typeof resolved}.`
|
|
1567
2640
|
});
|
|
1568
2641
|
}
|
|
1569
2642
|
return resolved;
|
|
@@ -1620,67 +2693,148 @@ var RecoveryOrchestrator = class {
|
|
|
1620
2693
|
);
|
|
1621
2694
|
return { recovered, failures, droppedPermanent, inspected: entries.length };
|
|
1622
2695
|
}
|
|
2696
|
+
/**
|
|
2697
|
+
* Verify, acknowledge and settle one transaction that arrived outside a
|
|
2698
|
+
* `purchase()` call — a renewal, a refund, an Ask-to-Buy approval, a
|
|
2699
|
+
* pending Play purchase that cleared.
|
|
2700
|
+
*
|
|
2701
|
+
* Routed through the same path recovery uses, deliberately: persisted
|
|
2702
|
+
* before verification so app death cannot lose it, and left in the store
|
|
2703
|
+
* to retry if anything fails. A second settlement path would be a second
|
|
2704
|
+
* place for those to drift.
|
|
2705
|
+
*
|
|
2706
|
+
* **One invariant does not carry over, and it is worth being exact about.**
|
|
2707
|
+
* On the iOS update feed the native plugin calls `transaction.finish()`
|
|
2708
|
+
* *before* it notifies listeners, so by the time iap sees the transaction
|
|
2709
|
+
* the platform has already finished it. "Acknowledged only after the
|
|
2710
|
+
* backend confirms" therefore describes the purchase flow, not this path —
|
|
2711
|
+
* iap has no say in the ordering here. The consequence is that persisting
|
|
2712
|
+
* first matters *more*, not less: StoreKit will not redeliver a finished
|
|
2713
|
+
* transaction, so the `unfinished` entry is the only remaining handle on
|
|
2714
|
+
* it. Callers on that path pass `alreadyFinished`.
|
|
2715
|
+
*/
|
|
2716
|
+
/**
|
|
2717
|
+
* Whether an Android consumable's purchase has left the store's owned set,
|
|
2718
|
+
* which makes a failed consume terminal rather than retryable.
|
|
2719
|
+
*
|
|
2720
|
+
* The sequence this exists for: the consume succeeds on Play, then the
|
|
2721
|
+
* bridge reply or the `unfinished.remove` that follows fails. Play has
|
|
2722
|
+
* already dropped the purchase — Billing 8.x stops returning consumed
|
|
2723
|
+
* purchases from `getPurchases()` — so every later consume of that token
|
|
2724
|
+
* rejects, the entry is retained, and recovery retries it at every launch
|
|
2725
|
+
* forever, burning a `recoveryMaxBatch` slot and a verify each time. The
|
|
2726
|
+
* unfinished store has no age-based eviction to catch that.
|
|
2727
|
+
*
|
|
2728
|
+
* Acknowledging had no such trap: the purchase stayed owned, so a retry
|
|
2729
|
+
* eventually worked. So this asks the store a positive question — is the
|
|
2730
|
+
* purchase still there — rather than trying to read a meaning out of the
|
|
2731
|
+
* error, which would mean matching on Play's message text.
|
|
2732
|
+
*
|
|
2733
|
+
* Only for Google consumables, only on the failure path, and `false` on
|
|
2734
|
+
* any doubt: a store that cannot answer must not be taken to have said
|
|
2735
|
+
* "gone", or a genuinely retryable failure would be dropped.
|
|
2736
|
+
*/
|
|
2737
|
+
async alreadyGone(tx) {
|
|
2738
|
+
if (tx.platform !== "google" || tx.productType !== "consumable") return false;
|
|
2739
|
+
try {
|
|
2740
|
+
const owned = await this.deps.nativeAdapter.getOwnedTransactions();
|
|
2741
|
+
return !owned.some((candidate) => candidate.token === tx.token);
|
|
2742
|
+
} catch (error) {
|
|
2743
|
+
this.deps.logger.debug(
|
|
2744
|
+
`Recovery: could not re-read the owned set for productId=${tx.productId}; treating the settle failure as retryable.`,
|
|
2745
|
+
error
|
|
2746
|
+
);
|
|
2747
|
+
return false;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
async settleTransaction(tx, opts = {}) {
|
|
2751
|
+
if (opts.persist !== false) {
|
|
2752
|
+
try {
|
|
2753
|
+
await this.deps.unfinished.add(tx);
|
|
2754
|
+
} catch (error) {
|
|
2755
|
+
this.deps.logger.warn(
|
|
2756
|
+
`Transaction update: failed to persist unfinished entry for "${tx.productId}"; verifying anyway.`,
|
|
2757
|
+
error
|
|
2758
|
+
);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
const result = await this.processTransaction(tx, opts);
|
|
2762
|
+
if (result.kind === "recovered") {
|
|
2763
|
+
await this.applyEntitlements(result.entitlements);
|
|
2764
|
+
return "settled";
|
|
2765
|
+
}
|
|
2766
|
+
return result.kind === "dropped-permanent" ? "dropped-permanent" : "failed";
|
|
2767
|
+
}
|
|
1623
2768
|
async processEntry(entry) {
|
|
2769
|
+
return this.processTransaction(entryToNativeTransaction(entry));
|
|
2770
|
+
}
|
|
2771
|
+
async processTransaction(tx, opts = {}) {
|
|
1624
2772
|
const { nativeAdapter, unfinished, logger, emitter, permanentErrorCodes } = this.deps;
|
|
1625
|
-
const
|
|
1626
|
-
const tokenLabel = maskToken(entry.token);
|
|
2773
|
+
const tokenLabel = maskToken(tx.token);
|
|
1627
2774
|
try {
|
|
1628
2775
|
const response = await verifyNativeTransaction(this.deps.backend, tx);
|
|
1629
2776
|
if (!response.valid) {
|
|
1630
2777
|
if (permanentErrorCodes.has(response.error)) {
|
|
1631
2778
|
logger.info(
|
|
1632
|
-
`Recovery: dropping permanently-invalid token=${tokenLabel} productId=${
|
|
2779
|
+
`Recovery: dropping permanently-invalid token=${tokenLabel} productId=${tx.productId} (${response.error}).`
|
|
1633
2780
|
);
|
|
1634
2781
|
try {
|
|
1635
2782
|
await nativeAdapter.acknowledge(tx);
|
|
1636
2783
|
} catch (error) {
|
|
1637
2784
|
logger.warn(
|
|
1638
|
-
`Recovery: best-effort acknowledge() failed for productId=${
|
|
2785
|
+
`Recovery: best-effort acknowledge() failed for productId=${tx.productId}; proceeding with removal anyway.`,
|
|
1639
2786
|
error
|
|
1640
2787
|
);
|
|
1641
2788
|
}
|
|
1642
2789
|
try {
|
|
1643
|
-
await unfinished.remove(
|
|
2790
|
+
await unfinished.remove(tx.token);
|
|
1644
2791
|
} catch (error) {
|
|
1645
2792
|
logger.warn(
|
|
1646
|
-
`Recovery: unfinished.remove() failed for productId=${
|
|
2793
|
+
`Recovery: unfinished.remove() failed for productId=${tx.productId} after permanent classification; will dedupe on next launch.`,
|
|
1647
2794
|
error
|
|
1648
2795
|
);
|
|
1649
2796
|
}
|
|
1650
2797
|
emitter.emit("recovery-dropped-permanent", {
|
|
1651
|
-
productId:
|
|
1652
|
-
token:
|
|
2798
|
+
productId: tx.productId,
|
|
2799
|
+
token: tx.token,
|
|
1653
2800
|
error: response.error,
|
|
1654
2801
|
...response.message !== void 0 ? { message: response.message } : {}
|
|
1655
2802
|
});
|
|
1656
2803
|
return { kind: "dropped-permanent" };
|
|
1657
2804
|
}
|
|
1658
2805
|
logger.debug(
|
|
1659
|
-
`Recovery: backend rejected token=${tokenLabel} productId=${
|
|
2806
|
+
`Recovery: backend rejected token=${tokenLabel} productId=${tx.productId} (${response.error}); leaving in list for retry.`
|
|
1660
2807
|
);
|
|
1661
2808
|
return { kind: "failed" };
|
|
1662
2809
|
}
|
|
1663
2810
|
try {
|
|
1664
|
-
await nativeAdapter
|
|
2811
|
+
await finalizeTransaction({ nativeAdapter, transaction: tx, logger });
|
|
1665
2812
|
} catch (error) {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
2813
|
+
if (opts.alreadyFinished || await this.alreadyGone(tx)) {
|
|
2814
|
+
logger.debug(
|
|
2815
|
+
`Recovery: settling productId=${tx.productId} failed, but the store no longer has the purchase; treating as settled.`
|
|
2816
|
+
);
|
|
2817
|
+
} else {
|
|
2818
|
+
logger.warn(
|
|
2819
|
+
`Recovery: settling productId=${tx.productId} with the store failed; entry retained for next launch.`,
|
|
2820
|
+
error
|
|
2821
|
+
);
|
|
2822
|
+
return { kind: "failed" };
|
|
2823
|
+
}
|
|
1671
2824
|
}
|
|
1672
2825
|
try {
|
|
1673
|
-
await unfinished.remove(
|
|
2826
|
+
await unfinished.remove(tx.token);
|
|
1674
2827
|
} catch (error) {
|
|
1675
2828
|
logger.warn(
|
|
1676
|
-
`Recovery: unfinished.remove() failed for productId=${
|
|
2829
|
+
`Recovery: unfinished.remove() failed for productId=${tx.productId}; will dedupe on next launch.`,
|
|
1677
2830
|
error
|
|
1678
2831
|
);
|
|
1679
2832
|
}
|
|
2833
|
+
await resolvePendingPurchase(this.deps, tx.productId, "purchased");
|
|
1680
2834
|
return { kind: "recovered", entitlements: response.entitlements };
|
|
1681
2835
|
} catch (error) {
|
|
1682
2836
|
logger.warn(
|
|
1683
|
-
`Recovery: verify failed for productId=${
|
|
2837
|
+
`Recovery: verify failed for productId=${tx.productId}; will retry next launch.`,
|
|
1684
2838
|
error
|
|
1685
2839
|
);
|
|
1686
2840
|
return { kind: "failed" };
|
|
@@ -1703,127 +2857,656 @@ var RecoveryOrchestrator = class {
|
|
|
1703
2857
|
previous
|
|
1704
2858
|
});
|
|
1705
2859
|
}
|
|
1706
|
-
};
|
|
1707
|
-
function entryToNativeTransaction(entry) {
|
|
1708
|
-
const tx = {
|
|
1709
|
-
platform: entry.platform,
|
|
1710
|
-
productId: entry.productId,
|
|
1711
|
-
token: entry.token,
|
|
1712
|
-
|
|
2860
|
+
};
|
|
2861
|
+
function entryToNativeTransaction(entry) {
|
|
2862
|
+
const tx = {
|
|
2863
|
+
platform: entry.platform,
|
|
2864
|
+
productId: entry.productId,
|
|
2865
|
+
token: entry.token,
|
|
2866
|
+
// Entries persisted before `purchaseReference` existed carry only the
|
|
2867
|
+
// token; on Android the two are the same value anyway, and on iOS the
|
|
2868
|
+
// token is the right fallback (see `NativeTransaction`).
|
|
2869
|
+
// `||`, not `??`: a stored empty string is as unusable as an absent one.
|
|
2870
|
+
purchaseReference: entry.purchaseReference || entry.token,
|
|
2871
|
+
productType: entry.productType
|
|
2872
|
+
};
|
|
2873
|
+
if (entry.appUserId) tx.appUserId = entry.appUserId;
|
|
2874
|
+
if (entry.expiresAt) {
|
|
2875
|
+
tx.subscription = {
|
|
2876
|
+
state: null,
|
|
2877
|
+
autoRenewing: null,
|
|
2878
|
+
renewsAt: null,
|
|
2879
|
+
renewalProductId: null,
|
|
2880
|
+
expiresAt: entry.expiresAt
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
if (entry.purchaseState) {
|
|
2884
|
+
tx.raw = { purchaseState: entry.purchaseState };
|
|
2885
|
+
}
|
|
2886
|
+
if (entry.packageName) tx.packageName = entry.packageName;
|
|
2887
|
+
return tx;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// src/core/restore-flow.ts
|
|
2891
|
+
init_errors();
|
|
2892
|
+
var RestoreOrchestrator = class {
|
|
2893
|
+
constructor(deps) {
|
|
2894
|
+
this.deps = deps;
|
|
2895
|
+
}
|
|
2896
|
+
deps;
|
|
2897
|
+
async restorePurchases() {
|
|
2898
|
+
const { nativeAdapter, backend, cache, unfinished, emitter, logger } = this.deps;
|
|
2899
|
+
emitter.emit("restore-started", void 0);
|
|
2900
|
+
let owned;
|
|
2901
|
+
try {
|
|
2902
|
+
owned = await nativeAdapter.getOwnedTransactions();
|
|
2903
|
+
} catch (cause) {
|
|
2904
|
+
throw toIAPError(cause, "Failed to fetch owned transactions.", exports.IAPErrorCode.STORE_ERROR);
|
|
2905
|
+
}
|
|
2906
|
+
if (owned.length === 0) {
|
|
2907
|
+
const entitlements2 = this.deps.getCurrentEntitlements();
|
|
2908
|
+
emitter.emit("restore-completed", { restored: 0, entitlements: entitlements2 });
|
|
2909
|
+
return { restored: 0, entitlements: entitlements2, purchases: [] };
|
|
2910
|
+
}
|
|
2911
|
+
let response;
|
|
2912
|
+
try {
|
|
2913
|
+
response = backend.restoreNative ? await backend.restoreNative(owned) : await backend.restore({ transactions: owned.map((tx) => this.toRestoreEntry(tx)) });
|
|
2914
|
+
} catch (cause) {
|
|
2915
|
+
throw toIAPError(cause, "Backend restore call failed.", exports.IAPErrorCode.BACKEND_UNAVAILABLE);
|
|
2916
|
+
}
|
|
2917
|
+
if (!response.valid) {
|
|
2918
|
+
const detail = response.message ? `${response.message} [${response.error}]` : `Backend rejected restore (${response.error}).`;
|
|
2919
|
+
throw new exports.IAPError({
|
|
2920
|
+
code: exports.IAPErrorCode.VERIFICATION_REJECTED,
|
|
2921
|
+
message: detail
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
const ackable = response.verifiedTokens === void 0 ? owned : owned.filter((tx) => response.verifiedTokens?.includes(tx.token));
|
|
2925
|
+
if (ackable.length !== owned.length) {
|
|
2926
|
+
logger.debug(
|
|
2927
|
+
`Restore: settling ${ackable.length} of ${owned.length} owned transaction(s); the rest were not verified.`
|
|
2928
|
+
);
|
|
2929
|
+
}
|
|
2930
|
+
const settled = [];
|
|
2931
|
+
for (const tx of ackable) {
|
|
2932
|
+
try {
|
|
2933
|
+
await finalizeTransaction({ nativeAdapter, transaction: tx, logger });
|
|
2934
|
+
settled.push(tx);
|
|
2935
|
+
} catch (error) {
|
|
2936
|
+
logger.warn(`Settling "${tx.productId}" with the store failed during restore.`, error);
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
const entitlements = response.entitlements;
|
|
2940
|
+
const previous = this.deps.getCurrentEntitlements();
|
|
2941
|
+
try {
|
|
2942
|
+
const cachedAt = await cache.save(entitlements);
|
|
2943
|
+
this.deps.setCachePersisted(cachedAt);
|
|
2944
|
+
} catch (error) {
|
|
2945
|
+
logger.warn(
|
|
2946
|
+
"Failed to persist entitlements after restore; in-memory state still updated.",
|
|
2947
|
+
error
|
|
2948
|
+
);
|
|
2949
|
+
}
|
|
2950
|
+
this.deps.setEntitlements(entitlements);
|
|
2951
|
+
for (const tx of settled) {
|
|
2952
|
+
try {
|
|
2953
|
+
await unfinished.remove(tx.token);
|
|
2954
|
+
} catch (error) {
|
|
2955
|
+
logger.warn(
|
|
2956
|
+
`Failed to remove "${tx.productId}" from unfinished list during restore.`,
|
|
2957
|
+
error
|
|
2958
|
+
);
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
for (const tx of settled) {
|
|
2962
|
+
try {
|
|
2963
|
+
await resolvePendingPurchase(this.deps, tx.productId, "purchased");
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
logger.warn(
|
|
2966
|
+
`Failed to clear the pending mark for "${tx.productId}" during restore.`,
|
|
2967
|
+
error
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
const next = this.deps.getCurrentEntitlements();
|
|
2972
|
+
emitter.emit("restore-completed", { restored: owned.length, entitlements: next });
|
|
2973
|
+
if (!entitlementsEqual(previous, next)) {
|
|
2974
|
+
emitter.emit("entitlements-changed", { entitlements: next, previous });
|
|
2975
|
+
}
|
|
2976
|
+
return {
|
|
2977
|
+
restored: owned.length,
|
|
2978
|
+
entitlements: next,
|
|
2979
|
+
purchases: owned.map(toStorePurchase)
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
toRestoreEntry(tx) {
|
|
2983
|
+
if (tx.platform === "apple") {
|
|
2984
|
+
return {
|
|
2985
|
+
platform: "apple",
|
|
2986
|
+
productId: tx.productId,
|
|
2987
|
+
transactionId: tx.token
|
|
2988
|
+
};
|
|
2989
|
+
}
|
|
2990
|
+
if (!tx.packageName) {
|
|
2991
|
+
throw new exports.IAPError({
|
|
2992
|
+
code: exports.IAPErrorCode.STORE_ERROR,
|
|
2993
|
+
message: `Google owned transaction for "${tx.productId}" has no packageName; cannot restore. Set config.android.packageName to your app's applicationId \u2014 the native plugin does not report it, so iap cannot derive it from the purchase.`
|
|
2994
|
+
});
|
|
2995
|
+
}
|
|
2996
|
+
return {
|
|
2997
|
+
platform: "google",
|
|
2998
|
+
productId: tx.productId,
|
|
2999
|
+
purchaseToken: tx.token,
|
|
3000
|
+
packageName: tx.packageName
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
|
|
3005
|
+
// src/core/transaction-updates.ts
|
|
3006
|
+
var TransactionUpdateOrchestrator = class {
|
|
3007
|
+
constructor(deps) {
|
|
3008
|
+
this.deps = deps;
|
|
3009
|
+
}
|
|
3010
|
+
deps;
|
|
3011
|
+
/**
|
|
3012
|
+
* Tokens currently being settled, so two paths cannot settle the same
|
|
3013
|
+
* transaction at once.
|
|
3014
|
+
*
|
|
3015
|
+
* The listener is fire-and-forget and reconciliation runs on resume, so
|
|
3016
|
+
* they genuinely overlap. Without this, both verify and both acknowledge —
|
|
3017
|
+
* and on Android a second `acknowledge` of an already-acked token errors,
|
|
3018
|
+
* which the settle path reads as a failure and retains the entry for.
|
|
3019
|
+
*/
|
|
3020
|
+
settling = /* @__PURE__ */ new Set();
|
|
3021
|
+
/** The in-flight reconciliation pass, so concurrent callers join it. */
|
|
3022
|
+
reconciling = null;
|
|
3023
|
+
/** Settle a transaction pushed by the platform. Never throws. */
|
|
3024
|
+
async handle(transaction) {
|
|
3025
|
+
const { logger, emitter, isPurchaseInFlight } = this.deps;
|
|
3026
|
+
try {
|
|
3027
|
+
if (!this.deps.isActive()) {
|
|
3028
|
+
logger.debug("Transaction update arrived after teardown; ignoring.");
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
if (isPurchaseInFlight(transaction.productId)) {
|
|
3032
|
+
logger.debug(
|
|
3033
|
+
`Transaction update for "${transaction.productId}" arrived while a purchase is in flight; leaving it to the purchase flow.`
|
|
3034
|
+
);
|
|
3035
|
+
return;
|
|
3036
|
+
}
|
|
3037
|
+
emitter.emit("transaction-updated", {
|
|
3038
|
+
productId: transaction.productId,
|
|
3039
|
+
purchase: toStorePurchase(transaction)
|
|
3040
|
+
});
|
|
3041
|
+
const isRevocation = transaction.subscription?.state === "revoked";
|
|
3042
|
+
const outcome = await this.settleOnce(transaction, {
|
|
3043
|
+
persist: !isRevocation,
|
|
3044
|
+
alreadyFinished: transaction.platform === "apple"
|
|
3045
|
+
});
|
|
3046
|
+
if (outcome !== "settled") {
|
|
3047
|
+
logger.debug(
|
|
3048
|
+
`Transaction update for "${transaction.productId}" did not settle (${outcome}).`
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
} catch (error) {
|
|
3052
|
+
logger.warn(
|
|
3053
|
+
`Transaction update handling threw for "${transaction.productId}"; ignoring.`,
|
|
3054
|
+
error
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
/**
|
|
3059
|
+
* Ask the store what became of the purchases iap reported as pending.
|
|
3060
|
+
*
|
|
3061
|
+
* Call at `initialize()` and on resume. Does nothing when nothing is
|
|
3062
|
+
* tracked, so the common case costs no native calls. Never throws.
|
|
3063
|
+
*/
|
|
3064
|
+
async reconcilePending() {
|
|
3065
|
+
if (this.reconciling) return this.reconciling;
|
|
3066
|
+
this.reconciling = this.runReconcile().finally(() => {
|
|
3067
|
+
this.reconciling = null;
|
|
3068
|
+
});
|
|
3069
|
+
return this.reconciling;
|
|
3070
|
+
}
|
|
3071
|
+
async runReconcile() {
|
|
3072
|
+
const { nativeAdapter, logger, pending } = this.deps;
|
|
3073
|
+
try {
|
|
3074
|
+
if (!this.deps.isActive()) return;
|
|
3075
|
+
const tracked = await pending.list();
|
|
3076
|
+
if (tracked.length === 0) return;
|
|
3077
|
+
const stillPending = await nativeAdapter.getPendingProductIds?.() ?? null;
|
|
3078
|
+
const owned = await nativeAdapter.getOwnedTransactions();
|
|
3079
|
+
const ownedByProduct = new Map(owned.map((tx) => [tx.productId, tx]));
|
|
3080
|
+
for (const productId of tracked) {
|
|
3081
|
+
if (this.deps.isPurchaseInFlight(productId)) {
|
|
3082
|
+
logger.debug(
|
|
3083
|
+
`Pending purchase "${productId}" is being purchased right now; leaving it to the purchase flow.`
|
|
3084
|
+
);
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
const ownedTx = ownedByProduct.get(productId);
|
|
3088
|
+
if (ownedTx) {
|
|
3089
|
+
const outcome = await this.settleOnce(ownedTx);
|
|
3090
|
+
if (outcome !== "settled") {
|
|
3091
|
+
logger.debug(
|
|
3092
|
+
`Pending purchase "${productId}" is owned but did not settle (${outcome}); keeping it pending.`
|
|
3093
|
+
);
|
|
3094
|
+
}
|
|
3095
|
+
continue;
|
|
3096
|
+
}
|
|
3097
|
+
if (stillPending === null) {
|
|
3098
|
+
logger.debug(
|
|
3099
|
+
`Pending purchase "${productId}" is not owned, but this platform cannot report pending state; keeping it pending.`
|
|
3100
|
+
);
|
|
3101
|
+
continue;
|
|
3102
|
+
}
|
|
3103
|
+
if (stillPending.includes(productId)) continue;
|
|
3104
|
+
await this.resolvePending(productId, "cancelled");
|
|
3105
|
+
}
|
|
3106
|
+
} catch (error) {
|
|
3107
|
+
logger.warn("Reconciling pending purchases failed; markers kept for the next pass.", error);
|
|
3108
|
+
}
|
|
3109
|
+
}
|
|
3110
|
+
/**
|
|
3111
|
+
* Settle a transaction, unless it is already being settled.
|
|
3112
|
+
*
|
|
3113
|
+
* Keyed on the token, not the product: two different transactions for one
|
|
3114
|
+
* product (an iOS subscription has a token per renewal) should both
|
|
3115
|
+
* settle, while the same transaction reaching both paths should not.
|
|
3116
|
+
*/
|
|
3117
|
+
async settleOnce(tx, opts = {}) {
|
|
3118
|
+
if (this.settling.has(tx.token)) return "already-settling";
|
|
3119
|
+
this.settling.add(tx.token);
|
|
3120
|
+
try {
|
|
3121
|
+
return await this.deps.recoverer.settleTransaction(tx, opts);
|
|
3122
|
+
} finally {
|
|
3123
|
+
this.settling.delete(tx.token);
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
/** Announce how a pending purchase ended. See `resolve-pending.ts`. */
|
|
3127
|
+
async resolvePending(productId, status) {
|
|
3128
|
+
await resolvePendingPurchase(this.deps, productId, status);
|
|
3129
|
+
}
|
|
3130
|
+
};
|
|
3131
|
+
|
|
3132
|
+
// src/core/initialize.ts
|
|
3133
|
+
async function initializeIap(state, refreshEntitlements) {
|
|
3134
|
+
if (state.destroyed) {
|
|
3135
|
+
throw new exports.IAPError({
|
|
3136
|
+
code: exports.IAPErrorCode.NOT_INITIALIZED,
|
|
3137
|
+
message: "IAP instance has been destroyed; create a new one with createIAP()."
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
if (state.initialized) {
|
|
3141
|
+
state.logger.debug("initialize() called more than once; ignoring.");
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
await state.storage.ensureAvailable?.();
|
|
3145
|
+
const platform = getPlatform();
|
|
3146
|
+
if (!isNative()) {
|
|
3147
|
+
state.logger.info("Native purchases unavailable on web; entitlement queries still functional.");
|
|
3148
|
+
} else {
|
|
3149
|
+
state.logger.debug(`Initializing on platform=${platform}`);
|
|
3150
|
+
}
|
|
3151
|
+
if (!state.config.products) {
|
|
3152
|
+
if (typeof state.backend.listProducts !== "function") {
|
|
3153
|
+
throw new exports.IAPError({
|
|
3154
|
+
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
3155
|
+
message: "config.products is omitted but backend adapter does not implement listProducts(). This is a library bug; the schema should have caught it."
|
|
3156
|
+
});
|
|
3157
|
+
}
|
|
3158
|
+
const fetched = await state.backend.listProducts();
|
|
3159
|
+
const validated = configuredProductsArraySchema.safeParse(fetched);
|
|
3160
|
+
if (!validated.success) {
|
|
3161
|
+
throw new exports.IAPError({
|
|
3162
|
+
code: exports.IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
3163
|
+
message: `backend.listProducts() returned an invalid manifest: ${validated.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`,
|
|
3164
|
+
cause: validated.error
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
ensureUniqueProductIds(validated.data);
|
|
3168
|
+
state.products = Object.freeze([...validated.data]);
|
|
3169
|
+
state.logger.debug(`Resolved ${validated.data.length} product(s) from backend manifest.`);
|
|
3170
|
+
}
|
|
3171
|
+
state.adapter = await selectNativeAdapter(
|
|
3172
|
+
state.logger,
|
|
3173
|
+
state.config.android.packageName,
|
|
3174
|
+
() => state.products
|
|
3175
|
+
);
|
|
3176
|
+
const configGetAuthHeaders = state.config.backend?.getAuthHeaders;
|
|
3177
|
+
const getAuthHeaders = configGetAuthHeaders ? async () => configGetAuthHeaders() : async () => ({});
|
|
3178
|
+
const sharedDeps = {
|
|
3179
|
+
nativeAdapter: state.adapter,
|
|
3180
|
+
backend: state.backend,
|
|
3181
|
+
cache: state.cache,
|
|
3182
|
+
unfinished: state.unfinished,
|
|
3183
|
+
emitter: state.emitter,
|
|
3184
|
+
logger: state.logger,
|
|
3185
|
+
getCurrentEntitlements: () => state.entitlements,
|
|
3186
|
+
setEntitlements: (next) => {
|
|
3187
|
+
state.entitlements = freezeAll(next);
|
|
3188
|
+
},
|
|
3189
|
+
setCachePersisted: (cachedAt) => {
|
|
3190
|
+
state.cachedAt = cachedAt;
|
|
3191
|
+
},
|
|
3192
|
+
getAuthHeaders
|
|
3193
|
+
};
|
|
3194
|
+
state.orchestrator = new PurchaseOrchestrator({
|
|
3195
|
+
...sharedDeps,
|
|
3196
|
+
products: state.products,
|
|
3197
|
+
pending: state.pending
|
|
3198
|
+
});
|
|
3199
|
+
state.restorer = new RestoreOrchestrator({ ...sharedDeps, pending: state.pending });
|
|
3200
|
+
state.recoverer = new RecoveryOrchestrator({
|
|
3201
|
+
...sharedDeps,
|
|
3202
|
+
pending: state.pending,
|
|
3203
|
+
maxBatch: state.config.options.recoveryMaxBatch,
|
|
3204
|
+
permanentErrorCodes: new Set(
|
|
3205
|
+
state.config.options.permanentErrorCodes ?? DEFAULT_PERMANENT_ERROR_CODES
|
|
3206
|
+
)
|
|
3207
|
+
});
|
|
3208
|
+
state.updates = new TransactionUpdateOrchestrator({
|
|
3209
|
+
nativeAdapter: state.adapter,
|
|
3210
|
+
recoverer: state.recoverer,
|
|
3211
|
+
pending: state.pending,
|
|
3212
|
+
emitter: state.emitter,
|
|
3213
|
+
logger: state.logger,
|
|
3214
|
+
isPurchaseInFlight: (productId) => state.orchestrator?.isInFlight(productId) ?? false,
|
|
3215
|
+
isActive: () => state.initialized && !state.destroyed
|
|
3216
|
+
});
|
|
3217
|
+
if (platform === "android" && !state.config.android.packageName) {
|
|
3218
|
+
state.logger.warn(
|
|
3219
|
+
"config.android.packageName is not set. Google verification and restore requests cannot be built without it, so purchases will fail with STORE_ERROR. Set it to your app's applicationId."
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
try {
|
|
3223
|
+
await state.adapter.isAvailable();
|
|
3224
|
+
} catch (error) {
|
|
3225
|
+
state.logger.warn("Native adapter availability check threw; continuing.", error);
|
|
3226
|
+
}
|
|
3227
|
+
const cached = await state.cache.load();
|
|
3228
|
+
if (cached) {
|
|
3229
|
+
state.entitlements = freezeAll(cached.entitlements);
|
|
3230
|
+
state.cachedAt = cached.cachedAt;
|
|
3231
|
+
state.logger.debug(
|
|
3232
|
+
`Loaded ${cached.entitlements.length} cached entitlement(s) from ${new Date(cached.cachedAt).toISOString()}.`
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
if (state.config.options.recoverUnfinishedTransactions && isNative()) {
|
|
3236
|
+
try {
|
|
3237
|
+
const result = await state.recoverer.recoverUnfinishedTransactions();
|
|
3238
|
+
if (result.inspected > 0) {
|
|
3239
|
+
state.logger.info(
|
|
3240
|
+
`Recovery inspected ${result.inspected} unfinished transaction(s): ${result.recovered} recovered, ${result.failures} retained.`
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
} catch (error) {
|
|
3244
|
+
state.logger.warn("Recovery threw unexpectedly; continuing initialize.", error);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
if (isNative()) {
|
|
3248
|
+
state.txWatch = await attachTransactionWatch({
|
|
3249
|
+
adapter: state.adapter,
|
|
3250
|
+
updates: state.updates,
|
|
3251
|
+
logger: state.logger
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
if (state.config.options.refreshOnResume && isNative()) {
|
|
3255
|
+
state.resumeListener = await attachAppResumeListener({
|
|
3256
|
+
logger: state.logger,
|
|
3257
|
+
onResume: async () => {
|
|
3258
|
+
try {
|
|
3259
|
+
await refreshEntitlements();
|
|
3260
|
+
} catch (error) {
|
|
3261
|
+
state.logger.warn("refreshOnResume: refresh() failed.", error);
|
|
3262
|
+
}
|
|
3263
|
+
await state.updates?.reconcilePending();
|
|
3264
|
+
}
|
|
3265
|
+
});
|
|
3266
|
+
}
|
|
3267
|
+
if (state.cachedAt !== null && Date.now() - state.cachedAt > state.config.options.entitlementCacheTtlMs) {
|
|
3268
|
+
state.logger.debug("Cache exceeds TTL; scheduling background refresh.");
|
|
3269
|
+
queueMicrotask(() => {
|
|
3270
|
+
if (!state.initialized || state.destroyed) return;
|
|
3271
|
+
refreshEntitlements().catch((error) => {
|
|
3272
|
+
state.logger.warn("TTL background refresh failed.", error);
|
|
3273
|
+
});
|
|
3274
|
+
});
|
|
3275
|
+
}
|
|
3276
|
+
state.initialized = true;
|
|
3277
|
+
state.emitter.emit("ready", void 0);
|
|
3278
|
+
if (isNative()) {
|
|
3279
|
+
queueMicrotask(() => {
|
|
3280
|
+
if (!state.initialized || state.destroyed) return;
|
|
3281
|
+
void state.updates?.reconcilePending();
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
// src/core/mutation-lock.ts
|
|
3287
|
+
function createMutationLock() {
|
|
3288
|
+
let tail = Promise.resolve();
|
|
3289
|
+
return async function runExclusive(fn) {
|
|
3290
|
+
const previous = tail;
|
|
3291
|
+
let release;
|
|
3292
|
+
tail = new Promise((resolve) => {
|
|
3293
|
+
release = resolve;
|
|
3294
|
+
});
|
|
3295
|
+
try {
|
|
3296
|
+
await previous;
|
|
3297
|
+
return await fn();
|
|
3298
|
+
} finally {
|
|
3299
|
+
release();
|
|
3300
|
+
}
|
|
1713
3301
|
};
|
|
1714
|
-
if (entry.packageName) tx.packageName = entry.packageName;
|
|
1715
|
-
return tx;
|
|
1716
3302
|
}
|
|
1717
3303
|
|
|
1718
|
-
// src/core/
|
|
1719
|
-
|
|
1720
|
-
var
|
|
1721
|
-
|
|
1722
|
-
|
|
3304
|
+
// src/core/pending-purchases.ts
|
|
3305
|
+
var STORE_KEY = IAP_STORAGE_KEYS.pendingPurchases;
|
|
3306
|
+
var pendingEntrySchema = zod.z.union([
|
|
3307
|
+
zod.z.object({ productId: zod.z.string().min(1), recordedAt: zod.z.string().min(1) }),
|
|
3308
|
+
zod.z.string().min(1).transform((productId) => ({ productId, recordedAt: "" }))
|
|
3309
|
+
]);
|
|
3310
|
+
var pendingListSchema = zod.z.array(pendingEntrySchema);
|
|
3311
|
+
var MAX_PENDING_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
3312
|
+
var PendingPurchasesStore = class {
|
|
3313
|
+
constructor(storage, logger) {
|
|
3314
|
+
this.storage = storage;
|
|
3315
|
+
this.logger = logger;
|
|
1723
3316
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
3317
|
+
storage;
|
|
3318
|
+
logger;
|
|
3319
|
+
runExclusive = createMutationLock();
|
|
3320
|
+
/** Product ids currently marked pending, or `[]` if empty / corrupt. */
|
|
3321
|
+
async list() {
|
|
3322
|
+
return (await this.readLive()).map((entry) => entry.productId);
|
|
3323
|
+
}
|
|
3324
|
+
/** Live entries, with anything past {@link MAX_PENDING_AGE_MS} dropped. */
|
|
3325
|
+
async readLive() {
|
|
3326
|
+
let raw;
|
|
1729
3327
|
try {
|
|
1730
|
-
|
|
3328
|
+
raw = await this.storage.get(STORE_KEY);
|
|
1731
3329
|
} catch (cause) {
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
if (owned.length === 0) {
|
|
1735
|
-
const entitlements2 = this.deps.getCurrentEntitlements();
|
|
1736
|
-
emitter.emit("restore-completed", { restored: 0, entitlements: entitlements2 });
|
|
1737
|
-
return { restored: 0, entitlements: entitlements2 };
|
|
3330
|
+
this.logger.warn("Storage read failed; treating pending list as empty.", cause);
|
|
3331
|
+
return [];
|
|
1738
3332
|
}
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
};
|
|
1742
|
-
let response;
|
|
3333
|
+
if (!raw) return [];
|
|
3334
|
+
let parsed;
|
|
1743
3335
|
try {
|
|
1744
|
-
|
|
3336
|
+
parsed = JSON.parse(raw);
|
|
1745
3337
|
} catch (cause) {
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const detail = response.message ? `${response.message} [${response.error}]` : `Backend rejected restore (${response.error}).`;
|
|
1750
|
-
throw new exports.IAPError({
|
|
1751
|
-
code: exports.IAPErrorCode.VERIFICATION_REJECTED,
|
|
1752
|
-
message: detail
|
|
1753
|
-
});
|
|
3338
|
+
this.logger.warn("pending_purchases payload is not valid JSON; clearing.", cause);
|
|
3339
|
+
await this.safeRemove();
|
|
3340
|
+
return [];
|
|
1754
3341
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
}
|
|
3342
|
+
const result = pendingListSchema.safeParse(parsed);
|
|
3343
|
+
if (!result.success) {
|
|
3344
|
+
this.logger.warn("pending_purchases has unexpected shape; clearing.", result.error);
|
|
3345
|
+
await this.safeRemove();
|
|
3346
|
+
return [];
|
|
1761
3347
|
}
|
|
1762
|
-
const
|
|
1763
|
-
const
|
|
1764
|
-
|
|
1765
|
-
const
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
3348
|
+
const now = Date.now();
|
|
3349
|
+
const live = result.data.filter((entry) => {
|
|
3350
|
+
if (entry.recordedAt === "") return true;
|
|
3351
|
+
const marked = Date.parse(entry.recordedAt);
|
|
3352
|
+
if (Number.isNaN(marked)) return false;
|
|
3353
|
+
return now - marked < MAX_PENDING_AGE_MS;
|
|
3354
|
+
});
|
|
3355
|
+
if (live.length !== result.data.length) {
|
|
3356
|
+
this.logger.debug(
|
|
3357
|
+
`Dropping ${result.data.length - live.length} pending mark(s) past the age limit.`
|
|
1771
3358
|
);
|
|
3359
|
+
await this.persist(live);
|
|
1772
3360
|
}
|
|
1773
|
-
|
|
1774
|
-
for (const tx of owned) {
|
|
1775
|
-
try {
|
|
1776
|
-
await unfinished.remove(tx.token);
|
|
1777
|
-
} catch (error) {
|
|
1778
|
-
logger.warn(
|
|
1779
|
-
`Failed to remove "${tx.productId}" from unfinished list during restore.`,
|
|
1780
|
-
error
|
|
1781
|
-
);
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
const next = this.deps.getCurrentEntitlements();
|
|
1785
|
-
emitter.emit("restore-completed", { restored: owned.length, entitlements: next });
|
|
1786
|
-
if (!entitlementsEqual(previous, next)) {
|
|
1787
|
-
emitter.emit("entitlements-changed", { entitlements: next, previous });
|
|
1788
|
-
}
|
|
1789
|
-
return { restored: owned.length, entitlements: next };
|
|
3361
|
+
return live;
|
|
1790
3362
|
}
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
3363
|
+
async has(productId) {
|
|
3364
|
+
return (await this.list()).includes(productId);
|
|
3365
|
+
}
|
|
3366
|
+
/**
|
|
3367
|
+
* Remove a product's pending mark and report whether it was there.
|
|
3368
|
+
*
|
|
3369
|
+
* The atomic form of `has()` then `remove()`, and the reason it exists:
|
|
3370
|
+
* `purchase-pending-resolved` promises to fire at most once per pending
|
|
3371
|
+
* purchase, and the two callers that resolve one can run concurrently —
|
|
3372
|
+
* the iOS transaction listener is fire-and-forget, so it can interleave
|
|
3373
|
+
* with the reconciliation that runs on resume. Checked and removed
|
|
3374
|
+
* separately, both would observe the mark and both would emit.
|
|
3375
|
+
*/
|
|
3376
|
+
async takeIfPresent(productId) {
|
|
3377
|
+
return this.runExclusive(async () => {
|
|
3378
|
+
const current = await this.readLive();
|
|
3379
|
+
if (!current.some((entry) => entry.productId === productId)) return false;
|
|
3380
|
+
await this.persist(current.filter((entry) => entry.productId !== productId));
|
|
3381
|
+
return true;
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3384
|
+
/** Mark a product pending. Idempotent — re-marking does not reset the age. */
|
|
3385
|
+
async add(productId) {
|
|
3386
|
+
return this.runExclusive(async () => {
|
|
3387
|
+
const current = await this.readLive();
|
|
3388
|
+
if (current.some((entry) => entry.productId === productId)) return;
|
|
3389
|
+
await this.persist([...current, { productId, recordedAt: (/* @__PURE__ */ new Date()).toISOString() }]);
|
|
3390
|
+
});
|
|
3391
|
+
}
|
|
3392
|
+
/** Clear a product's pending mark. No-op if absent. */
|
|
3393
|
+
async remove(productId) {
|
|
3394
|
+
return this.runExclusive(async () => {
|
|
3395
|
+
const current = await this.readLive();
|
|
3396
|
+
const next = current.filter((entry) => entry.productId !== productId);
|
|
3397
|
+
if (next.length === current.length) return;
|
|
3398
|
+
await this.persist(next);
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
/** Drop every pending mark. Used by `iap.reset()`. */
|
|
3402
|
+
async clear() {
|
|
3403
|
+
return this.runExclusive(() => this.safeRemove());
|
|
3404
|
+
}
|
|
3405
|
+
async persist(entries) {
|
|
3406
|
+
try {
|
|
3407
|
+
await this.storage.set(STORE_KEY, JSON.stringify(entries));
|
|
3408
|
+
} catch (cause) {
|
|
3409
|
+
this.logger.warn("Failed to persist pending_purchases; pending state may be lost.", cause);
|
|
1798
3410
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
3411
|
+
}
|
|
3412
|
+
async safeRemove() {
|
|
3413
|
+
try {
|
|
3414
|
+
await this.storage.remove(STORE_KEY);
|
|
3415
|
+
} catch (cause) {
|
|
3416
|
+
this.logger.warn("Failed to clear pending_purchases key.", cause);
|
|
1804
3417
|
}
|
|
1805
|
-
return {
|
|
1806
|
-
platform: "google",
|
|
1807
|
-
productId: tx.productId,
|
|
1808
|
-
purchaseToken: tx.token,
|
|
1809
|
-
packageName: tx.packageName
|
|
1810
|
-
};
|
|
1811
3418
|
}
|
|
1812
3419
|
};
|
|
1813
3420
|
|
|
3421
|
+
// src/core/reset.ts
|
|
3422
|
+
init_errors();
|
|
3423
|
+
async function resetIap(state) {
|
|
3424
|
+
const inFlight = state.orchestrator?.inFlightProducts() ?? [];
|
|
3425
|
+
if (inFlight.length > 0) {
|
|
3426
|
+
throw new exports.IAPError({
|
|
3427
|
+
code: exports.IAPErrorCode.ALREADY_IN_PROGRESS,
|
|
3428
|
+
message: `Cannot reset while a purchase of ${inFlight.map((id) => `"${id}"`).join(", ")} is in progress: its unfinished-transaction record is still needed. Await the purchase, then reset.`,
|
|
3429
|
+
// The per-code hint is written for the other caller of this code — a
|
|
3430
|
+
// double `purchase()` of one product — and would tell a consumer
|
|
3431
|
+
// debugging a failed reset that they double-called `purchase()`.
|
|
3432
|
+
includeHint: false,
|
|
3433
|
+
// It is recoverable, whatever the code's default says: await the
|
|
3434
|
+
// purchase and try again.
|
|
3435
|
+
recoverable: true
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
await state.storage.ensureAvailable?.();
|
|
3439
|
+
const previous = state.entitlements;
|
|
3440
|
+
state.entitlements = [];
|
|
3441
|
+
state.cachedAt = null;
|
|
3442
|
+
await state.cache.clear();
|
|
3443
|
+
await state.unfinished.clear();
|
|
3444
|
+
await state.pending.clear();
|
|
3445
|
+
for (const key of ALL_IAP_STORAGE_KEYS) {
|
|
3446
|
+
try {
|
|
3447
|
+
await state.storage.remove(key);
|
|
3448
|
+
} catch (error) {
|
|
3449
|
+
state.logger.warn(`reset(): failed to remove the "${key}" key.`, error);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
try {
|
|
3453
|
+
await state.storage.clear();
|
|
3454
|
+
} catch (error) {
|
|
3455
|
+
state.logger.warn("reset(): failed to clear the storage namespace.", error);
|
|
3456
|
+
}
|
|
3457
|
+
if (previous.length > 0) {
|
|
3458
|
+
state.emitter.emit("entitlements-changed", {
|
|
3459
|
+
entitlements: [...state.entitlements],
|
|
3460
|
+
previous
|
|
3461
|
+
});
|
|
3462
|
+
}
|
|
3463
|
+
}
|
|
3464
|
+
function activeNow(state) {
|
|
3465
|
+
if (!state.config.verification) return [...state.entitlements];
|
|
3466
|
+
return activeEntitlements(
|
|
3467
|
+
state.entitlements,
|
|
3468
|
+
Date.now(),
|
|
3469
|
+
state.config.options.entitlementGraceMs
|
|
3470
|
+
);
|
|
3471
|
+
}
|
|
3472
|
+
|
|
1814
3473
|
// src/core/unfinished-transactions.ts
|
|
1815
3474
|
init_errors();
|
|
1816
|
-
var
|
|
3475
|
+
var STORE_KEY2 = IAP_STORAGE_KEYS.unfinishedTransactions;
|
|
1817
3476
|
var unfinishedEntrySchema = zod.z.object({
|
|
1818
3477
|
platform: zod.z.enum(["apple", "google"]),
|
|
1819
3478
|
productId: zod.z.string().min(1),
|
|
1820
3479
|
token: zod.z.string().min(1),
|
|
1821
3480
|
productType: productTypeSchema,
|
|
1822
3481
|
packageName: zod.z.string().optional(),
|
|
3482
|
+
/**
|
|
3483
|
+
* The renewal-stable purchase reference. Optional, and deliberately
|
|
3484
|
+
* unconstrained beyond its type: entries written before it existed are
|
|
3485
|
+
* still valid, and readers fall back to `token`. A `min(1)` here would
|
|
3486
|
+
* make an empty string fail the whole entry's parse — rejecting a
|
|
3487
|
+
* recoverable purchase over a field recovery does not even read.
|
|
3488
|
+
*/
|
|
3489
|
+
purchaseReference: zod.z.string().optional(),
|
|
3490
|
+
/**
|
|
3491
|
+
* The fields a verification needs that the extracted request shapes do
|
|
3492
|
+
* not carry. All optional, so entries written before they existed still
|
|
3493
|
+
* parse.
|
|
3494
|
+
*
|
|
3495
|
+
* These exist because recovery re-verifies from the **stored entry**, not
|
|
3496
|
+
* from a live transaction. Without them a rebuilt transaction has no
|
|
3497
|
+
* binding, no expiry and no purchase state — so an on-device check sees
|
|
3498
|
+
* an amputated object and answers differently than it did at purchase
|
|
3499
|
+
* time: an expired subscription verifies as non-expiring and is granted,
|
|
3500
|
+
* and a purchase with a binding fails `BINDING_MISMATCH` forever and is
|
|
3501
|
+
* never acknowledged (which on Android means Google refunds it).
|
|
3502
|
+
*/
|
|
3503
|
+
appUserId: zod.z.string().optional(),
|
|
3504
|
+
expiresAt: zod.z.string().optional(),
|
|
3505
|
+
purchaseState: zod.z.string().optional(),
|
|
1823
3506
|
/** ISO 8601 timestamp the entry was first persisted. */
|
|
1824
3507
|
recordedAt: zod.z.string()
|
|
1825
3508
|
});
|
|
1826
|
-
var
|
|
3509
|
+
var entryListSchema = zod.z.array(zod.z.unknown());
|
|
1827
3510
|
var UnfinishedTransactionsStore = class {
|
|
1828
3511
|
constructor(storage, logger) {
|
|
1829
3512
|
this.storage = storage;
|
|
@@ -1838,12 +3521,12 @@ var UnfinishedTransactionsStore = class {
|
|
|
1838
3521
|
* could each `list()` the same snapshot and overwrite each other's
|
|
1839
3522
|
* `persist()`.
|
|
1840
3523
|
*/
|
|
1841
|
-
|
|
3524
|
+
runExclusive = createMutationLock();
|
|
1842
3525
|
/** Returns the current list, or `[]` if empty / corrupt. */
|
|
1843
3526
|
async list() {
|
|
1844
3527
|
let raw;
|
|
1845
3528
|
try {
|
|
1846
|
-
raw = await this.storage.get(
|
|
3529
|
+
raw = await this.storage.get(STORE_KEY2);
|
|
1847
3530
|
} catch (cause) {
|
|
1848
3531
|
this.logger.warn("Storage read failed; treating unfinished list as empty.", cause);
|
|
1849
3532
|
return [];
|
|
@@ -1857,18 +3540,39 @@ var UnfinishedTransactionsStore = class {
|
|
|
1857
3540
|
await this.safeClear();
|
|
1858
3541
|
return [];
|
|
1859
3542
|
}
|
|
1860
|
-
const
|
|
1861
|
-
if (!
|
|
1862
|
-
this.logger.warn("unfinished_transactions
|
|
3543
|
+
const list = entryListSchema.safeParse(parsed);
|
|
3544
|
+
if (!list.success) {
|
|
3545
|
+
this.logger.warn("unfinished_transactions is not a list; clearing.", list.error);
|
|
1863
3546
|
await this.safeClear();
|
|
1864
3547
|
return [];
|
|
1865
3548
|
}
|
|
1866
|
-
|
|
3549
|
+
const entries = [];
|
|
3550
|
+
let dropped = 0;
|
|
3551
|
+
for (const candidate of list.data) {
|
|
3552
|
+
const entry = unfinishedEntrySchema.safeParse(candidate);
|
|
3553
|
+
if (entry.success) {
|
|
3554
|
+
entries.push(entry.data);
|
|
3555
|
+
} else {
|
|
3556
|
+
dropped++;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
if (dropped > 0) {
|
|
3560
|
+
this.logger.warn(
|
|
3561
|
+
`Dropped ${dropped} unreadable unfinished-transaction entry/entries; kept ${entries.length}.`
|
|
3562
|
+
);
|
|
3563
|
+
}
|
|
3564
|
+
return entries;
|
|
1867
3565
|
}
|
|
1868
3566
|
/**
|
|
1869
3567
|
* Append a transaction to the list. Idempotent: if a same-token entry
|
|
1870
3568
|
* already exists, this is a no-op (avoids dupes when restore + active
|
|
1871
3569
|
* purchase race for the same StoreKit replay).
|
|
3570
|
+
*
|
|
3571
|
+
* **Keyed on `token`, deliberately — not on `purchaseReference`.** The
|
|
3572
|
+
* token is what acknowledges one transaction, and on iOS a subscription
|
|
3573
|
+
* has a separate token per renewal while sharing one reference. Keying
|
|
3574
|
+
* on the reference would collapse several unacknowledged renewals into
|
|
3575
|
+
* one entry and leave the rest unfinished.
|
|
1872
3576
|
*/
|
|
1873
3577
|
async add(tx) {
|
|
1874
3578
|
return this.runExclusive(async () => {
|
|
@@ -1880,6 +3584,12 @@ var UnfinishedTransactionsStore = class {
|
|
|
1880
3584
|
token: tx.token,
|
|
1881
3585
|
productType: tx.productType,
|
|
1882
3586
|
...tx.packageName ? { packageName: tx.packageName } : {},
|
|
3587
|
+
...tx.purchaseReference ? { purchaseReference: tx.purchaseReference } : {},
|
|
3588
|
+
...tx.appUserId ? { appUserId: tx.appUserId } : {},
|
|
3589
|
+
...tx.subscription?.expiresAt ? { expiresAt: tx.subscription.expiresAt } : {},
|
|
3590
|
+
...typeof tx.raw?.purchaseState === "string" || typeof tx.raw?.purchaseState === "number" ? {
|
|
3591
|
+
purchaseState: String(tx.raw.purchaseState)
|
|
3592
|
+
} : {},
|
|
1883
3593
|
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1884
3594
|
};
|
|
1885
3595
|
await this.persist([...current, entry]);
|
|
@@ -1898,40 +3608,20 @@ var UnfinishedTransactionsStore = class {
|
|
|
1898
3608
|
async clear() {
|
|
1899
3609
|
return this.runExclusive(() => this.safeClear());
|
|
1900
3610
|
}
|
|
1901
|
-
/**
|
|
1902
|
-
* Run `fn` with exclusive access to the storage key. Implements a simple
|
|
1903
|
-
* promise chain — every mutation awaits the previous one's completion.
|
|
1904
|
-
* Reads (`list()`) are NOT serialized because they're tolerant of stale
|
|
1905
|
-
* snapshots (callers either compose or accept the read-once semantic).
|
|
1906
|
-
*/
|
|
1907
|
-
async runExclusive(fn) {
|
|
1908
|
-
const prev = this.mutationLock;
|
|
1909
|
-
let release;
|
|
1910
|
-
this.mutationLock = new Promise((resolve) => {
|
|
1911
|
-
release = resolve;
|
|
1912
|
-
});
|
|
1913
|
-
try {
|
|
1914
|
-
await prev;
|
|
1915
|
-
return await fn();
|
|
1916
|
-
} finally {
|
|
1917
|
-
release();
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
3611
|
async persist(entries) {
|
|
1921
3612
|
try {
|
|
1922
|
-
await this.storage.set(
|
|
3613
|
+
await this.storage.set(STORE_KEY2, JSON.stringify(entries));
|
|
1923
3614
|
} catch (cause) {
|
|
1924
|
-
throw
|
|
1925
|
-
code: exports.IAPErrorCode.STORAGE_ERROR,
|
|
1926
|
-
message: "Failed to persist unfinished transactions list.",
|
|
3615
|
+
throw toIAPError(
|
|
1927
3616
|
cause,
|
|
1928
|
-
|
|
1929
|
-
|
|
3617
|
+
"Failed to persist unfinished transactions list.",
|
|
3618
|
+
exports.IAPErrorCode.STORAGE_ERROR
|
|
3619
|
+
);
|
|
1930
3620
|
}
|
|
1931
3621
|
}
|
|
1932
3622
|
async safeClear() {
|
|
1933
3623
|
try {
|
|
1934
|
-
await this.storage.remove(
|
|
3624
|
+
await this.storage.remove(STORE_KEY2);
|
|
1935
3625
|
} catch (cause) {
|
|
1936
3626
|
this.logger.warn("Failed to clear unfinished_transactions key.", cause);
|
|
1937
3627
|
}
|
|
@@ -2010,14 +3700,20 @@ function isLogger(value) {
|
|
|
2010
3700
|
}
|
|
2011
3701
|
|
|
2012
3702
|
// src/createIAP.ts
|
|
2013
|
-
init_platform();
|
|
2014
3703
|
function createIAP(input) {
|
|
2015
3704
|
const config = parseConfig(input);
|
|
2016
3705
|
const logger = resolveLogger(config.options.logLevel, config.options.logger);
|
|
2017
3706
|
const storage = selectStorageAdapter(config.storage);
|
|
2018
3707
|
const cache = new EntitlementCache(storage, logger);
|
|
2019
3708
|
const unfinished = new UnfinishedTransactionsStore(storage, logger);
|
|
2020
|
-
const
|
|
3709
|
+
const pending = new PendingPurchasesStore(storage, logger);
|
|
3710
|
+
const backend = selectBackendAdapter({
|
|
3711
|
+
config,
|
|
3712
|
+
logger,
|
|
3713
|
+
getNativeAdapter: () => state.adapter,
|
|
3714
|
+
getProducts: () => state.products,
|
|
3715
|
+
getCurrentEntitlements: () => state.entitlements
|
|
3716
|
+
});
|
|
2021
3717
|
const emitter = new TypedEventEmitter();
|
|
2022
3718
|
if (config.products) {
|
|
2023
3719
|
ensureUniqueProductIds(config.products);
|
|
@@ -2029,9 +3725,12 @@ function createIAP(input) {
|
|
|
2029
3725
|
storage,
|
|
2030
3726
|
cache,
|
|
2031
3727
|
unfinished,
|
|
3728
|
+
pending,
|
|
2032
3729
|
orchestrator: null,
|
|
2033
3730
|
restorer: null,
|
|
2034
3731
|
recoverer: null,
|
|
3732
|
+
updates: null,
|
|
3733
|
+
txWatch: null,
|
|
2035
3734
|
resumeListener: null,
|
|
2036
3735
|
emitter,
|
|
2037
3736
|
logger,
|
|
@@ -2061,123 +3760,7 @@ function createIAP(input) {
|
|
|
2061
3760
|
}
|
|
2062
3761
|
return {
|
|
2063
3762
|
async initialize() {
|
|
2064
|
-
|
|
2065
|
-
throw new exports.IAPError({
|
|
2066
|
-
code: exports.IAPErrorCode.NOT_INITIALIZED,
|
|
2067
|
-
message: "IAP instance has been destroyed; create a new one with createIAP()."
|
|
2068
|
-
});
|
|
2069
|
-
}
|
|
2070
|
-
if (state.initialized) {
|
|
2071
|
-
state.logger.debug("initialize() called more than once; ignoring.");
|
|
2072
|
-
return;
|
|
2073
|
-
}
|
|
2074
|
-
const platform = getPlatform();
|
|
2075
|
-
if (!isNative()) {
|
|
2076
|
-
state.logger.info(
|
|
2077
|
-
"Native purchases unavailable on web; entitlement queries still functional."
|
|
2078
|
-
);
|
|
2079
|
-
} else {
|
|
2080
|
-
state.logger.debug(`Initializing on platform=${platform}`);
|
|
2081
|
-
}
|
|
2082
|
-
if (!state.config.products) {
|
|
2083
|
-
if (typeof state.backend.listProducts !== "function") {
|
|
2084
|
-
throw new exports.IAPError({
|
|
2085
|
-
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
2086
|
-
message: "config.products is omitted but backend adapter does not implement listProducts(). This is a library bug; the schema should have caught it."
|
|
2087
|
-
});
|
|
2088
|
-
}
|
|
2089
|
-
const fetched = await state.backend.listProducts();
|
|
2090
|
-
const validated = configuredProductsArraySchema.safeParse(fetched);
|
|
2091
|
-
if (!validated.success) {
|
|
2092
|
-
throw new exports.IAPError({
|
|
2093
|
-
code: exports.IAPErrorCode.BACKEND_BAD_RESPONSE,
|
|
2094
|
-
message: `backend.listProducts() returned an invalid manifest: ${validated.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`,
|
|
2095
|
-
cause: validated.error
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
ensureUniqueProductIds(validated.data);
|
|
2099
|
-
state.products = Object.freeze([...validated.data]);
|
|
2100
|
-
state.logger.debug(`Resolved ${validated.data.length} product(s) from backend manifest.`);
|
|
2101
|
-
}
|
|
2102
|
-
state.adapter = await selectNativeAdapter();
|
|
2103
|
-
const configGetAuthHeaders = state.config.backend.getAuthHeaders;
|
|
2104
|
-
const getAuthHeaders = configGetAuthHeaders ? async () => configGetAuthHeaders() : async () => ({});
|
|
2105
|
-
const sharedDeps = {
|
|
2106
|
-
nativeAdapter: state.adapter,
|
|
2107
|
-
backend: state.backend,
|
|
2108
|
-
cache: state.cache,
|
|
2109
|
-
unfinished: state.unfinished,
|
|
2110
|
-
emitter: state.emitter,
|
|
2111
|
-
logger: state.logger,
|
|
2112
|
-
getCurrentEntitlements: () => state.entitlements,
|
|
2113
|
-
setEntitlements: (next) => {
|
|
2114
|
-
state.entitlements = freezeAll(next);
|
|
2115
|
-
},
|
|
2116
|
-
setCachePersisted: (cachedAt) => {
|
|
2117
|
-
state.cachedAt = cachedAt;
|
|
2118
|
-
},
|
|
2119
|
-
getAuthHeaders
|
|
2120
|
-
};
|
|
2121
|
-
state.orchestrator = new PurchaseOrchestrator({
|
|
2122
|
-
...sharedDeps,
|
|
2123
|
-
products: state.products
|
|
2124
|
-
});
|
|
2125
|
-
state.restorer = new RestoreOrchestrator(sharedDeps);
|
|
2126
|
-
state.recoverer = new RecoveryOrchestrator({
|
|
2127
|
-
...sharedDeps,
|
|
2128
|
-
maxBatch: state.config.options.recoveryMaxBatch,
|
|
2129
|
-
permanentErrorCodes: new Set(
|
|
2130
|
-
state.config.options.permanentErrorCodes ?? DEFAULT_PERMANENT_ERROR_CODES
|
|
2131
|
-
)
|
|
2132
|
-
});
|
|
2133
|
-
try {
|
|
2134
|
-
await state.adapter.isAvailable();
|
|
2135
|
-
} catch (error) {
|
|
2136
|
-
state.logger.warn("Native adapter availability check threw; continuing.", error);
|
|
2137
|
-
}
|
|
2138
|
-
const cached = await state.cache.load();
|
|
2139
|
-
if (cached) {
|
|
2140
|
-
state.entitlements = freezeAll(cached.entitlements);
|
|
2141
|
-
state.cachedAt = cached.cachedAt;
|
|
2142
|
-
state.logger.debug(
|
|
2143
|
-
`Loaded ${cached.entitlements.length} cached entitlement(s) from ${new Date(cached.cachedAt).toISOString()}.`
|
|
2144
|
-
);
|
|
2145
|
-
}
|
|
2146
|
-
if (state.config.options.recoverUnfinishedTransactions && isNative()) {
|
|
2147
|
-
try {
|
|
2148
|
-
const result = await state.recoverer.recoverUnfinishedTransactions();
|
|
2149
|
-
if (result.inspected > 0) {
|
|
2150
|
-
state.logger.info(
|
|
2151
|
-
`Recovery inspected ${result.inspected} unfinished transaction(s): ${result.recovered} recovered, ${result.failures} retained.`
|
|
2152
|
-
);
|
|
2153
|
-
}
|
|
2154
|
-
} catch (error) {
|
|
2155
|
-
state.logger.warn("Recovery threw unexpectedly; continuing initialize.", error);
|
|
2156
|
-
}
|
|
2157
|
-
}
|
|
2158
|
-
if (state.config.options.refreshOnResume && isNative()) {
|
|
2159
|
-
state.resumeListener = await attachAppResumeListener({
|
|
2160
|
-
logger: state.logger,
|
|
2161
|
-
onResume: async () => {
|
|
2162
|
-
try {
|
|
2163
|
-
await refreshEntitlements();
|
|
2164
|
-
} catch (error) {
|
|
2165
|
-
state.logger.warn("refreshOnResume: refresh() failed.", error);
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
});
|
|
2169
|
-
}
|
|
2170
|
-
if (state.cachedAt !== null && Date.now() - state.cachedAt > state.config.options.entitlementCacheTtlMs) {
|
|
2171
|
-
state.logger.debug("Cache exceeds TTL; scheduling background refresh.");
|
|
2172
|
-
queueMicrotask(() => {
|
|
2173
|
-
if (!state.initialized || state.destroyed) return;
|
|
2174
|
-
refreshEntitlements().catch((error) => {
|
|
2175
|
-
state.logger.warn("TTL background refresh failed.", error);
|
|
2176
|
-
});
|
|
2177
|
-
});
|
|
2178
|
-
}
|
|
2179
|
-
state.initialized = true;
|
|
2180
|
-
state.emitter.emit("ready", void 0);
|
|
3763
|
+
return initializeIap(state, refreshEntitlements);
|
|
2181
3764
|
},
|
|
2182
3765
|
refresh: refreshEntitlements,
|
|
2183
3766
|
async destroy() {
|
|
@@ -2195,6 +3778,14 @@ function createIAP(input) {
|
|
|
2195
3778
|
}
|
|
2196
3779
|
state.resumeListener = null;
|
|
2197
3780
|
}
|
|
3781
|
+
if (state.txWatch) {
|
|
3782
|
+
try {
|
|
3783
|
+
await state.txWatch.remove();
|
|
3784
|
+
} catch (error) {
|
|
3785
|
+
state.logger.warn("Transaction listener remove threw; continuing teardown.", error);
|
|
3786
|
+
}
|
|
3787
|
+
state.txWatch = null;
|
|
3788
|
+
}
|
|
2198
3789
|
if (state.adapter?.dispose) {
|
|
2199
3790
|
try {
|
|
2200
3791
|
await state.adapter.dispose();
|
|
@@ -2206,6 +3797,7 @@ function createIAP(input) {
|
|
|
2206
3797
|
state.orchestrator = null;
|
|
2207
3798
|
state.restorer = null;
|
|
2208
3799
|
state.recoverer = null;
|
|
3800
|
+
state.updates = null;
|
|
2209
3801
|
},
|
|
2210
3802
|
async purchase(opts) {
|
|
2211
3803
|
requireInitialized(state);
|
|
@@ -2235,29 +3827,32 @@ function createIAP(input) {
|
|
|
2235
3827
|
message: "Native adapter not constructed; this is a library bug."
|
|
2236
3828
|
});
|
|
2237
3829
|
}
|
|
2238
|
-
return state.adapter.getProducts(state.products
|
|
3830
|
+
return state.adapter.getProducts([...state.products]);
|
|
2239
3831
|
},
|
|
2240
3832
|
async getStorefront() {
|
|
2241
3833
|
requireInitialized(state);
|
|
2242
3834
|
return state.adapter?.getStorefront?.() ?? null;
|
|
2243
3835
|
},
|
|
2244
3836
|
hasEntitlement(key) {
|
|
2245
|
-
return state.
|
|
3837
|
+
return activeNow(state).some((e) => e.key === key);
|
|
2246
3838
|
},
|
|
2247
3839
|
getEntitlements() {
|
|
2248
|
-
return
|
|
3840
|
+
return activeNow(state);
|
|
2249
3841
|
},
|
|
2250
3842
|
getEntitlement(key) {
|
|
2251
|
-
return state.
|
|
3843
|
+
return activeNow(state).find((e) => e.key === key) ?? null;
|
|
3844
|
+
},
|
|
3845
|
+
getEntitlementsVerifiedAt() {
|
|
3846
|
+
return state.cachedAt === null ? null : new Date(state.cachedAt).toISOString();
|
|
3847
|
+
},
|
|
3848
|
+
async reset() {
|
|
3849
|
+
return resetIap(state);
|
|
2252
3850
|
},
|
|
2253
3851
|
on(event, handler) {
|
|
2254
3852
|
return state.emitter.on(event, handler);
|
|
2255
3853
|
}
|
|
2256
3854
|
};
|
|
2257
3855
|
}
|
|
2258
|
-
function freezeAll(items) {
|
|
2259
|
-
return items.map((item) => Object.freeze({ ...item }));
|
|
2260
|
-
}
|
|
2261
3856
|
function parseConfig(input) {
|
|
2262
3857
|
try {
|
|
2263
3858
|
const parsed = iapConfigSchema.parse(input);
|
|
@@ -2275,36 +3870,16 @@ ${issues}`,
|
|
|
2275
3870
|
throw error;
|
|
2276
3871
|
}
|
|
2277
3872
|
}
|
|
2278
|
-
function ensureUniqueProductIds(products) {
|
|
2279
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2280
|
-
for (const product of products) {
|
|
2281
|
-
if (seen.has(product.id)) {
|
|
2282
|
-
throw new exports.IAPError({
|
|
2283
|
-
code: exports.IAPErrorCode.INVALID_CONFIG,
|
|
2284
|
-
message: `Duplicate product id "${product.id}" in product manifest.`
|
|
2285
|
-
});
|
|
2286
|
-
}
|
|
2287
|
-
seen.add(product.id);
|
|
2288
|
-
}
|
|
2289
|
-
}
|
|
2290
3873
|
function resolveLogger(level, candidate) {
|
|
2291
3874
|
if (isLogger(candidate)) return candidate;
|
|
2292
3875
|
return createDefaultLogger(level);
|
|
2293
3876
|
}
|
|
2294
|
-
function requireInitialized(state) {
|
|
2295
|
-
if (!state.initialized) {
|
|
2296
|
-
throw new exports.IAPError({
|
|
2297
|
-
code: exports.IAPErrorCode.NOT_INITIALIZED,
|
|
2298
|
-
message: "Call iap.initialize() before this method."
|
|
2299
|
-
});
|
|
2300
|
-
}
|
|
2301
|
-
}
|
|
2302
3877
|
|
|
2303
3878
|
// src/index.ts
|
|
2304
3879
|
init_errors();
|
|
2305
3880
|
|
|
2306
3881
|
// src/version.ts
|
|
2307
|
-
var VERSION = "8.
|
|
3882
|
+
var VERSION = "8.2.0";
|
|
2308
3883
|
|
|
2309
3884
|
exports.DEFAULT_PERMANENT_ERROR_CODES = DEFAULT_PERMANENT_ERROR_CODES;
|
|
2310
3885
|
exports.HttpBackendAdapter = HttpBackendAdapter;
|
|
@@ -2312,6 +3887,8 @@ exports.HttpClient = HttpClient;
|
|
|
2312
3887
|
exports.VERSION = VERSION;
|
|
2313
3888
|
exports.createIAP = createIAP;
|
|
2314
3889
|
exports.errorHint = errorHint;
|
|
3890
|
+
exports.isExpired = isExpired;
|
|
2315
3891
|
exports.isIAPError = isIAPError;
|
|
3892
|
+
exports.verifyPurchaseOnDevice = verifyPurchaseOnDevice;
|
|
2316
3893
|
//# sourceMappingURL=index.cjs.map
|
|
2317
3894
|
//# sourceMappingURL=index.cjs.map
|