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