@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.
- package/LICENSE +190 -0
- package/README.md +320 -0
- package/dist/cli/invoml.d.ts +2 -0
- package/dist/cli/invoml.js +78 -0
- package/dist/src/calculator.d.ts +3 -0
- package/dist/src/calculator.js +164 -0
- package/dist/src/discounts.d.ts +6 -0
- package/dist/src/discounts.js +46 -0
- package/dist/src/format.d.ts +3 -0
- package/dist/src/format.js +14 -0
- package/dist/src/html-css.d.ts +4 -0
- package/dist/src/html-css.js +300 -0
- package/dist/src/html-renderer.d.ts +5 -0
- package/dist/src/html-renderer.js +365 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.js +8 -0
- package/dist/src/markdown.d.ts +8 -0
- package/dist/src/markdown.js +89 -0
- package/dist/src/parser.d.ts +10 -0
- package/dist/src/parser.js +16 -0
- package/dist/src/rounding.d.ts +5 -0
- package/dist/src/rounding.js +24 -0
- package/dist/src/schema.d.ts +6 -0
- package/dist/src/schema.js +33 -0
- package/dist/src/serializer.d.ts +8 -0
- package/dist/src/serializer.js +181 -0
- package/dist/src/style.d.ts +21 -0
- package/dist/src/style.js +70 -0
- package/dist/src/tax.d.ts +3 -0
- package/dist/src/tax.js +40 -0
- package/dist/src/types.d.ts +132 -0
- package/dist/src/types.js +10 -0
- package/invoml-v1.0.schema.json +258 -0
- package/package.json +65 -0
|
@@ -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,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;
|