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