@oliviermtlbali/ust-calc 1.0.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.
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Raw item as it comes from various projects.
3
+ * Fields are optional because different projects use different naming conventions:
4
+ * - VAT rate: `tva` (confhub, punsch-taxi cart) or `ust` (invoicepunschtaxi, punsch-taxi invoice)
5
+ * - Quantity: `amount` (confhub, punsch-taxi cart), `quantity` (invoicepunschtaxi), or `value` (createRechnung)
6
+ */
7
+ interface RawItem {
8
+ price: number;
9
+ tva?: number | null;
10
+ ust?: number | null;
11
+ amount?: number;
12
+ quantity?: number;
13
+ value?: number | string;
14
+ discount?: number | null;
15
+ priceNetto?: number;
16
+ inSumme?: boolean;
17
+ name?: string;
18
+ }
19
+ /**
20
+ * Normalized item with consistent field names.
21
+ */
22
+ interface TaxableItem {
23
+ price: number;
24
+ vatRate: number;
25
+ quantity: number;
26
+ discount: number;
27
+ priceNetto?: number;
28
+ }
29
+ interface VatBreakdown {
30
+ /** Netto subtotal for 10% items */
31
+ netto10: number;
32
+ /** Netto subtotal for 20% items (including shipping netto) */
33
+ netto20: number;
34
+ /** 10% tax amount */
35
+ vat10: number;
36
+ /** 20% tax amount */
37
+ vat20: number;
38
+ /** netto10 + netto20 */
39
+ totalNetto: number;
40
+ /** vat10 + vat20 */
41
+ totalVat: number;
42
+ /** totalNetto + totalVat */
43
+ totalBrutto: number;
44
+ /** Sum of 0% VAT items (Trinkgeld) */
45
+ tipTotal: number;
46
+ /** totalBrutto + tipTotal */
47
+ grandTotal: number;
48
+ }
49
+ interface NettoOptions {
50
+ /** Decimal places for rounding (default: 2) */
51
+ precision?: number;
52
+ /** Apply discount on 'netto' (default) or 'brutto' before converting */
53
+ discountOn?: "netto" | "brutto";
54
+ }
55
+ interface BreakdownOptions {
56
+ /** Delivery fee in netto (20% VAT applied). Explicitly netto to prevent confusion. */
57
+ shippingCostNetto?: number;
58
+ /** Decimal places for intermediate rounding (default: 2) */
59
+ precision?: number;
60
+ }
61
+
62
+ /**
63
+ * Auto-detects field names across projects and produces a consistent TaxableItem.
64
+ *
65
+ * VAT rate: uses `ust ?? tva ?? defaultVatRate`.
66
+ * - Uses ?? (not ||) so that tva: 0 is correctly treated as tax-exempt (fixes BUG 5).
67
+ *
68
+ * Quantity: uses `quantity ?? amount ?? coerced(value) ?? 1`.
69
+ * - `value` can be a string in createRechnung data, so it's coerced to number.
70
+ *
71
+ * Discount: uses `discount ?? 0`, clamped to [0, 100].
72
+ */
73
+ declare function normalizeItem(raw: RawItem, defaultVatRate?: number): TaxableItem;
74
+
75
+ /**
76
+ * Compute the netto (ex-VAT) price of a single item.
77
+ *
78
+ * Algorithm A (netto-first):
79
+ * netto = brutto / (1 + vatRate/100), rounded to `precision` DP
80
+ * If discount: netto = netto × (1 − discount/100), rounded
81
+ *
82
+ * When `discountOn: 'brutto'`:
83
+ * discountedBrutto = brutto × (1 − discount/100)
84
+ * netto = discountedBrutto / (1 + vatRate/100), rounded
85
+ *
86
+ * @returns Netto price as a JS number (per single unit, NOT multiplied by quantity).
87
+ */
88
+ declare function getNettoPrice(item: RawItem, options?: NettoOptions): number;
89
+ /**
90
+ * Compute brutto from a netto value and VAT rate.
91
+ * brutto = netto × (1 + vatRate/100)
92
+ */
93
+ declare function getBruttoFromNetto(netto: number, vatRate?: number, precision?: number): number;
94
+ /**
95
+ * Compute the (possibly discounted) brutto price of an item.
96
+ * If discount > 0: brutto × (1 − discount/100), rounded
97
+ * Else: brutto as-is
98
+ */
99
+ declare function getBruttoPrice(item: RawItem, precision?: number): number;
100
+
101
+ /**
102
+ * Compute a full Austrian VAT breakdown from a list of items.
103
+ *
104
+ * Uses Algorithm A (netto-first):
105
+ * 1. For each item, compute netto per unit via getNettoPrice (rounded to `precision` DP).
106
+ * 2. Multiply by quantity to get line total netto.
107
+ * 3. Group into three buckets: 20%, 10%, 0% (tips/tax-exempt).
108
+ * 4. Sum each bucket. Add shipping (netto) to the 20% bucket.
109
+ * 5. Compute VAT per bucket: nettoTotal × rate.
110
+ * 6. Round final totals to 2 DP.
111
+ *
112
+ * This replaces all the ad-hoc ust20.reduce / ust10.reduce patterns across projects.
113
+ *
114
+ * Items should already be filtered (e.g. `inSumme === true`) before passing here.
115
+ */
116
+ declare function calculateVatBreakdown(items: RawItem[], options?: BreakdownOptions): VatBreakdown;
117
+
118
+ export { type BreakdownOptions, type NettoOptions, type RawItem, type TaxableItem, type VatBreakdown, calculateVatBreakdown, getBruttoFromNetto, getBruttoPrice, getNettoPrice, normalizeItem };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Raw item as it comes from various projects.
3
+ * Fields are optional because different projects use different naming conventions:
4
+ * - VAT rate: `tva` (confhub, punsch-taxi cart) or `ust` (invoicepunschtaxi, punsch-taxi invoice)
5
+ * - Quantity: `amount` (confhub, punsch-taxi cart), `quantity` (invoicepunschtaxi), or `value` (createRechnung)
6
+ */
7
+ interface RawItem {
8
+ price: number;
9
+ tva?: number | null;
10
+ ust?: number | null;
11
+ amount?: number;
12
+ quantity?: number;
13
+ value?: number | string;
14
+ discount?: number | null;
15
+ priceNetto?: number;
16
+ inSumme?: boolean;
17
+ name?: string;
18
+ }
19
+ /**
20
+ * Normalized item with consistent field names.
21
+ */
22
+ interface TaxableItem {
23
+ price: number;
24
+ vatRate: number;
25
+ quantity: number;
26
+ discount: number;
27
+ priceNetto?: number;
28
+ }
29
+ interface VatBreakdown {
30
+ /** Netto subtotal for 10% items */
31
+ netto10: number;
32
+ /** Netto subtotal for 20% items (including shipping netto) */
33
+ netto20: number;
34
+ /** 10% tax amount */
35
+ vat10: number;
36
+ /** 20% tax amount */
37
+ vat20: number;
38
+ /** netto10 + netto20 */
39
+ totalNetto: number;
40
+ /** vat10 + vat20 */
41
+ totalVat: number;
42
+ /** totalNetto + totalVat */
43
+ totalBrutto: number;
44
+ /** Sum of 0% VAT items (Trinkgeld) */
45
+ tipTotal: number;
46
+ /** totalBrutto + tipTotal */
47
+ grandTotal: number;
48
+ }
49
+ interface NettoOptions {
50
+ /** Decimal places for rounding (default: 2) */
51
+ precision?: number;
52
+ /** Apply discount on 'netto' (default) or 'brutto' before converting */
53
+ discountOn?: "netto" | "brutto";
54
+ }
55
+ interface BreakdownOptions {
56
+ /** Delivery fee in netto (20% VAT applied). Explicitly netto to prevent confusion. */
57
+ shippingCostNetto?: number;
58
+ /** Decimal places for intermediate rounding (default: 2) */
59
+ precision?: number;
60
+ }
61
+
62
+ /**
63
+ * Auto-detects field names across projects and produces a consistent TaxableItem.
64
+ *
65
+ * VAT rate: uses `ust ?? tva ?? defaultVatRate`.
66
+ * - Uses ?? (not ||) so that tva: 0 is correctly treated as tax-exempt (fixes BUG 5).
67
+ *
68
+ * Quantity: uses `quantity ?? amount ?? coerced(value) ?? 1`.
69
+ * - `value` can be a string in createRechnung data, so it's coerced to number.
70
+ *
71
+ * Discount: uses `discount ?? 0`, clamped to [0, 100].
72
+ */
73
+ declare function normalizeItem(raw: RawItem, defaultVatRate?: number): TaxableItem;
74
+
75
+ /**
76
+ * Compute the netto (ex-VAT) price of a single item.
77
+ *
78
+ * Algorithm A (netto-first):
79
+ * netto = brutto / (1 + vatRate/100), rounded to `precision` DP
80
+ * If discount: netto = netto × (1 − discount/100), rounded
81
+ *
82
+ * When `discountOn: 'brutto'`:
83
+ * discountedBrutto = brutto × (1 − discount/100)
84
+ * netto = discountedBrutto / (1 + vatRate/100), rounded
85
+ *
86
+ * @returns Netto price as a JS number (per single unit, NOT multiplied by quantity).
87
+ */
88
+ declare function getNettoPrice(item: RawItem, options?: NettoOptions): number;
89
+ /**
90
+ * Compute brutto from a netto value and VAT rate.
91
+ * brutto = netto × (1 + vatRate/100)
92
+ */
93
+ declare function getBruttoFromNetto(netto: number, vatRate?: number, precision?: number): number;
94
+ /**
95
+ * Compute the (possibly discounted) brutto price of an item.
96
+ * If discount > 0: brutto × (1 − discount/100), rounded
97
+ * Else: brutto as-is
98
+ */
99
+ declare function getBruttoPrice(item: RawItem, precision?: number): number;
100
+
101
+ /**
102
+ * Compute a full Austrian VAT breakdown from a list of items.
103
+ *
104
+ * Uses Algorithm A (netto-first):
105
+ * 1. For each item, compute netto per unit via getNettoPrice (rounded to `precision` DP).
106
+ * 2. Multiply by quantity to get line total netto.
107
+ * 3. Group into three buckets: 20%, 10%, 0% (tips/tax-exempt).
108
+ * 4. Sum each bucket. Add shipping (netto) to the 20% bucket.
109
+ * 5. Compute VAT per bucket: nettoTotal × rate.
110
+ * 6. Round final totals to 2 DP.
111
+ *
112
+ * This replaces all the ad-hoc ust20.reduce / ust10.reduce patterns across projects.
113
+ *
114
+ * Items should already be filtered (e.g. `inSumme === true`) before passing here.
115
+ */
116
+ declare function calculateVatBreakdown(items: RawItem[], options?: BreakdownOptions): VatBreakdown;
117
+
118
+ export { type BreakdownOptions, type NettoOptions, type RawItem, type TaxableItem, type VatBreakdown, calculateVatBreakdown, getBruttoFromNetto, getBruttoPrice, getNettoPrice, normalizeItem };
package/dist/index.js ADDED
@@ -0,0 +1,167 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ calculateVatBreakdown: () => calculateVatBreakdown,
34
+ getBruttoFromNetto: () => getBruttoFromNetto,
35
+ getBruttoPrice: () => getBruttoPrice,
36
+ getNettoPrice: () => getNettoPrice,
37
+ normalizeItem: () => normalizeItem
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/normalize.ts
42
+ function normalizeItem(raw, defaultVatRate = 20) {
43
+ const vatRate = raw.ust ?? raw.tva ?? defaultVatRate;
44
+ let quantity;
45
+ if (raw.quantity != null) {
46
+ quantity = Number(raw.quantity);
47
+ } else if (raw.amount != null) {
48
+ quantity = Number(raw.amount);
49
+ } else if (raw.value != null) {
50
+ quantity = Number(raw.value);
51
+ } else {
52
+ quantity = 1;
53
+ }
54
+ const rawDiscount = raw.discount ?? 0;
55
+ const discount = Math.max(0, Math.min(100, rawDiscount));
56
+ return {
57
+ price: raw.price,
58
+ vatRate,
59
+ quantity,
60
+ discount,
61
+ ...raw.priceNetto != null ? { priceNetto: raw.priceNetto } : {}
62
+ };
63
+ }
64
+
65
+ // src/core.ts
66
+ var import_decimal = __toESM(require("decimal.js"));
67
+ import_decimal.default.set({ rounding: import_decimal.default.ROUND_HALF_UP });
68
+ function getNettoPrice(item, options = {}) {
69
+ const { precision = 2, discountOn = "netto" } = options;
70
+ const normalized = normalizeItem(item);
71
+ const { price, vatRate, discount } = normalized;
72
+ const d = (v) => new import_decimal.default(v);
73
+ if (vatRate === 0) {
74
+ if (discount > 0) {
75
+ return d(price).times(d(1).minus(d(discount).div(100))).toDecimalPlaces(precision, import_decimal.default.ROUND_HALF_UP).toNumber();
76
+ }
77
+ return d(price).toDecimalPlaces(precision, import_decimal.default.ROUND_HALF_UP).toNumber();
78
+ }
79
+ const divisor = d(1).plus(d(vatRate).div(100));
80
+ if (discountOn === "brutto" && discount > 0) {
81
+ const discountedBrutto = d(price).times(
82
+ d(1).minus(d(discount).div(100))
83
+ );
84
+ return discountedBrutto.div(divisor).toDecimalPlaces(precision, import_decimal.default.ROUND_HALF_UP).toNumber();
85
+ }
86
+ const netto = d(price).div(divisor).toDecimalPlaces(precision, import_decimal.default.ROUND_HALF_UP);
87
+ if (discount > 0) {
88
+ const discountedNetto = netto.times(d(1).minus(d(discount).div(100)));
89
+ const discountAmount = netto.minus(discountedNetto).toDecimalPlaces(
90
+ precision,
91
+ import_decimal.default.ROUND_HALF_UP
92
+ );
93
+ return netto.minus(discountAmount).toNumber();
94
+ }
95
+ return netto.toNumber();
96
+ }
97
+ function getBruttoFromNetto(netto, vatRate = 20, precision = 2) {
98
+ return new import_decimal.default(netto).times(new import_decimal.default(1).plus(new import_decimal.default(vatRate).div(100))).toDecimalPlaces(precision, import_decimal.default.ROUND_HALF_UP).toNumber();
99
+ }
100
+ function getBruttoPrice(item, precision = 2) {
101
+ const normalized = normalizeItem(item);
102
+ const { price, discount } = normalized;
103
+ if (discount > 0) {
104
+ return new import_decimal.default(price).times(new import_decimal.default(1).minus(new import_decimal.default(discount).div(100))).toDecimalPlaces(precision, import_decimal.default.ROUND_HALF_UP).toNumber();
105
+ }
106
+ return price;
107
+ }
108
+
109
+ // src/breakdown.ts
110
+ var import_decimal2 = __toESM(require("decimal.js"));
111
+ function calculateVatBreakdown(items, options = {}) {
112
+ const { shippingCostNetto = 0, precision = 2 } = options;
113
+ const d = (v) => new import_decimal2.default(v);
114
+ let netto10 = d(0);
115
+ let netto20 = d(0);
116
+ let tipTotal = d(0);
117
+ for (const item of items) {
118
+ const normalized = normalizeItem(item);
119
+ const { vatRate, quantity } = normalized;
120
+ if (vatRate === 0) {
121
+ const nettoPerUnit2 = getNettoPrice(item, { precision });
122
+ tipTotal = tipTotal.plus(d(nettoPerUnit2).times(quantity));
123
+ continue;
124
+ }
125
+ const nettoPerUnit = getNettoPrice(item, { precision });
126
+ const lineNetto = d(nettoPerUnit).times(quantity);
127
+ if (vatRate === 10) {
128
+ netto10 = netto10.plus(lineNetto);
129
+ } else {
130
+ netto20 = netto20.plus(lineNetto);
131
+ }
132
+ }
133
+ if (shippingCostNetto > 0) {
134
+ netto20 = netto20.plus(d(shippingCostNetto));
135
+ }
136
+ const vat10 = netto10.times(d(0.1));
137
+ const vat20 = netto20.times(d(0.2));
138
+ const finalNetto10 = netto10.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP);
139
+ const finalNetto20 = netto20.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP);
140
+ const finalVat10 = vat10.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP);
141
+ const finalVat20 = vat20.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP);
142
+ const finalTip = tipTotal.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP);
143
+ const totalNetto = finalNetto10.plus(finalNetto20);
144
+ const totalVat = finalVat10.plus(finalVat20);
145
+ const totalBrutto = totalNetto.plus(totalVat);
146
+ const grandTotal = totalBrutto.plus(finalTip);
147
+ return {
148
+ netto10: finalNetto10.toNumber(),
149
+ netto20: finalNetto20.toNumber(),
150
+ vat10: finalVat10.toNumber(),
151
+ vat20: finalVat20.toNumber(),
152
+ totalNetto: totalNetto.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP).toNumber(),
153
+ totalVat: totalVat.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP).toNumber(),
154
+ totalBrutto: totalBrutto.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP).toNumber(),
155
+ tipTotal: finalTip.toNumber(),
156
+ grandTotal: grandTotal.toDecimalPlaces(2, import_decimal2.default.ROUND_HALF_UP).toNumber()
157
+ };
158
+ }
159
+ // Annotate the CommonJS export names for ESM import in node:
160
+ 0 && (module.exports = {
161
+ calculateVatBreakdown,
162
+ getBruttoFromNetto,
163
+ getBruttoPrice,
164
+ getNettoPrice,
165
+ normalizeItem
166
+ });
167
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts","../src/normalize.ts","../src/core.ts","../src/breakdown.ts"],"sourcesContent":["export { normalizeItem } from \"./normalize\";\nexport { getNettoPrice, getBruttoFromNetto, getBruttoPrice } from \"./core\";\nexport { calculateVatBreakdown } from \"./breakdown\";\nexport type {\n RawItem,\n TaxableItem,\n VatBreakdown,\n NettoOptions,\n BreakdownOptions,\n} from \"./types\";\n","import type { RawItem, TaxableItem } from \"./types\";\n\n/**\n * Auto-detects field names across projects and produces a consistent TaxableItem.\n *\n * VAT rate: uses `ust ?? tva ?? defaultVatRate`.\n * - Uses ?? (not ||) so that tva: 0 is correctly treated as tax-exempt (fixes BUG 5).\n *\n * Quantity: uses `quantity ?? amount ?? coerced(value) ?? 1`.\n * - `value` can be a string in createRechnung data, so it's coerced to number.\n *\n * Discount: uses `discount ?? 0`, clamped to [0, 100].\n */\nexport function normalizeItem(\n raw: RawItem,\n defaultVatRate: number = 20\n): TaxableItem {\n // VAT rate: prefer ust, fall back to tva, then default.\n // ?? ensures 0 is preserved (fixes BUG 5 where || treated 0 as falsy → 20%).\n const vatRate = raw.ust ?? raw.tva ?? defaultVatRate;\n\n // Quantity: prefer quantity, then amount, then value (coerced from string).\n let quantity: number;\n if (raw.quantity != null) {\n quantity = Number(raw.quantity);\n } else if (raw.amount != null) {\n quantity = Number(raw.amount);\n } else if (raw.value != null) {\n quantity = Number(raw.value);\n } else {\n quantity = 1;\n }\n\n // Clamp discount to [0, 100], default 0.\n const rawDiscount = raw.discount ?? 0;\n const discount = Math.max(0, Math.min(100, rawDiscount));\n\n return {\n price: raw.price,\n vatRate,\n quantity,\n discount,\n ...(raw.priceNetto != null ? { priceNetto: raw.priceNetto } : {}),\n };\n}\n","import Decimal from \"decimal.js\";\nimport { normalizeItem } from \"./normalize\";\nimport type { RawItem, NettoOptions } from \"./types\";\n\n// Configure decimal.js for consistent behavior across all callers.\nDecimal.set({ rounding: Decimal.ROUND_HALF_UP });\n\n/**\n * Compute the netto (ex-VAT) price of a single item.\n *\n * Algorithm A (netto-first):\n * netto = brutto / (1 + vatRate/100), rounded to `precision` DP\n * If discount: netto = netto × (1 − discount/100), rounded\n *\n * When `discountOn: 'brutto'`:\n * discountedBrutto = brutto × (1 − discount/100)\n * netto = discountedBrutto / (1 + vatRate/100), rounded\n *\n * @returns Netto price as a JS number (per single unit, NOT multiplied by quantity).\n */\nexport function getNettoPrice(\n item: RawItem,\n options: NettoOptions = {}\n): number {\n const { precision = 2, discountOn = \"netto\" } = options;\n const normalized = normalizeItem(item);\n const { price, vatRate, discount } = normalized;\n\n const d = (v: number) => new Decimal(v);\n\n if (vatRate === 0) {\n // Tax-exempt: netto === brutto. Apply discount directly.\n if (discount > 0) {\n return d(price)\n .times(d(1).minus(d(discount).div(100)))\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n }\n return d(price).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();\n }\n\n const divisor = d(1).plus(d(vatRate).div(100));\n\n if (discountOn === \"brutto\" && discount > 0) {\n // Discount applied to brutto first, then convert to netto.\n const discountedBrutto = d(price).times(\n d(1).minus(d(discount).div(100))\n );\n return discountedBrutto\n .div(divisor)\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n }\n\n // Standard: convert to netto, then apply discount on netto.\n const netto = d(price)\n .div(divisor)\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP);\n\n if (discount > 0) {\n const discountedNetto = netto.times(d(1).minus(d(discount).div(100)));\n // Round the discount amount, then subtract (matches existing behavior).\n const discountAmount = netto.minus(discountedNetto).toDecimalPlaces(\n precision,\n Decimal.ROUND_HALF_UP\n );\n return netto.minus(discountAmount).toNumber();\n }\n\n return netto.toNumber();\n}\n\n/**\n * Compute brutto from a netto value and VAT rate.\n * brutto = netto × (1 + vatRate/100)\n */\nexport function getBruttoFromNetto(\n netto: number,\n vatRate: number = 20,\n precision: number = 2\n): number {\n return new Decimal(netto)\n .times(new Decimal(1).plus(new Decimal(vatRate).div(100)))\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n}\n\n/**\n * Compute the (possibly discounted) brutto price of an item.\n * If discount > 0: brutto × (1 − discount/100), rounded\n * Else: brutto as-is\n */\nexport function getBruttoPrice(\n item: RawItem,\n precision: number = 2\n): number {\n const normalized = normalizeItem(item);\n const { price, discount } = normalized;\n\n if (discount > 0) {\n return new Decimal(price)\n .times(new Decimal(1).minus(new Decimal(discount).div(100)))\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n }\n\n return price;\n}\n","import Decimal from \"decimal.js\";\nimport { normalizeItem } from \"./normalize\";\nimport { getNettoPrice } from \"./core\";\nimport type { RawItem, VatBreakdown, BreakdownOptions } from \"./types\";\n\n/**\n * Compute a full Austrian VAT breakdown from a list of items.\n *\n * Uses Algorithm A (netto-first):\n * 1. For each item, compute netto per unit via getNettoPrice (rounded to `precision` DP).\n * 2. Multiply by quantity to get line total netto.\n * 3. Group into three buckets: 20%, 10%, 0% (tips/tax-exempt).\n * 4. Sum each bucket. Add shipping (netto) to the 20% bucket.\n * 5. Compute VAT per bucket: nettoTotal × rate.\n * 6. Round final totals to 2 DP.\n *\n * This replaces all the ad-hoc ust20.reduce / ust10.reduce patterns across projects.\n *\n * Items should already be filtered (e.g. `inSumme === true`) before passing here.\n */\nexport function calculateVatBreakdown(\n items: RawItem[],\n options: BreakdownOptions = {}\n): VatBreakdown {\n const { shippingCostNetto = 0, precision = 2 } = options;\n\n const d = (v: number) => new Decimal(v);\n\n let netto10 = d(0);\n let netto20 = d(0);\n let tipTotal = d(0);\n\n for (const item of items) {\n const normalized = normalizeItem(item);\n const { vatRate, quantity } = normalized;\n\n if (vatRate === 0) {\n // Tax-exempt items (Trinkgeld / tips).\n // For tips, netto === brutto. Apply discount if any.\n const nettoPerUnit = getNettoPrice(item, { precision });\n tipTotal = tipTotal.plus(d(nettoPerUnit).times(quantity));\n continue;\n }\n\n const nettoPerUnit = getNettoPrice(item, { precision });\n const lineNetto = d(nettoPerUnit).times(quantity);\n\n if (vatRate === 10) {\n netto10 = netto10.plus(lineNetto);\n } else {\n // Everything that isn't 10% or 0% goes into the 20% bucket.\n // This includes the standard 20% rate and any non-standard rates\n // (though in practice Austrian VAT is only 10% or 20%).\n netto20 = netto20.plus(lineNetto);\n }\n }\n\n // Add shipping to 20% netto bucket (shipping is always 20% VAT in Austria).\n if (shippingCostNetto > 0) {\n netto20 = netto20.plus(d(shippingCostNetto));\n }\n\n // Compute VAT amounts.\n const vat10 = netto10.times(d(0.1));\n const vat20 = netto20.times(d(0.2));\n\n // Round all final values to 2 DP.\n const finalNetto10 = netto10.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalNetto20 = netto20.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalVat10 = vat10.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalVat20 = vat20.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalTip = tipTotal.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n\n const totalNetto = finalNetto10.plus(finalNetto20);\n const totalVat = finalVat10.plus(finalVat20);\n const totalBrutto = totalNetto.plus(totalVat);\n const grandTotal = totalBrutto.plus(finalTip);\n\n return {\n netto10: finalNetto10.toNumber(),\n netto20: finalNetto20.toNumber(),\n vat10: finalVat10.toNumber(),\n vat20: finalVat20.toNumber(),\n totalNetto: totalNetto.toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toNumber(),\n totalVat: totalVat.toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toNumber(),\n totalBrutto: totalBrutto\n .toDecimalPlaces(2, Decimal.ROUND_HALF_UP)\n .toNumber(),\n tipTotal: finalTip.toNumber(),\n grandTotal: grandTotal\n .toDecimalPlaces(2, Decimal.ROUND_HALF_UP)\n .toNumber(),\n };\n}\n"],"mappings":"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","names":["Decimal","import_decimal","Decimal","nettoPerUnit"]}
package/dist/index.mjs ADDED
@@ -0,0 +1,126 @@
1
+ // src/normalize.ts
2
+ function normalizeItem(raw, defaultVatRate = 20) {
3
+ const vatRate = raw.ust ?? raw.tva ?? defaultVatRate;
4
+ let quantity;
5
+ if (raw.quantity != null) {
6
+ quantity = Number(raw.quantity);
7
+ } else if (raw.amount != null) {
8
+ quantity = Number(raw.amount);
9
+ } else if (raw.value != null) {
10
+ quantity = Number(raw.value);
11
+ } else {
12
+ quantity = 1;
13
+ }
14
+ const rawDiscount = raw.discount ?? 0;
15
+ const discount = Math.max(0, Math.min(100, rawDiscount));
16
+ return {
17
+ price: raw.price,
18
+ vatRate,
19
+ quantity,
20
+ discount,
21
+ ...raw.priceNetto != null ? { priceNetto: raw.priceNetto } : {}
22
+ };
23
+ }
24
+
25
+ // src/core.ts
26
+ import Decimal from "decimal.js";
27
+ Decimal.set({ rounding: Decimal.ROUND_HALF_UP });
28
+ function getNettoPrice(item, options = {}) {
29
+ const { precision = 2, discountOn = "netto" } = options;
30
+ const normalized = normalizeItem(item);
31
+ const { price, vatRate, discount } = normalized;
32
+ const d = (v) => new Decimal(v);
33
+ if (vatRate === 0) {
34
+ if (discount > 0) {
35
+ return d(price).times(d(1).minus(d(discount).div(100))).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();
36
+ }
37
+ return d(price).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();
38
+ }
39
+ const divisor = d(1).plus(d(vatRate).div(100));
40
+ if (discountOn === "brutto" && discount > 0) {
41
+ const discountedBrutto = d(price).times(
42
+ d(1).minus(d(discount).div(100))
43
+ );
44
+ return discountedBrutto.div(divisor).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();
45
+ }
46
+ const netto = d(price).div(divisor).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP);
47
+ if (discount > 0) {
48
+ const discountedNetto = netto.times(d(1).minus(d(discount).div(100)));
49
+ const discountAmount = netto.minus(discountedNetto).toDecimalPlaces(
50
+ precision,
51
+ Decimal.ROUND_HALF_UP
52
+ );
53
+ return netto.minus(discountAmount).toNumber();
54
+ }
55
+ return netto.toNumber();
56
+ }
57
+ function getBruttoFromNetto(netto, vatRate = 20, precision = 2) {
58
+ return new Decimal(netto).times(new Decimal(1).plus(new Decimal(vatRate).div(100))).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();
59
+ }
60
+ function getBruttoPrice(item, precision = 2) {
61
+ const normalized = normalizeItem(item);
62
+ const { price, discount } = normalized;
63
+ if (discount > 0) {
64
+ return new Decimal(price).times(new Decimal(1).minus(new Decimal(discount).div(100))).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();
65
+ }
66
+ return price;
67
+ }
68
+
69
+ // src/breakdown.ts
70
+ import Decimal2 from "decimal.js";
71
+ function calculateVatBreakdown(items, options = {}) {
72
+ const { shippingCostNetto = 0, precision = 2 } = options;
73
+ const d = (v) => new Decimal2(v);
74
+ let netto10 = d(0);
75
+ let netto20 = d(0);
76
+ let tipTotal = d(0);
77
+ for (const item of items) {
78
+ const normalized = normalizeItem(item);
79
+ const { vatRate, quantity } = normalized;
80
+ if (vatRate === 0) {
81
+ const nettoPerUnit2 = getNettoPrice(item, { precision });
82
+ tipTotal = tipTotal.plus(d(nettoPerUnit2).times(quantity));
83
+ continue;
84
+ }
85
+ const nettoPerUnit = getNettoPrice(item, { precision });
86
+ const lineNetto = d(nettoPerUnit).times(quantity);
87
+ if (vatRate === 10) {
88
+ netto10 = netto10.plus(lineNetto);
89
+ } else {
90
+ netto20 = netto20.plus(lineNetto);
91
+ }
92
+ }
93
+ if (shippingCostNetto > 0) {
94
+ netto20 = netto20.plus(d(shippingCostNetto));
95
+ }
96
+ const vat10 = netto10.times(d(0.1));
97
+ const vat20 = netto20.times(d(0.2));
98
+ const finalNetto10 = netto10.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP);
99
+ const finalNetto20 = netto20.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP);
100
+ const finalVat10 = vat10.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP);
101
+ const finalVat20 = vat20.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP);
102
+ const finalTip = tipTotal.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP);
103
+ const totalNetto = finalNetto10.plus(finalNetto20);
104
+ const totalVat = finalVat10.plus(finalVat20);
105
+ const totalBrutto = totalNetto.plus(totalVat);
106
+ const grandTotal = totalBrutto.plus(finalTip);
107
+ return {
108
+ netto10: finalNetto10.toNumber(),
109
+ netto20: finalNetto20.toNumber(),
110
+ vat10: finalVat10.toNumber(),
111
+ vat20: finalVat20.toNumber(),
112
+ totalNetto: totalNetto.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP).toNumber(),
113
+ totalVat: totalVat.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP).toNumber(),
114
+ totalBrutto: totalBrutto.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP).toNumber(),
115
+ tipTotal: finalTip.toNumber(),
116
+ grandTotal: grandTotal.toDecimalPlaces(2, Decimal2.ROUND_HALF_UP).toNumber()
117
+ };
118
+ }
119
+ export {
120
+ calculateVatBreakdown,
121
+ getBruttoFromNetto,
122
+ getBruttoPrice,
123
+ getNettoPrice,
124
+ normalizeItem
125
+ };
126
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/normalize.ts","../src/core.ts","../src/breakdown.ts"],"sourcesContent":["import type { RawItem, TaxableItem } from \"./types\";\n\n/**\n * Auto-detects field names across projects and produces a consistent TaxableItem.\n *\n * VAT rate: uses `ust ?? tva ?? defaultVatRate`.\n * - Uses ?? (not ||) so that tva: 0 is correctly treated as tax-exempt (fixes BUG 5).\n *\n * Quantity: uses `quantity ?? amount ?? coerced(value) ?? 1`.\n * - `value` can be a string in createRechnung data, so it's coerced to number.\n *\n * Discount: uses `discount ?? 0`, clamped to [0, 100].\n */\nexport function normalizeItem(\n raw: RawItem,\n defaultVatRate: number = 20\n): TaxableItem {\n // VAT rate: prefer ust, fall back to tva, then default.\n // ?? ensures 0 is preserved (fixes BUG 5 where || treated 0 as falsy → 20%).\n const vatRate = raw.ust ?? raw.tva ?? defaultVatRate;\n\n // Quantity: prefer quantity, then amount, then value (coerced from string).\n let quantity: number;\n if (raw.quantity != null) {\n quantity = Number(raw.quantity);\n } else if (raw.amount != null) {\n quantity = Number(raw.amount);\n } else if (raw.value != null) {\n quantity = Number(raw.value);\n } else {\n quantity = 1;\n }\n\n // Clamp discount to [0, 100], default 0.\n const rawDiscount = raw.discount ?? 0;\n const discount = Math.max(0, Math.min(100, rawDiscount));\n\n return {\n price: raw.price,\n vatRate,\n quantity,\n discount,\n ...(raw.priceNetto != null ? { priceNetto: raw.priceNetto } : {}),\n };\n}\n","import Decimal from \"decimal.js\";\nimport { normalizeItem } from \"./normalize\";\nimport type { RawItem, NettoOptions } from \"./types\";\n\n// Configure decimal.js for consistent behavior across all callers.\nDecimal.set({ rounding: Decimal.ROUND_HALF_UP });\n\n/**\n * Compute the netto (ex-VAT) price of a single item.\n *\n * Algorithm A (netto-first):\n * netto = brutto / (1 + vatRate/100), rounded to `precision` DP\n * If discount: netto = netto × (1 − discount/100), rounded\n *\n * When `discountOn: 'brutto'`:\n * discountedBrutto = brutto × (1 − discount/100)\n * netto = discountedBrutto / (1 + vatRate/100), rounded\n *\n * @returns Netto price as a JS number (per single unit, NOT multiplied by quantity).\n */\nexport function getNettoPrice(\n item: RawItem,\n options: NettoOptions = {}\n): number {\n const { precision = 2, discountOn = \"netto\" } = options;\n const normalized = normalizeItem(item);\n const { price, vatRate, discount } = normalized;\n\n const d = (v: number) => new Decimal(v);\n\n if (vatRate === 0) {\n // Tax-exempt: netto === brutto. Apply discount directly.\n if (discount > 0) {\n return d(price)\n .times(d(1).minus(d(discount).div(100)))\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n }\n return d(price).toDecimalPlaces(precision, Decimal.ROUND_HALF_UP).toNumber();\n }\n\n const divisor = d(1).plus(d(vatRate).div(100));\n\n if (discountOn === \"brutto\" && discount > 0) {\n // Discount applied to brutto first, then convert to netto.\n const discountedBrutto = d(price).times(\n d(1).minus(d(discount).div(100))\n );\n return discountedBrutto\n .div(divisor)\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n }\n\n // Standard: convert to netto, then apply discount on netto.\n const netto = d(price)\n .div(divisor)\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP);\n\n if (discount > 0) {\n const discountedNetto = netto.times(d(1).minus(d(discount).div(100)));\n // Round the discount amount, then subtract (matches existing behavior).\n const discountAmount = netto.minus(discountedNetto).toDecimalPlaces(\n precision,\n Decimal.ROUND_HALF_UP\n );\n return netto.minus(discountAmount).toNumber();\n }\n\n return netto.toNumber();\n}\n\n/**\n * Compute brutto from a netto value and VAT rate.\n * brutto = netto × (1 + vatRate/100)\n */\nexport function getBruttoFromNetto(\n netto: number,\n vatRate: number = 20,\n precision: number = 2\n): number {\n return new Decimal(netto)\n .times(new Decimal(1).plus(new Decimal(vatRate).div(100)))\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n}\n\n/**\n * Compute the (possibly discounted) brutto price of an item.\n * If discount > 0: brutto × (1 − discount/100), rounded\n * Else: brutto as-is\n */\nexport function getBruttoPrice(\n item: RawItem,\n precision: number = 2\n): number {\n const normalized = normalizeItem(item);\n const { price, discount } = normalized;\n\n if (discount > 0) {\n return new Decimal(price)\n .times(new Decimal(1).minus(new Decimal(discount).div(100)))\n .toDecimalPlaces(precision, Decimal.ROUND_HALF_UP)\n .toNumber();\n }\n\n return price;\n}\n","import Decimal from \"decimal.js\";\nimport { normalizeItem } from \"./normalize\";\nimport { getNettoPrice } from \"./core\";\nimport type { RawItem, VatBreakdown, BreakdownOptions } from \"./types\";\n\n/**\n * Compute a full Austrian VAT breakdown from a list of items.\n *\n * Uses Algorithm A (netto-first):\n * 1. For each item, compute netto per unit via getNettoPrice (rounded to `precision` DP).\n * 2. Multiply by quantity to get line total netto.\n * 3. Group into three buckets: 20%, 10%, 0% (tips/tax-exempt).\n * 4. Sum each bucket. Add shipping (netto) to the 20% bucket.\n * 5. Compute VAT per bucket: nettoTotal × rate.\n * 6. Round final totals to 2 DP.\n *\n * This replaces all the ad-hoc ust20.reduce / ust10.reduce patterns across projects.\n *\n * Items should already be filtered (e.g. `inSumme === true`) before passing here.\n */\nexport function calculateVatBreakdown(\n items: RawItem[],\n options: BreakdownOptions = {}\n): VatBreakdown {\n const { shippingCostNetto = 0, precision = 2 } = options;\n\n const d = (v: number) => new Decimal(v);\n\n let netto10 = d(0);\n let netto20 = d(0);\n let tipTotal = d(0);\n\n for (const item of items) {\n const normalized = normalizeItem(item);\n const { vatRate, quantity } = normalized;\n\n if (vatRate === 0) {\n // Tax-exempt items (Trinkgeld / tips).\n // For tips, netto === brutto. Apply discount if any.\n const nettoPerUnit = getNettoPrice(item, { precision });\n tipTotal = tipTotal.plus(d(nettoPerUnit).times(quantity));\n continue;\n }\n\n const nettoPerUnit = getNettoPrice(item, { precision });\n const lineNetto = d(nettoPerUnit).times(quantity);\n\n if (vatRate === 10) {\n netto10 = netto10.plus(lineNetto);\n } else {\n // Everything that isn't 10% or 0% goes into the 20% bucket.\n // This includes the standard 20% rate and any non-standard rates\n // (though in practice Austrian VAT is only 10% or 20%).\n netto20 = netto20.plus(lineNetto);\n }\n }\n\n // Add shipping to 20% netto bucket (shipping is always 20% VAT in Austria).\n if (shippingCostNetto > 0) {\n netto20 = netto20.plus(d(shippingCostNetto));\n }\n\n // Compute VAT amounts.\n const vat10 = netto10.times(d(0.1));\n const vat20 = netto20.times(d(0.2));\n\n // Round all final values to 2 DP.\n const finalNetto10 = netto10.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalNetto20 = netto20.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalVat10 = vat10.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalVat20 = vat20.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n const finalTip = tipTotal.toDecimalPlaces(2, Decimal.ROUND_HALF_UP);\n\n const totalNetto = finalNetto10.plus(finalNetto20);\n const totalVat = finalVat10.plus(finalVat20);\n const totalBrutto = totalNetto.plus(totalVat);\n const grandTotal = totalBrutto.plus(finalTip);\n\n return {\n netto10: finalNetto10.toNumber(),\n netto20: finalNetto20.toNumber(),\n vat10: finalVat10.toNumber(),\n vat20: finalVat20.toNumber(),\n totalNetto: totalNetto.toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toNumber(),\n totalVat: totalVat.toDecimalPlaces(2, Decimal.ROUND_HALF_UP).toNumber(),\n totalBrutto: totalBrutto\n .toDecimalPlaces(2, Decimal.ROUND_HALF_UP)\n .toNumber(),\n tipTotal: finalTip.toNumber(),\n grandTotal: grandTotal\n .toDecimalPlaces(2, Decimal.ROUND_HALF_UP)\n .toNumber(),\n };\n}\n"],"mappings":";AAaO,SAAS,cACd,KACA,iBAAyB,IACZ;AAGb,QAAM,UAAU,IAAI,OAAO,IAAI,OAAO;AAGtC,MAAI;AACJ,MAAI,IAAI,YAAY,MAAM;AACxB,eAAW,OAAO,IAAI,QAAQ;AAAA,EAChC,WAAW,IAAI,UAAU,MAAM;AAC7B,eAAW,OAAO,IAAI,MAAM;AAAA,EAC9B,WAAW,IAAI,SAAS,MAAM;AAC5B,eAAW,OAAO,IAAI,KAAK;AAAA,EAC7B,OAAO;AACL,eAAW;AAAA,EACb;AAGA,QAAM,cAAc,IAAI,YAAY;AACpC,QAAM,WAAW,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,WAAW,CAAC;AAEvD,SAAO;AAAA,IACL,OAAO,IAAI;AAAA,IACX;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAI,IAAI,cAAc,OAAO,EAAE,YAAY,IAAI,WAAW,IAAI,CAAC;AAAA,EACjE;AACF;;;AC5CA,OAAO,aAAa;AAKpB,QAAQ,IAAI,EAAE,UAAU,QAAQ,cAAc,CAAC;AAexC,SAAS,cACd,MACA,UAAwB,CAAC,GACjB;AACR,QAAM,EAAE,YAAY,GAAG,aAAa,QAAQ,IAAI;AAChD,QAAM,aAAa,cAAc,IAAI;AACrC,QAAM,EAAE,OAAO,SAAS,SAAS,IAAI;AAErC,QAAM,IAAI,CAAC,MAAc,IAAI,QAAQ,CAAC;AAEtC,MAAI,YAAY,GAAG;AAEjB,QAAI,WAAW,GAAG;AAChB,aAAO,EAAE,KAAK,EACX,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,GAAG,CAAC,CAAC,EACtC,gBAAgB,WAAW,QAAQ,aAAa,EAChD,SAAS;AAAA,IACd;AACA,WAAO,EAAE,KAAK,EAAE,gBAAgB,WAAW,QAAQ,aAAa,EAAE,SAAS;AAAA,EAC7E;AAEA,QAAM,UAAU,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,GAAG,CAAC;AAE7C,MAAI,eAAe,YAAY,WAAW,GAAG;AAE3C,UAAM,mBAAmB,EAAE,KAAK,EAAE;AAAA,MAChC,EAAE,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,GAAG,CAAC;AAAA,IACjC;AACA,WAAO,iBACJ,IAAI,OAAO,EACX,gBAAgB,WAAW,QAAQ,aAAa,EAChD,SAAS;AAAA,EACd;AAGA,QAAM,QAAQ,EAAE,KAAK,EAClB,IAAI,OAAO,EACX,gBAAgB,WAAW,QAAQ,aAAa;AAEnD,MAAI,WAAW,GAAG;AAChB,UAAM,kBAAkB,MAAM,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,GAAG,CAAC,CAAC;AAEpE,UAAM,iBAAiB,MAAM,MAAM,eAAe,EAAE;AAAA,MAClD;AAAA,MACA,QAAQ;AAAA,IACV;AACA,WAAO,MAAM,MAAM,cAAc,EAAE,SAAS;AAAA,EAC9C;AAEA,SAAO,MAAM,SAAS;AACxB;AAMO,SAAS,mBACd,OACA,UAAkB,IAClB,YAAoB,GACZ;AACR,SAAO,IAAI,QAAQ,KAAK,EACrB,MAAM,IAAI,QAAQ,CAAC,EAAE,KAAK,IAAI,QAAQ,OAAO,EAAE,IAAI,GAAG,CAAC,CAAC,EACxD,gBAAgB,WAAW,QAAQ,aAAa,EAChD,SAAS;AACd;AAOO,SAAS,eACd,MACA,YAAoB,GACZ;AACR,QAAM,aAAa,cAAc,IAAI;AACrC,QAAM,EAAE,OAAO,SAAS,IAAI;AAE5B,MAAI,WAAW,GAAG;AAChB,WAAO,IAAI,QAAQ,KAAK,EACrB,MAAM,IAAI,QAAQ,CAAC,EAAE,MAAM,IAAI,QAAQ,QAAQ,EAAE,IAAI,GAAG,CAAC,CAAC,EAC1D,gBAAgB,WAAW,QAAQ,aAAa,EAChD,SAAS;AAAA,EACd;AAEA,SAAO;AACT;;;AC3GA,OAAOA,cAAa;AAoBb,SAAS,sBACd,OACA,UAA4B,CAAC,GACf;AACd,QAAM,EAAE,oBAAoB,GAAG,YAAY,EAAE,IAAI;AAEjD,QAAM,IAAI,CAAC,MAAc,IAAIC,SAAQ,CAAC;AAEtC,MAAI,UAAU,EAAE,CAAC;AACjB,MAAI,UAAU,EAAE,CAAC;AACjB,MAAI,WAAW,EAAE,CAAC;AAElB,aAAW,QAAQ,OAAO;AACxB,UAAM,aAAa,cAAc,IAAI;AACrC,UAAM,EAAE,SAAS,SAAS,IAAI;AAE9B,QAAI,YAAY,GAAG;AAGjB,YAAMC,gBAAe,cAAc,MAAM,EAAE,UAAU,CAAC;AACtD,iBAAW,SAAS,KAAK,EAAEA,aAAY,EAAE,MAAM,QAAQ,CAAC;AACxD;AAAA,IACF;AAEA,UAAM,eAAe,cAAc,MAAM,EAAE,UAAU,CAAC;AACtD,UAAM,YAAY,EAAE,YAAY,EAAE,MAAM,QAAQ;AAEhD,QAAI,YAAY,IAAI;AAClB,gBAAU,QAAQ,KAAK,SAAS;AAAA,IAClC,OAAO;AAIL,gBAAU,QAAQ,KAAK,SAAS;AAAA,IAClC;AAAA,EACF;AAGA,MAAI,oBAAoB,GAAG;AACzB,cAAU,QAAQ,KAAK,EAAE,iBAAiB,CAAC;AAAA,EAC7C;AAGA,QAAM,QAAQ,QAAQ,MAAM,EAAE,GAAG,CAAC;AAClC,QAAM,QAAQ,QAAQ,MAAM,EAAE,GAAG,CAAC;AAGlC,QAAM,eAAe,QAAQ,gBAAgB,GAAGD,SAAQ,aAAa;AACrE,QAAM,eAAe,QAAQ,gBAAgB,GAAGA,SAAQ,aAAa;AACrE,QAAM,aAAa,MAAM,gBAAgB,GAAGA,SAAQ,aAAa;AACjE,QAAM,aAAa,MAAM,gBAAgB,GAAGA,SAAQ,aAAa;AACjE,QAAM,WAAW,SAAS,gBAAgB,GAAGA,SAAQ,aAAa;AAElE,QAAM,aAAa,aAAa,KAAK,YAAY;AACjD,QAAM,WAAW,WAAW,KAAK,UAAU;AAC3C,QAAM,cAAc,WAAW,KAAK,QAAQ;AAC5C,QAAM,aAAa,YAAY,KAAK,QAAQ;AAE5C,SAAO;AAAA,IACL,SAAS,aAAa,SAAS;AAAA,IAC/B,SAAS,aAAa,SAAS;AAAA,IAC/B,OAAO,WAAW,SAAS;AAAA,IAC3B,OAAO,WAAW,SAAS;AAAA,IAC3B,YAAY,WAAW,gBAAgB,GAAGA,SAAQ,aAAa,EAAE,SAAS;AAAA,IAC1E,UAAU,SAAS,gBAAgB,GAAGA,SAAQ,aAAa,EAAE,SAAS;AAAA,IACtE,aAAa,YACV,gBAAgB,GAAGA,SAAQ,aAAa,EACxC,SAAS;AAAA,IACZ,UAAU,SAAS,SAAS;AAAA,IAC5B,YAAY,WACT,gBAAgB,GAAGA,SAAQ,aAAa,EACxC,SAAS;AAAA,EACd;AACF;","names":["Decimal","Decimal","nettoPerUnit"]}
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@oliviermtlbali/ust-calc",
3
+ "version": "1.0.0",
4
+ "description": "Shared Austrian VAT (USt) calculation library",
5
+ "main": "./dist/index.js",
6
+ "module": "./dist/index.mjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup",
20
+ "prepublishOnly": "tsup",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "dependencies": {
25
+ "decimal.js": "^10.6.0"
26
+ },
27
+ "devDependencies": {
28
+ "tsup": "^8.0.0",
29
+ "typescript": "^5.3.0",
30
+ "vitest": "^1.0.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }