@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.
- package/LICENSE +21 -0
- package/README.md +410 -2
- package/dist/discounts.d.mts +33 -0
- package/dist/discounts.mjs +97 -0
- package/dist/errors-DGzIIflf.d.mts +24 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +64 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +11 -0
- package/dist/internal-p2FS_A4L.mjs +22 -0
- package/dist/lines.d.mts +142 -0
- package/dist/lines.mjs +385 -0
- package/dist/money-aFFtGd0E.mjs +166 -0
- package/dist/money.d.mts +50 -0
- package/dist/money.mjs +2 -0
- package/dist/payments.d.mts +31 -0
- package/dist/payments.mjs +166 -0
- package/dist/ports.d.mts +40 -0
- package/dist/ports.mjs +73 -0
- package/dist/snapshot.d.mts +26 -0
- package/dist/snapshot.mjs +32 -0
- package/dist/testing.d.mts +237 -0
- package/dist/testing.mjs +2318 -0
- package/dist/totals.d.mts +75 -0
- package/dist/totals.mjs +123 -0
- package/dist/types-BzlVbRp1.d.mts +493 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +46 -0
- package/package.json +91 -3
package/dist/errors.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region src/errors.ts
|
|
2
|
+
/**
|
|
3
|
+
* What can be wrong, as data. A check of untrusted input (a buyer's asks, the lines a source returns, a payment request) returns a list of
|
|
4
|
+
* {@link CommerceProblem} and never throws; a route maps `code` to its own error. Only the low-level helpers of `./money` throw, with a
|
|
5
|
+
* {@link CommerceError} that carries the same codes.
|
|
6
|
+
*/
|
|
7
|
+
const PROBLEM_CODES = [
|
|
8
|
+
"invalid_asks",
|
|
9
|
+
"invalid_ref",
|
|
10
|
+
"invalid_quantity",
|
|
11
|
+
"invalid_options",
|
|
12
|
+
"duplicate_ref",
|
|
13
|
+
"invalid_source",
|
|
14
|
+
"unknown_source",
|
|
15
|
+
"invalid_lines",
|
|
16
|
+
"line_count_mismatch",
|
|
17
|
+
"line_ref_mismatch",
|
|
18
|
+
"line_quantity_mismatch",
|
|
19
|
+
"invalid_line",
|
|
20
|
+
"invalid_title",
|
|
21
|
+
"invalid_price",
|
|
22
|
+
"invalid_currency",
|
|
23
|
+
"invalid_availability",
|
|
24
|
+
"invalid_max_quantity",
|
|
25
|
+
"invalid_fulfilment",
|
|
26
|
+
"invalid_stock_subject",
|
|
27
|
+
"invalid_snapshot",
|
|
28
|
+
"sold_out",
|
|
29
|
+
"not_orderable",
|
|
30
|
+
"not_found",
|
|
31
|
+
"over_max_quantity",
|
|
32
|
+
"empty_order",
|
|
33
|
+
"currency_mismatch",
|
|
34
|
+
"out_of_range",
|
|
35
|
+
"invalid_adjustment",
|
|
36
|
+
"discount_exceeds_subtotal",
|
|
37
|
+
"tax_breakdown_mismatch",
|
|
38
|
+
"invalid_payment",
|
|
39
|
+
"payment_not_reconciled",
|
|
40
|
+
"free_order",
|
|
41
|
+
"invalid_port"
|
|
42
|
+
];
|
|
43
|
+
/** Thrown by the low-level money helpers and by `assertTotals`. `code` is the first problem's; `problems` has at least one entry. */
|
|
44
|
+
var CommerceError = class extends Error {
|
|
45
|
+
code;
|
|
46
|
+
problems;
|
|
47
|
+
constructor(code, message, path = "", problems) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "CommerceError";
|
|
50
|
+
this.code = code;
|
|
51
|
+
this.problems = problems ?? [{
|
|
52
|
+
code,
|
|
53
|
+
path,
|
|
54
|
+
message
|
|
55
|
+
}];
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const problem = (code, path, message) => ({
|
|
59
|
+
code,
|
|
60
|
+
path,
|
|
61
|
+
message
|
|
62
|
+
});
|
|
63
|
+
//#endregion
|
|
64
|
+
export { CommerceError, PROBLEM_CODES, problem };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { a as problem, i as ProblemCode, n as CommerceProblem, r as PROBLEM_CODES, t as CommerceError } from "./errors-DGzIIflf.mjs";
|
|
2
|
+
import { $ as StartPaymentResult, A as LineHoldResult, B as PortContext, C as HoldStatus, D as LineAvailability, E as LineAsk, F as PAYMENT_METHODS, G as SettleResult, H as PortProviders, I as PaymentAdjustment, J as ShippingRatesPort, K as ShippingRate, L as PaymentLine, M as LineSnapshot, N as LineSource, O as LineHold, P as MAX_HOLD_MS, Q as StartPaymentInput, R as PaymentMethod, S as HoldResult, T as LINE_AVAILABILITIES, U as ResolvedLine, V as PortName, W as STOCK_TRACKINGS, X as ShortLine, Y as ShortAsk, Z as SnapshotValue, _ as Fulfilment, a as CancelPaymentResult, at as TaxInput, b as HoldLine, c as CommerceLocale, ct as Money, d as DiscountInput, et as StockLevel, f as DiscountPort, g as FindLineSource, h as FULFILMENTS, i as CancelPaymentFailure, it as TaxBreakdown, j as LineSettleResult, k as LineHoldRequest, l as DISCOUNT_REJECTIONS, m as DiscountResult, n as AdjustmentKind, nt as StockSubject, o as CartAsk, ot as TaxPort, p as DiscountRejection, q as ShippingRatesInput, r as AppliedDiscount, rt as StockTracking, s as CartLine, st as TaxResult, t as ADJUSTMENT_KINDS, tt as StockPort, u as Destination, v as HOLD_STATUSES, w as HoldingLineSource, x as HoldRequest, y as Hold, z as PaymentsPort } from "./types-BzlVbRp1.mjs";
|
|
3
|
+
import { DiscountableLine, checkLineDiscounts, lineDiscountsFrom, spreadDiscount } from "./discounts.mjs";
|
|
4
|
+
import { AskLimits, AsksResult, BlockedLine, BlockedReason, CartAskOptions, CartAsksResult, CartResult, DEFAULT_ASK_LIMITS, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, ResolveResult, blockedProblem, blockedReason, checkResolvedLine, checkResolvedLines, holdLinesFrom, holdsCapacity, partitionLines, resolveCart, resolveLines, validateAsks, validateCartAsks } from "./lines.mjs";
|
|
5
|
+
import { MAX_RATE_BP, ROUNDINGS, Rounding, allocateMinor, basisPointsOf, isCurrencyCode, isMinorUnits, lineTotalMinor, subtractMinor, sumMinor, taxIncludedIn } from "./money.mjs";
|
|
6
|
+
import { ChosenShipping, PricedLine, Totals, TotalsInput, TotalsResult, assertTotals, computeTotals } from "./totals.mjs";
|
|
7
|
+
import { PaymentRequestParts, checkStartPayment, paymentRequestFrom } from "./payments.mjs";
|
|
8
|
+
import { OPTIONAL_PORT_METHODS, PORT_METHODS, PORT_NAMES, UnlistedPortMethods, checkPortProvider, isPortName } from "./ports.mjs";
|
|
9
|
+
import { MAX_SNAPSHOT_TEXT, SNAPSHOT_KEYS, SnapshotKey, snapshotText } from "./snapshot.mjs";
|
|
10
|
+
//#region src/internal.d.ts
|
|
11
|
+
/** A non-empty title for a person, at most `max` long, with no space at either end. */
|
|
12
|
+
declare const isDisplayText: (value: unknown, max: number) => value is string;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { ADJUSTMENT_KINDS, AdjustmentKind, AppliedDiscount, AskLimits, AsksResult, BlockedLine, BlockedReason, CancelPaymentFailure, CancelPaymentResult, CartAsk, CartAskOptions, CartAsksResult, CartLine, CartResult, ChosenShipping, CommerceError, CommerceLocale, CommerceProblem, DEFAULT_ASK_LIMITS, DISCOUNT_REJECTIONS, Destination, DiscountInput, DiscountPort, DiscountRejection, DiscountResult, DiscountableLine, FULFILMENTS, FindLineSource, Fulfilment, HOLD_STATUSES, Hold, HoldLine, HoldRequest, HoldResult, HoldStatus, HoldingLineSource, LINE_AVAILABILITIES, LineAsk, LineAvailability, LineHold, LineHoldRequest, LineHoldResult, LineSettleResult, LineSnapshot, LineSource, MAX_HOLD_MS, MAX_RATE_BP, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, MAX_SNAPSHOT_TEXT, type Money, OPTIONAL_PORT_METHODS, PAYMENT_METHODS, PORT_METHODS, PORT_NAMES, PROBLEM_CODES, PaymentAdjustment, PaymentLine, PaymentMethod, PaymentRequestParts, PaymentsPort, PortContext, PortName, PortProviders, PricedLine, ProblemCode, ROUNDINGS, ResolveResult, ResolvedLine, Rounding, SNAPSHOT_KEYS, STOCK_TRACKINGS, SettleResult, ShippingRate, ShippingRatesInput, ShippingRatesPort, ShortAsk, ShortLine, SnapshotKey, SnapshotValue, StartPaymentInput, StartPaymentResult, StockLevel, StockPort, StockSubject, StockTracking, TaxBreakdown, TaxInput, TaxPort, TaxResult, Totals, TotalsInput, TotalsResult, UnlistedPortMethods, allocateMinor, assertTotals, basisPointsOf, blockedProblem, blockedReason, checkLineDiscounts, checkPortProvider, checkResolvedLine, checkResolvedLines, checkStartPayment, computeTotals, holdLinesFrom, holdsCapacity, isCurrencyCode, isDisplayText, isMinorUnits, isPortName, lineDiscountsFrom, lineTotalMinor, partitionLines, paymentRequestFrom, problem, resolveCart, resolveLines, snapshotText, spreadDiscount, subtractMinor, sumMinor, taxIncludedIn, validateAsks, validateCartAsks };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CommerceError, PROBLEM_CODES, problem } from "./errors.mjs";
|
|
2
|
+
import { r as isDisplayText } from "./internal-p2FS_A4L.mjs";
|
|
3
|
+
import { a as isCurrencyCode, c as subtractMinor, i as basisPointsOf, l as sumMinor, n as ROUNDINGS, o as isMinorUnits, r as allocateMinor, s as lineTotalMinor, t as MAX_RATE_BP, u as taxIncludedIn } from "./money-aFFtGd0E.mjs";
|
|
4
|
+
import { checkLineDiscounts, lineDiscountsFrom, spreadDiscount } from "./discounts.mjs";
|
|
5
|
+
import { ADJUSTMENT_KINDS, DISCOUNT_REJECTIONS, FULFILMENTS, HOLD_STATUSES, LINE_AVAILABILITIES, MAX_HOLD_MS, PAYMENT_METHODS, STOCK_TRACKINGS } from "./types.mjs";
|
|
6
|
+
import { DEFAULT_ASK_LIMITS, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, blockedProblem, blockedReason, checkResolvedLine, checkResolvedLines, holdLinesFrom, holdsCapacity, partitionLines, resolveCart, resolveLines, validateAsks, validateCartAsks } from "./lines.mjs";
|
|
7
|
+
import { checkStartPayment, paymentRequestFrom } from "./payments.mjs";
|
|
8
|
+
import { OPTIONAL_PORT_METHODS, PORT_METHODS, PORT_NAMES, checkPortProvider, isPortName } from "./ports.mjs";
|
|
9
|
+
import { MAX_SNAPSHOT_TEXT, SNAPSHOT_KEYS, snapshotText } from "./snapshot.mjs";
|
|
10
|
+
import { assertTotals, computeTotals } from "./totals.mjs";
|
|
11
|
+
export { ADJUSTMENT_KINDS, CommerceError, DEFAULT_ASK_LIMITS, DISCOUNT_REJECTIONS, FULFILMENTS, HOLD_STATUSES, LINE_AVAILABILITIES, MAX_HOLD_MS, MAX_RATE_BP, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, MAX_SNAPSHOT_TEXT, OPTIONAL_PORT_METHODS, PAYMENT_METHODS, PORT_METHODS, PORT_NAMES, PROBLEM_CODES, ROUNDINGS, SNAPSHOT_KEYS, STOCK_TRACKINGS, allocateMinor, assertTotals, basisPointsOf, blockedProblem, blockedReason, checkLineDiscounts, checkPortProvider, checkResolvedLine, checkResolvedLines, checkStartPayment, computeTotals, holdLinesFrom, holdsCapacity, isCurrencyCode, isDisplayText, isMinorUnits, isPortName, lineDiscountsFrom, lineTotalMinor, partitionLines, paymentRequestFrom, problem, resolveCart, resolveLines, snapshotText, spreadDiscount, subtractMinor, sumMinor, taxIncludedIn, validateAsks, validateCartAsks };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/internal.ts
|
|
2
|
+
/** Small checks the modules share. Not a public subpath. */
|
|
3
|
+
/**
|
|
4
|
+
* What an id, a ref, a key or an option value may not contain: control characters (C0 and C1), the invisible format characters (zero-width,
|
|
5
|
+
* bidirectional overrides, the soft hyphen, the byte order mark), the line and paragraph separators and lone surrogates.
|
|
6
|
+
*/
|
|
7
|
+
const UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/u;
|
|
8
|
+
/**
|
|
9
|
+
* What a title shown to a person may not contain: the same, except the format characters a script or an emoji needs (the zero-width joiner of
|
|
10
|
+
* a family emoji, the non-joiner of Persian, the directional marks of Arabic and Hebrew), which stay; the bidirectional overrides and isolates,
|
|
11
|
+
* which can make a title read as something else, and the byte order mark go.
|
|
12
|
+
*/
|
|
13
|
+
const UNSAFE_DISPLAY = /[\p{Cc}\p{Zl}\p{Zp}\p{Cs}\u202a-\u202e\u2066-\u2069\ufeff]/u;
|
|
14
|
+
/** A subject type of the stock port: dotted lowercase words (`catalog.variant`, `menu.item`), so `type:id` can be split back. */
|
|
15
|
+
const SUBJECT_TYPE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
|
|
16
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
/** A non-empty string of printable characters (an id, a ref, a key), at most `max` long. */
|
|
18
|
+
const isText = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && !UNSAFE.test(value);
|
|
19
|
+
/** A non-empty title for a person, at most `max` long, with no space at either end. */
|
|
20
|
+
const isDisplayText = (value, max) => typeof value === "string" && value.length > 0 && value.length <= max && value === value.trim() && !UNSAFE_DISPLAY.test(value);
|
|
21
|
+
//#endregion
|
|
22
|
+
export { isText as a, isRecord as i, UNSAFE as n, isDisplayText as r, SUBJECT_TYPE as t };
|
package/dist/lines.d.mts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
|
|
2
|
+
import { B as PortContext, E as LineAsk, N as LineSource, U as ResolvedLine, b as HoldLine, g as FindLineSource, nt as StockSubject, o as CartAsk, s as CartLine, w as HoldingLineSource } from "./types-BzlVbRp1.mjs";
|
|
3
|
+
//#region src/lines.d.ts
|
|
4
|
+
interface AskLimits {
|
|
5
|
+
/** Lines in one request. */
|
|
6
|
+
maxLines: number;
|
|
7
|
+
/** One line's quantity. */
|
|
8
|
+
maxQuantity: number;
|
|
9
|
+
maxRefLength: number;
|
|
10
|
+
/** Options on one line. */
|
|
11
|
+
maxOptions: number;
|
|
12
|
+
}
|
|
13
|
+
declare const DEFAULT_ASK_LIMITS: AskLimits;
|
|
14
|
+
/** The longest ref or stock subject id there is: `maxRefLength` of {@link AskLimits} is cut to this, so a line can always be checked. */
|
|
15
|
+
declare const MAX_REF_LENGTH = 1024;
|
|
16
|
+
/** What a line's snapshot may weigh as JSON, in bytes: it is copied into every order line. */
|
|
17
|
+
declare const MAX_SNAPSHOT_BYTES = 2048;
|
|
18
|
+
type AsksResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
asks: LineAsk[];
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
problems: CommerceProblem[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Reads the `asks` of a request body. What comes out has exactly `ref`, `quantity` and (when given) `options`: anything else the browser sent,
|
|
27
|
+
* a `price` above all, is dropped, never read. A repeated ref is refused (the client sums its own quantities).
|
|
28
|
+
*/
|
|
29
|
+
declare function validateAsks(input: unknown, limits?: Partial<AskLimits>): AsksResult;
|
|
30
|
+
type CartAsksResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
asks: CartAsk[];
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
problems: CommerceProblem[];
|
|
36
|
+
};
|
|
37
|
+
interface CartAskOptions extends Partial<AskLimits> {
|
|
38
|
+
/**
|
|
39
|
+
* The registry's lookup (`(key) => linePort(key)...`, as {@link resolveCart} takes it). With it, an ask whose source nobody registered is
|
|
40
|
+
* refused as `unknown_source`, so a cart that names a feature that was removed stops before anything is held; without it only the key's
|
|
41
|
+
* shape is checked.
|
|
42
|
+
*/
|
|
43
|
+
find?: FindLineSource;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reads the lines of a cart: a list of `{ source, ref, quantity, options? }` (the same limits as {@link validateAsks}: 1 to 50 lines,
|
|
47
|
+
* quantities 1 to 99, refs of 1 to 120 printable characters) and gives back exactly those fields, in the order sent. It is the check a cart's
|
|
48
|
+
* `complete` runs on its lines BEFORE any price is asked and any place is held, so a hostile or stale cart costs nothing and holds nothing.
|
|
49
|
+
*
|
|
50
|
+
* - `invalid_source`: the source is not a key (dotted lowercase words, at most 80 characters), so it is never looked up;
|
|
51
|
+
* - `unknown_source`: with {@link CartAskOptions.find}, nothing is registered under the key;
|
|
52
|
+
* - `duplicate_ref`: the same source, ref and options twice (the same ref under two sources, or with other options, is two lines);
|
|
53
|
+
* - what {@link validateAsks} refuses for a ref, a quantity or the options, at the same paths (`asks[2].quantity`).
|
|
54
|
+
*
|
|
55
|
+
* Anything else in an ask, a price above all, is dropped and never read. Whether the thing exists and can be bought is the source's answer,
|
|
56
|
+
* not this check's: price the result with {@link resolveCart}.
|
|
57
|
+
*
|
|
58
|
+
* ```ts
|
|
59
|
+
* const read = validateCartAsks(cart.lines, { find: (key) => { const found = linePort<LineSource>(key); return found.ok ? found.port : undefined; } });
|
|
60
|
+
* if (!read.ok) return refuse(read.problems);
|
|
61
|
+
* const priced = await resolveCart(find, ctx, read.asks);
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
declare function validateCartAsks(input: unknown, options?: CartAskOptions): CartAsksResult;
|
|
65
|
+
/** The problems with one line, judged on its own: its shape and the values it may hold. Does not look at the ask it answers. */
|
|
66
|
+
declare function checkResolvedLine(line: unknown, path?: string): CommerceProblem[];
|
|
67
|
+
/** The problems with a source's whole answer to `asks`: one line per ask, in order, each valid and echoing its ask's ref and quantity. */
|
|
68
|
+
declare function checkResolvedLines(asks: readonly LineAsk[], lines: unknown, path?: string): CommerceProblem[];
|
|
69
|
+
type ResolveResult = {
|
|
70
|
+
ok: true;
|
|
71
|
+
lines: ResolvedLine[];
|
|
72
|
+
} | {
|
|
73
|
+
ok: false;
|
|
74
|
+
problems: CommerceProblem[];
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Asks a source for the lines and checks the answer, so nothing a source gets wrong reaches a total. The source is given frozen copies, so
|
|
78
|
+
* one that sorts or edits its input in place fails loudly instead of corrupting the caller's order. A source that throws is not caught: that
|
|
79
|
+
* is a fault, not a problem with the answer.
|
|
80
|
+
*/
|
|
81
|
+
declare function resolveLines(source: LineSource, ctx: PortContext, asks: readonly LineAsk[]): Promise<ResolveResult>;
|
|
82
|
+
type CartResult = {
|
|
83
|
+
ok: true;
|
|
84
|
+
lines: CartLine[];
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
problems: CommerceProblem[];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Prices a cart whose lines name different sources: each source is asked once, for its own asks in the cart's order (through
|
|
91
|
+
* {@link resolveLines}, so its answer is checked and it is given frozen copies without the `source` key), the sources at the same time, and
|
|
92
|
+
* the lines come back one per ask in the cart's order, each with its `source`.
|
|
93
|
+
*
|
|
94
|
+
* - An ask whose source `find` does not have (a feature that was removed, a key nobody registered, an empty key) is a `not_found` line with a
|
|
95
|
+
* price of 0 in no currency (`XXX`), as a ref no source has: the cart shows it as no longer sold and totals the rest (`partitionLines`).
|
|
96
|
+
* - A source that answers wrong makes the whole answer `{ ok: false, problems }`, listed source by source; a problem with one line has the cart's
|
|
97
|
+
* line as its path (`lines[3].unitAmountMinor`), one with a source's whole answer (`line_count_mismatch`) the path `lines`, and every message
|
|
98
|
+
* starts with the source's key. A source that throws is not caught.
|
|
99
|
+
* - The asks are not checked here: check them where they come in (`validateAsks` for the ref, quantity and options; the registry's lookup
|
|
100
|
+
* finds only a key that was registered). A line not sold here shows its ref as its title.
|
|
101
|
+
*
|
|
102
|
+
* ```ts
|
|
103
|
+
* const cart = await resolveCart((key) => { const found = linePort<LineSource>(key); return found.ok ? found.port : undefined; }, ctx, asks);
|
|
104
|
+
* if (!cart.ok) throw new Error(cart.problems[0]?.message);
|
|
105
|
+
* const { chargeable, blocked } = partitionLines(cart.lines);
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
declare function resolveCart(find: FindLineSource, ctx: PortContext, asks: readonly CartAsk[]): Promise<CartResult>;
|
|
109
|
+
/**
|
|
110
|
+
* Whether a source holds its own places (`hold`, `commit` and `release` are all there), so a checkout holds its asks beside the stock. A source
|
|
111
|
+
* with only some of the three is `false`, and a checkout would not hold its places: `checkPortProvider` and the line-source harness (its `holds`
|
|
112
|
+
* check) report such a source.
|
|
113
|
+
*/
|
|
114
|
+
declare function holdsCapacity(source: LineSource): source is HoldingLineSource;
|
|
115
|
+
type BlockedReason = "sold_out" | "not_orderable" | "not_found" | "over_max_quantity";
|
|
116
|
+
/** Why a (valid) line cannot be charged now, or `undefined` when it can. */
|
|
117
|
+
declare function blockedReason(line: ResolvedLine): BlockedReason | undefined;
|
|
118
|
+
declare function blockedProblem(line: ResolvedLine, path: string): CommerceProblem | undefined;
|
|
119
|
+
interface BlockedLine<Line extends ResolvedLine = ResolvedLine> {
|
|
120
|
+
/** The position in the list that was partitioned, which is the position of the ask. */
|
|
121
|
+
index: number;
|
|
122
|
+
line: Line;
|
|
123
|
+
reason: BlockedReason;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Splits valid lines into those that can be charged and those that cannot, with the reason: a cart shows the second group ("sold out", "only 2
|
|
127
|
+
* left") and totals the first. The lines keep their type, so a cart's lines keep their `source`.
|
|
128
|
+
*/
|
|
129
|
+
declare function partitionLines<Line extends ResolvedLine>(lines: readonly Line[]): {
|
|
130
|
+
chargeable: Line[];
|
|
131
|
+
blocked: BlockedLine<Line>[];
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* The lines to hold for these (resolved or priced) lines: each line that names a stock subject, with its quantity. Hold with this and not with
|
|
135
|
+
* the `ref`: a hold on a subject inventory does not know is taken as untracked, always available, with no error.
|
|
136
|
+
*/
|
|
137
|
+
declare function holdLinesFrom(lines: readonly {
|
|
138
|
+
stock?: StockSubject;
|
|
139
|
+
quantity: number;
|
|
140
|
+
}[]): HoldLine[];
|
|
141
|
+
//#endregion
|
|
142
|
+
export { AskLimits, AsksResult, BlockedLine, BlockedReason, CartAskOptions, CartAsksResult, CartResult, DEFAULT_ASK_LIMITS, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, ResolveResult, blockedProblem, blockedReason, checkResolvedLine, checkResolvedLines, holdLinesFrom, holdsCapacity, partitionLines, resolveCart, resolveLines, validateAsks, validateCartAsks };
|
package/dist/lines.mjs
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { problem } from "./errors.mjs";
|
|
2
|
+
import { a as isText, i as isRecord, n as UNSAFE, r as isDisplayText, t as SUBJECT_TYPE } from "./internal-p2FS_A4L.mjs";
|
|
3
|
+
import { a as isCurrencyCode, o as isMinorUnits } from "./money-aFFtGd0E.mjs";
|
|
4
|
+
import { FULFILMENTS, LINE_AVAILABILITIES } from "./types.mjs";
|
|
5
|
+
//#region src/lines.ts
|
|
6
|
+
/**
|
|
7
|
+
* Checks at the two edges of a line source: what the browser asks for, and what a source answers. Both return a list of problems and never
|
|
8
|
+
* throw, because the first is hostile input and the second is code another feature wrote (or a project edited). A checkout runs
|
|
9
|
+
* {@link validateAsks} on the request body and {@link resolveLines} on the way to a price, so a wrong line stops there and not in the total.
|
|
10
|
+
*/
|
|
11
|
+
const DEFAULT_ASK_LIMITS = {
|
|
12
|
+
maxLines: 50,
|
|
13
|
+
maxQuantity: 99,
|
|
14
|
+
maxRefLength: 120,
|
|
15
|
+
maxOptions: 10
|
|
16
|
+
};
|
|
17
|
+
/** The longest ref or stock subject id there is: `maxRefLength` of {@link AskLimits} is cut to this, so a line can always be checked. */
|
|
18
|
+
const MAX_REF_LENGTH = 1024;
|
|
19
|
+
/** What a line's snapshot may weigh as JSON, in bytes: it is copied into every order line. */
|
|
20
|
+
const MAX_SNAPSHOT_BYTES = 2048;
|
|
21
|
+
const MAX_TITLE_LENGTH = 200;
|
|
22
|
+
const MAX_OPTION_KEY_LENGTH = 40;
|
|
23
|
+
const MAX_OPTION_VALUE_LENGTH = 200;
|
|
24
|
+
const MAX_SNAPSHOT_DEPTH = 6;
|
|
25
|
+
const FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
|
|
26
|
+
"__proto__",
|
|
27
|
+
"constructor",
|
|
28
|
+
"prototype"
|
|
29
|
+
]);
|
|
30
|
+
const OPTION_KEY = /^[A-Za-z][A-Za-z0-9_.-]*$/;
|
|
31
|
+
const TAX_CODE = /^[A-Za-z0-9]{1,20}$/;
|
|
32
|
+
/** A non-empty string of printable characters with no space at either end. */
|
|
33
|
+
function isPlainText(value, maxLength) {
|
|
34
|
+
return isText(value, maxLength) && value === value.trim();
|
|
35
|
+
}
|
|
36
|
+
/** A line source's key: dotted lowercase words (`catalog.variant`), what `block.json` allows and the pattern pack's registry finds. */
|
|
37
|
+
const SOURCE_KEY = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
|
|
38
|
+
const MAX_SOURCE_KEY_LENGTH = 80;
|
|
39
|
+
/** The name of an ask in a repeat check: a ref with its options in a fixed order (and its source, in a cart). */
|
|
40
|
+
const askKey = (source, ref, options) => `${source ?? ""}\u0000${ref}\u0000${JSON.stringify(Object.entries(options ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))}`;
|
|
41
|
+
/** The one reader behind {@link validateAsks} and {@link validateCartAsks}: the list, each ask's shape, and the repeats. */
|
|
42
|
+
function readAsks(input, limits, how) {
|
|
43
|
+
const max = {
|
|
44
|
+
...DEFAULT_ASK_LIMITS,
|
|
45
|
+
...limits
|
|
46
|
+
};
|
|
47
|
+
max.maxRefLength = Math.min(max.maxRefLength, MAX_REF_LENGTH);
|
|
48
|
+
if (!Array.isArray(input)) return {
|
|
49
|
+
ok: false,
|
|
50
|
+
problems: [problem("invalid_asks", "asks", "asks must be a list")]
|
|
51
|
+
};
|
|
52
|
+
if (input.length === 0) return {
|
|
53
|
+
ok: false,
|
|
54
|
+
problems: [problem("invalid_asks", "asks", "ask for at least one thing")]
|
|
55
|
+
};
|
|
56
|
+
if (input.length > max.maxLines) return {
|
|
57
|
+
ok: false,
|
|
58
|
+
problems: [problem("invalid_asks", "asks", `at most ${max.maxLines} lines`)]
|
|
59
|
+
};
|
|
60
|
+
const problems = [];
|
|
61
|
+
const asks = [];
|
|
62
|
+
const seen = /* @__PURE__ */ new Set();
|
|
63
|
+
for (const [index, raw] of input.entries()) {
|
|
64
|
+
const path = `asks[${index}]`;
|
|
65
|
+
if (!isRecord(raw)) {
|
|
66
|
+
problems.push(problem("invalid_asks", path, `each ask is an object with ${how.cart ? "a source, " : ""}a ref and a quantity`));
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const { ref, quantity, options, source } = raw;
|
|
70
|
+
let valid = true;
|
|
71
|
+
if (how.cart) {
|
|
72
|
+
if (typeof source !== "string" || source.length > MAX_SOURCE_KEY_LENGTH || !SOURCE_KEY.test(source)) {
|
|
73
|
+
problems.push(problem("invalid_source", `${path}.source`, "source is the key of a line source: dotted lowercase words (catalog.variant)"));
|
|
74
|
+
valid = false;
|
|
75
|
+
} else if (how.find && how.find(source) === void 0) {
|
|
76
|
+
problems.push(problem("unknown_source", `${path}.source`, `nothing sells through ${source}`));
|
|
77
|
+
valid = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!isPlainText(ref, max.maxRefLength)) {
|
|
81
|
+
problems.push(problem("invalid_ref", `${path}.ref`, `ref is 1 to ${max.maxRefLength} printable characters with no space at either end`));
|
|
82
|
+
valid = false;
|
|
83
|
+
}
|
|
84
|
+
if (typeof quantity !== "number" || !Number.isSafeInteger(quantity) || quantity < 1 || quantity > max.maxQuantity) {
|
|
85
|
+
problems.push(problem("invalid_quantity", `${path}.quantity`, `quantity is a whole number from 1 to ${max.maxQuantity}`));
|
|
86
|
+
valid = false;
|
|
87
|
+
}
|
|
88
|
+
let cleanOptions;
|
|
89
|
+
if (options !== void 0) {
|
|
90
|
+
const read = readOptions(options, max, `${path}.options`);
|
|
91
|
+
if (Array.isArray(read)) {
|
|
92
|
+
problems.push(...read);
|
|
93
|
+
valid = false;
|
|
94
|
+
} else cleanOptions = read;
|
|
95
|
+
}
|
|
96
|
+
if (!valid) continue;
|
|
97
|
+
const key = askKey(how.cart ? source : void 0, ref, cleanOptions);
|
|
98
|
+
if (seen.has(key)) {
|
|
99
|
+
problems.push(problem("duplicate_ref", `${path}.ref`, "the same thing is asked for twice: send one line with the total quantity"));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
seen.add(key);
|
|
103
|
+
const ask = {
|
|
104
|
+
ref,
|
|
105
|
+
quantity,
|
|
106
|
+
...cleanOptions ? { options: cleanOptions } : {}
|
|
107
|
+
};
|
|
108
|
+
asks.push(how.cart ? {
|
|
109
|
+
source,
|
|
110
|
+
...ask
|
|
111
|
+
} : ask);
|
|
112
|
+
}
|
|
113
|
+
return problems.length > 0 ? {
|
|
114
|
+
ok: false,
|
|
115
|
+
problems
|
|
116
|
+
} : {
|
|
117
|
+
ok: true,
|
|
118
|
+
asks
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reads the `asks` of a request body. What comes out has exactly `ref`, `quantity` and (when given) `options`: anything else the browser sent,
|
|
123
|
+
* a `price` above all, is dropped, never read. A repeated ref is refused (the client sums its own quantities).
|
|
124
|
+
*/
|
|
125
|
+
function validateAsks(input, limits = {}) {
|
|
126
|
+
const read = readAsks(input, limits, { cart: false });
|
|
127
|
+
return read.ok ? {
|
|
128
|
+
ok: true,
|
|
129
|
+
asks: read.asks
|
|
130
|
+
} : read;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Reads the lines of a cart: a list of `{ source, ref, quantity, options? }` (the same limits as {@link validateAsks}: 1 to 50 lines,
|
|
134
|
+
* quantities 1 to 99, refs of 1 to 120 printable characters) and gives back exactly those fields, in the order sent. It is the check a cart's
|
|
135
|
+
* `complete` runs on its lines BEFORE any price is asked and any place is held, so a hostile or stale cart costs nothing and holds nothing.
|
|
136
|
+
*
|
|
137
|
+
* - `invalid_source`: the source is not a key (dotted lowercase words, at most 80 characters), so it is never looked up;
|
|
138
|
+
* - `unknown_source`: with {@link CartAskOptions.find}, nothing is registered under the key;
|
|
139
|
+
* - `duplicate_ref`: the same source, ref and options twice (the same ref under two sources, or with other options, is two lines);
|
|
140
|
+
* - what {@link validateAsks} refuses for a ref, a quantity or the options, at the same paths (`asks[2].quantity`).
|
|
141
|
+
*
|
|
142
|
+
* Anything else in an ask, a price above all, is dropped and never read. Whether the thing exists and can be bought is the source's answer,
|
|
143
|
+
* not this check's: price the result with {@link resolveCart}.
|
|
144
|
+
*
|
|
145
|
+
* ```ts
|
|
146
|
+
* const read = validateCartAsks(cart.lines, { find: (key) => { const found = linePort<LineSource>(key); return found.ok ? found.port : undefined; } });
|
|
147
|
+
* if (!read.ok) return refuse(read.problems);
|
|
148
|
+
* const priced = await resolveCart(find, ctx, read.asks);
|
|
149
|
+
* ```
|
|
150
|
+
*/
|
|
151
|
+
function validateCartAsks(input, options = {}) {
|
|
152
|
+
const { find, ...limits } = options;
|
|
153
|
+
return readAsks(input, limits, {
|
|
154
|
+
cart: true,
|
|
155
|
+
find
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function readOptions(value, max, path) {
|
|
159
|
+
if (!isRecord(value)) return [problem("invalid_options", path, "options is an object of strings")];
|
|
160
|
+
const entries = Object.entries(value);
|
|
161
|
+
if (entries.length > max.maxOptions) return [problem("invalid_options", path, `at most ${max.maxOptions} options`)];
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const [key, option] of entries) {
|
|
164
|
+
if (key.length > MAX_OPTION_KEY_LENGTH || !OPTION_KEY.test(key) || FORBIDDEN_KEYS.has(key)) return [problem("invalid_options", `${path}.${key.slice(0, 20)}`, "an option name is letters, digits, dot, dash or underscore, starting with a letter")];
|
|
165
|
+
if (typeof option !== "string" || option.length > MAX_OPTION_VALUE_LENGTH || UNSAFE.test(option)) return [problem("invalid_options", `${path}.${key}`, `an option value is a string of at most ${MAX_OPTION_VALUE_LENGTH} printable characters`)];
|
|
166
|
+
out[key] = option;
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/** Why a value cannot be written to JSON and read back the same, or `undefined` when it can. */
|
|
171
|
+
function notJson(value, depth, path) {
|
|
172
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return void 0;
|
|
173
|
+
if (typeof value === "number") return Number.isFinite(value) ? void 0 : "a number that is not finite";
|
|
174
|
+
if (depth >= MAX_SNAPSHOT_DEPTH) return `nested more than ${MAX_SNAPSHOT_DEPTH} levels`;
|
|
175
|
+
if (typeof value !== "object") return `a ${typeof value}`;
|
|
176
|
+
if (path.has(value)) return "a cycle";
|
|
177
|
+
const proto = Object.getPrototypeOf(value);
|
|
178
|
+
if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return "an object that is not plain";
|
|
179
|
+
path.add(value);
|
|
180
|
+
for (const item of Array.isArray(value) ? value : Object.values(value)) {
|
|
181
|
+
const reason = notJson(item, depth + 1, path);
|
|
182
|
+
if (reason) return reason;
|
|
183
|
+
}
|
|
184
|
+
path.delete(value);
|
|
185
|
+
}
|
|
186
|
+
/** The problems with one line, judged on its own: its shape and the values it may hold. Does not look at the ask it answers. */
|
|
187
|
+
function checkResolvedLine(line, path = "line") {
|
|
188
|
+
if (!isRecord(line)) return [problem("invalid_line", path, "a line is an object")];
|
|
189
|
+
const out = [];
|
|
190
|
+
const add = (code, field, message) => out.push(problem(code, `${path}.${field}`, message));
|
|
191
|
+
if (!isPlainText(line.ref, 1024)) add("invalid_ref", "ref", "ref is the ask's ref");
|
|
192
|
+
if (!isDisplayText(line.title, MAX_TITLE_LENGTH)) add("invalid_title", "title", `title is 1 to ${MAX_TITLE_LENGTH} printable characters`);
|
|
193
|
+
if (typeof line.quantity !== "number" || !Number.isSafeInteger(line.quantity) || line.quantity < 1) add("invalid_quantity", "quantity", "quantity is a whole number of at least 1");
|
|
194
|
+
if (!isMinorUnits(line.unitAmountMinor)) add("invalid_price", "unitAmountMinor", "a price is a whole number of minor units, zero or more");
|
|
195
|
+
if (!isCurrencyCode(line.currency)) add("invalid_currency", "currency", "currency is an uppercase three-letter ISO 4217 code");
|
|
196
|
+
if (!LINE_AVAILABILITIES.includes(line.availability)) add("invalid_availability", "availability", `availability is one of ${LINE_AVAILABILITIES.join(", ")}`);
|
|
197
|
+
else if (line.availability === "not_found" && line.unitAmountMinor !== 0) add("invalid_price", "unitAmountMinor", "a line that is not found has no price: it is 0");
|
|
198
|
+
if (line.maxQuantity !== void 0 && (typeof line.maxQuantity !== "number" || !Number.isSafeInteger(line.maxQuantity) || line.maxQuantity < 1)) add("invalid_max_quantity", "maxQuantity", "maxQuantity is a whole number of at least 1 when given (a line with none left is sold_out)");
|
|
199
|
+
if (line.taxCode !== void 0 && (typeof line.taxCode !== "string" || !TAX_CODE.test(line.taxCode))) add("invalid_line", "taxCode", "taxCode is 1 to 20 letters or digits when given");
|
|
200
|
+
if (typeof line.taxIncluded !== "boolean") add("invalid_line", "taxIncluded", "taxIncluded is true or false");
|
|
201
|
+
if (!FULFILMENTS.includes(line.fulfilment)) add("invalid_fulfilment", "fulfilment", `fulfilment is one of ${FULFILMENTS.join(", ")}`);
|
|
202
|
+
if (line.stock !== void 0) {
|
|
203
|
+
const stock = line.stock;
|
|
204
|
+
if (!isRecord(stock) || !isText(stock.subjectType, 80) || !SUBJECT_TYPE.test(stock.subjectType) || !isPlainText(stock.subjectId, 1024)) add("invalid_stock_subject", "stock", "stock is { subjectType, subjectId }: a dotted lowercase type (catalog.variant) and printable text");
|
|
205
|
+
}
|
|
206
|
+
if (!isRecord(line.snapshot)) add("invalid_snapshot", "snapshot", "snapshot is an object");
|
|
207
|
+
else {
|
|
208
|
+
const reason = notJson(line.snapshot, 0, /* @__PURE__ */ new Set());
|
|
209
|
+
if (reason) add("invalid_snapshot", "snapshot", `snapshot must survive JSON unchanged: it holds ${reason}`);
|
|
210
|
+
else if (new TextEncoder().encode(JSON.stringify(line.snapshot)).length > 2048) add("invalid_snapshot", "snapshot", `snapshot is more than ${MAX_SNAPSHOT_BYTES} bytes as JSON`);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
/** The problems with a source's whole answer to `asks`: one line per ask, in order, each valid and echoing its ask's ref and quantity. */
|
|
215
|
+
function checkResolvedLines(asks, lines, path = "lines") {
|
|
216
|
+
if (!Array.isArray(lines)) return [problem("invalid_lines", path, "a source returns a list of lines")];
|
|
217
|
+
if (lines.length !== asks.length) return [problem("line_count_mismatch", path, `${asks.length} asked, ${lines.length} returned: a source answers every ask, in order`)];
|
|
218
|
+
const out = [];
|
|
219
|
+
for (const [index, line] of lines.entries()) {
|
|
220
|
+
const at = `${path}[${index}]`;
|
|
221
|
+
const shape = checkResolvedLine(line, at);
|
|
222
|
+
out.push(...shape);
|
|
223
|
+
if (!isRecord(line)) continue;
|
|
224
|
+
const ask = asks[index];
|
|
225
|
+
if (line.ref !== ask.ref) out.push(problem("line_ref_mismatch", `${at}.ref`, `answered ${String(line.ref)} to ${ask.ref}: lines come back in the order asked`));
|
|
226
|
+
if (line.quantity !== ask.quantity) out.push(problem("line_quantity_mismatch", `${at}.quantity`, "a source echoes the quantity asked and never changes it"));
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Asks a source for the lines and checks the answer, so nothing a source gets wrong reaches a total. The source is given frozen copies, so
|
|
232
|
+
* one that sorts or edits its input in place fails loudly instead of corrupting the caller's order. A source that throws is not caught: that
|
|
233
|
+
* is a fault, not a problem with the answer.
|
|
234
|
+
*/
|
|
235
|
+
async function resolveLines(source, ctx, asks) {
|
|
236
|
+
const given = Object.freeze(asks.map((ask) => Object.freeze({
|
|
237
|
+
ref: ask.ref,
|
|
238
|
+
quantity: ask.quantity,
|
|
239
|
+
...ask.options ? { options: Object.freeze({ ...ask.options }) } : {}
|
|
240
|
+
})));
|
|
241
|
+
const lines = await source.resolve(ctx, given);
|
|
242
|
+
const problems = checkResolvedLines(asks, lines);
|
|
243
|
+
return problems.length > 0 ? {
|
|
244
|
+
ok: false,
|
|
245
|
+
problems
|
|
246
|
+
} : {
|
|
247
|
+
ok: true,
|
|
248
|
+
lines: [...lines]
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** No currency (ISO 4217 `XXX`): the price of a line that is not sold here, which is 0 and never charged. */
|
|
252
|
+
const NO_CURRENCY = "XXX";
|
|
253
|
+
/** `text` cut to `max` UTF-16 units with an ellipsis, never in the middle of a character. */
|
|
254
|
+
function cut(text, max) {
|
|
255
|
+
if (text.length <= max) return text;
|
|
256
|
+
let out = "";
|
|
257
|
+
for (const character of text) {
|
|
258
|
+
if (out.length + character.length > max - 1) break;
|
|
259
|
+
out += character;
|
|
260
|
+
}
|
|
261
|
+
return `${out}\u2026`;
|
|
262
|
+
}
|
|
263
|
+
/** The line for an ask whose source is not there (a feature that was removed, a key nobody registered): not sold, like a ref no source has. */
|
|
264
|
+
const notSoldHere = (ask, source) => ({
|
|
265
|
+
source,
|
|
266
|
+
ref: ask.ref,
|
|
267
|
+
title: cut(ask.ref, MAX_TITLE_LENGTH),
|
|
268
|
+
quantity: ask.quantity,
|
|
269
|
+
unitAmountMinor: 0,
|
|
270
|
+
currency: NO_CURRENCY,
|
|
271
|
+
availability: "not_found",
|
|
272
|
+
taxIncluded: true,
|
|
273
|
+
fulfilment: "none",
|
|
274
|
+
snapshot: {}
|
|
275
|
+
});
|
|
276
|
+
/**
|
|
277
|
+
* Prices a cart whose lines name different sources: each source is asked once, for its own asks in the cart's order (through
|
|
278
|
+
* {@link resolveLines}, so its answer is checked and it is given frozen copies without the `source` key), the sources at the same time, and
|
|
279
|
+
* the lines come back one per ask in the cart's order, each with its `source`.
|
|
280
|
+
*
|
|
281
|
+
* - An ask whose source `find` does not have (a feature that was removed, a key nobody registered, an empty key) is a `not_found` line with a
|
|
282
|
+
* price of 0 in no currency (`XXX`), as a ref no source has: the cart shows it as no longer sold and totals the rest (`partitionLines`).
|
|
283
|
+
* - A source that answers wrong makes the whole answer `{ ok: false, problems }`, listed source by source; a problem with one line has the cart's
|
|
284
|
+
* line as its path (`lines[3].unitAmountMinor`), one with a source's whole answer (`line_count_mismatch`) the path `lines`, and every message
|
|
285
|
+
* starts with the source's key. A source that throws is not caught.
|
|
286
|
+
* - The asks are not checked here: check them where they come in (`validateAsks` for the ref, quantity and options; the registry's lookup
|
|
287
|
+
* finds only a key that was registered). A line not sold here shows its ref as its title.
|
|
288
|
+
*
|
|
289
|
+
* ```ts
|
|
290
|
+
* const cart = await resolveCart((key) => { const found = linePort<LineSource>(key); return found.ok ? found.port : undefined; }, ctx, asks);
|
|
291
|
+
* if (!cart.ok) throw new Error(cart.problems[0]?.message);
|
|
292
|
+
* const { chargeable, blocked } = partitionLines(cart.lines);
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
async function resolveCart(find, ctx, asks) {
|
|
296
|
+
const groups = /* @__PURE__ */ new Map();
|
|
297
|
+
for (const [index, ask] of asks.entries()) {
|
|
298
|
+
const key = typeof ask.source === "string" ? ask.source : "";
|
|
299
|
+
groups.set(key, [...groups.get(key) ?? [], index]);
|
|
300
|
+
}
|
|
301
|
+
const answers = await Promise.all([...groups].map(async ([key, indexes]) => {
|
|
302
|
+
const found = key === "" ? void 0 : find(key);
|
|
303
|
+
if (typeof found?.resolve !== "function") return {
|
|
304
|
+
key,
|
|
305
|
+
indexes,
|
|
306
|
+
result: void 0
|
|
307
|
+
};
|
|
308
|
+
return {
|
|
309
|
+
key,
|
|
310
|
+
indexes,
|
|
311
|
+
result: await resolveLines(found, ctx, indexes.map((index) => asks[index]))
|
|
312
|
+
};
|
|
313
|
+
}));
|
|
314
|
+
const lines = new Array(asks.length);
|
|
315
|
+
const problems = [];
|
|
316
|
+
for (const { key, indexes, result } of answers) if (result === void 0) for (const index of indexes) lines[index] = notSoldHere(asks[index], key);
|
|
317
|
+
else if (!result.ok) for (const found of result.problems) {
|
|
318
|
+
const path = found.path.replace(/^lines\[(\d+)\]/, (_match, at) => `lines[${indexes[Number(at)]}]`);
|
|
319
|
+
problems.push(problem(found.code, path, `${key}: ${found.message}`));
|
|
320
|
+
}
|
|
321
|
+
else result.lines.forEach((resolved, at) => {
|
|
322
|
+
lines[indexes[at]] = {
|
|
323
|
+
...resolved,
|
|
324
|
+
source: key
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
return problems.length > 0 ? {
|
|
328
|
+
ok: false,
|
|
329
|
+
problems
|
|
330
|
+
} : {
|
|
331
|
+
ok: true,
|
|
332
|
+
lines
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Whether a source holds its own places (`hold`, `commit` and `release` are all there), so a checkout holds its asks beside the stock. A source
|
|
337
|
+
* with only some of the three is `false`, and a checkout would not hold its places: `checkPortProvider` and the line-source harness (its `holds`
|
|
338
|
+
* check) report such a source.
|
|
339
|
+
*/
|
|
340
|
+
function holdsCapacity(source) {
|
|
341
|
+
return typeof source.hold === "function" && typeof source.commit === "function" && typeof source.release === "function";
|
|
342
|
+
}
|
|
343
|
+
/** Why a (valid) line cannot be charged now, or `undefined` when it can. */
|
|
344
|
+
function blockedReason(line) {
|
|
345
|
+
if (line.availability !== "available") return line.availability;
|
|
346
|
+
if (line.maxQuantity !== void 0 && line.quantity > line.maxQuantity) return "over_max_quantity";
|
|
347
|
+
}
|
|
348
|
+
function blockedProblem(line, path) {
|
|
349
|
+
const reason = blockedReason(line);
|
|
350
|
+
if (!reason) return void 0;
|
|
351
|
+
return problem(reason, path, reason === "over_max_quantity" ? `only ${line.maxQuantity} of ${line.ref} can be taken` : `${line.ref} is ${reason.replace("_", " ")}`);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Splits valid lines into those that can be charged and those that cannot, with the reason: a cart shows the second group ("sold out", "only 2
|
|
355
|
+
* left") and totals the first. The lines keep their type, so a cart's lines keep their `source`.
|
|
356
|
+
*/
|
|
357
|
+
function partitionLines(lines) {
|
|
358
|
+
const chargeable = [];
|
|
359
|
+
const blocked = [];
|
|
360
|
+
for (const [index, line] of lines.entries()) {
|
|
361
|
+
const reason = blockedReason(line);
|
|
362
|
+
if (reason) blocked.push({
|
|
363
|
+
index,
|
|
364
|
+
line,
|
|
365
|
+
reason
|
|
366
|
+
});
|
|
367
|
+
else chargeable.push(line);
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
chargeable,
|
|
371
|
+
blocked
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The lines to hold for these (resolved or priced) lines: each line that names a stock subject, with its quantity. Hold with this and not with
|
|
376
|
+
* the `ref`: a hold on a subject inventory does not know is taken as untracked, always available, with no error.
|
|
377
|
+
*/
|
|
378
|
+
function holdLinesFrom(lines) {
|
|
379
|
+
return lines.flatMap((line) => line.stock ? [{
|
|
380
|
+
subject: { ...line.stock },
|
|
381
|
+
quantity: line.quantity
|
|
382
|
+
}] : []);
|
|
383
|
+
}
|
|
384
|
+
//#endregion
|
|
385
|
+
export { DEFAULT_ASK_LIMITS, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, blockedProblem, blockedReason, checkResolvedLine, checkResolvedLines, holdLinesFrom, holdsCapacity, partitionLines, resolveCart, resolveLines, validateAsks, validateCartAsks };
|