@mpgd/game-services 0.31.0 → 0.33.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.
@@ -1,4 +1,5 @@
1
1
  import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, summarizeCommercePurchaseState, } from '@mpgd/platform/commerce';
2
+ import { isStoreAccountMismatchReason, readPurchaseBoundToAnotherAccount, } from './another-account.js';
2
3
  import { compareCommerceServiceCapabilities, } from './commerce-evidence-registry.js';
3
4
  import { chunkLedgerEntryIds } from './commerce-ledger.js';
4
5
  /**
@@ -37,13 +38,65 @@ export function isCommercePurchaseOperationRecord(record) {
37
38
  && record !== null
38
39
  && record.kind === commercePurchaseOperationKind;
39
40
  }
40
- /** Summary status of a record: the RFC mapping, with `revoked` kept distinct. */
41
+ /**
42
+ * Summary status of a record: the RFC mapping, with `revoked` kept distinct.
43
+ * A record parked as another account's purchase is `rejected` for its
44
+ * player: nothing was granted to it, and nothing will be.
45
+ */
41
46
  export function summarizeCommercePurchaseOperation(record) {
42
47
  if (isRetryableBeforeLaunch(record)) {
43
48
  return 'failed';
44
49
  }
50
+ if (isParkedForAnotherAccount(record)) {
51
+ return 'rejected';
52
+ }
45
53
  return summarizeCommercePurchaseState(record.state);
46
54
  }
