@mpgd/adapter-ait 0.13.2 → 0.14.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/host.d.ts +13 -1
- package/dist/host.js +1247 -216
- package/dist/iap-diagnostics.d.ts +59 -0
- package/dist/iap-diagnostics.js +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/origins.d.ts +10 -0
- package/dist/origins.js +31 -0
- package/package.json +7 -3
package/dist/host.js
CHANGED
|
@@ -2,7 +2,10 @@ import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as gra
|
|
|
2
2
|
import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
|
|
3
3
|
import { assertAdShowInput, toAdAdapter } from '@mpgd/platform/ads';
|
|
4
4
|
import { createAitAdProvider } from './ad-provider.js';
|
|
5
|
+
import { aitIapDiagnosticCodes, aitIapProductNotGrantedByPartnerCode, } from './iap-diagnostics.js';
|
|
5
6
|
import { dispatchAitLifecycleEvent } from './lifecycle.js';
|
|
7
|
+
export { aitIapDiagnosticCodes, aitIapProductNotGrantedByPartnerCode, } from './iap-diagnostics.js';
|
|
8
|
+
export { aitBrowserOrigins, isAitBrowserOrigin } from './origins.js';
|
|
6
9
|
const defaultAdTimeoutMs = 60_000;
|
|
7
10
|
const defaultAdLoadQueueTimeoutMs = 5_000;
|
|
8
11
|
const defaultAdDisplayStartTimeoutMs = 60_000;
|
|
@@ -37,6 +40,26 @@ const maximumIndexedCompletedIapPurchaseAttempts = 64;
|
|
|
37
40
|
const pendingIapPurchaseAttemptRecoveryAgeMs = defaultIapPurchaseSessionTimeoutMs;
|
|
38
41
|
/** Rotates bounded pending-order recovery so an early rejected order cannot starve later work. */
|
|
39
42
|
const iapPendingOrderCursorStorageKey = 'mpgd:ait:pending-order-cursor:v1';
|
|
43
|
+
/**
|
|
44
|
+
* Links a provider order id to the client purchase attempt that opened it, so
|
|
45
|
+
* recovery paths can pass the game's own purchase key to the verifier and
|
|
46
|
+
* finish that attempt's retry barrier.
|
|
47
|
+
*/
|
|
48
|
+
const iapOrderAttemptLinkStoragePrefix = 'mpgd:ait:iap-order-attempt:v1:';
|
|
49
|
+
/**
|
|
50
|
+
* Orders whose provider acknowledgement was started after a server grant but
|
|
51
|
+
* not confirmed. A timed-out `completeProductGrant()` may still succeed later
|
|
52
|
+
* and remove the order from the pending list, so this list keeps the
|
|
53
|
+
* same-product checkout barrier until a later pass sees the order gone.
|
|
54
|
+
*/
|
|
55
|
+
const iapAcknowledgementsInFlightStorageKey = 'mpgd:ait:iap-ack-in-flight:v1';
|
|
56
|
+
const maximumIapAcknowledgementsInFlight = 32;
|
|
57
|
+
/**
|
|
58
|
+
* Barriers whose SKU is no longer configured are kept apart from the active
|
|
59
|
+
* capacity. That product is no longer sold, so once more than this many
|
|
60
|
+
* accumulate, the oldest is dropped; it only served diagnostics by then.
|
|
61
|
+
*/
|
|
62
|
+
const maximumRetiredIapAcknowledgements = 32;
|
|
40
63
|
const defaultNotificationAgreementTimeoutMs = 120_000;
|
|
41
64
|
const notificationTopics = new Set([
|
|
42
65
|
'daily-ready',
|
|
@@ -90,6 +113,12 @@ const defaultDependencies = {
|
|
|
90
113
|
submitGameCenterLeaderBoardScore,
|
|
91
114
|
iap: IAP,
|
|
92
115
|
};
|
|
116
|
+
/**
|
|
117
|
+
* Native IAP dependency objects whose single IAP lock is held. Module scoped
|
|
118
|
+
* so that two bridges on the same native SDK, for example after the host
|
|
119
|
+
* bridge is reinstalled while an old request is still active, share it.
|
|
120
|
+
*/
|
|
121
|
+
const aitIapExclusiveOperations = new WeakSet();
|
|
93
122
|
export function installAitHostBridge(options = {}) {
|
|
94
123
|
const bridge = createAitHostBridge(options);
|
|
95
124
|
globalThis.__GAME_PLATFORM_BRIDGE__ = bridge;
|
|
@@ -112,6 +141,16 @@ export function createAitHostBridge(options = {}) {
|
|
|
112
141
|
const iapProductGrantTimeoutMs = normalizeIapProductGrantTimeout(options.iapProductGrantTimeoutMs);
|
|
113
142
|
const promotionGrantsInFlight = new Map();
|
|
114
143
|
const iapPurchasesInFlight = new Map();
|
|
144
|
+
/**
|
|
145
|
+
* The single IAP lock, shared by every bridge built on the same native IAP
|
|
146
|
+
* dependency (see `aitIapExclusiveOperations`). A purchase holds it from
|
|
147
|
+
* its attempt-marker check until its checkout reaches a terminal result,
|
|
148
|
+
* and a restore holds it from before preparation until reconciliation and
|
|
149
|
+
* the entitlement read finish. Only one of them may read, verify or
|
|
150
|
+
* acknowledge provider orders or change attempt markers at a time; the
|
|
151
|
+
* other gets `AIT_IAP_CHECKOUT_IN_PROGRESS` and nothing is charged.
|
|
152
|
+
*/
|
|
153
|
+
const iapLockOwner = dependencies.iap;
|
|
115
154
|
let completedIapAttemptRetention = Promise.resolve();
|
|
116
155
|
const notificationSubscriptions = new Set();
|
|
117
156
|
const loadedAdGroupIds = new Set();
|
|
@@ -154,6 +193,16 @@ export function createAitHostBridge(options = {}) {
|
|
|
154
193
|
void (completedIapAttemptRetention = next);
|
|
155
194
|
return next;
|
|
156
195
|
};
|
|
196
|
+
/** Callers must hold the single IAP lock for the whole pass. */
|
|
197
|
+
const reconcilePendingIapOrders = async (verifier, timeoutMs) => {
|
|
198
|
+
return await reconcileAitIapPendingOrders({
|
|
199
|
+
dependencies,
|
|
200
|
+
products: iapProducts,
|
|
201
|
+
verifier,
|
|
202
|
+
retainCompletedAttempt: enqueueCompletedIapAttemptRetention,
|
|
203
|
+
timeoutMs,
|
|
204
|
+
});
|
|
205
|
+
};
|
|
157
206
|
return {
|
|
158
207
|
advertising: {
|
|
159
208
|
id: nativeAds.id,
|
|
@@ -297,15 +346,19 @@ export function createAitHostBridge(options = {}) {
|
|
|
297
346
|
}
|
|
298
347
|
}
|
|
299
348
|
// IAP is opt-in and server-authoritative. Never return demo grants.
|
|
300
|
-
case 'commerce.getProducts':
|
|
301
|
-
|
|
349
|
+
case 'commerce.getProducts': {
|
|
350
|
+
const catalog = await listAitIapProducts({
|
|
302
351
|
dependencies,
|
|
303
352
|
products: iapProducts,
|
|
304
353
|
prepare: options.prepareIap,
|
|
305
354
|
verifier: options.verifyIapProductGrant,
|
|
306
355
|
entitlementReader: options.readIapEntitlements,
|
|
307
356
|
timeoutMs: iapProductGrantTimeoutMs,
|
|
308
|
-
})
|
|
357
|
+
});
|
|
358
|
+
return catalog.status === 'ok'
|
|
359
|
+
? ok(request, catalog.products)
|
|
360
|
+
: createAitIapDiagnosticError(request.id, catalog.diagnostic, 'Apps in Toss IAP catalog is unavailable');
|
|
361
|
+
}
|
|
309
362
|
case 'commerce.purchase': {
|
|
310
363
|
const purchase = readCommercePurchase(request.payload);
|
|
311
364
|
const product = iapProducts.byProductId.get(purchase.productId);
|
|
@@ -319,61 +372,101 @@ export function createAitHostBridge(options = {}) {
|
|
|
319
372
|
entitlementReader: options.readIapEntitlements,
|
|
320
373
|
timeoutMs: iapProductGrantTimeoutMs,
|
|
321
374
|
});
|
|
322
|
-
if (
|
|
323
|
-
|| !iapSupported
|
|
375
|
+
if (!iapSupported
|
|
324
376
|
|| prepareIap === undefined
|
|
325
377
|
|| verifyIapProductGrant === undefined) {
|
|
326
|
-
return ok(request, failedPurchase());
|
|
327
|
-
}
|
|
328
|
-
const persisted = await resolvePersistedAitIapPurchaseAttempt({
|
|
329
|
-
dependencies,
|
|
330
|
-
storage: dependencies.storage,
|
|
331
|
-
product,
|
|
332
|
-
idempotencyKey: purchase.idempotencyKey,
|
|
333
|
-
timeoutMs: iapProductGrantTimeoutMs,
|
|
334
|
-
});
|
|
335
|
-
if (persisted !== undefined) {
|
|
336
|
-
return ok(request, persisted);
|
|
378
|
+
return ok(request, failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.unavailable, false)));
|
|
337
379
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
return ok(request, failedPurchase());
|
|
380
|
+
if (product === undefined) {
|
|
381
|
+
return ok(request, failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.productNotConfigured, false)));
|
|
341
382
|
}
|
|
342
383
|
const purchaseRequestKey = createAitIapPurchaseRequestKey(purchase.productId, purchase.idempotencyKey);
|
|
343
384
|
const existing = iapPurchasesInFlight.get(purchaseRequestKey);
|
|
344
385
|
if (existing !== undefined) {
|
|
345
386
|
return ok(request, await existing);
|
|
346
387
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
388
|
+
// Check and claim the single IAP lock synchronously, before reading
|
|
389
|
+
// the attempt marker: a restore or another key must not reconcile,
|
|
390
|
+
// acknowledge or clear markers while this attempt decides whether to
|
|
391
|
+
// open a checkout or while its grant callback is active.
|
|
392
|
+
if (aitIapExclusiveOperations.has(iapLockOwner)) {
|
|
393
|
+
return ok(request, failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.checkoutInProgress, true)));
|
|
394
|
+
}
|
|
395
|
+
aitIapExclusiveOperations.add(iapLockOwner);
|
|
396
|
+
const pending = (async () => {
|
|
397
|
+
const persisted = await resolvePersistedAitIapPurchaseAttempt({
|
|
398
|
+
dependencies,
|
|
399
|
+
storage: dependencies.storage,
|
|
400
|
+
product,
|
|
401
|
+
idempotencyKey: purchase.idempotencyKey,
|
|
402
|
+
timeoutMs: iapProductGrantTimeoutMs,
|
|
403
|
+
});
|
|
404
|
+
if (persisted !== undefined) {
|
|
405
|
+
return persisted;
|
|
406
|
+
}
|
|
407
|
+
const productUnavailable = await checkAitOneTimeIapProduct(dependencies, product, iapProductGrantTimeoutMs);
|
|
408
|
+
if (productUnavailable !== undefined) {
|
|
409
|
+
return failedPurchase(productUnavailable);
|
|
410
|
+
}
|
|
411
|
+
return await purchaseAitIapProduct({
|
|
412
|
+
dependencies,
|
|
413
|
+
products: iapProducts,
|
|
414
|
+
product,
|
|
415
|
+
idempotencyKey: purchase.idempotencyKey,
|
|
416
|
+
prepare: prepareIap,
|
|
417
|
+
verifier: verifyIapProductGrant,
|
|
418
|
+
retainCompletedAttempt: enqueueCompletedIapAttemptRetention,
|
|
419
|
+
reconcilePendingOrders: (timeoutMs) => reconcilePendingIapOrders(verifyIapProductGrant, timeoutMs),
|
|
420
|
+
timeoutMs: iapProductGrantTimeoutMs,
|
|
421
|
+
});
|
|
422
|
+
})();
|
|
356
423
|
iapPurchasesInFlight.set(purchaseRequestKey, pending);
|
|
357
424
|
try {
|
|
358
425
|
return ok(request, await pending);
|
|
359
426
|
}
|
|
360
427
|
finally {
|
|
428
|
+
aitIapExclusiveOperations.delete(iapLockOwner);
|
|
361
429
|
if (iapPurchasesInFlight.get(purchaseRequestKey) === pending) {
|
|
362
430
|
iapPurchasesInFlight.delete(purchaseRequestKey);
|
|
363
431
|
}
|
|
364
432
|
}
|
|
365
433
|
}
|
|
366
|
-
case 'commerce.restore':
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
434
|
+
case 'commerce.restore': {
|
|
435
|
+
// Claimed before preparation and held until restore finishes, so a
|
|
436
|
+
// purchase cannot start a checkout while restore is still preparing.
|
|
437
|
+
if (aitIapExclusiveOperations.has(iapLockOwner)) {
|
|
438
|
+
return createAitIapDiagnosticError(request.id, aitIapDiagnostic(aitIapDiagnosticCodes.checkoutInProgress, true), 'Apps in Toss purchase restore did not complete');
|
|
439
|
+
}
|
|
440
|
+
aitIapExclusiveOperations.add(iapLockOwner);
|
|
441
|
+
let restored;
|
|
442
|
+
try {
|
|
443
|
+
restored = await restoreAitIapProducts({
|
|
444
|
+
dependencies,
|
|
445
|
+
products: iapProducts,
|
|
446
|
+
prepare: options.prepareIap,
|
|
447
|
+
verifier: options.verifyIapProductGrant,
|
|
448
|
+
entitlementReader: options.readIapEntitlements,
|
|
449
|
+
reconcilePendingOrders: reconcilePendingIapOrders,
|
|
450
|
+
timeoutMs: iapProductGrantTimeoutMs,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
aitIapExclusiveOperations.delete(iapLockOwner);
|
|
455
|
+
}
|
|
456
|
+
return restored.status === 'ok'
|
|
457
|
+
? ok(request, restored.result)
|
|
458
|
+
: createAitIapDiagnosticError(request.id, restored.diagnostic, 'Apps in Toss purchase restore did not complete');
|
|
459
|
+
}
|
|
460
|
+
case 'commerce.getEntitlements': {
|
|
461
|
+
if (options.readIapEntitlements === undefined) {
|
|
462
|
+
return ok(request, []);
|
|
463
|
+
}
|
|
464
|
+
// A failed or timed-out authority read is not proof of no ownership.
|
|
465
|
+
const entitlements = await readAitIapEntitlementsIfAvailable(options.readIapEntitlements, iapProductGrantTimeoutMs);
|
|
466
|
+
return entitlements === undefined
|
|
467
|
+
? createAitIapDiagnosticError(request.id, aitIapDiagnostic(aitIapDiagnosticCodes.entitlementReadFailed, true), 'Apps in Toss purchase entitlements are unavailable')
|
|
468
|
+
: ok(request, entitlements);
|
|
469
|
+
}
|
|
377
470
|
case 'ads.preload': {
|
|
378
471
|
const placementId = readPlacementId(request.payload);
|
|
379
472
|
const adGroupId = adGroupIds.get(placementId);
|
|
@@ -1023,47 +1116,92 @@ function isAitIapSupported(input) {
|
|
|
1023
1116
|
&& isAitNativeMethodSupported(input.dependencies.iap.getPendingOrders)
|
|
1024
1117
|
&& isAitNativeMethodSupported(input.dependencies.iap.completeProductGrant);
|
|
1025
1118
|
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Product metadata is not authoritative, so a catalog that cannot be read is
|
|
1121
|
+
* never replaced with stale game-owned prices. Unconfigured or unsupported IAP
|
|
1122
|
+
* still lists no products; a configured catalog that fails reports a stable
|
|
1123
|
+
* diagnostic code instead of an indistinguishable empty list.
|
|
1124
|
+
*/
|
|
1026
1125
|
async function listAitIapProducts(input) {
|
|
1027
1126
|
if (!isAitIapSupported(input)) {
|
|
1028
|
-
return [];
|
|
1127
|
+
return { status: 'ok', products: [] };
|
|
1029
1128
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1129
|
+
const catalog = await readAitIapCatalog(input.dependencies, input.timeoutMs);
|
|
1130
|
+
if (catalog.status !== 'ok') {
|
|
1131
|
+
return catalog;
|
|
1132
|
+
}
|
|
1133
|
+
const products = [];
|
|
1134
|
+
for (const nativeProduct of catalog.products) {
|
|
1135
|
+
const configured = input.products.bySku.get(nativeProduct.sku);
|
|
1136
|
+
if (configured === undefined) {
|
|
1137
|
+
continue;
|
|
1034
1138
|
}
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (configured === undefined) {
|
|
1039
|
-
continue;
|
|
1040
|
-
}
|
|
1041
|
-
const product = toAitIapProductInfo(nativeProduct, configured);
|
|
1042
|
-
if (product !== undefined) {
|
|
1043
|
-
products.push(product);
|
|
1044
|
-
}
|
|
1139
|
+
const product = toAitIapProductInfo(nativeProduct, configured);
|
|
1140
|
+
if (product !== undefined) {
|
|
1141
|
+
products.push(product);
|
|
1045
1142
|
}
|
|
1046
|
-
return products;
|
|
1047
1143
|
}
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1144
|
+
if (products.length === 0) {
|
|
1145
|
+
return {
|
|
1146
|
+
status: 'failed',
|
|
1147
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.configuredSkusNotVisible, false),
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (products.length < input.products.byProductId.size) {
|
|
1151
|
+
console.warn('AIT IAP catalog is missing configured products.', {
|
|
1152
|
+
configuredCount: input.products.byProductId.size,
|
|
1153
|
+
visibleCount: products.length,
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
return { status: 'ok', products };
|
|
1157
|
+
}
|
|
1158
|
+
async function readAitIapCatalog(dependencies, timeoutMs) {
|
|
1159
|
+
const outcome = await callAitIapNative(() => joinAitIapNativeRead(dependencies.iap, 'getProductItemList', () => dependencies.iap.getProductItemList()), timeoutMs);
|
|
1160
|
+
if (outcome.status === 'timeout') {
|
|
1161
|
+
return {
|
|
1162
|
+
status: 'failed',
|
|
1163
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.catalogUnavailable, true),
|
|
1164
|
+
};
|
|
1052
1165
|
}
|
|
1166
|
+
if (outcome.status === 'error') {
|
|
1167
|
+
return {
|
|
1168
|
+
status: 'failed',
|
|
1169
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.catalogUnavailable, true, outcome.providerCode),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
// The SDK resolves without a value when the Toss app is too old.
|
|
1173
|
+
const result = outcome.value;
|
|
1174
|
+
if (!isRecord(result)) {
|
|
1175
|
+
return {
|
|
1176
|
+
status: 'failed',
|
|
1177
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.unsupportedAppVersion, false),
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
const products = Array.isArray(result.products)
|
|
1181
|
+
? result.products.filter((product) => isRecord(product) && typeof product.sku === 'string')
|
|
1182
|
+
: [];
|
|
1183
|
+
if (products.length === 0) {
|
|
1184
|
+
return {
|
|
1185
|
+
status: 'failed',
|
|
1186
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.catalogEmpty, true),
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
return { status: 'ok', products };
|
|
1053
1190
|
}
|
|
1054
|
-
|
|
1191
|
+
/** Returns undefined when the game authority could not be read in time. */
|
|
1192
|
+
async function readAitIapEntitlementsIfAvailable(reader, timeoutMs) {
|
|
1055
1193
|
if (reader === undefined || timeoutMs <= 0) {
|
|
1056
|
-
return
|
|
1194
|
+
return undefined;
|
|
1057
1195
|
}
|
|
1058
1196
|
try {
|
|
1059
1197
|
const entitlements = await waitForAitIapNativeCall(reader, timeoutMs);
|
|
1060
|
-
return entitlements === undefined ?
|
|
1198
|
+
return entitlements === undefined ? undefined : [...entitlements];
|
|
1061
1199
|
}
|
|
1062
1200
|
catch (error) {
|
|
1063
1201
|
// The game authority owns durable purchase state. A read failure must not
|
|
1064
1202
|
// turn stale local data into a visible entitlement.
|
|
1065
1203
|
console.warn('AIT IAP entitlement read failed; reporting no entitlements.', errorMessage(error));
|
|
1066
|
-
return
|
|
1204
|
+
return undefined;
|
|
1067
1205
|
}
|
|
1068
1206
|
}
|
|
1069
1207
|
/**
|
|
@@ -1072,18 +1210,15 @@ async function readAitIapEntitlements(reader, timeoutMs) {
|
|
|
1072
1210
|
* coalescing map has been cleared.
|
|
1073
1211
|
*/
|
|
1074
1212
|
async function resolvePersistedAitIapPurchaseAttempt(input) {
|
|
1213
|
+
const storageUnavailable = aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true);
|
|
1214
|
+
const attemptPending = aitIapDiagnostic(aitIapDiagnosticCodes.attemptPending, true);
|
|
1075
1215
|
const storageKey = aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey);
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
catch (error) {
|
|
1081
|
-
console.warn('AIT IAP purchase attempt storage read failed; treating the attempt as pending.', errorMessage(error));
|
|
1082
|
-
return pendingPurchase();
|
|
1083
|
-
}
|
|
1084
|
-
if (serialized === undefined) {
|
|
1085
|
-
return pendingPurchase();
|
|
1216
|
+
const read = await readAitIapStorage(input.storage, storageKey, input.timeoutMs);
|
|
1217
|
+
if (read.status !== 'ok') {
|
|
1218
|
+
console.warn('AIT IAP purchase attempt storage read failed; treating the attempt as pending.');
|
|
1219
|
+
return pendingPurchase(undefined, storageUnavailable);
|
|
1086
1220
|
}
|
|
1221
|
+
const serialized = read.value;
|
|
1087
1222
|
if (serialized === null) {
|
|
1088
1223
|
return undefined;
|
|
1089
1224
|
}
|
|
@@ -1093,57 +1228,94 @@ async function resolvePersistedAitIapPurchaseAttempt(input) {
|
|
|
1093
1228
|
}
|
|
1094
1229
|
catch (error) {
|
|
1095
1230
|
console.warn('AIT IAP purchase attempt marker is invalid; treating the attempt as pending.', errorMessage(error));
|
|
1096
|
-
return pendingPurchase();
|
|
1231
|
+
return pendingPurchase(undefined, storageUnavailable);
|
|
1097
1232
|
}
|
|
1098
|
-
if (!
|
|
1099
|
-
|
|
1100
|
-
|| state.idempotencyKey !== input.idempotencyKey) {
|
|
1101
|
-
return pendingPurchase();
|
|
1233
|
+
if (!isAitIapMarkerForAttempt(state, input.product, input.idempotencyKey)) {
|
|
1234
|
+
return pendingPurchase(undefined, storageUnavailable);
|
|
1102
1235
|
}
|
|
1103
1236
|
if (state.status === aitIapPurchaseAttemptStatus.pending) {
|
|
1104
1237
|
if (typeof state.orderId === 'string' && isAitIapOrderId(state.orderId)) {
|
|
1105
|
-
return pendingPurchase(state.orderId);
|
|
1238
|
+
return pendingPurchase(state.orderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true));
|
|
1106
1239
|
}
|
|
1107
1240
|
const pendingSince = normalizePendingSince(state.pendingSince);
|
|
1108
1241
|
if (pendingSince === undefined
|
|
1109
1242
|
|| !isAitIapPendingAttemptPastRecoveryWindow(pendingSince)) {
|
|
1110
|
-
return pendingPurchase();
|
|
1243
|
+
return pendingPurchase(undefined, attemptPending);
|
|
1111
1244
|
}
|
|
1112
1245
|
const hasPendingProviderOrder = await hasAitIapPendingOrderForSku(input.dependencies, input.product.sku, input.timeoutMs);
|
|
1113
1246
|
if (hasPendingProviderOrder !== false) {
|
|
1114
|
-
return pendingPurchase();
|
|
1247
|
+
return pendingPurchase(undefined, attemptPending);
|
|
1115
1248
|
}
|
|
1116
1249
|
const cleared = await clearAitIapPurchaseAttempt(input);
|
|
1117
|
-
return cleared ? undefined : pendingPurchase();
|
|
1250
|
+
return cleared ? undefined : pendingPurchase(undefined, storageUnavailable);
|
|
1118
1251
|
}
|
|
1119
1252
|
if (state.status === aitIapPurchaseAttemptStatus.serverGranted
|
|
1120
1253
|
&& typeof state.orderId === 'string'
|
|
1121
1254
|
&& isAitIapOrderId(state.orderId)) {
|
|
1122
|
-
return pendingPurchase(state.orderId);
|
|
1255
|
+
return pendingPurchase(state.orderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantCompletionFailed, true));
|
|
1123
1256
|
}
|
|
1124
1257
|
if (state.status === aitIapPurchaseAttemptStatus.completed
|
|
1125
1258
|
&& typeof state.orderId === 'string'
|
|
1126
1259
|
&& isAitIapOrderId(state.orderId)) {
|
|
1127
|
-
return completedAitIapPurchase(input.product, state.orderId);
|
|
1260
|
+
return completedAitIapPurchase(input.product, state.orderId, state.source === 'pending-order-restore' ? 'pending-order-restore' : 'process-product-grant');
|
|
1128
1261
|
}
|
|
1129
|
-
return pendingPurchase();
|
|
1262
|
+
return pendingPurchase(undefined, storageUnavailable);
|
|
1263
|
+
}
|
|
1264
|
+
const completedAitIapAttemptTombstoneVersion = 1;
|
|
1265
|
+
/**
|
|
1266
|
+
* Whether a parsed marker belongs to this product and client key. Completed
|
|
1267
|
+
* tombstones omit the client key (their storage key carries it); full-form
|
|
1268
|
+
* markers, including completed ones written before tombstones, must match it.
|
|
1269
|
+
*/
|
|
1270
|
+
function isAitIapMarkerForAttempt(state, product, idempotencyKey) {
|
|
1271
|
+
if (!isRecord(state) || state.productId !== product.productId) {
|
|
1272
|
+
return false;
|
|
1273
|
+
}
|
|
1274
|
+
if (state.v === completedAitIapAttemptTombstoneVersion) {
|
|
1275
|
+
return state.status === aitIapPurchaseAttemptStatus.completed
|
|
1276
|
+
&& state.idempotencyKey === undefined;
|
|
1277
|
+
}
|
|
1278
|
+
return state.v === undefined && state.idempotencyKey === idempotencyKey;
|
|
1130
1279
|
}
|
|
1131
1280
|
async function persistAitIapPurchaseAttempt(input) {
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
:
|
|
1139
|
-
|
|
1140
|
-
|
|
1281
|
+
// A completed marker is kept forever as the idempotency record of its
|
|
1282
|
+
// client key, so it is a compact tombstone: the storage key already names
|
|
1283
|
+
// the product and client key, and replay needs only the order and source.
|
|
1284
|
+
const marker = input.status === aitIapPurchaseAttemptStatus.completed
|
|
1285
|
+
? {
|
|
1286
|
+
v: completedAitIapAttemptTombstoneVersion,
|
|
1287
|
+
status: input.status,
|
|
1288
|
+
productId: input.product.productId,
|
|
1289
|
+
...(input.orderId === undefined ? {} : { orderId: input.orderId }),
|
|
1290
|
+
...(input.source === 'pending-order-restore' ? { source: input.source } : {}),
|
|
1291
|
+
}
|
|
1292
|
+
: {
|
|
1293
|
+
status: input.status,
|
|
1294
|
+
productId: input.product.productId,
|
|
1295
|
+
idempotencyKey: input.idempotencyKey,
|
|
1296
|
+
...(input.status === aitIapPurchaseAttemptStatus.pending
|
|
1297
|
+
? { pendingSince: new Date().toISOString() }
|
|
1298
|
+
: {}),
|
|
1299
|
+
...(input.orderId === undefined ? {} : { orderId: input.orderId }),
|
|
1300
|
+
};
|
|
1301
|
+
return await writeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), JSON.stringify(marker), input.timeoutMs);
|
|
1141
1302
|
}
|
|
1142
1303
|
async function clearAitIapPurchaseAttempt(input) {
|
|
1143
1304
|
return await removeAitIapStorage(input.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey), input.timeoutMs);
|
|
1144
1305
|
}
|
|
1145
1306
|
async function verifyAndPersistAitIapProductGrant(input) {
|
|
1146
1307
|
const startedAt = Date.now();
|
|
1308
|
+
// Best effort and not awaited here: the link only lets recovery pass the
|
|
1309
|
+
// client key for this order, so a slow write must never consume the grant
|
|
1310
|
+
// deadline. The purchase still settles it before releasing the IAP lock, so
|
|
1311
|
+
// restore never reads an absence that races this write.
|
|
1312
|
+
input.trackWrite(writeAitIapOrderAttemptLink({
|
|
1313
|
+
storage: input.storage,
|
|
1314
|
+
product: input.product,
|
|
1315
|
+
idempotencyKey: input.idempotencyKey,
|
|
1316
|
+
orderId: input.orderId,
|
|
1317
|
+
timeoutMs: input.timeoutMs,
|
|
1318
|
+
}));
|
|
1147
1319
|
const correlated = await persistAitIapPurchaseAttempt({
|
|
1148
1320
|
storage: input.storage,
|
|
1149
1321
|
product: input.product,
|
|
@@ -1167,6 +1339,7 @@ async function verifyAndPersistAitIapProductGrant(input) {
|
|
|
1167
1339
|
orderId: input.orderId,
|
|
1168
1340
|
product: input.product,
|
|
1169
1341
|
idempotencyKey: input.orderIdempotencyKey,
|
|
1342
|
+
clientIdempotencyKey: input.idempotencyKey,
|
|
1170
1343
|
source: input.source,
|
|
1171
1344
|
timeoutMs: verificationTimeoutMs,
|
|
1172
1345
|
});
|
|
@@ -1190,27 +1363,10 @@ function remainingAitIapOperationTimeout(startedAt, timeoutMs) {
|
|
|
1190
1363
|
return Math.max(0, timeoutMs - Math.max(0, Date.now() - startedAt));
|
|
1191
1364
|
}
|
|
1192
1365
|
async function writeAitIapStorage(storage, key, value, timeoutMs) {
|
|
1193
|
-
return await
|
|
1366
|
+
return (await runAitIapStorageOperation(storage, key, () => storage.setItem(key, value), timeoutMs)).status === 'ok';
|
|
1194
1367
|
}
|
|
1195
1368
|
async function removeAitIapStorage(storage, key, timeoutMs) {
|
|
1196
|
-
return await
|
|
1197
|
-
}
|
|
1198
|
-
async function waitForAitIapStorageOperation(operation, timeoutMs) {
|
|
1199
|
-
let timeout;
|
|
1200
|
-
const timeoutResult = new Promise((resolve) => {
|
|
1201
|
-
timeout = globalThis.setTimeout(() => resolve(false), timeoutMs);
|
|
1202
|
-
});
|
|
1203
|
-
const operationResult = Promise.resolve()
|
|
1204
|
-
.then(operation)
|
|
1205
|
-
.then(() => true, () => false);
|
|
1206
|
-
try {
|
|
1207
|
-
return await Promise.race([operationResult, timeoutResult]);
|
|
1208
|
-
}
|
|
1209
|
-
finally {
|
|
1210
|
-
if (timeout !== undefined) {
|
|
1211
|
-
globalThis.clearTimeout(timeout);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1369
|
+
return (await runAitIapStorageOperation(storage, key, () => storage.removeItem(key), timeoutMs)).status === 'ok';
|
|
1214
1370
|
}
|
|
1215
1371
|
function aitIapPurchaseAttemptStorageKey(productId, idempotencyKey) {
|
|
1216
1372
|
return `${iapPurchaseAttemptStoragePrefix}${encodeURIComponent(productId)}:${encodeURIComponent(idempotencyKey)}`;
|
|
@@ -1222,11 +1378,26 @@ function isAitIapPendingAttemptPastRecoveryWindow(pendingSince) {
|
|
|
1222
1378
|
}
|
|
1223
1379
|
async function hasAitIapPendingOrderForSku(dependencies, sku, timeoutMs) {
|
|
1224
1380
|
try {
|
|
1225
|
-
const result = await waitForAitIapNativeCall(() => dependencies.iap.getPendingOrders(), timeoutMs);
|
|
1226
|
-
|
|
1381
|
+
const result = await waitForAitIapNativeCall(() => joinAitIapNativeRead(dependencies.iap, 'getPendingOrders', () => dependencies.iap.getPendingOrders()), timeoutMs);
|
|
1382
|
+
const orders = isRecord(result) ? result.orders : undefined;
|
|
1383
|
+
if (!Array.isArray(orders)) {
|
|
1227
1384
|
return undefined;
|
|
1228
1385
|
}
|
|
1229
|
-
|
|
1386
|
+
let found = false;
|
|
1387
|
+
for (const order of orders) {
|
|
1388
|
+
if (!isRecord(order) || typeof order.sku !== 'string') {
|
|
1389
|
+
// An entry that may be this SKU's order is not proof of absence.
|
|
1390
|
+
return undefined;
|
|
1391
|
+
}
|
|
1392
|
+
if (order.sku !== sku) {
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
if (typeof order.orderId !== 'string' || !isAitIapOrderId(order.orderId)) {
|
|
1396
|
+
return undefined;
|
|
1397
|
+
}
|
|
1398
|
+
found = true;
|
|
1399
|
+
}
|
|
1400
|
+
return found;
|
|
1230
1401
|
}
|
|
1231
1402
|
catch (error) {
|
|
1232
1403
|
console.warn('AIT IAP pending-order lookup failed; preserving the client retry barrier.', errorMessage(error));
|
|
@@ -1236,14 +1407,18 @@ async function hasAitIapPendingOrderForSku(dependencies, sku, timeoutMs) {
|
|
|
1236
1407
|
/**
|
|
1237
1408
|
* Keep the completed-attempt index bounded without deleting terminal retry
|
|
1238
1409
|
* markers. A client idempotency key may be replayed indefinitely, so removing
|
|
1239
|
-
* its durable result could open a second native checkout.
|
|
1240
|
-
*
|
|
1410
|
+
* its durable result could open a second native checkout. Completed markers
|
|
1411
|
+
* are compact tombstones instead. The index is only bookkeeping for recent
|
|
1412
|
+
* entries; all storage failures leave markers intact.
|
|
1241
1413
|
*/
|
|
1242
1414
|
async function retainCompletedAitIapPurchaseAttempt(storage, storageKey, timeoutMs) {
|
|
1243
1415
|
const startedAt = Date.now();
|
|
1244
1416
|
try {
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1417
|
+
const read = await readAitIapStorage(storage, iapCompletedPurchaseAttemptIndexStorageKey, timeoutMs);
|
|
1418
|
+
if (read.status !== 'ok') {
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
const knownKeys = parseAitIapCompletedPurchaseAttemptIndex(read.value);
|
|
1247
1422
|
const keys = [...knownKeys.filter((knownKey) => knownKey !== storageKey), storageKey];
|
|
1248
1423
|
const retainedKeys = keys.slice(-maximumIndexedCompletedIapPurchaseAttempts);
|
|
1249
1424
|
const retentionTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
|
|
@@ -1276,14 +1451,13 @@ function parseAitIapCompletedPurchaseAttemptIndex(serialized) {
|
|
|
1276
1451
|
}
|
|
1277
1452
|
}
|
|
1278
1453
|
async function readAitIapPendingOrderCursor(storage, timeoutMs) {
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
}
|
|
1283
|
-
catch (error) {
|
|
1284
|
-
console.warn('AIT IAP pending-order cursor read failed; starting from the first eligible order.', errorMessage(error));
|
|
1454
|
+
const read = await readAitIapStorage(storage, iapPendingOrderCursorStorageKey, timeoutMs);
|
|
1455
|
+
if (read.status !== 'ok') {
|
|
1456
|
+
console.warn('AIT IAP pending-order cursor read failed; starting from the first eligible order.');
|
|
1285
1457
|
return undefined;
|
|
1286
1458
|
}
|
|
1459
|
+
const cursor = read.value;
|
|
1460
|
+
return typeof cursor === 'string' && isAitIapOrderId(cursor) ? cursor : undefined;
|
|
1287
1461
|
}
|
|
1288
1462
|
async function writeAitIapPendingOrderCursor(storage, orderId, timeoutMs) {
|
|
1289
1463
|
return await writeAitIapStorage(storage, iapPendingOrderCursorStorageKey, orderId, timeoutMs);
|
|
@@ -1313,7 +1487,14 @@ async function purchaseAitIapProduct(input) {
|
|
|
1313
1487
|
platformSku: input.product.sku,
|
|
1314
1488
|
}, input.timeoutMs);
|
|
1315
1489
|
if (!prepared) {
|
|
1316
|
-
return failedPurchase();
|
|
1490
|
+
return failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.preparationFailed, true));
|
|
1491
|
+
}
|
|
1492
|
+
// A paid order whose grant failed stays pending at the provider. Resolve it
|
|
1493
|
+
// before opening another checkout so the player is never charged twice for
|
|
1494
|
+
// a product they already paid for.
|
|
1495
|
+
const checkoutBarrier = resolveAitIapCheckoutBarrier(await input.reconcilePendingOrders(input.timeoutMs), input.product);
|
|
1496
|
+
if (checkoutBarrier !== undefined) {
|
|
1497
|
+
return failedPurchase(checkoutBarrier);
|
|
1317
1498
|
}
|
|
1318
1499
|
const started = await persistAitIapPurchaseAttempt({
|
|
1319
1500
|
storage: input.dependencies.storage,
|
|
@@ -1325,10 +1506,29 @@ async function purchaseAitIapProduct(input) {
|
|
|
1325
1506
|
if (!started) {
|
|
1326
1507
|
// We cannot tell whether a delayed native-storage write eventually
|
|
1327
1508
|
// succeeded. Do not open a checkout without a durable retry barrier.
|
|
1328
|
-
return pendingPurchase();
|
|
1509
|
+
return pendingPurchase(undefined, aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true));
|
|
1329
1510
|
}
|
|
1330
1511
|
return new Promise((resolve) => {
|
|
1331
1512
|
const grantAttempts = new Map();
|
|
1513
|
+
/**
|
|
1514
|
+
* Grant attempts, link writes and direct recovery that may outlive the
|
|
1515
|
+
* terminal callback. The result (and with it the IAP lock) is released
|
|
1516
|
+
* only after all of them settle; each is bounded by its own deadline.
|
|
1517
|
+
*/
|
|
1518
|
+
const inFlightWork = new Set();
|
|
1519
|
+
const trackWrite = (work) => {
|
|
1520
|
+
inFlightWork.add(work);
|
|
1521
|
+
const forget = () => {
|
|
1522
|
+
inFlightWork.delete(work);
|
|
1523
|
+
};
|
|
1524
|
+
void work.then(forget, forget);
|
|
1525
|
+
};
|
|
1526
|
+
/** Waits until no tracked work remains, including work added while waiting. */
|
|
1527
|
+
const drainInFlightWork = async () => {
|
|
1528
|
+
while (inFlightWork.size > 0) {
|
|
1529
|
+
await Promise.allSettled([...inFlightWork]);
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1332
1532
|
let settled = false;
|
|
1333
1533
|
let cleanup;
|
|
1334
1534
|
let cleanupRequested = false;
|
|
@@ -1351,7 +1551,9 @@ async function purchaseAitIapProduct(input) {
|
|
|
1351
1551
|
else {
|
|
1352
1552
|
safelyCleanupAitIapPurchase(cleanup);
|
|
1353
1553
|
}
|
|
1354
|
-
|
|
1554
|
+
void drainInFlightWork().then(() => {
|
|
1555
|
+
resolve(result);
|
|
1556
|
+
});
|
|
1355
1557
|
};
|
|
1356
1558
|
const startMarkerDeletion = (clearedResult) => {
|
|
1357
1559
|
markerDeletionStarted = true;
|
|
@@ -1361,10 +1563,17 @@ async function purchaseAitIapProduct(input) {
|
|
|
1361
1563
|
idempotencyKey: input.idempotencyKey,
|
|
1362
1564
|
timeoutMs: input.timeoutMs,
|
|
1363
1565
|
}).then((cleared) => {
|
|
1364
|
-
finish(cleared
|
|
1566
|
+
finish(cleared
|
|
1567
|
+
? clearedResult
|
|
1568
|
+
: pendingPurchase(undefined, aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true)));
|
|
1365
1569
|
});
|
|
1366
1570
|
};
|
|
1367
1571
|
const processProductGrant = (orderId) => {
|
|
1572
|
+
if (settled) {
|
|
1573
|
+
// The terminal result is already decided; never start a grant whose
|
|
1574
|
+
// writes could land after the IAP lock is released.
|
|
1575
|
+
return Promise.resolve(false);
|
|
1576
|
+
}
|
|
1368
1577
|
if (markerDeletionStarted) {
|
|
1369
1578
|
// Cancellation won the serialization race. Do not let a later queued
|
|
1370
1579
|
// grant callback commit after its retry barrier starts disappearing.
|
|
@@ -1387,6 +1596,7 @@ async function purchaseAitIapProduct(input) {
|
|
|
1387
1596
|
idempotencyKey: input.idempotencyKey,
|
|
1388
1597
|
orderIdempotencyKey: createAitIapOrderIdempotencyKey(orderId),
|
|
1389
1598
|
source: 'process-product-grant',
|
|
1599
|
+
trackWrite,
|
|
1390
1600
|
timeoutMs: input.timeoutMs,
|
|
1391
1601
|
}).then((granted) => {
|
|
1392
1602
|
if (granted) {
|
|
@@ -1395,13 +1605,80 @@ async function purchaseAitIapProduct(input) {
|
|
|
1395
1605
|
return granted;
|
|
1396
1606
|
});
|
|
1397
1607
|
grantAttempts.set(orderId, attempt);
|
|
1608
|
+
trackWrite(attempt);
|
|
1398
1609
|
return attempt;
|
|
1399
1610
|
};
|
|
1611
|
+
const completeAttempt = async (orderId, source) => {
|
|
1612
|
+
const persisted = await persistAitIapPurchaseAttempt({
|
|
1613
|
+
storage: input.dependencies.storage,
|
|
1614
|
+
product: input.product,
|
|
1615
|
+
idempotencyKey: input.idempotencyKey,
|
|
1616
|
+
orderId,
|
|
1617
|
+
status: aitIapPurchaseAttemptStatus.completed,
|
|
1618
|
+
source,
|
|
1619
|
+
timeoutMs: input.timeoutMs,
|
|
1620
|
+
});
|
|
1621
|
+
if (!persisted) {
|
|
1622
|
+
return pendingPurchase(orderId, aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true));
|
|
1623
|
+
}
|
|
1624
|
+
void input.retainCompletedAttempt(input.dependencies.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey));
|
|
1625
|
+
return completedAitIapPurchase(input.product, orderId, source);
|
|
1626
|
+
};
|
|
1627
|
+
/**
|
|
1628
|
+
* The SDK reports PRODUCT_NOT_GRANTED_BY_PARTNER after a real payment whose
|
|
1629
|
+
* grant callback returned false or ran out of time. The pending-order list
|
|
1630
|
+
* can lag right after checkout, so verify this exact order once more and
|
|
1631
|
+
* acknowledge it directly instead of waiting for a later restore.
|
|
1632
|
+
*/
|
|
1633
|
+
const recoverNotGrantedOrder = async (orderId, providerCode) => {
|
|
1634
|
+
const startedAt = Date.now();
|
|
1635
|
+
const priorAttempt = grantAttempts.get(orderId);
|
|
1636
|
+
let granted = priorAttempt === undefined ? false : await priorAttempt;
|
|
1637
|
+
if (!granted) {
|
|
1638
|
+
granted = await verifyAndPersistAitIapProductGrant({
|
|
1639
|
+
verifier: input.verifier,
|
|
1640
|
+
storage: input.dependencies.storage,
|
|
1641
|
+
orderId,
|
|
1642
|
+
product: input.product,
|
|
1643
|
+
idempotencyKey: input.idempotencyKey,
|
|
1644
|
+
orderIdempotencyKey: createAitIapOrderIdempotencyKey(orderId),
|
|
1645
|
+
source: 'pending-order-restore',
|
|
1646
|
+
trackWrite,
|
|
1647
|
+
timeoutMs: input.timeoutMs,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
if (!granted) {
|
|
1651
|
+
return pendingPurchase(orderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true, providerCode));
|
|
1652
|
+
}
|
|
1653
|
+
grantedOrderId = orderId;
|
|
1654
|
+
// Durably finish this client attempt before acknowledging: once
|
|
1655
|
+
// acknowledged, the order leaves the pending list and recovery could no
|
|
1656
|
+
// longer finish the marker. Without the marker, do not acknowledge.
|
|
1657
|
+
const completed = await completeAttempt(orderId, 'pending-order-restore');
|
|
1658
|
+
if (completed.status !== 'completed') {
|
|
1659
|
+
return completed;
|
|
1660
|
+
}
|
|
1661
|
+
const acknowledgement = await acknowledgeAitIapOrder({
|
|
1662
|
+
dependencies: input.dependencies,
|
|
1663
|
+
configuredSkus: input.products.bySku,
|
|
1664
|
+
orderId,
|
|
1665
|
+
product: input.product,
|
|
1666
|
+
idempotencyKey: input.idempotencyKey,
|
|
1667
|
+
timeoutMs: remainingAitIapOperationTimeout(startedAt, input.timeoutMs),
|
|
1668
|
+
});
|
|
1669
|
+
if (acknowledgement.status !== 'acknowledged') {
|
|
1670
|
+
// The server grant and the client marker are durable; restore will
|
|
1671
|
+
// acknowledge the still-pending provider order later.
|
|
1672
|
+
return pendingPurchase(orderId, acknowledgement.diagnostic);
|
|
1673
|
+
}
|
|
1674
|
+
await removeAitIapOrderAttemptLink(input.dependencies.storage, orderId, remainingAitIapOperationTimeout(startedAt, input.timeoutMs));
|
|
1675
|
+
return completed;
|
|
1676
|
+
};
|
|
1400
1677
|
sessionTimeout = globalThis.setTimeout(() => {
|
|
1401
1678
|
// The native SDK may have dispatched an order even when it fails to
|
|
1402
1679
|
// deliver a terminal callback. Preserve the marker and let retry or
|
|
1403
1680
|
// restore reconcile it instead of opening a second checkout.
|
|
1404
|
-
finish(pendingPurchase(grantedOrderId));
|
|
1681
|
+
finish(pendingPurchase(grantedOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.checkoutTimeout, true)));
|
|
1405
1682
|
}, defaultIapPurchaseSessionTimeoutMs);
|
|
1406
1683
|
try {
|
|
1407
1684
|
cleanup = input.dependencies.iap.createOneTimePurchaseOrder({
|
|
@@ -1410,26 +1687,50 @@ async function purchaseAitIapProduct(input) {
|
|
|
1410
1687
|
processProductGrant: ({ orderId }) => processProductGrant(orderId),
|
|
1411
1688
|
},
|
|
1412
1689
|
onEvent: async ({ data }) => {
|
|
1690
|
+
if (settled) {
|
|
1691
|
+
// A late terminal callback must not write after the result is decided.
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1413
1694
|
const granted = await (grantAttempts.get(data.orderId) ?? processProductGrant(data.orderId));
|
|
1414
|
-
if (
|
|
1415
|
-
finish(pendingPurchase(providerOrderId));
|
|
1695
|
+
if (settled) {
|
|
1416
1696
|
return;
|
|
1417
1697
|
}
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
product: input.product,
|
|
1422
|
-
idempotencyKey: input.idempotencyKey,
|
|
1423
|
-
orderId: data.orderId,
|
|
1424
|
-
status: aitIapPurchaseAttemptStatus.completed,
|
|
1425
|
-
timeoutMs: input.timeoutMs,
|
|
1426
|
-
});
|
|
1427
|
-
if (persisted) {
|
|
1428
|
-
void input.retainCompletedAttempt(input.dependencies.storage, aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey));
|
|
1698
|
+
if (!granted) {
|
|
1699
|
+
finish(pendingPurchase(providerOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true)));
|
|
1700
|
+
return;
|
|
1429
1701
|
}
|
|
1430
|
-
|
|
1702
|
+
// Tracked before awaiting, so another terminal path that finishes
|
|
1703
|
+
// meanwhile still waits for these writes before releasing the lock.
|
|
1704
|
+
const completion = (async () => {
|
|
1705
|
+
const completed = await completeAttempt(data.orderId, 'process-product-grant');
|
|
1706
|
+
if (completed.status === 'completed') {
|
|
1707
|
+
// The SDK acknowledged the order after the grant callback, and
|
|
1708
|
+
// the attempt marker is completed: the link is no longer needed.
|
|
1709
|
+
await removeAitIapOrderAttemptLink(input.dependencies.storage, data.orderId, input.timeoutMs);
|
|
1710
|
+
}
|
|
1711
|
+
return completed;
|
|
1712
|
+
})();
|
|
1713
|
+
trackWrite(completion);
|
|
1714
|
+
finish(await completion);
|
|
1431
1715
|
},
|
|
1432
1716
|
onError: (error) => {
|
|
1717
|
+
if (settled) {
|
|
1718
|
+
// A late native error must not start recovery after the result
|
|
1719
|
+
// (and the IAP lock) is released.
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const providerCode = readAitIapProviderCode(error);
|
|
1723
|
+
const knownOrderId = providerOrderId;
|
|
1724
|
+
if (providerCode === aitIapProductNotGrantedByPartnerCode
|
|
1725
|
+
&& knownOrderId !== undefined
|
|
1726
|
+
&& !markerDeletionStarted) {
|
|
1727
|
+
const recovery = recoverNotGrantedOrder(knownOrderId, providerCode);
|
|
1728
|
+
trackWrite(recovery);
|
|
1729
|
+
void recovery.then(finish, () => {
|
|
1730
|
+
finish(pendingPurchase(knownOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true, providerCode)));
|
|
1731
|
+
});
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1433
1734
|
if (grantedOrderId !== undefined
|
|
1434
1735
|
|| providerOrderId !== undefined
|
|
1435
1736
|
|| grantAttempts.size > 0
|
|
@@ -1437,7 +1738,10 @@ async function purchaseAitIapProduct(input) {
|
|
|
1437
1738
|
// A cancellation racing an authoritative grant is ambiguous. Keep
|
|
1438
1739
|
// the durable client retry barrier until retry/restore observes
|
|
1439
1740
|
// the verifier's eventual result.
|
|
1440
|
-
|
|
1741
|
+
const orderObserved = providerOrderId !== undefined || grantAttempts.size > 0;
|
|
1742
|
+
finish(pendingPurchase(grantedOrderId ?? providerOrderId, aitIapDiagnostic(orderObserved
|
|
1743
|
+
? aitIapDiagnosticCodes.grantPending
|
|
1744
|
+
: aitIapDiagnosticCodes.nativePurchaseFailed, true, providerCode)));
|
|
1441
1745
|
return;
|
|
1442
1746
|
}
|
|
1443
1747
|
startMarkerDeletion(cancelledPurchase());
|
|
@@ -1447,23 +1751,46 @@ async function purchaseAitIapProduct(input) {
|
|
|
1447
1751
|
safelyCleanupAitIapPurchase(cleanup);
|
|
1448
1752
|
}
|
|
1449
1753
|
}
|
|
1450
|
-
catch {
|
|
1754
|
+
catch (error) {
|
|
1451
1755
|
// A malformed SDK can invoke a grant callback and then throw during
|
|
1452
1756
|
// registration. Preserve that active order; only a throw with no grant
|
|
1453
1757
|
// startup proves there is no authoritative commit racing deletion.
|
|
1758
|
+
const providerCode = readAitIapProviderCode(error);
|
|
1454
1759
|
if (providerOrderId !== undefined || grantAttempts.size > 0) {
|
|
1455
|
-
finish(pendingPurchase(providerOrderId));
|
|
1760
|
+
finish(pendingPurchase(providerOrderId, aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true, providerCode)));
|
|
1456
1761
|
return;
|
|
1457
1762
|
}
|
|
1458
|
-
startMarkerDeletion(failedPurchase());
|
|
1763
|
+
startMarkerDeletion(failedPurchase(aitIapDiagnostic(aitIapDiagnosticCodes.checkoutStartFailed, true, providerCode)));
|
|
1459
1764
|
}
|
|
1460
1765
|
});
|
|
1461
1766
|
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Decides whether recovery found a reason not to open a new checkout for this
|
|
1769
|
+
* product. Orders for other configured products are still reconciled, but only
|
|
1770
|
+
* a same-product order can turn this purchase into a duplicate charge.
|
|
1771
|
+
*/
|
|
1772
|
+
function resolveAitIapCheckoutBarrier(reconciliation, product) {
|
|
1773
|
+
if (reconciliation.status === 'unavailable') {
|
|
1774
|
+
return reconciliation.diagnostic.code === aitIapDiagnosticCodes.unsupportedAppVersion
|
|
1775
|
+
? reconciliation.diagnostic
|
|
1776
|
+
: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true, reconciliation.diagnostic.providerCode);
|
|
1777
|
+
}
|
|
1778
|
+
if (reconciliation.unresolvedOrders.some((order) => order.sku === product.sku)) {
|
|
1779
|
+
return aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderUnresolved, true, reconciliation.diagnostic?.providerCode);
|
|
1780
|
+
}
|
|
1781
|
+
if (reconciliation.settledPurchases.some((settlement) => settlement.productId === product.productId)) {
|
|
1782
|
+
return aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderRecovered, true);
|
|
1783
|
+
}
|
|
1784
|
+
return undefined;
|
|
1785
|
+
}
|
|
1462
1786
|
async function restoreAitIapProducts(input) {
|
|
1463
1787
|
const prepare = input.prepare;
|
|
1464
1788
|
const verifier = input.verifier;
|
|
1465
1789
|
if (!isAitIapSupported(input) || prepare === undefined || verifier === undefined) {
|
|
1466
|
-
return {
|
|
1790
|
+
return {
|
|
1791
|
+
status: 'failed',
|
|
1792
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.unavailable, false),
|
|
1793
|
+
};
|
|
1467
1794
|
}
|
|
1468
1795
|
const restoreStartedAt = Date.now();
|
|
1469
1796
|
const getRemainingRestoreTimeout = () => {
|
|
@@ -1471,53 +1798,212 @@ async function restoreAitIapProducts(input) {
|
|
|
1471
1798
|
};
|
|
1472
1799
|
const prepared = await prepareAitIap(prepare, { intent: 'restore' }, getRemainingRestoreTimeout());
|
|
1473
1800
|
if (!prepared) {
|
|
1474
|
-
return {
|
|
1801
|
+
return {
|
|
1802
|
+
status: 'failed',
|
|
1803
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.preparationFailed, true),
|
|
1804
|
+
};
|
|
1475
1805
|
}
|
|
1476
1806
|
const postPrepareTimeoutMs = getRemainingRestoreTimeout();
|
|
1477
1807
|
const reservedEntitlementReadTimeoutMs = Math.max(1, Math.ceil(postPrepareTimeoutMs / 3));
|
|
1478
1808
|
const reconciliationTimeoutMs = Math.max(0, postPrepareTimeoutMs - reservedEntitlementReadTimeoutMs);
|
|
1479
|
-
|
|
1480
|
-
const getRemainingReconciliationTimeout = () => {
|
|
1481
|
-
return Math.min(getRemainingRestoreTimeout(), remainingAitIapOperationTimeout(reconciliationStartedAt, reconciliationTimeoutMs));
|
|
1482
|
-
};
|
|
1483
|
-
const finishRestore = async (locallyRestoredEntitlements = []) => {
|
|
1484
|
-
const entitlementTimeoutMs = getRemainingRestoreTimeout();
|
|
1485
|
-
const authoritativeEntitlements = await readAitIapEntitlements(input.entitlementReader, entitlementTimeoutMs);
|
|
1809
|
+
if (reconciliationTimeoutMs === 0) {
|
|
1486
1810
|
return {
|
|
1487
|
-
|
|
1811
|
+
status: 'failed',
|
|
1812
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.restoreTimeout, true),
|
|
1488
1813
|
};
|
|
1814
|
+
}
|
|
1815
|
+
const reconciliation = await input.reconcilePendingOrders(verifier, Math.min(reconciliationTimeoutMs, getRemainingRestoreTimeout()));
|
|
1816
|
+
if (reconciliation.status === 'unavailable') {
|
|
1817
|
+
// Restore exists to settle paid orders. If the provider list cannot be
|
|
1818
|
+
// read, report that instead of an empty success the game cannot tell
|
|
1819
|
+
// apart from "nothing to restore".
|
|
1820
|
+
return { status: 'failed', diagnostic: reconciliation.diagnostic };
|
|
1821
|
+
}
|
|
1822
|
+
const authoritativeEntitlements = await readAitIapEntitlementsIfAvailable(input.entitlementReader, getRemainingRestoreTimeout());
|
|
1823
|
+
const { settledPurchases } = reconciliation;
|
|
1824
|
+
if (authoritativeEntitlements === undefined && settledPurchases.length === 0) {
|
|
1825
|
+
return {
|
|
1826
|
+
status: 'failed',
|
|
1827
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.entitlementReadFailed, true),
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
let diagnostic;
|
|
1831
|
+
if (authoritativeEntitlements === undefined) {
|
|
1832
|
+
diagnostic = aitIapDiagnostic(aitIapDiagnosticCodes.entitlementReadFailed, true);
|
|
1833
|
+
}
|
|
1834
|
+
else if (reconciliation.unresolvedOrders.length > 0) {
|
|
1835
|
+
diagnostic = aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderUnresolved, true, reconciliation.diagnostic?.providerCode);
|
|
1836
|
+
}
|
|
1837
|
+
return {
|
|
1838
|
+
status: 'ok',
|
|
1839
|
+
result: {
|
|
1840
|
+
restoredEntitlements: mergeAitIapRestoredEntitlements(reconciliation.restoredEntitlements, authoritativeEntitlements ?? [], input.products),
|
|
1841
|
+
...(settledPurchases.length === 0 ? {} : { settledPurchases }),
|
|
1842
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
1843
|
+
},
|
|
1489
1844
|
};
|
|
1490
|
-
|
|
1491
|
-
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Verifies and acknowledges provider orders for configured SKUs that are paid
|
|
1848
|
+
* but not yet granted. Work is bounded per call and rotated across calls so a
|
|
1849
|
+
* permanently rejected order cannot starve later ones.
|
|
1850
|
+
*/
|
|
1851
|
+
async function reconcileAitIapPendingOrders(input) {
|
|
1852
|
+
const startedAt = Date.now();
|
|
1853
|
+
const getRemainingTimeout = () => {
|
|
1854
|
+
return remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
|
|
1855
|
+
};
|
|
1856
|
+
const pendingOrderTimeoutMs = getRemainingTimeout();
|
|
1857
|
+
if (pendingOrderTimeoutMs === 0) {
|
|
1858
|
+
return {
|
|
1859
|
+
status: 'unavailable',
|
|
1860
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true),
|
|
1861
|
+
};
|
|
1492
1862
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
}
|
|
1499
|
-
pendingOrders = await waitForAitIapNativeCall(() => input.dependencies.iap.getPendingOrders(), pendingOrderTimeoutMs);
|
|
1863
|
+
const pendingOrders = await callAitIapNative(() => joinAitIapNativeRead(input.dependencies.iap, 'getPendingOrders', () => input.dependencies.iap.getPendingOrders()), pendingOrderTimeoutMs);
|
|
1864
|
+
if (pendingOrders.status !== 'ok') {
|
|
1865
|
+
return {
|
|
1866
|
+
status: 'unavailable',
|
|
1867
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true, pendingOrders.status === 'error' ? pendingOrders.providerCode : undefined),
|
|
1868
|
+
};
|
|
1500
1869
|
}
|
|
1501
|
-
|
|
1502
|
-
|
|
1870
|
+
const pendingOrderResult = pendingOrders.value;
|
|
1871
|
+
if (!isRecord(pendingOrderResult)) {
|
|
1872
|
+
return {
|
|
1873
|
+
status: 'unavailable',
|
|
1874
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.unsupportedAppVersion, false),
|
|
1875
|
+
};
|
|
1503
1876
|
}
|
|
1504
|
-
if (
|
|
1505
|
-
|
|
1877
|
+
if (!Array.isArray(pendingOrderResult.orders)) {
|
|
1878
|
+
// A malformed list is not proof that no paid order exists.
|
|
1879
|
+
return {
|
|
1880
|
+
status: 'unavailable',
|
|
1881
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true),
|
|
1882
|
+
};
|
|
1506
1883
|
}
|
|
1884
|
+
const nativeOrders = pendingOrderResult.orders;
|
|
1507
1885
|
const eligibleOrders = [];
|
|
1508
1886
|
const seenOrderIds = new Set();
|
|
1509
|
-
|
|
1887
|
+
const malformedPendingOrders = {
|
|
1888
|
+
status: 'unavailable',
|
|
1889
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.pendingOrderCheckFailed, true),
|
|
1890
|
+
};
|
|
1891
|
+
/** Provider-reported SKU per pending order id. */
|
|
1892
|
+
const providerPendingOrderSkus = new Map();
|
|
1893
|
+
for (const order of nativeOrders) {
|
|
1894
|
+
// An entry that might be a configured product's paid order is never
|
|
1895
|
+
// proof of absence. Only well-formed entries for other SKUs are ignored.
|
|
1896
|
+
if (!isRecord(order) || typeof order.sku !== 'string') {
|
|
1897
|
+
return malformedPendingOrders;
|
|
1898
|
+
}
|
|
1899
|
+
if (typeof order.orderId === 'string') {
|
|
1900
|
+
const knownSku = providerPendingOrderSkus.get(order.orderId);
|
|
1901
|
+
if (knownSku !== undefined && knownSku !== order.sku) {
|
|
1902
|
+
// One order id reported with two SKUs cannot be attributed safely.
|
|
1903
|
+
return malformedPendingOrders;
|
|
1904
|
+
}
|
|
1905
|
+
providerPendingOrderSkus.set(order.orderId, order.sku);
|
|
1906
|
+
}
|
|
1510
1907
|
const product = input.products.bySku.get(order.sku);
|
|
1511
|
-
if (product === undefined
|
|
1512
|
-
|
|
1513
|
-
|
|
1908
|
+
if (product === undefined) {
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
if (typeof order.orderId !== 'string' || !isAitIapOrderId(order.orderId)) {
|
|
1912
|
+
return malformedPendingOrders;
|
|
1913
|
+
}
|
|
1914
|
+
if (seenOrderIds.has(order.orderId)) {
|
|
1514
1915
|
continue;
|
|
1515
1916
|
}
|
|
1516
1917
|
seenOrderIds.add(order.orderId);
|
|
1517
|
-
eligibleOrders.push({
|
|
1918
|
+
eligibleOrders.push({
|
|
1919
|
+
order: {
|
|
1920
|
+
orderId: order.orderId,
|
|
1921
|
+
sku: order.sku,
|
|
1922
|
+
paymentCompletedDate: typeof order.paymentCompletedDate === 'string'
|
|
1923
|
+
? order.paymentCompletedDate
|
|
1924
|
+
: '',
|
|
1925
|
+
},
|
|
1926
|
+
product,
|
|
1927
|
+
});
|
|
1518
1928
|
}
|
|
1929
|
+
const settledPurchases = [];
|
|
1519
1930
|
const restoredEntitlements = [];
|
|
1520
|
-
const
|
|
1931
|
+
const unresolvedOrders = [];
|
|
1932
|
+
let diagnostic;
|
|
1933
|
+
// A barrier whose order the provider no longer lists is settled only when
|
|
1934
|
+
// the server confirms the grant again; otherwise it stays an unresolved
|
|
1935
|
+
// same-product barrier. A settled barrier is reported (keeping the
|
|
1936
|
+
// same-product checkout barrier for this pass) and only then dropped,
|
|
1937
|
+
// together with its order link. Barriers for SKUs that are no longer
|
|
1938
|
+
// configured are kept but not resolved here.
|
|
1939
|
+
const acknowledgementReadTimeoutMs = getRemainingTimeout();
|
|
1940
|
+
const acknowledgements = acknowledgementReadTimeoutMs === 0
|
|
1941
|
+
? undefined
|
|
1942
|
+
: await readAitIapAcknowledgementsInFlight(input.dependencies.storage, acknowledgementReadTimeoutMs);
|
|
1943
|
+
if (acknowledgements === undefined) {
|
|
1944
|
+
return malformedPendingOrders;
|
|
1945
|
+
}
|
|
1946
|
+
const confirmedAcknowledgements = [];
|
|
1947
|
+
for (const entry of acknowledgements) {
|
|
1948
|
+
const pendingSku = providerPendingOrderSkus.get(entry.orderId);
|
|
1949
|
+
if (pendingSku !== undefined) {
|
|
1950
|
+
if (pendingSku !== entry.sku) {
|
|
1951
|
+
// The provider lists this barrier's order under another product. Do
|
|
1952
|
+
// not let it stop guarding its own product: fail closed instead.
|
|
1953
|
+
return malformedPendingOrders;
|
|
1954
|
+
}
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
const product = input.products.bySku.get(entry.sku);
|
|
1958
|
+
if (product === undefined) {
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
// Absence alone never settles a barrier, whatever its phase: a phase
|
|
1962
|
+
// write can land late without a dispatch, and the list can lag. Only the
|
|
1963
|
+
// server confirming the grant for this order settles it.
|
|
1964
|
+
const verificationTimeoutMs = getRemainingTimeout();
|
|
1965
|
+
const confirmed = verificationTimeoutMs > 0 && await verifyAitIapProductGrant({
|
|
1966
|
+
verifier: input.verifier,
|
|
1967
|
+
orderId: entry.orderId,
|
|
1968
|
+
product,
|
|
1969
|
+
idempotencyKey: createAitIapOrderIdempotencyKey(entry.orderId),
|
|
1970
|
+
...(entry.idempotencyKey === undefined
|
|
1971
|
+
? {}
|
|
1972
|
+
: { clientIdempotencyKey: entry.idempotencyKey }),
|
|
1973
|
+
source: 'pending-order-restore',
|
|
1974
|
+
timeoutMs: verificationTimeoutMs,
|
|
1975
|
+
});
|
|
1976
|
+
if (!confirmed) {
|
|
1977
|
+
unresolvedOrders.push({ orderId: entry.orderId, sku: entry.sku });
|
|
1978
|
+
diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true);
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
confirmedAcknowledgements.push(entry);
|
|
1982
|
+
settledPurchases.push({
|
|
1983
|
+
transactionId: entry.orderId,
|
|
1984
|
+
productId: product.productId,
|
|
1985
|
+
status: 'granted',
|
|
1986
|
+
...(entry.idempotencyKey === undefined ? {} : { idempotencyKey: entry.idempotencyKey }),
|
|
1987
|
+
});
|
|
1988
|
+
restoredEntitlements.push({
|
|
1989
|
+
id: product.productId,
|
|
1990
|
+
source: 'purchase',
|
|
1991
|
+
grantedAt: new Date().toISOString(),
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
if (confirmedAcknowledgements.length > 0) {
|
|
1995
|
+
const clearTimeoutMs = getRemainingTimeout();
|
|
1996
|
+
if (clearTimeoutMs > 0) {
|
|
1997
|
+
// A failed clear only reports the same settled order again next time.
|
|
1998
|
+
const cleared = await removeAitIapAcknowledgementsInFlight(input.dependencies.storage, confirmedAcknowledgements.map(({ orderId }) => orderId), clearTimeoutMs);
|
|
1999
|
+
if (cleared) {
|
|
2000
|
+
for (const { orderId } of confirmedAcknowledgements) {
|
|
2001
|
+
await removeAitIapOrderAttemptLink(input.dependencies.storage, orderId, getRemainingTimeout());
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
const cursorTimeoutMs = getRemainingTimeout();
|
|
1521
2007
|
const cursor = cursorTimeoutMs === 0
|
|
1522
2008
|
? undefined
|
|
1523
2009
|
: await readAitIapPendingOrderCursor(input.dependencies.storage, cursorTimeoutMs);
|
|
@@ -1527,53 +2013,492 @@ async function restoreAitIapProducts(input) {
|
|
|
1527
2013
|
limit: maximumPendingIapOrders,
|
|
1528
2014
|
ignoredOrderCount: eligibleOrders.length - orders.length,
|
|
1529
2015
|
});
|
|
2016
|
+
const selectedOrderIds = new Set(orders.map(({ order }) => order.orderId));
|
|
2017
|
+
for (const { order } of eligibleOrders) {
|
|
2018
|
+
if (!selectedOrderIds.has(order.orderId)) {
|
|
2019
|
+
unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
1530
2022
|
}
|
|
1531
|
-
for (const selectedOrder of orders) {
|
|
2023
|
+
for (const [index, selectedOrder] of orders.entries()) {
|
|
1532
2024
|
const { order, product } = selectedOrder;
|
|
1533
|
-
const
|
|
2025
|
+
const deferRemaining = () => {
|
|
2026
|
+
for (const deferred of orders.slice(index)) {
|
|
2027
|
+
unresolvedOrders.push({ orderId: deferred.order.orderId, sku: deferred.order.sku });
|
|
2028
|
+
}
|
|
2029
|
+
diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.restoreTimeout, true);
|
|
2030
|
+
};
|
|
2031
|
+
const cursorWriteTimeoutMs = getRemainingTimeout();
|
|
1534
2032
|
if (cursorWriteTimeoutMs === 0) {
|
|
2033
|
+
deferRemaining();
|
|
1535
2034
|
break;
|
|
1536
2035
|
}
|
|
1537
2036
|
const cursorAdvanced = await writeAitIapPendingOrderCursor(input.dependencies.storage, order.orderId, cursorWriteTimeoutMs);
|
|
1538
2037
|
if (!cursorAdvanced) {
|
|
2038
|
+
deferRemaining();
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
2041
|
+
const linkTimeoutMs = getRemainingTimeout();
|
|
2042
|
+
if (linkTimeoutMs === 0) {
|
|
2043
|
+
deferRemaining();
|
|
1539
2044
|
break;
|
|
1540
2045
|
}
|
|
1541
|
-
const
|
|
2046
|
+
const link = await readAitIapOrderAttemptLink({
|
|
2047
|
+
storage: input.dependencies.storage,
|
|
2048
|
+
product,
|
|
2049
|
+
orderId: order.orderId,
|
|
2050
|
+
timeoutMs: linkTimeoutMs,
|
|
2051
|
+
});
|
|
2052
|
+
if (link.status === 'unavailable') {
|
|
2053
|
+
unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
|
|
2054
|
+
diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true);
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
2057
|
+
const clientIdempotencyKey = link.status === 'linked' ? link.idempotencyKey : undefined;
|
|
2058
|
+
const verificationTimeoutMs = getRemainingTimeout();
|
|
1542
2059
|
if (verificationTimeoutMs === 0) {
|
|
2060
|
+
deferRemaining();
|
|
1543
2061
|
break;
|
|
1544
2062
|
}
|
|
1545
2063
|
const granted = await verifyAitIapProductGrant({
|
|
1546
|
-
verifier,
|
|
2064
|
+
verifier: input.verifier,
|
|
1547
2065
|
orderId: order.orderId,
|
|
1548
2066
|
product,
|
|
1549
2067
|
idempotencyKey: createAitIapOrderIdempotencyKey(order.orderId),
|
|
2068
|
+
...(clientIdempotencyKey === undefined ? {} : { clientIdempotencyKey }),
|
|
1550
2069
|
source: 'pending-order-restore',
|
|
1551
2070
|
timeoutMs: verificationTimeoutMs,
|
|
1552
2071
|
});
|
|
1553
2072
|
if (!granted) {
|
|
2073
|
+
unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
|
|
2074
|
+
diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.grantPending, true);
|
|
1554
2075
|
continue;
|
|
1555
2076
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
2077
|
+
if (clientIdempotencyKey !== undefined) {
|
|
2078
|
+
// Secure the client attempt before acknowledging: once acknowledged, the
|
|
2079
|
+
// order leaves the pending list and could no longer finish this marker.
|
|
2080
|
+
const markerTimeoutMs = getRemainingTimeout();
|
|
2081
|
+
const marker = markerTimeoutMs === 0
|
|
2082
|
+
? 'failed'
|
|
2083
|
+
: await completeLinkedAitIapPurchaseAttempt({
|
|
2084
|
+
storage: input.dependencies.storage,
|
|
2085
|
+
product,
|
|
2086
|
+
idempotencyKey: clientIdempotencyKey,
|
|
2087
|
+
orderId: order.orderId,
|
|
2088
|
+
retainCompletedAttempt: input.retainCompletedAttempt,
|
|
2089
|
+
timeoutMs: markerTimeoutMs,
|
|
1569
2090
|
});
|
|
2091
|
+
if (marker === 'failed') {
|
|
2092
|
+
unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
|
|
2093
|
+
diagnostic ??= aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true);
|
|
2094
|
+
continue;
|
|
1570
2095
|
}
|
|
1571
2096
|
}
|
|
1572
|
-
|
|
2097
|
+
const completionTimeoutMs = getRemainingTimeout();
|
|
2098
|
+
if (completionTimeoutMs === 0) {
|
|
2099
|
+
deferRemaining();
|
|
2100
|
+
break;
|
|
2101
|
+
}
|
|
2102
|
+
const acknowledgement = await acknowledgeAitIapOrder({
|
|
2103
|
+
dependencies: input.dependencies,
|
|
2104
|
+
configuredSkus: input.products.bySku,
|
|
2105
|
+
orderId: order.orderId,
|
|
2106
|
+
product,
|
|
2107
|
+
...(clientIdempotencyKey === undefined ? {} : { idempotencyKey: clientIdempotencyKey }),
|
|
2108
|
+
timeoutMs: completionTimeoutMs,
|
|
2109
|
+
});
|
|
2110
|
+
if (acknowledgement.status !== 'acknowledged') {
|
|
1573
2111
|
// Keep provider state pending when its completion acknowledgement fails.
|
|
2112
|
+
unresolvedOrders.push({ orderId: order.orderId, sku: order.sku });
|
|
2113
|
+
diagnostic ??= acknowledgement.diagnostic;
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
if (link.status === 'linked') {
|
|
2117
|
+
await removeAitIapOrderAttemptLink(input.dependencies.storage, order.orderId, getRemainingTimeout());
|
|
2118
|
+
}
|
|
2119
|
+
settledPurchases.push({
|
|
2120
|
+
transactionId: order.orderId,
|
|
2121
|
+
productId: product.productId,
|
|
2122
|
+
status: 'granted',
|
|
2123
|
+
...(clientIdempotencyKey === undefined ? {} : { idempotencyKey: clientIdempotencyKey }),
|
|
2124
|
+
});
|
|
2125
|
+
restoredEntitlements.push({
|
|
2126
|
+
id: product.productId,
|
|
2127
|
+
source: 'purchase',
|
|
2128
|
+
grantedAt: normalizeAitIapGrantTime(order.paymentCompletedDate),
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
return {
|
|
2132
|
+
status: 'reconciled',
|
|
2133
|
+
settledPurchases,
|
|
2134
|
+
restoredEntitlements,
|
|
2135
|
+
unresolvedOrders,
|
|
2136
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* Finishes the retry barrier of the client attempt that opened a recovered
|
|
2141
|
+
* order, so replaying that client key reports the completed purchase instead
|
|
2142
|
+
* of staying pending or reopening a checkout. It runs after the server grant
|
|
2143
|
+
* and before the provider acknowledgement. `failed` means the marker could not
|
|
2144
|
+
* be read or written and the order must stay unacknowledged; `not-applicable`
|
|
2145
|
+
* means no marker for this order needs finishing.
|
|
2146
|
+
*/
|
|
2147
|
+
async function completeLinkedAitIapPurchaseAttempt(input) {
|
|
2148
|
+
const startedAt = Date.now();
|
|
2149
|
+
const storageKey = aitIapPurchaseAttemptStorageKey(input.product.productId, input.idempotencyKey);
|
|
2150
|
+
const read = await readAitIapStorage(input.storage, storageKey, input.timeoutMs);
|
|
2151
|
+
if (read.status !== 'ok') {
|
|
2152
|
+
return 'failed';
|
|
2153
|
+
}
|
|
2154
|
+
// A linked attempt without a marker would let a replay of its key open a
|
|
2155
|
+
// new checkout for an order that is about to be acknowledged, so write the
|
|
2156
|
+
// completed marker in that case too.
|
|
2157
|
+
if (read.value !== null) {
|
|
2158
|
+
let state;
|
|
2159
|
+
try {
|
|
2160
|
+
state = JSON.parse(read.value);
|
|
2161
|
+
}
|
|
2162
|
+
catch {
|
|
2163
|
+
return 'failed';
|
|
2164
|
+
}
|
|
2165
|
+
if (!isAitIapMarkerForAttempt(state, input.product, input.idempotencyKey)) {
|
|
2166
|
+
// A corrupted linked marker cannot be finished; keep the order pending.
|
|
2167
|
+
return 'failed';
|
|
2168
|
+
}
|
|
2169
|
+
if (!isLinkedAitIapAttemptMarker(state, input.orderId)) {
|
|
2170
|
+
return 'not-applicable';
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
|
|
2174
|
+
if (remainingTimeoutMs === 0) {
|
|
2175
|
+
return 'failed';
|
|
2176
|
+
}
|
|
2177
|
+
const persisted = await persistAitIapPurchaseAttempt({
|
|
2178
|
+
storage: input.storage,
|
|
2179
|
+
product: input.product,
|
|
2180
|
+
idempotencyKey: input.idempotencyKey,
|
|
2181
|
+
orderId: input.orderId,
|
|
2182
|
+
status: aitIapPurchaseAttemptStatus.completed,
|
|
2183
|
+
source: 'pending-order-restore',
|
|
2184
|
+
timeoutMs: remainingTimeoutMs,
|
|
2185
|
+
});
|
|
2186
|
+
if (!persisted) {
|
|
2187
|
+
return 'failed';
|
|
2188
|
+
}
|
|
2189
|
+
void input.retainCompletedAttempt(input.storage, storageKey);
|
|
2190
|
+
return 'completed';
|
|
2191
|
+
}
|
|
2192
|
+
/**
|
|
2193
|
+
* Acknowledges a server-granted order with `completeProductGrant()`. The
|
|
2194
|
+
* order is first recorded as in flight, so a timed-out acknowledgement that
|
|
2195
|
+
* succeeds later still leaves a same-product checkout barrier. The record is
|
|
2196
|
+
* removed after a confirmed success or a definitive rejection; after a
|
|
2197
|
+
* timeout it stays until reconciliation sees the order leave the provider
|
|
2198
|
+
* list. Without the record, the order is not acknowledged.
|
|
2199
|
+
*/
|
|
2200
|
+
/**
|
|
2201
|
+
* Native `completeProductGrant()` calls that have not settled yet, per IAP
|
|
2202
|
+
* dependency object and order. A retry for the same order joins the running
|
|
2203
|
+
* call instead of dispatching a second acknowledgement.
|
|
2204
|
+
*/
|
|
2205
|
+
const aitIapAcknowledgementsRunning = new WeakMap();
|
|
2206
|
+
function dispatchAitIapAcknowledgement(iap, orderId) {
|
|
2207
|
+
let running = aitIapAcknowledgementsRunning.get(iap);
|
|
2208
|
+
if (running === undefined) {
|
|
2209
|
+
running = new Map();
|
|
2210
|
+
aitIapAcknowledgementsRunning.set(iap, running);
|
|
2211
|
+
}
|
|
2212
|
+
const existing = running.get(orderId);
|
|
2213
|
+
if (existing !== undefined) {
|
|
2214
|
+
return existing;
|
|
2215
|
+
}
|
|
2216
|
+
const calls = running;
|
|
2217
|
+
const call = Promise.resolve()
|
|
2218
|
+
.then(() => iap.completeProductGrant({ params: { orderId } }))
|
|
2219
|
+
.then((value) => ({ status: 'ok', value: value === true }), (error) => {
|
|
2220
|
+
const providerCode = readAitIapProviderCode(error);
|
|
2221
|
+
return providerCode === undefined ? { status: 'error' } : { status: 'error', providerCode };
|
|
2222
|
+
})
|
|
2223
|
+
.finally(() => {
|
|
2224
|
+
if (calls.get(orderId) === call) {
|
|
2225
|
+
calls.delete(orderId);
|
|
2226
|
+
}
|
|
2227
|
+
});
|
|
2228
|
+
calls.set(orderId, call);
|
|
2229
|
+
return call;
|
|
2230
|
+
}
|
|
2231
|
+
function isAitIapAcknowledgementRunning(iap, orderId) {
|
|
2232
|
+
return aitIapAcknowledgementsRunning.get(iap)?.has(orderId) === true;
|
|
2233
|
+
}
|
|
2234
|
+
async function acknowledgeAitIapOrder(input) {
|
|
2235
|
+
const startedAt = Date.now();
|
|
2236
|
+
const remaining = () => remainingAitIapOperationTimeout(startedAt, input.timeoutMs);
|
|
2237
|
+
const barrierUnavailable = {
|
|
2238
|
+
status: 'not-acknowledged',
|
|
2239
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.attemptStorageUnavailable, true),
|
|
2240
|
+
};
|
|
2241
|
+
const storage = input.dependencies.storage;
|
|
2242
|
+
const entries = remaining() === 0
|
|
2243
|
+
? undefined
|
|
2244
|
+
: await readAitIapAcknowledgementsInFlight(storage, remaining());
|
|
2245
|
+
if (entries === undefined) {
|
|
2246
|
+
return barrierUnavailable;
|
|
2247
|
+
}
|
|
2248
|
+
const iap = input.dependencies.iap;
|
|
2249
|
+
const existing = entries.find((entry) => entry.orderId === input.orderId);
|
|
2250
|
+
const otherEntries = entries.filter((entry) => entry.orderId !== input.orderId);
|
|
2251
|
+
const activeCount = otherEntries
|
|
2252
|
+
.filter((entry) => input.configuredSkus.has(entry.sku)).length;
|
|
2253
|
+
if (activeCount >= maximumIapAcknowledgementsInFlight) {
|
|
2254
|
+
return barrierUnavailable;
|
|
2255
|
+
}
|
|
2256
|
+
const retired = otherEntries.filter((entry) => !input.configuredSkus.has(entry.sku));
|
|
2257
|
+
const droppedRetiredOrderIds = new Set(retired
|
|
2258
|
+
.slice(0, Math.max(0, retired.length - maximumRetiredIapAcknowledgements))
|
|
2259
|
+
.map((entry) => entry.orderId));
|
|
2260
|
+
const others = otherEntries.filter((entry) => !droppedRetiredOrderIds.has(entry.orderId));
|
|
2261
|
+
// A `started` barrier stays as written: a running acknowledgement is
|
|
2262
|
+
// joined, and a fresh dispatch (only possible once no call is running, for
|
|
2263
|
+
// example after a reload) reuses the existing barrier.
|
|
2264
|
+
const barrierStarted = existing?.phase === 'started';
|
|
2265
|
+
if (!barrierStarted && isAitIapAcknowledgementRunning(iap, input.orderId)) {
|
|
2266
|
+
// A call is running without a durable `started` barrier; never touch it.
|
|
2267
|
+
return barrierUnavailable;
|
|
2268
|
+
}
|
|
2269
|
+
const withPhase = (phase) => JSON.stringify([
|
|
2270
|
+
...others,
|
|
2271
|
+
{
|
|
2272
|
+
orderId: input.orderId,
|
|
2273
|
+
sku: input.product.sku,
|
|
2274
|
+
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
|
2275
|
+
phase,
|
|
2276
|
+
},
|
|
2277
|
+
]);
|
|
2278
|
+
if (!barrierStarted) {
|
|
2279
|
+
// Two durable steps: the barrier first, then the dispatch mark. Only a
|
|
2280
|
+
// record marked `started` may later be confirmed by the order's absence.
|
|
2281
|
+
const recorded = remaining() > 0 && await writeAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, withPhase('recorded'), remaining());
|
|
2282
|
+
if (!recorded) {
|
|
2283
|
+
return barrierUnavailable;
|
|
2284
|
+
}
|
|
2285
|
+
const dispatchMarked = remaining() > 0 && await writeAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, withPhase('started'), remaining());
|
|
2286
|
+
if (!dispatchMarked) {
|
|
2287
|
+
return barrierUnavailable;
|
|
1574
2288
|
}
|
|
1575
2289
|
}
|
|
1576
|
-
|
|
2290
|
+
const completionTimeoutMs = remaining();
|
|
2291
|
+
const completion = completionTimeoutMs === 0
|
|
2292
|
+
? { status: 'timeout' }
|
|
2293
|
+
: await callAitIapNative(() => dispatchAitIapAcknowledgement(iap, input.orderId), completionTimeoutMs).then((outcome) => (outcome.status === 'ok' ? outcome.value : outcome));
|
|
2294
|
+
if (completion.status === 'ok' && completion.value) {
|
|
2295
|
+
// Only a confirmed acknowledgement clears a `started` barrier here.
|
|
2296
|
+
await removeAitIapAcknowledgementsInFlight(storage, [input.orderId], Math.max(1, remaining()));
|
|
2297
|
+
return { status: 'acknowledged' };
|
|
2298
|
+
}
|
|
2299
|
+
// A timeout, `false` or an error leaves the barrier `started`: an earlier
|
|
2300
|
+
// or still-running dispatch may yet succeed. The order stays unresolved
|
|
2301
|
+
// until a confirmed acknowledgement or the order leaving the provider list.
|
|
2302
|
+
if (completion.status === 'timeout') {
|
|
2303
|
+
return {
|
|
2304
|
+
status: 'not-acknowledged',
|
|
2305
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.grantCompletionFailed, true),
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
return {
|
|
2309
|
+
status: 'not-acknowledged',
|
|
2310
|
+
diagnostic: aitIapDiagnostic(aitIapDiagnosticCodes.grantCompletionFailed, true, completion.status === 'error' ? completion.providerCode : undefined),
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
/** Returns undefined when the list cannot be read or is malformed. */
|
|
2314
|
+
async function readAitIapAcknowledgementsInFlight(storage, timeoutMs) {
|
|
2315
|
+
const read = await readAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, timeoutMs);
|
|
2316
|
+
if (read.status !== 'ok') {
|
|
2317
|
+
return undefined;
|
|
2318
|
+
}
|
|
2319
|
+
if (read.value === null) {
|
|
2320
|
+
return [];
|
|
2321
|
+
}
|
|
2322
|
+
try {
|
|
2323
|
+
const value = JSON.parse(read.value);
|
|
2324
|
+
if (!Array.isArray(value)) {
|
|
2325
|
+
return undefined;
|
|
2326
|
+
}
|
|
2327
|
+
const entries = [];
|
|
2328
|
+
for (const entry of value) {
|
|
2329
|
+
if (!isRecord(entry)
|
|
2330
|
+
|| typeof entry.orderId !== 'string'
|
|
2331
|
+
|| !isAitIapOrderId(entry.orderId)
|
|
2332
|
+
|| typeof entry.sku !== 'string'
|
|
2333
|
+
|| (entry.idempotencyKey !== undefined
|
|
2334
|
+
&& !isAitIapClientIdempotencyKey(entry.idempotencyKey))
|
|
2335
|
+
|| (entry.phase !== 'recorded' && entry.phase !== 'started')) {
|
|
2336
|
+
return undefined;
|
|
2337
|
+
}
|
|
2338
|
+
entries.push({
|
|
2339
|
+
orderId: entry.orderId,
|
|
2340
|
+
sku: entry.sku,
|
|
2341
|
+
...(typeof entry.idempotencyKey === 'string'
|
|
2342
|
+
? { idempotencyKey: entry.idempotencyKey }
|
|
2343
|
+
: {}),
|
|
2344
|
+
phase: entry.phase,
|
|
2345
|
+
});
|
|
2346
|
+
}
|
|
2347
|
+
return entries;
|
|
2348
|
+
}
|
|
2349
|
+
catch {
|
|
2350
|
+
return undefined;
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
async function removeAitIapAcknowledgementsInFlight(storage, orderIds, timeoutMs) {
|
|
2354
|
+
const startedAt = Date.now();
|
|
2355
|
+
const entries = await readAitIapAcknowledgementsInFlight(storage, timeoutMs);
|
|
2356
|
+
const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
|
|
2357
|
+
if (entries === undefined || remainingTimeoutMs === 0) {
|
|
2358
|
+
return false;
|
|
2359
|
+
}
|
|
2360
|
+
const removed = new Set(orderIds);
|
|
2361
|
+
return await writeAitIapStorage(storage, iapAcknowledgementsInFlightStorageKey, JSON.stringify(entries.filter((entry) => !removed.has(entry.orderId))), remainingTimeoutMs);
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* Per native storage object and adapter-reserved key, operations run strictly
|
|
2365
|
+
* one after another. Each starts only after every earlier operation on that
|
|
2366
|
+
* key has actually settled, not merely timed out, so a late-landing write can
|
|
2367
|
+
* never overwrite a newer one and a read never observes an absence that races
|
|
2368
|
+
* an in-flight write. A caller whose turn does not come within its deadline
|
|
2369
|
+
* gets `timeout` and its operation is never started. Idle keys are forgotten.
|
|
2370
|
+
*/
|
|
2371
|
+
const aitIapStorageQueues = new WeakMap();
|
|
2372
|
+
async function runAitIapStorageOperation(storage, key, operation, timeoutMs) {
|
|
2373
|
+
const startedAt = Date.now();
|
|
2374
|
+
let queues = aitIapStorageQueues.get(storage);
|
|
2375
|
+
if (queues === undefined) {
|
|
2376
|
+
queues = new Map();
|
|
2377
|
+
aitIapStorageQueues.set(storage, queues);
|
|
2378
|
+
}
|
|
2379
|
+
const keyQueues = queues;
|
|
2380
|
+
let queue = keyQueues.get(key);
|
|
2381
|
+
if (queue === undefined) {
|
|
2382
|
+
queue = { tail: Promise.resolve(), active: 0, hung: false };
|
|
2383
|
+
keyQueues.set(key, queue);
|
|
2384
|
+
}
|
|
2385
|
+
const current = queue;
|
|
2386
|
+
if (current.hung) {
|
|
2387
|
+
return { status: 'timeout' };
|
|
2388
|
+
}
|
|
2389
|
+
const previous = current.tail;
|
|
2390
|
+
let release = () => { };
|
|
2391
|
+
const turn = new Promise((resolve) => {
|
|
2392
|
+
let released = false;
|
|
2393
|
+
release = () => {
|
|
2394
|
+
if (released) {
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
released = true;
|
|
2398
|
+
current.active -= 1;
|
|
2399
|
+
if (current.active === 0 && keyQueues.get(key) === current) {
|
|
2400
|
+
keyQueues.delete(key);
|
|
2401
|
+
}
|
|
2402
|
+
resolve();
|
|
2403
|
+
};
|
|
2404
|
+
});
|
|
2405
|
+
current.active += 1;
|
|
2406
|
+
current.tail = previous.then(() => turn);
|
|
2407
|
+
const ready = await callAitIapNative(() => previous, timeoutMs);
|
|
2408
|
+
const remainingTimeoutMs = remainingAitIapOperationTimeout(startedAt, timeoutMs);
|
|
2409
|
+
if (ready.status !== 'ok' || remainingTimeoutMs === 0) {
|
|
2410
|
+
// Give up this turn without starting the operation. Later operations
|
|
2411
|
+
// still wait for the earlier ones through the tail chain.
|
|
2412
|
+
if (ready.status !== 'ok') {
|
|
2413
|
+
current.hung = true;
|
|
2414
|
+
}
|
|
2415
|
+
void previous.then(release);
|
|
2416
|
+
return { status: 'timeout' };
|
|
2417
|
+
}
|
|
2418
|
+
let started;
|
|
2419
|
+
try {
|
|
2420
|
+
started = Promise.resolve(operation());
|
|
2421
|
+
}
|
|
2422
|
+
catch (error) {
|
|
2423
|
+
release();
|
|
2424
|
+
const providerCode = readAitIapProviderCode(error);
|
|
2425
|
+
return providerCode === undefined ? { status: 'error' } : { status: 'error', providerCode };
|
|
2426
|
+
}
|
|
2427
|
+
void started.then(release, release);
|
|
2428
|
+
const outcome = await callAitIapNative(() => started, remainingTimeoutMs);
|
|
2429
|
+
if (outcome.status === 'timeout') {
|
|
2430
|
+
current.hung = true;
|
|
2431
|
+
}
|
|
2432
|
+
return outcome;
|
|
2433
|
+
}
|
|
2434
|
+
async function readAitIapStorage(storage, key, timeoutMs) {
|
|
2435
|
+
const read = await runAitIapStorageOperation(storage, key, () => storage.getItem(key), timeoutMs);
|
|
2436
|
+
if (read.status !== 'ok') {
|
|
2437
|
+
return read;
|
|
2438
|
+
}
|
|
2439
|
+
const value = read.value;
|
|
2440
|
+
return value === null || typeof value === 'string'
|
|
2441
|
+
? { status: 'ok', value }
|
|
2442
|
+
: { status: 'error' };
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* The caller already validated the order link for this client key and
|
|
2446
|
+
* product. A pending marker without an order id is the same attempt whose
|
|
2447
|
+
* order-correlation write failed while the link write succeeded.
|
|
2448
|
+
*/
|
|
2449
|
+
function isLinkedAitIapAttemptMarker(state, orderId) {
|
|
2450
|
+
if (state.status === aitIapPurchaseAttemptStatus.serverGranted) {
|
|
2451
|
+
return state.orderId === orderId;
|
|
2452
|
+
}
|
|
2453
|
+
return state.status === aitIapPurchaseAttemptStatus.pending
|
|
2454
|
+
&& (state.orderId === orderId || state.orderId === undefined);
|
|
2455
|
+
}
|
|
2456
|
+
async function writeAitIapOrderAttemptLink(input) {
|
|
2457
|
+
return await writeAitIapStorage(input.storage, aitIapOrderAttemptLinkStorageKey(input.orderId), JSON.stringify({
|
|
2458
|
+
productId: input.product.productId,
|
|
2459
|
+
idempotencyKey: input.idempotencyKey,
|
|
2460
|
+
}), input.timeoutMs);
|
|
2461
|
+
}
|
|
2462
|
+
/** Best effort: a link has no recovery role once its order is terminal. */
|
|
2463
|
+
async function removeAitIapOrderAttemptLink(storage, orderId, timeoutMs) {
|
|
2464
|
+
if (timeoutMs > 0) {
|
|
2465
|
+
await removeAitIapStorage(storage, aitIapOrderAttemptLinkStorageKey(orderId), timeoutMs);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
/**
|
|
2469
|
+
* Only a successful read that returns no value proves the order has no client
|
|
2470
|
+
* attempt link. A read also waits for any in-flight write of the same link. A failed, timed-out or unreadable link must keep the order
|
|
2471
|
+
* unacknowledged, or its attempt marker could later be cleared and reopen a
|
|
2472
|
+
* checkout for an order that was already granted.
|
|
2473
|
+
*/
|
|
2474
|
+
async function readAitIapOrderAttemptLink(input) {
|
|
2475
|
+
const read = await readAitIapStorage(input.storage, aitIapOrderAttemptLinkStorageKey(input.orderId), input.timeoutMs);
|
|
2476
|
+
if (read.status !== 'ok') {
|
|
2477
|
+
return { status: 'unavailable' };
|
|
2478
|
+
}
|
|
2479
|
+
if (read.value === null) {
|
|
2480
|
+
return { status: 'absent' };
|
|
2481
|
+
}
|
|
2482
|
+
try {
|
|
2483
|
+
const link = JSON.parse(read.value);
|
|
2484
|
+
return isRecord(link)
|
|
2485
|
+
&& link.productId === input.product.productId
|
|
2486
|
+
&& isAitIapClientIdempotencyKey(link.idempotencyKey)
|
|
2487
|
+
? { status: 'linked', idempotencyKey: link.idempotencyKey }
|
|
2488
|
+
: { status: 'unavailable' };
|
|
2489
|
+
}
|
|
2490
|
+
catch {
|
|
2491
|
+
return { status: 'unavailable' };
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
function aitIapOrderAttemptLinkStorageKey(orderId) {
|
|
2495
|
+
return `${iapOrderAttemptLinkStoragePrefix}${encodeURIComponent(orderId)}`;
|
|
2496
|
+
}
|
|
2497
|
+
function isAitIapClientIdempotencyKey(value) {
|
|
2498
|
+
return typeof value === 'string'
|
|
2499
|
+
&& value.length > 0
|
|
2500
|
+
&& value.length <= 256
|
|
2501
|
+
&& !/[\p{Cc}\p{Cf}]/u.test(value);
|
|
1577
2502
|
}
|
|
1578
2503
|
function mergeAitIapRestoredEntitlements(restoredEntitlements, authoritativeEntitlements, products) {
|
|
1579
2504
|
const entitlementsById = new Map();
|
|
@@ -1609,22 +2534,73 @@ async function prepareAitIap(prepare, input, timeoutMs) {
|
|
|
1609
2534
|
}
|
|
1610
2535
|
}
|
|
1611
2536
|
}
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
2537
|
+
/**
|
|
2538
|
+
* The provider catalog is the authoritative source for the purchase flow. Do
|
|
2539
|
+
* not route an unknown or unavailable SKU into a one-time order; return why.
|
|
2540
|
+
*/
|
|
2541
|
+
async function checkAitOneTimeIapProduct(dependencies, configured, timeoutMs) {
|
|
2542
|
+
const catalog = await readAitIapCatalog(dependencies, timeoutMs);
|
|
2543
|
+
if (catalog.status !== 'ok') {
|
|
2544
|
+
return catalog.diagnostic;
|
|
2545
|
+
}
|
|
2546
|
+
const nativeProduct = catalog.products.find(({ sku }) => sku === configured.sku);
|
|
2547
|
+
if (nativeProduct === undefined) {
|
|
2548
|
+
return aitIapDiagnostic(aitIapDiagnosticCodes.configuredSkusNotVisible, false);
|
|
2549
|
+
}
|
|
2550
|
+
return nativeProduct.type === 'CONSUMABLE' || nativeProduct.type === 'NON_CONSUMABLE'
|
|
2551
|
+
? undefined
|
|
2552
|
+
: aitIapDiagnostic(aitIapDiagnosticCodes.productTypeUnsupported, false);
|
|
2553
|
+
}
|
|
2554
|
+
/**
|
|
2555
|
+
* Native IAP reads that have not settled yet, per IAP dependency object and
|
|
2556
|
+
* method. A retry while an earlier read is still running (for example a hung
|
|
2557
|
+
* native call) joins it instead of starting another native request, so
|
|
2558
|
+
* retries cannot pile up uncancelled native calls.
|
|
2559
|
+
*/
|
|
2560
|
+
const aitIapNativeReadsRunning = new WeakMap();
|
|
2561
|
+
function joinAitIapNativeRead(owner, method, read) {
|
|
2562
|
+
let running = aitIapNativeReadsRunning.get(owner);
|
|
2563
|
+
if (running === undefined) {
|
|
2564
|
+
running = new Map();
|
|
2565
|
+
aitIapNativeReadsRunning.set(owner, running);
|
|
2566
|
+
}
|
|
2567
|
+
const existing = running.get(method);
|
|
2568
|
+
if (existing !== undefined) {
|
|
2569
|
+
return existing;
|
|
2570
|
+
}
|
|
2571
|
+
const reads = running;
|
|
2572
|
+
const call = Promise.resolve().then(read);
|
|
2573
|
+
const forget = () => {
|
|
2574
|
+
if (reads.get(method) === call) {
|
|
2575
|
+
reads.delete(method);
|
|
1621
2576
|
}
|
|
1622
|
-
|
|
2577
|
+
};
|
|
2578
|
+
void call.then(forget, forget);
|
|
2579
|
+
reads.set(method, call);
|
|
2580
|
+
return call;
|
|
2581
|
+
}
|
|
2582
|
+
/**
|
|
2583
|
+
* Like waitForAitIapNativeCall, but keeps a timeout, a thrown provider error
|
|
2584
|
+
* and a resolved `undefined` (an unsupported Toss app) distinguishable.
|
|
2585
|
+
*/
|
|
2586
|
+
async function callAitIapNative(operation, timeoutMs) {
|
|
2587
|
+
const timedOut = Symbol('timed-out');
|
|
2588
|
+
let timeout;
|
|
2589
|
+
const timeoutResult = new Promise((resolve) => {
|
|
2590
|
+
timeout = globalThis.setTimeout(() => resolve(timedOut), timeoutMs);
|
|
2591
|
+
});
|
|
2592
|
+
try {
|
|
2593
|
+
const value = await Promise.race([operation(), timeoutResult]);
|
|
2594
|
+
return value === timedOut ? { status: 'timeout' } : { status: 'ok', value: value };
|
|
1623
2595
|
}
|
|
1624
|
-
catch {
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
2596
|
+
catch (error) {
|
|
2597
|
+
const providerCode = readAitIapProviderCode(error);
|
|
2598
|
+
return providerCode === undefined ? { status: 'error' } : { status: 'error', providerCode };
|
|
2599
|
+
}
|
|
2600
|
+
finally {
|
|
2601
|
+
if (timeout !== undefined) {
|
|
2602
|
+
globalThis.clearTimeout(timeout);
|
|
2603
|
+
}
|
|
1628
2604
|
}
|
|
1629
2605
|
}
|
|
1630
2606
|
async function waitForAitIapNativeCall(operation, timeoutMs) {
|
|
@@ -1657,6 +2633,9 @@ async function verifyAitIapProductGrant(input) {
|
|
|
1657
2633
|
productId: input.product.productId,
|
|
1658
2634
|
platformSku: input.product.sku,
|
|
1659
2635
|
idempotencyKey: input.idempotencyKey,
|
|
2636
|
+
...(input.clientIdempotencyKey === undefined
|
|
2637
|
+
? {}
|
|
2638
|
+
: { clientIdempotencyKey: input.clientIdempotencyKey }),
|
|
1660
2639
|
source: input.source,
|
|
1661
2640
|
purchasedAt: new Date().toISOString(),
|
|
1662
2641
|
signal: controller.signal,
|
|
@@ -1672,27 +2651,76 @@ async function verifyAitIapProductGrant(input) {
|
|
|
1672
2651
|
}
|
|
1673
2652
|
}
|
|
1674
2653
|
}
|
|
1675
|
-
function completedAitIapPurchase(product, orderId) {
|
|
2654
|
+
function completedAitIapPurchase(product, orderId, source = 'process-product-grant') {
|
|
1676
2655
|
return {
|
|
1677
2656
|
status: 'completed',
|
|
1678
2657
|
transactionId: orderId,
|
|
1679
2658
|
entitlementIds: [product.productId],
|
|
1680
|
-
evidence: createAitIapEvidence(orderId, product.sku,
|
|
2659
|
+
evidence: createAitIapEvidence(orderId, product.sku, source),
|
|
1681
2660
|
};
|
|
1682
2661
|
}
|
|
1683
|
-
function failedPurchase() {
|
|
1684
|
-
return {
|
|
2662
|
+
function failedPurchase(diagnostic) {
|
|
2663
|
+
return {
|
|
2664
|
+
status: 'failed',
|
|
2665
|
+
entitlementIds: [],
|
|
2666
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
2667
|
+
};
|
|
1685
2668
|
}
|
|
1686
2669
|
function cancelledPurchase() {
|
|
1687
2670
|
return { status: 'cancelled', entitlementIds: [] };
|
|
1688
2671
|
}
|
|
1689
|
-
function pendingPurchase(transactionId) {
|
|
2672
|
+
function pendingPurchase(transactionId, diagnostic) {
|
|
1690
2673
|
return {
|
|
1691
2674
|
status: 'pending',
|
|
1692
2675
|
...(transactionId === undefined ? {} : { transactionId }),
|
|
1693
2676
|
entitlementIds: [],
|
|
2677
|
+
...(diagnostic === undefined ? {} : { diagnostic }),
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
function aitIapDiagnostic(code, retryable, providerCode) {
|
|
2681
|
+
return {
|
|
2682
|
+
code,
|
|
2683
|
+
retryable,
|
|
2684
|
+
...(providerCode === undefined ? {} : { providerCode }),
|
|
1694
2685
|
};
|
|
1695
2686
|
}
|
|
2687
|
+
function createAitIapDiagnosticError(id, diagnostic, summary) {
|
|
2688
|
+
const providerDetail = diagnostic.providerCode === undefined
|
|
2689
|
+
? ''
|
|
2690
|
+
: `; provider code ${diagnostic.providerCode}`;
|
|
2691
|
+
return createBridgeError(id, diagnostic.code, `${summary} (${diagnostic.code}${providerDetail}).`, diagnostic.retryable);
|
|
2692
|
+
}
|
|
2693
|
+
/**
|
|
2694
|
+
* Native SDK codes that may be recognized in error text when the SDK does not
|
|
2695
|
+
* attach a structured code. Nothing else is ever read from a message.
|
|
2696
|
+
*/
|
|
2697
|
+
const aitIapKnownMessageProviderCodes = [aitIapProductNotGrantedByPartnerCode];
|
|
2698
|
+
/**
|
|
2699
|
+
* Reads a native SDK error code from structured `code`/`errorCode` fields.
|
|
2700
|
+
* Free-form error text can contain order or account identifiers, so it is
|
|
2701
|
+
* only checked for an exact, allowlisted SDK code.
|
|
2702
|
+
*/
|
|
2703
|
+
function readAitIapProviderCode(error) {
|
|
2704
|
+
if (isRecord(error)) {
|
|
2705
|
+
for (const candidate of [error.code, error.errorCode]) {
|
|
2706
|
+
if (isAitIapProviderCode(candidate)) {
|
|
2707
|
+
return candidate;
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
let message = '';
|
|
2712
|
+
if (error instanceof Error) {
|
|
2713
|
+
message = error.message;
|
|
2714
|
+
}
|
|
2715
|
+
else if (typeof error === 'string') {
|
|
2716
|
+
message = error;
|
|
2717
|
+
}
|
|
2718
|
+
const tokens = new Set(message.split(/[^A-Z0-9_]+/u));
|
|
2719
|
+
return aitIapKnownMessageProviderCodes.find((code) => tokens.has(code));
|
|
2720
|
+
}
|
|
2721
|
+
function isAitIapProviderCode(value) {
|
|
2722
|
+
return typeof value === 'string' && /^[A-Z][A-Z0-9_:-]{0,127}$/u.test(value);
|
|
2723
|
+
}
|
|
1696
2724
|
function createAitIapEvidence(orderId, sku, source) {
|
|
1697
2725
|
return {
|
|
1698
2726
|
schema: iapEvidenceSchema,
|
|
@@ -1777,6 +2805,9 @@ function toAitIapProductInfo(nativeProduct, configured) {
|
|
|
1777
2805
|
};
|
|
1778
2806
|
}
|
|
1779
2807
|
function normalizeAitIapDisplayText(value) {
|
|
2808
|
+
if (typeof value !== 'string') {
|
|
2809
|
+
return undefined;
|
|
2810
|
+
}
|
|
1780
2811
|
const normalized = value.trim();
|
|
1781
2812
|
return normalized.length > 0 && normalized.length <= 2_048 && !/[\p{Cc}\p{Cf}]/u.test(normalized)
|
|
1782
2813
|
? normalized
|