@vibedout/commerce 0.0.0-stage → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2318 @@
1
+ import { i as isRecord, t as SUBJECT_TYPE$1 } from "./internal-p2FS_A4L.mjs";
2
+ import { a as isCurrencyCode, i as basisPointsOf, o as isMinorUnits, s as lineTotalMinor, u as taxIncludedIn } from "./money-aFFtGd0E.mjs";
3
+ import { checkLineDiscounts, spreadDiscount } from "./discounts.mjs";
4
+ import { DISCOUNT_REJECTIONS, MAX_HOLD_MS } from "./types.mjs";
5
+ import { checkResolvedLines, holdsCapacity } from "./lines.mjs";
6
+ import { checkStartPayment } from "./payments.mjs";
7
+ import { computeTotals } from "./totals.mjs";
8
+ //#region src/testing/context.ts
9
+ /** 2026-10-08 12:00 UTC: a fixed "now" for tests, so a hold that expires does so when the test says. */
10
+ const TEST_NOW = Date.UTC(2026, 9, 8, 12, 0, 0);
11
+ const noDatabase = () => {
12
+ throw new Error("this test context has no database: pass a ctx with a D1 binding to a port that needs one");
13
+ };
14
+ /**
15
+ * A {@link PortContext} for a test double: a fixed clock, no environment, and a `db` whose every method throws, so a double that reaches for
16
+ * the database fails loudly. A port that really uses D1 is tested with the project's own `env.DB` instead.
17
+ */
18
+ function testContext(overrides = {}) {
19
+ return {
20
+ env: {},
21
+ db: {
22
+ prepare: noDatabase,
23
+ batch: noDatabase,
24
+ exec: noDatabase,
25
+ dump: noDatabase,
26
+ withSession: noDatabase
27
+ },
28
+ now: TEST_NOW,
29
+ locale: "es-MX",
30
+ userId: null,
31
+ ...overrides
32
+ };
33
+ }
34
+ //#endregion
35
+ //#region src/testing/discount-conformance.ts
36
+ /**
37
+ * The behaviour every {@link DiscountPort} must have, as a test suite. A discount provider's rules are its data, so the suite cannot know which
38
+ * order earns a discount: the caller hands it `scenarios`, inputs that make its rules apply (and `plain`, one that earns nothing), and the suite
39
+ * holds every answer to the contract.
40
+ *
41
+ * What it fixes: amounts are whole minor units in the order's currency and each discount has a title; together they are at most the subtotal and
42
+ * no line is discounted by more than it costs; a discount that names its lines names real ones and they add up to it; every code a buyer typed
43
+ * is answered, as applied or rejected with a reason from the list, and never both; the same input gives the same answer and the input is left as
44
+ * it was. Codes the provider knows are its own tests; this suite asks for one nobody has.
45
+ */
46
+ /** A deep copy that cannot be edited: a provider that sorts or changes what it was given throws. */
47
+ function frozen$1(value) {
48
+ const copy = structuredClone(value);
49
+ const freeze = (item) => {
50
+ if (typeof item !== "object" || item === null || Object.isFrozen(item)) return;
51
+ Object.freeze(item);
52
+ for (const child of Object.values(item)) freeze(child);
53
+ };
54
+ freeze(copy);
55
+ return copy;
56
+ }
57
+ /** A code no provider has, so it is rejected: upper-case letters and digits, as a code is usually written. */
58
+ const NOBODY_HAS_THIS_CODE = "ZZ-NO-SUCH-CODE-0000";
59
+ function runDiscountConformance(make, tools, options) {
60
+ const { describe, it, expect } = tools;
61
+ if (options.scenarios.length === 0) throw new Error("runDiscountConformance needs at least one scenario that earns a discount");
62
+ const contextAt = async (now = TEST_NOW) => options.context ? await options.context(now) : testContext({ now });
63
+ const apply = async (port, input) => port.apply(await contextAt(), frozen$1(input));
64
+ const subtotal = (input) => input.lines.reduce((sum, item) => sum + lineTotalMinor(item.unitAmountMinor, item.quantity), 0);
65
+ describe("DiscountPort conformance", () => {
66
+ for (const scenario of options.scenarios) {
67
+ it(`answers whole amounts in the order's currency with a title, never more than the subtotal (${scenario.name})`, async () => {
68
+ const result = await apply(await make(), scenario.input);
69
+ let total = 0;
70
+ for (const discount of result.applied) {
71
+ expect(isMinorUnits(discount.amountMinor)).toBe(true);
72
+ expect(isCurrencyCode(discount.currency) && discount.currency === scenario.input.currency).toBe(true);
73
+ expect(typeof discount.title === "string" && discount.title.length > 0).toBe(true);
74
+ total += discount.amountMinor;
75
+ }
76
+ expect(total <= subtotal(scenario.input)).toBe(true);
77
+ });
78
+ it(`names real lines that add up to the discount, none discounted by more than it costs (${scenario.name})`, async () => {
79
+ const named = (await apply(await make(), scenario.input)).applied.filter((discount) => discount.lines !== void 0);
80
+ const problems = checkLineDiscounts(scenario.input.lines, named);
81
+ expect(problems.map((item) => `${item.path}: ${item.message}`)).toEqual([]);
82
+ });
83
+ it(`gives the same answer to the same input, and leaves what it was given as it was (${scenario.name})`, async () => {
84
+ const port = await make();
85
+ const first = await apply(port, scenario.input);
86
+ const second = await apply(port, scenario.input);
87
+ expect(second).toEqual(first);
88
+ });
89
+ }
90
+ it("answers every code a buyer typed once, as applied or rejected with a reason from the list, not both", async () => {
91
+ const scenario = options.scenarios[0].input;
92
+ const result = await apply(await make(), {
93
+ ...scenario,
94
+ codes: [NOBODY_HAS_THIS_CODE, NOBODY_HAS_THIS_CODE]
95
+ });
96
+ const rejected = result.rejected.filter((item) => item.code === NOBODY_HAS_THIS_CODE);
97
+ expect(rejected.length).toBe(1);
98
+ expect(DISCOUNT_REJECTIONS.includes(rejected[0]?.reason ?? "")).toBe(true);
99
+ expect(result.applied.some((discount) => discount.code === NOBODY_HAS_THIS_CODE)).toBe(false);
100
+ });
101
+ if (options.plain) {
102
+ const plain = options.plain;
103
+ it("gives nothing to an order that earns nothing", async () => {
104
+ const result = await apply(await make(), plain);
105
+ expect(result.applied).toEqual([]);
106
+ });
107
+ }
108
+ });
109
+ }
110
+ //#endregion
111
+ //#region src/testing/fake-discount.ts
112
+ /**
113
+ * A discount port for tests: `percentBp` off every line of at least `fromQuantity`, with the lines it came off named. It knows no code, so a
114
+ * code a buyer typed is `rejected` as `unknown`. The reference `runDiscountConformance` is written from.
115
+ */
116
+ function createFlatDiscount(options = {}) {
117
+ const percentBp = options.percentBp ?? 1e3;
118
+ const fromQuantity = options.fromQuantity ?? 5;
119
+ const title = options.title ?? "Volume discount";
120
+ return { async apply(_ctx, input) {
121
+ const parts = input.lines.flatMap((line, index) => line.quantity >= fromQuantity ? [{
122
+ index,
123
+ amountMinor: basisPointsOf(lineTotalMinor(line.unitAmountMinor, line.quantity), percentBp)
124
+ }] : []);
125
+ const amountMinor = parts.reduce((sum, part) => sum + part.amountMinor, 0);
126
+ return {
127
+ applied: amountMinor > 0 ? [{
128
+ title,
129
+ amountMinor,
130
+ currency: input.currency,
131
+ lines: parts
132
+ }] : [],
133
+ rejected: [...new Set(input.codes)].map((code) => ({
134
+ code,
135
+ reason: "unknown"
136
+ }))
137
+ };
138
+ } };
139
+ }
140
+ //#endregion
141
+ //#region src/testing/fake-tax.ts
142
+ /**
143
+ * A tax port for tests: one rate for everything, worked per line on what is paid (the line after its discount: `lineDiscountsMinor` when it is
144
+ * given, else the discount spread in proportion), shipping taxed on top. A line whose price includes the tax contributes to `includedMinor`
145
+ * (the tax inside the price), the others and the shipping to `addedMinor`. The rate and rounding are the money helpers', so it is also the
146
+ * small reference the conformance suite is written from.
147
+ */
148
+ function createFlatTax(options = {}) {
149
+ const rateBp = options.rateBp ?? 1600;
150
+ const code = options.code ?? "IVA";
151
+ return { async compute(_ctx, input) {
152
+ const shares = input.lineDiscountsMinor ?? spreadDiscount(input.lines, input.discountMinor);
153
+ let added = 0;
154
+ let included = 0;
155
+ input.lines.forEach((line, index) => {
156
+ const paid = lineTotalMinor(line.unitAmountMinor, line.quantity) - (shares[index] ?? 0);
157
+ if (line.taxIncluded) included += taxIncludedIn(paid, rateBp);
158
+ else added += basisPointsOf(paid, rateBp);
159
+ });
160
+ added += basisPointsOf(input.shippingMinor, rateBp);
161
+ const title = `${code} ${rateBp / 100}%`;
162
+ const breakdown = [];
163
+ if (added > 0) breakdown.push({
164
+ code,
165
+ title,
166
+ rateBp,
167
+ amountMinor: added,
168
+ included: false
169
+ });
170
+ if (included > 0) breakdown.push({
171
+ code,
172
+ title,
173
+ rateBp,
174
+ amountMinor: included,
175
+ included: true
176
+ });
177
+ return {
178
+ currency: input.currency,
179
+ addedMinor: added,
180
+ includedMinor: included,
181
+ breakdown
182
+ };
183
+ } };
184
+ }
185
+ //#endregion
186
+ //#region src/testing/line-hold-conformance.ts
187
+ /**
188
+ * The behaviour every line source that holds its own places must have (`hold`, `commit` and `release`, optional on `LineSource` since
189
+ * 0.2.0), as a test suite. Run it against the in-memory slots in Node (`runLineHoldConformance((places) => createMemorySlots(places), { describe,
190
+ * it, expect })`) and against the feature's own source in workerd, with a `make` that writes the places into its tables (a slot of N seats
191
+ * for each ref) and a `context` that carries the project's `env.DB`. Each test makes a source of its own, so `make` must return one with
192
+ * nothing else in it.
193
+ *
194
+ * The rules are the stock port's, by the ask instead of a stock subject (`runStockConformance`): a hold is every ask or none and the same
195
+ * `holdId` is the same hold; the last place goes to exactly one of two buyers; a ref the source does not have is never held; the clock is
196
+ * `ctx.now`; places come back at `expiresAt` by themselves; a payment after that still commits and says it was late (and oversold when the
197
+ * place went meanwhile); `release` after `commit` is refused; `resolve` counts what a hold took. The suite reads the places left from what a
198
+ * hold of more than there is answers (`short[0].available`), and once from `resolve` (`sold_out` when none is left).
199
+ */
200
+ /** More places than any test makes: a hold of this many is always short, and its answer says how many are left. */
201
+ const PROBE = 1e3;
202
+ function runLineHoldConformance(make, tools, options = {}) {
203
+ const { describe, it, expect } = tools;
204
+ const contextAt = async (now) => options.context ? await options.context(now) : testContext({ now });
205
+ const T0 = TEST_NOW;
206
+ const TTL = 1e3;
207
+ let probes = 0;
208
+ const source = async (places) => {
209
+ const made = await make(places);
210
+ if (!holdsCapacity(made)) throw new Error("this source does not hold its own places: runLineHoldConformance is for a source with hold, commit and release");
211
+ return made;
212
+ };
213
+ const ask = (ref, quantity, options) => ({
214
+ ref,
215
+ quantity,
216
+ ...options ? { options } : {}
217
+ });
218
+ const hold = async (port, holdId, lines, now = T0, ttl = TTL) => port.hold(await contextAt(now), {
219
+ holdId,
220
+ lines,
221
+ expiresAt: now + ttl
222
+ });
223
+ const settle = async (port, what, holdId, now = T0) => port[what](await contextAt(now), holdId);
224
+ /** The places of `ref` left at `now`, from what a hold of more than there is answers. It holds nothing. */
225
+ const left = async (port, ref, now = T0) => {
226
+ probes += 1;
227
+ const probe = await hold(port, `conformance-probe-${probes}`, [ask(ref, PROBE)], now);
228
+ return probe.ok || probe.code !== "insufficient_stock" ? void 0 : probe.short[0]?.available;
229
+ };
230
+ describe("LineSource holds conformance", () => {
231
+ it("is a source that holds: hold, commit and release are all there", async () => {
232
+ expect(holdsCapacity(await source({ a: 1 }))).toBe(true);
233
+ });
234
+ it("holds places and gives them back on release", async () => {
235
+ const port = await source({ a: 3 });
236
+ const held = await hold(port, "h1", [ask("a", 2)]);
237
+ expect(held.ok && [
238
+ held.hold.status,
239
+ held.hold.expiresAt,
240
+ held.hold.lines
241
+ ]).toEqual([
242
+ "held",
243
+ T0 + TTL,
244
+ [ask("a", 2)]
245
+ ]);
246
+ expect(await left(port, "a")).toBe(1);
247
+ const released = await settle(port, "release", "h1");
248
+ expect(released.ok && [released.changed, released.hold.status]).toEqual([true, "released"]);
249
+ expect(await left(port, "a")).toBe(3);
250
+ });
251
+ it("counts the places a hold took when it resolves, and gives them back", async () => {
252
+ const port = await source({
253
+ a: 1,
254
+ b: 3
255
+ });
256
+ const resolved = async (ref) => (await port.resolve(await contextAt(T0), [ask(ref, 1)]))[0];
257
+ expect((await resolved("a"))?.availability).toBe("available");
258
+ await hold(port, "h1", [ask("a", 1)]);
259
+ expect((await resolved("a"))?.availability).toBe("sold_out");
260
+ await settle(port, "release", "h1");
261
+ expect((await resolved("a"))?.availability).toBe("available");
262
+ await hold(port, "h2", [ask("b", 2)]);
263
+ const one = await resolved("b");
264
+ expect([one?.availability, typeof one?.maxQuantity === "number" && one.maxQuantity <= 1]).toEqual(["available", true]);
265
+ });
266
+ it("treats the same hold id as the same hold, and other asks under it as a conflict", async () => {
267
+ const port = await source({
268
+ a: 5,
269
+ b: 5
270
+ });
271
+ expect((await hold(port, "h1", [ask("a", 1)])).ok).toBe(true);
272
+ expect((await hold(port, "h1", [ask("a", 1)])).ok).toBe(true);
273
+ expect(await left(port, "a")).toBe(4);
274
+ for (const lines of [
275
+ [ask("a", 2)],
276
+ [ask("b", 1)],
277
+ [ask("a", 1), ask("b", 1)],
278
+ [ask("a", 1, { staff: "ana" })]
279
+ ]) expect(await hold(port, "h1", lines)).toEqual({
280
+ ok: false,
281
+ code: "hold_conflict"
282
+ });
283
+ expect([await left(port, "a"), await left(port, "b")]).toEqual([4, 5]);
284
+ });
285
+ it("adds up two asks of one ref before it checks", async () => {
286
+ const port = await source({ a: 3 });
287
+ expect(await hold(port, "h1", [ask("a", 2), ask("a", 2)])).toEqual({
288
+ ok: false,
289
+ code: "insufficient_stock",
290
+ short: [{
291
+ ref: "a",
292
+ requested: 4,
293
+ available: 3
294
+ }]
295
+ });
296
+ expect(await left(port, "a")).toBe(3);
297
+ });
298
+ it("holds every ask or none: a short ask takes nothing of the others", async () => {
299
+ const port = await source({
300
+ a: 1,
301
+ b: 5
302
+ });
303
+ expect(await hold(port, "h1", [ask("b", 2), ask("a", 2)])).toEqual({
304
+ ok: false,
305
+ code: "insufficient_stock",
306
+ short: [{
307
+ ref: "a",
308
+ requested: 2,
309
+ available: 1
310
+ }]
311
+ });
312
+ expect([await left(port, "a"), await left(port, "b")]).toEqual([1, 5]);
313
+ });
314
+ it("gives the last place to exactly one of two buyers", async () => {
315
+ const port = await source({ last: 1 });
316
+ const results = await Promise.all([hold(port, "buyer-1", [ask("last", 1)]), hold(port, "buyer-2", [ask("last", 1)])]);
317
+ expect(results.filter((result) => result.ok).length).toBe(1);
318
+ expect(results.filter((result) => !result.ok && result.code === "insufficient_stock").length).toBe(1);
319
+ expect(await left(port, "last")).toBe(0);
320
+ });
321
+ it("cannot hold a ref it does not have", async () => {
322
+ const port = await source({ a: 1 });
323
+ expect(await hold(port, "h1", [ask("nope", 1)])).toEqual({
324
+ ok: false,
325
+ code: "insufficient_stock",
326
+ short: [{
327
+ ref: "nope",
328
+ requested: 1,
329
+ available: 0
330
+ }]
331
+ });
332
+ const both = await hold(port, "h2", [ask("a", 1), ask("nope", 1)]);
333
+ expect(both.ok === false && both.code).toBe("insufficient_stock");
334
+ expect(await left(port, "a")).toBe(1);
335
+ });
336
+ it("keeps the places sold on commit, and commits once", async () => {
337
+ const port = await source({ a: 3 });
338
+ await hold(port, "h1", [ask("a", 2)]);
339
+ const committed = await settle(port, "commit", "h1");
340
+ expect(committed.ok && [
341
+ committed.changed,
342
+ committed.hold.status,
343
+ committed.late
344
+ ]).toEqual([
345
+ true,
346
+ "committed",
347
+ void 0
348
+ ]);
349
+ const again = await settle(port, "commit", "h1");
350
+ expect(again.ok && again.changed).toBe(false);
351
+ expect(await left(port, "a")).toBe(1);
352
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(1);
353
+ });
354
+ it("refuses to release a hold that was committed", async () => {
355
+ const port = await source({ a: 3 });
356
+ await hold(port, "h1", [ask("a", 2)]);
357
+ await settle(port, "commit", "h1");
358
+ expect(await settle(port, "release", "h1")).toEqual({
359
+ ok: false,
360
+ code: "invalid_state",
361
+ status: "committed"
362
+ });
363
+ expect(await left(port, "a")).toBe(1);
364
+ });
365
+ it("releases once, then says nothing changed, and answers a hold it does not know", async () => {
366
+ const port = await source({ a: 3 });
367
+ await hold(port, "h1", [ask("a", 2)]);
368
+ expect((await settle(port, "release", "h1")).ok).toBe(true);
369
+ const again = await settle(port, "release", "h1");
370
+ expect(again.ok && again.changed).toBe(false);
371
+ expect(await settle(port, "commit", "nope")).toEqual({
372
+ ok: false,
373
+ code: "unknown_hold"
374
+ });
375
+ expect(await settle(port, "release", "nope")).toEqual({
376
+ ok: false,
377
+ code: "unknown_hold"
378
+ });
379
+ expect(await left(port, "a")).toBe(3);
380
+ });
381
+ it("gives the places back by themselves at expiresAt, by the context's clock", async () => {
382
+ const port = await source({ a: 3 });
383
+ await hold(port, "h1", [ask("a", 2)]);
384
+ expect(await left(port, "a", T0 + TTL - 1)).toBe(1);
385
+ expect(await left(port, "a", T0 + TTL)).toBe(3);
386
+ });
387
+ it("commits a payment that arrives after the hold ran out, takes the places again and says it was late", async () => {
388
+ const port = await source({ a: 3 });
389
+ await hold(port, "h1", [ask("a", 2)]);
390
+ const late = await settle(port, "commit", "h1", T0 + 2 * TTL);
391
+ expect(late.ok && [
392
+ late.changed,
393
+ late.late,
394
+ late.oversold
395
+ ]).toEqual([
396
+ true,
397
+ true,
398
+ void 0
399
+ ]);
400
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(1);
401
+ });
402
+ it("leaves a hold that ran out expired, not released, when it is released late", async () => {
403
+ const port = await source({ a: 3 });
404
+ await hold(port, "h1", [ask("a", 2)]);
405
+ const result = await settle(port, "release", "h1", T0 + 2 * TTL);
406
+ expect(result.ok && [result.changed, result.hold.status]).toEqual([false, "expired"]);
407
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(3);
408
+ });
409
+ it("does not call a late commit oversold when it takes the places exactly to none", async () => {
410
+ const port = await source({ a: 2 });
411
+ await hold(port, "h1", [ask("a", 2)]);
412
+ const late = await settle(port, "commit", "h1", T0 + 2 * TTL);
413
+ expect(late.ok && [late.late, late.oversold]).toEqual([true, void 0]);
414
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(0);
415
+ });
416
+ it("flags a late commit that finds the places gone", async () => {
417
+ const port = await source({ a: 2 });
418
+ await hold(port, "h1", [ask("a", 2)]);
419
+ expect((await hold(port, "h2", [ask("a", 2)], T0 + 2 * TTL)).ok).toBe(true);
420
+ const late = await settle(port, "commit", "h1", T0 + 2 * TTL);
421
+ expect(late.ok && [late.late, late.oversold]).toEqual([true, true]);
422
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(0);
423
+ });
424
+ it("answers hold_closed for a hold id whose hold is over, after a release, an expiry or a commit", async () => {
425
+ const port = await source({ a: 3 });
426
+ await hold(port, "released", [ask("a", 1)]);
427
+ await settle(port, "release", "released");
428
+ expect(await hold(port, "released", [ask("a", 1)])).toEqual({
429
+ ok: false,
430
+ code: "hold_closed",
431
+ status: "released"
432
+ });
433
+ await hold(port, "expired", [ask("a", 1)]);
434
+ expect(await hold(port, "expired", [ask("a", 1)], T0 + 2 * TTL)).toEqual({
435
+ ok: false,
436
+ code: "hold_closed",
437
+ status: "expired"
438
+ });
439
+ await hold(port, "committed", [ask("a", 1)], T0 + 3 * TTL);
440
+ await settle(port, "commit", "committed", T0 + 3 * TTL);
441
+ expect(await hold(port, "committed", [ask("a", 1)], T0 + 3 * TTL)).toEqual({
442
+ ok: false,
443
+ code: "hold_closed",
444
+ status: "committed"
445
+ });
446
+ expect(await left(port, "a", T0 + 3 * TTL)).toBe(2);
447
+ });
448
+ it("extends a live hold when it is asked again with a later time, and never shortens it", async () => {
449
+ const port = await source({ a: 3 });
450
+ await hold(port, "h1", [ask("a", 2)]);
451
+ const longer = await hold(port, "h1", [ask("a", 2)], T0, 5 * TTL);
452
+ expect(longer.ok && longer.hold.expiresAt).toBe(T0 + 5 * TTL);
453
+ const shorter = await hold(port, "h1", [ask("a", 2)], T0, TTL / 10);
454
+ expect(shorter.ok && shorter.hold.expiresAt).toBe(T0 + 5 * TTL);
455
+ expect(await left(port, "a", T0 + 4 * TTL)).toBe(1);
456
+ expect(await left(port, "a", T0 + 5 * TTL)).toBe(3);
457
+ });
458
+ it("holds for at most 14 days", async () => {
459
+ const port = await source({ a: 3 });
460
+ expect((await hold(port, "ok", [ask("a", 1)], T0, MAX_HOLD_MS)).ok).toBe(true);
461
+ const past = await hold(port, "late", [ask("a", 1)], T0, MAX_HOLD_MS + 1);
462
+ expect(past.ok === false && past.code).toBe("invalid_request");
463
+ expect(await left(port, "a")).toBe(2);
464
+ });
465
+ it("refuses a request that is not valid and takes nothing", async () => {
466
+ const port = await source({ a: 3 });
467
+ const ctx = await contextAt(T0);
468
+ const bad = [
469
+ {
470
+ holdId: "",
471
+ lines: [ask("a", 1)],
472
+ expiresAt: T0 + TTL
473
+ },
474
+ {
475
+ holdId: "h",
476
+ lines: [],
477
+ expiresAt: T0 + TTL
478
+ },
479
+ {
480
+ holdId: "h",
481
+ lines: [ask("a", 0)],
482
+ expiresAt: T0 + TTL
483
+ },
484
+ {
485
+ holdId: "h",
486
+ lines: [ask("a", 1.5)],
487
+ expiresAt: T0 + TTL
488
+ },
489
+ {
490
+ holdId: "h",
491
+ lines: [ask("a", 1)],
492
+ expiresAt: T0
493
+ }
494
+ ];
495
+ for (const request of bad) {
496
+ const result = await port.hold(ctx, request);
497
+ expect(result.ok === false && result.code).toBe("invalid_request");
498
+ }
499
+ expect(await left(port, "a")).toBe(3);
500
+ });
501
+ });
502
+ }
503
+ //#endregion
504
+ //#region src/testing/line-source.ts
505
+ const HARNESS_CHECKS = [
506
+ "empty",
507
+ "shape",
508
+ "order",
509
+ "unknown",
510
+ "unavailable",
511
+ "server-prices",
512
+ "no-mutation",
513
+ "repeatable",
514
+ "duplicates",
515
+ "buyer-price",
516
+ "holds"
517
+ ];
518
+ /** Refs a hostile buyer sends. A source answers each with a `not_found` line, whatever it does with a ref. */
519
+ const HOSTILE_REFS = [
520
+ "' OR 1=1 --",
521
+ "__proto__",
522
+ "constructor",
523
+ "toString",
524
+ "../../etc/passwd",
525
+ "%00%0a",
526
+ "ñandú 🌮",
527
+ "x".repeat(120)
528
+ ];
529
+ /**
530
+ * The option names the harness tries as a browser's own price. A source may not read a price from any of them (except the one `buyerPrice`
531
+ * declares); add the names your source reads for other things with `extraPriceOptions`.
532
+ */
533
+ const PRICE_OPTION_NAMES = [
534
+ "price",
535
+ "amount",
536
+ "unitAmountMinor",
537
+ "unit_price",
538
+ "total",
539
+ "currency",
540
+ "discount"
541
+ ];
542
+ const PRICE_OPTIONS = {
543
+ price: "1",
544
+ amount: "1",
545
+ unitAmountMinor: "1",
546
+ unit_price: "1",
547
+ total: "1",
548
+ currency: "USD",
549
+ discount: "100"
550
+ };
551
+ const asks = (...items) => items.map(([ref, quantity, options]) => ({
552
+ ref,
553
+ quantity,
554
+ ...options ? { options } : {}
555
+ }));
556
+ function deepFreeze(value) {
557
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
558
+ Object.freeze(value);
559
+ for (const item of Object.values(value)) deepFreeze(item);
560
+ }
561
+ return value;
562
+ }
563
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
564
+ async function resolveOnce(source, ctx, given) {
565
+ try {
566
+ return {
567
+ ok: true,
568
+ lines: await source.resolve(ctx, deepFreeze(structuredClone(given)))
569
+ };
570
+ } catch (error) {
571
+ return {
572
+ ok: false,
573
+ error: error instanceof Error ? `${error.name}: ${error.message}` : String(error)
574
+ };
575
+ }
576
+ }
577
+ /** Runs the checks and returns what failed; an empty list is a source a checkout can rely on. */
578
+ async function checkLineSource(source, fixtures, only) {
579
+ const ctx = fixtures.ctx ?? testContext();
580
+ const findings = [];
581
+ const want = (check) => !only || only.includes(check);
582
+ const fail = (check, message, problems) => findings.push({
583
+ check,
584
+ message,
585
+ ...problems ? { problems } : {}
586
+ });
587
+ /** Resolves, and reports a throw or a malformed answer under `check`. */
588
+ const resolve = async (check, label, given) => {
589
+ const result = await resolveOnce(source, ctx, given);
590
+ if (!result.ok) {
591
+ fail(check, `${label}: the source threw ${result.error}`);
592
+ return;
593
+ }
594
+ const problems = checkResolvedLines(given, result.lines);
595
+ if (problems.length > 0) {
596
+ fail(check, `${label}: the answer is not valid`, problems);
597
+ return;
598
+ }
599
+ return result.lines;
600
+ };
601
+ if (fixtures.known.length === 0) {
602
+ fail("shape", "fixtures.known is empty: name at least one ref the source can sell");
603
+ return findings;
604
+ }
605
+ const first = fixtures.known[0];
606
+ if (want("empty")) {
607
+ const result = await resolveOnce(source, ctx, []);
608
+ if (!result.ok) fail("empty", `no asks: the source threw ${result.error}`);
609
+ else if (!Array.isArray(result.lines) || result.lines.length !== 0) fail("empty", "no asks: the source must answer with an empty list");
610
+ }
611
+ if (want("shape")) for (const known of fixtures.known) for (const quantity of [
612
+ 1,
613
+ 3,
614
+ 99
615
+ ]) {
616
+ const label = `${known.ref} x${quantity}`;
617
+ const line = (await resolve("shape", label, asks([
618
+ known.ref,
619
+ quantity,
620
+ known.options
621
+ ])))?.[0];
622
+ if (!line) continue;
623
+ if (line.availability !== "available") fail("shape", `${label}: a known ref must be available, got ${line.availability}`);
624
+ if (known.unitAmountMinor !== void 0 && line.unitAmountMinor !== known.unitAmountMinor) fail("shape", `${label}: the price is ${line.unitAmountMinor}, expected ${known.unitAmountMinor}`);
625
+ if (known.currency !== void 0 && line.currency !== known.currency) fail("shape", `${label}: the currency is ${line.currency}, expected ${known.currency}`);
626
+ }
627
+ if (want("order")) {
628
+ const given = asks(...fixtures.known.map((known, index) => [
629
+ known.ref,
630
+ index + 1,
631
+ known.options
632
+ ]));
633
+ given.push(...asks([fixtures.missingRef ?? "no-such-thing-here", 2]));
634
+ const batch = await resolve("order", "a batch", given);
635
+ const reversed = await resolve("order", "the same batch reversed", [...given].reverse());
636
+ if (batch && reversed && !same([...batch].reverse(), reversed)) fail("order", "the same asks in another order give different lines: lines must follow the asks one to one");
637
+ if (batch) for (const [index, ask] of given.entries()) {
638
+ const alone = await resolve("order", `${ask.ref} alone`, [ask]);
639
+ if (alone && !same(alone[0], batch[index])) fail("order", `${ask.ref} is priced differently alone and in a batch`);
640
+ }
641
+ }
642
+ if (want("unknown")) {
643
+ const refs = [fixtures.missingRef ?? "no-such-thing-here", ...HOSTILE_REFS];
644
+ for (const ref of refs) {
645
+ const line = (await resolve("unknown", `ref ${JSON.stringify(ref.length > 30 ? `${ref.slice(0, 27)}...` : ref)}`, asks([ref, 1])))?.[0];
646
+ if (line && line.availability !== "not_found") fail("unknown", `ref ${JSON.stringify(ref.slice(0, 30))} does not exist but came back ${line.availability}`);
647
+ }
648
+ }
649
+ if (want("unavailable")) for (const item of fixtures.unavailable ?? []) {
650
+ const line = (await resolve("unavailable", item.ref, asks([item.ref, 1])))?.[0];
651
+ if (line && line.availability !== item.availability) fail("unavailable", `${item.ref} must be ${item.availability}, got ${line.availability}`);
652
+ }
653
+ if (want("server-prices")) for (const known of fixtures.known) {
654
+ const plain = await resolve("server-prices", known.ref, asks([
655
+ known.ref,
656
+ 1,
657
+ known.options
658
+ ]));
659
+ const names = [...Object.entries(PRICE_OPTIONS), ...(fixtures.extraPriceOptions ?? []).map((name) => [name, "1"])].filter(([name]) => !(fixtures.buyerPrice?.ref === known.ref && fixtures.buyerPrice.option === name));
660
+ const hostile = await resolve("server-prices", `${known.ref} with a price in its options`, asks([
661
+ known.ref,
662
+ 1,
663
+ {
664
+ ...known.options,
665
+ ...Object.fromEntries(names)
666
+ }
667
+ ]));
668
+ const a = plain?.[0];
669
+ const b = hostile?.[0];
670
+ if (a && b && (a.unitAmountMinor !== b.unitAmountMinor || a.currency !== b.currency)) fail("server-prices", `${known.ref}: options the buyer sent changed the price (${a.unitAmountMinor} ${a.currency} became ${b.unitAmountMinor} ${b.currency}): a price is the source's alone`);
671
+ }
672
+ if (want("no-mutation")) {
673
+ const result = await resolveOnce(source, ctx, asks([
674
+ first.ref,
675
+ 2,
676
+ first.options
677
+ ], [fixtures.missingRef ?? "no-such-thing-here", 1]));
678
+ if (!result.ok) fail("no-mutation", `the source cannot be given read-only asks (does it sort or edit them in place?): ${result.error}`);
679
+ }
680
+ if (want("repeatable")) {
681
+ const given = asks([
682
+ first.ref,
683
+ 2,
684
+ first.options
685
+ ]);
686
+ const one = await resolve("repeatable", "the first call", given);
687
+ const two = await resolve("repeatable", "the second call", given);
688
+ if (one && two && !same(one, two)) fail("repeatable", "the same ask twice gives different lines (a price must come from data, not from the clock or a counter)");
689
+ }
690
+ if (want("duplicates")) await resolve("duplicates", "the same ref twice", asks([
691
+ first.ref,
692
+ 1,
693
+ first.options
694
+ ], [
695
+ first.ref,
696
+ 2,
697
+ first.options
698
+ ]));
699
+ if (want("holds")) {
700
+ const methods = [
701
+ "hold",
702
+ "commit",
703
+ "release"
704
+ ].filter((method) => source[method] !== void 0);
705
+ const functions = methods.filter((method) => typeof source[method] === "function");
706
+ if (methods.length > 0 && (methods.length < 3 || functions.length < 3)) fail("holds", `the source has ${methods.join(", ")}: a source that holds its own places has hold, commit and release, all functions (run runLineHoldConformance on it), or none`);
707
+ }
708
+ if (want("buyer-price") && fixtures.buyerPrice) {
709
+ const { ref, option, min, max } = fixtures.buyerPrice;
710
+ const base = fixtures.known.find((known) => known.ref === ref);
711
+ if (!base) fail("buyer-price", `buyerPrice.ref ${ref} is not in fixtures.known`);
712
+ else {
713
+ const priced = async (value) => (await resolve("buyer-price", `${ref} at ${JSON.stringify(value)}`, asks([
714
+ ref,
715
+ 1,
716
+ {
717
+ ...base.options,
718
+ [option]: value
719
+ }
720
+ ])))?.[0];
721
+ for (const amount of [
722
+ min,
723
+ Math.floor((min + max) / 2),
724
+ max
725
+ ]) {
726
+ const line = await priced(String(amount));
727
+ if (line && (line.availability !== "available" || line.unitAmountMinor !== amount)) fail("buyer-price", `an amount of ${amount} inside ${min} to ${max} must be priced as ${amount} and available, got ${line.availability} at ${line.unitAmountMinor}`);
728
+ else if (line && amount === max && line.maxQuantity !== 1) fail("buyer-price", `a line the buyer prices must have maxQuantity 1, or a quantity multiplies the amount past ${max}; got ${line.maxQuantity}`);
729
+ }
730
+ const refused = [
731
+ String(max + 1),
732
+ "12.5",
733
+ "1e3",
734
+ "-5",
735
+ "0x10",
736
+ "",
737
+ " 100",
738
+ "100 ",
739
+ "abc",
740
+ "٣٠٠",
741
+ "9".repeat(30),
742
+ "+100",
743
+ "1_000",
744
+ "NaN",
745
+ "Infinity"
746
+ ];
747
+ refused.push(String(min - 1));
748
+ for (const value of refused) {
749
+ const line = await priced(value);
750
+ if (line?.availability === "available") fail("buyer-price", `${JSON.stringify(value)} is not a whole amount inside ${min} to ${max} but the line is available at ${line.unitAmountMinor}`);
751
+ }
752
+ }
753
+ }
754
+ return findings;
755
+ }
756
+ const WHAT = {
757
+ empty: "answers no asks with no lines",
758
+ shape: "answers a known ref with a valid, available line at the server's price",
759
+ order: "returns one line per ask, in the order asked, whatever else is in the batch",
760
+ unknown: "answers a ref that does not exist, or a hostile one, with a not_found line and no exception",
761
+ unavailable: "reports a sold out or not orderable item as such",
762
+ "server-prices": "ignores a price the buyer put in the options",
763
+ "no-mutation": "leaves the asks it was given alone",
764
+ repeatable: "gives the same line for the same ask",
765
+ duplicates: "keeps two asks for one ref as two lines",
766
+ "buyer-price": "prices the amount a buyer chooses only when it is a whole number inside the bounds",
767
+ holds: "holds its own places with hold, commit and release together, or not at all"
768
+ };
769
+ /** The checks as a test suite: one `it` for each, failing with the findings. Run it in the feature's own tests, in workerd for a source that reads D1. */
770
+ function runLineSourceConformance(source, fixtures, tools) {
771
+ const { describe, it, expect } = tools;
772
+ describe("line source conformance", () => {
773
+ for (const check of HARNESS_CHECKS) {
774
+ if (check === "buyer-price" && !fixtures.buyerPrice) continue;
775
+ it(WHAT[check], async () => {
776
+ expect(await checkLineSource(source, fixtures, [check])).toEqual([]);
777
+ });
778
+ }
779
+ });
780
+ }
781
+ //#endregion
782
+ //#region src/testing/payments.ts
783
+ /**
784
+ * A payments port for tests: it checks each request the way a real one must (`checkStartPayment`), answers a payment id and a redirect URL,
785
+ * and is idempotent by `reference` (the same request again is the same payment; a different one under the same reference is refused). It
786
+ * cancels a payment that is not paid, once, and then refuses to start its reference again; `markPaid` stands in for the provider's event.
787
+ */
788
+ function createFakePayments() {
789
+ const accepted = /* @__PURE__ */ new Map();
790
+ const started = [];
791
+ const cancelled = [];
792
+ let failure;
793
+ return {
794
+ started,
795
+ cancelled,
796
+ failNext(next) {
797
+ failure = next;
798
+ },
799
+ markPaid(reference) {
800
+ const payment = accepted.get(reference);
801
+ if (!payment) throw new Error(`markPaid: no payment was ever started for ${reference}`);
802
+ payment.state = "paid";
803
+ },
804
+ async start(_ctx, input) {
805
+ if (failure) {
806
+ const next = failure;
807
+ failure = void 0;
808
+ return next;
809
+ }
810
+ const problems = checkStartPayment(input);
811
+ if (problems.length > 0) return {
812
+ ok: false,
813
+ code: "invalid_request",
814
+ message: problems.map((item) => `${item.path}: ${item.message}`).join("; ")
815
+ };
816
+ const key = JSON.stringify(input);
817
+ const seen = accepted.get(input.reference);
818
+ if (seen) {
819
+ if (seen.state === "cancelled") return {
820
+ ok: false,
821
+ code: "invalid_request",
822
+ message: "reference: the payment for this reference was cancelled"
823
+ };
824
+ if (seen.state === "paid") return {
825
+ ok: false,
826
+ code: "invalid_request",
827
+ message: "reference: this reference has already been paid"
828
+ };
829
+ return seen.input === key ? seen.result : {
830
+ ok: false,
831
+ code: "invalid_request",
832
+ message: "reference: this reference was already used for a different payment"
833
+ };
834
+ }
835
+ const paymentId = `pay_fake_${accepted.size + 1}`;
836
+ const result = {
837
+ ok: true,
838
+ paymentId,
839
+ redirectUrl: `https://pay.example.test/${paymentId}`,
840
+ ...input.expiresAt === void 0 ? {} : { expiresAt: input.expiresAt }
841
+ };
842
+ accepted.set(input.reference, {
843
+ input: key,
844
+ result,
845
+ state: "open"
846
+ });
847
+ started.push(input);
848
+ return result;
849
+ },
850
+ async cancel(_ctx, reference) {
851
+ const payment = accepted.get(reference);
852
+ if (!payment) return {
853
+ ok: false,
854
+ code: "unknown_payment",
855
+ message: "no payment was started for this reference"
856
+ };
857
+ if (payment.state === "paid") return {
858
+ ok: false,
859
+ code: "already_paid",
860
+ message: "this payment was paid: refund it instead"
861
+ };
862
+ if (payment.state === "cancelled") return {
863
+ ok: true,
864
+ changed: false
865
+ };
866
+ payment.state = "cancelled";
867
+ cancelled.push(reference);
868
+ return {
869
+ ok: true,
870
+ changed: true
871
+ };
872
+ }
873
+ };
874
+ }
875
+ //#endregion
876
+ //#region src/testing/payments-conformance.ts
877
+ const SHIPPING = {
878
+ kind: "shipping",
879
+ title: "Envío",
880
+ amountMinor: 9900,
881
+ currency: "MXN"
882
+ };
883
+ const TAX = {
884
+ kind: "tax",
885
+ title: "IVA",
886
+ amountMinor: 4800,
887
+ currency: "MXN"
888
+ };
889
+ const discount = (amountMinor) => ({
890
+ kind: "discount",
891
+ title: "Promo",
892
+ amountMinor,
893
+ currency: "MXN"
894
+ });
895
+ /** Two of a thing at 150.00 and shipping of 99.00: 399.00. */
896
+ const request = (reference, patch = {}) => ({
897
+ reference,
898
+ lines: [{
899
+ ref: "conformance-item",
900
+ title: "Conformance item",
901
+ quantity: 2,
902
+ unitAmountMinor: 15e3,
903
+ currency: "MXN"
904
+ }],
905
+ adjustments: [SHIPPING],
906
+ totalMinor: 39900,
907
+ currency: "MXN",
908
+ successUrl: "https://shop.example/orders/ok",
909
+ cancelUrl: "https://shop.example/orders/cancel",
910
+ ...patch
911
+ });
912
+ const isPage = (value) => {
913
+ if (typeof value !== "string") return false;
914
+ try {
915
+ return ["https:", "http:"].includes(new URL(value).protocol);
916
+ } catch {
917
+ return false;
918
+ }
919
+ };
920
+ function runPaymentsConformance(make, tools, options = {}) {
921
+ const { describe, it, expect } = tools;
922
+ const contextAt = async (now = TEST_NOW) => options.context ? await options.context(now) : testContext({ now });
923
+ let made = 0;
924
+ const reference = () => `${options.referencePrefix ?? "conformance"}-${++made}`;
925
+ const start = async (port, input) => port.start(await contextAt(), input);
926
+ const codeOf = (result) => result.ok ? "ok" : result.code;
927
+ describe("PaymentsPort conformance", () => {
928
+ it("starts a payment and answers its id and the page to send the payer to", async () => {
929
+ const result = await start(await make(), request(reference()));
930
+ expect(result.ok).toBe(true);
931
+ expect(result.ok && typeof result.paymentId === "string" && result.paymentId.length > 0).toBe(true);
932
+ expect(result.ok && isPage(result.redirectUrl)).toBe(true);
933
+ });
934
+ it("is the same payment when the same reference is started again", async () => {
935
+ const port = await make();
936
+ const ref = reference();
937
+ const first = await start(port, request(ref));
938
+ const again = await start(port, request(ref));
939
+ expect(again.ok && first.ok && [again.paymentId, again.redirectUrl]).toEqual(first.ok && [first.paymentId, first.redirectUrl]);
940
+ });
941
+ it("refuses another amount under a reference it has started", async () => {
942
+ const port = await make();
943
+ const ref = reference();
944
+ expect(codeOf(await start(port, request(ref)))).toBe("ok");
945
+ const cheaper = request(ref, {
946
+ lines: [{
947
+ ref: "conformance-item",
948
+ title: "Conformance item",
949
+ quantity: 1,
950
+ unitAmountMinor: 15e3,
951
+ currency: "MXN"
952
+ }],
953
+ totalMinor: 24900
954
+ });
955
+ expect(codeOf(await start(port, cheaper))).toBe("invalid_request");
956
+ });
957
+ it("refuses a request whose lines and adjustments do not add up to the total", async () => {
958
+ const port = await make();
959
+ expect(codeOf(await start(port, request(reference(), { totalMinor: 39899 })))).toBe("invalid_request");
960
+ expect(codeOf(await start(port, request(reference(), { adjustments: [SHIPPING, TAX] })))).toBe("invalid_request");
961
+ });
962
+ it("refuses a discount larger than the items, even when the shipping and the tax would pay for it", async () => {
963
+ const port = await make();
964
+ const over = request(reference(), {
965
+ adjustments: [
966
+ SHIPPING,
967
+ TAX,
968
+ discount(30001)
969
+ ],
970
+ totalMinor: 14699
971
+ });
972
+ expect(codeOf(await start(port, over))).toBe("invalid_request");
973
+ const whole = request(reference(), {
974
+ adjustments: [
975
+ SHIPPING,
976
+ TAX,
977
+ discount(3e4)
978
+ ],
979
+ totalMinor: 14700
980
+ });
981
+ expect(codeOf(await start(port, whole))).toBe("ok");
982
+ });
983
+ it("refuses a total of zero: a free order is paid without a provider", async () => {
984
+ const port = await make();
985
+ const free = request(reference(), {
986
+ adjustments: [discount(3e4)],
987
+ totalMinor: 0
988
+ });
989
+ expect(codeOf(await start(port, free))).toBe("invalid_request");
990
+ });
991
+ it("refuses a request that is not valid: no lines, a return address that is not https", async () => {
992
+ const port = await make();
993
+ expect(codeOf(await start(port, request(reference(), { lines: [] })))).toBe("invalid_request");
994
+ expect(codeOf(await start(port, request(reference(), { successUrl: "http://shop.example/ok" })))).toBe("invalid_request");
995
+ });
996
+ if (options.skipCancel) return;
997
+ const cancelOf = async (port, ref) => {
998
+ if (typeof port.cancel !== "function") throw new Error("this provider has no cancel: pass { skipCancel: true } to runPaymentsConformance when it cannot stop a payment");
999
+ return port.cancel(await contextAt(), ref);
1000
+ };
1001
+ it("cancels a payment once: a second call says nothing changed", async () => {
1002
+ const port = await make();
1003
+ const ref = reference();
1004
+ await start(port, request(ref));
1005
+ expect(await cancelOf(port, ref)).toEqual({
1006
+ ok: true,
1007
+ changed: true
1008
+ });
1009
+ expect(await cancelOf(port, ref)).toEqual({
1010
+ ok: true,
1011
+ changed: false
1012
+ });
1013
+ });
1014
+ it("refuses to start a cancelled reference again", async () => {
1015
+ const port = await make();
1016
+ const ref = reference();
1017
+ await start(port, request(ref));
1018
+ await cancelOf(port, ref);
1019
+ expect(codeOf(await start(port, request(ref)))).toBe("invalid_request");
1020
+ });
1021
+ it("cancels only the payment it is asked to: another one stays payable", async () => {
1022
+ const port = await make();
1023
+ const [one, other] = [reference(), reference()];
1024
+ const first = await start(port, request(other));
1025
+ await start(port, request(one));
1026
+ await cancelOf(port, one);
1027
+ const again = await start(port, request(other));
1028
+ expect(again.ok && first.ok && again.paymentId).toBe(first.ok && first.paymentId);
1029
+ });
1030
+ it("answers unknown_payment for a reference it never started", async () => {
1031
+ const port = await make();
1032
+ const result = await cancelOf(port, reference());
1033
+ expect(result.ok === false && result.code).toBe("unknown_payment");
1034
+ });
1035
+ });
1036
+ }
1037
+ //#endregion
1038
+ //#region src/testing/stock.ts
1039
+ /**
1040
+ * A stock port that lives in memory, with the semantics the contract fixes: a hold is atomic (every line or none) and idempotent by
1041
+ * `holdId`, the last unit goes to one buyer, a hold that is not committed by `expiresAt` frees its units by itself, and a payment that comes
1042
+ * after that still commits (the money is in) and says it was late; a committed hold is fulfilled once, when its units leave, or uncommitted
1043
+ * once, when the sale is undone before they do. It is the
1044
+ * double a checkout is tested against, and the reference `runStockConformance` is written from. The inventory feature's D1 implementation
1045
+ * passes the same suite.
1046
+ */
1047
+ const subjectKey = (subject) => `${subject.subjectType}:${subject.subjectId}`;
1048
+ const available = (state) => Math.max(0, state.onHand - state.reserved - state.committed);
1049
+ function createMemoryStock(seed = {}) {
1050
+ const items = new Map(Object.entries(seed));
1051
+ const counts = /* @__PURE__ */ new Map();
1052
+ for (const [key, item] of items) if (item.tracking === "count") counts.set(key, {
1053
+ onHand: item.onHand,
1054
+ reserved: 0,
1055
+ committed: 0,
1056
+ allowOversell: item.allowOversell ?? false
1057
+ });
1058
+ const holds = /* @__PURE__ */ new Map();
1059
+ const view = (hold) => ({
1060
+ holdId: hold.holdId,
1061
+ status: hold.status,
1062
+ expiresAt: hold.expiresAt,
1063
+ lines: hold.lines.map((line) => ({
1064
+ ...line,
1065
+ subject: { ...line.subject }
1066
+ })),
1067
+ ...hold.fulfilledAt === void 0 ? {} : { fulfilledAt: hold.fulfilledAt }
1068
+ });
1069
+ /** Frees the units of every hold whose time has come, the way the inventory feature's minute sweep does. */
1070
+ const sweep = (now) => {
1071
+ for (const hold of holds.values()) {
1072
+ if (hold.status !== "held" || hold.expiresAt > now) continue;
1073
+ hold.status = "expired";
1074
+ for (const line of hold.lines) {
1075
+ const state = counts.get(subjectKey(line.subject));
1076
+ if (state) state.reserved -= line.quantity;
1077
+ }
1078
+ }
1079
+ };
1080
+ const level = (subject, now) => {
1081
+ const key = subjectKey(subject);
1082
+ const item = items.get(key) ?? { tracking: "none" };
1083
+ if (item.tracking === "none") return {
1084
+ subject,
1085
+ tracking: "none",
1086
+ inStock: true,
1087
+ available: null
1088
+ };
1089
+ if (item.tracking === "toggle") return item.out === true && !(typeof item.outUntil === "number" && item.outUntil <= now) ? {
1090
+ subject,
1091
+ tracking: "toggle",
1092
+ inStock: false,
1093
+ available: 0,
1094
+ outUntil: item.outUntil ?? null
1095
+ } : {
1096
+ subject,
1097
+ tracking: "toggle",
1098
+ inStock: true,
1099
+ available: null
1100
+ };
1101
+ const state = counts.get(key);
1102
+ const left = available(state);
1103
+ return {
1104
+ subject,
1105
+ tracking: "count",
1106
+ inStock: state.allowOversell || left > 0,
1107
+ available: left
1108
+ };
1109
+ };
1110
+ const merge = (lines) => {
1111
+ const merged = /* @__PURE__ */ new Map();
1112
+ for (const line of lines) {
1113
+ const key = subjectKey(line.subject);
1114
+ const seen = merged.get(key);
1115
+ if (seen) seen.quantity += line.quantity;
1116
+ else merged.set(key, {
1117
+ subject: {
1118
+ subjectType: line.subject.subjectType,
1119
+ subjectId: line.subject.subjectId
1120
+ },
1121
+ quantity: line.quantity
1122
+ });
1123
+ }
1124
+ return [...merged.values()];
1125
+ };
1126
+ const sameLines = (a, b) => {
1127
+ if (a.length !== b.length) return false;
1128
+ const wanted = new Map(b.map((line) => [subjectKey(line.subject), line.quantity]));
1129
+ return a.every((line) => wanted.get(subjectKey(line.subject)) === line.quantity);
1130
+ };
1131
+ const validRequest = (request, now) => {
1132
+ if (typeof request?.holdId !== "string" || request.holdId === "") return "holdId is required";
1133
+ if (!Array.isArray(request.lines) || request.lines.length === 0) return "a hold has at least one line";
1134
+ for (const line of request.lines) {
1135
+ if (typeof line?.subject?.subjectType !== "string" || !SUBJECT_TYPE$1.test(line.subject.subjectType) || typeof line.subject.subjectId !== "string" || line.subject.subjectId === "") return "every line names a subject: a dotted lowercase type (catalog.variant) and an id";
1136
+ if (!Number.isSafeInteger(line.quantity) || line.quantity < 1) return "every quantity is a whole number of at least 1";
1137
+ }
1138
+ if (!Number.isSafeInteger(request.expiresAt) || request.expiresAt <= now) return "expiresAt is a time in the future";
1139
+ if (request.expiresAt - now > 12096e5) return "expiresAt is more than 14 days away: is it in milliseconds?";
1140
+ };
1141
+ return {
1142
+ async availability(ctx, subjects) {
1143
+ sweep(ctx.now);
1144
+ return subjects.map((subject) => level(subject, ctx.now));
1145
+ },
1146
+ async hold(ctx, request) {
1147
+ sweep(ctx.now);
1148
+ const invalid = validRequest(request, ctx.now);
1149
+ if (invalid) return {
1150
+ ok: false,
1151
+ code: "invalid_request",
1152
+ message: invalid
1153
+ };
1154
+ const lines = merge(request.lines);
1155
+ const existing = holds.get(request.holdId);
1156
+ if (existing) {
1157
+ if (!sameLines(existing.lines, lines)) return {
1158
+ ok: false,
1159
+ code: "hold_conflict"
1160
+ };
1161
+ if (existing.status !== "held") return {
1162
+ ok: false,
1163
+ code: "hold_closed",
1164
+ status: existing.status
1165
+ };
1166
+ existing.expiresAt = Math.max(existing.expiresAt, request.expiresAt);
1167
+ return {
1168
+ ok: true,
1169
+ hold: view(existing)
1170
+ };
1171
+ }
1172
+ const short = [];
1173
+ for (const line of lines) {
1174
+ const current = level(line.subject, ctx.now);
1175
+ const state = counts.get(subjectKey(line.subject));
1176
+ if (current.tracking === "count" && state && !state.allowOversell && available(state) < line.quantity) short.push({
1177
+ subject: line.subject,
1178
+ requested: line.quantity,
1179
+ available: available(state)
1180
+ });
1181
+ else if (current.tracking === "toggle" && !current.inStock) short.push({
1182
+ subject: line.subject,
1183
+ requested: line.quantity,
1184
+ available: 0
1185
+ });
1186
+ }
1187
+ if (short.length > 0) return {
1188
+ ok: false,
1189
+ code: "insufficient_stock",
1190
+ short
1191
+ };
1192
+ for (const line of lines) {
1193
+ const state = counts.get(subjectKey(line.subject));
1194
+ if (state) state.reserved += line.quantity;
1195
+ }
1196
+ const hold = {
1197
+ holdId: request.holdId,
1198
+ status: "held",
1199
+ expiresAt: request.expiresAt,
1200
+ lines
1201
+ };
1202
+ holds.set(hold.holdId, hold);
1203
+ return {
1204
+ ok: true,
1205
+ hold: view(hold)
1206
+ };
1207
+ },
1208
+ async commit(ctx, holdId) {
1209
+ sweep(ctx.now);
1210
+ const hold = holds.get(holdId);
1211
+ if (!hold) return {
1212
+ ok: false,
1213
+ code: "unknown_hold"
1214
+ };
1215
+ if (hold.status === "committed") return {
1216
+ ok: true,
1217
+ hold: view(hold),
1218
+ changed: false
1219
+ };
1220
+ const late = hold.status !== "held";
1221
+ let oversold = false;
1222
+ for (const line of hold.lines) {
1223
+ const state = counts.get(subjectKey(line.subject));
1224
+ if (!state) continue;
1225
+ if (!late) state.reserved -= line.quantity;
1226
+ state.committed += line.quantity;
1227
+ if (state.onHand - state.reserved - state.committed < 0 && !state.allowOversell) oversold = true;
1228
+ }
1229
+ hold.status = "committed";
1230
+ return {
1231
+ ok: true,
1232
+ hold: view(hold),
1233
+ changed: true,
1234
+ ...late ? { late: true } : {},
1235
+ ...oversold ? { oversold: true } : {}
1236
+ };
1237
+ },
1238
+ async release(ctx, holdId) {
1239
+ sweep(ctx.now);
1240
+ const hold = holds.get(holdId);
1241
+ if (!hold) return {
1242
+ ok: false,
1243
+ code: "unknown_hold"
1244
+ };
1245
+ if (hold.status === "committed") return {
1246
+ ok: false,
1247
+ code: "invalid_state",
1248
+ status: "committed"
1249
+ };
1250
+ if (hold.status !== "held") return {
1251
+ ok: true,
1252
+ hold: view(hold),
1253
+ changed: false
1254
+ };
1255
+ hold.status = "released";
1256
+ for (const line of hold.lines) {
1257
+ const state = counts.get(subjectKey(line.subject));
1258
+ if (state) state.reserved -= line.quantity;
1259
+ }
1260
+ return {
1261
+ ok: true,
1262
+ hold: view(hold),
1263
+ changed: true
1264
+ };
1265
+ },
1266
+ async fulfil(ctx, holdId) {
1267
+ sweep(ctx.now);
1268
+ const hold = holds.get(holdId);
1269
+ if (!hold) return {
1270
+ ok: false,
1271
+ code: "unknown_hold"
1272
+ };
1273
+ if (hold.status !== "committed") return {
1274
+ ok: false,
1275
+ code: "invalid_state",
1276
+ status: hold.status
1277
+ };
1278
+ if (hold.fulfilledAt !== void 0) return {
1279
+ ok: true,
1280
+ hold: view(hold),
1281
+ changed: false
1282
+ };
1283
+ for (const line of hold.lines) {
1284
+ const state = counts.get(subjectKey(line.subject));
1285
+ if (!state) continue;
1286
+ state.committed -= line.quantity;
1287
+ state.onHand -= line.quantity;
1288
+ }
1289
+ hold.fulfilledAt = ctx.now;
1290
+ return {
1291
+ ok: true,
1292
+ hold: view(hold),
1293
+ changed: true
1294
+ };
1295
+ },
1296
+ async uncommit(ctx, holdId) {
1297
+ sweep(ctx.now);
1298
+ const hold = holds.get(holdId);
1299
+ if (!hold) return {
1300
+ ok: false,
1301
+ code: "unknown_hold"
1302
+ };
1303
+ if (hold.status === "released" || hold.status === "expired") return {
1304
+ ok: true,
1305
+ hold: view(hold),
1306
+ changed: false
1307
+ };
1308
+ if (hold.status !== "committed" || hold.fulfilledAt !== void 0) return {
1309
+ ok: false,
1310
+ code: "invalid_state",
1311
+ status: hold.status
1312
+ };
1313
+ for (const line of hold.lines) {
1314
+ const state = counts.get(subjectKey(line.subject));
1315
+ if (state) state.committed -= line.quantity;
1316
+ }
1317
+ hold.status = "released";
1318
+ return {
1319
+ ok: true,
1320
+ hold: view(hold),
1321
+ changed: true
1322
+ };
1323
+ },
1324
+ counts() {
1325
+ return Object.fromEntries([...counts].map(([key, state]) => [key, {
1326
+ onHand: state.onHand,
1327
+ reserved: state.reserved,
1328
+ committed: state.committed,
1329
+ available: available(state)
1330
+ }]));
1331
+ }
1332
+ };
1333
+ }
1334
+ //#endregion
1335
+ //#region src/testing/slots.ts
1336
+ /**
1337
+ * A line source with places of its own (a booking slot and its seats), in memory: the reference `runLineHoldConformance` is written
1338
+ * from, and the double a checkout tests its handling of such a source against. Its holds are the in-memory stock's (`createMemoryStock`),
1339
+ * one counted subject per ref, so the two doubles keep the same rules; what it adds is the line source around them: a price, the places
1340
+ * left in `resolve`, and holds made of asks.
1341
+ */
1342
+ const SUBJECT_TYPE = "memory.slot";
1343
+ const subject$1 = (ref) => ({
1344
+ subjectType: SUBJECT_TYPE,
1345
+ subjectId: ref
1346
+ });
1347
+ /** Asks with the same ref and options are one, their quantities added; the order of first appearance is kept. */
1348
+ function merged(asks) {
1349
+ const out = /* @__PURE__ */ new Map();
1350
+ for (const ask of asks) {
1351
+ const options = ask.options ? Object.fromEntries(Object.entries(ask.options).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : void 0;
1352
+ const key = JSON.stringify([ask.ref, options ?? null]);
1353
+ const seen = out.get(key);
1354
+ if (seen) seen.quantity += ask.quantity;
1355
+ else out.set(key, {
1356
+ ref: ask.ref,
1357
+ quantity: ask.quantity,
1358
+ ...options ? { options } : {}
1359
+ });
1360
+ }
1361
+ return [...out.values()];
1362
+ }
1363
+ const sameAsks = (a, b) => JSON.stringify([...a].map((ask) => JSON.stringify(ask)).sort()) === JSON.stringify([...b].map((ask) => JSON.stringify(ask)).sort());
1364
+ const copy = (asks) => asks.map((ask) => ({
1365
+ ...ask,
1366
+ ...ask.options ? { options: { ...ask.options } } : {}
1367
+ }));
1368
+ const validAsk = (ask) => isRecord(ask) && typeof ask.ref === "string" && ask.ref !== "" && typeof ask.quantity === "number" && Number.isSafeInteger(ask.quantity) && ask.quantity >= 1 && (ask.options === void 0 || isRecord(ask.options) && Object.values(ask.options).every((value) => typeof value === "string"));
1369
+ function createMemorySlots(places, options = {}) {
1370
+ const priceMinor = options.unitAmountMinor ?? 1e4;
1371
+ const stock = createMemoryStock(Object.fromEntries(Object.entries(places).map(([ref, count]) => [subjectKey(subject$1(ref)), {
1372
+ tracking: "count",
1373
+ onHand: count
1374
+ }])));
1375
+ const known = (ref) => Object.hasOwn(places, ref);
1376
+ /** The asks each hold was made of, by hold id: the stock keeps the places, this keeps what the buyer asked for. */
1377
+ const asksOf = /* @__PURE__ */ new Map();
1378
+ const view = (hold) => ({
1379
+ holdId: hold.holdId,
1380
+ status: hold.status,
1381
+ expiresAt: hold.expiresAt,
1382
+ lines: copy(asksOf.get(hold.holdId) ?? [])
1383
+ });
1384
+ const settled = (result) => result.ok ? {
1385
+ ...result,
1386
+ hold: view(result.hold)
1387
+ } : result;
1388
+ return {
1389
+ async resolve(ctx, asks) {
1390
+ const levels = await stock.availability(ctx, asks.filter((ask) => known(ask.ref)).map((ask) => subject$1(ask.ref)));
1391
+ const left = new Map(levels.map((level) => [level.subject.subjectId, level.available ?? 0]));
1392
+ return asks.map((ask) => {
1393
+ if (!known(ask.ref)) return {
1394
+ ref: ask.ref,
1395
+ title: ask.ref,
1396
+ quantity: ask.quantity,
1397
+ unitAmountMinor: 0,
1398
+ currency: "MXN",
1399
+ availability: "not_found",
1400
+ taxIncluded: true,
1401
+ fulfilment: "service",
1402
+ snapshot: {}
1403
+ };
1404
+ const free = left.get(ask.ref) ?? 0;
1405
+ return {
1406
+ ref: ask.ref,
1407
+ title: ask.ref,
1408
+ quantity: ask.quantity,
1409
+ unitAmountMinor: priceMinor,
1410
+ currency: "MXN",
1411
+ availability: free > 0 ? "available" : "sold_out",
1412
+ ...free > 0 ? { maxQuantity: free } : {},
1413
+ taxIncluded: true,
1414
+ fulfilment: "service",
1415
+ snapshot: { slot: ask.ref }
1416
+ };
1417
+ });
1418
+ },
1419
+ async hold(ctx, request) {
1420
+ if (typeof request?.holdId !== "string" || request.holdId === "") return {
1421
+ ok: false,
1422
+ code: "invalid_request",
1423
+ message: "holdId is required"
1424
+ };
1425
+ if (!Array.isArray(request.lines) || request.lines.length === 0) return {
1426
+ ok: false,
1427
+ code: "invalid_request",
1428
+ message: "a hold has at least one ask"
1429
+ };
1430
+ if (!request.lines.every(validAsk)) return {
1431
+ ok: false,
1432
+ code: "invalid_request",
1433
+ message: "every ask has a ref, a whole quantity of at least 1 and options of strings"
1434
+ };
1435
+ const asks = merged(request.lines);
1436
+ const earlier = asksOf.get(request.holdId);
1437
+ if (earlier && !sameAsks(earlier, asks)) return {
1438
+ ok: false,
1439
+ code: "hold_conflict"
1440
+ };
1441
+ if (!Number.isSafeInteger(request.expiresAt) || request.expiresAt <= ctx.now || request.expiresAt - ctx.now > 12096e5) return {
1442
+ ok: false,
1443
+ code: "invalid_request",
1444
+ message: "expiresAt is a time in the future, at most 14 days away"
1445
+ };
1446
+ const unknown = asks.filter((ask) => !known(ask.ref));
1447
+ if (unknown.length > 0) return {
1448
+ ok: false,
1449
+ code: "insufficient_stock",
1450
+ short: unknown.map((ask) => ({
1451
+ ref: ask.ref,
1452
+ requested: ask.quantity,
1453
+ available: 0
1454
+ }))
1455
+ };
1456
+ const lines = asks.map((ask) => ({
1457
+ subject: subject$1(ask.ref),
1458
+ quantity: ask.quantity
1459
+ }));
1460
+ const result = await stock.hold(ctx, {
1461
+ holdId: request.holdId,
1462
+ lines,
1463
+ expiresAt: request.expiresAt
1464
+ });
1465
+ if (result.ok) {
1466
+ if (!earlier) asksOf.set(request.holdId, asks);
1467
+ return {
1468
+ ok: true,
1469
+ hold: view(result.hold)
1470
+ };
1471
+ }
1472
+ if (result.code === "insufficient_stock") return {
1473
+ ok: false,
1474
+ code: "insufficient_stock",
1475
+ short: result.short.map((line) => ({
1476
+ ref: line.subject.subjectId,
1477
+ requested: line.requested,
1478
+ available: line.available
1479
+ }))
1480
+ };
1481
+ return result;
1482
+ },
1483
+ async commit(ctx, holdId) {
1484
+ return settled(await stock.commit(ctx, holdId));
1485
+ },
1486
+ async release(ctx, holdId) {
1487
+ return settled(await stock.release(ctx, holdId));
1488
+ }
1489
+ };
1490
+ }
1491
+ //#endregion
1492
+ //#region src/testing/stock-conformance.ts
1493
+ /**
1494
+ * The behaviour every {@link StockPort} must have, as a test suite. Run it against the in-memory stock in Node
1495
+ * (`runStockConformance((seed) => createMemoryStock(seed), { describe, it, expect })`) and against the inventory feature's own port in
1496
+ * workerd on a migrated D1, with a `make` that writes the seed into its tables. Each test seeds a stock of its own, so `make` must return
1497
+ * one with nothing else in it.
1498
+ *
1499
+ * What it fixes: a hold is every line or none, and the same `holdId` is the same hold; the last unit goes to exactly one of two buyers; the
1500
+ * clock is `ctx.now`, never the database's; units come back at `expiresAt` by themselves; a payment that comes after that still commits and
1501
+ * says it was late; `release` after `commit` is refused; `fulfil` (optional) moves a committed hold's units off the shelf once and never
1502
+ * changes what can be bought; `uncommit` (optional) gives the units of a committed, unfulfilled hold back to what can be bought, once. What
1503
+ * `fulfil` does to the counts on hand and sold is not visible through the port: a provider's own tests read it (`counts()` of the in-memory
1504
+ * stock, the inventory feature's levels and ledger).
1505
+ */
1506
+ const subject = (id) => ({
1507
+ subjectType: "conformance.item",
1508
+ subjectId: id
1509
+ });
1510
+ const line$1 = (id, quantity) => ({
1511
+ subject: subject(id),
1512
+ quantity
1513
+ });
1514
+ const key = (id) => subjectKey(subject(id));
1515
+ function runStockConformance(make, tools, options = {}) {
1516
+ const { describe, it, expect } = tools;
1517
+ const contextAt = async (now) => options.context ? await options.context(now) : testContext({ now });
1518
+ const T0 = TEST_NOW;
1519
+ const TTL = 1e3;
1520
+ const left = async (port, id, now = T0) => (await port.availability(await contextAt(now), [subject(id)]))[0]?.available;
1521
+ const hold = async (port, holdId, lines, now = T0, ttl = TTL) => port.hold(await contextAt(now), {
1522
+ holdId,
1523
+ lines,
1524
+ expiresAt: now + ttl
1525
+ });
1526
+ const settle = async (port, what, holdId, now = T0) => port[what](await contextAt(now), holdId);
1527
+ describe("StockPort conformance", () => {
1528
+ it("reports a subject nobody tracks as always available", async () => {
1529
+ const [level] = await (await make({})).availability(await contextAt(T0), [subject("untracked")]);
1530
+ expect(level?.tracking).toBe("none");
1531
+ expect(level?.inStock).toBe(true);
1532
+ expect(level?.available).toBe(null);
1533
+ });
1534
+ it("reports counts, a toggle that is out and a toggle that is in", async () => {
1535
+ const levels = await (await make({
1536
+ [key("counted")]: {
1537
+ tracking: "count",
1538
+ onHand: 3
1539
+ },
1540
+ [key("out")]: {
1541
+ tracking: "toggle",
1542
+ out: true
1543
+ },
1544
+ [key("in")]: {
1545
+ tracking: "toggle",
1546
+ out: false
1547
+ }
1548
+ })).availability(await contextAt(T0), [
1549
+ subject("counted"),
1550
+ subject("out"),
1551
+ subject("in")
1552
+ ]);
1553
+ expect(levels.map((level) => [
1554
+ level.tracking,
1555
+ level.inStock,
1556
+ level.available
1557
+ ])).toEqual([
1558
+ [
1559
+ "count",
1560
+ true,
1561
+ 3
1562
+ ],
1563
+ [
1564
+ "toggle",
1565
+ false,
1566
+ 0
1567
+ ],
1568
+ [
1569
+ "toggle",
1570
+ true,
1571
+ null
1572
+ ]
1573
+ ]);
1574
+ });
1575
+ it("brings a toggle back at its outUntil time", async () => {
1576
+ const port = await make({ [key("dish")]: {
1577
+ tracking: "toggle",
1578
+ out: true,
1579
+ outUntil: T0 + 5e3
1580
+ } });
1581
+ expect((await port.availability(await contextAt(T0 + 4999), [subject("dish")]))[0]?.inStock).toBe(false);
1582
+ expect((await port.availability(await contextAt(T0 + 5e3), [subject("dish")]))[0]?.inStock).toBe(true);
1583
+ });
1584
+ it("holds units and gives them back on release", async () => {
1585
+ const port = await make({ [key("a")]: {
1586
+ tracking: "count",
1587
+ onHand: 3
1588
+ } });
1589
+ const held = await hold(port, "h1", [line$1("a", 2)]);
1590
+ expect(held.ok).toBe(true);
1591
+ expect(held.ok && held.hold.status).toBe("held");
1592
+ expect(held.ok && held.hold.expiresAt).toBe(T0 + TTL);
1593
+ expect(await left(port, "a")).toBe(1);
1594
+ const released = await settle(port, "release", "h1");
1595
+ expect(released.ok && released.changed).toBe(true);
1596
+ expect(await left(port, "a")).toBe(3);
1597
+ });
1598
+ it("treats the same hold id as the same hold, and a different request under it as a conflict", async () => {
1599
+ const port = await make({ [key("a")]: {
1600
+ tracking: "count",
1601
+ onHand: 3
1602
+ } });
1603
+ expect((await hold(port, "h1", [line$1("a", 2)])).ok).toBe(true);
1604
+ expect((await hold(port, "h1", [line$1("a", 2)])).ok).toBe(true);
1605
+ expect(await left(port, "a")).toBe(1);
1606
+ expect(await hold(port, "h1", [line$1("a", 1)])).toEqual({
1607
+ ok: false,
1608
+ code: "hold_conflict"
1609
+ });
1610
+ expect(await left(port, "a")).toBe(1);
1611
+ });
1612
+ it("adds up two lines of one subject before it checks", async () => {
1613
+ const port = await make({ [key("a")]: {
1614
+ tracking: "count",
1615
+ onHand: 3
1616
+ } });
1617
+ expect(await hold(port, "h1", [line$1("a", 2), line$1("a", 2)])).toEqual({
1618
+ ok: false,
1619
+ code: "insufficient_stock",
1620
+ short: [{
1621
+ subject: subject("a"),
1622
+ requested: 4,
1623
+ available: 3
1624
+ }]
1625
+ });
1626
+ expect(await left(port, "a")).toBe(3);
1627
+ });
1628
+ it("holds every line or none: a short line releases the ones before it", async () => {
1629
+ const port = await make({
1630
+ [key("a")]: {
1631
+ tracking: "count",
1632
+ onHand: 1
1633
+ },
1634
+ [key("b")]: {
1635
+ tracking: "count",
1636
+ onHand: 5
1637
+ }
1638
+ });
1639
+ const result = await hold(port, "h1", [line$1("b", 2), line$1("a", 2)]);
1640
+ expect(result).toEqual({
1641
+ ok: false,
1642
+ code: "insufficient_stock",
1643
+ short: [{
1644
+ subject: subject("a"),
1645
+ requested: 2,
1646
+ available: 1
1647
+ }]
1648
+ });
1649
+ expect(await left(port, "b")).toBe(5);
1650
+ expect(await left(port, "a")).toBe(1);
1651
+ });
1652
+ it("gives the last unit to exactly one of two buyers", async () => {
1653
+ const port = await make({ [key("last")]: {
1654
+ tracking: "count",
1655
+ onHand: 1
1656
+ } });
1657
+ const results = await Promise.all([hold(port, "buyer-1", [line$1("last", 1)]), hold(port, "buyer-2", [line$1("last", 1)])]);
1658
+ expect(results.filter((result) => result.ok).length).toBe(1);
1659
+ expect(results.filter((result) => !result.ok && result.code === "insufficient_stock").length).toBe(1);
1660
+ expect(await left(port, "last")).toBe(0);
1661
+ });
1662
+ it("refuses a toggle that is out, takes one that is in, and takes a subject nobody tracks", async () => {
1663
+ const port = await make({
1664
+ [key("out")]: {
1665
+ tracking: "toggle",
1666
+ out: true
1667
+ },
1668
+ [key("in")]: {
1669
+ tracking: "toggle",
1670
+ out: false
1671
+ }
1672
+ });
1673
+ expect(await hold(port, "h1", [line$1("out", 1)])).toEqual({
1674
+ ok: false,
1675
+ code: "insufficient_stock",
1676
+ short: [{
1677
+ subject: subject("out"),
1678
+ requested: 1,
1679
+ available: 0
1680
+ }]
1681
+ });
1682
+ expect((await hold(port, "h2", [line$1("in", 1)])).ok).toBe(true);
1683
+ expect((await hold(port, "h3", [line$1("untracked", 5)])).ok).toBe(true);
1684
+ });
1685
+ it("sells past zero only for an item that allows it", async () => {
1686
+ const port = await make({
1687
+ [key("pre")]: {
1688
+ tracking: "count",
1689
+ onHand: 1,
1690
+ allowOversell: true
1691
+ },
1692
+ [key("firm")]: {
1693
+ tracking: "count",
1694
+ onHand: 1
1695
+ }
1696
+ });
1697
+ expect((await hold(port, "h1", [line$1("pre", 3)])).ok).toBe(true);
1698
+ expect((await hold(port, "h2", [line$1("firm", 3)])).ok).toBe(false);
1699
+ });
1700
+ it("keeps the units sold on commit, and commits only once", async () => {
1701
+ const port = await make({ [key("a")]: {
1702
+ tracking: "count",
1703
+ onHand: 3
1704
+ } });
1705
+ await hold(port, "h1", [line$1("a", 2)]);
1706
+ const committed = await settle(port, "commit", "h1");
1707
+ expect(committed.ok && committed.changed).toBe(true);
1708
+ expect(committed.ok && committed.hold.status).toBe("committed");
1709
+ expect(committed.ok && committed.late).toBe(void 0);
1710
+ expect(await left(port, "a")).toBe(1);
1711
+ const again = await settle(port, "commit", "h1");
1712
+ expect(again.ok && again.changed).toBe(false);
1713
+ expect(await left(port, "a")).toBe(1);
1714
+ });
1715
+ it("refuses to release a hold that was committed", async () => {
1716
+ const port = await make({ [key("a")]: {
1717
+ tracking: "count",
1718
+ onHand: 3
1719
+ } });
1720
+ await hold(port, "h1", [line$1("a", 2)]);
1721
+ await settle(port, "commit", "h1");
1722
+ expect(await settle(port, "release", "h1")).toEqual({
1723
+ ok: false,
1724
+ code: "invalid_state",
1725
+ status: "committed"
1726
+ });
1727
+ expect(await left(port, "a")).toBe(1);
1728
+ });
1729
+ it("releases once and then says nothing changed", async () => {
1730
+ const port = await make({ [key("a")]: {
1731
+ tracking: "count",
1732
+ onHand: 3
1733
+ } });
1734
+ await hold(port, "h1", [line$1("a", 2)]);
1735
+ expect((await settle(port, "release", "h1")).ok).toBe(true);
1736
+ const again = await settle(port, "release", "h1");
1737
+ expect(again.ok && again.changed).toBe(false);
1738
+ expect(await left(port, "a")).toBe(3);
1739
+ });
1740
+ it("answers a hold it does not know", async () => {
1741
+ const port = await make({});
1742
+ expect(await settle(port, "commit", "nope")).toEqual({
1743
+ ok: false,
1744
+ code: "unknown_hold"
1745
+ });
1746
+ expect(await settle(port, "release", "nope")).toEqual({
1747
+ ok: false,
1748
+ code: "unknown_hold"
1749
+ });
1750
+ });
1751
+ it("gives the units back by itself at expiresAt, by the context's clock", async () => {
1752
+ const port = await make({ [key("a")]: {
1753
+ tracking: "count",
1754
+ onHand: 3
1755
+ } });
1756
+ await hold(port, "h1", [line$1("a", 2)]);
1757
+ expect(await left(port, "a", T0 + TTL - 1)).toBe(1);
1758
+ expect(await left(port, "a", T0 + TTL)).toBe(3);
1759
+ });
1760
+ it("commits a payment that arrives after the hold expired, takes the units again and says it was late", async () => {
1761
+ const port = await make({ [key("a")]: {
1762
+ tracking: "count",
1763
+ onHand: 3
1764
+ } });
1765
+ await hold(port, "h1", [line$1("a", 2)]);
1766
+ const late = await settle(port, "commit", "h1", T0 + 2 * TTL);
1767
+ expect(late.ok && late.changed).toBe(true);
1768
+ expect(late.ok && late.late).toBe(true);
1769
+ expect(late.ok && late.oversold).toBe(void 0);
1770
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(1);
1771
+ });
1772
+ it("flags a late commit that finds the stock gone, and never reports a negative number", async () => {
1773
+ const port = await make({ [key("a")]: {
1774
+ tracking: "count",
1775
+ onHand: 2
1776
+ } });
1777
+ await hold(port, "h1", [line$1("a", 2)]);
1778
+ expect((await hold(port, "h2", [line$1("a", 2)], T0 + 2 * TTL)).ok).toBe(true);
1779
+ const late = await settle(port, "commit", "h1", T0 + 2 * TTL);
1780
+ expect(late.ok && late.late).toBe(true);
1781
+ expect(late.ok && late.oversold).toBe(true);
1782
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(0);
1783
+ });
1784
+ it("leaves an expired hold released, not committed, when it is released late", async () => {
1785
+ const port = await make({ [key("a")]: {
1786
+ tracking: "count",
1787
+ onHand: 3
1788
+ } });
1789
+ await hold(port, "h1", [line$1("a", 2)]);
1790
+ const result = await settle(port, "release", "h1", T0 + 2 * TTL);
1791
+ expect(result.ok && result.changed).toBe(false);
1792
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(3);
1793
+ });
1794
+ it("reports a count at zero, and one that has just been held, as out of stock", async () => {
1795
+ const port = await make({
1796
+ [key("none")]: {
1797
+ tracking: "count",
1798
+ onHand: 0
1799
+ },
1800
+ [key("last")]: {
1801
+ tracking: "count",
1802
+ onHand: 1
1803
+ }
1804
+ });
1805
+ const levelOf = async (id) => {
1806
+ const level = (await port.availability(await contextAt(T0), [subject(id)]))[0];
1807
+ return [level?.inStock, level?.available];
1808
+ };
1809
+ expect(await levelOf("none")).toEqual([false, 0]);
1810
+ expect(await levelOf("last")).toEqual([true, 1]);
1811
+ await hold(port, "h1", [line$1("last", 1)]);
1812
+ expect(await levelOf("last")).toEqual([false, 0]);
1813
+ });
1814
+ it("treats the same hold id with other lines as a conflict: another quantity, another subject, one line more", async () => {
1815
+ const port = await make({
1816
+ [key("a")]: {
1817
+ tracking: "count",
1818
+ onHand: 5
1819
+ },
1820
+ [key("b")]: {
1821
+ tracking: "count",
1822
+ onHand: 5
1823
+ }
1824
+ });
1825
+ expect((await hold(port, "h1", [line$1("a", 1)])).ok).toBe(true);
1826
+ for (const lines of [
1827
+ [line$1("a", 2)],
1828
+ [line$1("b", 1)],
1829
+ [line$1("a", 1), line$1("b", 1)],
1830
+ [line$1("a", 1), line$1("a", 1)]
1831
+ ]) expect(await hold(port, "h1", lines)).toEqual({
1832
+ ok: false,
1833
+ code: "hold_conflict"
1834
+ });
1835
+ expect(await left(port, "a")).toBe(4);
1836
+ expect(await left(port, "b")).toBe(5);
1837
+ });
1838
+ it("does not call a late commit oversold when it takes the stock exactly to zero", async () => {
1839
+ const port = await make({ [key("a")]: {
1840
+ tracking: "count",
1841
+ onHand: 2
1842
+ } });
1843
+ await hold(port, "h1", [line$1("a", 2)]);
1844
+ const late = await settle(port, "commit", "h1", T0 + 2 * TTL);
1845
+ expect(late.ok && late.late).toBe(true);
1846
+ expect(late.ok && late.oversold).toBe(void 0);
1847
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(0);
1848
+ });
1849
+ it("answers hold_closed for a hold id whose hold is over, after a release, an expiry or a commit, and holds nothing", async () => {
1850
+ const port = await make({ [key("a")]: {
1851
+ tracking: "count",
1852
+ onHand: 3
1853
+ } });
1854
+ await hold(port, "released", [line$1("a", 1)]);
1855
+ await settle(port, "release", "released");
1856
+ expect(await hold(port, "released", [line$1("a", 1)])).toEqual({
1857
+ ok: false,
1858
+ code: "hold_closed",
1859
+ status: "released"
1860
+ });
1861
+ await hold(port, "expired", [line$1("a", 1)]);
1862
+ expect(await hold(port, "expired", [line$1("a", 1)], T0 + 2 * TTL, TTL)).toEqual({
1863
+ ok: false,
1864
+ code: "hold_closed",
1865
+ status: "expired"
1866
+ });
1867
+ await hold(port, "committed", [line$1("a", 1)], T0 + 3 * TTL, TTL);
1868
+ await settle(port, "commit", "committed", T0 + 3 * TTL);
1869
+ expect(await hold(port, "committed", [line$1("a", 1)], T0 + 3 * TTL, TTL)).toEqual({
1870
+ ok: false,
1871
+ code: "hold_closed",
1872
+ status: "committed"
1873
+ });
1874
+ expect(await left(port, "a", T0 + 3 * TTL)).toBe(2);
1875
+ });
1876
+ it("extends a live hold when it is asked again with a later time, and never shortens it", async () => {
1877
+ const port = await make({ [key("a")]: {
1878
+ tracking: "count",
1879
+ onHand: 3
1880
+ } });
1881
+ await hold(port, "h1", [line$1("a", 2)]);
1882
+ const longer = await hold(port, "h1", [line$1("a", 2)], T0, 5 * TTL);
1883
+ expect(longer.ok && longer.hold.expiresAt).toBe(T0 + 5 * TTL);
1884
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(1);
1885
+ const shorter = await hold(port, "h1", [line$1("a", 2)], T0, TTL / 10);
1886
+ expect(shorter.ok && shorter.hold.expiresAt).toBe(T0 + 5 * TTL);
1887
+ expect(await left(port, "a", T0 + 4 * TTL)).toBe(1);
1888
+ expect(await left(port, "a", T0 + 5 * TTL)).toBe(3);
1889
+ });
1890
+ it("holds for at most 14 days, so a time in the wrong unit is refused and not kept for years", async () => {
1891
+ const port = await make({ [key("a")]: {
1892
+ tracking: "count",
1893
+ onHand: 3
1894
+ } });
1895
+ expect((await hold(port, "ok", [line$1("a", 1)], T0, MAX_HOLD_MS)).ok).toBe(true);
1896
+ const past = await hold(port, "late", [line$1("a", 1)], T0, MAX_HOLD_MS + 1);
1897
+ expect(past.ok === false && past.code).toBe("invalid_request");
1898
+ const wrongUnit = await hold(port, "units", [line$1("a", 1)], T0, 18e8);
1899
+ expect(wrongUnit.ok === false && wrongUnit.code).toBe("invalid_request");
1900
+ expect(await left(port, "a")).toBe(2);
1901
+ });
1902
+ it("refuses a subject type that is not a dotted lowercase key", async () => {
1903
+ const port = await make({});
1904
+ for (const subjectType of [
1905
+ "Shop.Sku",
1906
+ "shop sku",
1907
+ "a:b",
1908
+ "",
1909
+ "shop..sku",
1910
+ "shop."
1911
+ ]) {
1912
+ const result = await port.hold(await contextAt(T0), {
1913
+ holdId: "h",
1914
+ lines: [{
1915
+ subject: {
1916
+ subjectType,
1917
+ subjectId: "x"
1918
+ },
1919
+ quantity: 1
1920
+ }],
1921
+ expiresAt: T0 + TTL
1922
+ });
1923
+ expect(`${subjectType} -> ${result.ok === false && result.code}`).toBe(`${subjectType} -> invalid_request`);
1924
+ }
1925
+ });
1926
+ it("refuses a request that is not valid and changes nothing", async () => {
1927
+ const port = await make({ [key("a")]: {
1928
+ tracking: "count",
1929
+ onHand: 3
1930
+ } });
1931
+ const ctx = await contextAt(T0);
1932
+ const bad = [
1933
+ {
1934
+ holdId: "",
1935
+ lines: [line$1("a", 1)],
1936
+ expiresAt: T0 + TTL
1937
+ },
1938
+ {
1939
+ holdId: "h",
1940
+ lines: [],
1941
+ expiresAt: T0 + TTL
1942
+ },
1943
+ {
1944
+ holdId: "h",
1945
+ lines: [line$1("a", 0)],
1946
+ expiresAt: T0 + TTL
1947
+ },
1948
+ {
1949
+ holdId: "h",
1950
+ lines: [line$1("a", 1.5)],
1951
+ expiresAt: T0 + TTL
1952
+ },
1953
+ {
1954
+ holdId: "h",
1955
+ lines: [line$1("a", 1)],
1956
+ expiresAt: T0
1957
+ }
1958
+ ];
1959
+ for (const request of bad) {
1960
+ const result = await port.hold(ctx, request);
1961
+ expect(result.ok === false && result.code).toBe("invalid_request");
1962
+ }
1963
+ expect(await left(port, "a")).toBe(3);
1964
+ });
1965
+ if (!options.skipFulfil) {
1966
+ const fulfil = async (port, holdId, now = T0) => {
1967
+ if (typeof port.fulfil !== "function") throw new Error("this provider has no fulfil: pass { skipFulfil: true } to runStockConformance when it tracks no units on hand");
1968
+ return port.fulfil(await contextAt(now), holdId);
1969
+ };
1970
+ it("fulfils a committed hold once and leaves what can be bought as it was", async () => {
1971
+ const port = await make({ [key("a")]: {
1972
+ tracking: "count",
1973
+ onHand: 5
1974
+ } });
1975
+ await hold(port, "h1", [line$1("a", 2)]);
1976
+ await settle(port, "commit", "h1");
1977
+ expect(await left(port, "a")).toBe(3);
1978
+ const done = await fulfil(port, "h1", T0 + 10);
1979
+ expect(done.ok && done.changed).toBe(true);
1980
+ expect(done.ok && done.hold.status).toBe("committed");
1981
+ expect(done.ok && done.hold.fulfilledAt).toBe(T0 + 10);
1982
+ expect(await left(port, "a", T0 + 10)).toBe(3);
1983
+ const again = await fulfil(port, "h1", T0 + 20);
1984
+ expect(again.ok && again.changed).toBe(false);
1985
+ expect(again.ok && again.hold.fulfilledAt).toBe(T0 + 10);
1986
+ expect(await left(port, "a", T0 + 20)).toBe(3);
1987
+ });
1988
+ it("refuses to fulfil a hold that is not committed, says its status and moves nothing", async () => {
1989
+ const port = await make({ [key("a")]: {
1990
+ tracking: "count",
1991
+ onHand: 5
1992
+ } });
1993
+ await hold(port, "held", [line$1("a", 1)]);
1994
+ expect(await fulfil(port, "held")).toEqual({
1995
+ ok: false,
1996
+ code: "invalid_state",
1997
+ status: "held"
1998
+ });
1999
+ await hold(port, "released", [line$1("a", 1)]);
2000
+ await settle(port, "release", "released");
2001
+ expect(await fulfil(port, "released")).toEqual({
2002
+ ok: false,
2003
+ code: "invalid_state",
2004
+ status: "released"
2005
+ });
2006
+ expect(await fulfil(port, "nope")).toEqual({
2007
+ ok: false,
2008
+ code: "unknown_hold"
2009
+ });
2010
+ expect(await left(port, "a")).toBe(4);
2011
+ expect(await fulfil(port, "held", T0 + 2 * TTL)).toEqual({
2012
+ ok: false,
2013
+ code: "invalid_state",
2014
+ status: "expired"
2015
+ });
2016
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(5);
2017
+ });
2018
+ it("keeps a fulfilled hold committed: it cannot be released, and its id stays closed", async () => {
2019
+ const port = await make({ [key("a")]: {
2020
+ tracking: "count",
2021
+ onHand: 5
2022
+ } });
2023
+ await hold(port, "h1", [line$1("a", 2)]);
2024
+ await settle(port, "commit", "h1");
2025
+ await fulfil(port, "h1");
2026
+ expect(await settle(port, "release", "h1")).toEqual({
2027
+ ok: false,
2028
+ code: "invalid_state",
2029
+ status: "committed"
2030
+ });
2031
+ expect(await hold(port, "h1", [line$1("a", 2)])).toEqual({
2032
+ ok: false,
2033
+ code: "hold_closed",
2034
+ status: "committed"
2035
+ });
2036
+ const committed = await settle(port, "commit", "h1");
2037
+ expect(committed.ok && committed.changed).toBe(false);
2038
+ expect(await left(port, "a")).toBe(3);
2039
+ });
2040
+ it("fulfils a late commit, and a hold of things nobody counts", async () => {
2041
+ const port = await make({ [key("a")]: {
2042
+ tracking: "count",
2043
+ onHand: 3
2044
+ } });
2045
+ await hold(port, "late", [line$1("a", 2)]);
2046
+ await settle(port, "commit", "late", T0 + 2 * TTL);
2047
+ const late = await fulfil(port, "late", T0 + 2 * TTL);
2048
+ expect(late.ok && late.changed).toBe(true);
2049
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(1);
2050
+ await hold(port, "free", [line$1("untracked", 4)], T0 + 2 * TTL);
2051
+ await settle(port, "commit", "free", T0 + 2 * TTL);
2052
+ const free = await fulfil(port, "free", T0 + 2 * TTL);
2053
+ expect(free.ok && free.changed).toBe(true);
2054
+ });
2055
+ it("fulfils once when two calls arrive together", async () => {
2056
+ const port = await make({ [key("a")]: {
2057
+ tracking: "count",
2058
+ onHand: 5
2059
+ } });
2060
+ await hold(port, "h1", [line$1("a", 2)]);
2061
+ await settle(port, "commit", "h1");
2062
+ const results = await Promise.all([fulfil(port, "h1"), fulfil(port, "h1")]);
2063
+ expect(results.filter((result) => result.ok && result.changed).length).toBe(1);
2064
+ expect(results.filter((result) => result.ok && !result.changed).length).toBe(1);
2065
+ expect(await left(port, "a")).toBe(3);
2066
+ });
2067
+ }
2068
+ if (options.skipUncommit) return;
2069
+ const uncommit = async (port, holdId, now = T0) => {
2070
+ if (typeof port.uncommit !== "function") throw new Error("this provider has no uncommit: pass { skipUncommit: true } to runStockConformance when it does not count units");
2071
+ return port.uncommit(await contextAt(now), holdId);
2072
+ };
2073
+ it("gives the units of a committed hold back once, and ends the hold as released", async () => {
2074
+ const port = await make({ [key("a")]: {
2075
+ tracking: "count",
2076
+ onHand: 5
2077
+ } });
2078
+ await hold(port, "h1", [line$1("a", 2)]);
2079
+ await settle(port, "commit", "h1");
2080
+ expect(await left(port, "a")).toBe(3);
2081
+ const undone = await uncommit(port, "h1", T0 + 10);
2082
+ expect(undone.ok && undone.changed).toBe(true);
2083
+ expect(undone.ok && undone.hold.status).toBe("released");
2084
+ expect(await left(port, "a", T0 + 10)).toBe(5);
2085
+ const again = await uncommit(port, "h1", T0 + 20);
2086
+ expect(again.ok && again.changed).toBe(false);
2087
+ expect(again.ok && again.hold.status).toBe("released");
2088
+ expect(await left(port, "a", T0 + 20)).toBe(5);
2089
+ });
2090
+ it("can sell the units again after an uncommit", async () => {
2091
+ const port = await make({ [key("a")]: {
2092
+ tracking: "count",
2093
+ onHand: 2
2094
+ } });
2095
+ await hold(port, "first", [line$1("a", 2)]);
2096
+ await settle(port, "commit", "first");
2097
+ expect((await hold(port, "second", [line$1("a", 1)])).ok).toBe(false);
2098
+ await uncommit(port, "first");
2099
+ const second = await hold(port, "second", [line$1("a", 2)]);
2100
+ expect(second.ok).toBe(true);
2101
+ expect(await left(port, "a")).toBe(0);
2102
+ });
2103
+ it("refuses to uncommit a hold that is still held, says its status and moves nothing", async () => {
2104
+ const port = await make({ [key("a")]: {
2105
+ tracking: "count",
2106
+ onHand: 5
2107
+ } });
2108
+ await hold(port, "held", [line$1("a", 1)]);
2109
+ expect(await uncommit(port, "held")).toEqual({
2110
+ ok: false,
2111
+ code: "invalid_state",
2112
+ status: "held"
2113
+ });
2114
+ expect(await uncommit(port, "nope")).toEqual({
2115
+ ok: false,
2116
+ code: "unknown_hold"
2117
+ });
2118
+ expect(await left(port, "a")).toBe(4);
2119
+ });
2120
+ it("takes a hold that is already released or expired as nothing to do", async () => {
2121
+ const port = await make({ [key("a")]: {
2122
+ tracking: "count",
2123
+ onHand: 5
2124
+ } });
2125
+ await hold(port, "released", [line$1("a", 1)]);
2126
+ await settle(port, "release", "released");
2127
+ const released = await uncommit(port, "released");
2128
+ expect(released.ok).toBe(true);
2129
+ expect(released.ok && released.changed).toBe(false);
2130
+ await hold(port, "expired", [line$1("a", 2)]);
2131
+ const expired = await uncommit(port, "expired", T0 + 2 * TTL);
2132
+ expect(expired.ok).toBe(true);
2133
+ expect(expired.ok && expired.changed).toBe(false);
2134
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(5);
2135
+ });
2136
+ it("keeps the id of an uncommitted hold closed to a new hold", async () => {
2137
+ const port = await make({ [key("a")]: {
2138
+ tracking: "count",
2139
+ onHand: 5
2140
+ } });
2141
+ await hold(port, "h1", [line$1("a", 2)]);
2142
+ await settle(port, "commit", "h1");
2143
+ await uncommit(port, "h1");
2144
+ expect(await hold(port, "h1", [line$1("a", 2)])).toEqual({
2145
+ ok: false,
2146
+ code: "hold_closed",
2147
+ status: "released"
2148
+ });
2149
+ expect(await left(port, "a")).toBe(5);
2150
+ });
2151
+ it("uncommits a late commit, and a hold of things nobody counts", async () => {
2152
+ const port = await make({ [key("a")]: {
2153
+ tracking: "count",
2154
+ onHand: 3
2155
+ } });
2156
+ await hold(port, "late", [line$1("a", 2)]);
2157
+ await settle(port, "commit", "late", T0 + 2 * TTL);
2158
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(1);
2159
+ const late = await uncommit(port, "late", T0 + 2 * TTL);
2160
+ expect(late.ok && late.changed).toBe(true);
2161
+ expect(await left(port, "a", T0 + 2 * TTL)).toBe(3);
2162
+ await hold(port, "free", [line$1("untracked", 4)], T0 + 2 * TTL);
2163
+ await settle(port, "commit", "free", T0 + 2 * TTL);
2164
+ const free = await uncommit(port, "free", T0 + 2 * TTL);
2165
+ expect(free.ok && free.changed).toBe(true);
2166
+ });
2167
+ it("uncommits once when two calls arrive together", async () => {
2168
+ const port = await make({ [key("a")]: {
2169
+ tracking: "count",
2170
+ onHand: 5
2171
+ } });
2172
+ await hold(port, "h1", [line$1("a", 2)]);
2173
+ await settle(port, "commit", "h1");
2174
+ const results = await Promise.all([uncommit(port, "h1"), uncommit(port, "h1")]);
2175
+ expect(results.filter((result) => result.ok && result.changed).length).toBe(1);
2176
+ expect(results.filter((result) => result.ok && !result.changed).length).toBe(1);
2177
+ expect(await left(port, "a")).toBe(5);
2178
+ });
2179
+ it("refuses to uncommit a hold whose units already left the shelf", async () => {
2180
+ const port = await make({ [key("a")]: {
2181
+ tracking: "count",
2182
+ onHand: 5
2183
+ } });
2184
+ await hold(port, "h1", [line$1("a", 2)]);
2185
+ await settle(port, "commit", "h1");
2186
+ if (typeof port.fulfil !== "function") return;
2187
+ await port.fulfil(await contextAt(T0), "h1");
2188
+ expect(await uncommit(port, "h1")).toEqual({
2189
+ ok: false,
2190
+ code: "invalid_state",
2191
+ status: "committed"
2192
+ });
2193
+ expect(await left(port, "a")).toBe(3);
2194
+ });
2195
+ });
2196
+ }
2197
+ //#endregion
2198
+ //#region src/testing/tax-conformance.ts
2199
+ /**
2200
+ * The behaviour every {@link TaxPort} must have, as a test suite. Run it against the flat double in Node
2201
+ * (`runTaxConformance(() => createFlatTax(), { describe, it, expect })`) and against a tax feature's own port in workerd, with a `context` that
2202
+ * carries the project's `env.DB`.
2203
+ *
2204
+ * What it fixes, whatever the country's rules: the answer is in the order's currency and in whole minor units; a breakdown adds up to the
2205
+ * headline, so `computeTotals` takes the answer; a price that includes the tax adds nothing on top, and the tax inside it is part of the price;
2206
+ * a discount never raises the tax; a free line carries none; the same input gives the same answer and the input is left as it was. The rates
2207
+ * themselves (16% in Mexico, 8% at the border, 0% and exempt) are the provider's own golden files, because they are law and not contract.
2208
+ */
2209
+ const line = (ref, patch = {}) => ({
2210
+ ref,
2211
+ title: `Conformance ${ref}`,
2212
+ quantity: 2,
2213
+ unitAmountMinor: 11600,
2214
+ currency: "MXN",
2215
+ availability: "available",
2216
+ taxIncluded: true,
2217
+ fulfilment: "ship",
2218
+ snapshot: { sku: ref },
2219
+ ...patch
2220
+ });
2221
+ /** A deep copy that cannot be edited: a provider that sorts or changes what it was given throws. */
2222
+ function frozen(value) {
2223
+ const copy = structuredClone(value);
2224
+ const freeze = (item) => {
2225
+ if (typeof item !== "object" || item === null || Object.isFrozen(item)) return;
2226
+ Object.freeze(item);
2227
+ for (const child of Object.values(item)) freeze(child);
2228
+ };
2229
+ freeze(copy);
2230
+ return copy;
2231
+ }
2232
+ function runTaxConformance(make, tools, options = {}) {
2233
+ const { describe, it, expect } = tools;
2234
+ const currency = options.currency ?? "MXN";
2235
+ const contextAt = async (now = TEST_NOW) => options.context ? await options.context(now) : testContext({ now });
2236
+ const asked = (patch = {}) => ({
2237
+ currency,
2238
+ lines: [line("a", { currency }), line("b", {
2239
+ currency,
2240
+ taxIncluded: false,
2241
+ unitAmountMinor: 5e3,
2242
+ quantity: 3
2243
+ })],
2244
+ discountMinor: 0,
2245
+ shippingMinor: 0,
2246
+ ...options.destination ? { destination: options.destination } : {},
2247
+ ...patch
2248
+ });
2249
+ const compute = async (port, input) => port.compute(await contextAt(), frozen(input));
2250
+ const sum = (result) => result.addedMinor + result.includedMinor;
2251
+ describe("TaxPort conformance", () => {
2252
+ it("answers in the order's currency, in whole minor units of zero or more", async () => {
2253
+ const result = await compute(await make(), asked({ shippingMinor: 9900 }));
2254
+ expect(result.currency).toBe(currency);
2255
+ for (const amount of [result.addedMinor, result.includedMinor]) expect(Number.isSafeInteger(amount) && amount >= 0).toBe(true);
2256
+ });
2257
+ it("adds up: a breakdown sums to the headline, so the totals take the answer", async () => {
2258
+ const port = await make();
2259
+ const input = asked({
2260
+ shippingMinor: 9900,
2261
+ discountMinor: 1e3
2262
+ });
2263
+ const result = await compute(port, input);
2264
+ const part = (included) => (result.breakdown ?? []).filter((item) => item.included === included).reduce((total, item) => total + item.amountMinor, 0);
2265
+ if (result.breakdown) {
2266
+ expect(part(false)).toBe(result.addedMinor);
2267
+ expect(part(true)).toBe(result.includedMinor);
2268
+ }
2269
+ const totals = computeTotals({
2270
+ lines: input.lines,
2271
+ shipping: {
2272
+ title: "Envío",
2273
+ amountMinor: 9900,
2274
+ currency
2275
+ },
2276
+ tax: result
2277
+ });
2278
+ expect(totals.ok).toBe(true);
2279
+ if (totals.ok) expect(totals.totals.taxMinor).toBe(result.addedMinor);
2280
+ });
2281
+ it("adds nothing on top of prices that already include the tax", async () => {
2282
+ const result = await compute(await make(), asked({ lines: [line("a", { currency }), line("c", {
2283
+ currency,
2284
+ unitAmountMinor: 2500
2285
+ })] }));
2286
+ expect(result.addedMinor).toBe(0);
2287
+ });
2288
+ it("keeps the tax inside a price smaller than the price", async () => {
2289
+ const result = await compute(await make(), asked({ lines: [line("a", { currency })] }));
2290
+ expect(result.includedMinor <= 23200).toBe(true);
2291
+ });
2292
+ it("never taxes more when there is a discount", async () => {
2293
+ const port = await make();
2294
+ const without = await compute(port, asked({ discountMinor: 0 }));
2295
+ const withDiscount = await compute(port, asked({ discountMinor: 5e3 }));
2296
+ expect(sum(withDiscount) <= sum(without)).toBe(true);
2297
+ });
2298
+ it("taxes a line that costs nothing at nothing", async () => {
2299
+ const free = await compute(await make(), asked({ lines: [line("free", {
2300
+ currency,
2301
+ unitAmountMinor: 0
2302
+ })] }));
2303
+ expect(sum(free)).toBe(0);
2304
+ });
2305
+ it("gives the same answer to the same input, and leaves what it was given as it was", async () => {
2306
+ const port = await make();
2307
+ const input = asked({
2308
+ shippingMinor: 9900,
2309
+ discountMinor: 1e3
2310
+ });
2311
+ const first = await compute(port, input);
2312
+ const second = await compute(port, input);
2313
+ expect(second).toEqual(first);
2314
+ });
2315
+ });
2316
+ }
2317
+ //#endregion
2318
+ export { HARNESS_CHECKS, HOSTILE_REFS, PRICE_OPTION_NAMES, TEST_NOW, checkLineSource, createFakePayments, createFlatDiscount, createFlatTax, createMemorySlots, createMemoryStock, runDiscountConformance, runLineHoldConformance, runLineSourceConformance, runPaymentsConformance, runStockConformance, runTaxConformance, subjectKey, testContext };