@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,181 @@
1
+ // src/serializer.ts
2
+ import { resolveOrder } from './style.js';
3
+ import { getCurrencyDecimals } from './rounding.js';
4
+ import { fmtNum } from './format.js';
5
+ /** Serialize an InvoML document to a JSON string. Defaults to pretty-printed; pass `{ compact: true }` for minified output. */
6
+ export function toJSON(doc, options = {}) {
7
+ return JSON.stringify(doc, null, options.compact ? undefined : 2);
8
+ }
9
+ /** Render an InvoML document (with computed totals in `doc.totals`) as a human-readable Markdown table. Follows the document's style.order or falls back to the default order. */
10
+ export function toMarkdown(doc) {
11
+ const lines = [];
12
+ const currency = doc.meta.currency;
13
+ const dp = getCurrencyDecimals(currency);
14
+ const fmt = (n) => fmtNum(n, dp);
15
+ const order = resolveOrder(doc);
16
+ for (const block of order) {
17
+ renderBlock(block, doc, lines, currency, fmt);
18
+ }
19
+ return lines.join('\n');
20
+ }
21
+ function renderBlock(block, doc, lines, currency, fmt) {
22
+ if (block === 'header') {
23
+ const type = doc.meta.documentType.replaceAll('_', ' ').toUpperCase();
24
+ lines.push(`# ${type} ${doc.meta.number}`);
25
+ lines.push('');
26
+ lines.push(`**Date:** ${doc.meta.issueDate}`);
27
+ if (doc.meta.dueDate)
28
+ lines.push(`**Due:** ${doc.meta.dueDate}`);
29
+ lines.push(`**Currency:** ${currency}`);
30
+ if (doc.meta.reference)
31
+ lines.push(`**Reference:** ${doc.meta.reference}`);
32
+ lines.push('');
33
+ }
34
+ else if (block === 'from') {
35
+ renderParty('From', doc.from, lines);
36
+ }
37
+ else if (block === 'to') {
38
+ renderParty('To', doc.to, lines);
39
+ }
40
+ else if (block === 'items') {
41
+ renderItems(doc, lines, fmt);
42
+ }
43
+ else if (block === 'totals') {
44
+ renderTotals(doc.totals, lines, currency, fmt);
45
+ }
46
+ else if (block === 'payment') {
47
+ renderPayment(doc, lines);
48
+ }
49
+ else if (block === 'notes') {
50
+ if (doc.notes) {
51
+ lines.push('---');
52
+ lines.push('');
53
+ lines.push(`*${doc.notes}*`);
54
+ lines.push('');
55
+ }
56
+ }
57
+ else if (block.startsWith('section:')) {
58
+ const key = block.slice('section:'.length);
59
+ const section = doc.sections?.[key];
60
+ if (section) {
61
+ lines.push(`### ${section.title}`);
62
+ lines.push('');
63
+ lines.push(section.content);
64
+ lines.push('');
65
+ }
66
+ }
67
+ }
68
+ function renderParty(label, party, lines) {
69
+ if (!party)
70
+ return;
71
+ lines.push(`**${label}:**`);
72
+ if (party.content) {
73
+ lines.push(party.content);
74
+ }
75
+ else {
76
+ if (party.name)
77
+ lines.push(party.name);
78
+ if (party.address)
79
+ lines.push(party.address);
80
+ if (party.attention)
81
+ lines.push(`Attn: ${party.attention}`);
82
+ if (party.email)
83
+ lines.push(party.email);
84
+ if (party.phone)
85
+ lines.push(party.phone);
86
+ if (party.website)
87
+ lines.push(party.website);
88
+ if (party.taxId)
89
+ lines.push(`Tax ID: ${party.taxId}`);
90
+ if (party.businessNumber)
91
+ lines.push(`Business No: ${party.businessNumber}`);
92
+ }
93
+ lines.push('');
94
+ }
95
+ function formatDiscount(discount) {
96
+ if (typeof discount === 'string')
97
+ return discount;
98
+ if (discount.type === 'percentage')
99
+ return `${discount.value}%`;
100
+ return String(discount.value);
101
+ }
102
+ function renderItems(doc, lines, fmt) {
103
+ const hasUnit = doc.items.some(i => i.unit);
104
+ const hasDiscount = doc.items.some(i => i.discount);
105
+ const cols = ['Description', 'Qty'];
106
+ if (hasUnit)
107
+ cols.push('Unit');
108
+ cols.push('Unit Price');
109
+ if (hasDiscount)
110
+ cols.push('Discount');
111
+ cols.push('Amount');
112
+ lines.push('| ' + cols.join(' | ') + ' |');
113
+ lines.push('| ' + cols.map(() => '---').join(' | ') + ' |');
114
+ for (const item of doc.items) {
115
+ const row = [item.description, String(item.quantity)];
116
+ if (hasUnit)
117
+ row.push(item.unit ?? '');
118
+ row.push(fmt(item.unitPrice));
119
+ if (hasDiscount)
120
+ row.push(item.discount ? formatDiscount(item.discount) : '');
121
+ row.push(fmt(item.amount ?? item.quantity * item.unitPrice));
122
+ lines.push('| ' + row.join(' | ') + ' |');
123
+ }
124
+ lines.push('');
125
+ }
126
+ function renderTotals(totals, lines, currency, fmt) {
127
+ if (!totals)
128
+ return;
129
+ lines.push('| | |');
130
+ lines.push('| ---: | ---: |');
131
+ lines.push(`| **Subtotal** | ${fmt(totals.subtotal)} |`);
132
+ if (totals.discountDetails) {
133
+ for (const d of totals.discountDetails) {
134
+ lines.push(`| ${d.label ?? 'Discount'} | -${fmt(d.amount)} |`);
135
+ }
136
+ lines.push(`| After Discounts | ${fmt(totals.afterDiscounts)} |`);
137
+ }
138
+ if (totals.taxDetails) {
139
+ for (const t of totals.taxDetails) {
140
+ const suffix = t.inclusive ? ' (included)' : '';
141
+ lines.push(`| ${t.label ?? t.category}${suffix} | ${fmt(t.amount)} |`);
142
+ }
143
+ }
144
+ if (totals.withholdingTotal && totals.withholdingTotal > 0) {
145
+ lines.push(`| Withholding | -${fmt(totals.withholdingTotal)} |`);
146
+ }
147
+ lines.push(`| **Total (${currency})** | **${fmt(totals.total)}** |`);
148
+ if (totals.prepaidAmount && totals.prepaidAmount > 0) {
149
+ lines.push(`| Prepaid | -${fmt(totals.prepaidAmount)} |`);
150
+ lines.push(`| **Amount Due** | **${fmt(totals.amountDue)}** |`);
151
+ }
152
+ lines.push('');
153
+ }
154
+ function renderPayment(doc, lines) {
155
+ if (!doc.payment)
156
+ return;
157
+ lines.push('### Payment');
158
+ lines.push('');
159
+ if (doc.payment.content) {
160
+ lines.push(doc.payment.content);
161
+ }
162
+ else {
163
+ if (doc.payment.beneficiary)
164
+ lines.push(`**Beneficiary:** ${doc.payment.beneficiary}`);
165
+ if (doc.payment.bank)
166
+ lines.push(`**Bank:** ${doc.payment.bank}`);
167
+ if (doc.payment.iban)
168
+ lines.push(`**IBAN:** ${doc.payment.iban}`);
169
+ if (doc.payment.swift)
170
+ lines.push(`**SWIFT:** ${doc.payment.swift}`);
171
+ if (doc.payment.routingNumber)
172
+ lines.push(`**Routing:** ${doc.payment.routingNumber}`);
173
+ if (doc.payment.accountNumber)
174
+ lines.push(`**Account:** ${doc.payment.accountNumber}`);
175
+ if (doc.payment.cryptoAddress)
176
+ lines.push(`**Address:** ${doc.payment.cryptoAddress}`);
177
+ if (doc.payment.cryptoNetwork)
178
+ lines.push(`**Network:** ${doc.payment.cryptoNetwork}`);
179
+ }
180
+ lines.push('');
181
+ }
@@ -0,0 +1,21 @@
1
+ import type { InvoMLDocument, InvoMLStyle } from './types.js';
2
+ export declare const RESERVED_BLOCK_NAMES: readonly ["header", "from", "to", "items", "totals", "payment", "notes"];
3
+ export declare const DEFAULT_ORDER: string[];
4
+ export interface StyleValidationResult {
5
+ valid: boolean;
6
+ errors: string[];
7
+ warnings: string[];
8
+ }
9
+ /** Resolve the effective block rendering order for a document.
10
+ * If style.order is present, deduplicate it and return the result.
11
+ * Otherwise build the canonical default order, inserting custom sections (sorted alphabetically)
12
+ * after totals and before payment. */
13
+ export declare function resolveOrder(doc: InvoMLDocument): string[];
14
+ /** Validate a style object against the normative style rules. */
15
+ export declare function validateStyle(style: InvoMLStyle, sectionNames?: string[]): StyleValidationResult;
16
+ /** Resolve the full style object with defaults applied. */
17
+ export declare function resolveStyle(doc: InvoMLDocument): {
18
+ order: string[];
19
+ properties: Record<string, string>;
20
+ blocks: Record<string, Record<string, string>>;
21
+ };
@@ -0,0 +1,70 @@
1
+ // src/style.ts
2
+ export const RESERVED_BLOCK_NAMES = ['header', 'from', 'to', 'items', 'totals', 'payment', 'notes'];
3
+ export const DEFAULT_ORDER = ['header', 'from', 'to', 'items', 'totals', 'payment', 'notes'];
4
+ /** Resolve the effective block rendering order for a document.
5
+ * If style.order is present, deduplicate it and return the result.
6
+ * Otherwise build the canonical default order, inserting custom sections (sorted alphabetically)
7
+ * after totals and before payment. */
8
+ export function resolveOrder(doc) {
9
+ if (doc.style?.order) {
10
+ // Deduplicate: each block name renders at most once (first occurrence wins)
11
+ const seen = new Set();
12
+ return doc.style.order.filter(b => {
13
+ if (seen.has(b))
14
+ return false;
15
+ seen.add(b);
16
+ return true;
17
+ });
18
+ }
19
+ const sectionNames = doc.sections ? Object.keys(doc.sections).sort() : [];
20
+ if (sectionNames.length === 0)
21
+ return DEFAULT_ORDER;
22
+ const result = [];
23
+ for (const block of DEFAULT_ORDER) {
24
+ result.push(block);
25
+ if (block === 'totals') {
26
+ for (const name of sectionNames) {
27
+ result.push(`section:${name}`);
28
+ }
29
+ }
30
+ }
31
+ return result;
32
+ }
33
+ /** Validate a style object against the normative style rules. */
34
+ export function validateStyle(style, sectionNames) {
35
+ const errors = [];
36
+ const warnings = [];
37
+ if (style.order !== undefined) {
38
+ if (style.order.length === 0) {
39
+ errors.push('style.order must contain at least one entry');
40
+ }
41
+ else if (sectionNames !== undefined) {
42
+ for (const entry of style.order) {
43
+ if (entry.startsWith('section:')) {
44
+ const key = entry.slice('section:'.length);
45
+ if (!sectionNames.includes(key)) {
46
+ errors.push(`style.order references unknown section "${key}"`);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+ if (style.blocks) {
53
+ for (const key of Object.keys(style.blocks)) {
54
+ const isReserved = RESERVED_BLOCK_NAMES.includes(key);
55
+ const isSectionPattern = /^section:[a-zA-Z0-9_-]+$/.test(key);
56
+ if (!isReserved && !isSectionPattern) {
57
+ warnings.push(`style.blocks key "${key}" is not a reserved block name or section:<key> pattern`);
58
+ }
59
+ }
60
+ }
61
+ return { valid: errors.length === 0, errors, warnings };
62
+ }
63
+ /** Resolve the full style object with defaults applied. */
64
+ export function resolveStyle(doc) {
65
+ return {
66
+ order: resolveOrder(doc),
67
+ properties: doc.style?.properties ?? {},
68
+ blocks: doc.style?.blocks ?? {},
69
+ };
70
+ }
@@ -0,0 +1,3 @@
1
+ import type { InvoMLTaxSimple, InvoMLTaxFull, InvoMLTaxCategory, InvoMLItem, ResolvedTaxConfig } from './types.js';
2
+ export declare function resolveTaxConfig(tax: InvoMLTaxSimple | InvoMLTaxFull | undefined): ResolvedTaxConfig | null;
3
+ export declare function resolveCategory(item: InvoMLItem, config: ResolvedTaxConfig): InvoMLTaxCategory;
@@ -0,0 +1,40 @@
1
+ import { CalculationError } from './types.js';
2
+ function isTaxFull(tax) {
3
+ return 'categories' in tax;
4
+ }
5
+ export function resolveTaxConfig(tax) {
6
+ if (!tax)
7
+ return null;
8
+ if (isTaxFull(tax)) {
9
+ return {
10
+ system: tax.system ?? 'vat',
11
+ categories: tax.categories,
12
+ compound: tax.compound ?? false,
13
+ inclusive: tax.inclusive ?? false,
14
+ };
15
+ }
16
+ const implicitId = tax.label.toLowerCase().replace(/ /g, '-');
17
+ return {
18
+ system: 'vat',
19
+ categories: [{
20
+ id: implicitId, label: tax.label, rate: tax.rate,
21
+ default: true, exempt: false, reverseCharge: false,
22
+ withholding: false, inclusive: tax.inclusive ?? false,
23
+ }],
24
+ compound: false,
25
+ inclusive: tax.inclusive ?? false,
26
+ };
27
+ }
28
+ export function resolveCategory(item, config) {
29
+ if (item.taxCategory) {
30
+ const match = config.categories.find(c => c.id === item.taxCategory);
31
+ if (!match)
32
+ throw new CalculationError('UNKNOWN_CATEGORY', `Unknown tax category '${item.taxCategory}'`);
33
+ return match;
34
+ }
35
+ const defaults = config.categories.filter(c => c.default);
36
+ if (defaults.length === 0) {
37
+ throw new CalculationError('NO_DEFAULT_CATEGORY', `Item '${item.description}' has no taxCategory and no default category is defined`);
38
+ }
39
+ return defaults[0];
40
+ }
@@ -0,0 +1,132 @@
1
+ export interface InvoMLDocument {
2
+ $invoml: '1.0';
3
+ meta: InvoMLMeta;
4
+ from?: InvoMLParty;
5
+ to?: InvoMLParty;
6
+ items: InvoMLItem[];
7
+ discounts?: InvoMLDiscount[];
8
+ payment?: InvoMLPayment;
9
+ sections?: Record<string, InvoMLSection>;
10
+ notes?: string;
11
+ totals?: InvoMLTotals;
12
+ style?: InvoMLStyle;
13
+ }
14
+ export interface InvoMLStyle {
15
+ template?: string;
16
+ order?: string[];
17
+ properties?: Record<string, string>;
18
+ blocks?: Record<string, Record<string, string>>;
19
+ }
20
+ export interface InvoMLMeta {
21
+ documentType: 'invoice' | 'quote' | 'credit_note' | 'receipt';
22
+ number: string;
23
+ issueDate: string;
24
+ currency: string;
25
+ dueDate?: string;
26
+ expiryDate?: string;
27
+ locale?: string;
28
+ reference?: string;
29
+ creditNoteReference?: string;
30
+ tax?: InvoMLTaxSimple | InvoMLTaxFull;
31
+ }
32
+ export interface InvoMLTaxSimple {
33
+ label: string;
34
+ rate: number;
35
+ inclusive?: boolean;
36
+ }
37
+ export interface InvoMLTaxFull {
38
+ system?: string;
39
+ compound?: boolean;
40
+ inclusive?: boolean;
41
+ categories: InvoMLTaxCategory[];
42
+ }
43
+ export interface InvoMLTaxCategory {
44
+ id: string;
45
+ label: string;
46
+ rate: number;
47
+ default?: boolean;
48
+ exempt?: boolean;
49
+ reverseCharge?: boolean;
50
+ withholding?: boolean;
51
+ inclusive?: boolean;
52
+ }
53
+ export interface InvoMLItem {
54
+ description: string;
55
+ quantity: number;
56
+ unitPrice: number;
57
+ unit?: string;
58
+ discount?: string | InvoMLDiscount;
59
+ taxCategory?: string;
60
+ amount?: number;
61
+ taxAmount?: number;
62
+ }
63
+ export interface InvoMLDiscount {
64
+ type: 'percentage' | 'fixed';
65
+ value: number;
66
+ label?: string;
67
+ }
68
+ export interface InvoMLParty {
69
+ content?: string;
70
+ name?: string;
71
+ address?: string;
72
+ taxId?: string;
73
+ email?: string;
74
+ phone?: string;
75
+ website?: string;
76
+ businessNumber?: string;
77
+ attention?: string;
78
+ countryCode?: string;
79
+ }
80
+ export interface InvoMLPayment {
81
+ title?: string;
82
+ content?: string;
83
+ method?: 'bank-international' | 'bank-domestic' | 'crypto' | 'card' | 'other';
84
+ beneficiary?: string;
85
+ bank?: string;
86
+ iban?: string;
87
+ swift?: string;
88
+ routingNumber?: string;
89
+ accountNumber?: string;
90
+ cryptoAddress?: string;
91
+ cryptoNetwork?: string;
92
+ }
93
+ export interface InvoMLSection {
94
+ title: string;
95
+ content: string;
96
+ }
97
+ export interface InvoMLTotals {
98
+ subtotal: number;
99
+ discountDetails?: {
100
+ label?: string;
101
+ amount: number;
102
+ }[];
103
+ afterDiscounts: number;
104
+ taxDetails?: InvoMLTaxDetail[];
105
+ taxTotal: number;
106
+ withholdingTotal?: number;
107
+ total: number;
108
+ prepaidAmount?: number;
109
+ amountDue: number;
110
+ currencySymbol?: string;
111
+ locale?: string;
112
+ }
113
+ export interface InvoMLTaxDetail {
114
+ category: string;
115
+ label?: string;
116
+ rate?: number;
117
+ base: number;
118
+ amount: number;
119
+ inclusive?: boolean;
120
+ withholding?: boolean;
121
+ }
122
+ /** Error class for calculation errors defined in SPEC Section 8.2. Includes a machine-readable `code` property. */
123
+ export declare class CalculationError extends Error {
124
+ readonly code: string;
125
+ constructor(code: string, message: string);
126
+ }
127
+ export interface ResolvedTaxConfig {
128
+ system: string;
129
+ categories: InvoMLTaxCategory[];
130
+ compound: boolean;
131
+ inclusive: boolean;
132
+ }
@@ -0,0 +1,10 @@
1
+ // src/types.ts
2
+ /** Error class for calculation errors defined in SPEC Section 8.2. Includes a machine-readable `code` property. */
3
+ export class CalculationError extends Error {
4
+ code;
5
+ constructor(code, message) {
6
+ super(message);
7
+ this.code = code;
8
+ this.name = 'CalculationError';
9
+ }
10
+ }