@mpgd/game-services 0.28.1 → 0.30.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/README.md +18 -3
- package/dist/account-deletion.d.ts +306 -0
- package/dist/account-deletion.js +491 -0
- package/dist/app-store-recovery.d.ts +6 -0
- package/dist/app-store-recovery.js +26 -16
- package/dist/app-store-server-notifications.d.ts +158 -0
- package/dist/app-store-server-notifications.js +270 -0
- package/dist/app-store-server.d.ts +8 -0
- package/dist/app-store-server.js +135 -37
- package/dist/app-store-verifier.d.ts +34 -0
- package/dist/app-store-verifier.js +161 -31
- package/dist/commerce-ledger.d.ts +55 -0
- package/dist/commerce-ledger.js +198 -0
- package/dist/commerce-reconciler.js +47 -7
- package/dist/evidence-verification.d.ts +19 -0
- package/dist/google-play-publisher-client.d.ts +2 -1
- package/dist/google-play-publisher-client.js +80 -0
- package/dist/google-play-purchase.d.ts +7 -0
- package/dist/google-play-purchase.js +32 -7
- package/dist/google-play-store-server.d.ts +160 -0
- package/dist/google-play-store-server.js +297 -0
- package/dist/guest-session.d.ts +112 -2
- package/dist/guest-session.js +196 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/notifications/delivery-provider.d.ts +7 -0
- package/dist/notifications/fcm.d.ts +30 -3
- package/dist/notifications/fcm.js +72 -13
- package/dist/notifications/service.js +33 -5
- package/dist/notifications/store-conformance.js +7 -1
- package/dist/notifications/store.d.ts +9 -0
- package/dist/notifications/store.js +41 -0
- package/dist/operation-results.d.ts +7 -0
- package/dist/purchase-sharing.d.ts +41 -0
- package/dist/purchase-sharing.js +37 -0
- package/dist/runtime.d.ts +6 -0
- package/dist/runtime.js +13 -0
- package/dist/server.d.ts +60 -1
- package/dist/server.js +536 -42
- package/dist/types.d.ts +30 -0
- package/dist/types.js +22 -0
- package/dist/verified-leaderboard.d.ts +13 -0
- package/dist/verified-leaderboard.js +31 -0
- package/package.json +17 -5
|
@@ -461,22 +461,49 @@ export function createCommerceReconciler(input) {
|
|
|
461
461
|
trigger();
|
|
462
462
|
scheduleRun();
|
|
463
463
|
};
|
|
464
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Reads the store's available purchases into the inbox. `restore` is the
|
|
466
|
+
* player's Restore Purchases read only: the store also lists owned
|
|
467
|
+
* non-consumables its recovery list hides (acknowledged on Play, bound to
|
|
468
|
+
* another guest's token on StoreKit), so a reinstall or a new device can
|
|
469
|
+
* restore them. Startup and resume recovery never set it, so an
|
|
470
|
+
* acknowledged durable is not sent again on every launch.
|
|
471
|
+
*/
|
|
472
|
+
const readAvailable = async (capabilitiesNow, restore = false) => {
|
|
465
473
|
if (!capabilitiesNow.lifecycle.availablePurchases) {
|
|
466
474
|
return true;
|
|
467
475
|
}
|
|
468
|
-
|
|
476
|
+
const read = async (withRestore) => {
|
|
469
477
|
const available = await provider.getAvailablePurchases({
|
|
470
478
|
includePending: capabilitiesNow.lifecycle.pending,
|
|
479
|
+
...(withRestore ? { restore: true } : {}),
|
|
471
480
|
});
|
|
472
481
|
for (const purchase of available) {
|
|
473
482
|
inbox.push({ type: 'purchaseUpdated', purchase });
|
|
474
483
|
}
|
|
484
|
+
};
|
|
485
|
+
try {
|
|
486
|
+
await read(restore);
|
|
475
487
|
return true;
|
|
476
488
|
}
|
|
477
489
|
catch (error) {
|
|
490
|
+
if (!restore) {
|
|
491
|
+
fail('available', error);
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
// A failed restore read still makes the recovery read, so Restore
|
|
495
|
+
// Purchases never finds less than a recovery pass: a bridge host built
|
|
496
|
+
// before `restore` rejects the field, and a store may fail only the
|
|
497
|
+
// wider read. The restore failure is still reported.
|
|
478
498
|
fail('available', error);
|
|
479
|
-
|
|
499
|
+
try {
|
|
500
|
+
await read(false);
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
catch (fallbackError) {
|
|
504
|
+
fail('available', fallbackError);
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
480
507
|
}
|
|
481
508
|
};
|
|
482
509
|
const buildRequest = (record) => {
|
|
@@ -497,6 +524,11 @@ export function createCommerceReconciler(input) {
|
|
|
497
524
|
idempotencyKey: record.idempotencyKey,
|
|
498
525
|
purchasedAt: purchase.purchasedAt ?? now(),
|
|
499
526
|
evidence: purchase.evidence,
|
|
527
|
+
// A purchase the store reported without this journal starting it (a
|
|
528
|
+
// reinstall, a new device, Restore Purchases) is a restore: the backend
|
|
529
|
+
// may grant this player a non-consumable bound to another guest (RFC
|
|
530
|
+
// "Restore sharing"). A purchase the game started never asks for it.
|
|
531
|
+
...(record.origin === 'discovered' ? { intent: 'restore' } : {}),
|
|
500
532
|
};
|
|
501
533
|
};
|
|
502
534
|
// Reconciler step 2: match a reported purchase to a journal record.
|
|
@@ -847,7 +879,8 @@ export function createCommerceReconciler(input) {
|
|
|
847
879
|
// read again until it succeeds, so a purchase that predates the
|
|
848
880
|
// subscription is not missed for the session.
|
|
849
881
|
if (capabilitiesNow !== undefined && (!capabilitiesNow.lifecycle.events || initialScanPending)) {
|
|
850
|
-
initialScanPending = !(await readAvailable(capabilitiesNow))
|
|
882
|
+
initialScanPending = !(await readAvailable(capabilitiesNow, options.restore === true))
|
|
883
|
+
&& capabilitiesNow.lifecycle.events;
|
|
851
884
|
}
|
|
852
885
|
// An event for a product whose store call is still running waits for that
|
|
853
886
|
// call to journal the store answer: matched then, it is never journaled
|
|
@@ -1292,11 +1325,18 @@ export function createCommerceReconciler(input) {
|
|
|
1292
1325
|
await loadRecords();
|
|
1293
1326
|
}
|
|
1294
1327
|
const capabilitiesNow = await readCapabilities(true);
|
|
1295
|
-
// A polled provider is read by the pass itself.
|
|
1328
|
+
// A polled provider is read by the pass itself. Either read is a
|
|
1329
|
+
// restore read: owned non-consumables the store's recovery list
|
|
1330
|
+
// hides (a reinstall, a new device) reach the backend as restores.
|
|
1331
|
+
let restoreRead = false;
|
|
1296
1332
|
if (capabilitiesNow !== undefined && capabilitiesNow.lifecycle.events) {
|
|
1297
|
-
await readAvailable(capabilitiesNow);
|
|
1333
|
+
restoreRead = await readAvailable(capabilitiesNow, true);
|
|
1334
|
+
if (restoreRead) {
|
|
1335
|
+
// This read covers a start-time read that failed.
|
|
1336
|
+
initialScanPending = false;
|
|
1337
|
+
}
|
|
1298
1338
|
}
|
|
1299
|
-
await pass({ explicit: true, refreshCapabilities: false });
|
|
1339
|
+
await pass({ explicit: true, refreshCapabilities: false, restore: !restoreRead });
|
|
1300
1340
|
});
|
|
1301
1341
|
await runUntilIdle();
|
|
1302
1342
|
},
|
|
@@ -24,6 +24,25 @@ export type EvidenceVerificationDecision = {
|
|
|
24
24
|
readonly ledgerEntryId: string;
|
|
25
25
|
/** The ledger target the entry was written under when it is not the request's target. */
|
|
26
26
|
readonly ledgerTarget?: string;
|
|
27
|
+
} | {
|
|
28
|
+
/**
|
|
29
|
+
* The store verified a restored non-consumable, but its account
|
|
30
|
+
* binding (Google Play `obfuscatedAccountId` / `obfuscatedProfileId`,
|
|
31
|
+
* StoreKit `appAccountToken`) names another account than the
|
|
32
|
+
* requesting player's, or none: the player reinstalled or moved to a
|
|
33
|
+
* new device and is a new guest (RFC "Restore sharing"). Every other
|
|
34
|
+
* check passed. The reference verifiers answer it only for a request
|
|
35
|
+
* with `intent: 'restore'` of a non-consumable; the backend then grants
|
|
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`.
|
|
39
|
+
*/
|
|
40
|
+
readonly status: 'restorable';
|
|
41
|
+
readonly reason: string;
|
|
42
|
+
readonly verificationId: string;
|
|
43
|
+
readonly verifiedAt: string;
|
|
44
|
+
readonly payload?: EntitlementLedgerPayload;
|
|
45
|
+
readonly platformEvidenceId?: string | null;
|
|
27
46
|
} | {
|
|
28
47
|
readonly status: 'pending';
|
|
29
48
|
readonly reason?: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GooglePlayProductPurchaseClient } from './google-play-purchase.js';
|
|
2
|
+
import type { GooglePlayVoidedPurchasesClient } from './google-play-store-server.js';
|
|
2
3
|
export interface GooglePlayPublisherClientOptions {
|
|
3
4
|
/** Supplies an androidpublisher-scoped OAuth access token for each request. */
|
|
4
5
|
readonly getAccessToken: (signal: AbortSignal) => Promise<string> | string;
|
|
@@ -8,4 +9,4 @@ export interface GooglePlayPublisherClientOptions {
|
|
|
8
9
|
* Google Play purchase verification and post-ledger finalization transport.
|
|
9
10
|
* The caller owns OAuth credential rotation and must never expose this client to the game.
|
|
10
11
|
*/
|
|
11
|
-
export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient;
|
|
12
|
+
export declare function createGooglePlayPublisherClient(options: GooglePlayPublisherClientOptions): GooglePlayProductPurchaseClient & GooglePlayVoidedPurchasesClient;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
+
foreignBinding = 'GOOGLE_PLAY_PROFILE_MISMATCH';
|
|
345
|
+
}
|
|
346
|
+
if (foreignBinding !== undefined && input.reportForeignBinding !== true) {
|
|
347
|
+
return rejected(foreignBinding);
|
|
335
348
|
}
|
|
336
|
-
if (
|
|
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 (
|
|
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
|
+
}>;
|