@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.
- package/dist/commerce-conformance.d.ts +34 -51
- package/dist/commerce-conformance.js +72 -465
- package/dist/commerce-contract.d.ts +31 -2
- package/dist/commerce-wallet.d.ts +324 -0
- package/dist/commerce-wallet.js +2087 -0
- package/dist/commerce.d.ts +34 -5
- package/dist/commerce.js +238 -10
- package/package.json +6 -2
|
@@ -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
|
+
}
|