@vibedout/commerce 0.0.0-stage → 0.2.4

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,82 @@
1
+ import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
2
+ import { N as LineSnapshot, W as ResolvedLine, _ as Fulfilment, at as TaxBreakdown, ct as TaxResult, r as AppliedDiscount, rt as StockSubject } from "./types-BUyLunnB.mjs";
3
+ //#region src/totals.d.ts
4
+ interface PricedLine {
5
+ ref: string;
6
+ title: string;
7
+ quantity: number;
8
+ unitAmountMinor: number;
9
+ /** `unitAmountMinor * quantity`. */
10
+ lineTotalMinor: number;
11
+ /**
12
+ * What the discounts took off this line (0.2.4), never more than `lineTotalMinor`; the lines' add up to `Totals.discountMinor`. The line's
13
+ * share is what the discount said (`AppliedDiscount.lines`) or its part of the rest, worked once here (`attributeDiscounts`), so the tax, the
14
+ * payment request and the invoice state the same discount on the same line.
15
+ */
16
+ discountMinor: number;
17
+ currency: string;
18
+ taxCode?: string;
19
+ taxIncluded: boolean;
20
+ fulfilment: Fulfilment;
21
+ /** What inventory holds this line under: hold with `holdLinesFrom`, not with the `ref`. */
22
+ stock?: StockSubject;
23
+ /** What the order keeps of the line. */
24
+ snapshot: LineSnapshot;
25
+ }
26
+ /**
27
+ * The shipping rate the buyer chose, as the shipping port quoted it just now (never an amount from the browser). A `ShippingRate` is one, and
28
+ * its `currency` must be the order's.
29
+ */
30
+ interface ChosenShipping {
31
+ id?: string;
32
+ title: string;
33
+ amountMinor: number;
34
+ currency: string;
35
+ }
36
+ interface TotalsInput {
37
+ /** Every line must be `available` and within its `maxQuantity`: use `partitionLines` first to show the others. */
38
+ lines: readonly ResolvedLine[];
39
+ /** From the discount port's `applied`, each in the order's currency. Their sum may not exceed the subtotal. */
40
+ discounts?: readonly AppliedDiscount[];
41
+ shipping?: ChosenShipping;
42
+ /** The tax port's answer, in the order's currency. */
43
+ tax?: TaxResult;
44
+ }
45
+ interface Totals {
46
+ currency: string;
47
+ lines: readonly PricedLine[];
48
+ subtotalMinor: number;
49
+ /** The discounts as given, each with the lines it came off (0.2.4: `attributeDiscounts`; a discount of 0 has none). */
50
+ discounts: readonly AppliedDiscount[];
51
+ discountMinor: number;
52
+ shipping: ChosenShipping | null;
53
+ shippingMinor: number;
54
+ /** What tax adds on top. */
55
+ taxMinor: number;
56
+ /** The tax already inside the prices, for the invoice. It is not in `totalMinor` a second time. */
57
+ taxIncludedMinor: number;
58
+ taxBreakdown: readonly TaxBreakdown[];
59
+ /** `subtotalMinor - discountMinor + shippingMinor + taxMinor`. */
60
+ totalMinor: number;
61
+ }
62
+ type TotalsResult = {
63
+ ok: true;
64
+ totals: Totals;
65
+ } | {
66
+ ok: false;
67
+ problems: readonly CommerceProblem[];
68
+ };
69
+ /**
70
+ * ```ts
71
+ * const result = computeTotals({ lines, discounts: discount.applied, shipping: rate, tax });
72
+ * if (!result.ok) return c.json({ problems: result.problems }, 422);
73
+ * result.totals.totalMinor; // 169900
74
+ * ```
75
+ *
76
+ * Every amount is in the currency of the lines: a shipping rate, a tax or a discount in another one is a `currency_mismatch`, never added.
77
+ */
78
+ declare function computeTotals(input: TotalsInput): TotalsResult;
79
+ /** The totals, or a {@link CommerceError} with the problems, for a caller that treats a refusal as a bug. */
80
+ declare function assertTotals(result: TotalsResult): Totals;
81
+ //#endregion
82
+ export { ChosenShipping, PricedLine, Totals, TotalsInput, TotalsResult, assertTotals, computeTotals };
@@ -0,0 +1,130 @@
1
+ import { CommerceError, problem } from "./errors.mjs";
2
+ import { i as isRecord, r as isDisplayText } from "./internal-p2FS_A4L.mjs";
3
+ import { a as isCurrencyCode, l as sumMinor, o as isMinorUnits, s as lineTotalMinor } from "./money-aFFtGd0E.mjs";
4
+ import { attributeDiscounts } from "./discounts.mjs";
5
+ import { blockedProblem, checkResolvedLine } from "./lines.mjs";
6
+ //#region src/totals.ts
7
+ /**
8
+ * The total of an order, in minor units, from lines a source priced and the answers of the optional ports (discount, shipping, tax). It
9
+ * refuses instead of guessing: a line that cannot be bought, two currencies, an amount that is not a whole number, a discount larger than
10
+ * what it discounts, a tax breakdown that does not add up. It returns the problems and does not throw, so a route answers with them.
11
+ */
12
+ const refuse = (...problems) => ({
13
+ ok: false,
14
+ problems
15
+ });
16
+ /**
17
+ * ```ts
18
+ * const result = computeTotals({ lines, discounts: discount.applied, shipping: rate, tax });
19
+ * if (!result.ok) return c.json({ problems: result.problems }, 422);
20
+ * result.totals.totalMinor; // 169900
21
+ * ```
22
+ *
23
+ * Every amount is in the currency of the lines: a shipping rate, a tax or a discount in another one is a `currency_mismatch`, never added.
24
+ */
25
+ function computeTotals(input) {
26
+ const lines = input.lines;
27
+ if (!Array.isArray(lines) || lines.length === 0) return refuse(problem("empty_order", "lines", "an order needs at least one line"));
28
+ const problems = [];
29
+ for (const [index, line] of lines.entries()) {
30
+ const path = `lines[${index}]`;
31
+ const shape = checkResolvedLine(line, path);
32
+ if (shape.length > 0) problems.push(...shape);
33
+ else {
34
+ const blocked = blockedProblem(line, path);
35
+ if (blocked) problems.push(blocked);
36
+ }
37
+ }
38
+ if (problems.length > 0) return refuse(...problems);
39
+ const currency = lines[0].currency;
40
+ for (const [index, line] of lines.entries()) if (line.currency !== currency) problems.push(problem("currency_mismatch", `lines[${index}].currency`, `${line.currency} cannot be added to ${currency}`));
41
+ if (problems.length > 0) return refuse(...problems);
42
+ const discounts = input.discounts ?? [];
43
+ const sameCurrency = (value, path) => {
44
+ if (!isCurrencyCode(value)) problems.push(problem("invalid_adjustment", path, "an adjustment states its currency, an uppercase ISO 4217 code"));
45
+ else if (value !== currency) problems.push(problem("currency_mismatch", path, `${value} cannot be added to ${currency}`));
46
+ };
47
+ discounts.forEach((discount, index) => {
48
+ if (!isRecord(discount) || !isMinorUnits(discount.amountMinor) || !isDisplayText(discount.title, 200)) problems.push(problem("invalid_adjustment", `discounts[${index}]`, "a discount has a title and a whole amount of minor units, zero or more"));
49
+ else sameCurrency(discount.currency, `discounts[${index}].currency`);
50
+ });
51
+ const given = input.shipping ?? null;
52
+ if (given && (!isRecord(given) || !isMinorUnits(given.amountMinor) || !isDisplayText(given.title, 200))) problems.push(problem("invalid_adjustment", "shipping", "a shipping rate has a title and a whole amount of minor units, zero or more"));
53
+ else if (given) sameCurrency(given.currency, "shipping.currency");
54
+ const tax = input.tax;
55
+ if (tax && (!isRecord(tax) || !isMinorUnits(tax.addedMinor) || !isMinorUnits(tax.includedMinor))) problems.push(problem("invalid_adjustment", "tax", "tax is whole amounts of minor units, zero or more"));
56
+ else if (tax) sameCurrency(tax.currency, "tax.currency");
57
+ tax?.breakdown?.forEach((part, index) => {
58
+ if (!isRecord(part) || !isMinorUnits(part.amountMinor) || typeof part.included !== "boolean") problems.push(problem("invalid_adjustment", `tax.breakdown[${index}]`, "a tax part is a whole amount of minor units, zero or more"));
59
+ });
60
+ if (problems.length > 0) return refuse(...problems);
61
+ const shipping = given ? {
62
+ ...given.id === void 0 ? {} : { id: given.id },
63
+ title: given.title,
64
+ amountMinor: given.amountMinor,
65
+ currency
66
+ } : null;
67
+ try {
68
+ const priced = lines.map((line) => ({
69
+ ref: line.ref,
70
+ title: line.title,
71
+ quantity: line.quantity,
72
+ unitAmountMinor: line.unitAmountMinor,
73
+ lineTotalMinor: lineTotalMinor(line.unitAmountMinor, line.quantity),
74
+ discountMinor: 0,
75
+ currency,
76
+ ...line.taxCode === void 0 ? {} : { taxCode: line.taxCode },
77
+ taxIncluded: line.taxIncluded,
78
+ fulfilment: line.fulfilment,
79
+ ...line.stock === void 0 ? {} : { stock: line.stock },
80
+ snapshot: line.snapshot
81
+ }));
82
+ const subtotalMinor = sumMinor(currency, priced.map((line) => line.lineTotalMinor));
83
+ const discountMinor = sumMinor(currency, discounts.map((discount) => discount.amountMinor));
84
+ if (discountMinor > subtotalMinor) return refuse(problem("discount_exceeds_subtotal", "discounts", `discounts of ${discountMinor} exceed the subtotal of ${subtotalMinor}`));
85
+ const attributed = attributeDiscounts(lines, discounts);
86
+ for (const discount of attributed) for (const part of discount.lines ?? []) {
87
+ const line = priced[part.index];
88
+ line.discountMinor += part.amountMinor;
89
+ }
90
+ const shippingMinor = shipping?.amountMinor ?? 0;
91
+ const taxMinor = tax?.addedMinor ?? 0;
92
+ const taxIncludedMinor = tax?.includedMinor ?? 0;
93
+ const taxBreakdown = tax?.breakdown ?? [];
94
+ if (tax?.breakdown) {
95
+ const part = (included) => sumMinor(currency, tax.breakdown?.filter((item) => item.included === included).map((item) => item.amountMinor) ?? []);
96
+ if (part(false) !== taxMinor || part(true) !== taxIncludedMinor) return refuse(problem("tax_breakdown_mismatch", "tax.breakdown", "the breakdown does not add up to addedMinor and includedMinor"));
97
+ }
98
+ return {
99
+ ok: true,
100
+ totals: {
101
+ currency,
102
+ lines: priced,
103
+ subtotalMinor,
104
+ discounts: attributed,
105
+ discountMinor,
106
+ shipping,
107
+ shippingMinor,
108
+ taxMinor,
109
+ taxIncludedMinor,
110
+ taxBreakdown,
111
+ totalMinor: sumMinor(currency, [
112
+ subtotalMinor - discountMinor,
113
+ shippingMinor,
114
+ taxMinor
115
+ ])
116
+ }
117
+ };
118
+ } catch (error) {
119
+ if (error instanceof CommerceError) return refuse(...error.problems);
120
+ throw error;
121
+ }
122
+ }
123
+ /** The totals, or a {@link CommerceError} with the problems, for a caller that treats a refusal as a bug. */
124
+ function assertTotals(result) {
125
+ if (result.ok) return result.totals;
126
+ const first = result.problems[0];
127
+ throw new CommerceError(first.code, first.message, first.path, result.problems);
128
+ }
129
+ //#endregion
130
+ export { assertTotals, computeTotals };