@mpgd/platform 0.18.0 → 0.20.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.
@@ -0,0 +1,2162 @@
1
+ import { assertCommercePurchaseTransition, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createCommerceEventBuffer, createDerivedPurchaseIdempotencyKey, isTerminalCommercePurchaseState, } from './commerce.js';
2
+ /**
3
+ * Commerce v2 conformance runner (RFC "Conformance vectors") with the in-memory
4
+ * reference pieces every vector needs: a fake clock, a fake store with its
5
+ * provider, a fake backend and ledger, an in-memory journal, the reference
6
+ * reconciler runtime, and a reference credit wallet with its provider. Pure
7
+ * in-process code: no Phaser, DOM, network, or SDK imports. Nothing here
8
+ * grants outside the fake ledger, and the fake ledger only writes what a
9
+ * vector scripts through `queueVerify`, `push`, or `revoke`.
10
+ *
11
+ * Store (B-phase) providers run the same vectors by supplying
12
+ * `createProvider` and translating the store actor steps into their own fake
13
+ * SDK; the reconciler, journal, backend, and matcher stay shared. A vector
14
+ * flagged `profile.adaptive` then matches expectations derived from the
15
+ * provider's declared capabilities rather than its fixture profile; see
16
+ * `deriveAdaptiveCommerceConformanceExpectation` and
17
+ * `commerceConformanceAdaptiveSkipReason`.
18
+ */
19
+ export const commerceConformanceDefaultPlayerId = 'player-1';
20
+ export const commerceConformanceDefaultTitleId = 'title-a';
21
+ export const commerceConformanceDefaultClockStart = '2026-10-01T00:00:00Z';
22
+ /** An operation the vector did not script; it fails the vector with its message. */
23
+ export class CommerceConformanceScriptError extends Error {
24
+ name = 'CommerceConformanceScriptError';
25
+ }
26
+ export function createCommerceConformanceClock(initial = commerceConformanceDefaultClockStart) {
27
+ let current = normalizeIsoTimestamp(initial);
28
+ return {
29
+ now: () => current,
30
+ set(at) {
31
+ current = normalizeIsoTimestamp(at);
32
+ },
33
+ };
34
+ }
35
+ /** Canonical `YYYY-MM-DDTHH:mm:ssZ`; milliseconds are kept only when non-zero. */
36
+ export function normalizeIsoTimestamp(input) {
37
+ const millis = Date.parse(input);
38
+ if (Number.isNaN(millis)) {
39
+ throw new TypeError(`Invalid ISO-8601 timestamp: ${input}`);
40
+ }
41
+ return formatIsoTimestamp(millis);
42
+ }
43
+ function formatIsoTimestamp(millis) {
44
+ const iso = new Date(millis).toISOString();
45
+ return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
46
+ }
47
+ /** Adds an ISO-8601 duration (`P30D`, `P1M`, `PT12H`, ...) to a timestamp, in UTC calendar units. */
48
+ export function addIsoDuration(at, duration) {
49
+ const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u
50
+ .exec(duration);
51
+ if (match === null || duration === 'P' || duration === 'PT') {
52
+ throw new TypeError(`Invalid ISO-8601 duration: ${duration}`);
53
+ }
54
+ const [, years, months, weeks, days, hours, minutes, seconds] = match;
55
+ const date = new Date(normalizeIsoTimestamp(at));
56
+ date.setUTCFullYear(date.getUTCFullYear() + Number(years ?? 0), date.getUTCMonth() + Number(months ?? 0), date.getUTCDate() + Number(weeks ?? 0) * 7 + Number(days ?? 0));
57
+ date.setUTCHours(date.getUTCHours() + Number(hours ?? 0), date.getUTCMinutes() + Number(minutes ?? 0), date.getUTCSeconds() + Number(seconds ?? 0));
58
+ return formatIsoTimestamp(date.getTime());
59
+ }
60
+ // Capabilities and fixture filling.
61
+ /** Capabilities of a vector profile: omitted blocks default to all supported and `available`. */
62
+ export function createCommerceConformanceCapabilities(profile) {
63
+ return {
64
+ purchase: profile.purchase === undefined
65
+ ? { consumable: true, durable: true, subscription: true, quantity: true }
66
+ : { ...profile.purchase },
67
+ catalog: { products: true, offers: true, localizedPrice: true },
68
+ lifecycle: profile.lifecycle === undefined
69
+ ? { availablePurchases: true, pending: true, restore: true, events: true, finish: 'client' }
70
+ : { ...profile.lifecycle },
71
+ authority: profile.authority === undefined
72
+ ? { evidence: 'client-token', accountBinding: 'optional' }
73
+ : { ...profile.authority },
74
+ availability: 'available',
75
+ };
76
+ }
77
+ /** Fake evidence for a fixture purchase: `fixture.<store>.v1` carrying the token. */
78
+ export function createCommerceConformanceEvidence(store, token) {
79
+ return { schema: `fixture.${store}.v1`, payload: { token } };
80
+ }
81
+ function createFixtureAccountBinding(context) {
82
+ switch (context.profile.authority?.accountBinding ?? 'optional') {
83
+ case 'required':
84
+ return { kind: 'obfuscated-account-id', value: context.playerId };
85
+ case 'host':
86
+ return { kind: 'host-identity' };
87
+ case 'optional':
88
+ return undefined;
89
+ }
90
+ }
91
+ /**
92
+ * Fills vector purchase shorthand into a typed `StorePurchase`: the profile's
93
+ * store, quantity 1, the catalog logical id of the SKU, fake evidence, and the
94
+ * account binding the profile's authority block implies.
95
+ */
96
+ export function fillCommerceConformancePurchase(fixture, context) {
97
+ const store = context.profile.store;
98
+ const entry = context.catalog.find((candidate) => candidate.storeSku === fixture.storeSku);
99
+ const accountBinding = createFixtureAccountBinding(context);
100
+ return {
101
+ store,
102
+ transactionId: fixture.transactionId,
103
+ ...(fixture.originalTransactionId === undefined
104
+ ? {}
105
+ : { originalTransactionId: fixture.originalTransactionId }),
106
+ storeSku: fixture.storeSku,
107
+ ...(entry === undefined ? {} : { logicalId: entry.logicalId }),
108
+ quantity: 1,
109
+ state: fixture.state,
110
+ ...(fixture.purchasedAt === undefined ? {} : { purchasedAt: fixture.purchasedAt }),
111
+ token: fixture.token,
112
+ evidence: createCommerceConformanceEvidence(store, fixture.token),
113
+ ...(accountBinding === undefined ? {} : { accountBinding }),
114
+ ...(fixture.settlement === undefined ? {} : { settlement: { ...fixture.settlement } }),
115
+ };
116
+ }
117
+ export function commerceErrorInfoOfFixture(fixture) {
118
+ return {
119
+ code: fixture.code,
120
+ retryable: fixture.retryable,
121
+ ...(fixture.storeCode === undefined ? {} : { storeCode: fixture.storeCode }),
122
+ message: `Commerce operation failed: ${fixture.code}.`,
123
+ };
124
+ }
125
+ /**
126
+ * Fake store: answers `launched` unless a `queueRequest` entry is queued,
127
+ * `{ finished: true }` unless a `queueFinish` entry is queued, lists reported
128
+ * purchases until they are finished, and buffers events emitted before the
129
+ * first listener with `createCommerceEventBuffer`.
130
+ */
131
+ export function createCommerceConformanceStore(input) {
132
+ const capabilities = createCommerceConformanceCapabilities(input.profile);
133
+ const store = input.profile.store;
134
+ const finishOwner = capabilities.lifecycle.finish;
135
+ const purchases = [];
136
+ const requestQueue = [];
137
+ const finishQueue = [];
138
+ const finishCalls = [];
139
+ const buffer = createCommerceEventBuffer();
140
+ let listener;
141
+ let purchaseUiOpened = 0;
142
+ let restoreCalls = 0;
143
+ const emit = (event) => {
144
+ // An event-less store reports nothing; the reconciler polls its available list instead.
145
+ if (!capabilities.lifecycle.events) {
146
+ return;
147
+ }
148
+ if (listener === undefined) {
149
+ buffer.push(event);
150
+ return;
151
+ }
152
+ listener(event);
153
+ };
154
+ const upsert = (purchase) => {
155
+ const index = purchases.findIndex((candidate) => candidate.transactionId === purchase.transactionId);
156
+ if (index === -1) {
157
+ purchases.push(purchase);
158
+ }
159
+ else {
160
+ purchases[index] = purchase;
161
+ }
162
+ };
163
+ const remove = (transactionId) => {
164
+ const index = purchases.findIndex((candidate) => candidate.transactionId === transactionId);
165
+ if (index !== -1) {
166
+ purchases.splice(index, 1);
167
+ }
168
+ };
169
+ const report = (fixture) => {
170
+ const purchase = fillCommerceConformancePurchase(fixture, input);
171
+ upsert(purchase);
172
+ emit({ type: 'purchaseUpdated', purchase });
173
+ };
174
+ const provider = {
175
+ store,
176
+ async getCapabilities() {
177
+ return createCommerceConformanceCapabilities(input.profile);
178
+ },
179
+ async fetchProducts(request) {
180
+ return input.catalog
181
+ .filter((entry) => matchesFetchProducts(entry, request))
182
+ .map((entry) => ({
183
+ logicalId: entry.logicalId,
184
+ store,
185
+ storeSku: entry.storeSku,
186
+ kind: entry.kind,
187
+ title: entry.logicalId,
188
+ description: `Fixture product ${entry.storeSku}`,
189
+ price: { formatted: '$0.99', currencyCode: 'USD', amountMicros: 990_000 },
190
+ }));
191
+ },
192
+ async requestPurchase() {
193
+ purchaseUiOpened += 1;
194
+ const queued = requestQueue.shift();
195
+ if (queued === undefined) {
196
+ return { outcome: 'launched' };
197
+ }
198
+ if (queued.outcome === 'launched') {
199
+ return { outcome: 'launched' };
200
+ }
201
+ if (queued.outcome === 'purchased') {
202
+ const purchase = fillCommerceConformancePurchase(queued.purchase, input);
203
+ upsert(purchase);
204
+ return { outcome: 'purchased', purchase };
205
+ }
206
+ return { outcome: queued.outcome, error: commerceErrorInfoOfFixture(queued.error) };
207
+ },
208
+ async getAvailablePurchases(request = {}) {
209
+ if (!capabilities.lifecycle.availablePurchases) {
210
+ return [];
211
+ }
212
+ const includePending = request.includePending ?? false;
213
+ // A revoked purchase is not recoverable; it reaches the runtime through the event path only.
214
+ return purchases.filter((purchase) => purchase.state !== 'revoked' && (includePending || purchase.state !== 'pending'));
215
+ },
216
+ async finishTransaction(request) {
217
+ const by = finishOwner === 'host' ? 'host' : 'client';
218
+ const queued = finishQueue.shift() ?? { finished: true };
219
+ if ('error' in queued) {
220
+ finishCalls.push({ by, ...request, result: queued.error });
221
+ throw new CommerceError(queued.error, { message: 'Fixture finish failure.' });
222
+ }
223
+ finishCalls.push({ by, ...request, result: queued.finished ? 'finished' : 'unfinished' });
224
+ if (queued.finished) {
225
+ remove(request.transactionId);
226
+ }
227
+ return { finished: queued.finished };
228
+ },
229
+ async restorePurchases() {
230
+ restoreCalls += 1;
231
+ },
232
+ subscribe(next) {
233
+ if (listener !== undefined) {
234
+ throw new CommerceConformanceScriptError('The fixture store supports one listener at a time.');
235
+ }
236
+ listener = next;
237
+ buffer.drain(next);
238
+ return () => {
239
+ if (listener === next) {
240
+ listener = undefined;
241
+ }
242
+ };
243
+ },
244
+ };
245
+ return {
246
+ provider,
247
+ get purchases() {
248
+ return [...purchases];
249
+ },
250
+ get purchaseUiOpened() {
251
+ return purchaseUiOpened;
252
+ },
253
+ get restoreCalls() {
254
+ return restoreCalls;
255
+ },
256
+ get finishCalls() {
257
+ return [...finishCalls];
258
+ },
259
+ complete: report,
260
+ update: report,
261
+ error(event) {
262
+ emit({
263
+ type: 'purchaseError',
264
+ ...(event.logicalId === undefined ? {} : { logicalId: event.logicalId }),
265
+ error: commerceErrorInfoOfFixture(event.error),
266
+ });
267
+ },
268
+ queueRequest(result) {
269
+ requestQueue.push(result);
270
+ },
271
+ queueFinish(result) {
272
+ finishQueue.push(result);
273
+ },
274
+ finalize(request) {
275
+ remove(request.transactionId);
276
+ },
277
+ markFinished: remove,
278
+ };
279
+ }
280
+ function matchesFetchProducts(entry, request) {
281
+ const type = request.type ?? 'all';
282
+ if (type === 'in-app' && entry.kind === 'subscription') {
283
+ return false;
284
+ }
285
+ if (type === 'subscription' && entry.kind !== 'subscription') {
286
+ return false;
287
+ }
288
+ return request.logicalIds === undefined || request.logicalIds.includes(entry.logicalId);
289
+ }
290
+ /**
291
+ * Fake backend: every verify call consumes the next `queueVerify` decision in
292
+ * order and is logged; an unqueued verify is an unscripted operation. A
293
+ * `granted` decision makes sure the ledger holds the entry, so a replayed
294
+ * grant and a host-pushed grant never create a second entry. With
295
+ * `finalizeOnReread`, a replayed granted key is answered as finalized from
296
+ * the backend's own state.
297
+ */
298
+ export function createCommerceConformanceBackend(input = {}) {
299
+ const decisions = [];
300
+ const verifyRequests = [];
301
+ const ledger = [];
302
+ /** Ledger entry id per granted idempotency key. */
303
+ const grants = new Map();
304
+ const findEntry = (entryId) => ledger.find((entry) => entry.entryId === entryId);
305
+ const reversalOf = (ledgerEntryId) => ledger.find((entry) => entry.type === 'revoke' && entry.reverses === ledgerEntryId);
306
+ return {
307
+ get verifyRequests() {
308
+ return [...verifyRequests];
309
+ },
310
+ get ledger() {
311
+ return [...ledger];
312
+ },
313
+ queueVerify(decision) {
314
+ decisions.push(decision);
315
+ },
316
+ async verifyPurchase(request) {
317
+ verifyRequests.push(request);
318
+ const granted = grants.get(request.idempotencyKey);
319
+ if (granted !== undefined
320
+ && reversalOf(granted) === undefined
321
+ && input.finalizeOnReread?.() === true) {
322
+ // The key's own grant answers the re-read: no queued decision is
323
+ // consumed, so a decision scripted for another record stays in place.
324
+ // A reversed grant is not finalized; the re-read falls through to the script.
325
+ await input.finalize?.({
326
+ store: request.store,
327
+ transactionId: request.transactionId,
328
+ ledgerEntryId: granted,
329
+ logicalId: request.logicalId,
330
+ });
331
+ return {
332
+ status: 'granted',
333
+ ledgerEntryId: granted,
334
+ finalization: 'completed',
335
+ alreadyProcessed: true,
336
+ };
337
+ }
338
+ const decision = decisions.shift();
339
+ if (decision === undefined) {
340
+ throw new CommerceConformanceScriptError(`Unscripted verify for ${request.idempotencyKey} (${request.store}/${request.transactionId}); `
341
+ + 'queue a backend.queueVerify decision.');
342
+ }
343
+ if (decision.status !== 'granted') {
344
+ return { status: decision.status };
345
+ }
346
+ const ledgerEntryId = decision.ledgerEntryId;
347
+ if (ledgerEntryId === undefined) {
348
+ throw new CommerceConformanceScriptError(`A granted decision for ${request.idempotencyKey} must name a ledgerEntryId.`);
349
+ }
350
+ if (findEntry(ledgerEntryId) === undefined) {
351
+ ledger.push({
352
+ entryId: ledgerEntryId,
353
+ type: 'grant',
354
+ logicalId: request.logicalId,
355
+ store: request.store,
356
+ transactionId: request.transactionId,
357
+ idempotencyKey: request.idempotencyKey,
358
+ });
359
+ }
360
+ grants.set(request.idempotencyKey, ledgerEntryId);
361
+ if (decision.finalization === 'completed') {
362
+ await input.finalize?.({
363
+ store: request.store,
364
+ transactionId: request.transactionId,
365
+ ledgerEntryId,
366
+ logicalId: request.logicalId,
367
+ });
368
+ }
369
+ return {
370
+ status: 'granted',
371
+ ledgerEntryId,
372
+ ...(decision.finalization === undefined ? {} : { finalization: decision.finalization }),
373
+ ...(decision.alreadyProcessed === undefined
374
+ ? {}
375
+ : { alreadyProcessed: decision.alreadyProcessed }),
376
+ };
377
+ },
378
+ async getLedgerStatus(ledgerEntryIds) {
379
+ return ledgerEntryIds.map((ledgerEntryId) => {
380
+ const entry = findEntry(ledgerEntryId);
381
+ if (entry === undefined || entry.type !== 'grant') {
382
+ return { ledgerEntryId, status: 'unknown' };
383
+ }
384
+ const reversal = reversalOf(ledgerEntryId);
385
+ return reversal === undefined
386
+ ? { ledgerEntryId, status: 'active' }
387
+ : { ledgerEntryId, status: 'reversed', reversedBy: reversal.entryId };
388
+ });
389
+ },
390
+ push(grant) {
391
+ // Keyed by store transaction id: a retried fulfillment replays the
392
+ // original grant, even when the retry names a different entry id.
393
+ const replayed = ledger.find((entry) => entry.type === 'grant'
394
+ && (entry.entryId === grant.entryId || entry.transactionId === grant.transactionId));
395
+ if (replayed !== undefined) {
396
+ return;
397
+ }
398
+ ledger.push({
399
+ entryId: grant.entryId,
400
+ type: 'grant',
401
+ logicalId: grant.logicalId,
402
+ store: grant.store ?? 'server-push',
403
+ transactionId: grant.transactionId,
404
+ pushed: true,
405
+ });
406
+ },
407
+ revoke(revocation) {
408
+ const grant = findEntry(revocation.ledgerEntryId);
409
+ if (grant === undefined || grant.type !== 'grant') {
410
+ throw new CommerceConformanceScriptError(`Cannot revoke ${revocation.ledgerEntryId}: the ledger holds no such grant.`);
411
+ }
412
+ if (findEntry(revocation.entryId) !== undefined) {
413
+ return;
414
+ }
415
+ ledger.push({
416
+ entryId: revocation.entryId,
417
+ type: 'revoke',
418
+ logicalId: grant.logicalId,
419
+ reverses: revocation.ledgerEntryId,
420
+ reason: revocation.reason,
421
+ });
422
+ },
423
+ entitlements() {
424
+ return ledger.flatMap((entry) => entry.type === 'grant' && reversalOf(entry.entryId) === undefined
425
+ ? [{ id: entry.entryId, logicalId: entry.logicalId }]
426
+ : []);
427
+ },
428
+ };
429
+ }
430
+ /** In-memory journal that keeps every revision so `expect.journal` can read the full sequence. */
431
+ export function createCommerceConformanceJournal() {
432
+ const revisions = new Map();
433
+ const current = (key) => revisions.get(key)?.at(-1);
434
+ const list = (playerId, accept) => {
435
+ const records = [];
436
+ for (const key of revisions.keys()) {
437
+ const record = current(key);
438
+ if (record !== undefined && record.playerId === playerId && accept(record)) {
439
+ records.push(record);
440
+ }
441
+ }
442
+ return records;
443
+ };
444
+ return {
445
+ get keys() {
446
+ return [...revisions.keys()];
447
+ },
448
+ async reserve(record) {
449
+ const existing = current(record.key);
450
+ if (existing !== undefined) {
451
+ return { created: false, record: existing };
452
+ }
453
+ const stored = { ...record, revision: 0 };
454
+ revisions.set(record.key, [stored]);
455
+ return { created: true, record: stored };
456
+ },
457
+ async read(key) {
458
+ return current(key);
459
+ },
460
+ async replace(expectedRevision, record) {
461
+ const history = revisions.get(record.key);
462
+ const existing = history?.at(-1);
463
+ if (history === undefined || existing === undefined) {
464
+ throw new Error(`Journal record ${record.key} does not exist.`);
465
+ }
466
+ if (existing.revision !== expectedRevision) {
467
+ throw new Error(`Journal record ${record.key} is at revision ${existing.revision}, not ${expectedRevision}.`);
468
+ }
469
+ if (existing.playerId !== record.playerId
470
+ || existing.store !== record.store
471
+ || existing.logicalId !== record.logicalId) {
472
+ throw new Error(`Journal record ${record.key} identity cannot change.`);
473
+ }
474
+ if (existing.state === record.state) {
475
+ throw new Error(`Journal record ${record.key} revision ${expectedRevision + 1} must change its state.`);
476
+ }
477
+ assertCommercePurchaseTransition(existing.state, record.state);
478
+ const stored = { ...record, revision: expectedRevision + 1 };
479
+ history.push(stored);
480
+ return stored;
481
+ },
482
+ async listRecoverable(playerId) {
483
+ return list(playerId, (record) => !isTerminalCommercePurchaseState(record.state));
484
+ },
485
+ async listGranted(playerId) {
486
+ return list(playerId, (record) => record.ledgerEntryId !== undefined
487
+ && (record.state === 'granted'
488
+ || record.state === 'finishing'
489
+ || record.state === 'finished'));
490
+ },
491
+ history(key) {
492
+ return (revisions.get(key) ?? []).map((record) => record.state);
493
+ },
494
+ snapshot() {
495
+ const snapshot = {};
496
+ for (const [key, history] of revisions) {
497
+ snapshot[key] = history.map((record) => record.state);
498
+ }
499
+ return snapshot;
500
+ },
501
+ };
502
+ }
503
+ /**
504
+ * Reference reconciler. Every transition is its own journal revision written
505
+ * through compare-and-replace; the backend is in-process, so there is no
506
+ * transport, but a thrown finish or verify keeps the state for the next pass
507
+ * as a transport failure would. Only a ledger entry moves a record to
508
+ * `granted`, and only a ledger revocation moves it to `revoked`.
509
+ */
510
+ export function createCommerceConformanceRuntime(input) {
511
+ const { provider, backend, journal, clock, catalog, playerId } = input;
512
+ const store = provider.store;
513
+ const records = new Map();
514
+ const inbox = [];
515
+ let unsubscribe;
516
+ let started = false;
517
+ let triggerPending = false;
518
+ let running;
519
+ /** A failure from a pass nobody awaited; the next `runUntilIdle` rethrows it. */
520
+ let pendingFailure;
521
+ const catalogEntry = (logicalId) => {
522
+ const entry = catalog.find((candidate) => candidate.logicalId === logicalId);
523
+ if (entry === undefined) {
524
+ throw new CommerceConformanceScriptError(`Unknown logical id ${logicalId}; declare it in the vector catalog.`);
525
+ }
526
+ return entry;
527
+ };
528
+ const resolveLogicalId = (purchase) => {
529
+ if (purchase.logicalId !== undefined) {
530
+ return catalogEntry(purchase.logicalId).logicalId;
531
+ }
532
+ const entry = catalog.find((candidate) => candidate.storeSku === purchase.storeSku);
533
+ if (entry === undefined) {
534
+ throw new CommerceConformanceScriptError(`Purchase ${purchase.transactionId} names SKU ${purchase.storeSku}, which no catalog entry declares.`);
535
+ }
536
+ return entry.logicalId;
537
+ };
538
+ const remember = (record) => {
539
+ records.set(record.key, record);
540
+ return record;
541
+ };
542
+ const write = async (record, state, patch = {}) => remember(await journal.replace(record.revision, {
543
+ ...record,
544
+ ...patch,
545
+ state,
546
+ updatedAt: clock.now(),
547
+ }));
548
+ const create = async (record) => {
549
+ const reserved = await journal.reserve({ ...record, revision: 0, updatedAt: clock.now() });
550
+ remember(reserved.record);
551
+ return reserved;
552
+ };
553
+ const loadRecords = async () => {
554
+ for (const record of await journal.listRecoverable(playerId)) {
555
+ remember(record);
556
+ }
557
+ for (const record of await journal.listGranted(playerId)) {
558
+ remember(record);
559
+ }
560
+ };
561
+ const openRecordsFor = (logicalId) => [...records.values()].filter((record) => record.store === store
562
+ && record.logicalId === logicalId
563
+ && (record.state === 'launched' || record.state === 'pending'));
564
+ const trigger = () => {
565
+ triggerPending = true;
566
+ };
567
+ const listener = (event) => {
568
+ inbox.push(event);
569
+ trigger();
570
+ // Every event starts a pass on its own; a pass already in flight picks the trigger up.
571
+ scheduleRun();
572
+ };
573
+ // Reconciler step 2: match a reported purchase to a journal record.
574
+ const ingestPurchase = async (purchase) => {
575
+ if (purchase.store !== store) {
576
+ throw new CommerceConformanceScriptError(`Purchase ${purchase.transactionId} belongs to store ${purchase.store}, not ${store}.`);
577
+ }
578
+ const logicalId = resolveLogicalId(purchase);
579
+ let record = [...records.values()].find((candidate) => candidate.store === purchase.store
580
+ && candidate.purchase?.transactionId === purchase.transactionId);
581
+ if (record === undefined) {
582
+ const open = openRecordsFor(logicalId);
583
+ if (open.length > 1) {
584
+ throw new CommerceConformanceScriptError(`Purchase ${purchase.transactionId} matches ${open.length} open records for ${logicalId}.`);
585
+ }
586
+ record = open[0];
587
+ }
588
+ if (record === undefined) {
589
+ if (purchase.state === 'revoked') {
590
+ return;
591
+ }
592
+ const key = await createDerivedPurchaseIdempotencyKey({
593
+ store: purchase.store,
594
+ transactionId: purchase.transactionId,
595
+ token: purchase.token,
596
+ });
597
+ const existing = await journal.read(key);
598
+ if (existing !== undefined) {
599
+ remember(existing);
600
+ return;
601
+ }
602
+ await create({
603
+ key,
604
+ playerId,
605
+ store: purchase.store,
606
+ logicalId,
607
+ kind: catalogEntry(logicalId).kind,
608
+ origin: 'discovered',
609
+ state: purchase.state,
610
+ purchase,
611
+ });
612
+ return;
613
+ }
614
+ if (purchase.state === 'pending' && record.state === 'launched') {
615
+ await write(record, 'pending', { purchase });
616
+ return;
617
+ }
618
+ if (purchase.state === 'purchased'
619
+ && (record.state === 'launched' || (record.state === 'pending' && record.request === undefined))) {
620
+ await write(record, 'purchased', { purchase });
621
+ }
622
+ // Revoked store states never revoke on their own: only the ledger does (step 5).
623
+ };
624
+ const ingestError = async (logicalId, error) => {
625
+ const open = logicalId === undefined
626
+ ? [...records.values()].filter((record) => record.store === store && (record.state === 'launched' || record.state === 'pending'))
627
+ : openRecordsFor(logicalId);
628
+ if (open.length > 1) {
629
+ throw new CommerceConformanceScriptError(`purchaseError${logicalId === undefined ? '' : ` for ${logicalId}`} matches ${open.length} open records.`);
630
+ }
631
+ // An error for an attempt the journal never opened has nothing to transition.
632
+ const record = open[0];
633
+ if (record !== undefined) {
634
+ await write(record, error.code === 'USER_CANCELLED' ? 'cancelled' : 'failed', { error });
635
+ }
636
+ };
637
+ const buildRequest = (record) => {
638
+ const purchase = record.purchase;
639
+ if (purchase === undefined) {
640
+ throw new Error(`Journal record ${record.key} is purchased without a purchase.`);
641
+ }
642
+ return {
643
+ idempotencyKey: record.key,
644
+ playerId,
645
+ store: purchase.store,
646
+ transactionId: purchase.transactionId,
647
+ token: purchase.token,
648
+ logicalId: record.logicalId,
649
+ quantity: purchase.quantity,
650
+ evidence: purchase.evidence,
651
+ };
652
+ };
653
+ // Reconciler steps 3 and 4: drive one record as far as the backend and store allow.
654
+ const drive = async (initial, capabilities) => {
655
+ let record = initial;
656
+ let finalization;
657
+ for (;;) {
658
+ switch (record.state) {
659
+ case 'purchased':
660
+ record = await write(record, 'verifying', { request: buildRequest(record) });
661
+ continue;
662
+ case 'pending':
663
+ if (record.request === undefined) {
664
+ return;
665
+ }
666
+ record = await write(record, 'verifying');
667
+ continue;
668
+ case 'verifying': {
669
+ const request = record.request ?? buildRequest(record);
670
+ const response = await backend.verifyPurchase(request);
671
+ if (response.status === 'pending') {
672
+ await write(record, 'pending', { request });
673
+ return;
674
+ }
675
+ if (response.status === 'rejected') {
676
+ await write(record, 'rejected', { request });
677
+ return;
678
+ }
679
+ if (response.ledgerEntryId === undefined) {
680
+ throw new CommerceConformanceScriptError(`A granted verify response for ${record.key} must carry a ledgerEntryId.`);
681
+ }
682
+ finalization = response.finalization;
683
+ record = await write(record, 'granted', { request, ledgerEntryId: response.ledgerEntryId });
684
+ continue;
685
+ }
686
+ case 'granted':
687
+ if (capabilities.lifecycle.finish === 'none') {
688
+ await write(record, 'finished');
689
+ return;
690
+ }
691
+ record = await write(record, 'finishing');
692
+ continue;
693
+ case 'finishing': {
694
+ const owner = capabilities.lifecycle.finish;
695
+ if (owner === 'none') {
696
+ await write(record, 'finished');
697
+ return;
698
+ }
699
+ if (owner === 'server') {
700
+ if (finalization === undefined) {
701
+ const response = await backend.verifyPurchase(record.request ?? buildRequest(record));
702
+ finalization = response.status === 'granted' ? response.finalization : undefined;
703
+ }
704
+ if (finalization === 'completed') {
705
+ await write(record, 'finished');
706
+ }
707
+ return;
708
+ }
709
+ const ledgerEntryId = record.ledgerEntryId;
710
+ const transactionId = record.purchase?.transactionId;
711
+ if (ledgerEntryId === undefined || transactionId === undefined) {
712
+ throw new Error(`Journal record ${record.key} is finishing without a grant.`);
713
+ }
714
+ if (provider.finishTransaction === undefined) {
715
+ throw new CommerceConformanceScriptError(`The provider declares finish: '${owner}' but has no finishTransaction.`);
716
+ }
717
+ let finished = false;
718
+ try {
719
+ finished = (await provider.finishTransaction({
720
+ transactionId,
721
+ ledgerEntryId,
722
+ isConsumable: record.kind === 'consumable',
723
+ })).finished;
724
+ }
725
+ catch (error) {
726
+ if (error instanceof CommerceConformanceScriptError) {
727
+ throw error;
728
+ }
729
+ // Transport-class failure: keep `finishing` for the next pass.
730
+ finished = false;
731
+ }
732
+ if (finished) {
733
+ await write(record, 'finished');
734
+ }
735
+ return;
736
+ }
737
+ default:
738
+ return;
739
+ }
740
+ }
741
+ };
742
+ // Reconciler step 5: reversed grants move to revoked.
743
+ const revokeReversed = async () => {
744
+ const granted = (await journal.listGranted(playerId)).map(remember);
745
+ if (granted.length === 0) {
746
+ return;
747
+ }
748
+ const statuses = await backend.getLedgerStatus(granted.flatMap((record) => (record.ledgerEntryId === undefined
749
+ ? []
750
+ : [record.ledgerEntryId])));
751
+ for (const record of granted) {
752
+ const status = statuses.find((candidate) => candidate.ledgerEntryId === record.ledgerEntryId);
753
+ if (status?.status === 'reversed') {
754
+ await write(record, 'revoked', {
755
+ ...(status.reversedBy === undefined ? {} : { revokedBy: status.reversedBy }),
756
+ });
757
+ }
758
+ }
759
+ };
760
+ const pass = async () => {
761
+ const capabilities = await provider.getCapabilities();
762
+ // A provider without lifecycle.events is polled: every pass reads its available purchases.
763
+ if (!capabilities.lifecycle.events && capabilities.lifecycle.availablePurchases) {
764
+ for (const purchase of await provider.getAvailablePurchases({
765
+ includePending: capabilities.lifecycle.pending,
766
+ })) {
767
+ inbox.push({ type: 'purchaseUpdated', purchase });
768
+ }
769
+ }
770
+ for (const event of inbox.splice(0, inbox.length)) {
771
+ if (event.type === 'purchaseUpdated') {
772
+ await ingestPurchase(event.purchase);
773
+ }
774
+ else if (event.type === 'purchaseError') {
775
+ await ingestError(event.logicalId, event.error);
776
+ }
777
+ }
778
+ for (const record of (await journal.listRecoverable(playerId)).map(remember)) {
779
+ await drive(record, capabilities);
780
+ }
781
+ await revokeReversed();
782
+ };
783
+ const runUntilIdle = async () => {
784
+ if (running !== undefined) {
785
+ try {
786
+ await running;
787
+ }
788
+ catch (error) {
789
+ pendingFailure = undefined;
790
+ throw error;
791
+ }
792
+ return;
793
+ }
794
+ if (pendingFailure !== undefined) {
795
+ const failure = pendingFailure;
796
+ pendingFailure = undefined;
797
+ throw failure.error;
798
+ }
799
+ if (!started || !triggerPending) {
800
+ return;
801
+ }
802
+ const loop = (async () => {
803
+ while (started && triggerPending) {
804
+ triggerPending = false;
805
+ await pass();
806
+ }
807
+ })();
808
+ running = loop.finally(() => {
809
+ running = undefined;
810
+ });
811
+ await running;
812
+ };
813
+ function scheduleRun() {
814
+ runUntilIdle().catch((error) => {
815
+ pendingFailure = { error };
816
+ });
817
+ }
818
+ return {
819
+ get started() {
820
+ return started;
821
+ },
822
+ async start() {
823
+ if (started) {
824
+ return;
825
+ }
826
+ started = true;
827
+ await loadRecords();
828
+ // Subscribe first, then read: an event delivered before the listener is found either way.
829
+ unsubscribe = provider.subscribe(listener);
830
+ const capabilities = await provider.getCapabilities();
831
+ // An event-less provider is polled by the first pass instead, so it is read once.
832
+ if (capabilities.lifecycle.availablePurchases && capabilities.lifecycle.events) {
833
+ for (const purchase of await provider.getAvailablePurchases({
834
+ includePending: capabilities.lifecycle.pending,
835
+ })) {
836
+ inbox.push({ type: 'purchaseUpdated', purchase });
837
+ }
838
+ }
839
+ trigger();
840
+ await runUntilIdle();
841
+ },
842
+ async stop() {
843
+ started = false;
844
+ unsubscribe?.();
845
+ unsubscribe = undefined;
846
+ // Quiesce: a pass awaiting the backend or the store finishes before state is dropped.
847
+ if (running !== undefined) {
848
+ try {
849
+ await running;
850
+ }
851
+ catch (error) {
852
+ pendingFailure = { error };
853
+ }
854
+ }
855
+ triggerPending = false;
856
+ inbox.length = 0;
857
+ records.clear();
858
+ },
859
+ async reconcile() {
860
+ if (!started) {
861
+ await loadRecords();
862
+ }
863
+ await pass();
864
+ await runUntilIdle();
865
+ },
866
+ runUntilIdle,
867
+ async requestPurchase(request) {
868
+ const entry = catalogEntry(request.logicalId);
869
+ // A key is never regenerated on retry: the journaled attempt is the answer,
870
+ // and the store UI opens once per key however many calls overlap.
871
+ const journaled = (existing) => {
872
+ if ((existing.state === 'cancelled' || existing.state === 'failed')
873
+ && existing.error !== undefined) {
874
+ return { outcome: existing.state, error: existing.error };
875
+ }
876
+ return {
877
+ outcome: 'failed',
878
+ error: new CommerceError('IN_PROGRESS', {
879
+ message: `Idempotency key ${request.idempotencyKey} is already journaled.`,
880
+ }).toCommerceErrorInfo(),
881
+ };
882
+ };
883
+ const existing = await journal.read(request.idempotencyKey);
884
+ if (existing !== undefined) {
885
+ return journaled(existing);
886
+ }
887
+ const { created, record } = await create({
888
+ key: request.idempotencyKey,
889
+ playerId,
890
+ store,
891
+ logicalId: entry.logicalId,
892
+ kind: entry.kind,
893
+ origin: 'game',
894
+ state: 'requested',
895
+ });
896
+ if (!created) {
897
+ return journaled(record);
898
+ }
899
+ const failWith = async (error) => {
900
+ const info = commerceErrorInfoOf(error);
901
+ await write(record, 'failed', { error: info });
902
+ return { outcome: 'failed', error: info };
903
+ };
904
+ try {
905
+ assertRequestPurchaseSupported(await provider.getCapabilities(), request, entry.kind);
906
+ }
907
+ catch (error) {
908
+ return failWith(error);
909
+ }
910
+ if (!started) {
911
+ await loadRecords();
912
+ }
913
+ // An overlapping request for the same product that reserved its record
914
+ // earlier counts too: the earlier reservation reaches the store, the
915
+ // later one fails, so two overlapping calls never both open the UI.
916
+ let reservedEarlier = true;
917
+ const open = (await journal.listRecoverable(playerId)).filter((candidate) => {
918
+ if (candidate.key === record.key) {
919
+ reservedEarlier = false;
920
+ return false;
921
+ }
922
+ return (candidate.store === store
923
+ && candidate.logicalId === entry.logicalId
924
+ && (candidate.state === 'launched'
925
+ || candidate.state === 'pending'
926
+ || (candidate.state === 'requested' && reservedEarlier)));
927
+ });
928
+ if (open.length > 0) {
929
+ return failWith(new CommerceError('IN_PROGRESS', {
930
+ message: `A purchase of ${entry.logicalId} is already in progress (${open[0]?.key}).`,
931
+ }));
932
+ }
933
+ let result;
934
+ try {
935
+ result = await provider.requestPurchase(request);
936
+ }
937
+ catch (error) {
938
+ if (error instanceof CommerceConformanceScriptError) {
939
+ throw error;
940
+ }
941
+ return failWith(error);
942
+ }
943
+ if (result.outcome === 'launched') {
944
+ await write(record, 'launched');
945
+ }
946
+ else if (result.outcome === 'purchased') {
947
+ // A synchronous answer passes the same store and product checks as a reported purchase.
948
+ const purchase = result.purchase;
949
+ if (purchase.store !== store
950
+ || purchase.storeSku !== entry.storeSku
951
+ || resolveLogicalId(purchase) !== entry.logicalId) {
952
+ throw new CommerceConformanceScriptError(`The provider answered ${request.idempotencyKey} (${store}/${entry.logicalId}) with `
953
+ + `purchase ${purchase.transactionId} for ${purchase.store}/${purchase.storeSku}.`);
954
+ }
955
+ // Both states are written, in order, when the store answered synchronously.
956
+ const launched = await write(record, 'launched');
957
+ if (purchase.state === 'pending') {
958
+ await write(launched, 'pending', { purchase });
959
+ }
960
+ else if (purchase.state === 'purchased') {
961
+ await write(launched, 'purchased', { purchase });
962
+ }
963
+ }
964
+ else {
965
+ await write(record, result.outcome, { error: result.error });
966
+ }
967
+ trigger();
968
+ await runUntilIdle();
969
+ return result;
970
+ },
971
+ async fetchProducts(request = {}) {
972
+ return provider.fetchProducts(request);
973
+ },
974
+ };
975
+ }
976
+ /**
977
+ * Reference in-memory wallet implementing the RFC "Credit lots" and "Billing
978
+ * authority and memberships" rules: lot ids equal ledger entry ids, spend
979
+ * scope derives from the lot origin, spends consume eligible lots in the
980
+ * fixed order, are idempotent by key, and debit nothing on
981
+ * `INSUFFICIENT_BALANCE`; memberships debit `membership:<id>:<period>` per
982
+ * paid period and lapse at period end when the eligible balance runs out.
983
+ */
984
+ export function createCommerceConformanceWallet(input) {
985
+ const { clock, store } = input;
986
+ const currencies = new Set(input.currencies);
987
+ const lots = [];
988
+ const spends = new Map();
989
+ const debits = [];
990
+ const entitlements = [];
991
+ const subscriptions = new Map();
992
+ const subscriptionEvents = [];
993
+ const listeners = new Set();
994
+ const buffer = createCommerceEventBuffer();
995
+ const snapshotLot = (lot) => ({ ...lot });
996
+ const toEntitlement = (entitlement) => ({
997
+ entitlementId: entitlement.entitlementId,
998
+ titleId: entitlement.titleId,
999
+ logicalId: entitlement.logicalId,
1000
+ idempotencyKey: entitlement.idempotencyKey,
1001
+ createdAt: entitlement.createdAt,
1002
+ debits: [...entitlement.debits],
1003
+ });
1004
+ const toActiveSubscription = (subscription) => ({
1005
+ store,
1006
+ logicalId: subscription.logicalId,
1007
+ subscriptionId: subscription.subscriptionId,
1008
+ periodIndex: subscription.periodIndex,
1009
+ state: subscription.state,
1010
+ startedAt: subscription.startedAt,
1011
+ expiresAt: subscription.expiresAt,
1012
+ autoRenew: subscription.state === 'active',
1013
+ });
1014
+ const emitSubscription = (subscription, type, periodIndex, reason) => {
1015
+ const eventId = `${subscription.subscriptionId}:${periodIndex}:${type}`;
1016
+ if (subscriptionEvents.some((event) => event.eventId === eventId)) {
1017
+ return;
1018
+ }
1019
+ subscriptionEvents.push({
1020
+ eventId,
1021
+ type,
1022
+ subscriptionId: subscription.subscriptionId,
1023
+ logicalId: subscription.logicalId,
1024
+ periodIndex,
1025
+ expiresAt: subscription.expiresAt,
1026
+ ...(reason === undefined ? {} : { reason }),
1027
+ });
1028
+ const event = {
1029
+ type: 'subscriptionUpdated',
1030
+ subscription: toActiveSubscription(subscription),
1031
+ };
1032
+ if (listeners.size === 0) {
1033
+ buffer.push(event);
1034
+ return;
1035
+ }
1036
+ for (const listener of listeners) {
1037
+ listener(event);
1038
+ }
1039
+ };
1040
+ const eligibleLots = (titleId, currency, scope) => {
1041
+ const now = Date.parse(clock.now());
1042
+ return lots
1043
+ .filter((lot) => lot.currency === currency
1044
+ && lot.remaining > 0
1045
+ && (lot.expiresAt === undefined || Date.parse(lot.expiresAt) > now)
1046
+ && (lot.spendScope.kind === 'cross-title'
1047
+ || (scope === 'title' && lot.spendScope.titleId === titleId)))
1048
+ .sort(compareCreditLotSpendOrder);
1049
+ };
1050
+ const spendScoped = async (request, scope) => {
1051
+ const replay = spends.get(request.idempotencyKey);
1052
+ if (replay !== undefined) {
1053
+ return {
1054
+ status: 'debited',
1055
+ entitlementId: replay.entitlementId,
1056
+ debits: [...replay.debits],
1057
+ alreadyProcessed: true,
1058
+ };
1059
+ }
1060
+ if (!currencies.has(request.currency)) {
1061
+ return {
1062
+ status: 'rejected',
1063
+ error: {
1064
+ code: 'ITEM_UNAVAILABLE',
1065
+ retryable: false,
1066
+ message: `Currency ${request.currency} is not declared by the wallet.`,
1067
+ },
1068
+ };
1069
+ }
1070
+ if (!(request.amount > 0)) {
1071
+ return {
1072
+ status: 'rejected',
1073
+ error: {
1074
+ code: 'INVALID_RESPONSE',
1075
+ retryable: false,
1076
+ message: 'Spend amount must be positive.',
1077
+ },
1078
+ };
1079
+ }
1080
+ const candidates = eligibleLots(request.titleId, request.currency, scope);
1081
+ const available = candidates.reduce((sum, lot) => sum + lot.remaining, 0);
1082
+ if (available < request.amount) {
1083
+ return {
1084
+ status: 'rejected',
1085
+ error: {
1086
+ code: 'INSUFFICIENT_BALANCE',
1087
+ retryable: false,
1088
+ message: `Eligible ${request.currency} balance ${available} cannot cover ${request.amount}.`,
1089
+ },
1090
+ };
1091
+ }
1092
+ const spent = [];
1093
+ let remaining = request.amount;
1094
+ for (const lot of candidates) {
1095
+ if (remaining <= 0) {
1096
+ break;
1097
+ }
1098
+ const amount = Math.min(lot.remaining, remaining);
1099
+ lot.remaining -= amount;
1100
+ remaining -= amount;
1101
+ spent.push({ lotId: lot.lotId, amount });
1102
+ debits.push({ idempotencyKey: request.idempotencyKey, lotId: lot.lotId, amount });
1103
+ }
1104
+ const entitlementId = request.idempotencyKey;
1105
+ spends.set(request.idempotencyKey, { entitlementId, debits: spent });
1106
+ if (request.logicalId !== undefined) {
1107
+ entitlements.push({
1108
+ entitlementId,
1109
+ titleId: request.titleId,
1110
+ logicalId: request.logicalId,
1111
+ idempotencyKey: request.idempotencyKey,
1112
+ createdAt: clock.now(),
1113
+ debits: [...spent],
1114
+ redeemed: false,
1115
+ });
1116
+ }
1117
+ return { status: 'debited', entitlementId, debits: [...spent] };
1118
+ };
1119
+ const renew = async (subscription) => {
1120
+ while (subscription.state === 'active' && Date.parse(subscription.expiresAt) <= Date.parse(clock.now())) {
1121
+ const nextPeriod = subscription.periodIndex + 1;
1122
+ const result = await spendScoped({
1123
+ titleId: subscription.titleId,
1124
+ currency: subscription.terms.price.currency,
1125
+ amount: subscription.terms.price.amount,
1126
+ idempotencyKey: `membership:${subscription.subscriptionId}:${nextPeriod}`,
1127
+ }, subscription.terms.scope);
1128
+ if (result.status === 'rejected') {
1129
+ subscription.state = 'lapsed';
1130
+ emitSubscription(subscription, 'subscriptionLapsed', nextPeriod, result.error.code);
1131
+ return;
1132
+ }
1133
+ subscription.periodIndex = nextPeriod;
1134
+ subscription.expiresAt = addIsoDuration(subscription.expiresAt, subscription.terms.period);
1135
+ emitSubscription(subscription, 'subscriptionRenewed', nextPeriod);
1136
+ }
1137
+ };
1138
+ return {
1139
+ store,
1140
+ get lots() {
1141
+ return lots.map(snapshotLot);
1142
+ },
1143
+ get debits() {
1144
+ return [...debits];
1145
+ },
1146
+ get entitlements() {
1147
+ return entitlements.map(toEntitlement);
1148
+ },
1149
+ get subscriptionEvents() {
1150
+ return [...subscriptionEvents];
1151
+ },
1152
+ creditFromGrant(fixture, options = {}) {
1153
+ const existing = lots.find((lot) => lot.lotId === fixture.ledgerEntryId);
1154
+ if (existing !== undefined) {
1155
+ return snapshotLot(existing);
1156
+ }
1157
+ if (!currencies.has(fixture.currency)) {
1158
+ throw new CommerceConformanceScriptError(`Lot ${fixture.ledgerEntryId} uses currency ${fixture.currency}, which the wallet does not declare.`);
1159
+ }
1160
+ const lot = {
1161
+ lotId: fixture.ledgerEntryId,
1162
+ currency: fixture.currency,
1163
+ amount: fixture.amount,
1164
+ remaining: fixture.amount,
1165
+ createdAt: normalizeIsoTimestamp(fixture.createdAt),
1166
+ ...(fixture.expiresAt === undefined
1167
+ ? {}
1168
+ : { expiresAt: normalizeIsoTimestamp(fixture.expiresAt) }),
1169
+ origin: { ...fixture.origin },
1170
+ spendScope: deriveCreditLotSpendScope(fixture.origin, options.spendScope),
1171
+ };
1172
+ lots.push(lot);
1173
+ return snapshotLot(lot);
1174
+ },
1175
+ spend: (request) => spendScoped(request, 'title'),
1176
+ async startMembership(request) {
1177
+ const current = subscriptions.get(request.subscriptionId);
1178
+ if (current !== undefined && current.state === 'active') {
1179
+ return {
1180
+ status: 'started',
1181
+ subscription: toActiveSubscription(current),
1182
+ entitlementId: `membership:${request.subscriptionId}:0`,
1183
+ };
1184
+ }
1185
+ // The terms are validated before any credit moves, so malformed terms never charge.
1186
+ const startedAt = clock.now();
1187
+ let expiresAt;
1188
+ try {
1189
+ expiresAt = addIsoDuration(startedAt, request.terms.period);
1190
+ }
1191
+ catch {
1192
+ return {
1193
+ status: 'rejected',
1194
+ error: {
1195
+ code: 'ITEM_UNAVAILABLE',
1196
+ retryable: false,
1197
+ message: `Membership period ${request.terms.period} is not an ISO-8601 duration.`,
1198
+ },
1199
+ };
1200
+ }
1201
+ const result = await spendScoped({
1202
+ titleId: request.titleId,
1203
+ currency: request.terms.price.currency,
1204
+ amount: request.terms.price.amount,
1205
+ idempotencyKey: `membership:${request.subscriptionId}:0`,
1206
+ logicalId: request.logicalId,
1207
+ }, request.terms.scope);
1208
+ if (result.status === 'rejected') {
1209
+ return result;
1210
+ }
1211
+ const subscription = {
1212
+ subscriptionId: request.subscriptionId,
1213
+ titleId: request.titleId,
1214
+ logicalId: request.logicalId,
1215
+ terms: request.terms,
1216
+ periodIndex: 0,
1217
+ startedAt,
1218
+ expiresAt,
1219
+ state: 'active',
1220
+ };
1221
+ subscriptions.set(subscription.subscriptionId, subscription);
1222
+ emitSubscription(subscription, 'subscriptionStarted', 0);
1223
+ return {
1224
+ status: 'started',
1225
+ subscription: toActiveSubscription(subscription),
1226
+ entitlementId: result.entitlementId,
1227
+ };
1228
+ },
1229
+ async renewDue() {
1230
+ for (const subscription of subscriptions.values()) {
1231
+ await renew(subscription);
1232
+ }
1233
+ },
1234
+ async listOffers() {
1235
+ return [...(input.offers ?? [])];
1236
+ },
1237
+ async listUnredeemed(request) {
1238
+ return entitlements
1239
+ .filter((entitlement) => !entitlement.redeemed && entitlement.titleId === request.titleId)
1240
+ .map(toEntitlement);
1241
+ },
1242
+ async redeem(request) {
1243
+ const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.entitlementId);
1244
+ if (entitlement === undefined) {
1245
+ return { redeemed: false };
1246
+ }
1247
+ entitlement.redeemed = true;
1248
+ return { redeemed: true };
1249
+ },
1250
+ finalize(request) {
1251
+ const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.transactionId);
1252
+ if (entitlement !== undefined) {
1253
+ entitlement.redeemed = true;
1254
+ }
1255
+ },
1256
+ async getBalance(request) {
1257
+ const balances = {};
1258
+ for (const currency of currencies) {
1259
+ balances[currency] = eligibleLots(request.titleId, currency, 'title').reduce((sum, lot) => sum + lot.remaining, 0);
1260
+ }
1261
+ return { titleId: request.titleId, balances };
1262
+ },
1263
+ async getActiveSubscriptions() {
1264
+ return [...subscriptions.values()]
1265
+ .filter((subscription) => subscription.state === 'active')
1266
+ .map(toActiveSubscription);
1267
+ },
1268
+ subscribe(listener) {
1269
+ const first = listeners.size === 0;
1270
+ listeners.add(listener);
1271
+ if (first) {
1272
+ buffer.drain(listener);
1273
+ }
1274
+ return () => {
1275
+ listeners.delete(listener);
1276
+ };
1277
+ },
1278
+ };
1279
+ }
1280
+ /** Store lots are title-scoped, off-store lots cross-title, promotion lots as issued (title by default). */
1281
+ export function deriveCreditLotSpendScope(origin, explicit) {
1282
+ switch (origin.channel) {
1283
+ case 'store':
1284
+ return { kind: 'title', titleId: origin.titleId };
1285
+ case 'off-store':
1286
+ return { kind: 'cross-title' };
1287
+ case 'promotion':
1288
+ return explicit ?? { kind: 'title', titleId: origin.titleId };
1289
+ }
1290
+ }
1291
+ /** RFC spend order: earliest `expiresAt` (none last), `title` before `cross-title`, oldest `createdAt`, then `lotId`. */
1292
+ export function compareCreditLotSpendOrder(left, right) {
1293
+ const leftExpiry = left.expiresAt === undefined
1294
+ ? Number.POSITIVE_INFINITY
1295
+ : Date.parse(left.expiresAt);
1296
+ const rightExpiry = right.expiresAt === undefined
1297
+ ? Number.POSITIVE_INFINITY
1298
+ : Date.parse(right.expiresAt);
1299
+ if (leftExpiry !== rightExpiry) {
1300
+ return leftExpiry < rightExpiry ? -1 : 1;
1301
+ }
1302
+ if (left.spendScope.kind !== right.spendScope.kind) {
1303
+ return left.spendScope.kind === 'title' ? -1 : 1;
1304
+ }
1305
+ const createdDelta = Date.parse(left.createdAt) - Date.parse(right.createdAt);
1306
+ if (createdDelta !== 0) {
1307
+ return createdDelta < 0 ? -1 : 1;
1308
+ }
1309
+ return left.lotId < right.lotId ? -1 : left.lotId > right.lotId ? 1 : 0;
1310
+ }
1311
+ /**
1312
+ * `CommerceProvider` over the reference wallet: `fetchProducts` lists the
1313
+ * wallet's offers (catalog-derived offers only when the wallet lists none),
1314
+ * `requestPurchase` spends the offer price times the requested quantity
1315
+ * (outcome `purchased`, `settlement` set, evidence adapter-settled),
1316
+ * `getAvailablePurchases` lists unredeemed entitlements, and finishing is the
1317
+ * backend's `redeem`. Memberships exist only where the billing authority is
1318
+ * `host`.
1319
+ */
1320
+ export function createCommerceConformanceWalletProvider(input) {
1321
+ const { wallet, catalog, titleId, clock } = input;
1322
+ const store = input.profile.store;
1323
+ const membershipsOffered = input.billingAuthority === 'host';
1324
+ const defaultCurrency = input.currencies[0] ?? 'credits';
1325
+ const capabilities = () => {
1326
+ const base = createCommerceConformanceCapabilities(input.profile);
1327
+ return {
1328
+ ...base,
1329
+ purchase: { ...base.purchase, subscription: base.purchase.subscription && membershipsOffered },
1330
+ };
1331
+ };
1332
+ const toOffer = (entry) => ({
1333
+ logicalId: entry.logicalId,
1334
+ storeSku: entry.storeSku,
1335
+ kind: entry.kind,
1336
+ title: entry.logicalId,
1337
+ description: `Wallet offer ${entry.storeSku}`,
1338
+ // Fixture price for catalog entries without terms: one unit of the first wallet currency.
1339
+ price: entry.membership?.price ?? { currency: defaultCurrency, amount: 1 },
1340
+ ...(entry.membership === undefined ? {} : { membership: entry.membership }),
1341
+ });
1342
+ /** The wallet's own offers decide availability and price; the catalog fills in only when it lists none. */
1343
+ const listOffers = async () => {
1344
+ const offers = await wallet.listOffers({ titleId });
1345
+ return (offers.length === 0 ? catalog.map(toOffer) : offers).filter((offer) => offer.membership === undefined || membershipsOffered);
1346
+ };
1347
+ /** Quantities of purchases made through this provider, so re-listed entitlements keep them. */
1348
+ const quantities = new Map();
1349
+ const toPurchase = (offer, entitlementId, createdAt, quantity) => ({
1350
+ store,
1351
+ transactionId: entitlementId,
1352
+ storeSku: offer.storeSku,
1353
+ logicalId: offer.logicalId,
1354
+ quantity,
1355
+ state: 'purchased',
1356
+ purchasedAt: createdAt,
1357
+ token: entitlementId,
1358
+ evidence: { schema: `fixture.${store}.entitlement.v1`, payload: { entitlementId } },
1359
+ accountBinding: { kind: 'host-identity' },
1360
+ settlement: { ledgerEntryId: entitlementId },
1361
+ });
1362
+ const unavailable = (message) => ({
1363
+ outcome: 'failed',
1364
+ error: new CommerceError('ITEM_UNAVAILABLE', { message }).toCommerceErrorInfo(),
1365
+ });
1366
+ return {
1367
+ store,
1368
+ async getCapabilities() {
1369
+ return capabilities();
1370
+ },
1371
+ async fetchProducts(request) {
1372
+ return (await listOffers())
1373
+ .filter((offer) => matchesFetchProducts(offer, request))
1374
+ .map((offer) => ({
1375
+ logicalId: offer.logicalId,
1376
+ store,
1377
+ storeSku: offer.storeSku,
1378
+ kind: offer.kind,
1379
+ title: offer.title,
1380
+ description: offer.description,
1381
+ price: {
1382
+ formatted: `${offer.price.amount} ${offer.price.currency}`,
1383
+ currencyCode: offer.price.currency,
1384
+ },
1385
+ }));
1386
+ },
1387
+ async requestPurchase(request) {
1388
+ const offer = (await listOffers()).find((candidate) => candidate.logicalId === request.logicalId);
1389
+ if (offer === undefined) {
1390
+ return unavailable(`The wallet offers no product ${request.logicalId}.`);
1391
+ }
1392
+ const quantity = request.quantity ?? 1;
1393
+ if (quantity > 1 && !capabilities().purchase.quantity) {
1394
+ return unavailable('The wallet does not support purchase quantities above 1.');
1395
+ }
1396
+ if (offer.membership !== undefined) {
1397
+ if (quantity > 1) {
1398
+ return unavailable('A membership is purchased one period at a time.');
1399
+ }
1400
+ const started = await wallet.startMembership({
1401
+ subscriptionId: request.idempotencyKey,
1402
+ titleId,
1403
+ logicalId: offer.logicalId,
1404
+ terms: offer.membership,
1405
+ });
1406
+ if (started.status === 'rejected') {
1407
+ return { outcome: 'failed', error: started.error };
1408
+ }
1409
+ quantities.set(started.entitlementId, 1);
1410
+ return {
1411
+ outcome: 'purchased',
1412
+ purchase: toPurchase(offer, started.entitlementId, started.subscription.startedAt, 1),
1413
+ };
1414
+ }
1415
+ const spend = await wallet.spend({
1416
+ titleId,
1417
+ currency: offer.price.currency,
1418
+ amount: offer.price.amount * quantity,
1419
+ idempotencyKey: request.idempotencyKey,
1420
+ logicalId: offer.logicalId,
1421
+ });
1422
+ if (spend.status === 'rejected') {
1423
+ return { outcome: 'failed', error: spend.error };
1424
+ }
1425
+ quantities.set(spend.entitlementId, quantity);
1426
+ return {
1427
+ outcome: 'purchased',
1428
+ purchase: toPurchase(offer, spend.entitlementId, clock.now(), quantity),
1429
+ };
1430
+ },
1431
+ async getAvailablePurchases() {
1432
+ const offers = await listOffers();
1433
+ const unredeemed = await wallet.listUnredeemed({ titleId });
1434
+ return unredeemed.flatMap((entitlement) => {
1435
+ const offer = offers.find((candidate) => candidate.logicalId === entitlement.logicalId);
1436
+ return offer === undefined
1437
+ ? []
1438
+ : [toPurchase(offer, entitlement.entitlementId, entitlement.createdAt, quantities.get(entitlement.entitlementId) ?? 1)];
1439
+ });
1440
+ },
1441
+ async getActiveSubscriptions() {
1442
+ return wallet.getActiveSubscriptions();
1443
+ },
1444
+ subscribe(listener) {
1445
+ return wallet.subscribe(listener);
1446
+ },
1447
+ };
1448
+ }
1449
+ export function createCommerceConformanceHarness(context) {
1450
+ if (context.vector.wallet === undefined) {
1451
+ const store = createCommerceConformanceStore(context);
1452
+ return { provider: store.provider, store };
1453
+ }
1454
+ const wallet = createCommerceConformanceWallet({
1455
+ store: context.profile.store,
1456
+ clock: context.clock,
1457
+ currencies: context.vector.wallet.currencies,
1458
+ });
1459
+ const provider = createCommerceConformanceWalletProvider({
1460
+ wallet,
1461
+ profile: context.profile,
1462
+ catalog: context.catalog,
1463
+ currencies: context.vector.wallet.currencies,
1464
+ billingAuthority: context.vector.wallet.billingAuthority,
1465
+ titleId: context.titleId,
1466
+ clock: context.clock,
1467
+ });
1468
+ return { provider, wallet };
1469
+ }
1470
+ /**
1471
+ * Runs one vector: executes every step, lets the runtime run until idle after
1472
+ * each, then matches `expect`. Matching compares only the listed fields; arrays
1473
+ * compare in order and by length; a listed `journal` is the whole journal.
1474
+ * Any unscripted operation fails the vector at the step that needed it.
1475
+ *
1476
+ * With `options.createProvider`, a vector flagged `profile.adaptive` is first
1477
+ * checked against the provider's declared capabilities
1478
+ * (`commerceConformanceAdaptiveSkipReason`) and then matched against
1479
+ * expectations derived from a reference run under those capabilities
1480
+ * (`deriveAdaptiveCommerceConformanceExpectation`). Duplicate detection needs
1481
+ * the whole run set, so it applies through `runCommerceConformanceVectors`.
1482
+ */
1483
+ export async function runCommerceConformanceVector(vector, options = {}) {
1484
+ return runVectorIn(vector, options, purchaseFinishFamilyOf([vector]));
1485
+ }
1486
+ export async function runCommerceConformanceVectors(vectors, options = {}) {
1487
+ // The `purchase-<owner>-finish` family of this run is fixed up front; the
1488
+ // canonical member per owner is then one lookup per vector.
1489
+ const family = purchaseFinishFamilyOf(vectors);
1490
+ const results = [];
1491
+ for (const vector of vectors) {
1492
+ results.push(await runVectorIn(vector, options, family));
1493
+ }
1494
+ return results;
1495
+ }
1496
+ async function runVectorIn(vector, options, family) {
1497
+ const ids = {
1498
+ playerId: options.playerId ?? commerceConformanceDefaultPlayerId,
1499
+ titleId: options.titleId ?? commerceConformanceDefaultTitleId,
1500
+ };
1501
+ const failed = (failures) => ({ vector: vector.vector, status: 'failed', passed: false, failures });
1502
+ const skipped = (reason) => ({ vector: vector.vector, status: 'skipped', passed: false, failures: [], reason });
1503
+ // A harness that cannot be built is a failed run, never a rejected promise.
1504
+ let execution;
1505
+ try {
1506
+ execution = await prepareExecution(vector, options.createProvider ?? createCommerceConformanceHarness, ids);
1507
+ }
1508
+ catch (error) {
1509
+ return failed([
1510
+ { path: 'harness', expected: 'createProvider resolves', actual: describeError(error) },
1511
+ ]);
1512
+ }
1513
+ let expectation = vector.expect;
1514
+ let adaptive;
1515
+ let adaptation;
1516
+ if (options.createProvider !== undefined && vector.profile.adaptive === true) {
1517
+ let capabilities;
1518
+ try {
1519
+ capabilities = await execution.harness.provider.getCapabilities();
1520
+ }
1521
+ catch (error) {
1522
+ return failed([
1523
+ {
1524
+ path: 'capabilities',
1525
+ expected: 'provider.getCapabilities() resolves',
1526
+ actual: describeError(error),
1527
+ },
1528
+ ]);
1529
+ }
1530
+ const reason = adaptiveSkipReasonWith(vector, capabilities, family);
1531
+ if (reason !== undefined) {
1532
+ return skipped(reason);
1533
+ }
1534
+ const store = execution.harness.provider.store;
1535
+ adaptation = adaptationFor(vector, capabilities);
1536
+ // The reference run reports the tokens the provider under test reports.
1537
+ const tokenOf = execution.harness.store?.tokenOf;
1538
+ const tokenAdaptedVector = tokenOf === undefined
1539
+ ? vector
1540
+ : withStoreTokens(vector, (purchase) => tokenOf.call(execution.harness.store, purchase));
1541
+ let reference;
1542
+ try {
1543
+ reference = await prepareExecution(referenceVectorFor(tokenAdaptedVector, store, capabilities), createCommerceConformanceHarness, ids);
1544
+ }
1545
+ catch (error) {
1546
+ return failed([
1547
+ {
1548
+ path: 'reference.harness',
1549
+ expected: 'reference harness builds',
1550
+ actual: describeError(error),
1551
+ },
1552
+ ]);
1553
+ }
1554
+ const referenceFailures = await executeSteps(reference, adaptation);
1555
+ const referenceFailure = referenceFailures[0];
1556
+ if (referenceFailure !== undefined) {
1557
+ // The reference pieces cannot play the scenario under these capabilities,
1558
+ // which is a property of the vector and the capability set, not of the
1559
+ // provider under test; the step and its error are kept in the reason.
1560
+ return skipped(`the reference run under the provider capabilities failed at ${referenceFailure.path}: `
1561
+ + `expected ${String(referenceFailure.expected)}, got ${String(referenceFailure.actual)}`);
1562
+ }
1563
+ const derived = await deriveAdaptiveExpectation(tokenAdaptedVector, reference, store);
1564
+ if (derived.reason !== undefined) {
1565
+ return skipped(derived.reason);
1566
+ }
1567
+ expectation = derived.expect;
1568
+ adaptive = {
1569
+ store,
1570
+ finish: capabilities.lifecycle.finish,
1571
+ evidence: capabilities.authority.evidence,
1572
+ expect: expectation,
1573
+ };
1574
+ }
1575
+ const failures = await executeSteps(execution, adaptation);
1576
+ if (failures.length > 0) {
1577
+ return { ...failed(failures), ...(adaptive === undefined ? {} : { adaptive }) };
1578
+ }
1579
+ try {
1580
+ await matchExpectations(expectation, { ...execution, failures });
1581
+ }
1582
+ catch (error) {
1583
+ // A provider read that throws while expectations are evaluated is a failed
1584
+ // run, never a rejected promise.
1585
+ failures.push({
1586
+ path: 'expect',
1587
+ expected: 'expectations evaluate',
1588
+ actual: describeError(error),
1589
+ });
1590
+ }
1591
+ return {
1592
+ vector: vector.vector,
1593
+ status: failures.length === 0 ? 'passed' : 'failed',
1594
+ passed: failures.length === 0,
1595
+ failures,
1596
+ ...(adaptive === undefined ? {} : { adaptive }),
1597
+ };
1598
+ }
1599
+ async function prepareExecution(vector, createHarness, ids) {
1600
+ const clock = createCommerceConformanceClock();
1601
+ const journal = createCommerceConformanceJournal();
1602
+ const harnessRef = {};
1603
+ const adaptation = { current: undefined };
1604
+ const backend = createCommerceConformanceBackend({
1605
+ finalize: async (request) => {
1606
+ await harnessRef.current?.store?.finalize?.(request);
1607
+ await harnessRef.current?.wallet?.finalize?.(request);
1608
+ },
1609
+ finalizeOnReread: () => adaptation.current?.serverFinalization === true,
1610
+ });
1611
+ const context = {
1612
+ vector,
1613
+ profile: vector.profile,
1614
+ catalog: vector.catalog ?? [],
1615
+ playerId: ids.playerId,
1616
+ titleId: ids.titleId,
1617
+ clock,
1618
+ backend,
1619
+ journal,
1620
+ };
1621
+ const harness = await createHarness(context);
1622
+ harnessRef.current = harness;
1623
+ const runtime = createCommerceConformanceRuntime({
1624
+ provider: harness.provider,
1625
+ backend,
1626
+ journal,
1627
+ clock,
1628
+ catalog: context.catalog,
1629
+ playerId: ids.playerId,
1630
+ });
1631
+ return { vector, harness, runtime, backend, journal, clock, adaptation };
1632
+ }
1633
+ /** Runs every step; a thrown step ends the run with a failure at that step. */
1634
+ async function executeSteps(execution, adaptation) {
1635
+ const failures = [];
1636
+ execution.adaptation.current = adaptation;
1637
+ const context = { ...execution, failures };
1638
+ for (const [index, step] of execution.vector.steps.entries()) {
1639
+ const path = `steps[${index}]`;
1640
+ try {
1641
+ await runStep(step, path, context);
1642
+ await execution.runtime.runUntilIdle();
1643
+ }
1644
+ catch (error) {
1645
+ failures.push({
1646
+ path,
1647
+ expected: `${step.actor}.${step.action} completes`,
1648
+ actual: describeError(error),
1649
+ });
1650
+ break;
1651
+ }
1652
+ }
1653
+ return failures;
1654
+ }
1655
+ function describeError(error) {
1656
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1657
+ }
1658
+ // Adaptive vectors (RFC "Conformance vectors", adaptive vectors).
1659
+ /**
1660
+ * Members of the `purchase-<owner>-finish` family describe the same purchase
1661
+ * under different fixture finish owners; a provider runs the one named after
1662
+ * its own owner, or the first by name when none is (`finish: 'none'`).
1663
+ */
1664
+ const purchaseFinishFamily = /^purchase-(?:client|server|host|none)-finish$/u;
1665
+ export function isAdaptiveCommerceConformanceVector(vector) {
1666
+ return vector.profile.adaptive === true;
1667
+ }
1668
+ /**
1669
+ * Why an adaptive vector does not apply to a provider with `capabilities`, or
1670
+ * `undefined` when it runs. `set` is the whole run (for duplicate detection).
1671
+ * The rules:
1672
+ *
1673
+ * - a `game.requestPurchase` of a product kind the provider does not sell
1674
+ * (`purchase.<kind>` false, by the vector catalog), or with a quantity above
1675
+ * one without `purchase.quantity`, cannot open the store UI;
1676
+ * - a store-side `pending` purchase needs `lifecycle.pending`;
1677
+ * - a store `error` event needs `lifecycle.events`;
1678
+ * - without `lifecycle.events`, a store `complete` or `update` reaches the
1679
+ * reconciler only by polling, so the next `runtime` step after it must be
1680
+ * `reconcile` or `start`;
1681
+ * - a `queueFinish` script needs a `client` or `host` finish owner, since the
1682
+ * provider never calls `finishTransaction` under `server` or `none`;
1683
+ * - within the `purchase-<owner>-finish` family only one member runs: the
1684
+ * one named after the provider's owner, else the first by name.
1685
+ */
1686
+ export function commerceConformanceAdaptiveSkipReason(vector, capabilities, set = [vector]) {
1687
+ return adaptiveSkipReasonWith(vector, capabilities, purchaseFinishFamilyOf(set));
1688
+ }
1689
+ function adaptiveSkipReasonWith(vector, capabilities, family) {
1690
+ const stepReason = stepSkipReason(vector, capabilities);
1691
+ if (stepReason !== undefined) {
1692
+ return stepReason;
1693
+ }
1694
+ if (purchaseFinishFamily.test(vector.vector)) {
1695
+ const owner = capabilities.lifecycle.finish;
1696
+ // The canonical member must itself be runnable here, so a provider whose
1697
+ // capabilities rule out the member named after its owner still runs one.
1698
+ const runnable = family.filter((member) => stepSkipReason(member, capabilities) === undefined);
1699
+ const named = runnable.find((member) => member.vector === `purchase-${owner}-finish`);
1700
+ const canonical = (named ?? runnable[0])?.vector;
1701
+ if (canonical !== undefined && canonical !== vector.vector) {
1702
+ return `duplicate of ${canonical} for finish='${owner}'`;
1703
+ }
1704
+ }
1705
+ return undefined;
1706
+ }
1707
+ /** The step-level rules of `commerceConformanceAdaptiveSkipReason`, without the family rule. */
1708
+ function stepSkipReason(vector, capabilities) {
1709
+ const { lifecycle, purchase } = capabilities;
1710
+ const owner = lifecycle.finish;
1711
+ const catalog = vector.catalog ?? [];
1712
+ const steps = vector.steps;
1713
+ for (const [index, step] of steps.entries()) {
1714
+ if (step.actor === 'game' && step.action === 'requestPurchase') {
1715
+ const kind = catalog.find((entry) => entry.logicalId === step.input.logicalId)?.kind;
1716
+ if (kind !== undefined && !purchase[kind]) {
1717
+ return `requestPurchase of ${step.input.logicalId} needs purchase.${kind}; `
1718
+ + `the provider declares ${kind}: false`;
1719
+ }
1720
+ const quantity = step.input.quantity ?? 1;
1721
+ if (quantity > 1 && !purchase.quantity) {
1722
+ return `requestPurchase of ${step.input.logicalId} with quantity ${String(quantity)} needs `
1723
+ + 'purchase.quantity; the provider declares quantity: false';
1724
+ }
1725
+ continue;
1726
+ }
1727
+ if (step.actor !== 'store') {
1728
+ continue;
1729
+ }
1730
+ if (!lifecycle.pending
1731
+ && ((step.action === 'complete' || step.action === 'update') && step.purchase.state === 'pending'
1732
+ || (step.action === 'queueRequest'
1733
+ && step.result.outcome === 'purchased'
1734
+ && step.result.purchase.state === 'pending'))) {
1735
+ return 'store-side pending purchase needs lifecycle.pending; the provider declares pending: false';
1736
+ }
1737
+ if (step.action === 'error' && !lifecycle.events) {
1738
+ return 'purchaseError event needs lifecycle.events; the provider declares events: false';
1739
+ }
1740
+ if ((step.action === 'complete' || step.action === 'update') && !lifecycle.events) {
1741
+ // Polling ingests the purchase at the next reconcile or start; a step in
1742
+ // between that would have observed the event-driven grant or finish
1743
+ // (another store script, a ledger write) makes the scenario timing-bound.
1744
+ const prefix = `store.${step.action} at steps[${String(index)}] needs lifecycle.events; `
1745
+ + 'the provider declares events: false';
1746
+ if (!lifecycle.availablePurchases) {
1747
+ return `${prefix} and lifecycle.availablePurchases: false, so nothing can poll the purchase`;
1748
+ }
1749
+ let polled = false;
1750
+ for (const [offset, later] of steps.slice(index + 1).entries()) {
1751
+ if (later.actor === 'runtime') {
1752
+ if (later.action === 'stop') {
1753
+ break;
1754
+ }
1755
+ polled = true;
1756
+ break;
1757
+ }
1758
+ if (later.actor === 'clock' || (later.actor === 'backend' && later.action === 'queueVerify')) {
1759
+ continue;
1760
+ }
1761
+ return `${prefix} and ${later.actor}.${later.action} at steps[${String(index + 1 + offset)}] `
1762
+ + 'runs before the reconcile or start that would poll the purchase';
1763
+ }
1764
+ if (!polled) {
1765
+ return `${prefix} and no reconcile or start follows it to poll the purchase`;
1766
+ }
1767
+ }
1768
+ if (step.action === 'queueFinish' && owner !== 'client' && owner !== 'host') {
1769
+ return `queueFinish scripts a client finish; the provider declares finish: '${owner}'`;
1770
+ }
1771
+ }
1772
+ return undefined;
1773
+ }
1774
+ /** The adaptive `purchase-<owner>-finish` members of a run, sorted by name. */
1775
+ function purchaseFinishFamilyOf(set) {
1776
+ return set
1777
+ .filter((candidate) => candidate.profile.adaptive === true && purchaseFinishFamily.test(candidate.vector))
1778
+ .sort((left, right) => (left.vector < right.vector ? -1 : left.vector > right.vector ? 1 : 0));
1779
+ }
1780
+ function adaptationFor(vector, capabilities) {
1781
+ const fixtureOwner = createCommerceConformanceCapabilities(vector.profile).lifecycle.finish;
1782
+ return {
1783
+ serverFinalization: capabilities.lifecycle.finish === 'server' && fixtureOwner !== 'server',
1784
+ };
1785
+ }
1786
+ /**
1787
+ * The vector with every scripted store purchase (`complete`, `update`, and a
1788
+ * `queueRequest` answering `purchased`) carrying the token the store actor
1789
+ * reports for it (`CommerceConformanceStoreActor.tokenOf`).
1790
+ */
1791
+ export function withStoreTokens(vector, tokenOf) {
1792
+ const withToken = (purchase) => ({ ...purchase, token: tokenOf(purchase) });
1793
+ return {
1794
+ ...vector,
1795
+ steps: vector.steps.map((step) => {
1796
+ if (step.actor !== 'store') {
1797
+ return step;
1798
+ }
1799
+ if (step.action === 'complete' || step.action === 'update') {
1800
+ return { ...step, purchase: withToken(step.purchase) };
1801
+ }
1802
+ if (step.action === 'queueRequest' && step.result.outcome === 'purchased') {
1803
+ return { ...step, result: { ...step.result, purchase: withToken(step.result.purchase) } };
1804
+ }
1805
+ return step;
1806
+ }),
1807
+ };
1808
+ }
1809
+ /** The vector with the provider's store id and declared capabilities as its profile. */
1810
+ function referenceVectorFor(vector, store, capabilities) {
1811
+ return {
1812
+ ...vector,
1813
+ profile: {
1814
+ store,
1815
+ purchase: capabilities.purchase,
1816
+ lifecycle: capabilities.lifecycle,
1817
+ authority: capabilities.authority,
1818
+ adaptive: true,
1819
+ },
1820
+ };
1821
+ }
1822
+ /**
1823
+ * Derives the adaptive expectation for `vector` from the observable state of a
1824
+ * finished reference run under the provider's capabilities, applying the
1825
+ * rules the RFC table states:
1826
+ *
1827
+ * - `journal`: the reference journal, so the tail after `granted` is
1828
+ * `finishing, finished` for `client`, `host`, and `server` owners and
1829
+ * `finished` for `none`; derived keys carry the provider's store id;
1830
+ * - `finishCalls`: the reference store's calls, `by` the owner for `client`
1831
+ * and `host`, none for `server` and `none`;
1832
+ * - `verifyRequests`: the reference backend's log, with the finalization
1833
+ * re-read a `server` owner adds unless the grant already reported
1834
+ * `finalization: 'completed'`;
1835
+ * - `available`: what the reference store still lists once idle, so empty
1836
+ * after a completed finish for `client`, `host`, and `server` owners.
1837
+ *
1838
+ * Fields the fixture does not assert stay unasserted. The derivation is
1839
+ * refused (a skip reason) when the reference run does not end every journal
1840
+ * key in the fixture's terminal state, since the scenario then depends on a
1841
+ * capability the provider lacks.
1842
+ */
1843
+ export async function deriveAdaptiveCommerceConformanceExpectation(vector, reference) {
1844
+ const { expect } = vector;
1845
+ if (expect.journal !== undefined) {
1846
+ const adapted = new Map();
1847
+ for (const fixtureKey of Object.keys(expect.journal)) {
1848
+ adapted.set(fixtureKey, await adaptDerivedPurchaseKey(vector, fixtureKey, reference.store));
1849
+ }
1850
+ for (const [fixtureKey, states] of Object.entries(expect.journal)) {
1851
+ const key = adapted.get(fixtureKey) ?? fixtureKey;
1852
+ const terminal = states.at(-1);
1853
+ const actual = reference.journal[key]?.at(-1);
1854
+ if (actual !== terminal) {
1855
+ return {
1856
+ reason: `the reference run under the provider capabilities ends ${key} in `
1857
+ + `'${String(actual)}', not '${String(terminal)}'`,
1858
+ };
1859
+ }
1860
+ }
1861
+ const expectedKeys = new Set(adapted.values());
1862
+ const extra = Object.keys(reference.journal).filter((key) => !expectedKeys.has(key));
1863
+ if (extra.length > 0) {
1864
+ return {
1865
+ reason: `the reference run under the provider capabilities journals ${extra.join(', ')}, `
1866
+ + 'which the vector does not expect',
1867
+ };
1868
+ }
1869
+ }
1870
+ return {
1871
+ expect: {
1872
+ ...expect,
1873
+ ...(expect.journal === undefined ? {} : { journal: reference.journal }),
1874
+ ...(expect.verifyRequests === undefined
1875
+ ? {}
1876
+ : {
1877
+ verifyRequests: reference.verifyRequests.map((request) => ({
1878
+ idempotencyKey: request.idempotencyKey,
1879
+ store: request.store,
1880
+ transactionId: request.transactionId,
1881
+ token: request.token,
1882
+ })),
1883
+ }),
1884
+ ...(expect.finishCalls === undefined ? {} : { finishCalls: reference.finishCalls }),
1885
+ ...(expect.available === undefined
1886
+ ? {}
1887
+ : {
1888
+ available: reference.available.map((purchase) => ({
1889
+ transactionId: purchase.transactionId,
1890
+ storeSku: purchase.storeSku,
1891
+ state: purchase.state,
1892
+ })),
1893
+ }),
1894
+ },
1895
+ };
1896
+ }
1897
+ /**
1898
+ * A fixture journal key as the provider's store id derives it: only the
1899
+ * encoded store segment of a `store:<store>:<transactionId>` key
1900
+ * (`createDerivedPurchaseIdempotencyKey`) is replaced, so a transaction id
1901
+ * that happens to contain the fixture store id is left alone. Game-chosen
1902
+ * keys and store-specific key forms carry no such prefix and pass through.
1903
+ */
1904
+ export function adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore) {
1905
+ const fixturePrefix = `store:${encodeURIComponent(fixtureStore)}:`;
1906
+ return key.startsWith(fixturePrefix)
1907
+ ? `store:${encodeURIComponent(providerStore)}:${key.slice(fixturePrefix.length)}`
1908
+ : key;
1909
+ }
1910
+ /**
1911
+ * A fixture journal key as the provider's store id derives it. When the key is
1912
+ * the derived key of a purchase the vector scripts under the fixture store, it
1913
+ * is re-derived with `createDerivedPurchaseIdempotencyKey` for the provider
1914
+ * store, which also covers the store-specific key forms (`google-play` token
1915
+ * keys, `apps-in-toss:purchase:<id>`); otherwise only the generic
1916
+ * `store:<store>:` prefix is swapped (`adaptDerivedPurchaseKeyStore`).
1917
+ */
1918
+ export async function adaptDerivedPurchaseKey(vector, key, providerStore) {
1919
+ const fixtureStore = vector.profile.store;
1920
+ for (const purchase of scriptedPurchasesOf(vector)) {
1921
+ const derive = (store) => createDerivedPurchaseIdempotencyKey({
1922
+ store,
1923
+ transactionId: purchase.transactionId,
1924
+ token: purchase.token,
1925
+ });
1926
+ if (await derive(fixtureStore) === key) {
1927
+ return derive(providerStore);
1928
+ }
1929
+ }
1930
+ return adaptDerivedPurchaseKeyStore(key, fixtureStore, providerStore);
1931
+ }
1932
+ function scriptedPurchasesOf(vector) {
1933
+ return vector.steps.flatMap((step) => {
1934
+ if (step.actor !== 'store') {
1935
+ return [];
1936
+ }
1937
+ if (step.action === 'complete' || step.action === 'update') {
1938
+ return [step.purchase];
1939
+ }
1940
+ return step.action === 'queueRequest' && step.result.outcome === 'purchased'
1941
+ ? [step.result.purchase]
1942
+ : [];
1943
+ });
1944
+ }
1945
+ async function deriveAdaptiveExpectation(vector, reference, store) {
1946
+ return deriveAdaptiveCommerceConformanceExpectation(vector, {
1947
+ store,
1948
+ journal: reference.journal.snapshot(),
1949
+ verifyRequests: reference.backend.verifyRequests,
1950
+ finishCalls: reference.harness.store?.finishCalls ?? [],
1951
+ available: await reference.harness.provider.getAvailablePurchases({ includePending: true }),
1952
+ });
1953
+ }
1954
+ function storeActorOf(context, step) {
1955
+ if (context.harness.store === undefined) {
1956
+ throw new CommerceConformanceScriptError(`The harness has no store actor for ${step.actor}.${step.action}.`);
1957
+ }
1958
+ return context.harness.store;
1959
+ }
1960
+ function walletActorOf(context, step) {
1961
+ if (context.harness.wallet === undefined) {
1962
+ throw new CommerceConformanceScriptError(`The harness has no wallet actor for ${step.actor}.${step.action}.`);
1963
+ }
1964
+ return context.harness.wallet;
1965
+ }
1966
+ async function runStep(step, path, context) {
1967
+ switch (step.actor) {
1968
+ case 'runtime':
1969
+ if (step.action === 'start') {
1970
+ await context.runtime.start();
1971
+ }
1972
+ else if (step.action === 'stop') {
1973
+ await context.runtime.stop();
1974
+ }
1975
+ else {
1976
+ await context.runtime.reconcile();
1977
+ }
1978
+ return;
1979
+ case 'game':
1980
+ if (step.action === 'requestPurchase') {
1981
+ const result = await context.runtime.requestPurchase({
1982
+ logicalId: step.input.logicalId,
1983
+ idempotencyKey: step.input.idempotencyKey,
1984
+ source: step.input.source ?? 'shop',
1985
+ ...(step.input.offerId === undefined ? {} : { offerId: step.input.offerId }),
1986
+ ...(step.input.quantity === undefined ? {} : { quantity: step.input.quantity }),
1987
+ });
1988
+ if (step.returns !== undefined) {
1989
+ matchPartial(step.returns, result, `${path}.returns`, context.failures);
1990
+ }
1991
+ return;
1992
+ }
1993
+ if (step.action === 'fetchProducts') {
1994
+ const products = await context.runtime.fetchProducts(step.input ?? {});
1995
+ if (step.returns !== undefined) {
1996
+ matchPartial(step.returns, { logicalIds: products.map((product) => product.logicalId) }, `${path}.returns`, context.failures);
1997
+ }
1998
+ }
1999
+ return;
2000
+ case 'store': {
2001
+ const store = storeActorOf(context, step);
2002
+ switch (step.action) {
2003
+ case 'complete':
2004
+ store.complete(step.purchase);
2005
+ return;
2006
+ case 'update':
2007
+ store.update(step.purchase);
2008
+ return;
2009
+ case 'error':
2010
+ store.error({
2011
+ ...(step.logicalId === undefined ? {} : { logicalId: step.logicalId }),
2012
+ error: step.error,
2013
+ });
2014
+ return;
2015
+ case 'queueRequest':
2016
+ store.queueRequest(step.result);
2017
+ return;
2018
+ case 'queueFinish':
2019
+ store.queueFinish(step.result);
2020
+ return;
2021
+ }
2022
+ return;
2023
+ }
2024
+ case 'backend':
2025
+ switch (step.action) {
2026
+ case 'queueVerify':
2027
+ context.backend.queueVerify(step.decision);
2028
+ return;
2029
+ case 'push':
2030
+ context.backend.push({
2031
+ transactionId: step.transactionId,
2032
+ entryId: step.entryId,
2033
+ logicalId: step.logicalId,
2034
+ });
2035
+ return;
2036
+ case 'revoke':
2037
+ context.backend.revoke({
2038
+ ledgerEntryId: step.ledgerEntryId,
2039
+ entryId: step.entryId,
2040
+ reason: step.reason,
2041
+ });
2042
+ return;
2043
+ }
2044
+ return;
2045
+ case 'wallet': {
2046
+ const wallet = walletActorOf(context, step);
2047
+ switch (step.action) {
2048
+ case 'creditFromGrant':
2049
+ wallet.creditFromGrant(step.lot);
2050
+ return;
2051
+ case 'spend': {
2052
+ const result = await wallet.spend(step.input);
2053
+ if (step.returns !== undefined) {
2054
+ matchPartial(step.returns, result, `${path}.returns`, context.failures);
2055
+ }
2056
+ return;
2057
+ }
2058
+ case 'renewDue':
2059
+ await wallet.renewDue();
2060
+ return;
2061
+ }
2062
+ return;
2063
+ }
2064
+ case 'clock':
2065
+ context.clock.set(step.at);
2066
+ return;
2067
+ }
2068
+ }
2069
+ async function matchExpectations(expect, context) {
2070
+ const { failures, harness } = context;
2071
+ if (expect.journal !== undefined) {
2072
+ const actual = context.journal.snapshot();
2073
+ const expectedKeys = Object.keys(expect.journal);
2074
+ const actualKeys = Object.keys(actual);
2075
+ if (!sameStrings(expectedKeys, actualKeys)) {
2076
+ failures.push({ path: 'journal', expected: expectedKeys, actual: actualKeys });
2077
+ }
2078
+ for (const key of expectedKeys) {
2079
+ matchPartial(expect.journal[key], actual[key], `journal.${key}`, failures);
2080
+ }
2081
+ }
2082
+ if (expect.verifyRequests !== undefined) {
2083
+ matchPartial(expect.verifyRequests, context.backend.verifyRequests, 'verifyRequests', failures);
2084
+ }
2085
+ if (expect.ledger !== undefined) {
2086
+ matchPartial(expect.ledger, context.backend.ledger, 'ledger', failures);
2087
+ }
2088
+ if (expect.finishCalls !== undefined) {
2089
+ matchPartial(expect.finishCalls, harness.store?.finishCalls ?? [], 'finishCalls', failures);
2090
+ }
2091
+ if (expect.purchaseUiOpened !== undefined) {
2092
+ matchPartial(expect.purchaseUiOpened, harness.store?.purchaseUiOpened ?? 0, 'purchaseUiOpened', failures);
2093
+ }
2094
+ if (expect.available !== undefined) {
2095
+ const available = await harness.provider.getAvailablePurchases({ includePending: true });
2096
+ matchPartial(expect.available, available, 'available', failures);
2097
+ }
2098
+ if (expect.entitlements !== undefined) {
2099
+ matchPartial(expect.entitlements, context.backend.entitlements(), 'entitlements', failures);
2100
+ }
2101
+ if (expect.lots !== undefined) {
2102
+ matchPartial(expect.lots, harness.wallet?.lots ?? [], 'lots', failures);
2103
+ }
2104
+ if (expect.balances !== undefined) {
2105
+ for (const [titleId, expected] of Object.entries(expect.balances)) {
2106
+ const balance = harness.wallet === undefined
2107
+ ? {}
2108
+ : (await harness.wallet.getBalance({ titleId })).balances;
2109
+ matchPartial(expected, balance, `balances.${titleId}`, failures);
2110
+ }
2111
+ }
2112
+ if (expect.debits !== undefined) {
2113
+ matchPartial(expect.debits, harness.wallet?.debits ?? [], 'debits', failures);
2114
+ }
2115
+ if (expect.events !== undefined) {
2116
+ matchPartial(expect.events, harness.wallet?.subscriptionEvents ?? [], 'events', failures);
2117
+ }
2118
+ if (expect.activeSubscriptions !== undefined) {
2119
+ const active = harness.provider.getActiveSubscriptions === undefined
2120
+ ? await (harness.wallet?.getActiveSubscriptions() ?? Promise.resolve([]))
2121
+ : await harness.provider.getActiveSubscriptions();
2122
+ matchPartial(expect.activeSubscriptions, active, 'activeSubscriptions', failures);
2123
+ }
2124
+ }
2125
+ function sameStrings(left, right) {
2126
+ return left.length === right.length && left.every((value) => right.includes(value));
2127
+ }
2128
+ /**
2129
+ * Partial match: an expected object asserts only its listed keys, an expected
2130
+ * array asserts its length and each element in order, and primitives compare
2131
+ * strictly. Failures name the deepest differing path.
2132
+ */
2133
+ export function matchPartial(expected, actual, path, failures) {
2134
+ if (Array.isArray(expected)) {
2135
+ if (!Array.isArray(actual)) {
2136
+ failures.push({ path, expected, actual });
2137
+ return;
2138
+ }
2139
+ if (expected.length !== actual.length) {
2140
+ failures.push({ path: `${path}.length`, expected: expected.length, actual: actual.length });
2141
+ return;
2142
+ }
2143
+ expected.forEach((item, index) => {
2144
+ matchPartial(item, actual[index], `${path}[${index}]`, failures);
2145
+ });
2146
+ return;
2147
+ }
2148
+ if (typeof expected === 'object' && expected !== null) {
2149
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
2150
+ failures.push({ path, expected, actual });
2151
+ return;
2152
+ }
2153
+ const record = actual;
2154
+ for (const [key, value] of Object.entries(expected)) {
2155
+ matchPartial(value, record[key], `${path}.${key}`, failures);
2156
+ }
2157
+ return;
2158
+ }
2159
+ if (expected !== actual) {
2160
+ failures.push({ path, expected, actual });
2161
+ }
2162
+ }