@mpgd/game-services 0.28.1 → 0.29.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.
Files changed (44) hide show
  1. package/README.md +18 -3
  2. package/dist/account-deletion.d.ts +306 -0
  3. package/dist/account-deletion.js +491 -0
  4. package/dist/app-store-recovery.d.ts +6 -0
  5. package/dist/app-store-recovery.js +26 -16
  6. package/dist/app-store-server-notifications.d.ts +158 -0
  7. package/dist/app-store-server-notifications.js +270 -0
  8. package/dist/app-store-server.d.ts +8 -0
  9. package/dist/app-store-server.js +135 -37
  10. package/dist/app-store-verifier.d.ts +34 -0
  11. package/dist/app-store-verifier.js +161 -31
  12. package/dist/commerce-ledger.d.ts +55 -0
  13. package/dist/commerce-ledger.js +198 -0
  14. package/dist/commerce-reconciler.js +5 -0
  15. package/dist/evidence-verification.d.ts +19 -0
  16. package/dist/google-play-publisher-client.d.ts +2 -1
  17. package/dist/google-play-publisher-client.js +80 -0
  18. package/dist/google-play-purchase.d.ts +7 -0
  19. package/dist/google-play-purchase.js +32 -7
  20. package/dist/google-play-store-server.d.ts +160 -0
  21. package/dist/google-play-store-server.js +297 -0
  22. package/dist/guest-session.d.ts +112 -2
  23. package/dist/guest-session.js +196 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/notifications/delivery-provider.d.ts +7 -0
  27. package/dist/notifications/fcm.d.ts +30 -3
  28. package/dist/notifications/fcm.js +72 -13
  29. package/dist/notifications/service.js +33 -5
  30. package/dist/notifications/store-conformance.js +7 -1
  31. package/dist/notifications/store.d.ts +9 -0
  32. package/dist/notifications/store.js +41 -0
  33. package/dist/operation-results.d.ts +7 -0
  34. package/dist/purchase-sharing.d.ts +41 -0
  35. package/dist/purchase-sharing.js +37 -0
  36. package/dist/runtime.d.ts +6 -0
  37. package/dist/runtime.js +13 -0
  38. package/dist/server.d.ts +60 -1
  39. package/dist/server.js +536 -42
  40. package/dist/types.d.ts +30 -0
  41. package/dist/types.js +22 -0
  42. package/dist/verified-leaderboard.d.ts +13 -0
  43. package/dist/verified-leaderboard.js +31 -0
  44. package/package.json +17 -5
@@ -59,8 +59,88 @@ export function createGooglePlayPublisherClient(options) {
59
59
  + `/tokens/${pathPart(purchaseToken)}:consume`;
60
60
  await discardBody(await request('POST', path, signal));
61
61
  },
62
+ async listVoidedPurchases({ packageName, startTimeMillis, endTimeMillis, pageToken, maxResults, signal, }) {
63
+ // purchases.voidedpurchases.list, in-app products only (type 0, the
64
+ // default): https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.voidedpurchases/list
65
+ const query = new URLSearchParams();
66
+ if (pageToken === undefined) {
67
+ query.set('startTime', String(readMillis(startTimeMillis, 'startTimeMillis')));
68
+ query.set('endTime', String(readMillis(endTimeMillis, 'endTimeMillis')));
69
+ }
70
+ else {
71
+ query.set('token', pageToken);
72
+ }
73
+ if (maxResults !== undefined) {
74
+ if (!Number.isSafeInteger(maxResults) || maxResults < 1 || maxResults > 1000) {
75
+ throw new RangeError('maxResults must be an integer from 1 to 1000.');
76
+ }
77
+ query.set('maxResults', String(maxResults));
78
+ }
79
+ const response = await request('GET', `${purchasePath(packageName)}/voidedpurchases?${query.toString()}`, signal);
80
+ let body;
81
+ try {
82
+ body = await readBoundedJson(response);
83
+ }
84
+ catch {
85
+ if (signal.aborted) {
86
+ throw new DOMException('Google Play Publisher request was cancelled.', 'AbortError');
87
+ }
88
+ throw new Error('Google Play Publisher returned invalid voided purchases JSON.');
89
+ }
90
+ return readVoidedPurchasesPage(body);
91
+ },
62
92
  };
63
93
  }
