@amboras-dev/money 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +401 -2
- package/dist/ai.cjs +185 -0
- package/dist/ai.d.cts +97 -0
- package/dist/ai.d.ts +97 -0
- package/dist/ai.js +175 -0
- package/dist/allocate-CZRg4TOP.cjs +222 -0
- package/dist/allocate-X7_jAtOj.js +199 -0
- package/dist/amount-BV0O-aN8.cjs +241 -0
- package/dist/amount-BoYBMgK9.js +200 -0
- package/dist/arithmetic-BhNwisjb.js +132 -0
- package/dist/arithmetic-DjT4Kzgt.cjs +233 -0
- package/dist/billing.cjs +184 -0
- package/dist/billing.d.cts +81 -0
- package/dist/billing.d.ts +81 -0
- package/dist/billing.js +175 -0
- package/dist/commerce.cjs +536 -0
- package/dist/commerce.d.cts +293 -0
- package/dist/commerce.d.ts +293 -0
- package/dist/commerce.js +516 -0
- package/dist/format-BdPnijvn.js +99 -0
- package/dist/format-DLhno7eX.cjs +128 -0
- package/dist/fx.cjs +407 -0
- package/dist/fx.d.cts +157 -0
- package/dist/fx.d.ts +157 -0
- package/dist/fx.js +374 -0
- package/dist/index.cjs +352 -0
- package/dist/index.d.cts +300 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +276 -0
- package/dist/input.cjs +163 -0
- package/dist/input.d.cts +203 -0
- package/dist/input.d.ts +203 -0
- package/dist/input.js +153 -0
- package/dist/iso-minor-CqkWDxHS.js +48 -0
- package/dist/iso-minor-wSwJN57T.cjs +65 -0
- package/dist/medusa-4K8N1pUc.d.cts +87 -0
- package/dist/medusa-DBXJXXvF.d.ts +87 -0
- package/dist/medusa-DqJY1qpj.js +116 -0
- package/dist/medusa-lRY9xlsf.cjs +175 -0
- package/dist/money-B-3aFjNJ.d.cts +248 -0
- package/dist/money-B-3aFjNJ.d.ts +248 -0
- package/dist/money-B3wH_Z4S.cjs +3990 -0
- package/dist/money-WhnGiI00.js +3583 -0
- package/dist/number-text-BWu67jUq.cjs +145 -0
- package/dist/number-text-DE-q4jO5.js +134 -0
- package/dist/own-D7544D3k.js +4 -0
- package/dist/own-oP9DXFWv.cjs +9 -0
- package/dist/percent-C0InC2t2.cjs +43 -0
- package/dist/percent-D1VB9xXG.js +20 -0
- package/dist/rate-CVeMii4a.cjs +217 -0
- package/dist/rate-DBPiYgT1.d.ts +63 -0
- package/dist/rate-DicStBue.js +152 -0
- package/dist/rate-tqK8ijG0.d.cts +63 -0
- package/dist/reporting-CG-ai2sM.d.cts +31 -0
- package/dist/reporting-tpGJbqYp.d.ts +31 -0
- package/dist/retail-1mu888J1.js +69 -0
- package/dist/retail-C_BPLvMj.d.cts +31 -0
- package/dist/retail-Cml8YSKg.cjs +86 -0
- package/dist/retail-DRUTTQoJ.d.ts +31 -0
- package/dist/rounding-DHBtNCHG.cjs +79 -0
- package/dist/rounding-g9rvye0k.js +50 -0
- package/dist/sql.cjs +290 -0
- package/dist/sql.d.cts +147 -0
- package/dist/sql.d.ts +147 -0
- package/dist/sql.js +271 -0
- package/dist/testing.cjs +61995 -0
- package/dist/testing.d.cts +125 -0
- package/dist/testing.d.ts +125 -0
- package/dist/testing.js +61986 -0
- package/dist/wire-Bhuut8cW.cjs +2097 -0
- package/dist/wire-CaUN9Vf_.d.ts +309 -0
- package/dist/wire-sW0yzqth.js +1888 -0
- package/dist/wire-xjYXv4go.d.cts +309 -0
- package/dist/wire.cjs +37 -0
- package/dist/wire.d.cts +2 -0
- package/dist/wire.d.ts +2 -0
- package/dist/wire.js +2 -0
- package/package.json +158 -3
package/dist/commerce.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { $ as describeValue, B as decimalOf, E as resolveCurrency, Q as MoneyError, X as mulDiv, Z as ratioToDecimal, dt as roundToScale, et as addDecimal, i as medusaAmountDecimal, pt as toDecimalText, rt as divRound, s as storedDecimal, st as multiplyDecimal, t as boundedMoney, ut as pow10 } from "./money-WhnGiI00.js";
|
|
2
|
+
import { a as unitSize, r as roundStoredDecimal, t as mulDivToUnit } from "./rounding-g9rvye0k.js";
|
|
3
|
+
import { n as allocateUnits, t as allocate } from "./allocate-X7_jAtOj.js";
|
|
4
|
+
import { i as percentRatio, n as hundredPercent, t as basisPointsOf } from "./percent-D1VB9xXG.js";
|
|
5
|
+
import { t as formatMoney } from "./format-BdPnijvn.js";
|
|
6
|
+
import { a as readStoredDecimal, c as storedFieldValue } from "./medusa-DqJY1qpj.js";
|
|
7
|
+
import { n as retailUnits } from "./retail-1mu888J1.js";
|
|
8
|
+
//#region src/commerce/convention.ts
|
|
9
|
+
/**
|
|
10
|
+
* Currencies with storage digits: step 1, `'x100'`. Zero-decimal currencies: step 100 when
|
|
11
|
+
* there is a positive sample and every positive sample is a multiple of 100 (the platform
|
|
12
|
+
* convention), else step 1 `'whole-unit'` (never truncate a whole-unit store to hundreds).
|
|
13
|
+
*/
|
|
14
|
+
function storageConventionOf(currency, samples) {
|
|
15
|
+
const info = resolveCurrency(currency, { allowHistoric: true });
|
|
16
|
+
for (const s of samples) storedDecimal(s);
|
|
17
|
+
if (info.storageDigits !== 0) return {
|
|
18
|
+
step: 1,
|
|
19
|
+
convention: "x100"
|
|
20
|
+
};
|
|
21
|
+
const step = unitSize(info, "currency");
|
|
22
|
+
const positive = samples.filter((s) => s.amount > 0);
|
|
23
|
+
return positive.length > 0 && positive.every((s) => BigInt(s.amount) % step === 0n) ? {
|
|
24
|
+
step: 100,
|
|
25
|
+
convention: "x100"
|
|
26
|
+
} : {
|
|
27
|
+
step: 1,
|
|
28
|
+
convention: "whole-unit"
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/commerce/shared.ts
|
|
33
|
+
/** The one currency of `amounts` (the first defined one decides) and their stored integers. Throws `currency_mismatch`. */
|
|
34
|
+
function oneCurrency(amounts, verb) {
|
|
35
|
+
let info;
|
|
36
|
+
const units = amounts.map((m) => {
|
|
37
|
+
const own = storedDecimal(m).info;
|
|
38
|
+
if (info && own.code !== info.code) throw new MoneyError("currency_mismatch", `Cannot ${verb} ${info.code} and ${own.code}`);
|
|
39
|
+
info = info ?? own;
|
|
40
|
+
return BigInt(m.amount);
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
info,
|
|
44
|
+
units
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A percent (or any plain decimal) as an exact Decimal; `parse` / `amount_invalid` as `decimalOf`. */
|
|
48
|
+
function percentValue(value, field) {
|
|
49
|
+
return decimalOf(value, true, field);
|
|
50
|
+
}
|
|
51
|
+
/** `value` clamped to [0, 100] percent. */
|
|
52
|
+
function clampPercent(value) {
|
|
53
|
+
if (value.units < 0n) return {
|
|
54
|
+
units: 0n,
|
|
55
|
+
scale: 0
|
|
56
|
+
};
|
|
57
|
+
return value.units > hundredPercent(value.scale) ? {
|
|
58
|
+
units: 100n,
|
|
59
|
+
scale: 0
|
|
60
|
+
} : value;
|
|
61
|
+
}
|
|
62
|
+
/** A positive safe integer, else `amount_invalid`. */
|
|
63
|
+
function positiveInteger(value, name) {
|
|
64
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new MoneyError("amount_invalid", `${name} must be a positive integer, got ${describeValue(value)}`);
|
|
65
|
+
return BigInt(value);
|
|
66
|
+
}
|
|
67
|
+
/** A non-negative safe integer, else `amount_invalid`. */
|
|
68
|
+
function nonNegativeInteger(value, name) {
|
|
69
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new MoneyError("amount_invalid", `${name} must be a non-negative integer, got ${describeValue(value)}`);
|
|
70
|
+
return BigInt(value);
|
|
71
|
+
}
|
|
72
|
+
/** `decimals` as a digit count from 0 to 20, else `amount_invalid`. */
|
|
73
|
+
function digitsOf(decimals, name = "decimals") {
|
|
74
|
+
if (typeof decimals !== "number" || !Number.isSafeInteger(decimals) || decimals < 0 || decimals > 20) throw new MoneyError("amount_invalid", `${name} must be an integer from 0 to 20, got ${describeValue(decimals)}`);
|
|
75
|
+
return decimals;
|
|
76
|
+
}
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/commerce/cost.ts
|
|
79
|
+
const scaleOf = (scale) => digitsOf(scale ?? 4, "scale");
|
|
80
|
+
function quantityOf(value, name) {
|
|
81
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new MoneyError("amount_invalid", `${name} must be an integer, got ${describeValue(value)}`);
|
|
82
|
+
return BigInt(value);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Extra costs (freight, duties) spread over receipt lines by quantity, exactly: `extras`
|
|
86
|
+
* (rounded half-up to `scale`, default 4) is allocated with the last line absorbing the
|
|
87
|
+
* remainder; each line's landed unit cost is `max(0, round(unitCost + share / quantity))` at
|
|
88
|
+
* `scale`. Non-positive extras or quantities spread nothing.
|
|
89
|
+
*/
|
|
90
|
+
function landedUnitCosts(lines, extras, options = {}) {
|
|
91
|
+
const scale = scaleOf(options.scale);
|
|
92
|
+
const quantities = lines.map((l, i) => quantityOf(l.quantity, `lines[${i}].quantity`));
|
|
93
|
+
const pot = roundToScale(decimalOf(extras, true, "extras"), scale, "half-up");
|
|
94
|
+
const shares = pot.units > 0n && quantities.every((q) => q >= 0n) && quantities.some((q) => q > 0n) ? allocate(toDecimalText(pot), quantities.map((q) => Number(q)), {
|
|
95
|
+
strategy: "last",
|
|
96
|
+
scale
|
|
97
|
+
}) : lines.map(() => "0");
|
|
98
|
+
return lines.map((line, i) => {
|
|
99
|
+
const unit = decimalOf(line.unitCost, true, `lines[${i}].unitCost`);
|
|
100
|
+
const share = roundToScale(decimalOf(shares[i]), scale, "down").units;
|
|
101
|
+
const q = quantities[i];
|
|
102
|
+
const total = addDecimal(multiplyDecimal(unit, {
|
|
103
|
+
units: q,
|
|
104
|
+
scale: 0
|
|
105
|
+
}), {
|
|
106
|
+
units: share,
|
|
107
|
+
scale
|
|
108
|
+
});
|
|
109
|
+
const landed = q > 0n ? ratioToDecimal(total.units, pow10(total.scale) * q, scale) : roundToScale(unit, scale, "half-up");
|
|
110
|
+
return {
|
|
111
|
+
share: shares[i],
|
|
112
|
+
landedUnitCost: toDecimalText({
|
|
113
|
+
units: landed.units > 0n ? landed.units : 0n,
|
|
114
|
+
scale
|
|
115
|
+
})
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The weighted-average unit cost after a receipt: the receipt's cost (half-up to `scale`,
|
|
121
|
+
* default 4) when there is no average, nothing on hand or nothing received; else
|
|
122
|
+
* `(onHand x average + quantity x unitCost) / (onHand + quantity)` half-up to `scale`.
|
|
123
|
+
*/
|
|
124
|
+
function weightedAverageCost(current, receipt, options = {}) {
|
|
125
|
+
const scale = scaleOf(options.scale);
|
|
126
|
+
const onHand = quantityOf(current.onHand, "onHand");
|
|
127
|
+
const quantity = quantityOf(receipt.quantity, "quantity");
|
|
128
|
+
const cost = decimalOf(receipt.unitCost, true, "unitCost");
|
|
129
|
+
if (current.average === null || onHand <= 0n || quantity <= 0n) return toDecimalText(roundToScale(cost, scale, "half-up"));
|
|
130
|
+
const average = decimalOf(current.average, true, "average");
|
|
131
|
+
const held = multiplyDecimal({
|
|
132
|
+
units: onHand,
|
|
133
|
+
scale: 0
|
|
134
|
+
}, average);
|
|
135
|
+
const sum = addDecimal(held, multiplyDecimal({
|
|
136
|
+
units: quantity,
|
|
137
|
+
scale: 0
|
|
138
|
+
}, cost));
|
|
139
|
+
return toDecimalText(ratioToDecimal(sum.units, pow10(sum.scale) * (onHand + quantity), scale));
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/commerce/discount.ts
|
|
143
|
+
const isMoney = (value) => typeof value === "object" && value !== null;
|
|
144
|
+
/**
|
|
145
|
+
* The discount `spec` takes off `base` (a line or order total), clamped to `[0, base]`; a
|
|
146
|
+
* non-positive base gives 0. A percentage of the total rounds once (`per: 'total'`) or per
|
|
147
|
+
* unit then times the quantity (`per: 'unit'`: 10% of 2 x 18.99 truncated is 2 x 1.89); a
|
|
148
|
+
* fixed amount is Money in base's currency, per unit times the quantity with `per: 'unit'`.
|
|
149
|
+
*/
|
|
150
|
+
function discountAmount(spec, base, options = {}) {
|
|
151
|
+
const { info } = storedDecimal(base);
|
|
152
|
+
const total = BigInt(base.amount);
|
|
153
|
+
const quantity = options.per === "unit" ? positiveInteger(options.quantity, "quantity") : 1n;
|
|
154
|
+
let amount;
|
|
155
|
+
if (spec?.type === "percentage") {
|
|
156
|
+
if (isMoney(spec.value)) throw new MoneyError("amount_invalid", "A percentage discount takes a percent, not Money");
|
|
157
|
+
const pct = clampPercent(percentValue(spec.value, "value"));
|
|
158
|
+
const denominator = hundredPercent(pct.scale) * quantity;
|
|
159
|
+
amount = mulDivToUnit(total, pct.units, denominator, info, options.unit ?? "currency", options.rounding) * quantity;
|
|
160
|
+
} else if (spec?.type === "fixed") {
|
|
161
|
+
if (!isMoney(spec.value)) throw new MoneyError("amount_invalid", `A fixed discount takes Money, got ${describeValue(spec.value)}`);
|
|
162
|
+
amount = oneCurrency([base, spec.value], "discount").units[1] * quantity;
|
|
163
|
+
} else throw new MoneyError("amount_invalid", `Unknown discount type: ${describeValue(spec?.type)}`);
|
|
164
|
+
if (total <= 0n || amount <= 0n) return boundedMoney(0n, info);
|
|
165
|
+
return boundedMoney(amount < total ? amount : total, info);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The reward of a buy X get Y offer on one line: `min(quantity, maxQuantity)` discounted
|
|
169
|
+
* units, each `percent` of the unit price (rounded once on the total to the currency unit) or
|
|
170
|
+
* a fixed amount off each, never more than the unit price (promotion `fixBuyGetFixedRewards`).
|
|
171
|
+
* The units come from the input, never recovered from an amount.
|
|
172
|
+
*/
|
|
173
|
+
function buyGetReward(input) {
|
|
174
|
+
const { info } = storedDecimal(input.unitPrice);
|
|
175
|
+
const price = BigInt(input.unitPrice.amount);
|
|
176
|
+
const quantity = nonNegativeInteger(input.quantity, "quantity");
|
|
177
|
+
const cap = input.maxQuantity === void 0 ? quantity : nonNegativeInteger(input.maxQuantity, "maxQuantity");
|
|
178
|
+
const units = quantity < cap ? quantity : cap;
|
|
179
|
+
let amount;
|
|
180
|
+
if (input.reward?.kind === "percent") {
|
|
181
|
+
const pct = clampPercent(percentValue(input.reward.value, "reward.value"));
|
|
182
|
+
amount = mulDivToUnit(price * units, pct.units, hundredPercent(pct.scale), info, "currency");
|
|
183
|
+
} else if (input.reward?.kind === "fixed-each") {
|
|
184
|
+
const off = oneCurrency([input.unitPrice, input.reward.value], "reward").units[1];
|
|
185
|
+
amount = (off < price ? off : price) * units;
|
|
186
|
+
} else throw new MoneyError("amount_invalid", `Unknown reward kind: ${describeValue(input.reward?.kind)}`);
|
|
187
|
+
return {
|
|
188
|
+
amount: boundedMoney(amount > 0n ? amount : 0n, info),
|
|
189
|
+
discountedUnits: Number(units)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** `'10%'`, `'12.5%'` (up to 2 decimals, half-up) or the fixed amount via `formatMoney` (its currency must be `currency`). */
|
|
193
|
+
function formatDiscount(spec, currency, options = {}) {
|
|
194
|
+
const info = resolveCurrency(currency, { allowHistoric: true });
|
|
195
|
+
if (spec?.type === "percentage" && !isMoney(spec.value)) {
|
|
196
|
+
const pct = percentValue(spec.value, "value");
|
|
197
|
+
return `${toDecimalText(roundToScale(pct, 2, "half-up"))}%`;
|
|
198
|
+
}
|
|
199
|
+
if (spec?.type === "fixed" && isMoney(spec.value)) {
|
|
200
|
+
const own = storedDecimal(spec.value).info;
|
|
201
|
+
if (own.code !== info.code) throw new MoneyError("currency_mismatch", `Discount in ${own.code}, shown in ${info.code}`);
|
|
202
|
+
return formatMoney(spec.value, { locale: options.locale });
|
|
203
|
+
}
|
|
204
|
+
throw new MoneyError("amount_invalid", `Not a discount: ${describeValue(spec)}`);
|
|
205
|
+
}
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/commerce/order-value.ts
|
|
208
|
+
const negate = (a) => ({
|
|
209
|
+
units: -a.units,
|
|
210
|
+
scale: a.scale
|
|
211
|
+
});
|
|
212
|
+
const ZERO = {
|
|
213
|
+
units: 0n,
|
|
214
|
+
scale: 0
|
|
215
|
+
};
|
|
216
|
+
/** Sum of lines `max(0, unit_price x quantity - sum(adjustments))`; null when any part is unreadable. */
|
|
217
|
+
function itemsValue(items) {
|
|
218
|
+
let total = ZERO;
|
|
219
|
+
for (const item of items) {
|
|
220
|
+
const price = medusaAmountDecimal(item?.unit_price);
|
|
221
|
+
const quantity = medusaAmountDecimal(item?.quantity);
|
|
222
|
+
if (!price || !quantity) return null;
|
|
223
|
+
let line = multiplyDecimal(price, quantity);
|
|
224
|
+
for (const adjustment of item.adjustments ?? []) {
|
|
225
|
+
const amount = medusaAmountDecimal(adjustment?.amount);
|
|
226
|
+
if (!amount) return null;
|
|
227
|
+
line = addDecimal(line, negate(amount));
|
|
228
|
+
}
|
|
229
|
+
if (line.units > 0n) total = addDecimal(total, line);
|
|
230
|
+
}
|
|
231
|
+
return total;
|
|
232
|
+
}
|
|
233
|
+
/** An optional total: absent (null/undefined) is 0, unreadable is null. */
|
|
234
|
+
const optional = (raw) => raw === null || raw === void 0 ? ZERO : medusaAmountDecimal(raw);
|
|
235
|
+
/**
|
|
236
|
+
* The value an order reports to ad platforms, and where it came from: the graph total when
|
|
237
|
+
* trusted and positive, else the summary total (a genuine 0 or a negative is kept), else the
|
|
238
|
+
* items netted of adjustments when positive, else null. The result is rounded half-up to a
|
|
239
|
+
* storage unit. Skipping a send when the value is not positive (GA4) stays an app policy.
|
|
240
|
+
*/
|
|
241
|
+
function resolveOrderValue(input) {
|
|
242
|
+
const info = resolveCurrency(input.currency, { allowHistoric: true });
|
|
243
|
+
const toMoney = (d) => roundStoredDecimal(d, info);
|
|
244
|
+
const net = (total) => {
|
|
245
|
+
if (input.basis !== "net-of-tax-shipping") return total;
|
|
246
|
+
const tax = optional(input.taxTotal);
|
|
247
|
+
const shipping = optional(input.shippingTotal);
|
|
248
|
+
return tax && shipping ? addDecimal(total, negate(addDecimal(tax, shipping))) : null;
|
|
249
|
+
};
|
|
250
|
+
const graph = input.trustGraph ? medusaAmountDecimal(input.graphTotal) : null;
|
|
251
|
+
if (graph && graph.units > 0n) {
|
|
252
|
+
const value = net(graph);
|
|
253
|
+
return value ? {
|
|
254
|
+
value: toMoney(value),
|
|
255
|
+
source: "graph"
|
|
256
|
+
} : null;
|
|
257
|
+
}
|
|
258
|
+
const summary = medusaAmountDecimal(input.summaryTotal);
|
|
259
|
+
if (summary) {
|
|
260
|
+
const value = net(summary);
|
|
261
|
+
return value ? {
|
|
262
|
+
value: toMoney(value),
|
|
263
|
+
source: "summary"
|
|
264
|
+
} : null;
|
|
265
|
+
}
|
|
266
|
+
const items = input.items ? itemsValue(input.items) : null;
|
|
267
|
+
return items && items.units > 0n ? {
|
|
268
|
+
value: toMoney(items),
|
|
269
|
+
source: "items"
|
|
270
|
+
} : null;
|
|
271
|
+
}
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/commerce/payable.ts
|
|
274
|
+
/**
|
|
275
|
+
* `false` when the cart can complete with no payment (`total <= 0` and no negative credit-line
|
|
276
|
+
* balance: `credit_line_total >= 0 && total <= 0`), `true` when it must be paid, `undefined`
|
|
277
|
+
* when the total is not a finite number (still loading: fail closed, never "free"). As in the
|
|
278
|
+
* starter, only a number counts as a total, and an absent credit-line total counts as none.
|
|
279
|
+
*/
|
|
280
|
+
function requiresPayment(input, currency) {
|
|
281
|
+
resolveCurrency(currency, { allowHistoric: true });
|
|
282
|
+
const total = input?.total;
|
|
283
|
+
if (typeof total !== "number" || !Number.isFinite(total)) return void 0;
|
|
284
|
+
const credit = input.creditLineTotal;
|
|
285
|
+
return !(total <= 0 && (credit == null || credit >= 0));
|
|
286
|
+
}
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/commerce/pricing.ts
|
|
289
|
+
/**
|
|
290
|
+
* `m` with a markup: exact percent and flat in `order`, one rounding to a storage unit, then
|
|
291
|
+
* clamped to `[floor, ceiling]`, then to `>= 0`. A non-positive `m` (a failed quote) is
|
|
292
|
+
* returned unchanged.
|
|
293
|
+
*/
|
|
294
|
+
function applyMarkup(m, options) {
|
|
295
|
+
const { info } = oneCurrency([m, ...[
|
|
296
|
+
options.flat,
|
|
297
|
+
options.floor,
|
|
298
|
+
options.ceiling
|
|
299
|
+
].filter((x) => x !== void 0)], "mark up");
|
|
300
|
+
const amount = BigInt(m.amount);
|
|
301
|
+
if (amount <= 0n) return m;
|
|
302
|
+
const pct = percentValue(options.percent ?? 0, "percent");
|
|
303
|
+
const scale = hundredPercent(pct.scale);
|
|
304
|
+
const factor = scale + pct.units;
|
|
305
|
+
const flat = options.flat ? BigInt(options.flat.amount) : 0n;
|
|
306
|
+
const rounding = options.rounding ?? "half-up";
|
|
307
|
+
let value = options.order === "flat-then-percent" ? mulDiv(amount + flat, factor, scale, rounding) : divRound(amount * factor + flat * scale, scale, rounding);
|
|
308
|
+
if (options.floor && value < BigInt(options.floor.amount)) value = BigInt(options.floor.amount);
|
|
309
|
+
if (options.ceiling && value > BigInt(options.ceiling.amount)) value = BigInt(options.ceiling.amount);
|
|
310
|
+
return boundedMoney(value > 0n ? value : 0n, info);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The one retail price:
|
|
314
|
+
* - AliExpress (`markup: { percent }`): `cost x (1 + percent / 100)`, exact, then `rounding`
|
|
315
|
+
* once (`'none'`: half-up to the currency unit; `'.99'`, `'.95'`, `'whole'`: up to the ending).
|
|
316
|
+
* - Domains (`markup: { bps, capTiers }`, integer cents): `markup = min(round(cost x bps / 1e4),
|
|
317
|
+
* cap)` with the cap of the first tier whose `below` the cost is under (or that has none);
|
|
318
|
+
* `subtotal = cost + markup + extras`; `fee = ceil(subtotal x feeBps / 1e4) + fixed`; then
|
|
319
|
+
* `max(roundRetail(subtotal + fee, rounding), floor)` (`'up-to-99'` for domains).
|
|
320
|
+
*/
|
|
321
|
+
function retailPrice(input) {
|
|
322
|
+
const tiers = "bps" in input.markup ? input.markup.capTiers ?? [] : [];
|
|
323
|
+
const optional = [
|
|
324
|
+
input.extras,
|
|
325
|
+
input.fee?.fixed,
|
|
326
|
+
input.floor,
|
|
327
|
+
...tiers.flatMap((t) => [t.cap, t.below])
|
|
328
|
+
];
|
|
329
|
+
const { info } = oneCurrency([input.cost, ...optional.filter((x) => x !== void 0)], "price");
|
|
330
|
+
const cost = BigInt(input.cost.amount);
|
|
331
|
+
let p;
|
|
332
|
+
let q;
|
|
333
|
+
if ("percent" in input.markup) {
|
|
334
|
+
const pct = percentValue(input.markup.percent, "markup.percent");
|
|
335
|
+
q = hundredPercent(pct.scale);
|
|
336
|
+
p = cost * (q + pct.units);
|
|
337
|
+
} else if ("bps" in input.markup) {
|
|
338
|
+
const bps = nonNegativeInteger(input.markup.bps, "markup.bps");
|
|
339
|
+
let markup = basisPointsOf(cost, bps, "half-up");
|
|
340
|
+
const tier = tiers.find((t) => t.below === void 0 || cost < BigInt(t.below.amount));
|
|
341
|
+
if (tier && markup > BigInt(tier.cap.amount)) markup = BigInt(tier.cap.amount);
|
|
342
|
+
p = cost + markup;
|
|
343
|
+
q = 1n;
|
|
344
|
+
} else throw new MoneyError("amount_invalid", `markup needs percent or bps, got ${describeValue(input.markup)}`);
|
|
345
|
+
if (input.extras) p += BigInt(input.extras.amount) * q;
|
|
346
|
+
if (input.fee) {
|
|
347
|
+
const feeBps = nonNegativeInteger(input.fee.bps, "fee.bps");
|
|
348
|
+
const fee = basisPointsOf(p, feeBps, "ceil", q) + BigInt(input.fee.fixed.amount);
|
|
349
|
+
p += fee * q;
|
|
350
|
+
}
|
|
351
|
+
let price = retailUnits(p, q, info, input.rounding ?? "none");
|
|
352
|
+
if (input.floor && price < BigInt(input.floor.amount)) price = BigInt(input.floor.amount);
|
|
353
|
+
return boundedMoney(price, info);
|
|
354
|
+
}
|
|
355
|
+
/** `(price - cost) / price` in percent, half-up to `decimals` (default 1); a non-positive price gives 0. */
|
|
356
|
+
function marginPercent(price, cost, options = {}) {
|
|
357
|
+
const [p, c] = oneCurrency([price, cost], "compare").units;
|
|
358
|
+
const decimals = digitsOf(options.decimals ?? 1);
|
|
359
|
+
if (p <= 0n) return 0;
|
|
360
|
+
return Number(toDecimalText(percentRatio(p - c, p, decimals)));
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The change from `from` to `to` in percent, half-up to `decimals` (default 1): `'change'`
|
|
364
|
+
* (default) is `(to - from) / from`, `'off'` the discount `(from - to) / from` (promotion
|
|
365
|
+
* percent off, subscription `discount_percentage`). A non-positive `from` gives null.
|
|
366
|
+
*/
|
|
367
|
+
function percentChange(from, to, options = {}) {
|
|
368
|
+
const [f, t] = oneCurrency([from, to], "compare").units;
|
|
369
|
+
const decimals = digitsOf(options.decimals ?? 1);
|
|
370
|
+
if (f <= 0n) return null;
|
|
371
|
+
const delta = options.as === "off" ? f - t : t - f;
|
|
372
|
+
return Number(toDecimalText(percentRatio(delta, f, decimals)));
|
|
373
|
+
}
|
|
374
|
+
/** What is kept of a charge after a card fee: `m - round(m x bps / 1e4) - fixed`. */
|
|
375
|
+
function netOfFee(m, fee) {
|
|
376
|
+
const { info, units } = oneCurrency([m, fee.fixed], "net");
|
|
377
|
+
const bps = nonNegativeInteger(fee.bps, "fee.bps");
|
|
378
|
+
const [amount, fixed] = units;
|
|
379
|
+
return boundedMoney(amount - basisPointsOf(amount, bps, "half-up") - fixed, info);
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/commerce/tax.ts
|
|
383
|
+
/**
|
|
384
|
+
* The tax rate in percent that `tax` on `net` implies, snapped half-up to a multiple of `snap`
|
|
385
|
+
* (default 0.5, every real EU VAT rate; 0 = no snap, 10 decimals). `net <= 0` gives 0.
|
|
386
|
+
* sevDesk `inferTaxRatePercent`, exact.
|
|
387
|
+
*/
|
|
388
|
+
function inferTaxRatePercent(net, tax, options = {}) {
|
|
389
|
+
const { units } = oneCurrency([net, tax], "infer a tax rate from");
|
|
390
|
+
const [n, t] = units;
|
|
391
|
+
const snap = percentValue(options.snap ?? .5, "snap");
|
|
392
|
+
if (snap.units < 0n) throw new MoneyError("amount_invalid", `snap must not be negative, got ${describeValue(options.snap)}`);
|
|
393
|
+
if (n <= 0n) return 0;
|
|
394
|
+
if (snap.units === 0n) return Number(toDecimalText(percentRatio(t, n, 10)));
|
|
395
|
+
const steps = percentRatio(t, n * snap.units, snap.scale).units;
|
|
396
|
+
return Number(toDecimalText({
|
|
397
|
+
units: steps * snap.units,
|
|
398
|
+
scale: snap.scale
|
|
399
|
+
}));
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The rate in percent behind `tax` on `base` (Shopify archive `taxRateFor`): exclusive
|
|
403
|
+
* `100 tax / base`, inclusive `100 tax / (base - tax)`, half-up to `decimals` (default 10),
|
|
404
|
+
* trailing zeros dropped. Null when tax or base is not positive, or the denominator is not.
|
|
405
|
+
*/
|
|
406
|
+
function solveTaxRate(input) {
|
|
407
|
+
const { units } = oneCurrency([input.tax, input.base], "solve a tax rate from");
|
|
408
|
+
const [tax, base] = units;
|
|
409
|
+
const decimals = digitsOf(input.decimals ?? 10);
|
|
410
|
+
if (tax <= 0n || base <= 0n) return null;
|
|
411
|
+
const denominator = input.inclusive ? base - tax : base;
|
|
412
|
+
if (denominator <= 0n) return null;
|
|
413
|
+
return toDecimalText(percentRatio(tax, denominator, decimals));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Whether prices include tax (Shopify archive `inferTaxesIncluded`): `explicit` wins; a null
|
|
417
|
+
* total or a non-positive tax is exclusive; else inclusive iff
|
|
418
|
+
* `|sub + ship - total| < |sub + ship + tax - total|`.
|
|
419
|
+
*/
|
|
420
|
+
function inferTaxesIncluded(input) {
|
|
421
|
+
if (typeof input.explicit === "boolean") return input.explicit;
|
|
422
|
+
const { units } = oneCurrency([
|
|
423
|
+
input.subtotal,
|
|
424
|
+
input.shipping,
|
|
425
|
+
input.tax,
|
|
426
|
+
...input.total ? [input.total] : []
|
|
427
|
+
], "compare");
|
|
428
|
+
const [sub, ship, tax] = units;
|
|
429
|
+
if (!input.total || tax <= 0n) return false;
|
|
430
|
+
const total = units[3];
|
|
431
|
+
const abs = (x) => x < 0n ? -x : x;
|
|
432
|
+
return abs(sub + ship - total) < abs(sub + ship + tax - total);
|
|
433
|
+
}
|
|
434
|
+
/** `gross x 100 / (100 + rate)` rounded once to a storage unit. */
|
|
435
|
+
function netOf(gross, ratePercent, rounding) {
|
|
436
|
+
const rate = percentValue(ratePercent, "ratePercent");
|
|
437
|
+
const whole = hundredPercent(rate.scale);
|
|
438
|
+
const denominator = whole + rate.units;
|
|
439
|
+
if (denominator <= 0n) throw new MoneyError("amount_invalid", `A tax rate must be above -100%, got ${describeValue(ratePercent)}`);
|
|
440
|
+
return mulDiv(BigInt(gross.amount), whole, denominator, rounding);
|
|
441
|
+
}
|
|
442
|
+
/** `gross` split into `net = round(gross x 100 / (100 + rate))` and `tax = gross - net` (net + tax == gross). */
|
|
443
|
+
function splitGross(gross, ratePercent, options = {}) {
|
|
444
|
+
const { info } = storedDecimal(gross);
|
|
445
|
+
const net = netOf(gross, ratePercent, options.rounding ?? "half-up");
|
|
446
|
+
return {
|
|
447
|
+
net: boundedMoney(net, info),
|
|
448
|
+
tax: boundedMoney(BigInt(gross.amount) - net, info)
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/** A unit price without its tax when prices include tax (`round(unit x 100 / (100 + rate))`), else `unit`. */
|
|
452
|
+
function unitNetOfTax(unit, options) {
|
|
453
|
+
const { info } = storedDecimal(unit);
|
|
454
|
+
if (!options.taxInclusive) return unit;
|
|
455
|
+
return boundedMoney(netOf(unit, options.ratePercent, "half-up"), info);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* `refund` spread over positions (each a net and its tax): capped at the gross total, split by
|
|
459
|
+
* gross with the largest remainder, then each gross share split into net and tax in the
|
|
460
|
+
* position's proportion (largest remainder). Per row `net + tax == gross`, and the grosses
|
|
461
|
+
* sum to the (capped) refund. Negative amounts throw `amount_invalid`.
|
|
462
|
+
*/
|
|
463
|
+
function allocateWithTax(refund, positions) {
|
|
464
|
+
const { info, units } = oneCurrency([refund, ...positions.flatMap((p) => [p.net, p.tax])], "allocate");
|
|
465
|
+
const [total, ...parts] = units;
|
|
466
|
+
if (total < 0n || parts.some((p) => p < 0n)) throw new MoneyError("amount_invalid", "A refund and its positions must not be negative");
|
|
467
|
+
const grosses = positions.map((_, i) => parts[2 * i] + parts[2 * i + 1]);
|
|
468
|
+
const grossSum = grosses.reduce((a, b) => a + b, 0n);
|
|
469
|
+
return allocateUnits(total < grossSum ? total : grossSum, grosses).map((share, i) => {
|
|
470
|
+
const [net, tax] = allocateUnits(share, [parts[2 * i], parts[2 * i + 1]]);
|
|
471
|
+
return {
|
|
472
|
+
net: boundedMoney(net, info),
|
|
473
|
+
tax: boundedMoney(tax, info),
|
|
474
|
+
gross: boundedMoney(share, info)
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const INCLUSIVE_KEYS = [
|
|
479
|
+
"original_item_total",
|
|
480
|
+
"discount_total",
|
|
481
|
+
"original_shipping_total",
|
|
482
|
+
"tax_total",
|
|
483
|
+
"total"
|
|
484
|
+
];
|
|
485
|
+
const EXCLUSIVE_KEYS = [
|
|
486
|
+
"item_subtotal",
|
|
487
|
+
"discount_subtotal",
|
|
488
|
+
"shipping_subtotal",
|
|
489
|
+
"tax_total",
|
|
490
|
+
"total"
|
|
491
|
+
];
|
|
492
|
+
/**
|
|
493
|
+
* The totals a customer-facing summary shows, one selection for every email: tax-inclusive
|
|
494
|
+
* stores show the gross item and shipping totals with the tax "included", exclusive stores the
|
|
495
|
+
* net subtotals with the tax "added". Each field reads `raw_<key>` first, like
|
|
496
|
+
* `readStoredField`; Medusa's fractional totals round half-up to a storage unit; a missing or
|
|
497
|
+
* unreadable field is null, never 0.
|
|
498
|
+
*/
|
|
499
|
+
function displayTotals(totals, currency, options) {
|
|
500
|
+
const info = resolveCurrency(currency, { allowHistoric: true });
|
|
501
|
+
const read = (key) => {
|
|
502
|
+
const exact = readStoredDecimal(storedFieldValue(totals, key), { mode: "lenient" });
|
|
503
|
+
return exact === null ? null : roundStoredDecimal(decimalOf(exact), info);
|
|
504
|
+
};
|
|
505
|
+
const [subtotal, discount, shipping, tax, total] = (options.taxInclusive ? INCLUSIVE_KEYS : EXCLUSIVE_KEYS).map(read);
|
|
506
|
+
return {
|
|
507
|
+
subtotal,
|
|
508
|
+
discount,
|
|
509
|
+
shipping,
|
|
510
|
+
tax,
|
|
511
|
+
total,
|
|
512
|
+
taxIncluded: options.taxInclusive
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
//#endregion
|
|
516
|
+
export { allocateWithTax, applyMarkup, buyGetReward, discountAmount, displayTotals, formatDiscount, inferTaxRatePercent, inferTaxesIncluded, landedUnitCosts, marginPercent, netOfFee, percentChange, requiresPayment, resolveOrderValue, retailPrice, solveTaxRate, splitGross, storageConventionOf, unitNetOfTax, weightedAverageCost };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { B as decimalOf, E as resolveCurrency, M as resolveLocale, Q as MoneyError, at as fractionDigitsOf, dt as roundToScale, it as formatDecimal, j as numberFormat, nt as decimalFromNumber, s as storedDecimal, tt as compareDecimal } from "./money-WhnGiI00.js";
|
|
2
|
+
import { i as roundToUnit } from "./rounding-g9rvye0k.js";
|
|
3
|
+
//#region src/format.ts
|
|
4
|
+
function intlDisplay(options) {
|
|
5
|
+
const display = options.currencyDisplay ?? "symbol";
|
|
6
|
+
return display === "symbol+code" ? "symbol" : display;
|
|
7
|
+
}
|
|
8
|
+
function finish(text, code, options) {
|
|
9
|
+
const withCode = options.currencyDisplay === "symbol+code" && !text.includes(code) ? `${text} ${code}` : text;
|
|
10
|
+
return options.minusSign === "−" ? withCode.replace("-", "−") : withCode;
|
|
11
|
+
}
|
|
12
|
+
const asIntlValue = (text) => text;
|
|
13
|
+
/**
|
|
14
|
+
* Money for people: `'$1,234.50'`, `'¥1,200'`, `'KWD 1.005'`, `'-€5.00'`. Locale, currency
|
|
15
|
+
* display, digits and sign are explicit options; the active app locale is the caller's to
|
|
16
|
+
* pass. If Intl throws, `'<CODE> <decimal>'`.
|
|
17
|
+
*/
|
|
18
|
+
function formatMoney(m, options = {}) {
|
|
19
|
+
const { info, value } = storedDecimal(m);
|
|
20
|
+
const whole = fractionDigitsOf(value) === 0;
|
|
21
|
+
const digits = options.digits === "widen" ? Math.max(info.displayDigits, fractionDigitsOf(value)) : options.digits === "trim-zero" && whole ? 0 : info.displayDigits;
|
|
22
|
+
const text = formatDecimal(roundToScale(value, digits, "half-up"));
|
|
23
|
+
try {
|
|
24
|
+
return finish(numberFormat(resolveLocale(options.locale), {
|
|
25
|
+
style: "currency",
|
|
26
|
+
currency: info.code,
|
|
27
|
+
currencyDisplay: intlDisplay(options),
|
|
28
|
+
minimumFractionDigits: digits,
|
|
29
|
+
maximumFractionDigits: digits,
|
|
30
|
+
signDisplay: options.signDisplay ?? "auto"
|
|
31
|
+
}).format(asIntlValue(text)), info.code, options);
|
|
32
|
+
} catch {
|
|
33
|
+
return finish(`${info.code} ${text}`, info.code, options);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A stored amount that may be fractional (a Medusa total, 107.8175; a percentage promotion) for
|
|
38
|
+
* people: rounded half-up to a whole storage unit (`roundToUnit`), then `formatMoney` with `options`.
|
|
39
|
+
* A number must be finite; text is plain decimal; an unknown currency throws `currency_invalid`.
|
|
40
|
+
*/
|
|
41
|
+
function formatStored(amount, currency, options = {}) {
|
|
42
|
+
return formatMoney(roundToUnit(amount, currency), options);
|
|
43
|
+
}
|
|
44
|
+
function compact(currency, major, options, significant) {
|
|
45
|
+
return numberFormat(resolveLocale(options.locale), {
|
|
46
|
+
style: "currency",
|
|
47
|
+
currency,
|
|
48
|
+
currencyDisplay: intlDisplay(options),
|
|
49
|
+
notation: "compact",
|
|
50
|
+
compactDisplay: "short",
|
|
51
|
+
maximumSignificantDigits: significant,
|
|
52
|
+
signDisplay: options.signDisplay ?? "auto"
|
|
53
|
+
}).format(asIntlValue(major));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Like `formatMoney` below `threshold` major units (default 10000, by magnitude); at or above
|
|
57
|
+
* it, compact short notation with `maximumSignificantDigits` (default 3): `'$12.3K'`.
|
|
58
|
+
*/
|
|
59
|
+
function formatMoneyCompact(m, options = {}) {
|
|
60
|
+
const { info, value } = storedDecimal(m);
|
|
61
|
+
const threshold = decimalOf(options.threshold ?? 1e4, false, "threshold");
|
|
62
|
+
const magnitude = {
|
|
63
|
+
units: value.units < 0n ? -value.units : value.units,
|
|
64
|
+
scale: value.scale
|
|
65
|
+
};
|
|
66
|
+
if (compareDecimal(magnitude, threshold) < 0) return formatMoney(m, options);
|
|
67
|
+
try {
|
|
68
|
+
return finish(compact(info.code, formatDecimal(value), options, options.maximumSignificantDigits ?? 3), info.code, options);
|
|
69
|
+
} catch {
|
|
70
|
+
return formatMoney(m, options);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** `'$10.00 – $20.00'` (en dash between spaces), `'$10.00+'` when `high` is null; zero amounts show `zeroLabel` when given. */
|
|
74
|
+
function formatMoneyRange(low, high, options = {}) {
|
|
75
|
+
const lowCode = storedDecimal(low).info.code;
|
|
76
|
+
const show = (m) => m.amount === 0 && options.zeroLabel !== void 0 ? options.zeroLabel : formatMoney(m, options);
|
|
77
|
+
if (high === null) return `${show(low)}+`;
|
|
78
|
+
const highCode = storedDecimal(high).info.code;
|
|
79
|
+
if (highCode !== lowCode) throw new MoneyError("currency_mismatch", `Cannot format a range from ${lowCode} to ${highCode}`);
|
|
80
|
+
return `${show(low)} – ${show(high)}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A chart tick formatter for values that already are major units: always compact, with the
|
|
84
|
+
* same rounding as `formatMoneyCompact` (`1234567` gives `'$1.23M'`).
|
|
85
|
+
*/
|
|
86
|
+
function moneyAxisFormatter(currency, options = {}) {
|
|
87
|
+
const { code } = resolveCurrency(currency, { allowHistoric: true });
|
|
88
|
+
return (major) => {
|
|
89
|
+
if (!Number.isFinite(major)) return `${code} ${major}`;
|
|
90
|
+
const text = formatDecimal(decimalFromNumber(major));
|
|
91
|
+
try {
|
|
92
|
+
return finish(compact(code, text, options, 3), code, options);
|
|
93
|
+
} catch {
|
|
94
|
+
return `${code} ${text}`;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
export { moneyAxisFormatter as a, formatStored as i, formatMoneyCompact as n, formatMoneyRange as r, formatMoney as t };
|