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