94
+ function readMillis(value, label) {
95
+ if (!Number.isSafeInteger(value) || value < 0) {
96
+ throw new RangeError(`${label} must be a non-negative integer of milliseconds.`);
97
+ }
98
+ return value;
99
+ }
100
+ function readVoidedPurchasesPage(body) {
101
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) {
102
+ throw new Error('Google Play Publisher returned an invalid voided purchases page.');
103
+ }
104
+ const record = body;
105
+ const entries = record.voidedPurchases ?? [];
106
+ if (!Array.isArray(entries)) {
107
+ throw new Error('Google Play Publisher returned an invalid voided purchases page.');
108
+ }
109
+ const voidedPurchases = [];
110
+ let droppedEntries = 0;
111
+ for (const entry of entries) {
112
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
113
+ droppedEntries += 1;
114
+ continue;
115
+ }
116
+ const item = entry;
117
+ if (typeof item.purchaseToken !== 'string' || item.purchaseToken.length === 0) {
118
+ droppedEntries += 1;
119
+ continue;
120
+ }
121
+ voidedPurchases.push({
122
+ purchaseToken: item.purchaseToken,
123
+ ...(typeof item.orderId === 'string' ? { orderId: item.orderId } : {}),
124
+ ...pickMillisOrCode(item, 'voidedTimeMillis'),
125
+ ...pickMillisOrCode(item, 'purchaseTimeMillis'),
126
+ ...pickMillisOrCode(item, 'voidedSource'),
127
+ ...pickMillisOrCode(item, 'voidedReason'),
128
+ });
129
+ }
130
+ const pagination = record.tokenPagination;
131
+ const nextPageToken = typeof pagination === 'object' && pagination !== null
132
+ ? pagination.nextPageToken
133
+ : undefined;
134
+ return {
135
+ voidedPurchases,
136
+ ...(typeof nextPageToken === 'string' && nextPageToken.length > 0 ? { nextPageToken } : {}),
137
+ ...(droppedEntries === 0 ? {} : { droppedEntries }),
138
+ };
139
+ }
140
+ function pickMillisOrCode(item, key) {
141
+ const value = item[key];
142
+ return typeof value === 'string' || typeof value === 'number' ? { [key]: value } : {};
143
+ }
64
144
  function purchasePath(packageName) {
65
145
  return `/androidpublisher/v3/applications/${pathPart(packageName)}/purchases`;
66
146
  }
@@ -38,3 +38,10 @@ export interface GooglePlayProductPurchaseBoundary extends GameServicesPurchaseG
38
38
  verifyPurchase(input: VerifyPurchaseEvidenceInput): ReturnType<GameServicesEvidenceVerifier['verifyPurchase']>;
39
39
  }
40
40
  export declare function createGooglePlayProductPurchaseBoundary(input: CreateGooglePlayProductPurchaseBoundaryInput): GooglePlayProductPurchaseBoundary;
41
+ /**
42
+ * The ledger's authority identity of a Play one-time purchase: what
43
+ * `verifyPurchase` records as `evidenceVerificationId`. Store-side handlers
44
+ * (voided purchases, RTDN) find the grant of a purchase token with it, so the
45
+ * raw token never needs to be stored in the ledger.
46
+ */
47
+ export declare function createGooglePlayProductVerificationId(packageName: string, purchaseToken: string): Promise<string>;
@@ -49,18 +49,25 @@ export function createGooglePlayProductPurchaseBoundary(input) {
49
49
  orderMatch: createGooglePlayObservedOrderMatch(verificationInput.request.platformTransactionId, await createGooglePlayTokenPurchaseKey(purchaseToken)),
50
50
  accountBinding,
51
51
  allowConsumed: false,
52
+ // A restored non-consumable bound to an earlier player (a reinstall,
53
+ // a new device) is reported, not refused: the backend's restore
54
+ // sharing policy decides (RFC "Restore sharing").
55
+ reportForeignBinding: verificationInput.request.intent === 'restore'
56
+ && verificationInput.product.type === 'non_consumable',
52
57
  signal: verificationInput.signal,
53
58
  });
54
59
  if (inspection.status !== 'verified') {
55
60
  return inspection;
56
61
  }
57
- return {
58
- status: 'verified',
62
+ const verified = {
59
63
  verificationId: createGooglePlayVerificationId(inspection.purchase.purchaseTokenDigest),
60
64
  verifiedAt: now(),
61
65
  platformEvidenceId: inspection.purchase.orderId ?? null,
62
66
  payload: createVerificationPayload(packageName, inspection.purchase),
63
67
  };
68
+ return inspection.foreignBinding === undefined
69
+ ? { status: 'verified', ...verified }
70
+ : { status: 'restorable', reason: inspection.foreignBinding, ...verified };
64
71
  },
