@invompt/invoml 1.0.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ import { InternalDecimal, roundHalfUp, getCurrencyDecimals } from './rounding.js';
2
+ import { resolveTaxConfig, resolveCategory } from './tax.js';
3
+ import { parseDiscount, applyDiscount, allocateProportionally } from './discounts.js';
4
+ /** Compute all totals for an InvoML document using arbitrary-precision decimal arithmetic. Returns subtotal, per-category tax breakdowns, discount details, and amount due. */
5
+ export function calculate(doc) {
6
+ const taxConfig = resolveTaxConfig(doc.meta.tax);
7
+ const dp = getCurrencyDecimals(doc.meta.currency);
8
+ const round = (v) => roundHalfUp(v, dp);
9
+ // Work on shallow copies of items to avoid mutating the caller's input
10
+ const items = doc.items.map(i => ({ ...i }));
11
+ // ── STEP 1: Line Calculations ──
12
+ for (const item of items) {
13
+ // Use Decimal for multiplication to avoid native float precision loss
14
+ const gross = round(new InternalDecimal(item.quantity.toString()).times(item.unitPrice.toString()).toNumber());
15
+ const lineDiscount = item.discount ? applyDiscount(parseDiscount(item.discount), gross, dp) : 0;
16
+ item.amount = round(new InternalDecimal(gross.toString()).minus(lineDiscount.toString()).toNumber());
17
+ if (taxConfig && !taxConfig.compound) {
18
+ const cat = resolveCategory(item, taxConfig);
19
+ if (cat && !cat.exempt && !cat.reverseCharge) {
20
+ if (taxConfig.inclusive) {
21
+ // Use Decimal for the inclusive back-out division
22
+ const divisor = new InternalDecimal(1).plus(new InternalDecimal(cat.rate.toString()).dividedBy(100));
23
+ const net = round(new InternalDecimal(item.amount.toString()).dividedBy(divisor).toNumber());
24
+ item.taxAmount = round(new InternalDecimal(item.amount.toString()).minus(net.toString()).toNumber());
25
+ }
26
+ else {
27
+ // Use Decimal for exclusive tax multiplication
28
+ item.taxAmount = round(new InternalDecimal(item.amount.toString()).times(new InternalDecimal(cat.rate.toString()).dividedBy(100)).toNumber());
29
+ }
30
+ }
31
+ else {
32
+ item.taxAmount = 0;
33
+ }
34
+ }
35
+ else {
36
+ item.taxAmount = 0;
37
+ }
38
+ }
39
+ // ── STEP 2: Subtotal ──
40
+ let subtotal = round(items.reduce((sum, i) => sum.plus(i.amount ?? 0), new InternalDecimal(0)).toNumber());
41
+ // ── STEP 3: Invoice-Level Discounts (cascading) ──
42
+ let running = subtotal;
43
+ const discountDetails = [];
44
+ for (const discount of doc.discounts ?? []) {
45
+ const amount = applyDiscount(discount, running, dp);
46
+ discountDetails.push({ label: discount.label, amount });
47
+ running = round(new InternalDecimal(running.toString()).minus(amount.toString()).toNumber());
48
+ }
49
+ const afterDiscounts = running;
50
+ const discountTotal = round(new InternalDecimal(subtotal.toString()).minus(afterDiscounts.toString()).toNumber());
51
+ // ── STEP 4: Tax Calculation ──
52
+ const taxDetails = [];
53
+ let taxTotalD = new InternalDecimal(0);
54
+ let withholdingTotalD = new InternalDecimal(0);
55
+ if (taxConfig === null) {
56
+ // No tax — nothing to do
57
+ }
58
+ else if (taxConfig.compound) {
59
+ // Compound: all categories apply to full base, taxCategory is ignored
60
+ const base = afterDiscounts;
61
+ for (const cat of taxConfig.categories) {
62
+ // Use Decimal for the compound tax multiplication
63
+ const catTax = round(new InternalDecimal(base.toString()).times(new InternalDecimal(cat.rate.toString()).dividedBy(100)).toNumber());
64
+ taxDetails.push({ category: cat.id, label: cat.label, rate: cat.rate, base, amount: catTax, inclusive: false });
65
+ if (cat.withholding) {
66
+ withholdingTotalD = withholdingTotalD.plus(catTax);
67
+ }
68
+ else if (!cat.reverseCharge) {
69
+ taxTotalD = taxTotalD.plus(catTax);
70
+ }
71
+ }
72
+ }
73
+ else if (taxConfig.inclusive) {
74
+ // Inclusive: separate regular and withholding categories — same split as exclusive branch
75
+ const regularCats = taxConfig.categories.filter(c => !c.withholding);
76
+ const withholdingCats = taxConfig.categories.filter(c => c.withholding);
77
+ const catAmounts = regularCats.map(cat => {
78
+ const linesInCat = items.filter(i => resolveCategory(i, taxConfig).id === cat.id);
79
+ return round(linesInCat.reduce((s, i) => s.plus(i.amount ?? 0), new InternalDecimal(0)).toNumber());
80
+ });
81
+ const catDiscounts = allocateProportionally(catAmounts, discountTotal, subtotal, round);
82
+ for (let idx = 0; idx < regularCats.length; idx++) {
83
+ const cat = regularCats[idx];
84
+ const catNetAfterDiscount = round(new InternalDecimal(catAmounts[idx].toString()).minus(catDiscounts[idx].toString()).toNumber());
85
+ let catTax;
86
+ let catNetBeforeTax;
87
+ if (cat.exempt) {
88
+ // Exempt categories have zero tax regardless of rate
89
+ catTax = 0;
90
+ catNetBeforeTax = catNetAfterDiscount;
91
+ }
92
+ else {
93
+ // Use Decimal for the inclusive back-out division
94
+ const divisor = new InternalDecimal(1).plus(new InternalDecimal(cat.rate.toString()).dividedBy(100));
95
+ catNetBeforeTax = round(new InternalDecimal(catNetAfterDiscount.toString()).dividedBy(divisor).toNumber());
96
+ catTax = round(new InternalDecimal(catNetAfterDiscount.toString()).minus(catNetBeforeTax.toString()).toNumber());
97
+ }
98
+ taxDetails.push({ category: cat.id, label: cat.label, rate: cat.rate, base: catNetBeforeTax, amount: catTax, inclusive: true });
99
+ if (!cat.reverseCharge) {
100
+ taxTotalD = taxTotalD.plus(catTax);
101
+ }
102
+ }
103
+ // Withholding categories in inclusive mode: apply to afterDiscounts base (same as exclusive)
104
+ for (const cat of withholdingCats) {
105
+ const base = afterDiscounts;
106
+ const catTax = round(new InternalDecimal(base.toString()).times(new InternalDecimal(cat.rate.toString()).dividedBy(100)).toNumber());
107
+ taxDetails.push({ category: cat.id, label: cat.label, rate: cat.rate, base, amount: catTax, inclusive: false });
108
+ withholdingTotalD = withholdingTotalD.plus(catTax);
109
+ }
110
+ }
111
+ else {
112
+ // Standard / multi-rate
113
+ // Separate withholding categories (apply to full base) from regular categories
114
+ const regularCats = taxConfig.categories.filter(c => !c.withholding);
115
+ const withholdingCats = taxConfig.categories.filter(c => c.withholding);
116
+ const regularAmounts = regularCats.map(cat => {
117
+ const linesInCat = items.filter(i => resolveCategory(i, taxConfig).id === cat.id);
118
+ return round(linesInCat.reduce((s, i) => s.plus(i.amount ?? 0), new InternalDecimal(0)).toNumber());
119
+ });
120
+ const regularDiscounts = allocateProportionally(regularAmounts, discountTotal, subtotal, round);
121
+ for (let idx = 0; idx < regularCats.length; idx++) {
122
+ const cat = regularCats[idx];
123
+ const base = round(new InternalDecimal(regularAmounts[idx].toString()).minus(regularDiscounts[idx].toString()).toNumber());
124
+ // Use Decimal for the exclusive tax multiplication
125
+ const catTax = cat.exempt ? 0 : round(new InternalDecimal(base.toString()).times(new InternalDecimal(cat.rate.toString()).dividedBy(100)).toNumber());
126
+ taxDetails.push({ category: cat.id, label: cat.label, rate: cat.rate, base, amount: catTax, inclusive: false });
127
+ if (!cat.reverseCharge) {
128
+ taxTotalD = taxTotalD.plus(catTax);
129
+ }
130
+ }
131
+ // Withholding categories apply to afterDiscounts (full taxable base)
132
+ for (const cat of withholdingCats) {
133
+ const base = afterDiscounts;
134
+ const catTax = round(new InternalDecimal(base.toString()).times(new InternalDecimal(cat.rate.toString()).dividedBy(100)).toNumber());
135
+ taxDetails.push({ category: cat.id, label: cat.label, rate: cat.rate, base, amount: catTax, inclusive: false });
136
+ withholdingTotalD = withholdingTotalD.plus(catTax);
137
+ }
138
+ }
139
+ const taxTotal = round(taxTotalD.toNumber());
140
+ const withholdingTotal = round(withholdingTotalD.toNumber());
141
+ // ── STEP 5: Grand Total ──
142
+ let total;
143
+ if (taxConfig?.inclusive) {
144
+ total = afterDiscounts;
145
+ }
146
+ else {
147
+ // Use Decimal for the final total addition to avoid float precision loss
148
+ total = round(new InternalDecimal(afterDiscounts.toString()).plus(taxTotal.toString()).minus(withholdingTotal.toString()).toNumber());
149
+ }
150
+ // ── STEP 6: Amount Due ──
151
+ const prepaid = doc.totals?.prepaidAmount ?? 0;
152
+ const amountDue = round(new InternalDecimal(total.toString()).minus(prepaid.toString()).toNumber());
153
+ return {
154
+ subtotal,
155
+ discountDetails: discountDetails.length > 0 ? discountDetails : undefined,
156
+ afterDiscounts,
157
+ taxDetails: taxDetails.length > 0 ? taxDetails : undefined,
158
+ taxTotal,
159
+ withholdingTotal,
160
+ total,
161
+ prepaidAmount: prepaid,
162
+ amountDue,
163
+ };
164
+ }
@@ -0,0 +1,6 @@
1
+ import type { InvoMLDiscount } from './types.js';
2
+ export declare function parseDiscount(discount: string | InvoMLDiscount): InvoMLDiscount;
3
+ export declare function applyDiscount(discount: InvoMLDiscount, base: number, decimals?: number): number;
4
+ /** Allocate a total discount proportionally across categories based on their amounts.
5
+ * The last category absorbs any rounding residual (tie-breaking). */
6
+ export declare function allocateProportionally(categoryAmounts: number[], totalDiscount: number, subtotal: number, round: (v: number) => number): number[];
@@ -0,0 +1,46 @@
1
+ import { InternalDecimal, roundHalfUp } from './rounding.js';
2
+ const DISCOUNT_PATTERN = /^(\d+(\.\d+)?%|\d+(\.\d+)?)$/;
3
+ export function parseDiscount(discount) {
4
+ if (typeof discount === 'object')
5
+ return discount;
6
+ if (!DISCOUNT_PATTERN.test(discount)) {
7
+ throw new Error(`Invalid discount format: "${discount}"`);
8
+ }
9
+ if (discount.endsWith('%')) {
10
+ return { type: 'percentage', value: parseFloat(discount.slice(0, -1)) };
11
+ }
12
+ return { type: 'fixed', value: parseFloat(discount) };
13
+ }
14
+ export function applyDiscount(discount, base, decimals = 2) {
15
+ if (discount.type === 'percentage') {
16
+ // Use Decimal for multiplication to avoid native float precision loss
17
+ const amount = new InternalDecimal(base.toString())
18
+ .times(new InternalDecimal(discount.value.toString()).dividedBy(100));
19
+ return roundHalfUp(amount.toNumber(), decimals);
20
+ }
21
+ const absBase = Math.abs(base);
22
+ const amount = roundHalfUp(Math.min(discount.value, absBase), decimals);
23
+ return base < 0 ? -amount : amount;
24
+ }
25
+ /** Allocate a total discount proportionally across categories based on their amounts.
26
+ * The last category absorbs any rounding residual (tie-breaking). */
27
+ export function allocateProportionally(categoryAmounts, totalDiscount, subtotal, round) {
28
+ const result = [];
29
+ // Use Decimal accumulator to avoid float precision loss when summing allocated amounts
30
+ let allocated = new InternalDecimal(0);
31
+ for (let idx = 0; idx < categoryAmounts.length; idx++) {
32
+ if (idx === categoryAmounts.length - 1) {
33
+ result.push(round(new InternalDecimal(totalDiscount.toString()).minus(allocated).toNumber()));
34
+ }
35
+ else {
36
+ // Keep proportion as Decimal through the multiplication to avoid float round-trip
37
+ const proportion = subtotal !== 0
38
+ ? new InternalDecimal(categoryAmounts[idx].toString()).dividedBy(subtotal)
39
+ : new InternalDecimal(0);
40
+ const catDiscount = round(new InternalDecimal(totalDiscount.toString()).times(proportion).toNumber());
41
+ result.push(catDiscount);
42
+ allocated = allocated.plus(catDiscount);
43
+ }
44
+ }
45
+ return result;
46
+ }
@@ -0,0 +1,3 @@
1
+ /** US-style number formatter: commas for thousands, period for decimal, always dp places.
2
+ * Produces "7,200.00" for dp=2, "7,200" for dp=0, "7,200.000" for dp=3. */
3
+ export declare function fmtNum(n: number, dp: number): string;
@@ -0,0 +1,14 @@
1
+ /** US-style number formatter: commas for thousands, period for decimal, always dp places.
2
+ * Produces "7,200.00" for dp=2, "7,200" for dp=0, "7,200.000" for dp=3. */
3
+ export function fmtNum(n, dp) {
4
+ if (!isFinite(n))
5
+ throw new Error(`Cannot format non-finite number: ${n}`);
6
+ let fixed = n.toFixed(dp);
7
+ // Normalize negative zero: -0.00 → 0.00
8
+ if (parseFloat(fixed) === 0)
9
+ fixed = (0).toFixed(dp);
10
+ if (dp === 0)
11
+ return fixed.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
12
+ const [intPart, fracPart] = fixed.split('.');
13
+ return `${intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${fracPart}`;
14
+ }
@@ -0,0 +1,4 @@
1
+ /** Base CSS for all InvoML HTML output. Applied to every document regardless of template. */
2
+ export declare const BASE_CSS = "\n/* InvoML base styles */\n*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\n\nbody { background: #f5f5f5; }\n\n.invoml-container {\n --invoml-color-accent: #2563eb;\n --invoml-color-text: #1a1a1a;\n --invoml-color-muted: #666666;\n --invoml-color-border: #e0e0e0;\n --invoml-color-background: #ffffff;\n --invoml-font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n --invoml-font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n --invoml-padding-y: 10mm;\n --invoml-padding-x: 12mm;\n --invoml-section-gap: 18px;\n --invoml-table-row-padding: 10px;\n --invoml-parties-gap: 28px;\n --invoml-payment-margin-top: 28px;\n --invoml-meta-gap: 18px;\n --invoml-totals-margin: 18px;\n --invoml-line-height: 1.5;\n --invoml-paragraph-spacing: 12px;\n\n width: 210mm;\n margin: 0 auto;\n padding: var(--invoml-padding-y) var(--invoml-padding-x);\n background: var(--invoml-color-background);\n color: var(--invoml-color-text);\n font-family: var(--invoml-font-body);\n font-size: 14px;\n line-height: var(--invoml-line-height);\n -webkit-font-smoothing: antialiased;\n}\n\n.invoml-container h1, .invoml-container h2, .invoml-container h3,\n.invoml-container h4, .invoml-container h5, .invoml-container h6 {\n font-family: var(--invoml-font-heading);\n color: var(--invoml-color-text);\n line-height: 1.3;\n margin: 0;\n}\n\n.invoml-container a { color: var(--invoml-color-accent); text-decoration: none; }\n.invoml-container a:hover { text-decoration: underline; }\n.invoml-container table { border-collapse: collapse; border-spacing: 0; width: 100%; }\n.invoml-container th, .invoml-container td { text-align: left; vertical-align: top; }\n.invoml-container ul, .invoml-container ol { padding-left: 1.5em; }\n.invoml-container p + p { margin-top: var(--invoml-paragraph-spacing); }\n\n/* Density variants */\n.invoml-density-compact {\n --invoml-padding-y: 5mm;\n --invoml-padding-x: 6mm;\n --invoml-section-gap: 6px;\n --invoml-table-row-padding: 3px;\n --invoml-parties-gap: 10px;\n --invoml-payment-margin-top: 10px;\n --invoml-meta-gap: 6px;\n --invoml-totals-margin: 6px;\n --invoml-line-height: 1.3;\n --invoml-paragraph-spacing: 4px;\n}\n.invoml-density-spacious {\n --invoml-padding-y: 20mm;\n --invoml-padding-x: 24mm;\n --invoml-section-gap: 36px;\n --invoml-table-row-padding: 16px;\n --invoml-parties-gap: 48px;\n --invoml-payment-margin-top: 48px;\n --invoml-meta-gap: 28px;\n --invoml-totals-margin: 32px;\n --invoml-line-height: 1.8;\n --invoml-paragraph-spacing: 20px;\n}\n\n/* Header block */\n.invoml-header { margin-bottom: var(--invoml-section-gap); }\n.invoml-header-title {\n font-size: 28px;\n font-weight: 300;\n letter-spacing: -0.5px;\n color: var(--invoml-color-text);\n font-family: var(--invoml-font-heading);\n margin-bottom: 6px;\n}\n.invoml-header-number {\n font-size: 13px;\n font-weight: 600;\n color: var(--invoml-color-accent);\n letter-spacing: 0.5px;\n margin-bottom: 14px;\n display: inline-block;\n background: color-mix(in srgb, var(--invoml-color-accent) 10%, transparent);\n padding: 3px 10px;\n border-radius: 4px;\n}\n.invoml-header-meta {\n display: flex;\n flex-wrap: wrap;\n gap: var(--invoml-meta-gap);\n font-size: 13px;\n}\n.invoml-header-meta-item { display: flex; flex-direction: column; gap: 2px; min-width: 90px; }\n.invoml-header-meta-label {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: var(--invoml-color-muted);\n font-weight: 500;\n}\n.invoml-header-meta-value { color: var(--invoml-color-text); font-weight: 500; font-size: 13px; }\n\n/* Party blocks */\n.invoml-party {\n font-size: 14px;\n line-height: var(--invoml-line-height);\n margin-bottom: var(--invoml-section-gap);\n vertical-align: top;\n}\n.invoml-party-label {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n color: var(--invoml-color-muted);\n margin-bottom: 8px;\n font-weight: 500;\n}\n.invoml-party-name {\n font-weight: 600;\n color: var(--invoml-color-text);\n font-size: 15px;\n margin-bottom: 6px;\n}\n.invoml-party-details { color: var(--invoml-color-muted); font-size: 13px; line-height: var(--invoml-line-height); }\n.invoml-party-details > div { margin: 2px 0; }\n\n/* Items table */\n.invoml-items { margin: var(--invoml-totals-margin) 0; }\n.invoml-items th {\n padding: var(--invoml-table-row-padding) 0;\n border-bottom: 1px solid var(--invoml-color-border);\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: var(--invoml-color-muted);\n font-weight: 500;\n}\n.invoml-items td {\n padding: var(--invoml-table-row-padding) 0;\n border-bottom: 1px solid color-mix(in srgb, var(--invoml-color-border) 50%, transparent);\n font-size: 14px;\n color: var(--invoml-color-text);\n}\n.invoml-items .col-right { text-align: right; padding-right: 0; padding-left: 12px; }\n\n/* Totals */\n.invoml-totals { display: flex; justify-content: flex-end; margin-top: var(--invoml-totals-margin); }\n.invoml-totals-inner { width: 300px; }\n.invoml-totals-row {\n display: flex;\n justify-content: space-between;\n padding: 5px 0;\n font-size: 14px;\n color: var(--invoml-color-text);\n}\n.invoml-totals-row.is-grand {\n border-top: 2px solid var(--invoml-color-text);\n margin-top: 8px;\n padding-top: 10px;\n font-size: 15px;\n font-weight: 600;\n}\n.invoml-totals-row.is-amount-due {\n border-top: 1px solid var(--invoml-color-border);\n margin-top: 4px;\n padding-top: 8px;\n font-weight: 600;\n}\n.invoml-totals-label { color: var(--invoml-color-muted); }\n.invoml-totals-label.is-bold { color: var(--invoml-color-text); font-weight: 600; }\n.invoml-totals-amount { font-variant-numeric: tabular-nums; }\n\n/* Payment block */\n.invoml-payment {\n margin-top: var(--invoml-payment-margin-top);\n padding-top: 24px;\n border-top: 1px solid var(--invoml-color-border);\n}\n.invoml-payment-title {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 1.5px;\n color: var(--invoml-color-muted);\n margin-bottom: 12px;\n font-weight: 500;\n}\n.invoml-payment-details { font-size: 14px; line-height: var(--invoml-line-height); color: var(--invoml-color-muted); }\n.invoml-payment-details strong { color: var(--invoml-color-text); font-weight: 600; }\n\n/* Notes block */\n.invoml-notes {\n margin-top: var(--invoml-section-gap);\n padding-top: var(--invoml-section-gap);\n border-top: 1px solid var(--invoml-color-border);\n font-size: 13px;\n color: var(--invoml-color-muted);\n}\n\n/* Section block */\n.invoml-section { margin: var(--invoml-section-gap) 0; }\n.invoml-section-title {\n font-size: 13px;\n font-weight: 600;\n color: var(--invoml-color-text);\n margin-bottom: 8px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n.invoml-section-content { font-size: 14px; color: var(--invoml-color-text); line-height: var(--invoml-line-height); }\n";
3
+ /** Per-template CSS overrides. Applied when style.template is set. */
4
+ export declare const TEMPLATE_CSS: Record<string, string>;
@@ -0,0 +1,300 @@
1
+ /** Base CSS for all InvoML HTML output. Applied to every document regardless of template. */
2
+ export const BASE_CSS = `
3
+ /* InvoML base styles */
4
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
5
+
6
+ body { background: #f5f5f5; }
7
+
8
+ .invoml-container {
9
+ --invoml-color-accent: #2563eb;
10
+ --invoml-color-text: #1a1a1a;
11
+ --invoml-color-muted: #666666;
12
+ --invoml-color-border: #e0e0e0;
13
+ --invoml-color-background: #ffffff;
14
+ --invoml-font-heading: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
+ --invoml-font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
16
+ --invoml-padding-y: 10mm;
17
+ --invoml-padding-x: 12mm;
18
+ --invoml-section-gap: 18px;
19
+ --invoml-table-row-padding: 10px;
20
+ --invoml-parties-gap: 28px;
21
+ --invoml-payment-margin-top: 28px;
22
+ --invoml-meta-gap: 18px;
23
+ --invoml-totals-margin: 18px;
24
+ --invoml-line-height: 1.5;
25
+ --invoml-paragraph-spacing: 12px;
26
+
27
+ width: 210mm;
28
+ margin: 0 auto;
29
+ padding: var(--invoml-padding-y) var(--invoml-padding-x);
30
+ background: var(--invoml-color-background);
31
+ color: var(--invoml-color-text);
32
+ font-family: var(--invoml-font-body);
33
+ font-size: 14px;
34
+ line-height: var(--invoml-line-height);
35
+ -webkit-font-smoothing: antialiased;
36
+ }
37
+
38
+ .invoml-container h1, .invoml-container h2, .invoml-container h3,
39
+ .invoml-container h4, .invoml-container h5, .invoml-container h6 {
40
+ font-family: var(--invoml-font-heading);
41
+ color: var(--invoml-color-text);
42
+ line-height: 1.3;
43
+ margin: 0;
44
+ }
45
+
46
+ .invoml-container a { color: var(--invoml-color-accent); text-decoration: none; }
47
+ .invoml-container a:hover { text-decoration: underline; }
48
+ .invoml-container table { border-collapse: collapse; border-spacing: 0; width: 100%; }
49
+ .invoml-container th, .invoml-container td { text-align: left; vertical-align: top; }
50
+ .invoml-container ul, .invoml-container ol { padding-left: 1.5em; }
51
+ .invoml-container p + p { margin-top: var(--invoml-paragraph-spacing); }
52
+
53
+ /* Density variants */
54
+ .invoml-density-compact {
55
+ --invoml-padding-y: 5mm;
56
+ --invoml-padding-x: 6mm;
57
+ --invoml-section-gap: 6px;
58
+ --invoml-table-row-padding: 3px;
59
+ --invoml-parties-gap: 10px;
60
+ --invoml-payment-margin-top: 10px;
61
+ --invoml-meta-gap: 6px;
62
+ --invoml-totals-margin: 6px;
63
+ --invoml-line-height: 1.3;
64
+ --invoml-paragraph-spacing: 4px;
65
+ }
66
+ .invoml-density-spacious {
67
+ --invoml-padding-y: 20mm;
68
+ --invoml-padding-x: 24mm;
69
+ --invoml-section-gap: 36px;
70
+ --invoml-table-row-padding: 16px;
71
+ --invoml-parties-gap: 48px;
72
+ --invoml-payment-margin-top: 48px;
73
+ --invoml-meta-gap: 28px;
74
+ --invoml-totals-margin: 32px;
75
+ --invoml-line-height: 1.8;
76
+ --invoml-paragraph-spacing: 20px;
77
+ }
78
+
79
+ /* Header block */
80
+ .invoml-header { margin-bottom: var(--invoml-section-gap); }
81
+ .invoml-header-title {
82
+ font-size: 28px;
83
+ font-weight: 300;
84
+ letter-spacing: -0.5px;
85
+ color: var(--invoml-color-text);
86
+ font-family: var(--invoml-font-heading);
87
+ margin-bottom: 6px;
88
+ }
89
+ .invoml-header-number {
90
+ font-size: 13px;
91
+ font-weight: 600;
92
+ color: var(--invoml-color-accent);
93
+ letter-spacing: 0.5px;
94
+ margin-bottom: 14px;
95
+ display: inline-block;
96
+ background: color-mix(in srgb, var(--invoml-color-accent) 10%, transparent);
97
+ padding: 3px 10px;
98
+ border-radius: 4px;
99
+ }
100
+ .invoml-header-meta {
101
+ display: flex;
102
+ flex-wrap: wrap;
103
+ gap: var(--invoml-meta-gap);
104
+ font-size: 13px;
105
+ }
106
+ .invoml-header-meta-item { display: flex; flex-direction: column; gap: 2px; min-width: 90px; }
107
+ .invoml-header-meta-label {
108
+ font-size: 10px;
109
+ text-transform: uppercase;
110
+ letter-spacing: 1px;
111
+ color: var(--invoml-color-muted);
112
+ font-weight: 500;
113
+ }
114
+ .invoml-header-meta-value { color: var(--invoml-color-text); font-weight: 500; font-size: 13px; }
115
+
116
+ /* Party blocks */
117
+ .invoml-party {
118
+ font-size: 14px;
119
+ line-height: var(--invoml-line-height);
120
+ margin-bottom: var(--invoml-section-gap);
121
+ vertical-align: top;
122
+ }
123
+ .invoml-party-label {
124
+ font-size: 10px;
125
+ text-transform: uppercase;
126
+ letter-spacing: 1.5px;
127
+ color: var(--invoml-color-muted);
128
+ margin-bottom: 8px;
129
+ font-weight: 500;
130
+ }
131
+ .invoml-party-name {
132
+ font-weight: 600;
133
+ color: var(--invoml-color-text);
134
+ font-size: 15px;
135
+ margin-bottom: 6px;
136
+ }
137
+ .invoml-party-details { color: var(--invoml-color-muted); font-size: 13px; line-height: var(--invoml-line-height); }
138
+ .invoml-party-details > div { margin: 2px 0; }
139
+
140
+ /* Items table */
141
+ .invoml-items { margin: var(--invoml-totals-margin) 0; }
142
+ .invoml-items th {
143
+ padding: var(--invoml-table-row-padding) 0;
144
+ border-bottom: 1px solid var(--invoml-color-border);
145
+ font-size: 10px;
146
+ text-transform: uppercase;
147
+ letter-spacing: 1px;
148
+ color: var(--invoml-color-muted);
149
+ font-weight: 500;
150
+ }
151
+ .invoml-items td {
152
+ padding: var(--invoml-table-row-padding) 0;
153
+ border-bottom: 1px solid color-mix(in srgb, var(--invoml-color-border) 50%, transparent);
154
+ font-size: 14px;
155
+ color: var(--invoml-color-text);
156
+ }
157
+ .invoml-items .col-right { text-align: right; padding-right: 0; padding-left: 12px; }
158
+
159
+ /* Totals */
160
+ .invoml-totals { display: flex; justify-content: flex-end; margin-top: var(--invoml-totals-margin); }
161
+ .invoml-totals-inner { width: 300px; }
162
+ .invoml-totals-row {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ padding: 5px 0;
166
+ font-size: 14px;
167
+ color: var(--invoml-color-text);
168
+ }
169
+ .invoml-totals-row.is-grand {
170
+ border-top: 2px solid var(--invoml-color-text);
171
+ margin-top: 8px;
172
+ padding-top: 10px;
173
+ font-size: 15px;
174
+ font-weight: 600;
175
+ }
176
+ .invoml-totals-row.is-amount-due {
177
+ border-top: 1px solid var(--invoml-color-border);
178
+ margin-top: 4px;
179
+ padding-top: 8px;
180
+ font-weight: 600;
181
+ }
182
+ .invoml-totals-label { color: var(--invoml-color-muted); }
183
+ .invoml-totals-label.is-bold { color: var(--invoml-color-text); font-weight: 600; }
184
+ .invoml-totals-amount { font-variant-numeric: tabular-nums; }
185
+
186
+ /* Payment block */
187
+ .invoml-payment {
188
+ margin-top: var(--invoml-payment-margin-top);
189
+ padding-top: 24px;
190
+ border-top: 1px solid var(--invoml-color-border);
191
+ }
192
+ .invoml-payment-title {
193
+ font-size: 10px;
194
+ text-transform: uppercase;
195
+ letter-spacing: 1.5px;
196
+ color: var(--invoml-color-muted);
197
+ margin-bottom: 12px;
198
+ font-weight: 500;
199
+ }
200
+ .invoml-payment-details { font-size: 14px; line-height: var(--invoml-line-height); color: var(--invoml-color-muted); }
201
+ .invoml-payment-details strong { color: var(--invoml-color-text); font-weight: 600; }
202
+
203
+ /* Notes block */
204
+ .invoml-notes {
205
+ margin-top: var(--invoml-section-gap);
206
+ padding-top: var(--invoml-section-gap);
207
+ border-top: 1px solid var(--invoml-color-border);
208
+ font-size: 13px;
209
+ color: var(--invoml-color-muted);
210
+ }
211
+
212
+ /* Section block */
213
+ .invoml-section { margin: var(--invoml-section-gap) 0; }
214
+ .invoml-section-title {
215
+ font-size: 13px;
216
+ font-weight: 600;
217
+ color: var(--invoml-color-text);
218
+ margin-bottom: 8px;
219
+ text-transform: uppercase;
220
+ letter-spacing: 0.5px;
221
+ }
222
+ .invoml-section-content { font-size: 14px; color: var(--invoml-color-text); line-height: var(--invoml-line-height); }
223
+ `;
224
+ /** Per-template CSS overrides. Applied when style.template is set. */
225
+ export const TEMPLATE_CSS = {
226
+ professional: `
227
+ /* Template: professional */
228
+ .invoml-container[data-invoml-template="professional"] {
229
+ --invoml-color-accent: #2563eb;
230
+ --invoml-color-text: #1a1a1a;
231
+ --invoml-color-muted: #666666;
232
+ --invoml-color-border: #e0e0e0;
233
+ }
234
+ .invoml-container[data-invoml-template="professional"] .invoml-party {
235
+ display: inline-block;
236
+ width: 50%;
237
+ }
238
+ `,
239
+ minimal: `
240
+ /* Template: minimal */
241
+ .invoml-container[data-invoml-template="minimal"] {
242
+ --invoml-color-accent: #555555;
243
+ --invoml-color-text: #222222;
244
+ --invoml-color-muted: #888888;
245
+ --invoml-color-border: #eeeeee;
246
+ --invoml-padding-y: 20mm;
247
+ --invoml-padding-x: 24mm;
248
+ --invoml-section-gap: 36px;
249
+ --invoml-table-row-padding: 16px;
250
+ --invoml-parties-gap: 48px;
251
+ --invoml-payment-margin-top: 48px;
252
+ --invoml-meta-gap: 28px;
253
+ --invoml-totals-margin: 32px;
254
+ --invoml-line-height: 1.8;
255
+ --invoml-paragraph-spacing: 20px;
256
+ }
257
+ .invoml-container[data-invoml-template="minimal"] .invoml-header-number {
258
+ background: none;
259
+ padding: 0;
260
+ letter-spacing: 1px;
261
+ }
262
+ .invoml-container[data-invoml-template="minimal"] .invoml-party {
263
+ display: inline-block;
264
+ width: 50%;
265
+ }
266
+ `,
267
+ modern: `
268
+ /* Template: modern */
269
+ .invoml-container[data-invoml-template="modern"] {
270
+ --invoml-color-accent: #e63946;
271
+ --invoml-color-text: #111111;
272
+ --invoml-color-muted: #555555;
273
+ --invoml-color-border: #cccccc;
274
+ --invoml-padding-y: 5mm;
275
+ --invoml-padding-x: 6mm;
276
+ --invoml-section-gap: 6px;
277
+ --invoml-table-row-padding: 3px;
278
+ --invoml-parties-gap: 10px;
279
+ --invoml-payment-margin-top: 10px;
280
+ --invoml-meta-gap: 6px;
281
+ --invoml-totals-margin: 6px;
282
+ --invoml-line-height: 1.3;
283
+ --invoml-paragraph-spacing: 4px;
284
+ }
285
+ .invoml-container[data-invoml-template="modern"] .invoml-header-title {
286
+ font-size: 36px;
287
+ font-weight: 700;
288
+ letter-spacing: -2px;
289
+ }
290
+ .invoml-container[data-invoml-template="modern"] .invoml-header {
291
+ border-bottom: 3px solid var(--invoml-color-accent);
292
+ padding-bottom: 12px;
293
+ margin-bottom: 12px;
294
+ }
295
+ .invoml-container[data-invoml-template="modern"] .invoml-party {
296
+ display: inline-block;
297
+ width: 50%;
298
+ }
299
+ `,
300
+ };
@@ -0,0 +1,5 @@
1
+ import type { InvoMLDocument } from './types.js';
2
+ /** Render an InvoML document (with computed totals in doc.totals) as a
3
+ * self-contained HTML string suitable for browser display, iframe embedding,
4
+ * or headless PDF generation. */
5
+ export declare function toHTML(doc: InvoMLDocument): string;