@mpgd/platform 0.20.0 → 0.22.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,2087 @@
1
+ import * as _isFormatDateTime_1 from "typia/lib/internal/_isFormatDateTime";
2
+ import * as _isTypeUint32_1 from "typia/lib/internal/_isTypeUint32";
3
+ import * as _assertGuard_1 from "typia/lib/internal/_assertGuard";
4
+ import * as _accessExpressionAsString_1 from "typia/lib/internal/_accessExpressionAsString";
5
+ import typia from 'typia';
6
+ import { assertActiveSubscription, assertCreditEntitlement, assertCreditOffer, assertCreditSpendResult, assertRequestPurchaseSupported, CommerceError, commerceErrorInfoOf, createCommerceEventBuffer, } from './commerce.js';
7
+ export { assertCreditBalance, assertCreditEntitlement, assertCreditLot, assertCreditOffer, assertCreditSpendInput, assertCreditSpendResult, } from './commerce.js';
8
+ // Timestamps and periods.
9
+ /** Canonical `YYYY-MM-DDTHH:mm:ssZ`; milliseconds are kept only when non-zero. */
10
+ export function normalizeIsoTimestamp(input) {
11
+ const millis = Date.parse(input);
12
+ if (Number.isNaN(millis)) {
13
+ throw new TypeError(`Invalid ISO-8601 timestamp: ${input}`);
14
+ }
15
+ return formatIsoTimestamp(millis);
16
+ }
17
+ function formatIsoTimestamp(millis) {
18
+ const iso = new Date(millis).toISOString();
19
+ return iso.endsWith('.000Z') ? `${iso.slice(0, -5)}Z` : iso;
20
+ }
21
+ const isoDurationPattern = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/u;
22
+ /**
23
+ * Parses a positive ISO-8601 duration a membership period accepts (`P30D`,
24
+ * `P1M`, `PT12H`, ...). Zero-length durations (`P0D`, `PT0S`), a bare `P` or
25
+ * `PT`, and a `T` with no time components are rejected, so a period can
26
+ * never renew without time passing.
27
+ */
28
+ export function parseIsoDuration(duration) {
29
+ if (typeof duration !== 'string' || duration.endsWith('T')) {
30
+ return undefined;
31
+ }
32
+ const match = isoDurationPattern.exec(duration);
33
+ if (match === null) {
34
+ return undefined;
35
+ }
36
+ const [, years, months, weeks, days, hours, minutes, seconds] = match;
37
+ const parts = {
38
+ years: Number(years ?? 0),
39
+ months: Number(months ?? 0),
40
+ weeks: Number(weeks ?? 0),
41
+ days: Number(days ?? 0),
42
+ hours: Number(hours ?? 0),
43
+ minutes: Number(minutes ?? 0),
44
+ seconds: Number(seconds ?? 0),
45
+ };
46
+ const total = Object.values(parts).reduce((sum, value) => sum + value, 0);
47
+ return total > 0 && Number.isFinite(total) ? parts : undefined;
48
+ }
49
+ export function isIsoDuration(duration) {
50
+ return parseIsoDuration(duration) !== undefined;
51
+ }
52
+ /**
53
+ * Adds `times` ISO-8601 durations to a timestamp in UTC. Years and months are
54
+ * calendar months with the day clamped to the target month's length
55
+ * (`2026-01-31` + `P1M` is `2026-02-28`), so a membership's periods are
56
+ * anchored on its start date: period `n` ends at `startedAt + (n + 1) × period`
57
+ * rather than drifting from the previous period's end.
58
+ */
59
+ export function addIsoDuration(at, duration, times = 1) {
60
+ const parts = parseIsoDuration(duration);
61
+ if (parts === undefined) {
62
+ throw new TypeError(`Invalid ISO-8601 duration: ${duration}`);
63
+ }
64
+ if (!Number.isInteger(times) || times < 0) {
65
+ throw new TypeError(`Duration multiplier ${String(times)} must be a non-negative integer.`);
66
+ }
67
+ const date = new Date(normalizeIsoTimestamp(at));
68
+ const months = times * (parts.years * 12 + parts.months);
69
+ if (months > 0) {
70
+ const day = date.getUTCDate();
71
+ date.setUTCDate(1);
72
+ date.setUTCMonth(date.getUTCMonth() + months);
73
+ const lastDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0))
74
+ .getUTCDate();
75
+ date.setUTCDate(Math.min(day, lastDay));
76
+ }
77
+ const millis = times * ((parts.weeks * 7 + parts.days) * 86_400_000
78
+ + parts.hours * 3_600_000
79
+ + parts.minutes * 60_000
80
+ + parts.seconds * 1_000);
81
+ return formatIsoTimestamp(date.getTime() + millis);
82
+ }
83
+ // Billing authority.
84
+ /** Profiles whose distribution requires store billing for digital goods; their authority is always `store`. */
85
+ export const storeBillingAuthorityProfiles = Object.freeze(['google-play', 'app-store']);
86
+ /** Profiles whose authority defaults to `host`; every other profile defaults to `store`. */
87
+ export const hostBillingAuthorityProfiles = Object.freeze(['web', 'web-preview']);
88
+ export function isCommerceBillingAuthority(value) {
89
+ return value === 'store' || value === 'host';
90
+ }
91
+ /** The RFC default for a release profile before any project override. */
92
+ export function defaultCommerceBillingAuthority(profile) {
93
+ return hostBillingAuthorityProfiles.includes(profile) ? 'host' : 'store';
94
+ }
95
+ /**
96
+ * Resolves a target's billing authority from its release profile. `google-play`
97
+ * and `app-store` are always `store` and reject a `host` override (D-02: both
98
+ * stores require their own billing for digital entitlements). `web` and
99
+ * `web-preview` default to `host`, every other profile to `store`, and a
100
+ * project may override those after its own policy review.
101
+ */
102
+ export function resolveCommerceBillingAuthority(profile, override) {
103
+ if (typeof profile !== 'string' || profile.length === 0) {
104
+ throw new TypeError('A release profile is required to resolve the billing authority.');
105
+ }
106
+ if (override !== undefined && !isCommerceBillingAuthority(override)) {
107
+ throw new TypeError(`Billing authority override ${String(override)} must be store or host.`);
108
+ }
109
+ if (storeBillingAuthorityProfiles.includes(profile)) {
110
+ if (override === 'host') {
111
+ throw new RangeError(`Release profile ${profile} requires store billing; billing authority host is not allowed.`);
112
+ }
113
+ return 'store';
114
+ }
115
+ return override ?? defaultCommerceBillingAuthority(profile);
116
+ }
117
+ /** Stores whose lots are always title-scoped; neither a registry nor a promotion can make them `cross-title`. */
118
+ export const titleScopedCreditStores = Object.freeze(['google-play', 'app-store']);
119
+ export function isTitleScopedCreditStore(store) {
120
+ return titleScopedCreditStores.includes(store);
121
+ }
122
+ /** Rejects a registry that marks Google Play or the App Store `cross-title`, or holds an unknown scope. */
123
+ export function assertCreditScopeRegistry(registry) {
124
+ if (typeof registry !== 'object' || registry === null || Array.isArray(registry)) {
125
+ throw new TypeError('A credit scope registry must be an object keyed by store id.');
126
+ }
127
+ for (const [store, scope] of Object.entries(registry)) {
128
+ if (scope !== 'title' && scope !== 'cross-title') {
129
+ throw new TypeError(`Credit scope for store ${store} must be title or cross-title.`);
130
+ }
131
+ if (scope === 'cross-title' && isTitleScopedCreditStore(store)) {
132
+ throw new RangeError(`Store ${store} cannot be marked cross-title; its lots stay title-scoped.`);
133
+ }
134
+ }
135
+ return registry;
136
+ }
137
+ export const assertCreditLotSpendScope = (() => {
138
+ const _ao0 = (input, _path, _exceptionable = true) => ("title" === input.kind || _assertGuard_1._assertGuard(_exceptionable, {
139
+ method: "typia.createAssertEquals",
140
+ path: _path + ".kind",
141
+ expected: "\"title\"",
142
+ value: input.kind
143
+ }, _errorFactory)) && ("string" === typeof input.titleId && (1 <= input.titleId.length || _assertGuard_1._assertGuard(_exceptionable, {
144
+ method: "typia.createAssertEquals",
145
+ path: _path + ".titleId",
146
+ expected: "string & CommerceMinLength<1>",
147
+ value: input.titleId
148
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
149
+ method: "typia.createAssertEquals",
150
+ path: _path + ".titleId",
151
+ expected: "(string & CommerceMinLength<1>)",
152
+ value: input.titleId
153
+ }, _errorFactory)) && (2 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
154
+ if (["kind", "titleId"].some(prop => key === prop))
155
+ return true;
156
+ const value = input[key];
157
+ if (undefined === value)
158
+ return true;
159
+ return _assertGuard_1._assertGuard(_exceptionable, {
160
+ method: "typia.createAssertEquals",
161
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
162
+ expected: "undefined",
163
+ value: value
164
+ }, _errorFactory);
165
+ })));
166
+ const _ao1 = (input, _path, _exceptionable = true) => ("cross-title" === input.kind || _assertGuard_1._assertGuard(_exceptionable, {
167
+ method: "typia.createAssertEquals",
168
+ path: _path + ".kind",
169
+ expected: "\"cross-title\"",
170
+ value: input.kind
171
+ }, _errorFactory)) && (1 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
172
+ if (["kind"].some(prop => key === prop))
173
+ return true;
174
+ const value = input[key];
175
+ if (undefined === value)
176
+ return true;
177
+ return _assertGuard_1._assertGuard(_exceptionable, {
178
+ method: "typia.createAssertEquals",
179
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
180
+ expected: "undefined",
181
+ value: value
182
+ }, _errorFactory);
183
+ })));
184
+ const _au0 = (input, _path, _exceptionable = true) => (() => {
185
+ if ("title" === input.kind)
186
+ return _ao0(input, _path, true && _exceptionable);
187
+ else if ("cross-title" === input.kind)
188
+ return _ao1(input, _path, true && _exceptionable);
189
+ else
190
+ return _assertGuard_1._assertGuard(_exceptionable, {
191
+ method: "typia.createAssertEquals",
192
+ path: _path,
193
+ expected: "({ readonly kind: \"title\"; readonly titleId: NonEmptyString; } | { readonly kind: \"cross-title\"; })",
194
+ value: input
195
+ }, _errorFactory);
196
+ })();
197
+ const _io0 = (input, _exceptionable = true) => "title" === input.kind && ("string" === typeof input.titleId && 1 <= input.titleId.length) && (2 === Object.keys(input).length || Object.keys(input).every(key => {
198
+ if (["kind", "titleId"].some(prop => key === prop))
199
+ return true;
200
+ const value = input[key];
201
+ if (undefined === value)
202
+ return true;
203
+ return false;
204
+ }));
205
+ const _io1 = (input, _exceptionable = true) => "cross-title" === input.kind && (1 === Object.keys(input).length || Object.keys(input).every(key => {
206
+ if (["kind"].some(prop => key === prop))
207
+ return true;
208
+ const value = input[key];
209
+ if (undefined === value)
210
+ return true;
211
+ return false;
212
+ }));
213
+ const _iu0 = (input, _exceptionable = true) => (() => {
214
+ if ("title" === input.kind)
215
+ return _io0(input, true && _exceptionable);
216
+ else if ("cross-title" === input.kind)
217
+ return _io1(input, true && _exceptionable);
218
+ else
219
+ return false;
220
+ })();
221
+ const __is = (input, _exceptionable = true) => "object" === typeof input && null !== input && _iu0(input, true);
222
+ let _errorFactory;
223
+ return (input, errorFactory) => {
224
+ if (false === __is(input)) {
225
+ _errorFactory = errorFactory;
226
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _assertGuard_1._assertGuard(true, {
227
+ method: "typia.createAssertEquals",
228
+ path: _path + "",
229
+ expected: "({ readonly kind: \"cross-title\"; } | { readonly kind: \"title\"; readonly titleId: NonEmptyString; })",
230
+ value: input
231
+ }, _errorFactory)) && _au0(input, _path + "", true) || _assertGuard_1._assertGuard(true, {
232
+ method: "typia.createAssertEquals",
233
+ path: _path + "",
234
+ expected: "({ readonly kind: \"cross-title\"; } | { readonly kind: \"title\"; readonly titleId: NonEmptyString; })",
235
+ value: input
236
+ }, _errorFactory))(input, "$input", true);
237
+ }
238
+ return input;
239
+ };
240
+ })();
241
+ /**
242
+ * Derives a lot's `spendScope` from its origin (RFC "Credit lots"): a lot
243
+ * bought through a store is `title`-scoped to its purchasing title, an
244
+ * off-store lot is `cross-title`, and a promotion lot carries the scope it was
245
+ * issued with (`title` by default). A registry entry overrides the channel for
246
+ * store and off-store lots. A lot attributed to Google Play or the App Store is
247
+ * always `title`, whatever the channel, registry, or issued scope says.
248
+ */
249
+ export function deriveCreditLotSpendScope(origin, options = {}) {
250
+ const title = { kind: 'title', titleId: origin.titleId };
251
+ const registry = options.creditScope === undefined
252
+ ? undefined
253
+ : assertCreditScopeRegistry(options.creditScope);
254
+ if (origin.channel === 'promotion') {
255
+ if (options.explicit === undefined) {
256
+ return title;
257
+ }
258
+ const explicit = assertCreditLotSpendScope(options.explicit);
259
+ if (explicit.kind === 'cross-title' && isTitleScopedCreditStore(origin.store)) {
260
+ throw new RangeError(`A promotion lot attributed to ${origin.store} cannot be cross-title.`);
261
+ }
262
+ return { ...explicit };
263
+ }
264
+ if (options.explicit !== undefined) {
265
+ throw new TypeError(`A ${origin.channel} lot derives its spend scope; only promotion lots declare one.`);
266
+ }
267
+ if (isTitleScopedCreditStore(origin.store)) {
268
+ return title;
269
+ }
270
+ const registered = registry !== undefined
271
+ && Object.prototype.hasOwnProperty.call(registry, origin.store)
272
+ ? registry[origin.store]
273
+ : undefined;
274
+ const scope = registered ?? (origin.channel === 'off-store' ? 'cross-title' : 'title');
275
+ return scope === 'cross-title' ? { kind: 'cross-title' } : title;
276
+ }
277
+ // Lot selection and spend order.
278
+ /** RFC spend order: earliest `expiresAt` (none last), `title` before `cross-title`, oldest `createdAt`, then `lotId`. */
279
+ export function compareCreditLotSpendOrder(left, right) {
280
+ const leftExpiry = left.expiresAt === undefined
281
+ ? Number.POSITIVE_INFINITY
282
+ : Date.parse(left.expiresAt);
283
+ const rightExpiry = right.expiresAt === undefined
284
+ ? Number.POSITIVE_INFINITY
285
+ : Date.parse(right.expiresAt);
286
+ if (leftExpiry !== rightExpiry) {
287
+ return leftExpiry < rightExpiry ? -1 : 1;
288
+ }
289
+ if (left.spendScope.kind !== right.spendScope.kind) {
290
+ return left.spendScope.kind === 'title' ? -1 : 1;
291
+ }
292
+ const createdDelta = Date.parse(left.createdAt) - Date.parse(right.createdAt);
293
+ if (createdDelta !== 0) {
294
+ return createdDelta < 0 ? -1 : 1;
295
+ }
296
+ return left.lotId < right.lotId ? -1 : left.lotId > right.lotId ? 1 : 0;
297
+ }
298
+ /** Whether a lot may fund a spend: same currency, credit left, not expired, and in scope. */
299
+ export function isCreditLotEligible(lot, criteria) {
300
+ const now = typeof criteria.now === 'number' ? criteria.now : Date.parse(criteria.now);
301
+ if (Number.isNaN(now)) {
302
+ throw new TypeError(`Invalid ISO-8601 timestamp: ${String(criteria.now)}`);
303
+ }
304
+ if (lot.currency !== criteria.currency || !(lot.remaining > 0)) {
305
+ return false;
306
+ }
307
+ if (lot.expiresAt !== undefined && !(Date.parse(lot.expiresAt) > now)) {
308
+ return false;
309
+ }
310
+ if (criteria.createdBy !== undefined) {
311
+ const createdBy = typeof criteria.createdBy === 'number'
312
+ ? criteria.createdBy
313
+ : Date.parse(criteria.createdBy);
314
+ if (Number.isNaN(createdBy)) {
315
+ throw new TypeError(`Invalid ISO-8601 timestamp: ${String(criteria.createdBy)}`);
316
+ }
317
+ if (!(Date.parse(lot.createdAt) <= createdBy)) {
318
+ return false;
319
+ }
320
+ }
321
+ if (lot.spendScope.kind === 'cross-title') {
322
+ return true;
323
+ }
324
+ return (criteria.scope ?? 'title') === 'title' && lot.spendScope.titleId === criteria.titleId;
325
+ }
326
+ /** The eligible lots of a spend in the order they are consumed; the input is not mutated. */
327
+ export function selectEligibleCreditLots(lots, criteria) {
328
+ return lots.filter((lot) => isCreditLotEligible(lot, criteria)).sort(compareCreditLotSpendOrder);
329
+ }
330
+ export function sumCreditLotRemaining(lots) {
331
+ return lots.reduce((sum, lot) => sum + lot.remaining, 0);
332
+ }
333
+ export function sumCreditDebits(debits) {
334
+ return debits.reduce((sum, debit) => sum + debit.amount, 0);
335
+ }
336
+ /** Tolerance for comparing credit amounts, which the contract allows to be fractional. */
337
+ const creditAmountEpsilon = 1e-9;
338
+ export function sameCreditAmount(left, right) {
339
+ return Math.abs(left - right) <= creditAmountEpsilon;
340
+ }
341
+ /** The whole number of `unit` prices in `amount`, or `undefined` when `amount` is not a positive multiple. */
342
+ export function creditPriceMultiple(amount, unit) {
343
+ if (!(unit > 0) || !(amount > 0) || !Number.isFinite(amount) || !Number.isFinite(unit)) {
344
+ return undefined;
345
+ }
346
+ const multiple = Math.round(amount / unit);
347
+ return multiple >= 1 && sameCreditAmount(multiple * unit, amount) ? multiple : undefined;
348
+ }
349
+ /**
350
+ * Plans the debits of `amount` across eligible lots in spend order without
351
+ * mutating them. A spend the lots cannot cover is rejected with
352
+ * `INSUFFICIENT_BALANCE` and plans nothing.
353
+ */
354
+ export function planCreditDebits(eligible, amount) {
355
+ if (!Number.isFinite(amount) || amount <= 0) {
356
+ return {
357
+ status: 'rejected',
358
+ error: {
359
+ code: 'INVALID_RESPONSE',
360
+ retryable: false,
361
+ message: 'Spend amount must be a positive number.',
362
+ },
363
+ };
364
+ }
365
+ const ordered = [...eligible].sort(compareCreditLotSpendOrder);
366
+ const available = sumCreditLotRemaining(ordered);
367
+ if (available < amount && !sameCreditAmount(available, amount)) {
368
+ return {
369
+ status: 'rejected',
370
+ error: {
371
+ code: 'INSUFFICIENT_BALANCE',
372
+ retryable: false,
373
+ message: `Eligible balance ${available} cannot cover ${amount}.`,
374
+ },
375
+ };
376
+ }
377
+ const debits = [];
378
+ let remaining = amount;
379
+ for (const lot of ordered) {
380
+ if (remaining <= creditAmountEpsilon) {
381
+ break;
382
+ }
383
+ const debit = Math.min(lot.remaining, remaining);
384
+ if (debit <= 0) {
385
+ continue;
386
+ }
387
+ debits.push({ lotId: lot.lotId, amount: debit });
388
+ remaining -= debit;
389
+ }
390
+ return { status: 'debited', debits };
391
+ }
392
+ // Membership keys and subscription events.
393
+ /** Prefix of every scheduled membership debit key; a client spend cannot use it. */
394
+ export const membershipDebitKeyPrefix = 'membership:';
395
+ /** `membership:<subscriptionId>:<periodIndex>`, so a repeated scheduler tick cannot charge a period twice. */
396
+ export function createMembershipRenewalKey(subscriptionId, periodIndex) {
397
+ assertSubscriptionId(subscriptionId);
398
+ assertPeriodIndex(periodIndex);
399
+ return `${membershipDebitKeyPrefix}${subscriptionId}:${periodIndex}`;
400
+ }
401
+ export const creditSubscriptionEventTypes = Object.freeze([
402
+ 'subscriptionStarted',
403
+ 'subscriptionRenewed',
404
+ 'subscriptionLapsed',
405
+ 'subscriptionRevoked',
406
+ ]);
407
+ /** `<subscriptionId>:<periodIndex>:<type>`, so redelivery of a lifecycle event is idempotent. */
408
+ export function createCreditSubscriptionEventId(subscriptionId, periodIndex, type) {
409
+ assertSubscriptionId(subscriptionId);
410
+ assertPeriodIndex(periodIndex);
411
+ if (!creditSubscriptionEventTypes.includes(type)) {
412
+ throw new TypeError(`Unknown subscription event type ${String(type)}.`);
413
+ }
414
+ return `${subscriptionId}:${periodIndex}:${type}`;
415
+ }
416
+ function assertSubscriptionId(subscriptionId) {
417
+ if (typeof subscriptionId !== 'string' || subscriptionId.length === 0) {
418
+ throw new TypeError('A subscription id is required.');
419
+ }
420
+ }
421
+ function assertPeriodIndex(periodIndex) {
422
+ if (!Number.isInteger(periodIndex) || periodIndex < 0) {
423
+ throw new TypeError(`Period index ${String(periodIndex)} must be a non-negative integer.`);
424
+ }
425
+ }
426
+ /** The `CreditSpendResult` a membership start answers a `spend` with. */
427
+ export function creditSpendResultOfMembershipStart(result) {
428
+ if (result.status === 'rejected') {
429
+ return result;
430
+ }
431
+ return {
432
+ status: 'debited',
433
+ entitlementId: result.entitlementId,
434
+ debits: [...result.debits],
435
+ createdAt: result.createdAt,
436
+ ...(result.alreadyProcessed === true ? { alreadyProcessed: true } : {}),
437
+ };
438
+ }
439
+ export const assertCreditLotGrant = (() => {
440
+ const _ao0 = (input, _path, _exceptionable = true) => ("string" === typeof input.ledgerEntryId && (1 <= input.ledgerEntryId.length || _assertGuard_1._assertGuard(_exceptionable, {
441
+ method: "typia.createAssertEquals",
442
+ path: _path + ".ledgerEntryId",
443
+ expected: "string & CommerceMinLength<1>",
444
+ value: input.ledgerEntryId
445
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
446
+ method: "typia.createAssertEquals",
447
+ path: _path + ".ledgerEntryId",
448
+ expected: "(string & CommerceMinLength<1>)",
449
+ value: input.ledgerEntryId
450
+ }, _errorFactory)) && ("string" === typeof input.currency && (1 <= input.currency.length || _assertGuard_1._assertGuard(_exceptionable, {
451
+ method: "typia.createAssertEquals",
452
+ path: _path + ".currency",
453
+ expected: "string & CommerceMinLength<1>",
454
+ value: input.currency
455
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
456
+ method: "typia.createAssertEquals",
457
+ path: _path + ".currency",
458
+ expected: "(string & CommerceMinLength<1>)",
459
+ value: input.currency
460
+ }, _errorFactory)) && ("number" === typeof input.amount && (0 < input.amount || _assertGuard_1._assertGuard(_exceptionable, {
461
+ method: "typia.createAssertEquals",
462
+ path: _path + ".amount",
463
+ expected: "number & ExclusiveMinimum<0>",
464
+ value: input.amount
465
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
466
+ method: "typia.createAssertEquals",
467
+ path: _path + ".amount",
468
+ expected: "(number & ExclusiveMinimum<0>)",
469
+ value: input.amount
470
+ }, _errorFactory)) && (("object" === typeof input.origin && null !== input.origin || _assertGuard_1._assertGuard(_exceptionable, {
471
+ method: "typia.createAssertEquals",
472
+ path: _path + ".origin",
473
+ expected: "CreditLotOrigin",
474
+ value: input.origin
475
+ }, _errorFactory)) && _ao1(input.origin, _path + ".origin", true && _exceptionable) || _assertGuard_1._assertGuard(_exceptionable, {
476
+ method: "typia.createAssertEquals",
477
+ path: _path + ".origin",
478
+ expected: "CreditLotOrigin",
479
+ value: input.origin
480
+ }, _errorFactory)) && ("string" === typeof input.createdAt && (_isFormatDateTime_1._isFormatDateTime(input.createdAt) || _assertGuard_1._assertGuard(_exceptionable, {
481
+ method: "typia.createAssertEquals",
482
+ path: _path + ".createdAt",
483
+ expected: "string & CommerceDateTimeFormat",
484
+ value: input.createdAt
485
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
486
+ method: "typia.createAssertEquals",
487
+ path: _path + ".createdAt",
488
+ expected: "(string & CommerceDateTimeFormat)",
489
+ value: input.createdAt
490
+ }, _errorFactory)) && (undefined === input.expiresAt || "string" === typeof input.expiresAt && (_isFormatDateTime_1._isFormatDateTime(input.expiresAt) || _assertGuard_1._assertGuard(_exceptionable, {
491
+ method: "typia.createAssertEquals",
492
+ path: _path + ".expiresAt",
493
+ expected: "string & CommerceDateTimeFormat",
494
+ value: input.expiresAt
495
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
496
+ method: "typia.createAssertEquals",
497
+ path: _path + ".expiresAt",
498
+ expected: "((string & CommerceDateTimeFormat) | undefined)",
499
+ value: input.expiresAt
500
+ }, _errorFactory)) && (5 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
501
+ if (["ledgerEntryId", "currency", "amount", "origin", "createdAt", "expiresAt"].some(prop => key === prop))
502
+ return true;
503
+ const value = input[key];
504
+ if (undefined === value)
505
+ return true;
506
+ return _assertGuard_1._assertGuard(_exceptionable, {
507
+ method: "typia.createAssertEquals",
508
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
509
+ expected: "undefined",
510
+ value: value
511
+ }, _errorFactory);
512
+ })));
513
+ const _ao1 = (input, _path, _exceptionable = true) => ("off-store" === input.channel || "promotion" === input.channel || "store" === input.channel || _assertGuard_1._assertGuard(_exceptionable, {
514
+ method: "typia.createAssertEquals",
515
+ path: _path + ".channel",
516
+ expected: "(\"off-store\" | \"promotion\" | \"store\")",
517
+ value: input.channel
518
+ }, _errorFactory)) && ("string" === typeof input.store && (1 <= input.store.length || _assertGuard_1._assertGuard(_exceptionable, {
519
+ method: "typia.createAssertEquals",
520
+ path: _path + ".store",
521
+ expected: "string & CommerceMinLength<1>",
522
+ value: input.store
523
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
524
+ method: "typia.createAssertEquals",
525
+ path: _path + ".store",
526
+ expected: "(string & CommerceMinLength<1>)",
527
+ value: input.store
528
+ }, _errorFactory)) && ("string" === typeof input.titleId && (1 <= input.titleId.length || _assertGuard_1._assertGuard(_exceptionable, {
529
+ method: "typia.createAssertEquals",
530
+ path: _path + ".titleId",
531
+ expected: "string & CommerceMinLength<1>",
532
+ value: input.titleId
533
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
534
+ method: "typia.createAssertEquals",
535
+ path: _path + ".titleId",
536
+ expected: "(string & CommerceMinLength<1>)",
537
+ value: input.titleId
538
+ }, _errorFactory)) && (3 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
539
+ if (["channel", "store", "titleId"].some(prop => key === prop))
540
+ return true;
541
+ const value = input[key];
542
+ if (undefined === value)
543
+ return true;
544
+ return _assertGuard_1._assertGuard(_exceptionable, {
545
+ method: "typia.createAssertEquals",
546
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
547
+ expected: "undefined",
548
+ value: value
549
+ }, _errorFactory);
550
+ })));
551
+ const _io0 = (input, _exceptionable = true) => "string" === typeof input.ledgerEntryId && 1 <= input.ledgerEntryId.length && ("string" === typeof input.currency && 1 <= input.currency.length) && ("number" === typeof input.amount && 0 < input.amount) && ("object" === typeof input.origin && null !== input.origin && _io1(input.origin, true && _exceptionable)) && ("string" === typeof input.createdAt && _isFormatDateTime_1._isFormatDateTime(input.createdAt)) && (undefined === input.expiresAt || "string" === typeof input.expiresAt && _isFormatDateTime_1._isFormatDateTime(input.expiresAt)) && (5 === Object.keys(input).length || Object.keys(input).every(key => {
552
+ if (["ledgerEntryId", "currency", "amount", "origin", "createdAt", "expiresAt"].some(prop => key === prop))
553
+ return true;
554
+ const value = input[key];
555
+ if (undefined === value)
556
+ return true;
557
+ return false;
558
+ }));
559
+ const _io1 = (input, _exceptionable = true) => ("off-store" === input.channel || "promotion" === input.channel || "store" === input.channel) && ("string" === typeof input.store && 1 <= input.store.length) && ("string" === typeof input.titleId && 1 <= input.titleId.length) && (3 === Object.keys(input).length || Object.keys(input).every(key => {
560
+ if (["channel", "store", "titleId"].some(prop => key === prop))
561
+ return true;
562
+ const value = input[key];
563
+ if (undefined === value)
564
+ return true;
565
+ return false;
566
+ }));
567
+ const __is = (input, _exceptionable = true) => "object" === typeof input && null !== input && _io0(input, true);
568
+ let _errorFactory;
569
+ return (input, errorFactory) => {
570
+ if (false === __is(input)) {
571
+ _errorFactory = errorFactory;
572
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _assertGuard_1._assertGuard(true, {
573
+ method: "typia.createAssertEquals",
574
+ path: _path + "",
575
+ expected: "CreditLotGrant",
576
+ value: input
577
+ }, _errorFactory)) && _ao0(input, _path + "", true) || _assertGuard_1._assertGuard(true, {
578
+ method: "typia.createAssertEquals",
579
+ path: _path + "",
580
+ expected: "CreditLotGrant",
581
+ value: input
582
+ }, _errorFactory))(input, "$input", true);
583
+ }
584
+ return input;
585
+ };
586
+ })();
587
+ export const assertCreditMembershipTerms = (() => {
588
+ const _ao0 = (input, _path, _exceptionable = true) => ("platform" === input.scope || "title" === input.scope || _assertGuard_1._assertGuard(_exceptionable, {
589
+ method: "typia.createAssertEquals",
590
+ path: _path + ".scope",
591
+ expected: "(\"platform\" | \"title\")",
592
+ value: input.scope
593
+ }, _errorFactory)) && (("object" === typeof input.price && null !== input.price || _assertGuard_1._assertGuard(_exceptionable, {
594
+ method: "typia.createAssertEquals",
595
+ path: _path + ".price",
596
+ expected: "CreditPrice",
597
+ value: input.price
598
+ }, _errorFactory)) && _ao1(input.price, _path + ".price", true && _exceptionable) || _assertGuard_1._assertGuard(_exceptionable, {
599
+ method: "typia.createAssertEquals",
600
+ path: _path + ".price",
601
+ expected: "CreditPrice",
602
+ value: input.price
603
+ }, _errorFactory)) && ("string" === typeof input.period && (1 <= input.period.length || _assertGuard_1._assertGuard(_exceptionable, {
604
+ method: "typia.createAssertEquals",
605
+ path: _path + ".period",
606
+ expected: "string & CommerceMinLength<1>",
607
+ value: input.period
608
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
609
+ method: "typia.createAssertEquals",
610
+ path: _path + ".period",
611
+ expected: "(string & CommerceMinLength<1>)",
612
+ value: input.period
613
+ }, _errorFactory)) && (3 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
614
+ if (["scope", "price", "period"].some(prop => key === prop))
615
+ return true;
616
+ const value = input[key];
617
+ if (undefined === value)
618
+ return true;
619
+ return _assertGuard_1._assertGuard(_exceptionable, {
620
+ method: "typia.createAssertEquals",
621
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
622
+ expected: "undefined",
623
+ value: value
624
+ }, _errorFactory);
625
+ })));
626
+ const _ao1 = (input, _path, _exceptionable = true) => ("string" === typeof input.currency && (1 <= input.currency.length || _assertGuard_1._assertGuard(_exceptionable, {
627
+ method: "typia.createAssertEquals",
628
+ path: _path + ".currency",
629
+ expected: "string & CommerceMinLength<1>",
630
+ value: input.currency
631
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
632
+ method: "typia.createAssertEquals",
633
+ path: _path + ".currency",
634
+ expected: "(string & CommerceMinLength<1>)",
635
+ value: input.currency
636
+ }, _errorFactory)) && ("number" === typeof input.amount && (0 < input.amount || _assertGuard_1._assertGuard(_exceptionable, {
637
+ method: "typia.createAssertEquals",
638
+ path: _path + ".amount",
639
+ expected: "number & ExclusiveMinimum<0>",
640
+ value: input.amount
641
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
642
+ method: "typia.createAssertEquals",
643
+ path: _path + ".amount",
644
+ expected: "(number & ExclusiveMinimum<0>)",
645
+ value: input.amount
646
+ }, _errorFactory)) && (2 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
647
+ if (["currency", "amount"].some(prop => key === prop))
648
+ return true;
649
+ const value = input[key];
650
+ if (undefined === value)
651
+ return true;
652
+ return _assertGuard_1._assertGuard(_exceptionable, {
653
+ method: "typia.createAssertEquals",
654
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
655
+ expected: "undefined",
656
+ value: value
657
+ }, _errorFactory);
658
+ })));
659
+ const _io0 = (input, _exceptionable = true) => ("platform" === input.scope || "title" === input.scope) && ("object" === typeof input.price && null !== input.price && _io1(input.price, true && _exceptionable)) && ("string" === typeof input.period && 1 <= input.period.length) && (3 === Object.keys(input).length || Object.keys(input).every(key => {
660
+ if (["scope", "price", "period"].some(prop => key === prop))
661
+ return true;
662
+ const value = input[key];
663
+ if (undefined === value)
664
+ return true;
665
+ return false;
666
+ }));
667
+ const _io1 = (input, _exceptionable = true) => "string" === typeof input.currency && 1 <= input.currency.length && ("number" === typeof input.amount && 0 < input.amount) && (2 === Object.keys(input).length || Object.keys(input).every(key => {
668
+ if (["currency", "amount"].some(prop => key === prop))
669
+ return true;
670
+ const value = input[key];
671
+ if (undefined === value)
672
+ return true;
673
+ return false;
674
+ }));
675
+ const __is = (input, _exceptionable = true) => "object" === typeof input && null !== input && _io0(input, true);
676
+ let _errorFactory;
677
+ return (input, errorFactory) => {
678
+ if (false === __is(input)) {
679
+ _errorFactory = errorFactory;
680
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _assertGuard_1._assertGuard(true, {
681
+ method: "typia.createAssertEquals",
682
+ path: _path + "",
683
+ expected: "CreditMembershipTerms",
684
+ value: input
685
+ }, _errorFactory)) && _ao0(input, _path + "", true) || _assertGuard_1._assertGuard(true, {
686
+ method: "typia.createAssertEquals",
687
+ path: _path + "",
688
+ expected: "CreditMembershipTerms",
689
+ value: input
690
+ }, _errorFactory))(input, "$input", true);
691
+ }
692
+ return input;
693
+ };
694
+ })();
695
+ export const assertCreditRedeemInput = (() => {
696
+ const _ao0 = (input, _path, _exceptionable = true) => ("string" === typeof input.entitlementId && (1 <= input.entitlementId.length || _assertGuard_1._assertGuard(_exceptionable, {
697
+ method: "typia.createAssertEquals",
698
+ path: _path + ".entitlementId",
699
+ expected: "string & CommerceMinLength<1>",
700
+ value: input.entitlementId
701
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
702
+ method: "typia.createAssertEquals",
703
+ path: _path + ".entitlementId",
704
+ expected: "(string & CommerceMinLength<1>)",
705
+ value: input.entitlementId
706
+ }, _errorFactory)) && ("string" === typeof input.ledgerEntryId && (1 <= input.ledgerEntryId.length || _assertGuard_1._assertGuard(_exceptionable, {
707
+ method: "typia.createAssertEquals",
708
+ path: _path + ".ledgerEntryId",
709
+ expected: "string & CommerceMinLength<1>",
710
+ value: input.ledgerEntryId
711
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
712
+ method: "typia.createAssertEquals",
713
+ path: _path + ".ledgerEntryId",
714
+ expected: "(string & CommerceMinLength<1>)",
715
+ value: input.ledgerEntryId
716
+ }, _errorFactory)) && (2 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
717
+ if (["entitlementId", "ledgerEntryId"].some(prop => key === prop))
718
+ return true;
719
+ const value = input[key];
720
+ if (undefined === value)
721
+ return true;
722
+ return _assertGuard_1._assertGuard(_exceptionable, {
723
+ method: "typia.createAssertEquals",
724
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
725
+ expected: "undefined",
726
+ value: value
727
+ }, _errorFactory);
728
+ })));
729
+ const _io0 = (input, _exceptionable = true) => "string" === typeof input.entitlementId && 1 <= input.entitlementId.length && ("string" === typeof input.ledgerEntryId && 1 <= input.ledgerEntryId.length) && (2 === Object.keys(input).length || Object.keys(input).every(key => {
730
+ if (["entitlementId", "ledgerEntryId"].some(prop => key === prop))
731
+ return true;
732
+ const value = input[key];
733
+ if (undefined === value)
734
+ return true;
735
+ return false;
736
+ }));
737
+ const __is = (input, _exceptionable = true) => "object" === typeof input && null !== input && _io0(input, true);
738
+ let _errorFactory;
739
+ return (input, errorFactory) => {
740
+ if (false === __is(input)) {
741
+ _errorFactory = errorFactory;
742
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _assertGuard_1._assertGuard(true, {
743
+ method: "typia.createAssertEquals",
744
+ path: _path + "",
745
+ expected: "CreditRedeemInput",
746
+ value: input
747
+ }, _errorFactory)) && _ao0(input, _path + "", true) || _assertGuard_1._assertGuard(true, {
748
+ method: "typia.createAssertEquals",
749
+ path: _path + "",
750
+ expected: "CreditRedeemInput",
751
+ value: input
752
+ }, _errorFactory))(input, "$input", true);
753
+ }
754
+ return input;
755
+ };
756
+ })();
757
+ export const assertCreditSubscriptionEvent = (() => {
758
+ const _iv0 = new Set(["ACCOUNT_BINDING_REQUIRED", "ACTION_REQUIRED", "ALREADY_OWNED", "CONFIGURATION_REQUIRED", "INSUFFICIENT_BALANCE", "INVALID_RESPONSE", "IN_PROGRESS", "ITEM_UNAVAILABLE", "NETWORK", "NOT_PREPARED", "OUTCOME_UNKNOWN", "STORE_UNAVAILABLE", "UNKNOWN", "USER_CANCELLED"]);
759
+ const _av0 = new Set(["ACCOUNT_BINDING_REQUIRED", "ACTION_REQUIRED", "ALREADY_OWNED", "CONFIGURATION_REQUIRED", "INSUFFICIENT_BALANCE", "INVALID_RESPONSE", "IN_PROGRESS", "ITEM_UNAVAILABLE", "NETWORK", "NOT_PREPARED", "OUTCOME_UNKNOWN", "STORE_UNAVAILABLE", "UNKNOWN", "USER_CANCELLED"]);
760
+ const _ae0 = "(\"ACCOUNT_BINDING_REQUIRED\" | \"ACTION_REQUIRED\" | \"ALREADY_OWNED\" | \"CONFIGURATION_REQUIRED\" | \"INSUFFICIENT_BALANCE\" | \"INVALID_RESPONSE\" | \"IN_PROGRESS\" | \"ITEM_UNAVAILABLE\" | \"NETWORK\" | \"NOT_PREPARED\" | \"OUTCOME_UNKNOWN\" | \"STORE_UNAVAILABLE\" | \"UNKNOWN\" | \"USER_CANCELLED\" | undefined)";
761
+ const _ao0 = (input, _path, _exceptionable = true) => ("string" === typeof input.eventId && (1 <= input.eventId.length || _assertGuard_1._assertGuard(_exceptionable, {
762
+ method: "typia.createAssertEquals",
763
+ path: _path + ".eventId",
764
+ expected: "string & CommerceMinLength<1>",
765
+ value: input.eventId
766
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
767
+ method: "typia.createAssertEquals",
768
+ path: _path + ".eventId",
769
+ expected: "(string & CommerceMinLength<1>)",
770
+ value: input.eventId
771
+ }, _errorFactory)) && ("subscriptionLapsed" === input.type || "subscriptionRenewed" === input.type || "subscriptionRevoked" === input.type || "subscriptionStarted" === input.type || _assertGuard_1._assertGuard(_exceptionable, {
772
+ method: "typia.createAssertEquals",
773
+ path: _path + ".type",
774
+ expected: "(\"subscriptionLapsed\" | \"subscriptionRenewed\" | \"subscriptionRevoked\" | \"subscriptionStarted\")",
775
+ value: input.type
776
+ }, _errorFactory)) && ("string" === typeof input.subscriptionId && (1 <= input.subscriptionId.length || _assertGuard_1._assertGuard(_exceptionable, {
777
+ method: "typia.createAssertEquals",
778
+ path: _path + ".subscriptionId",
779
+ expected: "string & CommerceMinLength<1>",
780
+ value: input.subscriptionId
781
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
782
+ method: "typia.createAssertEquals",
783
+ path: _path + ".subscriptionId",
784
+ expected: "(string & CommerceMinLength<1>)",
785
+ value: input.subscriptionId
786
+ }, _errorFactory)) && ("string" === typeof input.logicalId && (1 <= input.logicalId.length || _assertGuard_1._assertGuard(_exceptionable, {
787
+ method: "typia.createAssertEquals",
788
+ path: _path + ".logicalId",
789
+ expected: "string & CommerceMinLength<1>",
790
+ value: input.logicalId
791
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
792
+ method: "typia.createAssertEquals",
793
+ path: _path + ".logicalId",
794
+ expected: "(string & CommerceMinLength<1>)",
795
+ value: input.logicalId
796
+ }, _errorFactory)) && ("number" === typeof input.periodIndex && (_isTypeUint32_1._isTypeUint32(input.periodIndex) || _assertGuard_1._assertGuard(_exceptionable, {
797
+ method: "typia.createAssertEquals",
798
+ path: _path + ".periodIndex",
799
+ expected: "number & CommerceUint32",
800
+ value: input.periodIndex
801
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
802
+ method: "typia.createAssertEquals",
803
+ path: _path + ".periodIndex",
804
+ expected: "(number & CommerceUint32)",
805
+ value: input.periodIndex
806
+ }, _errorFactory)) && ("string" === typeof input.expiresAt && (_isFormatDateTime_1._isFormatDateTime(input.expiresAt) || _assertGuard_1._assertGuard(_exceptionable, {
807
+ method: "typia.createAssertEquals",
808
+ path: _path + ".expiresAt",
809
+ expected: "string & CommerceDateTimeFormat",
810
+ value: input.expiresAt
811
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
812
+ method: "typia.createAssertEquals",
813
+ path: _path + ".expiresAt",
814
+ expected: "(string & CommerceDateTimeFormat)",
815
+ value: input.expiresAt
816
+ }, _errorFactory)) && (undefined === input.reason || true === _av0.has(input.reason) || _assertGuard_1._assertGuard(_exceptionable, {
817
+ method: "typia.createAssertEquals",
818
+ path: _path + ".reason",
819
+ expected: _ae0,
820
+ value: input.reason
821
+ }, _errorFactory)) && (6 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
822
+ if (["eventId", "type", "subscriptionId", "logicalId", "periodIndex", "expiresAt", "reason"].some(prop => key === prop))
823
+ return true;
824
+ const value = input[key];
825
+ if (undefined === value)
826
+ return true;
827
+ return _assertGuard_1._assertGuard(_exceptionable, {
828
+ method: "typia.createAssertEquals",
829
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
830
+ expected: "undefined",
831
+ value: value
832
+ }, _errorFactory);
833
+ })));
834
+ const _io0 = (input, _exceptionable = true) => "string" === typeof input.eventId && 1 <= input.eventId.length && ("subscriptionLapsed" === input.type || "subscriptionRenewed" === input.type || "subscriptionRevoked" === input.type || "subscriptionStarted" === input.type) && ("string" === typeof input.subscriptionId && 1 <= input.subscriptionId.length) && ("string" === typeof input.logicalId && 1 <= input.logicalId.length) && ("number" === typeof input.periodIndex && _isTypeUint32_1._isTypeUint32(input.periodIndex)) && ("string" === typeof input.expiresAt && _isFormatDateTime_1._isFormatDateTime(input.expiresAt)) && (undefined === input.reason || true === _iv0.has(input.reason)) && (6 === Object.keys(input).length || Object.keys(input).every(key => {
835
+ if (["eventId", "type", "subscriptionId", "logicalId", "periodIndex", "expiresAt", "reason"].some(prop => key === prop))
836
+ return true;
837
+ const value = input[key];
838
+ if (undefined === value)
839
+ return true;
840
+ return false;
841
+ }));
842
+ const __is = (input, _exceptionable = true) => "object" === typeof input && null !== input && _io0(input, true);
843
+ let _errorFactory;
844
+ return (input, errorFactory) => {
845
+ if (false === __is(input)) {
846
+ _errorFactory = errorFactory;
847
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _assertGuard_1._assertGuard(true, {
848
+ method: "typia.createAssertEquals",
849
+ path: _path + "",
850
+ expected: "CreditSubscriptionEvent",
851
+ value: input
852
+ }, _errorFactory)) && _ao0(input, _path + "", true) || _assertGuard_1._assertGuard(true, {
853
+ method: "typia.createAssertEquals",
854
+ path: _path + "",
855
+ expected: "CreditSubscriptionEvent",
856
+ value: input
857
+ }, _errorFactory))(input, "$input", true);
858
+ }
859
+ return input;
860
+ };
861
+ })();
862
+ export const assertCreditMembershipStartInput = (() => {
863
+ const _ao0 = (input, _path, _exceptionable = true) => ("string" === typeof input.subscriptionId && (1 <= input.subscriptionId.length || _assertGuard_1._assertGuard(_exceptionable, {
864
+ method: "typia.createAssertEquals",
865
+ path: _path + ".subscriptionId",
866
+ expected: "string & CommerceMinLength<1>",
867
+ value: input.subscriptionId
868
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
869
+ method: "typia.createAssertEquals",
870
+ path: _path + ".subscriptionId",
871
+ expected: "(string & CommerceMinLength<1>)",
872
+ value: input.subscriptionId
873
+ }, _errorFactory)) && ("string" === typeof input.titleId && (1 <= input.titleId.length || _assertGuard_1._assertGuard(_exceptionable, {
874
+ method: "typia.createAssertEquals",
875
+ path: _path + ".titleId",
876
+ expected: "string & CommerceMinLength<1>",
877
+ value: input.titleId
878
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
879
+ method: "typia.createAssertEquals",
880
+ path: _path + ".titleId",
881
+ expected: "(string & CommerceMinLength<1>)",
882
+ value: input.titleId
883
+ }, _errorFactory)) && ("string" === typeof input.logicalId && (1 <= input.logicalId.length || _assertGuard_1._assertGuard(_exceptionable, {
884
+ method: "typia.createAssertEquals",
885
+ path: _path + ".logicalId",
886
+ expected: "string & CommerceMinLength<1>",
887
+ value: input.logicalId
888
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
889
+ method: "typia.createAssertEquals",
890
+ path: _path + ".logicalId",
891
+ expected: "(string & CommerceMinLength<1>)",
892
+ value: input.logicalId
893
+ }, _errorFactory)) && (("object" === typeof input.terms && null !== input.terms || _assertGuard_1._assertGuard(_exceptionable, {
894
+ method: "typia.createAssertEquals",
895
+ path: _path + ".terms",
896
+ expected: "CreditMembershipTerms",
897
+ value: input.terms
898
+ }, _errorFactory)) && _ao1(input.terms, _path + ".terms", true && _exceptionable) || _assertGuard_1._assertGuard(_exceptionable, {
899
+ method: "typia.createAssertEquals",
900
+ path: _path + ".terms",
901
+ expected: "CreditMembershipTerms",
902
+ value: input.terms
903
+ }, _errorFactory)) && (4 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
904
+ if (["subscriptionId", "titleId", "logicalId", "terms"].some(prop => key === prop))
905
+ return true;
906
+ const value = input[key];
907
+ if (undefined === value)
908
+ return true;
909
+ return _assertGuard_1._assertGuard(_exceptionable, {
910
+ method: "typia.createAssertEquals",
911
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
912
+ expected: "undefined",
913
+ value: value
914
+ }, _errorFactory);
915
+ })));
916
+ const _ao1 = (input, _path, _exceptionable = true) => ("platform" === input.scope || "title" === input.scope || _assertGuard_1._assertGuard(_exceptionable, {
917
+ method: "typia.createAssertEquals",
918
+ path: _path + ".scope",
919
+ expected: "(\"platform\" | \"title\")",
920
+ value: input.scope
921
+ }, _errorFactory)) && (("object" === typeof input.price && null !== input.price || _assertGuard_1._assertGuard(_exceptionable, {
922
+ method: "typia.createAssertEquals",
923
+ path: _path + ".price",
924
+ expected: "CreditPrice",
925
+ value: input.price
926
+ }, _errorFactory)) && _ao2(input.price, _path + ".price", true && _exceptionable) || _assertGuard_1._assertGuard(_exceptionable, {
927
+ method: "typia.createAssertEquals",
928
+ path: _path + ".price",
929
+ expected: "CreditPrice",
930
+ value: input.price
931
+ }, _errorFactory)) && ("string" === typeof input.period && (1 <= input.period.length || _assertGuard_1._assertGuard(_exceptionable, {
932
+ method: "typia.createAssertEquals",
933
+ path: _path + ".period",
934
+ expected: "string & CommerceMinLength<1>",
935
+ value: input.period
936
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
937
+ method: "typia.createAssertEquals",
938
+ path: _path + ".period",
939
+ expected: "(string & CommerceMinLength<1>)",
940
+ value: input.period
941
+ }, _errorFactory)) && (3 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
942
+ if (["scope", "price", "period"].some(prop => key === prop))
943
+ return true;
944
+ const value = input[key];
945
+ if (undefined === value)
946
+ return true;
947
+ return _assertGuard_1._assertGuard(_exceptionable, {
948
+ method: "typia.createAssertEquals",
949
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
950
+ expected: "undefined",
951
+ value: value
952
+ }, _errorFactory);
953
+ })));
954
+ const _ao2 = (input, _path, _exceptionable = true) => ("string" === typeof input.currency && (1 <= input.currency.length || _assertGuard_1._assertGuard(_exceptionable, {
955
+ method: "typia.createAssertEquals",
956
+ path: _path + ".currency",
957
+ expected: "string & CommerceMinLength<1>",
958
+ value: input.currency
959
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
960
+ method: "typia.createAssertEquals",
961
+ path: _path + ".currency",
962
+ expected: "(string & CommerceMinLength<1>)",
963
+ value: input.currency
964
+ }, _errorFactory)) && ("number" === typeof input.amount && (0 < input.amount || _assertGuard_1._assertGuard(_exceptionable, {
965
+ method: "typia.createAssertEquals",
966
+ path: _path + ".amount",
967
+ expected: "number & ExclusiveMinimum<0>",
968
+ value: input.amount
969
+ }, _errorFactory)) || _assertGuard_1._assertGuard(_exceptionable, {
970
+ method: "typia.createAssertEquals",
971
+ path: _path + ".amount",
972
+ expected: "(number & ExclusiveMinimum<0>)",
973
+ value: input.amount
974
+ }, _errorFactory)) && (2 === Object.keys(input).length || (false === _exceptionable || Object.keys(input).every(key => {
975
+ if (["currency", "amount"].some(prop => key === prop))
976
+ return true;
977
+ const value = input[key];
978
+ if (undefined === value)
979
+ return true;
980
+ return _assertGuard_1._assertGuard(_exceptionable, {
981
+ method: "typia.createAssertEquals",
982
+ path: _path + _accessExpressionAsString_1._accessExpressionAsString(key),
983
+ expected: "undefined",
984
+ value: value
985
+ }, _errorFactory);
986
+ })));
987
+ const _io0 = (input, _exceptionable = true) => "string" === typeof input.subscriptionId && 1 <= input.subscriptionId.length && ("string" === typeof input.titleId && 1 <= input.titleId.length) && ("string" === typeof input.logicalId && 1 <= input.logicalId.length) && ("object" === typeof input.terms && null !== input.terms && _io1(input.terms, true && _exceptionable)) && (4 === Object.keys(input).length || Object.keys(input).every(key => {
988
+ if (["subscriptionId", "titleId", "logicalId", "terms"].some(prop => key === prop))
989
+ return true;
990
+ const value = input[key];
991
+ if (undefined === value)
992
+ return true;
993
+ return false;
994
+ }));
995
+ const _io1 = (input, _exceptionable = true) => ("platform" === input.scope || "title" === input.scope) && ("object" === typeof input.price && null !== input.price && _io2(input.price, true && _exceptionable)) && ("string" === typeof input.period && 1 <= input.period.length) && (3 === Object.keys(input).length || Object.keys(input).every(key => {
996
+ if (["scope", "price", "period"].some(prop => key === prop))
997
+ return true;
998
+ const value = input[key];
999
+ if (undefined === value)
1000
+ return true;
1001
+ return false;
1002
+ }));
1003
+ const _io2 = (input, _exceptionable = true) => "string" === typeof input.currency && 1 <= input.currency.length && ("number" === typeof input.amount && 0 < input.amount) && (2 === Object.keys(input).length || Object.keys(input).every(key => {
1004
+ if (["currency", "amount"].some(prop => key === prop))
1005
+ return true;
1006
+ const value = input[key];
1007
+ if (undefined === value)
1008
+ return true;
1009
+ return false;
1010
+ }));
1011
+ const __is = (input, _exceptionable = true) => "object" === typeof input && null !== input && _io0(input, true);
1012
+ let _errorFactory;
1013
+ return (input, errorFactory) => {
1014
+ if (false === __is(input)) {
1015
+ _errorFactory = errorFactory;
1016
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _assertGuard_1._assertGuard(true, {
1017
+ method: "typia.createAssertEquals",
1018
+ path: _path + "",
1019
+ expected: "CreditMembershipStartInput",
1020
+ value: input
1021
+ }, _errorFactory)) && _ao0(input, _path + "", true) || _assertGuard_1._assertGuard(true, {
1022
+ method: "typia.createAssertEquals",
1023
+ path: _path + "",
1024
+ expected: "CreditMembershipStartInput",
1025
+ value: input
1026
+ }, _errorFactory))(input, "$input", true);
1027
+ }
1028
+ return input;
1029
+ };
1030
+ })();
1031
+ /**
1032
+ * `assertCreditOffer` plus the wallet rules an offer must satisfy: membership
1033
+ * terms only on a `subscription` offer, every `subscription` offer with terms,
1034
+ * a valid positive period, and a membership priced as its terms say.
1035
+ */
1036
+ export function assertCreditWalletOffer(input) {
1037
+ const offer = assertCreditOffer(input);
1038
+ if (offer.membership !== undefined && offer.kind !== 'subscription') {
1039
+ throw new TypeError(`Offer ${offer.logicalId} carries membership terms but is ${offer.kind}.`);
1040
+ }
1041
+ if (offer.membership === undefined && offer.kind === 'subscription') {
1042
+ throw new TypeError(`Subscription offer ${offer.logicalId} has no membership terms.`);
1043
+ }
1044
+ if (offer.membership !== undefined) {
1045
+ if (!isIsoDuration(offer.membership.period)) {
1046
+ throw new TypeError(`Offer ${offer.logicalId} period ${offer.membership.period} is not a positive ISO-8601 duration.`);
1047
+ }
1048
+ if (offer.membership.price.currency !== offer.price.currency
1049
+ || !sameCreditAmount(offer.membership.price.amount, offer.price.amount)) {
1050
+ throw new TypeError(`Offer ${offer.logicalId} is not priced as its membership terms say.`);
1051
+ }
1052
+ }
1053
+ return offer;
1054
+ }
1055
+ function copyOffer(offer) {
1056
+ return {
1057
+ ...offer,
1058
+ price: { ...offer.price },
1059
+ ...(offer.membership === undefined
1060
+ ? {}
1061
+ : {
1062
+ membership: {
1063
+ scope: offer.membership.scope,
1064
+ price: { ...offer.membership.price },
1065
+ period: offer.membership.period,
1066
+ },
1067
+ }),
1068
+ };
1069
+ }
1070
+ // Spend evidence.
1071
+ export const creditWalletSpendEvidenceSchema = 'credit-wallet.spend.v1';
1072
+ /** The `adapter-settled` evidence of a wallet spend; `debits` is JSON because envelope payloads are flat. */
1073
+ export function createCreditWalletSpendEvidence(evidence) {
1074
+ return {
1075
+ schema: creditWalletSpendEvidenceSchema,
1076
+ payload: {
1077
+ store: evidence.store,
1078
+ titleId: evidence.titleId,
1079
+ entitlementId: evidence.entitlementId,
1080
+ amount: evidence.amount,
1081
+ debits: JSON.stringify(evidence.debits.map((debit) => ({ lotId: debit.lotId, amount: debit.amount }))),
1082
+ },
1083
+ };
1084
+ }
1085
+ const isCreditDebitList = (() => {
1086
+ const _io0 = (input, _exceptionable = true) => "string" === typeof input.lotId && 1 <= input.lotId.length && ("number" === typeof input.amount && 0 < input.amount) && (2 === Object.keys(input).length || Object.keys(input).every(key => {
1087
+ if (["lotId", "amount"].some(prop => key === prop))
1088
+ return true;
1089
+ const value = input[key];
1090
+ if (undefined === value)
1091
+ return true;
1092
+ return false;
1093
+ }));
1094
+ return (input, _exceptionable = true) => Array.isArray(input) && input.every((elem, _index1) => "object" === typeof elem && null !== elem && _io0(elem, true));
1095
+ })();
1096
+ /** Reads a `credit-wallet.spend.v1` envelope; any other schema or a malformed payload is `undefined`. */
1097
+ export function readCreditWalletSpendEvidence(envelope) {
1098
+ if (envelope.schema !== creditWalletSpendEvidenceSchema) {
1099
+ return undefined;
1100
+ }
1101
+ const { store, titleId, entitlementId, amount, debits } = envelope.payload;
1102
+ if (typeof store !== 'string'
1103
+ || typeof titleId !== 'string'
1104
+ || typeof entitlementId !== 'string'
1105
+ || typeof amount !== 'number'
1106
+ || typeof debits !== 'string') {
1107
+ return undefined;
1108
+ }
1109
+ let parsed;
1110
+ try {
1111
+ parsed = JSON.parse(debits);
1112
+ }
1113
+ catch {
1114
+ return undefined;
1115
+ }
1116
+ if (!isCreditDebitList(parsed)) {
1117
+ return undefined;
1118
+ }
1119
+ return { store, titleId, entitlementId, amount, debits: parsed };
1120
+ }
1121
+ /**
1122
+ * The RFC profile row of a project-owned wallet, before overrides.
1123
+ * `purchase.subscription` is true only where the billing authority is `host`
1124
+ * and the caller states that the client reports active subscriptions
1125
+ * (`client.subscriptions: true`); with no client description it is `false`.
1126
+ */
1127
+ export function createCreditCommerceCapabilities(billingAuthority, overrides = {}, client = {}) {
1128
+ if (!isCommerceBillingAuthority(billingAuthority)) {
1129
+ throw new TypeError(`Billing authority ${String(billingAuthority)} must be store or host.`);
1130
+ }
1131
+ const purchase = {
1132
+ consumable: true,
1133
+ durable: true,
1134
+ subscription: true,
1135
+ quantity: false,
1136
+ ...overrides.purchase,
1137
+ };
1138
+ return {
1139
+ purchase: {
1140
+ ...purchase,
1141
+ subscription: purchase.subscription && billingAuthority === 'host' && client.subscriptions === true,
1142
+ },
1143
+ catalog: { products: true, offers: false, localizedPrice: false, ...overrides.catalog },
1144
+ lifecycle: {
1145
+ availablePurchases: true,
1146
+ pending: false,
1147
+ restore: true,
1148
+ // The provider itself emits (restore re-reads), so events are on whatever the client does.
1149
+ events: true,
1150
+ finish: 'server',
1151
+ ...overrides.lifecycle,
1152
+ },
1153
+ authority: { evidence: 'adapter-settled', accountBinding: 'host', ...overrides.authority },
1154
+ availability: overrides.availability ?? 'available',
1155
+ };
1156
+ }
1157
+ /**
1158
+ * `CommerceProvider` over a project-owned `CreditWalletClient` (RFC "Credit
1159
+ * wallet"): `fetchProducts` lists the wallet's offers (memberships omitted
1160
+ * where the billing authority is `store`), `requestPurchase` spends the offer
1161
+ * price times the quantity under the game's idempotency key and answers
1162
+ * `purchased` with `adapter-settled` evidence (`credit-wallet.spend.v1`)
1163
+ * once the wallet's debits add up to that price, without a `settlement`
1164
+ * pointer: the backend ledger entry exists only after the reconciler verifies
1165
+ * that evidence, so there is nothing to point at yet,
1166
+ * `getAvailablePurchases` lists the title's unredeemed entitlements,
1167
+ * `finishTransaction` redeems one with the ledger entry id of its grant and
1168
+ * refuses to run without it, `restorePurchases` re-reads the unredeemed
1169
+ * entitlements as `purchaseUpdated` events, and the client's own events pass
1170
+ * through the standard pre-listener buffer. Every client answer is
1171
+ * validated; a malformed one is `INVALID_RESPONSE`. The provider never
1172
+ * grants and never credits: lots are credited by the wallet owner from
1173
+ * ledger grants, and memberships renew, lapse, and expire on the wallet
1174
+ * service alone.
1175
+ */
1176
+ export function createCreditCommerceProvider(client, options) {
1177
+ const declaredStore = client?.store;
1178
+ if (typeof declaredStore !== 'string' || declaredStore.length === 0) {
1179
+ throw new TypeError('A credit wallet client must declare its store id.');
1180
+ }
1181
+ const declaredTitleId = options
1182
+ ?.titleId;
1183
+ if (typeof declaredTitleId !== 'string' || declaredTitleId.length === 0) {
1184
+ throw new TypeError('A credit commerce provider requires a title id.');
1185
+ }
1186
+ if (!isCommerceBillingAuthority(options.billingAuthority)) {
1187
+ throw new TypeError(`Billing authority ${String(options.billingAuthority)} must be store or host.`);
1188
+ }
1189
+ const { store } = client;
1190
+ const { titleId, billingAuthority } = options;
1191
+ const currencies = options.currencies === undefined ? undefined : new Set(options.currencies);
1192
+ const membershipsOffered = billingAuthority === 'host';
1193
+ const subscriptionsServable = client.getActiveSubscriptions !== undefined;
1194
+ const now = () => normalizeIsoTimestamp(options.now?.() ?? new Date().toISOString());
1195
+ const capabilities = () => createCreditCommerceCapabilities(billingAuthority, options.capabilities, {
1196
+ subscriptions: subscriptionsServable,
1197
+ });
1198
+ const listeners = new Set();
1199
+ const buffer = createCommerceEventBuffer();
1200
+ /** Transaction ids of restore events held in the buffer, so repeated restores do not duplicate them. */
1201
+ const bufferedRestores = new Set();
1202
+ /** First fallback `purchasedAt` per entitlement, for wallets that report no settlement time. */
1203
+ const fallbackPurchasedAt = new Map();
1204
+ const purchasedAtOf = (entitlementId, createdAt) => {
1205
+ if (createdAt !== undefined) {
1206
+ return normalizeIsoTimestamp(createdAt);
1207
+ }
1208
+ const remembered = fallbackPurchasedAt.get(entitlementId);
1209
+ if (remembered !== undefined) {
1210
+ return remembered;
1211
+ }
1212
+ const stamped = now();
1213
+ fallbackPurchasedAt.set(entitlementId, stamped);
1214
+ return stamped;
1215
+ };
1216
+ /** One listener must not stop the others, the client's callback, a restore, or a buffer drain. */
1217
+ const deliver = (listener, event) => {
1218
+ try {
1219
+ listener(event);
1220
+ }
1221
+ catch (error) {
1222
+ console.warn('Credit wallet commerce listener failed.', describeError(error));
1223
+ }
1224
+ };
1225
+ const emit = (event) => {
1226
+ if (listeners.size === 0) {
1227
+ buffer.push(event);
1228
+ return;
1229
+ }
1230
+ for (const listener of [...listeners]) {
1231
+ deliver(listener, event);
1232
+ }
1233
+ };
1234
+ // The client is subscribed once, up front, so wallet events emitted before the
1235
+ // provider's first listener reach that listener through the buffer.
1236
+ const unsubscribeClient = client.subscribe?.(emit, { titleId });
1237
+ const invalidResponse = (what, cause) => new CommerceError('INVALID_RESPONSE', {
1238
+ message: `The credit wallet client answered ${what} with an invalid value.`,
1239
+ cause,
1240
+ });
1241
+ const toCommerceError = (error) => {
1242
+ if (error instanceof CommerceError) {
1243
+ return error;
1244
+ }
1245
+ const info = commerceErrorInfoOf(error);
1246
+ return new CommerceError(info.code, {
1247
+ retryable: info.retryable,
1248
+ ...(info.storeCode === undefined ? {} : { storeCode: info.storeCode }),
1249
+ ...(info.message === undefined ? {} : { message: info.message }),
1250
+ cause: error,
1251
+ });
1252
+ };
1253
+ /** Every offer the wallet lists, validated; memberships are not filtered here. */
1254
+ const listAllOffers = async () => {
1255
+ let offers;
1256
+ try {
1257
+ offers = await client.listOffers({ titleId });
1258
+ }
1259
+ catch (error) {
1260
+ throw toCommerceError(error);
1261
+ }
1262
+ if (!Array.isArray(offers)) {
1263
+ throw invalidResponse('listOffers', offers);
1264
+ }
1265
+ const validated = offers.map((offer) => {
1266
+ try {
1267
+ return assertCreditWalletOffer(offer);
1268
+ }
1269
+ catch (error) {
1270
+ throw invalidResponse('listOffers', error);
1271
+ }
1272
+ });
1273
+ for (const offer of validated) {
1274
+ if (currencies !== undefined && !currencies.has(offer.price.currency)) {
1275
+ throw new CommerceError('CONFIGURATION_REQUIRED', {
1276
+ message: `Offer ${offer.logicalId} is priced in ${offer.price.currency}, `
1277
+ + 'which the catalog does not declare.',
1278
+ });
1279
+ }
1280
+ }
1281
+ return validated;
1282
+ };
1283
+ /** Offers the provider sells: memberships only while `purchase.subscription` is declared. */
1284
+ const listOffers = async () => {
1285
+ const sellsMemberships = capabilities().purchase.subscription;
1286
+ return (await listAllOffers()).filter((offer) => offer.membership === undefined || sellsMemberships);
1287
+ };
1288
+ const toProduct = (offer) => ({
1289
+ logicalId: offer.logicalId,
1290
+ store,
1291
+ storeSku: offer.storeSku,
1292
+ kind: offer.kind,
1293
+ title: offer.title,
1294
+ description: offer.description,
1295
+ price: {
1296
+ formatted: `${offer.price.amount} ${offer.price.currency}`,
1297
+ currencyCode: offer.price.currency,
1298
+ },
1299
+ });
1300
+ const toPurchase = (input) => ({
1301
+ store,
1302
+ transactionId: input.entitlementId,
1303
+ storeSku: input.offer?.storeSku ?? input.logicalId,
1304
+ logicalId: input.logicalId,
1305
+ quantity: input.quantity,
1306
+ state: 'purchased',
1307
+ purchasedAt: input.purchasedAt,
1308
+ token: input.entitlementId,
1309
+ evidence: createCreditWalletSpendEvidence({
1310
+ store,
1311
+ titleId,
1312
+ entitlementId: input.entitlementId,
1313
+ amount: sumCreditDebits(input.debits),
1314
+ debits: input.debits,
1315
+ }),
1316
+ accountBinding: { kind: 'host-identity' },
1317
+ });
1318
+ /**
1319
+ * The quantity recorded at spend time; the price-based derivation is a
1320
+ * fallback for wallets that record none. Either way it is advisory: the
1321
+ * backend prices a wallet purchase from the evidence's `amount` and
1322
+ * `debits`, never from this number.
1323
+ */
1324
+ const quantityOf = (entitlement, offer) => {
1325
+ if (entitlement.quantity !== undefined) {
1326
+ return entitlement.quantity;
1327
+ }
1328
+ if (offer === undefined || offer.membership !== undefined) {
1329
+ return 1;
1330
+ }
1331
+ return creditPriceMultiple(sumCreditDebits(entitlement.debits), offer.price.amount) ?? 1;
1332
+ };
1333
+ /**
1334
+ * Offers for recovery reads. They only supply `storeSku` and the quantity
1335
+ * fallback, so a recovery read never fails because of them: a `listOffers`
1336
+ * failure counts as no offers, and a malformed or undeclared-currency offer
1337
+ * is skipped. A purchase without a valid offer is listed under its logical
1338
+ * id.
1339
+ */
1340
+ const listRecoveryOffers = async () => {
1341
+ let offers;
1342
+ try {
1343
+ offers = await client.listOffers({ titleId });
1344
+ }
1345
+ catch (error) {
1346
+ console.warn('Credit wallet offers unavailable during recovery.', describeError(error));
1347
+ return [];
1348
+ }
1349
+ if (!Array.isArray(offers)) {
1350
+ console.warn('Credit wallet offers unreadable during recovery.');
1351
+ return [];
1352
+ }
1353
+ return offers.flatMap((candidate) => {
1354
+ try {
1355
+ const offer = assertCreditWalletOffer(candidate);
1356
+ return currencies === undefined || currencies.has(offer.price.currency) ? [offer] : [];
1357
+ }
1358
+ catch {
1359
+ return [];
1360
+ }
1361
+ });
1362
+ };
1363
+ /**
1364
+ * The title's unredeemed entitlements. One listed for another title, or one
1365
+ * the wallet reports malformed, is the wallet's mistake and is dropped with
1366
+ * a warning so the others still recover.
1367
+ */
1368
+ const unredeemedPurchases = async () => {
1369
+ const offers = await listRecoveryOffers();
1370
+ let entitlements;
1371
+ try {
1372
+ entitlements = await client.listUnredeemed({ titleId });
1373
+ }
1374
+ catch (error) {
1375
+ throw toCommerceError(error);
1376
+ }
1377
+ if (!Array.isArray(entitlements)) {
1378
+ throw invalidResponse('listUnredeemed', entitlements);
1379
+ }
1380
+ return entitlements.flatMap((candidate) => {
1381
+ let entitlement;
1382
+ try {
1383
+ entitlement = assertCreditEntitlement(candidate);
1384
+ }
1385
+ catch (error) {
1386
+ console.warn('Credit wallet entitlement skipped during recovery.', describeError(error));
1387
+ return [];
1388
+ }
1389
+ if (entitlement.titleId !== titleId) {
1390
+ return [];
1391
+ }
1392
+ const offer = offers.find((item) => item.logicalId === entitlement.logicalId);
1393
+ return [
1394
+ toPurchase({
1395
+ offer,
1396
+ logicalId: entitlement.logicalId,
1397
+ entitlementId: entitlement.entitlementId,
1398
+ debits: entitlement.debits,
1399
+ purchasedAt: normalizeIsoTimestamp(entitlement.createdAt),
1400
+ quantity: quantityOf(entitlement, offer),
1401
+ }),
1402
+ ];
1403
+ });
1404
+ };
1405
+ const failed = (error) => ({
1406
+ outcome: error.code === 'USER_CANCELLED' ? 'cancelled' : 'failed',
1407
+ error,
1408
+ });
1409
+ const unavailable = (message) => failed(new CommerceError('ITEM_UNAVAILABLE', { message }).toCommerceErrorInfo());
1410
+ const provider = {
1411
+ store,
1412
+ async getCapabilities() {
1413
+ return capabilities();
1414
+ },
1415
+ async fetchProducts(request = {}) {
1416
+ return (await listOffers())
1417
+ .filter((offer) => matchesFetchProducts(offer, request))
1418
+ .map(toProduct);
1419
+ },
1420
+ async requestPurchase(request) {
1421
+ const offers = await listAllOffers();
1422
+ const offer = offers.find((candidate) => candidate.logicalId === request.logicalId);
1423
+ if (offer === undefined) {
1424
+ return unavailable(`The wallet offers no product ${request.logicalId}.`);
1425
+ }
1426
+ if (offer.membership !== undefined && !membershipsOffered) {
1427
+ return unavailable(`Membership ${offer.logicalId} is sold only where the billing authority is host.`);
1428
+ }
1429
+ try {
1430
+ assertRequestPurchaseSupported(capabilities(), request, offer.kind);
1431
+ }
1432
+ catch (error) {
1433
+ return failed(commerceErrorInfoOf(error));
1434
+ }
1435
+ const quantity = request.quantity ?? 1;
1436
+ if (offer.membership !== undefined && quantity !== 1) {
1437
+ return unavailable('A membership is purchased one period at a time.');
1438
+ }
1439
+ const spendInput = {
1440
+ titleId,
1441
+ currency: offer.price.currency,
1442
+ amount: offer.price.amount * quantity,
1443
+ idempotencyKey: request.idempotencyKey,
1444
+ logicalId: offer.logicalId,
1445
+ };
1446
+ let spend;
1447
+ try {
1448
+ const answer = await client.spend(spendInput);
1449
+ try {
1450
+ spend = assertCreditSpendResult(answer);
1451
+ }
1452
+ catch (error) {
1453
+ throw invalidResponse('spend', error);
1454
+ }
1455
+ }
1456
+ catch (error) {
1457
+ return failed(toCommerceError(error).toCommerceErrorInfo());
1458
+ }
1459
+ if (spend.status === 'rejected') {
1460
+ return failed(spend.error);
1461
+ }
1462
+ // A debited answer that does not add up to the price is not a purchase.
1463
+ if (!sameCreditAmount(sumCreditDebits(spend.debits), spendInput.amount)) {
1464
+ return failed(invalidResponse('spend', new RangeError(`Debits for ${offer.logicalId} sum to ${sumCreditDebits(spend.debits)}, `
1465
+ + `not ${spendInput.amount}.`)).toCommerceErrorInfo());
1466
+ }
1467
+ return {
1468
+ outcome: 'purchased',
1469
+ purchase: toPurchase({
1470
+ offer,
1471
+ logicalId: offer.logicalId,
1472
+ entitlementId: spend.entitlementId,
1473
+ debits: spend.debits,
1474
+ purchasedAt: purchasedAtOf(spend.entitlementId, spend.createdAt),
1475
+ quantity,
1476
+ }),
1477
+ };
1478
+ },
1479
+ async getAvailablePurchases() {
1480
+ return unredeemedPurchases();
1481
+ },
1482
+ async finishTransaction(request) {
1483
+ if (typeof request.ledgerEntryId !== 'string' || request.ledgerEntryId.length === 0) {
1484
+ // The reconciler's ledger grant is the finish gate: nothing is redeemed without it.
1485
+ throw new CommerceError('INVALID_RESPONSE', {
1486
+ message: 'A wallet entitlement is redeemed only with the ledger entry id of its grant.',
1487
+ });
1488
+ }
1489
+ if (typeof request.transactionId !== 'string' || request.transactionId.length === 0) {
1490
+ throw new CommerceError('INVALID_RESPONSE', {
1491
+ message: 'A wallet entitlement is redeemed by its entitlement id.',
1492
+ });
1493
+ }
1494
+ let result;
1495
+ try {
1496
+ result = await client.redeem({
1497
+ entitlementId: request.transactionId,
1498
+ ledgerEntryId: request.ledgerEntryId,
1499
+ });
1500
+ }
1501
+ catch (error) {
1502
+ throw toCommerceError(error);
1503
+ }
1504
+ if (typeof result?.redeemed !== 'boolean') {
1505
+ throw invalidResponse('redeem', result);
1506
+ }
1507
+ return { finished: result.redeemed };
1508
+ },
1509
+ async restorePurchases() {
1510
+ for (const purchase of await unredeemedPurchases()) {
1511
+ if (listeners.size === 0) {
1512
+ if (bufferedRestores.has(purchase.transactionId)) {
1513
+ continue;
1514
+ }
1515
+ bufferedRestores.add(purchase.transactionId);
1516
+ }
1517
+ emit({ type: 'purchaseUpdated', purchase });
1518
+ }
1519
+ },
1520
+ subscribe(listener) {
1521
+ const first = listeners.size === 0;
1522
+ listeners.add(listener);
1523
+ if (first) {
1524
+ bufferedRestores.clear();
1525
+ buffer.drain((event) => deliver(listener, event));
1526
+ }
1527
+ return () => {
1528
+ listeners.delete(listener);
1529
+ };
1530
+ },
1531
+ dispose() {
1532
+ unsubscribeClient?.();
1533
+ listeners.clear();
1534
+ },
1535
+ };
1536
+ if (client.getActiveSubscriptions !== undefined) {
1537
+ const getActiveSubscriptions = client.getActiveSubscriptions.bind(client);
1538
+ return {
1539
+ ...provider,
1540
+ async getActiveSubscriptions() {
1541
+ let subscriptions;
1542
+ try {
1543
+ subscriptions = await getActiveSubscriptions({ titleId });
1544
+ }
1545
+ catch (error) {
1546
+ throw toCommerceError(error);
1547
+ }
1548
+ if (!Array.isArray(subscriptions)) {
1549
+ throw invalidResponse('getActiveSubscriptions', subscriptions);
1550
+ }
1551
+ return subscriptions.map((subscription) => {
1552
+ try {
1553
+ return { ...assertActiveSubscription(subscription) };
1554
+ }
1555
+ catch (error) {
1556
+ throw invalidResponse('getActiveSubscriptions', error);
1557
+ }
1558
+ });
1559
+ },
1560
+ };
1561
+ }
1562
+ return provider;
1563
+ }
1564
+ function describeError(error) {
1565
+ return error instanceof Error ? error.message : String(error);
1566
+ }
1567
+ function matchesFetchProducts(offer, request) {
1568
+ const type = request.type ?? 'all';
1569
+ if (type === 'in-app' && offer.kind === 'subscription') {
1570
+ return false;
1571
+ }
1572
+ if (type === 'subscription' && offer.kind !== 'subscription') {
1573
+ return false;
1574
+ }
1575
+ return request.logicalIds === undefined || request.logicalIds.includes(offer.logicalId);
1576
+ }
1577
+ // Reference in-memory wallet.
1578
+ /** Periods one `renewDue` tick charges per membership before it yields to the next tick. */
1579
+ export const defaultMaxRenewalsPerTick = 12;
1580
+ const walletEventBufferLimit = 64;
1581
+ /**
1582
+ * Reference in-memory wallet implementing the RFC "Credit lots" and "Billing
1583
+ * authority and memberships" rules on the pure rule functions above: lot ids
1584
+ * equal ledger entry ids, spend scope derives from the lot origin, spends
1585
+ * consume eligible lots in the fixed order, are idempotent by key, are priced
1586
+ * by the wallet's own offers, and debit nothing on `INSUFFICIENT_BALANCE`;
1587
+ * memberships debit `membership:<subscriptionId>:<periodIndex>` per paid
1588
+ * period with lot eligibility judged at the period boundary, a
1589
+ * platform-scoped membership debits `cross-title` lots only, and a membership
1590
+ * lapses at period end when the eligible balance runs out.
1591
+ */
1592
+ export function createInMemoryCreditWallet(input) {
1593
+ const declaredStore = input?.store;
1594
+ if (typeof declaredStore !== 'string' || declaredStore.length === 0) {
1595
+ throw new TypeError('An in-memory credit wallet requires a store id.');
1596
+ }
1597
+ const { store } = input;
1598
+ const currencies = new Set();
1599
+ for (const currency of input.currencies ?? []) {
1600
+ if (typeof currency !== 'string' || currency.trim() !== currency || currency.length === 0) {
1601
+ throw new TypeError(`Wallet currency ${JSON.stringify(currency)} must be a trimmed non-empty code.`);
1602
+ }
1603
+ currencies.add(currency);
1604
+ }
1605
+ if (input.creditScope !== undefined) {
1606
+ assertCreditScopeRegistry(input.creditScope);
1607
+ }
1608
+ const maxRenewalsPerTick = input.maxRenewalsPerTick ?? defaultMaxRenewalsPerTick;
1609
+ if (!Number.isInteger(maxRenewalsPerTick) || maxRenewalsPerTick < 1) {
1610
+ throw new TypeError('maxRenewalsPerTick must be a positive integer.');
1611
+ }
1612
+ const now = () => normalizeIsoTimestamp(input.now?.() ?? new Date().toISOString());
1613
+ const offers = new Map();
1614
+ const addOffers = (candidates) => {
1615
+ const validated = candidates.map((candidate) => assertCreditWalletOffer(candidate));
1616
+ for (const offer of validated) {
1617
+ if (!currencies.has(offer.price.currency)) {
1618
+ throw new TypeError(`Offer ${offer.logicalId} is priced in ${offer.price.currency}, `
1619
+ + 'which the wallet does not declare.');
1620
+ }
1621
+ }
1622
+ for (const offer of validated) {
1623
+ offers.set(offer.logicalId, copyOffer(offer));
1624
+ }
1625
+ };
1626
+ addOffers(input.offers ?? []);
1627
+ const lots = [];
1628
+ const spends = new Map();
1629
+ const debits = [];
1630
+ const entitlements = [];
1631
+ const subscriptions = new Map();
1632
+ const subscriptionEvents = [];
1633
+ const listeners = new Set();
1634
+ const buffered = [];
1635
+ const snapshotLot = (lot) => ({
1636
+ ...lot,
1637
+ origin: { ...lot.origin },
1638
+ spendScope: { ...lot.spendScope },
1639
+ });
1640
+ const toEntitlement = (entitlement) => ({
1641
+ entitlementId: entitlement.entitlementId,
1642
+ titleId: entitlement.titleId,
1643
+ logicalId: entitlement.logicalId,
1644
+ idempotencyKey: entitlement.idempotencyKey,
1645
+ createdAt: entitlement.createdAt,
1646
+ debits: entitlement.debits.map((debit) => ({ ...debit })),
1647
+ ...(entitlement.quantity === undefined ? {} : { quantity: entitlement.quantity }),
1648
+ });
1649
+ const toActiveSubscription = (subscription) => ({
1650
+ store,
1651
+ logicalId: subscription.logicalId,
1652
+ subscriptionId: subscription.subscriptionId,
1653
+ periodIndex: subscription.periodIndex,
1654
+ state: subscription.state,
1655
+ startedAt: subscription.startedAt,
1656
+ expiresAt: subscription.expiresAt,
1657
+ autoRenew: subscription.state === 'active',
1658
+ });
1659
+ const deliverTo = (target, event) => {
1660
+ try {
1661
+ target.listener(event);
1662
+ }
1663
+ catch (error) {
1664
+ console.warn('Credit wallet listener failed.', describeError(error));
1665
+ }
1666
+ };
1667
+ const reaches = (entry, target) => entry.titleId === undefined || target.titleId === undefined || entry.titleId === target.titleId;
1668
+ const emit = (entry) => {
1669
+ const audience = [...listeners].filter((target) => reaches(entry, target));
1670
+ if (audience.length === 0) {
1671
+ buffered.push(entry);
1672
+ if (buffered.length > walletEventBufferLimit) {
1673
+ buffered.shift();
1674
+ }
1675
+ return;
1676
+ }
1677
+ for (const target of audience) {
1678
+ deliverTo(target, entry.event);
1679
+ }
1680
+ };
1681
+ const emitSubscription = (subscription, type, periodIndex, reason) => {
1682
+ const eventId = createCreditSubscriptionEventId(subscription.subscriptionId, periodIndex, type);
1683
+ if (subscriptionEvents.some((event) => event.eventId === eventId)) {
1684
+ return;
1685
+ }
1686
+ subscriptionEvents.push({
1687
+ eventId,
1688
+ type,
1689
+ subscriptionId: subscription.subscriptionId,
1690
+ logicalId: subscription.logicalId,
1691
+ periodIndex,
1692
+ expiresAt: subscription.expiresAt,
1693
+ ...(reason === undefined ? {} : { reason }),
1694
+ });
1695
+ emit({
1696
+ event: { type: 'subscriptionUpdated', subscription: toActiveSubscription(subscription) },
1697
+ titleId: subscription.terms.scope === 'platform' ? undefined : subscription.titleId,
1698
+ });
1699
+ };
1700
+ const rejected = (code, message) => ({
1701
+ status: 'rejected',
1702
+ error: { code, retryable: false, message },
1703
+ });
1704
+ const sameSpend = (left, right) => left.titleId === right.titleId
1705
+ && left.currency === right.currency
1706
+ && sameCreditAmount(left.amount, right.amount)
1707
+ && left.logicalId === right.logicalId;
1708
+ const replayOf = (key, request) => {
1709
+ const replay = spends.get(key);
1710
+ if (replay === undefined) {
1711
+ return undefined;
1712
+ }
1713
+ if (!sameSpend(replay.request, request)) {
1714
+ return rejected('INVALID_RESPONSE', `Idempotency key ${key} was already used for a different spend.`);
1715
+ }
1716
+ return {
1717
+ status: 'debited',
1718
+ entitlementId: replay.entitlementId,
1719
+ debits: replay.debits.map((debit) => ({ ...debit })),
1720
+ createdAt: replay.createdAt,
1721
+ alreadyProcessed: true,
1722
+ };
1723
+ };
1724
+ /**
1725
+ * The one debit path. `scope` is `platform` for platform memberships and
1726
+ * `title` otherwise; `at` is when the spend takes effect, which decides lot
1727
+ * expiry and the entitlement's `createdAt` (a renewal uses the boundary of
1728
+ * the period it pays for and, through `createdBy`, ignores lots granted
1729
+ * after it); `quantity` is recorded on the entitlement.
1730
+ */
1731
+ const spendScoped = (request, options) => {
1732
+ const { scope, at, quantity } = options;
1733
+ if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length === 0) {
1734
+ return rejected('INVALID_RESPONSE', 'A spend requires an idempotency key.');
1735
+ }
1736
+ const replay = replayOf(request.idempotencyKey, request);
1737
+ if (replay !== undefined) {
1738
+ return replay;
1739
+ }
1740
+ if (typeof request.titleId !== 'string' || request.titleId.length === 0) {
1741
+ return rejected('INVALID_RESPONSE', 'A spend requires a title id.');
1742
+ }
1743
+ if (!currencies.has(request.currency)) {
1744
+ return rejected('ITEM_UNAVAILABLE', `Currency ${String(request.currency)} is not declared by the wallet.`);
1745
+ }
1746
+ const plan = planCreditDebits(selectEligibleCreditLots(lots, {
1747
+ titleId: request.titleId,
1748
+ currency: request.currency,
1749
+ scope,
1750
+ now: at,
1751
+ ...(options.createdBy === undefined ? {} : { createdBy: options.createdBy }),
1752
+ }), request.amount);
1753
+ if (plan.status === 'rejected') {
1754
+ return plan;
1755
+ }
1756
+ for (const debit of plan.debits) {
1757
+ const lot = lots.find((candidate) => candidate.lotId === debit.lotId);
1758
+ if (lot === undefined) {
1759
+ throw new Error(`Planned debit names unknown lot ${debit.lotId}.`);
1760
+ }
1761
+ lot.remaining = Math.max(0, lot.remaining - debit.amount);
1762
+ debits.push({
1763
+ idempotencyKey: request.idempotencyKey,
1764
+ lotId: debit.lotId,
1765
+ amount: debit.amount,
1766
+ });
1767
+ }
1768
+ const entitlementId = request.idempotencyKey;
1769
+ spends.set(request.idempotencyKey, {
1770
+ request: { ...request },
1771
+ entitlementId,
1772
+ debits: plan.debits,
1773
+ createdAt: at,
1774
+ });
1775
+ if (request.logicalId !== undefined) {
1776
+ entitlements.push({
1777
+ entitlementId,
1778
+ titleId: request.titleId,
1779
+ logicalId: request.logicalId,
1780
+ idempotencyKey: request.idempotencyKey,
1781
+ createdAt: at,
1782
+ debits: plan.debits.map((debit) => ({ ...debit })),
1783
+ // Recorded only within the contract's uint32 range; a wider count falls back to the price derivation.
1784
+ ...(quantity === undefined || quantity > 0xff_ff_ff_ff ? {} : { quantity }),
1785
+ redeemedBy: undefined,
1786
+ });
1787
+ }
1788
+ return {
1789
+ status: 'debited',
1790
+ entitlementId,
1791
+ debits: plan.debits.map((debit) => ({ ...debit })),
1792
+ createdAt: at,
1793
+ };
1794
+ };
1795
+ const sameMembership = (subscription, request) => subscription.titleId === request.titleId
1796
+ && subscription.logicalId === request.logicalId
1797
+ && subscription.terms.scope === request.terms.scope
1798
+ && subscription.terms.period === request.terms.period
1799
+ && subscription.terms.price.currency === request.terms.price.currency
1800
+ && sameCreditAmount(subscription.terms.price.amount, request.terms.price.amount);
1801
+ const rejectedStart = (code, message) => ({
1802
+ status: 'rejected',
1803
+ error: { code, retryable: false, message },
1804
+ });
1805
+ const startMembership = async (request) => {
1806
+ let validated;
1807
+ try {
1808
+ validated = assertCreditMembershipStartInput(request);
1809
+ }
1810
+ catch (error) {
1811
+ return rejectedStart('INVALID_RESPONSE', `Membership start is malformed: ${error instanceof Error ? error.message : String(error)}`);
1812
+ }
1813
+ const periodZeroKey = createMembershipRenewalKey(validated.subscriptionId, 0);
1814
+ const current = subscriptions.get(validated.subscriptionId);
1815
+ if (current !== undefined) {
1816
+ const original = spends.get(periodZeroKey);
1817
+ if (!sameMembership(current, validated) || original === undefined) {
1818
+ return rejectedStart('INVALID_RESPONSE', `Subscription id ${validated.subscriptionId} was already used for a different membership.`);
1819
+ }
1820
+ // A replay answers with the original period-0 debits whatever the state
1821
+ // is now; a lapsed or revoked membership resumes only under a new id.
1822
+ return {
1823
+ status: 'started',
1824
+ subscription: toActiveSubscription(current),
1825
+ entitlementId: original.entitlementId,
1826
+ debits: original.debits.map((debit) => ({ ...debit })),
1827
+ createdAt: original.createdAt,
1828
+ alreadyProcessed: true,
1829
+ };
1830
+ }
1831
+ if (spends.has(validated.subscriptionId)) {
1832
+ // The id namespace is shared with client spend keys in both directions.
1833
+ return rejectedStart('INVALID_RESPONSE', `Subscription id ${validated.subscriptionId} was already used as a spend key.`);
1834
+ }
1835
+ // The terms are validated before any credit moves, so malformed terms never charge.
1836
+ if (!isIsoDuration(validated.terms.period)) {
1837
+ return rejectedStart('ITEM_UNAVAILABLE', `Membership period ${validated.terms.period} is not a positive ISO-8601 duration.`);
1838
+ }
1839
+ const startedAt = now();
1840
+ const expiresAt = addIsoDuration(startedAt, validated.terms.period, 1);
1841
+ const result = spendScoped({
1842
+ titleId: validated.titleId,
1843
+ currency: validated.terms.price.currency,
1844
+ amount: validated.terms.price.amount,
1845
+ idempotencyKey: periodZeroKey,
1846
+ logicalId: validated.logicalId,
1847
+ }, { scope: validated.terms.scope, at: startedAt, quantity: 1 });
1848
+ if (result.status === 'rejected') {
1849
+ return result;
1850
+ }
1851
+ const subscription = {
1852
+ subscriptionId: validated.subscriptionId,
1853
+ titleId: validated.titleId,
1854
+ logicalId: validated.logicalId,
1855
+ terms: {
1856
+ scope: validated.terms.scope,
1857
+ price: { ...validated.terms.price },
1858
+ period: validated.terms.period,
1859
+ },
1860
+ periodIndex: 0,
1861
+ startedAt,
1862
+ expiresAt,
1863
+ state: 'active',
1864
+ };
1865
+ subscriptions.set(subscription.subscriptionId, subscription);
1866
+ emitSubscription(subscription, 'subscriptionStarted', 0);
1867
+ return {
1868
+ status: 'started',
1869
+ subscription: toActiveSubscription(subscription),
1870
+ entitlementId: result.entitlementId,
1871
+ debits: result.debits,
1872
+ createdAt: startedAt,
1873
+ };
1874
+ };
1875
+ /** Charges every period that ended by `at`, at most `maxRenewalsPerTick` per call, each judged at its own boundary. */
1876
+ const renew = (subscription, at) => {
1877
+ let charged = 0;
1878
+ while (subscription.state === 'active'
1879
+ && Date.parse(subscription.expiresAt) <= at
1880
+ && charged < maxRenewalsPerTick) {
1881
+ const nextPeriod = subscription.periodIndex + 1;
1882
+ const boundary = subscription.expiresAt;
1883
+ const result = spendScoped({
1884
+ titleId: subscription.titleId,
1885
+ currency: subscription.terms.price.currency,
1886
+ amount: subscription.terms.price.amount,
1887
+ idempotencyKey: createMembershipRenewalKey(subscription.subscriptionId, nextPeriod),
1888
+ }, { scope: subscription.terms.scope, at: boundary, createdBy: boundary });
1889
+ if (result.status === 'rejected') {
1890
+ // The membership lapses at the end of the paid period; `expiresAt` is unchanged.
1891
+ subscription.state = 'lapsed';
1892
+ emitSubscription(subscription, 'subscriptionLapsed', nextPeriod, result.error.code);
1893
+ return;
1894
+ }
1895
+ subscription.periodIndex = nextPeriod;
1896
+ subscription.expiresAt = addIsoDuration(subscription.startedAt, subscription.terms.period, nextPeriod + 1);
1897
+ emitSubscription(subscription, 'subscriptionRenewed', nextPeriod);
1898
+ charged += 1;
1899
+ }
1900
+ };
1901
+ return {
1902
+ store,
1903
+ get lots() {
1904
+ return lots.map(snapshotLot);
1905
+ },
1906
+ get debits() {
1907
+ return debits.map((debit) => ({ ...debit }));
1908
+ },
1909
+ get entitlements() {
1910
+ return entitlements.map(toEntitlement);
1911
+ },
1912
+ get subscriptionEvents() {
1913
+ return subscriptionEvents.map((event) => ({ ...event }));
1914
+ },
1915
+ get offers() {
1916
+ return [...offers.values()].map(copyOffer);
1917
+ },
1918
+ addOffers,
1919
+ creditFromGrant(grant, options = {}) {
1920
+ const validated = assertCreditLotGrant(grant);
1921
+ const spendScope = deriveCreditLotSpendScope(validated.origin, {
1922
+ ...options,
1923
+ ...(input.creditScope === undefined ? {} : { creditScope: input.creditScope }),
1924
+ });
1925
+ const createdAt = normalizeIsoTimestamp(validated.createdAt);
1926
+ const expiresAt = validated.expiresAt === undefined
1927
+ ? undefined
1928
+ : normalizeIsoTimestamp(validated.expiresAt);
1929
+ const existing = lots.find((lot) => lot.lotId === validated.ledgerEntryId);
1930
+ if (existing !== undefined) {
1931
+ if (existing.currency !== validated.currency
1932
+ || !sameCreditAmount(existing.amount, validated.amount)
1933
+ || existing.origin.channel !== validated.origin.channel
1934
+ || existing.origin.store !== validated.origin.store
1935
+ || existing.origin.titleId !== validated.origin.titleId
1936
+ || existing.createdAt !== createdAt
1937
+ || existing.expiresAt !== expiresAt
1938
+ || existing.spendScope.kind !== spendScope.kind
1939
+ || (spendScope.kind === 'title'
1940
+ && existing.spendScope.kind === 'title'
1941
+ && existing.spendScope.titleId !== spendScope.titleId)) {
1942
+ throw new Error(`Ledger entry ${validated.ledgerEntryId} was already credited as a different lot.`);
1943
+ }
1944
+ return snapshotLot(existing);
1945
+ }
1946
+ if (!currencies.has(validated.currency)) {
1947
+ throw new TypeError(`Lot ${validated.ledgerEntryId} uses currency ${validated.currency}, `
1948
+ + 'which the wallet does not declare.');
1949
+ }
1950
+ const lot = {
1951
+ lotId: validated.ledgerEntryId,
1952
+ currency: validated.currency,
1953
+ amount: validated.amount,
1954
+ remaining: validated.amount,
1955
+ createdAt,
1956
+ ...(expiresAt === undefined ? {} : { expiresAt }),
1957
+ origin: { ...validated.origin },
1958
+ spendScope,
1959
+ };
1960
+ lots.push(lot);
1961
+ return snapshotLot(lot);
1962
+ },
1963
+ startMembership,
1964
+ async renewDue(at) {
1965
+ const millis = Date.parse(at === undefined ? now() : normalizeIsoTimestamp(at));
1966
+ for (const subscription of subscriptions.values()) {
1967
+ renew(subscription, millis);
1968
+ }
1969
+ },
1970
+ async listOffers() {
1971
+ return [...offers.values()].map(copyOffer);
1972
+ },
1973
+ async spend(request) {
1974
+ if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length === 0) {
1975
+ return rejected('INVALID_RESPONSE', 'A spend requires an idempotency key.');
1976
+ }
1977
+ if (request.idempotencyKey.startsWith(membershipDebitKeyPrefix)) {
1978
+ return rejected('INVALID_RESPONSE', `Idempotency keys starting with ${membershipDebitKeyPrefix} are reserved for scheduled debits.`);
1979
+ }
1980
+ // A membership start is recorded under the client's key too, so this
1981
+ // answers every replay, including one after the membership lapsed.
1982
+ const replay = replayOf(request.idempotencyKey, request);
1983
+ if (replay !== undefined) {
1984
+ return replay;
1985
+ }
1986
+ const offer = request.logicalId === undefined ? undefined : offers.get(request.logicalId);
1987
+ if (offer?.membership === undefined && subscriptions.has(request.idempotencyKey)) {
1988
+ // A membership the owner started under this id already consumed the key.
1989
+ return rejected('INVALID_RESPONSE', `Idempotency key ${request.idempotencyKey} is the id of a started membership.`);
1990
+ }
1991
+ if (request.logicalId === undefined) {
1992
+ return spendScoped(request, { scope: 'title', at: now() });
1993
+ }
1994
+ if (offer === undefined) {
1995
+ return rejected('ITEM_UNAVAILABLE', `The wallet offers no product ${request.logicalId}.`);
1996
+ }
1997
+ if (request.currency !== offer.price.currency) {
1998
+ return rejected('ITEM_UNAVAILABLE', `${offer.logicalId} is priced in ${offer.price.currency}, not ${request.currency}.`);
1999
+ }
2000
+ const multiple = creditPriceMultiple(request.amount, offer.price.amount);
2001
+ if (multiple === undefined) {
2002
+ return rejected('ITEM_UNAVAILABLE', `${offer.logicalId} costs ${offer.price.amount} ${offer.price.currency} per unit; `
2003
+ + `${request.amount} is not a whole number of units.`);
2004
+ }
2005
+ if (offer.membership === undefined) {
2006
+ return spendScoped(request, { scope: 'title', at: now(), quantity: multiple });
2007
+ }
2008
+ if (multiple !== 1) {
2009
+ return rejected('ITEM_UNAVAILABLE', 'A membership is purchased one period at a time.');
2010
+ }
2011
+ const started = await startMembership({
2012
+ subscriptionId: request.idempotencyKey,
2013
+ titleId: request.titleId,
2014
+ logicalId: offer.logicalId,
2015
+ terms: offer.membership,
2016
+ });
2017
+ if (started.status === 'started' && started.alreadyProcessed !== true) {
2018
+ spends.set(request.idempotencyKey, {
2019
+ request: { ...request },
2020
+ entitlementId: started.entitlementId,
2021
+ debits: started.debits,
2022
+ createdAt: started.createdAt,
2023
+ });
2024
+ }
2025
+ return creditSpendResultOfMembershipStart(started);
2026
+ },
2027
+ async listUnredeemed(request) {
2028
+ return entitlements
2029
+ .filter((entitlement) => entitlement.redeemedBy === undefined && entitlement.titleId === request.titleId)
2030
+ .map(toEntitlement);
2031
+ },
2032
+ async redeem(request) {
2033
+ if (typeof request.ledgerEntryId !== 'string'
2034
+ || request.ledgerEntryId.length === 0
2035
+ || typeof request.entitlementId !== 'string'
2036
+ || request.entitlementId.length === 0) {
2037
+ return { redeemed: false };
2038
+ }
2039
+ const entitlement = entitlements.find((candidate) => candidate.entitlementId === request.entitlementId);
2040
+ if (entitlement === undefined) {
2041
+ return { redeemed: false };
2042
+ }
2043
+ if (entitlement.redeemedBy !== undefined) {
2044
+ // Redeeming again under the same grant is a no-op; another grant cannot claim it.
2045
+ return { redeemed: entitlement.redeemedBy === request.ledgerEntryId };
2046
+ }
2047
+ entitlement.redeemedBy = request.ledgerEntryId;
2048
+ return { redeemed: true };
2049
+ },
2050
+ async getBalance(request) {
2051
+ const balances = {};
2052
+ for (const currency of currencies) {
2053
+ balances[currency] = sumCreditLotRemaining(selectEligibleCreditLots(lots, {
2054
+ titleId: request.titleId,
2055
+ currency,
2056
+ scope: 'title',
2057
+ now: now(),
2058
+ }));
2059
+ }
2060
+ return { titleId: request.titleId, balances };
2061
+ },
2062
+ async getActiveSubscriptions(request = {}) {
2063
+ return [...subscriptions.values()]
2064
+ .filter((subscription) => subscription.state === 'active'
2065
+ && (request.titleId === undefined
2066
+ || subscription.terms.scope === 'platform'
2067
+ || subscription.titleId === request.titleId))
2068
+ .map(toActiveSubscription);
2069
+ },
2070
+ subscribe(listener, input = {}) {
2071
+ const target = { listener, titleId: input.titleId };
2072
+ listeners.add(target);
2073
+ // Buffered events this listener can see are delivered now; the rest wait for their title.
2074
+ for (const entry of buffered.splice(0, buffered.length)) {
2075
+ if (reaches(entry, target)) {
2076
+ deliverTo(target, entry.event);
2077
+ }
2078
+ else {
2079
+ buffered.push(entry);
2080
+ }
2081
+ }
2082
+ return () => {
2083
+ listeners.delete(target);
2084
+ };
2085
+ },
2086
+ };
2087
+ }