65
72
  async finalizePurchaseGrant(finalizationInput) {
66
73
  if (finalizationInput.product.type === 'subscription') {
@@ -318,6 +325,7 @@ async function inspectGooglePlayPurchase(input) {
318
325
  && obfuscatedExternalProfileId === undefined) {
319
326
  return rejected('GOOGLE_PLAY_PROFILE_INVALID');
320
327
  }
328
+ let foreignBinding;
321
329
  if (input.accountBinding.status === 'bound') {
322
330
  // Defensive invariant: every bound context must carry at least one
323
331
  // identifier, even though the constructors below already enforce it.
@@ -325,19 +333,26 @@ async function inspectGooglePlayPurchase(input) {
325
333
  && input.accountBinding.profileId === undefined) {
326
334
  return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_REQUIRED');
327
335
  }
336
+ // A purchase made outside the app (a promo code) carries no ids at all
337
+ // ("purchases made outside your app won't have these identifiers set").
328
338
  if (input.accountBinding.accountId !== undefined
329
339
  && obfuscatedExternalAccountId !== input.accountBinding.accountId) {
330
- return rejected('GOOGLE_PLAY_ACCOUNT_MISMATCH');
340
+ foreignBinding = 'GOOGLE_PLAY_ACCOUNT_MISMATCH';
331
341
  }
332
- if (input.accountBinding.profileId !== undefined
342
+ else if (input.accountBinding.profileId !== undefined
333
343
  && obfuscatedExternalProfileId !== input.accountBinding.profileId) {
334
- return rejected('GOOGLE_PLAY_PROFILE_MISMATCH');
344
+ foreignBinding = 'GOOGLE_PLAY_PROFILE_MISMATCH';
345
+ }
346
+ if (foreignBinding !== undefined && input.reportForeignBinding !== true) {
347
+ return rejected(foreignBinding);
335
348
  }
336
- if (input.accountBinding.profileId === undefined
349
+ if (foreignBinding === undefined
350
+ && input.accountBinding.profileId === undefined
337
351
  && obfuscatedExternalProfileId !== undefined) {
338
352
  return rejected('GOOGLE_PLAY_PROFILE_BINDING_REQUIRED');
339
353
  }
340
- if (input.accountBinding.accountId === undefined
354
+ if (foreignBinding === undefined
355
+ && input.accountBinding.accountId === undefined
341
356
  && obfuscatedExternalAccountId !== undefined) {
342
357
  return rejected('GOOGLE_PLAY_ACCOUNT_BINDING_REQUIRED');
343
358
  }
@@ -350,6 +365,7 @@ async function inspectGooglePlayPurchase(input) {
350
365
  }
351
366
  return {
352
367
  status: 'verified',
368
+ ...(foreignBinding === undefined ? {} : { foreignBinding }),
353
369
  purchase: {
354
370
  purchaseToken: input.purchaseToken,
355
371
  purchaseTokenDigest: await digestPurchaseToken(input.packageName, input.purchaseToken),
@@ -545,6 +561,15 @@ async function digestPurchaseToken(packageName, purchaseToken) {
545
561
  const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
546
562
  return [...digest].map((value) => value.toString(16).padStart(2, '0')).join('');
547
563
  }
564
+ /**
565
+ * The ledger's authority identity of a Play one-time purchase: what
566
+ * `verifyPurchase` records as `evidenceVerificationId`. Store-side handlers
567
+ * (voided purchases, RTDN) find the grant of a purchase token with it, so the
568
+ * raw token never needs to be stored in the ledger.
569
+ */
570
+ export async function createGooglePlayProductVerificationId(packageName, purchaseToken) {
571
+ return createGooglePlayVerificationId(await digestPurchaseToken(packageName, purchaseToken));
572
+ }
548
573
  function createGooglePlayVerificationId(purchaseTokenDigest) {
549
574
  return `google-play:product:${purchaseTokenDigest}`;
550
575
  }
@@ -0,0 +1,160 @@
1
+ import type { GameServicesPurchaseGrantFinalizer } from './evidence-verification.js';
2
+ import type { GameServicesStore } from './server.js';
3
+ import type { PurchaseGrantFinalization } from './types.js';
4
+ /**
5
+ * Server-side Google Play upkeep that does not wait for the game client:
6
+ * revoking grants of voided purchases (refunds, chargebacks, Play's own
7
+ * refund of a purchase left unacknowledged) and retrying the acknowledge or
8
+ * consume of a grant whose client went away. Both read the ledger the
9
+ * purchase verifier wrote; neither grants anything.
10
+ *
11
+ * - Voided Purchases API: https://developers.google.com/android-publisher/voided-purchases
12
+ * - Three-day acknowledgement window: https://developer.android.com/google/play/billing/integrate#process
13
+ */
14
+ /** One entry of `purchases.voidedpurchases.list` (in-app products, `type=0`). */
15
+ export interface GooglePlayVoidedPurchase {
16
+ readonly purchaseToken: string;
17
+ readonly orderId?: string;
18
+ /** Milliseconds since the epoch, as Google's int64 string or a number. */
19
+ readonly voidedTimeMillis?: string | number;
20
+ readonly purchaseTimeMillis?: string | number;
21
+ /** 0 user, 1 developer, 2 Google. */
22
+ readonly voidedSource?: string | number;
23
+ /**
24
+ * 0 other, 1 remorse, 2 not received, 3 defective, 4 accidental purchase,
25
+ * 5 fraud, 6 friendly fraud, 7 chargeback, 8 unacknowledged purchase.
26
+ */
27
+ readonly voidedReason?: string | number;
28
+ }
29
+ export interface GooglePlayVoidedPurchasesPage {
30
+ readonly voidedPurchases: readonly GooglePlayVoidedPurchase[];
31
+ readonly nextPageToken?: string;
32
+ /** Entries of the page the client could not read (no purchase token); reported, never revoked. */
33
+ readonly droppedEntries?: number;
34
+ }
35
+ export interface GooglePlayVoidedPurchasesClient {
36
+ /**
37
+ * One page of voided in-app purchases. `startTimeMillis` and
38
+ * `endTimeMillis` filter on when Google recorded the void, at most 30 days
39
+ * back; Google ignores them when `pageToken` is set.
40
+ */
41
+ listVoidedPurchases(input: {
42
+ readonly packageName: string;
43
+ readonly startTimeMillis: number;
44
+ readonly endTimeMillis: number;
45
+ readonly pageToken?: string;
46
+ readonly maxResults?: number;
47
+ readonly signal: AbortSignal;
48
+ }): Promise<GooglePlayVoidedPurchasesPage>;
49
+ }
50
+ export type GooglePlayVoidedPurchaseOutcome = {
51
+ readonly status: 'revoked';
52
+ readonly ledgerEntryId: string;
53
+ readonly alreadyProcessed: boolean;
54
+ }
55
+ /** No purchase grant of this token on this ledger (granted elsewhere, or never). */
56
+ | {
57
+ readonly status: 'grant_not_found';
58
+ }
59
+ /** The ledger refused the revocation; logged, not retried. */
60
+ | {
61
+ readonly status: 'conflict';
62
+ readonly reason: string;
63
+ } | {
64
+ readonly status: 'invalid';
65
+ readonly reason: string;
66
+ };
67
+ /** Google keeps voided purchases for 30 days. */
68
+ export declare const googlePlayVoidedPurchasesWindowMs: number;
69
+ /**
70
+ * Kept off the oldest edge of that window: Google refuses a `startTime` older
71
+ * than 30 days at the time it receives the request, which is a little after
72
+ * the caller's clock read.
73
+ */
74
+ export declare const googlePlayVoidedPurchasesWindowSlackMs = 60000;
75
+ /**
76
+ * Appends the `revoke` entry for one voided purchase. The grant is found by
77
+ * the purchase token's verification id, so the revocation is keyed by the
78
+ * token: a purchase seen again by a later sweep (or an RTDN message) answers
79
+ * the first entry with `alreadyProcessed`.
80
+ */
81
+ export declare function revokeGooglePlayVoidedPurchase(input: {
82
+ readonly store: GameServicesStore;
83
+ readonly packageName: string;
84
+ readonly voided: GooglePlayVoidedPurchase;
85
+ readonly now?: () => string;
86
+ }): Promise<GooglePlayVoidedPurchaseOutcome>;
87
+ export interface SweepGooglePlayVoidedPurchasesInput {
88
+ readonly client: GooglePlayVoidedPurchasesClient;
89
+ readonly store: GameServicesStore;
90
+ readonly packageName: string;
91
+ /** Raised to Google's 30-day window, measured from `now` (`clamped` reports it). */
92
+ readonly startTimeMillis: number;
93
+ readonly endTimeMillis: number;
94
+ readonly signal: AbortSignal;
95
+ /**
96
+ * Pages read before the sweep stops as incomplete; 10 by default. A window
97
+ * that does not fit is not finished by repeating it (the listing does not
98
+ * shrink as grants are revoked): raise the cap or split the window into
99
+ * shorter ones.
100
+ */
101
+ readonly maxPages?: number;
102
+ /** Asked before every page and voided purchase; `false` stops the sweep as incomplete. */
103
+ readonly shouldContinue?: (next: 'page' | 'purchase') => boolean;
104
+ /** The clock, for the revocation time of a void without one and the 30-day window. */
105
+ readonly now?: () => string;
106
+ /** Every voided purchase's outcome; the purchase token is left out so it can be logged. */
107
+ readonly onOutcome?: (voided: Omit<GooglePlayVoidedPurchase, 'purchaseToken'>, outcome: GooglePlayVoidedPurchaseOutcome | {
108
+ readonly status: 'error';
109
+ readonly reason: 'GOOGLE_PLAY_VOIDED_STORE_ERROR';
110
+ /** The error's name and message; store errors carry no purchase token. */
111
+ readonly detail: string;
112
+ }) => void;
113
+ }
114
+ export interface GooglePlayVoidedPurchasesSweep {
115
+ /**
116
+ * Every page in the window was read and every entry applied or refused;
117
+ * the caller may advance its cursor to `endTimeMillis`. A store error on
118
+ * an entry (`errored`) leaves the sweep incomplete, since reading the window
119
+ * again retries it. Entries counted in `failed` are not retried that way:
120
+ * they need an operator (see `onOutcome`).
121
+ */
122
+ readonly complete: boolean;
123
+ /** The requested start was older than Google keeps and was raised: voids before it are gone. */
124
+ readonly clamped: boolean;
125
+ readonly pages: number;
126
+ readonly revoked: number;
127
+ readonly alreadyProcessed: number;
128
+ readonly notFound: number;
129
+ /** Ledger refusals and unreadable entries. */
130
+ readonly failed: number;
131
+ /** Entries a store error kept from being applied; the window must be read again. */
132
+ readonly errored: number;
133
+ }
134
+ /**
135
+ * Reads the voided purchases of one time window and revokes their grants.
136
+ * The sweep is the reliable path even next to RTDN, because a Pub/Sub push
137
+ * can be missed: run it on a schedule with a window that overlaps the last
138
+ * complete one (revocations are idempotent per token). An incomplete sweep
139
+ * (page budget, `shouldContinue`) must be repeated from the same start.
140
+ */
141
+ export declare function sweepGooglePlayVoidedPurchases(input: SweepGooglePlayVoidedPurchasesInput): Promise<GooglePlayVoidedPurchasesSweep>;
142
+ /**
143
+ * Retries the acknowledge or consume of a durable Play grant from the ledger
144
+ * entry and the purchase token the Worker kept for it (the ledger itself
145
+ * stores only the token digest). The finalizer is the same Play boundary the
146
+ * purchase path uses, so the provider state is re-read before acting and an
147
+ * already completed purchase answers `completed`. A grant that was reversed
148
+ * is never finalized: Google is refunding it.
149
+ */
150
+ export declare function retryGooglePlayPurchaseFinalization(input: {
151
+ readonly finalizer: GameServicesPurchaseGrantFinalizer;
152
+ readonly store: GameServicesStore;
153
+ readonly ledgerEntryId: string;
154
+ readonly purchaseToken: string;
155
+ readonly signal: AbortSignal;
156
+ readonly timeoutMs: number;
157
+ }): Promise<PurchaseGrantFinalization | {
158
+ readonly status: 'abandoned';
159
+ readonly reason: string;
160
+ }>;
@@ -0,0 +1,297 @@
1
+ import { createGooglePlayTokenPurchaseKey } from '@mpgd/platform/commerce';
2
+ import { findPurchaseGrantByEvidenceVerificationId, isEntitlementGrantReversed, readPurchaseVerificationId, revokeEntitlementGrant, } from './commerce-ledger.js';
3
+ import { createGooglePlayProductVerificationId, googlePlayProductPurchaseEvidenceSchema, } from './google-play-purchase.js';
4
+ /** Google keeps voided purchases for 30 days. */
5
+ export const googlePlayVoidedPurchasesWindowMs = 30 * 24 * 60 * 60 * 1000;
6
+ /**
7
+ * Kept off the oldest edge of that window: Google refuses a `startTime` older
8
+ * than 30 days at the time it receives the request, which is a little after
9
+ * the caller's clock read.
10
+ */
11
+ export const googlePlayVoidedPurchasesWindowSlackMs = 60_000;
12
+ /**
13
+ * Appends the `revoke` entry for one voided purchase. The grant is found by
14
+ * the purchase token's verification id, so the revocation is keyed by the
15
+ * token: a purchase seen again by a later sweep (or an RTDN message) answers
16
+ * the first entry with `alreadyProcessed`.
17
+ */
18
+ export async function revokeGooglePlayVoidedPurchase(input) {
19
+ const { voided } = input;
20
+ if (typeof voided?.purchaseToken !== 'string' || voided.purchaseToken.length === 0) {
21
+ return { status: 'invalid', reason: 'GOOGLE_PLAY_VOIDED_PURCHASE_TOKEN_MISSING' };
22
+ }
23
+ const verificationId = await createGooglePlayProductVerificationId(input.packageName, voided.purchaseToken);
24
+ const grant = await findPurchaseGrantByEvidenceVerificationId(input.store, verificationId);
25
+ if (grant === undefined || grant.payload.target !== 'android') {
26
+ return { status: 'grant_not_found' };
27
+ }
28
+ const voidedAt = readMillis(voided.voidedTimeMillis);
29
+ const voidedReason = readCode(voided.voidedReason);
30
+ const voidedSource = readCode(voided.voidedSource);
31
+ const result = await revokeEntitlementGrant(input.store, {
32
+ playerId: grant.playerId,
33
+ revokesLedgerEntryId: grant.ledgerEntryId,
34
+ idempotencyKey: `google-play:voided:${verificationId}`,
35
+ reason: revocationReasonOf(voidedReason),
36
+ revokedAt: voidedAt === undefined
37
+ ? (input.now ?? (() => new Date().toISOString()))()
38
+ : new Date(voidedAt).toISOString(),
39
+ evidenceVerificationId: `${verificationId}:revoke`,
40
+ payload: {
41
+ target: 'android',
42
+ store: 'google-play',
43
+ googlePlayPackageName: input.packageName,
44
+ ...(voided.orderId === undefined ? {} : { googlePlayOrderId: voided.orderId }),
45
+ ...(voidedReason === undefined ? {} : { googlePlayVoidedReason: voidedReason }),
46
+ ...(voidedSource === undefined ? {} : { googlePlayVoidedSource: voidedSource }),
47
+ ...(voidedAt === undefined ? {} : { googlePlayVoidedTimeMillis: voidedAt }),
48
+ },
49
+ });
50
+ if (result.status === 'revoked') {
51
+ return {
52
+ status: 'revoked',
53
+ ledgerEntryId: result.ledgerEntryId,
54
+ alreadyProcessed: result.alreadyProcessed,
55
+ };
56
+ }
57
+ if (result.status === 'grant_not_found') {
58
+ return { status: 'grant_not_found' };
59
+ }
60
+ return { status: 'conflict', reason: `GOOGLE_PLAY_VOIDED_${result.status.toUpperCase()}` };
61
+ }
62
+ /**
63
+ * Reads the voided purchases of one time window and revokes their grants.
64
+ * The sweep is the reliable path even next to RTDN, because a Pub/Sub push
65
+ * can be missed: run it on a schedule with a window that overlaps the last
66
+ * complete one (revocations are idempotent per token). An incomplete sweep
67
+ * (page budget, `shouldContinue`) must be repeated from the same start.
68
+ */
69
+ export async function sweepGooglePlayVoidedPurchases(input) {
70
+ const maxPages = input.maxPages ?? 10;
71
+ if (!Number.isSafeInteger(maxPages) || maxPages < 1) {
72
+ throw new RangeError('maxPages must be a positive integer.');
73
+ }
74
+ const requestedStart = input.startTimeMillis;
75
+ const endTimeMillis = input.endTimeMillis;
76
+ const nowMillis = input.now === undefined ? Date.now() : Date.parse(input.now());
77
+ if (!Number.isSafeInteger(requestedStart) || !Number.isSafeInteger(endTimeMillis)) {
78
+ throw new RangeError('The voided purchases window must be integer milliseconds.');
79
+ }
80
+ if (!Number.isFinite(nowMillis)) {
81
+ throw new RangeError('now() must return an ISO timestamp.');
82
+ }
83
+ const oldest = nowMillis - googlePlayVoidedPurchasesWindowMs + googlePlayVoidedPurchasesWindowSlackMs;
84
+ const startTimeMillis = Math.max(requestedStart, oldest);
85
+ if (startTimeMillis > endTimeMillis) {
86
+ throw new RangeError('The voided purchases window is empty or invalid.');
87
+ }
88
+ const clamped = startTimeMillis > requestedStart;
89
+ const shouldContinue = input.shouldContinue ?? (() => true);
90
+ const totals = {
91
+ pages: 0,
92
+ revoked: 0,
93
+ alreadyProcessed: 0,
94
+ notFound: 0,
95
+ failed: 0,
96
+ errored: 0,
97
+ };
98
+ let pageToken;
99
+ do {
100
+ if (totals.pages >= maxPages || !shouldContinue('page')) {
101
+ return { complete: false, clamped, ...totals };
102
+ }
103
+ const page = await input.client.listVoidedPurchases({
104
+ packageName: input.packageName,
105
+ startTimeMillis,
106
+ endTimeMillis,
107
+ ...(pageToken === undefined ? {} : { pageToken }),
108
+ signal: input.signal,
109
+ });
110
+ totals.pages += 1;
111
+ totals.failed += page.droppedEntries ?? 0;
112
+ for (const voided of page.voidedPurchases) {
113
+ if (!shouldContinue('purchase')) {
114
+ return { complete: false, clamped, ...totals };
115
+ }
116
+ const { purchaseToken: _token, ...loggable } = voided;
117
+ let outcome;
118
+ try {
119
+ outcome = await revokeGooglePlayVoidedPurchase({
120
+ store: input.store,
121
+ packageName: input.packageName,
122
+ voided,
123
+ ...(input.now === undefined ? {} : { now: input.now }),
124
+ });
125
+ }
126
+ catch (error) {
127
+ if (input.signal.aborted) {
128
+ throw error;
129
+ }
130
+ // One entry's store failure does not hide the rest of the window.
131
+ totals.errored += 1;
132
+ input.onOutcome?.(loggable, {
133
+ status: 'error',
134
+ reason: 'GOOGLE_PLAY_VOIDED_STORE_ERROR',
135
+ detail: error instanceof Error
136
+ ? `${error.name}: ${error.message}`.slice(0, 200)
137
+ : 'unknown error',
138
+ });
139
+ continue;
140
+ }
141
+ if (outcome.status === 'revoked') {
142
+ totals[outcome.alreadyProcessed ? 'alreadyProcessed' : 'revoked'] += 1;
143
+ }
144
+ else if (outcome.status === 'grant_not_found') {
145
+ totals.notFound += 1;
146
+ }
147
+ else {
148
+ totals.failed += 1;
149
+ }
150
+ input.onOutcome?.(loggable, outcome);
151
+ }
152
+ pageToken = page.nextPageToken === undefined || page.nextPageToken.length === 0
153
+ ? undefined
154
+ : page.nextPageToken;
155
+ } while (pageToken !== undefined);
156
+ return { complete: totals.errored === 0, clamped, ...totals };
157
+ }
158
+ /**
159
+ * Retries the acknowledge or consume of a durable Play grant from the ledger
160
+ * entry and the purchase token the Worker kept for it (the ledger itself
161
+ * stores only the token digest). The finalizer is the same Play boundary the
162
+ * purchase path uses, so the provider state is re-read before acting and an
163
+ * already completed purchase answers `completed`. A grant that was reversed
164
+ * is never finalized: Google is refunding it.
165
+ */
166
+ export async function retryGooglePlayPurchaseFinalization(input) {
167
+ const grant = await input.store.getEntitlementTransaction(input.ledgerEntryId);
168
+ if (grant === undefined || grant.source !== 'purchase' || grant.payload.target !== 'android') {
169
+ return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_GRANT_NOT_FOUND' };
170
+ }
171
+ if (await isEntitlementGrantReversed(input.store, grant.ledgerEntryId)) {
172
+ return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_GRANT_REVERSED' };
173
+ }
174
+ const payload = grant.payload;
175
+ const productType = readOneTimeProductType(payload.productType);
176
+ const platformProductId = payload.platformProductId;
177
+ // An orderless purchase (Google omitted the order id) was recorded without
178
+ // one; the boundary matches it by token, as the client's token key does.
179
+ const platformTransactionId = typeof payload.platformTransactionId === 'string'
180
+ && payload.platformTransactionId.length > 0
181
+ ? payload.platformTransactionId
182
+ : await createGooglePlayTokenPurchaseKey(input.purchaseToken);
183
+ const purchasedAt = payload.purchasedAt;
184
+ // A grant restore sharing wrote is acknowledged as the purchase it records.
185
+ const evidenceVerificationId = readPurchaseVerificationId(grant);
186
+ if (productType === undefined
187
+ || typeof platformProductId !== 'string' || platformProductId.length === 0
188
+ || typeof purchasedAt !== 'string' || purchasedAt.length === 0
189
+ || typeof evidenceVerificationId !== 'string'
190
+ || grant.grant === undefined) {
191
+ return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_CONTEXT_UNAVAILABLE' };
192
+ }
193
+ if (evidenceVerificationId
194
+ !== await createGooglePlayProductVerificationId(typeof payload.googlePlayPackageName === 'string' ? payload.googlePlayPackageName : '', input.purchaseToken)) {
195
+ // The kept token is not the one this grant was verified with.
196
+ return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_EVIDENCE_MISMATCH' };
197
+ }
198
+ const deploymentTarget = typeof payload.deploymentTarget === 'string'
199
+ ? payload.deploymentTarget
200
+ : undefined;
201
+ const store = typeof payload.store === 'string' ? payload.store : undefined;
202
+ const request = {
203
+ target: 'android',
204
+ ...(store === undefined ? {} : { store }),
205
+ ...(deploymentTarget === undefined ? {} : { deploymentTarget }),
206
+ playerId: grant.playerId,
207
+ productId: grant.grantId,
208
+ platformTransactionId,
209
+ idempotencyKey: grant.idempotencyKey,
210
+ purchasedAt,
211
+ evidence: {
212
+ schema: googlePlayProductPurchaseEvidenceSchema,
213
+ payload: { purchaseToken: input.purchaseToken },
214
+ },
215
+ };
216
+ const product = {
217
+ id: grant.grantId,
218
+ type: productType,
219
+ grant: grant.grant,
220
+ platformProductIds: { android: platformProductId },
221
+ };
222
+ const finalizationInput = {
223
+ request,
224
+ product,
225
+ platformProductId,
226
+ evidenceVerificationId,
227
+ evidencePayload: payload,
228
+ ledgerEntryId: grant.ledgerEntryId,
229
+ alreadyProcessed: true,
230
+ timeoutMs: input.timeoutMs,
231
+ };
232
+ if (input.finalizer.supportsPurchaseGrant?.(finalizationInput) === false) {
233
+ return { status: 'abandoned', reason: 'GOOGLE_PLAY_FINALIZATION_UNSUPPORTED' };
234
+ }
235
+ const result = await finalizeWithin(input.finalizer, finalizationInput, input.signal, input.timeoutMs);
236
+ if (result.status === 'pending' && result.reason === 'GOOGLE_PLAY_FINALIZATION_EVIDENCE_MISMATCH') {
237
+ // The boundary is configured for another package than the grant: no
238
+ // retry can succeed.
239
+ return { status: 'abandoned', reason: result.reason };
240
+ }
241
+ return result;
242
+ }
243
+ /**
244
+ * Runs the finalizer within `timeoutMs` like the purchase path does: the
245
+ * provider signal is aborted at the deadline and the retry answers pending,
246
+ * even when a provider call ignores the abort.
247
+ */
248
+ async function finalizeWithin(finalizer, input, signal, timeoutMs) {
249
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
250
+ throw new RangeError('timeoutMs must be a positive integer.');
251
+ }
252
+ const controller = new AbortController();
253
+ const abort = () => controller.abort(signal.reason);
254
+ signal.addEventListener('abort', abort, { once: true });
255
+ if (signal.aborted) {
256
+ abort();
257
+ }
258
+ let timer;
259
+ try {
260
+ return await Promise.race([
261
+ finalizer.finalizePurchaseGrant({ ...input, signal: controller.signal }),
262
+ new Promise((resolve) => {
263
+ timer = setTimeout(() => {
264
+ controller.abort();
265
+ resolve({
266
+ status: 'pending',
267
+ alreadyCompleted: false,
268
+ reason: 'GOOGLE_PLAY_FINALIZATION_TIMEOUT',
269
+ });
270
+ }, timeoutMs);
271
+ }),
272
+ ]);
273
+ }
274
+ finally {
275
+ if (timer !== undefined) {
276
+ clearTimeout(timer);
277
+ }
278
+ signal.removeEventListener('abort', abort);
279
+ }
280
+ }
281
+ function readOneTimeProductType(input) {
282
+ return input === 'consumable' || input === 'non_consumable' ? input : undefined;
283
+ }
284
+ function revocationReasonOf(voidedReason) {
285
+ return voidedReason === 7 ? 'chargeback' : 'voided';
286
+ }
287
+ function readMillis(input) {
288
+ const value = typeof input === 'string' && /^[0-9]{1,16}$/u.test(input) ? Number(input) : input;
289
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
290
+ && value <= 8_640_000_000_000_000
291
+ ? value
292
+ : undefined;
293
+ }
294
+ function readCode(input) {
295
+ const value = typeof input === 'string' && /^[0-9]{1,3}$/u.test(input) ? Number(input) : input;
296
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
297
+ }