@mpgd/platform 0.18.0 → 0.19.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,1701 @@
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.
14
+ */
15
+ export const commerceConformanceDefaultPlayerId = 'player-1';
16
+ export const commerceConformanceDefaultTitleId = 'title-a';
17
+ export const commerceConformanceDefaultClockStart = '2026-10-01T00:00:00Z';
18
+ /** An operation the vector did not script; it fails the vector with its message. */
19
+ export class CommerceConformanceScriptError extends Error {
20
+ name = 'CommerceConformanceScriptError';
21
+ }
22
+ export function createCommerceConformanceClock(initial = commerceConformanceDefaultClockStart) {
23
+ let current = normalizeIsoTimestamp(initial);
24
+ return {
25
+ now: () => current,
26
+ set(at) {
27
+ current = normalizeIsoTimestamp(at);
28
+ },
29
+ };
30
+ }
31
+ /** Canonical `YYYY-MM-DDTHH:mm:ssZ`; milliseconds are kept only when non-zero. */
32
+ export function normalizeIsoTimestamp(input) {
33
+ const millis = Date.parse(input);
34
+ if (Number.isNaN(millis)) {
35
+ throw new TypeError(`Invalid ISO-8601 timestamp: ${input}`);
36
+ }
37
+ return formatIsoTimestamp(millis);
38
+ }
39
+ function formatIsoTimestamp(millis) {
40
+ const iso = new Date(millis).toISOString();
41
+ return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
42
+ }
43
+ /** Adds an ISO-8601 duration (`P30D`, `P1M`, `PT12H`, ...) to a timestamp, in UTC calendar units. */
44
+ export function addIsoDuration(at, duration) {
45
+ const match = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u
46
+ .exec(duration);
47
+ if (match === null || duration === 'P' || duration === 'PT') {
48
+ throw new TypeError(`Invalid ISO-8601 duration: ${duration}`);
49
+ }
50
+ const [, years, months, weeks, days, hours, minutes, seconds] = match;
51
+ const date = new Date(normalizeIsoTimestamp(at));
52
+ date.setUTCFullYear(date.getUTCFullYear() + Number(years ?? 0), date.getUTCMonth() + Number(months ?? 0), date.getUTCDate() + Number(weeks ?? 0) * 7 + Number(days ?? 0));
53
+ date.setUTCHours(date.getUTCHours() + Number(hours ?? 0), date.getUTCMinutes() + Number(minutes ?? 0), date.getUTCSeconds() + Number(seconds ?? 0));
54
+ return formatIsoTimestamp(date.getTime());
55
+ }
56
+ // Capabilities and fixture filling.
57
+ /** Capabilities of a vector profile: omitted blocks default to all supported and `available`. */
58
+ export function createCommerceConformanceCapabilities(profile) {
59
+ return {
60
+ purchase: profile.purchase === undefined
61
+ ? { consumable: true, durable: true, subscription: true, quantity: true }
62
+ : { ...profile.purchase },
63
+ catalog: { products: true, offers: true, localizedPrice: true },
64
+ lifecycle: profile.lifecycle === undefined
65
+ ? { availablePurchases: true, pending: true, restore: true, events: true, finish: 'client' }
66
+ : { ...profile.lifecycle },
67
+ authority: profile.authority === undefined
68
+ ? { evidence: 'client-token', accountBinding: 'optional' }
69
+ : { ...profile.authority },
70
+ availability: 'available',
71
+ };
72
+ }
73
+ /** Fake evidence for a fixture purchase: `fixture.<store>.v1` carrying the token. */
74
+ export function createCommerceConformanceEvidence(store, token) {
75
+ return { schema: `fixture.${store}.v1`, payload: { token } };
76
+ }
77
+ function createFixtureAccountBinding(context) {
78
+ switch (context.profile.authority?.accountBinding ?? 'optional') {
79
+ case 'required':
80
+ return { kind: 'obfuscated-account-id', value: context.playerId };
81
+ case 'host':
82
+ return { kind: 'host-identity' };
83
+ case 'optional':
84
+ return undefined;
85
+ }
86
+ }
87
+ /**
88
+ * Fills vector purchase shorthand into a typed `StorePurchase`: the profile's
89
+ * store, quantity 1, the catalog logical id of the SKU, fake evidence, and the
90
+ * account binding the profile's authority block implies.
91
+ */
92
+ export function fillCommerceConformancePurchase(fixture, context) {
93
+ const store = context.profile.store;
94
+ const entry = context.catalog.find((candidate) => candidate.storeSku === fixture.storeSku);
95
+ const accountBinding = createFixtureAccountBinding(context);
96
+ return {
97
+ store,
98
+ transactionId: fixture.transactionId,
99
+ ...(fixture.originalTransactionId === undefined
100
+ ? {}
101
+ : { originalTransactionId: fixture.originalTransactionId }),
102
+ storeSku: fixture.storeSku,
103
+ ...(entry === undefined ? {} : { logicalId: entry.logicalId }),
104
+ quantity: 1,
105
+ state: fixture.state,
106
+ ...(fixture.purchasedAt === undefined ? {} : { purchasedAt: fixture.purchasedAt }),
107
+ token: fixture.token,
108
+ evidence: createCommerceConformanceEvidence(store, fixture.token),
109
+ ...(accountBinding === undefined ? {} : { accountBinding }),
110
+ ...(fixture.settlement === undefined ? {} : { settlement: { ...fixture.settlement } }),
111
+ };
112
+ }
113
+ export function commerceErrorInfoOfFixture(fixture) {
114
+ return {
115
+ code: fixture.code,
116
+ retryable: fixture.retryable,
117
+ ...(fixture.storeCode === undefined ? {} : { storeCode: fixture.storeCode }),
118
+ message: `Commerce operation failed: ${fixture.code}.`,
119
+ };
120
+ }
121
+ /**
122
+ * Fake store: answers `launched` unless a `queueRequest` entry is queued,
123
+ * `{ finished: true }` unless a `queueFinish` entry is queued, lists reported
124
+ * purchases until they are finished, and buffers events emitted before the
125
+ * first listener with `createCommerceEventBuffer`.
126
+ */
127
+ export function createCommerceConformanceStore(input) {
128
+ const capabilities = createCommerceConformanceCapabilities(input.profile);
129
+ const store = input.profile.store;
130
+ const finishOwner = capabilities.lifecycle.finish;
131
+ const purchases = [];
132
+ const requestQueue = [];
133
+ const finishQueue = [];
134
+ const finishCalls = [];
135
+ const buffer = createCommerceEventBuffer();
136
+ let listener;
137
+ let purchaseUiOpened = 0;
138
+ let restoreCalls = 0;
139
+ const emit = (event) => {
140
+ if (listener === undefined) {
141
+ buffer.push(event);
142
+ return;
143
+ }
144
+ listener(event);
145
+ };
146
+ const upsert = (purchase) => {
147
+ const index = purchases.findIndex((candidate) => candidate.transactionId === purchase.transactionId);
148
+ if (index === -1) {
149
+ purchases.push(purchase);
150
+ }
151
+ else {
152
+ purchases[index] = purchase;
153
+ }
154
+ };
155
+ const remove = (transactionId) => {
156
+ const index = purchases.findIndex((candidate) => candidate.transactionId === transactionId);
157
+ if (index !== -1) {
158
+ purchases.splice(index, 1);
159
+ }
160
+ };
161
+ const report = (fixture) => {
162
+ const purchase = fillCommerceConformancePurchase(fixture, input);
163
+ upsert(purchase);
164
+ emit({ type: 'purchaseUpdated', purchase });
165
+ };
166
+ const provider = {
167
+ store,
168
+ async getCapabilities() {
169
+ return createCommerceConformanceCapabilities(input.profile);
170
+ },
171
+ async fetchProducts(request) {
172
+ return input.catalog
173
+ .filter((entry) => matchesFetchProducts(entry, request))
174
+ .map((entry) => ({
175
+ logicalId: entry.logicalId,
176
+ store,
177
+ storeSku: entry.storeSku,
178
+ kind: entry.kind,
179
+ title: entry.logicalId,
180
+ description: `Fixture product ${entry.storeSku}`,
181
+ price: { formatted: '$0.99', currencyCode: 'USD', amountMicros: 990_000 },
182
+ }));
183
+ },
184
+ async requestPurchase() {
185
+ purchaseUiOpened += 1;
186
+ const queued = requestQueue.shift();
187
+ if (queued === undefined) {
188
+ return { outcome: 'launched' };
189
+ }
190
+ if (queued.outcome === 'launched') {
191
+ return { outcome: 'launched' };
192
+ }
193
+ if (queued.outcome === 'purchased') {
194
+ const purchase = fillCommerceConformancePurchase(queued.purchase, input);
195
+ upsert(purchase);
196
+ return { outcome: 'purchased', purchase };
197
+ }
198
+ return { outcome: queued.outcome, error: commerceErrorInfoOfFixture(queued.error) };
199
+ },
200
+ async getAvailablePurchases(request = {}) {
201
+ if (!capabilities.lifecycle.availablePurchases) {
202
+ return [];
203
+ }
204
+ const includePending = request.includePending ?? false;
205
+ // A revoked purchase is not recoverable; it reaches the runtime through the event path only.
206
+ return purchases.filter((purchase) => purchase.state !== 'revoked' && (includePending || purchase.state !== 'pending'));
207
+ },
208
+ async finishTransaction(request) {
209
+ const by = finishOwner === 'host' ? 'host' : 'client';
210
+ const queued = finishQueue.shift() ?? { finished: true };
211
+ if ('error' in queued) {
212
+ finishCalls.push({ by, ...request, result: queued.error });
213
+ throw new CommerceError(queued.error, { message: 'Fixture finish failure.' });
214
+ }
215
+ finishCalls.push({ by, ...request, result: queued.finished ? 'finished' : 'unfinished' });
216
+ if (queued.finished) {
217
+ remove(request.transactionId);
218
+ }
219
+ return { finished: queued.finished };
220
+ },
221
+ async restorePurchases() {
222
+ restoreCalls += 1;
223
+ },
224
+ subscribe(next) {
225
+ if (listener !== undefined) {
226
+ throw new CommerceConformanceScriptError('The fixture store supports one listener at a time.');
227
+ }
228
+ listener = next;
229
+ buffer.drain(next);
230
+ return () => {
231
+ if (listener === next) {
232
+ listener = undefined;
233
+ }
234
+ };
235
+ },
236
+ };
237
+ return {
238
+ provider,
239
+ get purchases() {
240
+ return [...purchases];
241
+ },
242
+ get purchaseUiOpened() {
243
+ return purchaseUiOpened;
244
+ },
245
+ get restoreCalls() {
246
+ return restoreCalls;
247
+ },
248
+ get finishCalls() {
249
+ return [...finishCalls];
250
+ },
251
+ complete: report,
252
+ update: report,
253
+ error(event) {
254
+ emit({
255
+ type: 'purchaseError',
256
+ ...(event.logicalId === undefined ? {} : { logicalId: event.logicalId }),
257
+ error: commerceErrorInfoOfFixture(event.error),
258
+ });
259
+ },
260
+ queueRequest(result) {
261
+ requestQueue.push(result);
262
+ },
263
+ queueFinish(result) {
264
+ finishQueue.push(result);
265
+ },
266
+ finalize(request) {
267
+ remove(request.transactionId);
268
+ },
269
+ markFinished: remove,
270
+ };
271
+ }
272
+ function matchesFetchProducts(entry, request) {
273
+ const type = request.type ?? 'all';
274
+ if (type === 'in-app' && entry.kind === 'subscription') {
275
+ return false;
276
+ }
277
+ if (type === 'subscription' && entry.kind !== 'subscription') {
278
+ return false;
279
+ }
280
+ return request.logicalIds === undefined || request.logicalIds.includes(entry.logicalId);
281
+ }
282
+ /**
283
+ * Fake backend: every verify call consumes the next `queueVerify` decision in
284
+ * order and is logged; an unqueued verify is an unscripted operation. A
285
+ * `granted` decision makes sure the ledger holds the entry, so a replayed
286
+ * grant and a host-pushed grant never create a second entry.
287
+ */
288
+ export function createCommerceConformanceBackend(input = {}) {
289
+ const decisions = [];
290
+ const verifyRequests = [];
291
+ const ledger = [];
292
+ const findEntry = (entryId) => ledger.find((entry) => entry.entryId === entryId);
293
+ const reversalOf = (ledgerEntryId) => ledger.find((entry) => entry.type === 'revoke' && entry.reverses === ledgerEntryId);
294
+ return {
295
+ get verifyRequests() {
296
+ return [...verifyRequests];
297
+ },
298
+ get ledger() {
299
+ return [...ledger];
300
+ },
301
+ queueVerify(decision) {
302
+ decisions.push(decision);
303
+ },
304
+ async verifyPurchase(request) {
305
+ verifyRequests.push(request);
306
+ const decision = decisions.shift();
307
+ if (decision === undefined) {
308
+ throw new CommerceConformanceScriptError(`Unscripted verify for ${request.idempotencyKey} (${request.store}/${request.transactionId}); `
309
+ + 'queue a backend.queueVerify decision.');
310
+ }
311
+ if (decision.status !== 'granted') {
312
+ return { status: decision.status };
313
+ }
314
+ const ledgerEntryId = decision.ledgerEntryId;
315
+ if (ledgerEntryId === undefined) {
316
+ throw new CommerceConformanceScriptError(`A granted decision for ${request.idempotencyKey} must name a ledgerEntryId.`);
317
+ }
318
+ if (findEntry(ledgerEntryId) === undefined) {
319
+ ledger.push({
320
+ entryId: ledgerEntryId,
321
+ type: 'grant',
322
+ logicalId: request.logicalId,
323
+ store: request.store,
324
+ transactionId: request.transactionId,
325
+ idempotencyKey: request.idempotencyKey,
326
+ });
327
+ }
328
+ if (decision.finalization === 'completed') {
329
+ await input.finalize?.({
330
+ store: request.store,
331
+ transactionId: request.transactionId,
332
+ ledgerEntryId,
333
+ logicalId: request.logicalId,
334
+ });
335
+ }
336
+ return {
337
+ status: 'granted',
338
+ ledgerEntryId,
339
+ ...(decision.finalization === undefined ? {} : { finalization: decision.finalization }),
340
+ ...(decision.alreadyProcessed === undefined
341
+ ? {}
342
+ : { alreadyProcessed: decision.alreadyProcessed }),
343
+ };
344
+ },
345
+ async getLedgerStatus(ledgerEntryIds) {
346
+ return ledgerEntryIds.map((ledgerEntryId) => {
347
+ const entry = findEntry(ledgerEntryId);
348
+ if (entry === undefined || entry.type !== 'grant') {
349
+ return { ledgerEntryId, status: 'unknown' };
350
+ }
351
+ const reversal = reversalOf(ledgerEntryId);
352
+ return reversal === undefined
353
+ ? { ledgerEntryId, status: 'active' }
354
+ : { ledgerEntryId, status: 'reversed', reversedBy: reversal.entryId };
355
+ });
356
+ },
357
+ push(grant) {
358
+ // Keyed by store transaction id: a retried fulfillment replays the
359
+ // original grant, even when the retry names a different entry id.
360
+ const replayed = ledger.find((entry) => entry.type === 'grant'
361
+ && (entry.entryId === grant.entryId || entry.transactionId === grant.transactionId));
362
+ if (replayed !== undefined) {
363
+ return;
364
+ }
365
+ ledger.push({
366
+ entryId: grant.entryId,
367
+ type: 'grant',
368
+ logicalId: grant.logicalId,
369
+ store: grant.store ?? 'server-push',
370
+ transactionId: grant.transactionId,
371
+ pushed: true,
372
+ });
373
+ },
374
+ revoke(revocation) {
375
+ const grant = findEntry(revocation.ledgerEntryId);
376
+ if (grant === undefined || grant.type !== 'grant') {
377
+ throw new CommerceConformanceScriptError(`Cannot revoke ${revocation.ledgerEntryId}: the ledger holds no such grant.`);
378
+ }
379
+ if (findEntry(revocation.entryId) !== undefined) {
380
+ return;
381
+ }
382
+ ledger.push({
383
+ entryId: revocation.entryId,
384
+ type: 'revoke',
385
+ logicalId: grant.logicalId,
386
+ reverses: revocation.ledgerEntryId,
387
+ reason: revocation.reason,
388
+ });
389
+ },
390
+ entitlements() {
391
+ return ledger.flatMap((entry) => entry.type === 'grant' && reversalOf(entry.entryId) === undefined
392
+ ? [{ id: entry.entryId, logicalId: entry.logicalId }]
393
+ : []);
394
+ },
395
+ };
396
+ }
397
+ /** In-memory journal that keeps every revision so `expect.journal` can read the full sequence. */
398
+ export function createCommerceConformanceJournal() {
399
+ const revisions = new Map();
400
+ const current = (key) => revisions.get(key)?.at(-1);
401
+ const list = (playerId, accept) => {
402
+ const records = [];
403
+ for (const key of revisions.keys()) {
404
+ const record = current(key);
405
+ if (record !== undefined && record.playerId === playerId && accept(record)) {
406
+ records.push(record);
407
+ }
408
+ }
409
+ return records;
410
+ };
411
+ return {
412
+ get keys() {
413
+ return [...revisions.keys()];
414
+ },
415
+ async reserve(record) {
416
+ const existing = current(record.key);
417
+ if (existing !== undefined) {
418
+ return { created: false, record: existing };
419
+ }
420
+ const stored = { ...record, revision: 0 };
421
+ revisions.set(record.key, [stored]);
422
+ return { created: true, record: stored };
423
+ },
424
+ async read(key) {
425
+ return current(key);
426
+ },
427
+ async replace(expectedRevision, record) {
428
+ const history = revisions.get(record.key);
429
+ const existing = history?.at(-1);
430
+ if (history === undefined || existing === undefined) {
431
+ throw new Error(`Journal record ${record.key} does not exist.`);
432
+ }
433
+ if (existing.revision !== expectedRevision) {
434
+ throw new Error(`Journal record ${record.key} is at revision ${existing.revision}, not ${expectedRevision}.`);
435
+ }
436
+ if (existing.playerId !== record.playerId
437
+ || existing.store !== record.store
438
+ || existing.logicalId !== record.logicalId) {
439
+ throw new Error(`Journal record ${record.key} identity cannot change.`);
440
+ }
441
+ if (existing.state === record.state) {
442
+ throw new Error(`Journal record ${record.key} revision ${expectedRevision + 1} must change its state.`);
443
+ }
444
+ assertCommercePurchaseTransition(existing.state, record.state);
445
+ const stored = { ...record, revision: expectedRevision + 1 };
446
+ history.push(stored);
447
+ return stored;
448
+ },
449
+ async listRecoverable(playerId) {
450
+ return list(playerId, (record) => !isTerminalCommercePurchaseState(record.state));
451
+ },
452
+ async listGranted(playerId) {
453
+ return list(playerId, (record) => record.ledgerEntryId !== undefined
454
+ && (record.state === 'granted'
455
+ || record.state === 'finishing'
456
+ || record.state === 'finished'));
457
+ },
458
+ history(key) {
459
+ return (revisions.get(key) ?? []).map((record) => record.state);
460
+ },
461
+ snapshot() {
462
+ const snapshot = {};
463
+ for (const [key, history] of revisions) {
464
+ snapshot[key] = history.map((record) => record.state);
465
+ }
466
+ return snapshot;
467
+ },
468
+ };
469
+ }
470
+ /**
471
+ * Reference reconciler. Every transition is its own journal revision written
472
+ * through compare-and-replace; the backend is in-process, so there is no
473
+ * transport, but a thrown finish or verify keeps the state for the next pass
474
+ * as a transport failure would. Only a ledger entry moves a record to
475
+ * `granted`, and only a ledger revocation moves it to `revoked`.
476
+ */
477
+ export function createCommerceConformanceRuntime(input) {
478
+ const { provider, backend, journal, clock, catalog, playerId } = input;
479
+ const store = provider.store;
480
+ const records = new Map();
481
+ const inbox = [];
482
+ let unsubscribe;
483
+ let started = false;
484
+ let triggerPending = false;
485
+ let running;
486
+ /** A failure from a pass nobody awaited; the next `runUntilIdle` rethrows it. */
487
+ let pendingFailure;
488
+ const catalogEntry = (logicalId) => {
489
+ const entry = catalog.find((candidate) => candidate.logicalId === logicalId);
490
+ if (entry === undefined) {
491
+ throw new CommerceConformanceScriptError(`Unknown logical id ${logicalId}; declare it in the vector catalog.`);
492
+ }
493
+ return entry;
494
+ };
495
+ const resolveLogicalId = (purchase) => {
496
+ if (purchase.logicalId !== undefined) {
497
+ return catalogEntry(purchase.logicalId).logicalId;
498
+ }
499
+ const entry = catalog.find((candidate) => candidate.storeSku === purchase.storeSku);
500
+ if (entry === undefined) {
501
+ throw new CommerceConformanceScriptError(`Purchase ${purchase.transactionId} names SKU ${purchase.storeSku}, which no catalog entry declares.`);
502
+ }
503
+ return entry.logicalId;
504
+ };
505
+ const remember = (record) => {
506
+ records.set(record.key, record);
507
+ return record;
508
+ };
509
+ const write = async (record, state, patch = {}) => remember(await journal.replace(record.revision, {
510
+ ...record,
511
+ ...patch,
512
+ state,
513
+ updatedAt: clock.now(),
514
+ }));
515
+ const create = async (record) => {
516
+ const reserved = await journal.reserve({ ...record, revision: 0, updatedAt: clock.now() });
517
+ remember(reserved.record);
518
+ return reserved;
519
+ };
520
+ const loadRecords = async () => {
521
+ for (const record of await journal.listRecoverable(playerId)) {
522
+ remember(record);
523
+ }
524
+ for (const record of await journal.listGranted(playerId)) {
525
+ remember(record);
526
+ }
527
+ };
528
+ const openRecordsFor = (logicalId) => [...records.values()].filter((record) => record.store === store
529
+ && record.logicalId === logicalId
530
+ && (record.state === 'launched' || record.state === 'pending'));
531
+ const trigger = () => {
532
+ triggerPending = true;
533
+ };
534
+ const listener = (event) => {
535
+ inbox.push(event);
536
+ trigger();
537
+ // Every event starts a pass on its own; a pass already in flight picks the trigger up.
538
+ scheduleRun();
539
+ };
540
+ // Reconciler step 2: match a reported purchase to a journal record.
541
+ const ingestPurchase = async (purchase) => {
542
+ if (purchase.store !== store) {
543
+ throw new CommerceConformanceScriptError(`Purchase ${purchase.transactionId} belongs to store ${purchase.store}, not ${store}.`);
544
+ }
545
+ const logicalId = resolveLogicalId(purchase);
546
+ let record = [...records.values()].find((candidate) => candidate.store === purchase.store
547
+ && candidate.purchase?.transactionId === purchase.transactionId);
548
+ if (record === undefined) {
549
+ const open = openRecordsFor(logicalId);
550
+ if (open.length > 1) {
551
+ throw new CommerceConformanceScriptError(`Purchase ${purchase.transactionId} matches ${open.length} open records for ${logicalId}.`);
552
+ }
553
+ record = open[0];
554
+ }
555
+ if (record === undefined) {
556
+ if (purchase.state === 'revoked') {
557
+ return;
558
+ }
559
+ const key = await createDerivedPurchaseIdempotencyKey({
560
+ store: purchase.store,
561
+ transactionId: purchase.transactionId,
562
+ token: purchase.token,
563
+ });
564
+ const existing = await journal.read(key);
565
+ if (existing !== undefined) {
566
+ remember(existing);
567
+ return;
568
+ }
569
+ await create({
570
+ key,
571
+ playerId,
572
+ store: purchase.store,
573
+ logicalId,
574
+ kind: catalogEntry(logicalId).kind,
575
+ origin: 'discovered',
576
+ state: purchase.state,
577
+ purchase,
578
+ });
579
+ return;
580
+ }
581
+ if (purchase.state === 'pending' && record.state === 'launched') {
582
+ await write(record, 'pending', { purchase });
583
+ return;
584
+ }
585
+ if (purchase.state === 'purchased'
586
+ && (record.state === 'launched' || (record.state === 'pending' && record.request === undefined))) {
587
+ await write(record, 'purchased', { purchase });
588
+ }
589
+ // Revoked store states never revoke on their own: only the ledger does (step 5).
590
+ };
591
+ const ingestError = async (logicalId, error) => {
592
+ const open = logicalId === undefined
593
+ ? [...records.values()].filter((record) => record.store === store && (record.state === 'launched' || record.state === 'pending'))
594
+ : openRecordsFor(logicalId);
595
+ if (open.length > 1) {
596
+ throw new CommerceConformanceScriptError(`purchaseError${logicalId === undefined ? '' : ` for ${logicalId}`} matches ${open.length} open records.`);
597
+ }
598
+ // An error for an attempt the journal never opened has nothing to transition.
599
+ const record = open[0];
600
+ if (record !== undefined) {
601
+ await write(record, error.code === 'USER_CANCELLED' ? 'cancelled' : 'failed', { error });
602
+ }
603
+ };
604
+ const buildRequest = (record) => {
605
+ const purchase = record.purchase;
606
+ if (purchase === undefined) {
607
+ throw new Error(`Journal record ${record.key} is purchased without a purchase.`);
608
+ }
609
+ return {
610
+ idempotencyKey: record.key,
611
+ playerId,
612
+ store: purchase.store,
613
+ transactionId: purchase.transactionId,
614
+ token: purchase.token,
615
+ logicalId: record.logicalId,
616
+ quantity: purchase.quantity,
617
+ evidence: purchase.evidence,
618
+ };
619
+ };
620
+ // Reconciler steps 3 and 4: drive one record as far as the backend and store allow.
621
+ const drive = async (initial, capabilities) => {
622
+ let record = initial;
623
+ let finalization;
624
+ for (;;) {
625
+ switch (record.state) {
626
+ case 'purchased':
627
+ record = await write(record, 'verifying', { request: buildRequest(record) });
628
+ continue;
629
+ case 'pending':
630
+ if (record.request === undefined) {
631
+ return;
632
+ }
633
+ record = await write(record, 'verifying');
634
+ continue;
635
+ case 'verifying': {
636
+ const request = record.request ?? buildRequest(record);
637
+ const response = await backend.verifyPurchase(request);
638
+ if (response.status === 'pending') {
639
+ await write(record, 'pending', { request });
640
+ return;
641
+ }
642
+ if (response.status === 'rejected') {
643
+ await write(record, 'rejected', { request });
644
+ return;
645
+ }
646
+ if (response.ledgerEntryId === undefined) {
647
+ throw new CommerceConformanceScriptError(`A granted verify response for ${record.key} must carry a ledgerEntryId.`);
648
+ }
649
+ finalization = response.finalization;
650
+ record = await write(record, 'granted', { request, ledgerEntryId: response.ledgerEntryId });
651
+ continue;
652
+ }
653
+ case 'granted':
654
+ if (capabilities.lifecycle.finish === 'none') {
655
+ await write(record, 'finished');
656
+ return;
657
+ }
658
+ record = await write(record, 'finishing');
659
+ continue;
660
+ case 'finishing': {
661
+ const owner = capabilities.lifecycle.finish;
662
+ if (owner === 'none') {
663
+ await write(record, 'finished');
664
+ return;
665
+ }
666
+ if (owner === 'server') {
667
+ if (finalization === undefined) {
668
+ const response = await backend.verifyPurchase(record.request ?? buildRequest(record));
669
+ finalization = response.status === 'granted' ? response.finalization : undefined;
670
+ }
671
+ if (finalization === 'completed') {
672
+ await write(record, 'finished');
673
+ }
674
+ return;
675
+ }
676
+ const ledgerEntryId = record.ledgerEntryId;
677
+ const transactionId = record.purchase?.transactionId;
678
+ if (ledgerEntryId === undefined || transactionId === undefined) {
679
+ throw new Error(`Journal record ${record.key} is finishing without a grant.`);
680
+ }
681
+ if (provider.finishTransaction === undefined) {
682
+ throw new CommerceConformanceScriptError(`The provider declares finish: '${owner}' but has no finishTransaction.`);
683
+ }
684
+ let finished = false;
685
+ try {
686
+ finished = (await provider.finishTransaction({
687
+ transactionId,
688
+ ledgerEntryId,
689
+ isConsumable: record.kind === 'consumable',
690
+ })).finished;
691
+ }
692
+ catch (error) {
693
+ if (error instanceof CommerceConformanceScriptError) {
694
+ throw error;
695
+ }
696
+ // Transport-class failure: keep `finishing` for the next pass.
697
+ finished = false;
698
+ }
699
+ if (finished) {
700
+ await write(record, 'finished');
701
+ }
702
+ return;
703
+ }
704
+ default:
705
+ return;
706
+ }
707
+ }
708
+ };
709
+ // Reconciler step 5: reversed grants move to revoked.
710
+ const revokeReversed = async () => {
711
+ const granted = (await journal.listGranted(playerId)).map(remember);
712
+ if (granted.length === 0) {
713
+ return;
714
+ }
715
+ const statuses = await backend.getLedgerStatus(granted.flatMap((record) => (record.ledgerEntryId === undefined
716
+ ? []
717
+ : [record.ledgerEntryId])));
718
+ for (const record of granted) {
719
+ const status = statuses.find((candidate) => candidate.ledgerEntryId === record.ledgerEntryId);
720
+ if (status?.status === 'reversed') {
721
+ await write(record, 'revoked', {
722
+ ...(status.reversedBy === undefined ? {} : { revokedBy: status.reversedBy }),
723
+ });
724
+ }
725
+ }
726
+ };
727
+ const pass = async () => {
728
+ const capabilities = await provider.getCapabilities();
729
+ for (const event of inbox.splice(0, inbox.length)) {
730
+ if (event.type === 'purchaseUpdated') {
731
+ await ingestPurchase(event.purchase);
732
+ }
733
+ else if (event.type === 'purchaseError') {
734
+ await ingestError(event.logicalId, event.error);
735
+ }
736
+ }
737
+ for (const record of (await journal.listRecoverable(playerId)).map(remember)) {
738
+ await drive(record, capabilities);
739
+ }
740
+ await revokeReversed();
741
+ };
742
+ const runUntilIdle = async () => {
743
+ if (running !== undefined) {
744
+ try {
745
+ await running;
746
+ }
747
+ catch (error) {
748
+ pendingFailure = undefined;
749
+ throw error;
750
+ }
751
+ return;
752
+ }
753
+ if (pendingFailure !== undefined) {
754
+ const failure = pendingFailure;
755
+ pendingFailure = undefined;
756
+ throw failure.error;
757
+ }
758
+ if (!started || !triggerPending) {
759
+ return;
760
+ }
761
+ const loop = (async () => {
762
+ while (started && triggerPending) {
763
+ triggerPending = false;
764
+ await pass();
765
+ }
766
+ })();
767
+ running = loop.finally(() => {
768
+ running = undefined;
769
+ });
770
+ await running;
771
+ };
772
+ function scheduleRun() {
773
+ runUntilIdle().catch((error) => {
774
+ pendingFailure = { error };
775
+ });
776
+ }
777
+ return {
778
+ get started() {
779
+ return started;
780
+ },
781
+ async start() {
782
+ if (started) {
783
+ return;
784
+ }
785
+ started = true;
786
+ await loadRecords();
787
+ // Subscribe first, then read: an event delivered before the listener is found either way.
788
+ unsubscribe = provider.subscribe(listener);
789
+ const capabilities = await provider.getCapabilities();
790
+ if (capabilities.lifecycle.availablePurchases) {
791
+ for (const purchase of await provider.getAvailablePurchases({
792
+ includePending: capabilities.lifecycle.pending,
793
+ })) {
794
+ inbox.push({ type: 'purchaseUpdated', purchase });
795
+ }
796
+ }
797
+ trigger();
798
+ await runUntilIdle();
799
+ },
800
+ async stop() {
801
+ started = false;
802
+ unsubscribe?.();
803
+ unsubscribe = undefined;
804
+ // Quiesce: a pass awaiting the backend or the store finishes before state is dropped.
805
+ if (running !== undefined) {
806
+ try {
807
+ await running;
808
+ }
809
+ catch (error) {
810
+ pendingFailure = { error };
811
+ }
812
+ }
813
+ triggerPending = false;
814
+ inbox.length = 0;
815
+ records.clear();
816
+ },
817
+ async reconcile() {
818
+ if (!started) {
819
+ await loadRecords();
820
+ }
821
+ await pass();
822
+ await runUntilIdle();
823
+ },
824
+ runUntilIdle,
825
+ async requestPurchase(request) {
826
+ const entry = catalogEntry(request.logicalId);
827
+ // A key is never regenerated on retry: the journaled attempt is the answer,
828
+ // and the store UI opens once per key however many calls overlap.
829
+ const journaled = (existing) => {
830
+ if ((existing.state === 'cancelled' || existing.state === 'failed')
831
+ && existing.error !== undefined) {
832
+ return { outcome: existing.state, error: existing.error };
833
+ }
834
+ return {
835
+ outcome: 'failed',
836
+ error: new CommerceError('IN_PROGRESS', {
837
+ message: `Idempotency key ${request.idempotencyKey} is already journaled.`,
838
+ }).toCommerceErrorInfo(),
839
+ };
840
+ };
841
+ const existing = await journal.read(request.idempotencyKey);
842
+ if (existing !== undefined) {
843
+ return journaled(existing);
844
+ }
845
+ const { created, record } = await create({
846
+ key: request.idempotencyKey,
847
+ playerId,
848
+ store,
849
+ logicalId: entry.logicalId,
850
+ kind: entry.kind,
851
+ origin: 'game',
852
+ state: 'requested',
853
+ });
854
+ if (!created) {
855
+ return journaled(record);
856
+ }
857
+ const failWith = async (error) => {
858
+ const info = commerceErrorInfoOf(error);
859
+ await write(record, 'failed', { error: info });
860
+ return { outcome: 'failed', error: info };
861
+ };
862
+ try {
863
+ assertRequestPurchaseSupported(await provider.getCapabilities(), request, entry.kind);
864
+ }
865
+ catch (error) {
866
+ return failWith(error);
867
+ }
868
+ if (!started) {
869
+ await loadRecords();
870
+ }
871
+ // An overlapping request for the same product that reserved its record
872
+ // earlier counts too: the earlier reservation reaches the store, the
873
+ // later one fails, so two overlapping calls never both open the UI.
874
+ let reservedEarlier = true;
875
+ const open = (await journal.listRecoverable(playerId)).filter((candidate) => {
876
+ if (candidate.key === record.key) {
877
+ reservedEarlier = false;
878
+ return false;
879
+ }
880
+ return (candidate.store === store
881
+ && candidate.logicalId === entry.logicalId
882
+ && (candidate.state === 'launched'
883
+ || candidate.state === 'pending'
884
+ || (candidate.state === 'requested' && reservedEarlier)));
885
+ });
886
+ if (open.length > 0) {
887
+ return failWith(new CommerceError('IN_PROGRESS', {
888
+ message: `A purchase of ${entry.logicalId} is already in progress (${open[0]?.key}).`,
889
+ }));
890
+ }
891
+ let result;
892
+ try {
893
+ result = await provider.requestPurchase(request);
894
+ }
895
+ catch (error) {
896
+ if (error instanceof CommerceConformanceScriptError) {
897
+ throw error;
898
+ }
899
+ return failWith(error);
900
+ }
901
+ if (result.outcome === 'launched') {
902
+ await write(record, 'launched');
903
+ }
904
+ else if (result.outcome === 'purchased') {
905
+ // A synchronous answer passes the same store and product checks as a reported purchase.
906
+ const purchase = result.purchase;
907
+ if (purchase.store !== store
908
+ || purchase.storeSku !== entry.storeSku
909
+ || resolveLogicalId(purchase) !== entry.logicalId) {
910
+ throw new CommerceConformanceScriptError(`The provider answered ${request.idempotencyKey} (${store}/${entry.logicalId}) with `
911
+ + `purchase ${purchase.transactionId} for ${purchase.store}/${purchase.storeSku}.`);
912
+ }
913
+ // Both states are written, in order, when the store answered synchronously.
914
+ const launched = await write(record, 'launched');
915
+ if (purchase.state === 'pending') {
916
+ await write(launched, 'pending', { purchase });
917
+ }
918
+ else if (purchase.state === 'purchased') {
919
+ await write(launched, 'purchased', { purchase });
920
+ }
921
+ }
922
+ else {
923
+ await write(record, result.outcome, { error: result.error });
924
+ }
925
+ trigger();
926
+ await runUntilIdle();
927
+ return result;
928
+ },
929
+ async fetchProducts(request = {}) {
930
+ return provider.fetchProducts(request);
931
+ },
932
+ };
933
+ }
934
+ /**
935
+ * Reference in-memory wallet implementing the RFC "Credit lots" and "Billing
936
+ * authority and memberships" rules: lot ids equal ledger entry ids, spend
937
+ * scope derives from the lot origin, spends consume eligible lots in the
938
+ * fixed order, are idempotent by key, and debit nothing on
939
+ * `INSUFFICIENT_BALANCE`; memberships debit `membership:<id>:<period>` per
940
+ * paid period and lapse at period end when the eligible balance runs out.
941
+ */
942
+ export function createCommerceConformanceWallet(input) {
943
+ const { clock, store } = input;
944
+ const currencies = new Set(input.currencies);
945
+ const lots = [];
946
+ const spends = new Map();
947
+ const debits = [];
948
+ const entitlements = [];
949
+ const subscriptions = new Map();
950
+ const subscriptionEvents = [];
951
+ const listeners = new Set();
952
+ const buffer = createCommerceEventBuffer();
953
+ const snapshotLot = (lot) => ({ ...lot });
954
+ const toEntitlement = (entitlement) => ({
955
+ entitlementId: entitlement.entitlementId,
956
+ titleId: entitlement.titleId,
957
+ logicalId: entitlement.logicalId,
958
+ idempotencyKey: entitlement.idempotencyKey,
959
+ createdAt: entitlement.createdAt,
960
+ debits: [...entitlement.debits],
961
+ });
962
+ const toActiveSubscription = (subscription) => ({
963
+ store,
964
+ logicalId: subscription.logicalId,
965
+ subscriptionId: subscription.subscriptionId,
966
+ periodIndex: subscription.periodIndex,
967
+ state: subscription.state,
968
+ startedAt: subscription.startedAt,
969
+ expiresAt: subscription.expiresAt,
970
+ autoRenew: subscription.state === 'active',
971
+ });
972
+ const emitSubscription = (subscription, type, periodIndex, reason) => {
973
+ const eventId = `${subscription.subscriptionId}:${periodIndex}:${type}`;
974
+ if (subscriptionEvents.some((event) => event.eventId === eventId)) {
975
+ return;
976
+ }
977
+ subscriptionEvents.push({
978
+ eventId,
979
+ type,
980
+ subscriptionId: subscription.subscriptionId,
981
+ logicalId: subscription.logicalId,
982
+ periodIndex,
983
+ expiresAt: subscription.expiresAt,
984
+ ...(reason === undefined ? {} : { reason }),
985
+ });
986
+ const event = {
987
+ type: 'subscriptionUpdated',
988
+ subscription: toActiveSubscription(subscription),
989
+ };
990
+ if (listeners.size === 0) {
991
+ buffer.push(event);
992
+ return;
993
+ }
994
+ for (const listener of listeners) {
995
+ listener(event);
996
+ }
997
+ };
998
+ const eligibleLots = (titleId, currency, scope) => {
999
+ const now = Date.parse(clock.now());
1000
+ return lots
1001
+ .filter((lot) => lot.currency === currency
1002
+ && lot.remaining > 0
1003
+ && (lot.expiresAt === undefined || Date.parse(lot.expiresAt) > now)
1004
+ && (lot.spendScope.kind === 'cross-title'
1005
+ || (scope === 'title' && lot.spendScope.titleId === titleId)))
1006
+ .sort(compareCreditLotSpendOrder);
1007
+ };
1008
+ const spendScoped = async (request, scope) => {
1009
+ const replay = spends.get(request.idempotencyKey);
1010
+ if (replay !== undefined) {
1011
+ return {
1012
+ status: 'debited',
1013
+ entitlementId: replay.entitlementId,
1014
+ debits: [...replay.debits],
1015
+ alreadyProcessed: true,
1016
+ };
1017
+ }
1018
+ if (!currencies.has(request.currency)) {
1019
+ return {
1020
+ status: 'rejected',
1021
+ error: {
1022
+ code: 'ITEM_UNAVAILABLE',
1023
+ retryable: false,
1024
+ message: `Currency ${request.currency} is not declared by the wallet.`,
1025
+ },
1026
+ };
1027
+ }
1028
+ if (!(request.amount > 0)) {
1029
+ return {
1030
+ status: 'rejected',
1031
+ error: {
1032
+ code: 'INVALID_RESPONSE',
1033
+ retryable: false,
1034
+ message: 'Spend amount must be positive.',
1035
+ },
1036
+ };
1037
+ }
1038
+ const candidates = eligibleLots(request.titleId, request.currency, scope);
1039
+ const available = candidates.reduce((sum, lot) => sum + lot.remaining, 0);
1040
+ if (available < request.amount) {
1041
+ return {
1042
+ status: 'rejected',
1043
+ error: {
1044
+ code: 'INSUFFICIENT_BALANCE',
1045
+ retryable: false,
1046
+ message: `Eligible ${request.currency} balance ${available} cannot cover ${request.amount}.`,
1047
+ },
1048
+ };
1049
+ }
1050
+ const spent = [];
1051
+ let remaining = request.amount;
1052
+ for (const lot of candidates) {
1053
+ if (remaining <= 0) {
1054
+ break;
1055
+ }
1056
+ const amount = Math.min(lot.remaining, remaining);
1057
+ lot.remaining -= amount;
1058
+ remaining -= amount;
1059
+ spent.push({ lotId: lot.lotId, amount });
1060
+ debits.push({ idempotencyKey: request.idempotencyKey, lotId: lot.lotId, amount });
1061
+ }
1062
+ const entitlementId = request.idempotencyKey;
1063
+ spends.set(request.idempotencyKey, { entitlementId, debits: spent });
1064
+ if (request.logicalId !== undefined) {
1065
+ entitlements.push({
1066
+ entitlementId,
1067
+ titleId: request.titleId,
1068
+ logicalId: request.logicalId,
1069
+ idempotencyKey: request.idempotencyKey,
1070
+ createdAt: clock.now(),
1071
+ debits: [...spent],
1072
+ redeemed: false,
1073
+ });
1074
+ }
1075
+ return { status: 'debited', entitlementId, debits: [...spent] };
1076
+ };
1077
+ const renew = async (subscription) => {
1078
+ while (subscription.state === 'active' && Date.parse(subscription.expiresAt) <= Date.parse(clock.now())) {
1079
+ const nextPeriod = subscription.periodIndex + 1;
1080
+ const result = await spendScoped({
1081
+ titleId: subscription.titleId,
1082
+ currency: subscription.terms.price.currency,
1083
+ amount: subscription.terms.price.amount,
1084
+ idempotencyKey: `membership:${subscription.subscriptionId}:${nextPeriod}`,
1085
+ }, subscription.terms.scope);
1086
+ if (result.status === 'rejected') {
1087
+ subscription.state = 'lapsed';
1088
+ emitSubscription(subscription, 'subscriptionLapsed', nextPeriod, result.error.code);
1089
+ return;
1090
+ }
1091
+ subscription.periodIndex = nextPeriod;
1092
+ subscription.expiresAt = addIsoDuration(subscription.expiresAt, subscription.terms.period);
1093
+ emitSubscription(subscription, 'subscriptionRenewed', nextPeriod);
1094
+ }
1095
+ };
1096
+ return {
1097
+ store,
1098
+ get lots() {
1099
+ return lots.map(snapshotLot);
1100
+ },
1101
+ get debits() {
1102
+ return [...debits];
1103
+ },
1104
+ get entitlements() {
1105
+ return entitlements.map(toEntitlement);
1106
+ },
1107
+ get subscriptionEvents() {
1108
+ return [...subscriptionEvents];
1109
+ },
1110
+ creditFromGrant(fixture, options = {}) {
1111
+ const existing = lots.find((lot) => lot.lotId === fixture.ledgerEntryId);
1112
+ if (existing !== undefined) {
1113
+ return snapshotLot(existing);
1114
+ }
1115
+ if (!currencies.has(fixture.currency)) {
1116
+ throw new CommerceConformanceScriptError(`Lot ${fixture.ledgerEntryId} uses currency ${fixture.currency}, which the wallet does not declare.`);
1117
+ }
1118
+ const lot = {
1119
+ lotId: fixture.ledgerEntryId,
1120
+ currency: fixture.currency,
1121
+ amount: fixture.amount,
1122
+ remaining: fixture.amount,
1123
+ createdAt: normalizeIsoTimestamp(fixture.createdAt),
1124
+ ...(fixture.expiresAt === undefined
1125
+ ? {}
1126
+ : { expiresAt: normalizeIsoTimestamp(fixture.expiresAt) }),
1127
+ origin: { ...fixture.origin },
1128
+ spendScope: deriveCreditLotSpendScope(fixture.origin, options.spendScope),
1129
+ };
1130
+ lots.push(lot);
1131
+ return snapshotLot(lot);
1132
+ },
1133
+ spend: (request) => spendScoped(request, 'title'),
1134
+ async startMembership(request) {
1135
+ const current = subscriptions.get(request.subscriptionId);
1136
+ if (current !== undefined && current.state === 'active') {
1137
+ return {
1138
+ status: 'started',
1139
+ subscription: toActiveSubscription(current),
1140
+ entitlementId: `membership:${request.subscriptionId}:0`,
1141
+ };
1142
+ }
1143
+ // The terms are validated before any credit moves, so malformed terms never charge.
1144
+ const startedAt = clock.now();
1145
+ let expiresAt;
1146
+ try {
1147
+ expiresAt = addIsoDuration(startedAt, request.terms.period);
1148
+ }
1149
+ catch {
1150
+ return {
1151
+ status: 'rejected',
1152
+ error: {
1153
+ code: 'ITEM_UNAVAILABLE',
1154
+ retryable: false,
1155
+ message: `Membership period ${request.terms.period} is not an ISO-8601 duration.`,
1156
+ },
1157
+ };
1158
+ }
1159
+ const result = await spendScoped({
1160
+ titleId: request.titleId,
1161
+ currency: request.terms.price.currency,
1162
+ amount: request.terms.price.amount,
1163
+ idempotencyKey: `membership:${request.subscriptionId}:0`,
1164
+ logicalId: request.logicalId,
1165
+ }, request.terms.scope);
1166
+ if (result.status === 'rejected') {
1167
+ return result;
1168
+ }
1169
+ const subscription = {
1170
+ subscriptionId: request.subscriptionId,
1171
+ titleId: request.titleId,
1172
+ logicalId: request.logicalId,
1173
+ terms: request.terms,
1174
+ periodIndex: 0,
1175
+ startedAt,
1176
+ expiresAt,
1177
+ state: 'active',
1178
+ };
1179
+ subscriptions.set(subscription.subscriptionId, subscription);
1180
+ emitSubscription(subscription, 'subscriptionStarted', 0);
1181
+ return {
1182
+ status: 'started',
1183
+ subscription: toActiveSubscription(subscription),
1184
+ entitlementId: result.entitlementId,
1185
+ };
1186
+ },
1187
+ async renewDue() {
1188
+ for (const subscription of subscriptions.values()) {
1189
+ await renew(subscription);
1190
+ }
1191
+ },
1192
+ async listOffers() {
1193
+ return [...(input.offers ?? [])];
1194
+ },
1195
+ async listUnredeemed(request) {
1196
+ return entitlements
1197
+ .filter((entitlement) => !entitlement.redeemed && entitlement.titleId === request.titleId)
1198
+ .map(toEntitlement);
1199
+ },
1200
+ async redeem(request) {
1201
+ const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.entitlementId);
1202
+ if (entitlement === undefined) {
1203
+ return { redeemed: false };
1204
+ }
1205
+ entitlement.redeemed = true;
1206
+ return { redeemed: true };
1207
+ },
1208
+ finalize(request) {
1209
+ const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.transactionId);
1210
+ if (entitlement !== undefined) {
1211
+ entitlement.redeemed = true;
1212
+ }
1213
+ },
1214
+ async getBalance(request) {
1215
+ const balances = {};
1216
+ for (const currency of currencies) {
1217
+ balances[currency] = eligibleLots(request.titleId, currency, 'title').reduce((sum, lot) => sum + lot.remaining, 0);
1218
+ }
1219
+ return { titleId: request.titleId, balances };
1220
+ },
1221
+ async getActiveSubscriptions() {
1222
+ return [...subscriptions.values()]
1223
+ .filter((subscription) => subscription.state === 'active')
1224
+ .map(toActiveSubscription);
1225
+ },
1226
+ subscribe(listener) {
1227
+ const first = listeners.size === 0;
1228
+ listeners.add(listener);
1229
+ if (first) {
1230
+ buffer.drain(listener);
1231
+ }
1232
+ return () => {
1233
+ listeners.delete(listener);
1234
+ };
1235
+ },
1236
+ };
1237
+ }
1238
+ /** Store lots are title-scoped, off-store lots cross-title, promotion lots as issued (title by default). */
1239
+ export function deriveCreditLotSpendScope(origin, explicit) {
1240
+ switch (origin.channel) {
1241
+ case 'store':
1242
+ return { kind: 'title', titleId: origin.titleId };
1243
+ case 'off-store':
1244
+ return { kind: 'cross-title' };
1245
+ case 'promotion':
1246
+ return explicit ?? { kind: 'title', titleId: origin.titleId };
1247
+ }
1248
+ }
1249
+ /** RFC spend order: earliest `expiresAt` (none last), `title` before `cross-title`, oldest `createdAt`, then `lotId`. */
1250
+ export function compareCreditLotSpendOrder(left, right) {
1251
+ const leftExpiry = left.expiresAt === undefined
1252
+ ? Number.POSITIVE_INFINITY
1253
+ : Date.parse(left.expiresAt);
1254
+ const rightExpiry = right.expiresAt === undefined
1255
+ ? Number.POSITIVE_INFINITY
1256
+ : Date.parse(right.expiresAt);
1257
+ if (leftExpiry !== rightExpiry) {
1258
+ return leftExpiry < rightExpiry ? -1 : 1;
1259
+ }
1260
+ if (left.spendScope.kind !== right.spendScope.kind) {
1261
+ return left.spendScope.kind === 'title' ? -1 : 1;
1262
+ }
1263
+ const createdDelta = Date.parse(left.createdAt) - Date.parse(right.createdAt);
1264
+ if (createdDelta !== 0) {
1265
+ return createdDelta < 0 ? -1 : 1;
1266
+ }
1267
+ return left.lotId < right.lotId ? -1 : left.lotId > right.lotId ? 1 : 0;
1268
+ }
1269
+ /**
1270
+ * `CommerceProvider` over the reference wallet: `fetchProducts` lists the
1271
+ * wallet's offers (catalog-derived offers only when the wallet lists none),
1272
+ * `requestPurchase` spends the offer price times the requested quantity
1273
+ * (outcome `purchased`, `settlement` set, evidence adapter-settled),
1274
+ * `getAvailablePurchases` lists unredeemed entitlements, and finishing is the
1275
+ * backend's `redeem`. Memberships exist only where the billing authority is
1276
+ * `host`.
1277
+ */
1278
+ export function createCommerceConformanceWalletProvider(input) {
1279
+ const { wallet, catalog, titleId, clock } = input;
1280
+ const store = input.profile.store;
1281
+ const membershipsOffered = input.billingAuthority === 'host';
1282
+ const defaultCurrency = input.currencies[0] ?? 'credits';
1283
+ const capabilities = () => {
1284
+ const base = createCommerceConformanceCapabilities(input.profile);
1285
+ return {
1286
+ ...base,
1287
+ purchase: { ...base.purchase, subscription: base.purchase.subscription && membershipsOffered },
1288
+ };
1289
+ };
1290
+ const toOffer = (entry) => ({
1291
+ logicalId: entry.logicalId,
1292
+ storeSku: entry.storeSku,
1293
+ kind: entry.kind,
1294
+ title: entry.logicalId,
1295
+ description: `Wallet offer ${entry.storeSku}`,
1296
+ // Fixture price for catalog entries without terms: one unit of the first wallet currency.
1297
+ price: entry.membership?.price ?? { currency: defaultCurrency, amount: 1 },
1298
+ ...(entry.membership === undefined ? {} : { membership: entry.membership }),
1299
+ });
1300
+ /** The wallet's own offers decide availability and price; the catalog fills in only when it lists none. */
1301
+ const listOffers = async () => {
1302
+ const offers = await wallet.listOffers({ titleId });
1303
+ return (offers.length === 0 ? catalog.map(toOffer) : offers).filter((offer) => offer.membership === undefined || membershipsOffered);
1304
+ };
1305
+ /** Quantities of purchases made through this provider, so re-listed entitlements keep them. */
1306
+ const quantities = new Map();
1307
+ const toPurchase = (offer, entitlementId, createdAt, quantity) => ({
1308
+ store,
1309
+ transactionId: entitlementId,
1310
+ storeSku: offer.storeSku,
1311
+ logicalId: offer.logicalId,
1312
+ quantity,
1313
+ state: 'purchased',
1314
+ purchasedAt: createdAt,
1315
+ token: entitlementId,
1316
+ evidence: { schema: `fixture.${store}.entitlement.v1`, payload: { entitlementId } },
1317
+ accountBinding: { kind: 'host-identity' },
1318
+ settlement: { ledgerEntryId: entitlementId },
1319
+ });
1320
+ const unavailable = (message) => ({
1321
+ outcome: 'failed',
1322
+ error: new CommerceError('ITEM_UNAVAILABLE', { message }).toCommerceErrorInfo(),
1323
+ });
1324
+ return {
1325
+ store,
1326
+ async getCapabilities() {
1327
+ return capabilities();
1328
+ },
1329
+ async fetchProducts(request) {
1330
+ return (await listOffers())
1331
+ .filter((offer) => matchesFetchProducts(offer, request))
1332
+ .map((offer) => ({
1333
+ logicalId: offer.logicalId,
1334
+ store,
1335
+ storeSku: offer.storeSku,
1336
+ kind: offer.kind,
1337
+ title: offer.title,
1338
+ description: offer.description,
1339
+ price: {
1340
+ formatted: `${offer.price.amount} ${offer.price.currency}`,
1341
+ currencyCode: offer.price.currency,
1342
+ },
1343
+ }));
1344
+ },
1345
+ async requestPurchase(request) {
1346
+ const offer = (await listOffers()).find((candidate) => candidate.logicalId === request.logicalId);
1347
+ if (offer === undefined) {
1348
+ return unavailable(`The wallet offers no product ${request.logicalId}.`);
1349
+ }
1350
+ const quantity = request.quantity ?? 1;
1351
+ if (quantity > 1 && !capabilities().purchase.quantity) {
1352
+ return unavailable('The wallet does not support purchase quantities above 1.');
1353
+ }
1354
+ if (offer.membership !== undefined) {
1355
+ if (quantity > 1) {
1356
+ return unavailable('A membership is purchased one period at a time.');
1357
+ }
1358
+ const started = await wallet.startMembership({
1359
+ subscriptionId: request.idempotencyKey,
1360
+ titleId,
1361
+ logicalId: offer.logicalId,
1362
+ terms: offer.membership,
1363
+ });
1364
+ if (started.status === 'rejected') {
1365
+ return { outcome: 'failed', error: started.error };
1366
+ }
1367
+ quantities.set(started.entitlementId, 1);
1368
+ return {
1369
+ outcome: 'purchased',
1370
+ purchase: toPurchase(offer, started.entitlementId, started.subscription.startedAt, 1),
1371
+ };
1372
+ }
1373
+ const spend = await wallet.spend({
1374
+ titleId,
1375
+ currency: offer.price.currency,
1376
+ amount: offer.price.amount * quantity,
1377
+ idempotencyKey: request.idempotencyKey,
1378
+ logicalId: offer.logicalId,
1379
+ });
1380
+ if (spend.status === 'rejected') {
1381
+ return { outcome: 'failed', error: spend.error };
1382
+ }
1383
+ quantities.set(spend.entitlementId, quantity);
1384
+ return {
1385
+ outcome: 'purchased',
1386
+ purchase: toPurchase(offer, spend.entitlementId, clock.now(), quantity),
1387
+ };
1388
+ },
1389
+ async getAvailablePurchases() {
1390
+ const offers = await listOffers();
1391
+ const unredeemed = await wallet.listUnredeemed({ titleId });
1392
+ return unredeemed.flatMap((entitlement) => {
1393
+ const offer = offers.find((candidate) => candidate.logicalId === entitlement.logicalId);
1394
+ return offer === undefined
1395
+ ? []
1396
+ : [toPurchase(offer, entitlement.entitlementId, entitlement.createdAt, quantities.get(entitlement.entitlementId) ?? 1)];
1397
+ });
1398
+ },
1399
+ async getActiveSubscriptions() {
1400
+ return wallet.getActiveSubscriptions();
1401
+ },
1402
+ subscribe(listener) {
1403
+ return wallet.subscribe(listener);
1404
+ },
1405
+ };
1406
+ }
1407
+ export function createCommerceConformanceHarness(context) {
1408
+ if (context.vector.wallet === undefined) {
1409
+ const store = createCommerceConformanceStore(context);
1410
+ return { provider: store.provider, store };
1411
+ }
1412
+ const wallet = createCommerceConformanceWallet({
1413
+ store: context.profile.store,
1414
+ clock: context.clock,
1415
+ currencies: context.vector.wallet.currencies,
1416
+ });
1417
+ const provider = createCommerceConformanceWalletProvider({
1418
+ wallet,
1419
+ profile: context.profile,
1420
+ catalog: context.catalog,
1421
+ currencies: context.vector.wallet.currencies,
1422
+ billingAuthority: context.vector.wallet.billingAuthority,
1423
+ titleId: context.titleId,
1424
+ clock: context.clock,
1425
+ });
1426
+ return { provider, wallet };
1427
+ }
1428
+ /**
1429
+ * Runs one vector: executes every step, lets the runtime run until idle after
1430
+ * each, then matches `expect`. Matching compares only the listed fields; arrays
1431
+ * compare in order and by length; a listed `journal` is the whole journal.
1432
+ * Any unscripted operation fails the vector at the step that needed it.
1433
+ */
1434
+ export async function runCommerceConformanceVector(vector, options = {}) {
1435
+ const failures = [];
1436
+ const playerId = options.playerId ?? commerceConformanceDefaultPlayerId;
1437
+ const titleId = options.titleId ?? commerceConformanceDefaultTitleId;
1438
+ const clock = createCommerceConformanceClock();
1439
+ const journal = createCommerceConformanceJournal();
1440
+ const harnessRef = {};
1441
+ const backend = createCommerceConformanceBackend({
1442
+ finalize: async (request) => {
1443
+ await harnessRef.current?.store?.finalize?.(request);
1444
+ await harnessRef.current?.wallet?.finalize?.(request);
1445
+ },
1446
+ });
1447
+ const context = {
1448
+ vector,
1449
+ profile: vector.profile,
1450
+ catalog: vector.catalog ?? [],
1451
+ playerId,
1452
+ titleId,
1453
+ clock,
1454
+ backend,
1455
+ journal,
1456
+ };
1457
+ const harness = await (options.createProvider ?? createCommerceConformanceHarness)(context);
1458
+ harnessRef.current = harness;
1459
+ const runtime = createCommerceConformanceRuntime({
1460
+ provider: harness.provider,
1461
+ backend,
1462
+ journal,
1463
+ clock,
1464
+ catalog: context.catalog,
1465
+ playerId,
1466
+ });
1467
+ for (const [index, step] of vector.steps.entries()) {
1468
+ const path = `steps[${index}]`;
1469
+ try {
1470
+ await runStep(step, path, { harness, runtime, backend, clock, failures });
1471
+ await runtime.runUntilIdle();
1472
+ }
1473
+ catch (error) {
1474
+ failures.push({
1475
+ path,
1476
+ expected: `${step.actor}.${step.action} completes`,
1477
+ actual: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
1478
+ });
1479
+ return { vector: vector.vector, passed: false, failures };
1480
+ }
1481
+ }
1482
+ await matchExpectations(vector, { harness, runtime, backend, journal, failures });
1483
+ return { vector: vector.vector, passed: failures.length === 0, failures };
1484
+ }
1485
+ export async function runCommerceConformanceVectors(vectors, options = {}) {
1486
+ const results = [];
1487
+ for (const vector of vectors) {
1488
+ results.push(await runCommerceConformanceVector(vector, options));
1489
+ }
1490
+ return results;
1491
+ }
1492
+ function storeActorOf(context, step) {
1493
+ if (context.harness.store === undefined) {
1494
+ throw new CommerceConformanceScriptError(`The harness has no store actor for ${step.actor}.${step.action}.`);
1495
+ }
1496
+ return context.harness.store;
1497
+ }
1498
+ function walletActorOf(context, step) {
1499
+ if (context.harness.wallet === undefined) {
1500
+ throw new CommerceConformanceScriptError(`The harness has no wallet actor for ${step.actor}.${step.action}.`);
1501
+ }
1502
+ return context.harness.wallet;
1503
+ }
1504
+ async function runStep(step, path, context) {
1505
+ switch (step.actor) {
1506
+ case 'runtime':
1507
+ if (step.action === 'start') {
1508
+ await context.runtime.start();
1509
+ }
1510
+ else if (step.action === 'stop') {
1511
+ await context.runtime.stop();
1512
+ }
1513
+ else {
1514
+ await context.runtime.reconcile();
1515
+ }
1516
+ return;
1517
+ case 'game':
1518
+ if (step.action === 'requestPurchase') {
1519
+ const result = await context.runtime.requestPurchase({
1520
+ logicalId: step.input.logicalId,
1521
+ idempotencyKey: step.input.idempotencyKey,
1522
+ source: step.input.source ?? 'shop',
1523
+ ...(step.input.offerId === undefined ? {} : { offerId: step.input.offerId }),
1524
+ ...(step.input.quantity === undefined ? {} : { quantity: step.input.quantity }),
1525
+ });
1526
+ if (step.returns !== undefined) {
1527
+ matchPartial(step.returns, result, `${path}.returns`, context.failures);
1528
+ }
1529
+ return;
1530
+ }
1531
+ if (step.action === 'fetchProducts') {
1532
+ const products = await context.runtime.fetchProducts(step.input ?? {});
1533
+ if (step.returns !== undefined) {
1534
+ matchPartial(step.returns, { logicalIds: products.map((product) => product.logicalId) }, `${path}.returns`, context.failures);
1535
+ }
1536
+ }
1537
+ return;
1538
+ case 'store': {
1539
+ const store = storeActorOf(context, step);
1540
+ switch (step.action) {
1541
+ case 'complete':
1542
+ store.complete(step.purchase);
1543
+ return;
1544
+ case 'update':
1545
+ store.update(step.purchase);
1546
+ return;
1547
+ case 'error':
1548
+ store.error({
1549
+ ...(step.logicalId === undefined ? {} : { logicalId: step.logicalId }),
1550
+ error: step.error,
1551
+ });
1552
+ return;
1553
+ case 'queueRequest':
1554
+ store.queueRequest(step.result);
1555
+ return;
1556
+ case 'queueFinish':
1557
+ store.queueFinish(step.result);
1558
+ return;
1559
+ }
1560
+ return;
1561
+ }
1562
+ case 'backend':
1563
+ switch (step.action) {
1564
+ case 'queueVerify':
1565
+ context.backend.queueVerify(step.decision);
1566
+ return;
1567
+ case 'push':
1568
+ context.backend.push({
1569
+ transactionId: step.transactionId,
1570
+ entryId: step.entryId,
1571
+ logicalId: step.logicalId,
1572
+ });
1573
+ return;
1574
+ case 'revoke':
1575
+ context.backend.revoke({
1576
+ ledgerEntryId: step.ledgerEntryId,
1577
+ entryId: step.entryId,
1578
+ reason: step.reason,
1579
+ });
1580
+ return;
1581
+ }
1582
+ return;
1583
+ case 'wallet': {
1584
+ const wallet = walletActorOf(context, step);
1585
+ switch (step.action) {
1586
+ case 'creditFromGrant':
1587
+ wallet.creditFromGrant(step.lot);
1588
+ return;
1589
+ case 'spend': {
1590
+ const result = await wallet.spend(step.input);
1591
+ if (step.returns !== undefined) {
1592
+ matchPartial(step.returns, result, `${path}.returns`, context.failures);
1593
+ }
1594
+ return;
1595
+ }
1596
+ case 'renewDue':
1597
+ await wallet.renewDue();
1598
+ return;
1599
+ }
1600
+ return;
1601
+ }
1602
+ case 'clock':
1603
+ context.clock.set(step.at);
1604
+ return;
1605
+ }
1606
+ }
1607
+ async function matchExpectations(vector, context) {
1608
+ const { expect } = vector;
1609
+ const { failures, harness } = context;
1610
+ if (expect.journal !== undefined) {
1611
+ const actual = context.journal.snapshot();
1612
+ const expectedKeys = Object.keys(expect.journal);
1613
+ const actualKeys = Object.keys(actual);
1614
+ if (!sameStrings(expectedKeys, actualKeys)) {
1615
+ failures.push({ path: 'journal', expected: expectedKeys, actual: actualKeys });
1616
+ }
1617
+ for (const key of expectedKeys) {
1618
+ matchPartial(expect.journal[key], actual[key], `journal.${key}`, failures);
1619
+ }
1620
+ }
1621
+ if (expect.verifyRequests !== undefined) {
1622
+ matchPartial(expect.verifyRequests, context.backend.verifyRequests, 'verifyRequests', failures);
1623
+ }
1624
+ if (expect.ledger !== undefined) {
1625
+ matchPartial(expect.ledger, context.backend.ledger, 'ledger', failures);
1626
+ }
1627
+ if (expect.finishCalls !== undefined) {
1628
+ matchPartial(expect.finishCalls, harness.store?.finishCalls ?? [], 'finishCalls', failures);
1629
+ }
1630
+ if (expect.purchaseUiOpened !== undefined) {
1631
+ matchPartial(expect.purchaseUiOpened, harness.store?.purchaseUiOpened ?? 0, 'purchaseUiOpened', failures);
1632
+ }
1633
+ if (expect.available !== undefined) {
1634
+ const available = await harness.provider.getAvailablePurchases({ includePending: true });
1635
+ matchPartial(expect.available, available, 'available', failures);
1636
+ }
1637
+ if (expect.entitlements !== undefined) {
1638
+ matchPartial(expect.entitlements, context.backend.entitlements(), 'entitlements', failures);
1639
+ }
1640
+ if (expect.lots !== undefined) {
1641
+ matchPartial(expect.lots, harness.wallet?.lots ?? [], 'lots', failures);
1642
+ }
1643
+ if (expect.balances !== undefined) {
1644
+ for (const [titleId, expected] of Object.entries(expect.balances)) {
1645
+ const balance = harness.wallet === undefined
1646
+ ? {}
1647
+ : (await harness.wallet.getBalance({ titleId })).balances;
1648
+ matchPartial(expected, balance, `balances.${titleId}`, failures);
1649
+ }
1650
+ }
1651
+ if (expect.debits !== undefined) {
1652
+ matchPartial(expect.debits, harness.wallet?.debits ?? [], 'debits', failures);
1653
+ }
1654
+ if (expect.events !== undefined) {
1655
+ matchPartial(expect.events, harness.wallet?.subscriptionEvents ?? [], 'events', failures);
1656
+ }
1657
+ if (expect.activeSubscriptions !== undefined) {
1658
+ const active = harness.provider.getActiveSubscriptions === undefined
1659
+ ? await (harness.wallet?.getActiveSubscriptions() ?? Promise.resolve([]))
1660
+ : await harness.provider.getActiveSubscriptions();
1661
+ matchPartial(expect.activeSubscriptions, active, 'activeSubscriptions', failures);
1662
+ }
1663
+ }
1664
+ function sameStrings(left, right) {
1665
+ return left.length === right.length && left.every((value) => right.includes(value));
1666
+ }
1667
+ /**
1668
+ * Partial match: an expected object asserts only its listed keys, an expected
1669
+ * array asserts its length and each element in order, and primitives compare
1670
+ * strictly. Failures name the deepest differing path.
1671
+ */
1672
+ export function matchPartial(expected, actual, path, failures) {
1673
+ if (Array.isArray(expected)) {
1674
+ if (!Array.isArray(actual)) {
1675
+ failures.push({ path, expected, actual });
1676
+ return;
1677
+ }
1678
+ if (expected.length !== actual.length) {
1679
+ failures.push({ path: `${path}.length`, expected: expected.length, actual: actual.length });
1680
+ return;
1681
+ }
1682
+ expected.forEach((item, index) => {
1683
+ matchPartial(item, actual[index], `${path}[${index}]`, failures);
1684
+ });
1685
+ return;
1686
+ }
1687
+ if (typeof expected === 'object' && expected !== null) {
1688
+ if (typeof actual !== 'object' || actual === null || Array.isArray(actual)) {
1689
+ failures.push({ path, expected, actual });
1690
+ return;
1691
+ }
1692
+ const record = actual;
1693
+ for (const [key, value] of Object.entries(expected)) {
1694
+ matchPartial(value, record[key], `${path}.${key}`, failures);
1695
+ }
1696
+ return;
1697
+ }
1698
+ if (expected !== actual) {
1699
+ failures.push({ path, expected, actual });
1700
+ }
1701
+ }