55
+ /** A `verifying` record the backend answered as another account's purchase. */
56
+ export function isParkedForAnotherAccount(record) {
57
+ return record.state === 'verifying' && record.anotherAccount !== undefined;
58
+ }
59
+ /**
60
+ * Whether another player's record under a shared key leaves the key to this
61
+ * player: parked as another account's purchase, or (a journal written before
62
+ * that answer) `rejected` with a store binding mismatch reason. Either way
63
+ * the purchase was never its player's, and nothing on the backend holds it.
64
+ */
65
+ function releasesSharedKey(record) {
66
+ return isParkedForAnotherAccount(record)
67
+ || (record.state === 'rejected'
68
+ && record.response !== undefined
69
+ && !record.response.verified
70
+ && isStoreAccountMismatchReason(record.response.reason));
71
+ }
72
+ /**
73
+ * Backend refusals that answer for a revoked purchase: the App Store verifier
74
+ * read the refund (`APP_STORE_TRANSACTION_REVOKED`), or the ledger had
75
+ * already reversed the grant this request names (`LEDGER_GRANT_REVERSED`).
76
+ */
77
+ export const commerceRevokedRejectionReasons = new Set([
78
+ 'APP_STORE_TRANSACTION_REVOKED',
79
+ 'LEDGER_GRANT_REVERSED',
80
+ ]);
81
+ /**
82
+ * A record the backend answered for a refund with no grant standing: its
83
+ * grant reversed by the ledger (`revoked`), or the purchase refused as
84
+ * revoked (`rejected` with a reason in `commerceRevokedRejectionReasons`). A
85
+ * store transaction of such a record that the store reports revoked may be
86
+ * finished without a grant (`FinishTransactionInput.revoked`). A refusal for
87
+ * any other reason keeps the transaction unfinished.
88
+ */
89
+ function answeredWithoutGrant(record) {
90
+ if (record.state === 'revoked') {
91
+ return true;
92
+ }
93
+ const response = record.response;
94
+ return record.state === 'rejected'
95
+ && response !== undefined
96
+ && !response.verified
97
+ && typeof response.reason === 'string'
98
+ && commerceRevokedRejectionReasons.has(response.reason);
99
+ }
47
100
  function isRetryableBeforeLaunch(record) {
48
101
  return record.state === 'requested' && record.error?.retryable === true
49
102
  && record.purchase === undefined && record.request === undefined;
@@ -483,6 +536,27 @@ export function createCommerceReconciler(input) {
483
536
  retryAt.delete(record.idempotencyKey);
484
537
  return next;
485
538
  };
539
+ /**
540
+ * Parks a `verifying` record the backend answered as another account's
541
+ * purchase, or (`marker` undefined) lets this player's Restore Purchases
542
+ * send it again. The state stays `verifying`: the record is not ended for
543
+ * the store transaction, which its account can still recover.
544
+ */
545
+ const reviseAnotherAccount = async (record, marker, patch = {}) => {
546
+ const { anotherAccount: _previous, ...rest } = record;
547
+ const next = {
548
+ ...rest,
549
+ ...patch,
550
+ ...(marker === undefined ? {} : { anotherAccount: marker }),
551
+ state: record.state,
552
+ revision: record.revision + 1,
553
+ updatedAt: now(),
554
+ };
555
+ await journal.replace(record.revision, next);
556
+ remember(next);
557
+ retryAt.delete(record.idempotencyKey);
558
+ return next;
559
+ };
486
560
  /**
487
561
  * Where this player's record of an idempotency key lives, and what that key
488
562
  * holds now. A record is under the shared key, or under this player's
@@ -496,6 +570,11 @@ export function createCommerceReconciler(input) {
496
570
  * the slot holds, and `recordIn` refuses it.
497
571
  * - `probeFree`: when the shared key is free, a record under the scoped key
498
572
  * still answers (retention may have dropped the other player's record).
573
+ *
574
+ * Another player's record that was answered as another account's purchase
575
+ * (`releasesSharedKey`) is never this player's to refuse: the scoped key is
576
+ * the slot for any kind, as with `takeHeld`, so the purchase's own account
577
+ * recovers it on the same install.
499
578
  */
500
579
  const resolveSlot = async (idempotencyKey, options = {}) => {
501
580
  const sharedKey = createCommercePurchaseOperationKey(target, idempotencyKey);
@@ -503,6 +582,7 @@ export function createCommerceReconciler(input) {
503
582
  const heldByAnother = shared !== undefined && heldByAnotherPlayer(shared);
504
583
  const probeScoped = heldByAnother
505
584
  ? options.takeHeld === true
585
+ || (isCommercePurchaseOperationRecord(shared) && releasesSharedKey(shared))
506
586
  : shared === undefined && options.probeFree === true;
507
587
  if (!probeScoped) {
508
588
  return { key: sharedKey, found: shared, heldByAnother };
@@ -532,9 +612,15 @@ export function createCommerceReconciler(input) {
532
612
  */
533
613
  const readRecord = async (idempotencyKey) => recordIn(await resolveSlot(idempotencyKey), idempotencyKey);
534
614
  const ownedRecordsOf = (listed) => listed.filter((record) => isCommercePurchaseOperationRecord(record) && ownsRecord(record));
615
+ /**
616
+ * The player's open records. One parked as another account's purchase is
617
+ * kept in memory, so a store report matches it without the journal's
618
+ * transaction index, but is not the player's to recover.
619
+ */
535
620
  const listRecoverable = async () => ownedRecordsOf(await journal.listRecoverable(playerId))
536
621
  .filter((record) => !isTerminalCommercePurchaseState(record.state))
537
- .map(remember);
622
+ .map(remember)
623
+ .filter((record) => !isParkedForAnotherAccount(record));
538
624
  const listGranted = async () => {
539
625
  const listed = journal.listGranted === undefined
540
626
  ? []
@@ -717,6 +803,252 @@ export function createCommerceReconciler(input) {
717
803
  fail('finish', error, record.idempotencyKey);
718
804
  }
719
805
  };
806
+ /**
807
+ * Consumables a `client`-finish store reported revoked this session (the
808
+ * App Store refunded them while they were unfinished, so StoreKit keeps
809
+ * them unfinished and redelivers them until `finish()`), by transaction
810
+ * id, with their record's idempotency key, or `undefined` for one this
811
+ * player holds no record of. `finishRevokedReported` finishes one, without
812
+ * any grant, once its record holds the backend's answer (`answeredWithoutGrant`),
813
+ * or at once without a record. Until then it waits: a grant the ledger
814
+ * still holds keeps the transaction unfinished, as before.
815
+ */
816
+ const revokedReports = new Map();
817
+ /** Transactions this session already asked the store to finish as revoked. */
818
+ const revokedFinishAsked = new Set();
819
+ /**
820
+ * Notes a store report of a revoked purchase whose record this player
821
+ * holds. Only a `client`-finish consumable on a provider that declares
822
+ * `acceptsRevokedFinish` is noted: any other store, kind, or provider keeps
823
+ * the revoked report as a no-op, and only the ledger moves a record to
824
+ * `revoked` (step 5).
825
+ */
826
+ const revokedFinishApplies = (kind, transactionId) => kind === 'consumable'
827
+ && capabilities?.lifecycle.finish === 'client'
828
+ && provider.finishTransaction !== undefined
829
+ && provider.acceptsRevokedFinish === true
830
+ && !revokedFinishAsked.has(transactionId);
831
+ const noteRevoked = (record, purchase, kind) => {
832
+ if (record.purchase === undefined
833
+ || !samePurchase(record.purchase, purchase)
834
+ // A `finished` record's transaction was finished: the report is the
835
+ // store's refund notice of a finished consumable (`Transaction.updates`),
836
+ // with nothing left unfinished. Step 5 revokes the record if the
837
+ // ledger reversed it; a later report of a still-unfinished one is
838
+ // noted then.
839
+ || record.state === 'finished'
840
+ || !revokedFinishApplies(record.productKind ?? kind, record.purchase.transactionId)) {
841
+ return;
842
+ }
843
+ revokedReports.set(record.purchase.transactionId, record.idempotencyKey);
844
+ };
845
+ /**
846
+ * Whether a store purchase is bound to this player's own account: the
847
+ * purchase carries a binding, this player's binding reads, and the two are
848
+ * the same kind and value. Stricter than `accountOf`, which calls an
849
+ * unbound purchase or an unread binding this player's.
850
+ */
851
+ const boundToThisPlayer = async (purchase) => {
852
+ const bound = purchase.accountBinding;
853
+ if (bound?.value === undefined || input.accountBinding === undefined) {
854
+ return false;
855
+ }
856
+ let own;
857
+ try {
858
+ own = await input.accountBinding();
859
+ }
860
+ catch {
861
+ return false;
862
+ }
863
+ if (own?.value === undefined || own.kind !== bound.kind) {
864
+ return false;
865
+ }
866
+ return bound.kind === 'app-account-token'
867
+ ? own.value.toLowerCase() === bound.value.toLowerCase()
868
+ : own.value === bound.value;
869
+ };
870
+ /**
871
+ * A revoked purchase this player holds no record of (the journal dropped
872
+ * it, it was lost, or the store answer was never journaled). A refunded
873
+ * consumable gives nobody anything and the backend learns of the refund
874
+ * from the store (App Store Server Notifications), so it is finished
875
+ * without a verify, never journaled or granted: verifying it could grant
876
+ * from a signed transaction saved before the refund. Only a consumable
877
+ * bound to this player's own account, on a provider that finishes revoked
878
+ * transactions; a record found under its derived key (a journal without the
879
+ * transaction index) takes the record's path instead.
880
+ */
881
+ const noteUnrecordedRevoked = async (purchase, kind) => {
882
+ if (!revokedFinishApplies(kind, purchase.transactionId)
883
+ // Already noted this session (the store redelivers it): no second
884
+ // derived-key hash and journal read.
885
+ || revokedReports.has(purchase.transactionId)) {
886
+ return;
887
+ }
888
+ const idempotencyKey = await createDerivedPurchaseIdempotencyKey({
889
+ store: purchase.store,
890
+ transactionId: purchase.transactionId,
891
+ token: purchase.token,
892
+ });
893
+ let found;
894
+ try {
895
+ found = await readRecord(idempotencyKey);
896
+ }
897
+ catch {
898
+ // Another player's record holds the key: not this player's to finish.
899
+ return;
900
+ }
901
+ if (found !== undefined) {
902
+ if (found.purchase !== undefined && samePurchase(found.purchase, purchase)) {
903
+ noteRevoked(found, purchase, kind);
904
+ }
905
+ return;
906
+ }
907
+ if (!(await boundToThisPlayer(purchase))) {
908
+ return;
909
+ }
910
+ revokedReports.set(purchase.transactionId, undefined);
911
+ };
912
+ /**
913
+ * Finishes the revoked transactions `noteRevoked` kept, once their record
914
+ * holds the backend's answer, with `revoked: true` and the answer as the
915
+ * ledger entry id (the reversed grant's entry, or the refused key). Nothing
916
+ * is granted and the record is not rewritten: it is already terminal. The
917
+ * store finishes only a transaction it still lists as revoked. Asked once
918
+ * per session; a transport failure is asked again on a later pass (after
919
+ * the retry interval, unless the pass is explicit), and a refusal is
920
+ * reported as a `finish` failure (for StoreKit: a native build from before
921
+ * the revoked finish; the next launch asks again).
922
+ */
923
+ const finishRevokedReported = async (explicit) => {
924
+ const finishTransaction = provider.finishTransaction;
925
+ for (const [transactionId, idempotencyKey] of [...revokedReports]) {
926
+ const record = idempotencyKey === undefined ? undefined : records.get(idempotencyKey);
927
+ if (idempotencyKey !== undefined) {
928
+ if (record === undefined || record.purchase?.transactionId !== transactionId) {
929
+ revokedReports.delete(transactionId);
930
+ continue;
931
+ }
932
+ if (!answeredWithoutGrant(record)) {
933
+ if (isTerminalCommercePurchaseState(record.state) && record.state !== 'finished') {
934
+ // Refused for another reason (or ended otherwise): it stays unfinished.
935
+ revokedReports.delete(transactionId);
936
+ }
937
+ // Otherwise still verifying, granted, finishing, or finished: the
938
+ // backend has not answered for the refund yet (step 3 or 5 will).
939
+ continue;
940
+ }
941
+ }
942
+ const retryKey = idempotencyKey ?? transactionId;
943
+ const waitUntil = retryAt.get(retryKey);
944
+ if (!explicit && waitUntil !== undefined && nowMs() < waitUntil) {
945
+ continue;
946
+ }
947
+ if (finishTransaction === undefined || sessionBlocked(idempotencyKey)) {
948
+ return;
949
+ }
950
+ revokedReports.delete(transactionId);
951
+ revokedFinishAsked.add(transactionId);
952
+ try {
953
+ const { finished } = await finishTransaction.call(provider, {
954
+ transactionId,
955
+ ...(record?.ledgerEntryId === undefined ? {} : { ledgerEntryId: record.ledgerEntryId }),
956
+ isConsumable: true,
957
+ revoked: true,
958
+ });
959
+ emit({
960
+ type: 'revoked-finish',
961
+ transactionId,
962
+ ...(idempotencyKey === undefined ? {} : { idempotencyKey }),
963
+ finished,
964
+ });
965
+ if (!finished) {
966
+ // Not counted as asked: the store could not finish it now (a native
967
+ // build from before the revoked finish, or a list that does not
968
+ // carry it yet). The next report of the transaction asks again.
969
+ revokedFinishAsked.delete(transactionId);
970
+ fail('finish', new Error(`The store did not finish the revoked transaction ${transactionId}.`), idempotencyKey);
971
+ }
972
+ }
973
+ catch (error) {
974
+ // Transport-class failure: a later pass asks again.
975
+ revokedFinishAsked.delete(transactionId);
976
+ revokedReports.set(transactionId, idempotencyKey);
977
+ retryLater(retryKey);
978
+ fail('finish', error, idempotencyKey);
979
+ }
980
+ }
981
+ };
982
+ /**
983
+ * A store report of a record the backend answered as another account's
984
+ * purchase. Only this player's Restore Purchases asks again (a restore
985
+ * sharing policy or a binding may have changed since), with the evidence
986
+ * the store reports now, so a stale saved request (an expired signed
987
+ * transaction) is not resent, and as a restore, which the player asked
988
+ * for (restore sharing may cover a durable the game started, too); other
989
+ * reports leave it parked. It stays parked until the backend answers
990
+ * something else: a transport failure (an offline Restore) changes
991
+ * nothing, so the record never becomes an open attempt that serializes the
992
+ * player's purchases or holds the shared key against the purchase's
993
+ * account. Another answer unparks it and settles it as a verify does; a
994
+ * grant is finished by the pass's recoverable records.
995
+ *
996
+ * Returns false when the journal dropped the record before Restore could
997
+ * rewrite it (another context's save applied the journal's retention after
998
+ * `rereadParked` read it): the record is forgotten and the caller matches
999
+ * the report as a purchase the journal does not know.
1000
+ */
1001
+ const reportParked = async (record, purchase, restoring) => {
1002
+ if (!restoring || purchase.state !== 'purchased') {
1003
+ return true;
1004
+ }
1005
+ const request = {
1006
+ ...buildRequest({ ...record, purchase }),
1007
+ intent: 'restore',
1008
+ };
1009
+ let refreshed;
1010
+ try {
1011
+ refreshed = await reviseAnotherAccount(record, record.anotherAccount, {
1012
+ purchase,
1013
+ request,
1014
+ });
1015
+ }
1016
+ catch (error) {
1017
+ if (await rereadParked(record) === undefined) {
1018
+ return false;
1019
+ }
1020
+ throw error;
1021
+ }
1022
+ const answer = await sendVerify(refreshed, request);
1023
+ if (answer === undefined) {
1024
+ return true;
1025
+ }
1026
+ if (answer.kind === 'another-account') {
1027
+ await parkForAnotherAccount(refreshed, request, answer);
1028
+ return true;
1029
+ }
1030
+ await settleVerify(await reviseAnotherAccount(refreshed, undefined), request, answer.response);
1031
+ return true;
1032
+ };
1033
+ /**
1034
+ * The journal's current record of a parked record this session holds, or
1035
+ * undefined (and forgotten) when the journal no longer holds it for this
1036
+ * player: dropped, or the key reserved again for another purchase of the
1037
+ * player, which this purchase's report must never be written onto.
1038
+ */
1039
+ const rereadParked = async (record) => {
1040
+ const current = await journal.read(record.key);
1041
+ if (current !== undefined
1042
+ && isCommercePurchaseOperationRecord(current)
1043
+ && ownsRecord(current)
1044
+ && current.purchase !== undefined
1045
+ && record.purchase !== undefined
1046
+ && samePurchase(current.purchase, record.purchase)) {
1047
+ return remember(current);
1048
+ }
1049
+ records.delete(record.idempotencyKey);
1050
+ return undefined;
1051
+ };
720
1052
  // Reconciler step 2: match a reported purchase to a journal record.
721
1053
  const ingestPurchase = async (purchase, restoring) => {
722
1054
  if (purchase.store !== storeId) {
@@ -746,6 +1078,38 @@ export function createCommerceReconciler(input) {
746
1078
  record = remember(stored);
747
1079
  }
748
1080
  }
1081
+ if (record !== undefined && isParkedForAnotherAccount(record) && restoring) {
1082
+ // Restore Purchases writes the record, so it must still be the journal's:
1083
+ // a journal's retention may have dropped a parked record this session
1084
+ // still holds (RFC "Another account's purchase"). Then the report is a
1085
+ // purchase the journal does not know, and is matched as one.
1086
+ record = await rereadParked(record);
1087
+ }
1088
+ if (record !== undefined && isParkedForAnotherAccount(record)) {
1089
+ if (await reportParked(record, purchase, restoring)) {
1090
+ return;
1091
+ }
1092
+ record = undefined;
1093
+ }
1094
+ if (purchase.state === 'revoked') {
1095
+ // A revoked store state never revokes or verifies anything on its own:
1096
+ // only the ledger revokes (step 5). It is matched only to this
1097
+ // player's record of the same transaction, for a finish without a
1098
+ // grant once the backend has answered for it. It is never attached to
1099
+ // the player's open (`launched`) attempt for the product: the store
1100
+ // reported no purchase for that attempt, and the open attempt may be
1101
+ // another purchase still in flight (an Ask to Buy waiting for approval),
1102
+ // which this refund must not end. Finishing the refunded transaction
1103
+ // as unrecorded grants nothing, and the attempt keeps waiting for its
1104
+ // own purchase report or its expiry, as before.
1105
+ if (record !== undefined) {
1106
+ noteRevoked(record, purchase, product.kind);
1107
+ }
1108
+ else {
1109
+ await noteUnrecordedRevoked(purchase, product.kind);
1110
+ }
1111
+ return;
1112
+ }
749
1113
  const account = record !== undefined || (restoring && product.kind === 'durable')
750
1114
  ? 'own'
751
1115
  : await accountOf(purchase);
@@ -776,10 +1140,6 @@ export function createCommerceReconciler(input) {
776
1140
  }
777
1141
  }
778
1142
  if (record === undefined) {
779
- if (purchase.state === 'revoked') {
780
- // Revoked store states never revoke on their own: only the ledger does (step 5).
781
- return;
782
- }
783
1143
  const idempotencyKey = await createDerivedPurchaseIdempotencyKey({
784
1144
  store: purchase.store,
785
1145
  transactionId: purchase.transactionId,
@@ -800,6 +1160,16 @@ export function createCommerceReconciler(input) {
800
1160
  probeFree: durable,
801
1161
  });
802
1162
  record = recordIn(slot, idempotencyKey);
1163
+ if (record !== undefined
1164
+ && isParkedForAnotherAccount(record)
1165
+ && record.logicalId === product.logicalId
1166
+ && record.purchase !== undefined
1167
+ && samePurchase(record.purchase, purchase)) {
1168
+ // Found under its derived key only (a journal without the
1169
+ // transaction index): the same rule as a transaction match.
1170
+ await reportParked(record, purchase, restoring);
1171
+ return;
1172
+ }
803
1173
  if (record !== undefined
804
1174
  && (record.logicalId !== product.logicalId
805
1175
  || (record.purchase !== undefined && !samePurchase(record.purchase, purchase)))) {
@@ -863,18 +1233,71 @@ export function createCommerceReconciler(input) {
863
1233
  retryable: false,
864
1234
  message,
865
1235
  });
1236
+ /**
1237
+ * The backend's answer: a response, another account's purchase (the
1238
+ * `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error, or a `rejected` receipt with a
1239
+ * store binding mismatch reason from a backend before it), or undefined
1240
+ * for a transport failure.
1241
+ */
866
1242
  const sendVerify = async (record, request) => {
867
1243
  emit({ type: 'verify-requested', record });
1244
+ let response;
868
1245
  try {
869
- const response = await backend.verifyPurchase(request);
870
- emit({ type: 'verify-result', record, response });
871
- return response;
1246
+ response = await backend.verifyPurchase(request);
872
1247
  }
873
1248
  catch (error) {
1249
+ const anotherAccount = readPurchaseBoundToAnotherAccount(error);
1250
+ if (anotherAccount !== undefined) {
1251
+ // An answer, not a failure: the caller parks the record and reports it.
1252
+ return { kind: 'another-account', reason: anotherAccount.reason };
1253
+ }
874
1254
  // Transport failure: the state is kept and the next pass resends the saved request.
875
1255
  fail('verify', error, record.idempotencyKey);
876
1256
  return undefined;
877
1257
  }
1258
+ emit({ type: 'verify-result', record, response });
1259
+ if (!response.verified
1260
+ && response.disposition !== 'pending'
1261
+ && isStoreAccountMismatchReason(response.reason)) {
1262
+ return { kind: 'another-account', reason: response.reason, response };
1263
+ }
1264
+ return { kind: 'response', response };
1265
+ };
1266
+ /**
1267
+ * Parks a `verifying` record the backend answered as another account's
1268
+ * purchase and reports it with an `another-account` event (not a failure).
1269
+ */
1270
+ const parkForAnotherAccount = async (record, request, answer) => {
1271
+ const parked = await reviseAnotherAccount(record, { reason: answer.reason, reportedAt: now() }, { request, ...(answer.response === undefined ? {} : { response: answer.response }) });
1272
+ emit({
1273
+ type: 'another-account',
1274
+ record: parked,
1275
+ reason: answer.reason,
1276
+ ...(answer.response === undefined ? {} : { response: answer.response }),
1277
+ });
1278
+ };
1279
+ /**
1280
+ * Journals the backend's answer to a `verifying` record: `pending` or
1281
+ * `rejected`, or `granted` (returned) for a grant that carries its ledger
1282
+ * entry; a malformed grant is reported and the record kept.
1283
+ */
1284
+ const settleVerify = async (record, request, response) => {
1285
+ if (!response.verified) {
1286
+ if (response.disposition === 'pending') {
1287
+ await write(record, 'pending', { request, response });
1288
+ retryLater(record.idempotencyKey);
1289
+ }
1290
+ else {
1291
+ await write(record, 'rejected', { request, response });
1292
+ }
1293
+ return undefined;
1294
+ }
1295
+ const ledgerEntryId = response.ledgerEntryId;
1296
+ if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
1297
+ fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
1298
+ return undefined;
1299
+ }
1300
+ return write(record, 'granted', { request, response, ledgerEntryId });
878
1301
  };
879
1302
  // Reconciler steps 3 and 4: drive one record as far as the backend and the store allow.
880
1303
  const drive = async (initial, capabilitiesNow, explicit) => {
@@ -936,29 +1359,30 @@ export function createCommerceReconciler(input) {
936
1359
  record = await write(record, 'verifying');
937
1360
  continue;
938
1361
  case 'verifying': {
1362
+ if (isParkedForAnotherAccount(record)) {
1363
+ // Another account's purchase: only this player's Restore
1364
+ // Purchases sends it again (`ingestPurchase`).
1365
+ return;
1366
+ }
939
1367
  const request = record.request ?? buildRequest(record);
940
- const response = await sendVerify(record, request);
941
- if (response === undefined) {
1368
+ const answer = await sendVerify(record, request);
1369
+ if (answer === undefined) {
942
1370
  retryLater(record.idempotencyKey);
943
1371
  return;
944
1372
  }
945
- if (!response.verified) {
946
- if (response.disposition === 'pending') {
947
- await write(record, 'pending', { request, response });
948
- retryLater(record.idempotencyKey);
949
- }
950
- else {
951
- await write(record, 'rejected', { request, response });
952
- }
1373
+ if (answer.kind === 'another-account') {
1374
+ // Not this player's, and not ended for the store transaction:
1375
+ // never sent again on its own, and the shared key is left to
1376
+ // the purchase's account (RFC "Another account's purchase").
1377
+ await parkForAnotherAccount(record, request, answer);
953
1378
  return;
954
1379
  }
955
- const ledgerEntryId = response.ledgerEntryId;
956
- if (typeof ledgerEntryId !== 'string' || ledgerEntryId.trim() === '') {
957
- fail('verify', new Error(`A verified response for ${record.idempotencyKey} must carry a ledgerEntryId.`), record.idempotencyKey);
1380
+ const granted = await settleVerify(record, request, answer.response);
1381
+ if (granted === undefined) {
958
1382
  return;
959
1383
  }
960
- finalization = response.finalization?.status;
961
- record = await write(record, 'granted', { request, response, ledgerEntryId });
1384
+ finalization = answer.response.finalization?.status;
1385
+ record = granted;
962
1386
  continue;
963
1387
  }
964
1388
  case 'granted':
@@ -985,7 +1409,8 @@ export function createCommerceReconciler(input) {
985
1409
  if (finalization === undefined) {
986
1410
  // A pass that did not grant resends the saved request to read
987
1411
  // finalization; the backend replays the grant, never a second one.
988
- reread = await sendVerify(record, record.request ?? buildRequest(record));
1412
+ const answer = await sendVerify(record, record.request ?? buildRequest(record));
1413
+ reread = answer?.kind === 'response' ? answer.response : undefined;
989
1414
  finalization = reread?.verified === true
990
1415
  ? reread.finalization?.status
991
1416
  : undefined;
@@ -1172,6 +1597,10 @@ export function createCommerceReconciler(input) {
1172
1597
  return;
1173
1598
  }
1174
1599
  await revokeReversed();
1600
+ if (sessionBlocked()) {
1601
+ return;
1602
+ }
1603
+ await finishRevokedReported(options.explicit);
1175
1604
  };
1176
1605
  const runUntilIdle = async () => {
1177
1606
  if (running !== undefined) {
@@ -1574,6 +2003,7 @@ export function createCommerceReconciler(input) {
1574
2003
  deferred.length = 0;
1575
2004
  records.clear();
1576
2005
  retryAt.clear();
2006
+ revokedReports.clear();
1577
2007
  throw error;
1578
2008
  }
1579
2009
  finally {
@@ -1611,6 +2041,7 @@ export function createCommerceReconciler(input) {
1611
2041
  deferred.length = 0;
1612
2042
  records.clear();
1613
2043
  retryAt.clear();
2044
+ revokedReports.clear();
1614
2045
  },
1615
2046
  async reconcile() {
1616
2047
  await withLock(async () => {
@@ -34,8 +34,9 @@ export type EvidenceVerificationDecision = {
34
34
  * check passed. The reference verifiers answer it only for a request
35
35
  * with `intent: 'restore'` of a non-consumable; the backend then grants
36
36
  * the player its own share under its `restoreSharing` policy, or
37
- * answers `rejected` with `reason` (the binding mismatch) when the
38
- * policy refuses. Fields are those of `verified`.
37
+ * answers `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` with `reason` (the
38
+ * binding mismatch) when the policy refuses, as for a `rejected`
39
+ * decision with `anotherAccount`. Fields are those of `verified`.
39
40
  */
40
41
  readonly status: 'restorable';
41
42
  readonly reason: string;
@@ -49,6 +50,17 @@ export type EvidenceVerificationDecision = {
49
50
  } | {
50
51
  readonly status: 'rejected';
51
52
  readonly reason: string;
53
+ /**
54
+ * The store binds the purchase to another account than the requesting
55
+ * player's (`reason` is the binding mismatch, for example
56
+ * `GOOGLE_PLAY_ACCOUNT_MISMATCH`), and every other check passed: the
57
+ * purchase is valid, only not this player's (RFC "Another account's
58
+ * purchase"). The backend grants nothing, records nothing, and answers
59
+ * the retryable `PURCHASE_BOUND_TO_ANOTHER_ACCOUNT` error instead of a
60
+ * `rejected` receipt, so the purchase stays recoverable by its account.
61
+ * A caller that reads only `status` still refuses it.
62
+ */
63
+ readonly anotherAccount?: true;
52
64
  };
53
65
  export interface VerifyPurchaseEvidenceInput {
54
66
  readonly request: VerifyPurchaseRequest;