@open-nav/invoicing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +29 -0
- package/README.md +190 -0
- package/dist/color.d.ts +31 -0
- package/dist/color.d.ts.map +1 -0
- package/dist/color.js +150 -0
- package/dist/color.js.map +1 -0
- package/dist/export.d.ts +89 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +115 -0
- package/dist/export.js.map +1 -0
- package/dist/format.d.ts +23 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +52 -0
- package/dist/format.js.map +1 -0
- package/dist/html.d.ts +43 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +355 -0
- package/dist/html.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/labels.d.ts +8 -0
- package/dist/labels.d.ts.map +1 -0
- package/dist/labels.js +199 -0
- package/dist/labels.js.map +1 -0
- package/dist/markings.d.ts +28 -0
- package/dist/markings.d.ts.map +1 -0
- package/dist/markings.js +69 -0
- package/dist/markings.js.map +1 -0
- package/dist/pdf-native.d.ts +32 -0
- package/dist/pdf-native.d.ts.map +1 -0
- package/dist/pdf-native.js +601 -0
- package/dist/pdf-native.js.map +1 -0
- package/dist/pdf.d.ts +92 -0
- package/dist/pdf.d.ts.map +1 -0
- package/dist/pdf.js +203 -0
- package/dist/pdf.js.map +1 -0
- package/dist/theme.d.ts +85 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +210 -0
- package/dist/theme.js.map +1 -0
- package/package.json +54 -0
- package/src/color.ts +170 -0
- package/src/export.ts +204 -0
- package/src/format.ts +66 -0
- package/src/html.ts +446 -0
- package/src/index.ts +9 -0
- package/src/labels.ts +222 -0
- package/src/markings.ts +102 -0
- package/src/pdf-native.ts +754 -0
- package/src/pdf.ts +302 -0
- package/src/theme.ts +305 -0
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-nav/invoicing",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render Hungarian invoices as printable documents, and produce the tax authority data export",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nav",
|
|
7
|
+
"onlineszamla",
|
|
8
|
+
"hungary",
|
|
9
|
+
"invoice",
|
|
10
|
+
"szamla",
|
|
11
|
+
"adatexport",
|
|
12
|
+
"pdf"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "open-nav contributors",
|
|
16
|
+
"homepage": "https://github.com/eshton/open-nav#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/eshton/open-nav.git",
|
|
20
|
+
"directory": "packages/invoicing"
|
|
21
|
+
},
|
|
22
|
+
"bugs": "https://github.com/eshton/open-nav/issues",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20.10.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"pdfmake": "^0.3.11",
|
|
49
|
+
"@open-nav/core": "^0.1.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -b"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/color.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour conversion for the PDF engine.
|
|
3
|
+
*
|
|
4
|
+
* A browser accepts every CSS colour form; PDF does not — pdfkit understands
|
|
5
|
+
* hex and the CSS colour keywords. Converting here means a theme behaves the
|
|
6
|
+
* same whichever engine renders it, rather than silently losing a colour in
|
|
7
|
+
* one of them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** CSS colour keywords, kept to the ones a document is plausibly styled with. */
|
|
11
|
+
const KEYWORDS: Record<string, string> = {
|
|
12
|
+
black: '#000000',
|
|
13
|
+
white: '#ffffff',
|
|
14
|
+
red: '#ff0000',
|
|
15
|
+
green: '#008000',
|
|
16
|
+
blue: '#0000ff',
|
|
17
|
+
navy: '#000080',
|
|
18
|
+
teal: '#008080',
|
|
19
|
+
olive: '#808000',
|
|
20
|
+
purple: '#800080',
|
|
21
|
+
maroon: '#800000',
|
|
22
|
+
gray: '#808080',
|
|
23
|
+
grey: '#808080',
|
|
24
|
+
silver: '#c0c0c0',
|
|
25
|
+
orange: '#ffa500',
|
|
26
|
+
yellow: '#ffff00',
|
|
27
|
+
rebeccapurple: '#663399',
|
|
28
|
+
transparent: '#ffffff',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert any accepted theme colour to `#rrggbb`.
|
|
33
|
+
*
|
|
34
|
+
* Alpha is dropped: PDF has no per-colour alpha in this position, and a
|
|
35
|
+
* silently half-transparent rule is worse than an opaque one.
|
|
36
|
+
*/
|
|
37
|
+
export function toHexColor(value: string, fallback = '#000000'): string {
|
|
38
|
+
const input = value.trim().toLowerCase();
|
|
39
|
+
|
|
40
|
+
const keyword = KEYWORDS[input];
|
|
41
|
+
if (keyword) return keyword;
|
|
42
|
+
|
|
43
|
+
if (input.startsWith('#')) {
|
|
44
|
+
const digits = input.slice(1);
|
|
45
|
+
if (digits.length === 3 || digits.length === 4) {
|
|
46
|
+
const [r, g, b] = [...digits.slice(0, 3)];
|
|
47
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
48
|
+
}
|
|
49
|
+
if (digits.length === 6 || digits.length === 8) return `#${digits.slice(0, 6)}`;
|
|
50
|
+
return fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rgb = /^rgba?\(([^)]+)\)$/.exec(input);
|
|
54
|
+
if (rgb) {
|
|
55
|
+
const parts = splitComponents(rgb[1]!);
|
|
56
|
+
if (parts.length < 3) return fallback;
|
|
57
|
+
return hex(channel(parts[0]!), channel(parts[1]!), channel(parts[2]!));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hsl = /^hsla?\(([^)]+)\)$/.exec(input);
|
|
61
|
+
if (hsl) {
|
|
62
|
+
const parts = splitComponents(hsl[1]!);
|
|
63
|
+
if (parts.length < 3) return fallback;
|
|
64
|
+
return hslToHex(angle(parts[0]!), percent(parts[1]!), percent(parts[2]!));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function splitComponents(body: string): string[] {
|
|
71
|
+
return body
|
|
72
|
+
.split(/[,\s/]+/)
|
|
73
|
+
.map((part) => part.trim())
|
|
74
|
+
.filter((part) => part !== '');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An rgb channel: 0-255, or a percentage. */
|
|
78
|
+
function channel(part: string): number {
|
|
79
|
+
const value = part.endsWith('%')
|
|
80
|
+
? (Number.parseFloat(part) / 100) * 255
|
|
81
|
+
: Number.parseFloat(part);
|
|
82
|
+
return clamp(Math.round(value), 0, 255);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function angle(part: string): number {
|
|
86
|
+
const value = Number.parseFloat(part);
|
|
87
|
+
return Number.isFinite(value) ? ((value % 360) + 360) % 360 : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function percent(part: string): number {
|
|
91
|
+
const value = Number.parseFloat(part);
|
|
92
|
+
return clamp(Number.isFinite(value) ? value / 100 : 0, 0, 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clamp(value: number, min: number, max: number): number {
|
|
96
|
+
return Math.min(Math.max(value, min), max);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hex(r: number, g: number, b: number): string {
|
|
100
|
+
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hslToHex(hue: number, saturation: number, lightness: number): string {
|
|
104
|
+
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
|
105
|
+
const sector = hue / 60;
|
|
106
|
+
const second = chroma * (1 - Math.abs((sector % 2) - 1));
|
|
107
|
+
const [r, g, b] =
|
|
108
|
+
sector < 1
|
|
109
|
+
? [chroma, second, 0]
|
|
110
|
+
: sector < 2
|
|
111
|
+
? [second, chroma, 0]
|
|
112
|
+
: sector < 3
|
|
113
|
+
? [0, chroma, second]
|
|
114
|
+
: sector < 4
|
|
115
|
+
? [0, second, chroma]
|
|
116
|
+
: sector < 5
|
|
117
|
+
? [second, 0, chroma]
|
|
118
|
+
: [chroma, 0, second];
|
|
119
|
+
const offset = lightness - chroma / 2;
|
|
120
|
+
return hex(
|
|
121
|
+
Math.round((r + offset) * 255),
|
|
122
|
+
Math.round((g + offset) * 255),
|
|
123
|
+
Math.round((b + offset) * 255),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Units CSS uses that convert to PDF points without a layout context. */
|
|
128
|
+
const UNIT_TO_POINTS: Record<string, number> = {
|
|
129
|
+
pt: 1,
|
|
130
|
+
px: 0.75,
|
|
131
|
+
pc: 12,
|
|
132
|
+
mm: 72 / 25.4,
|
|
133
|
+
cm: 72 / 2.54,
|
|
134
|
+
in: 72,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convert a CSS length to PDF points.
|
|
139
|
+
*
|
|
140
|
+
* `em`, `rem` and `%` are relative to a layout that does not exist here, so
|
|
141
|
+
* they fall back rather than being guessed at.
|
|
142
|
+
*/
|
|
143
|
+
export function toPoints(value: string, fallback: number): number {
|
|
144
|
+
const match = /^([0-9.]+)([a-z%]*)$/.exec(value.trim());
|
|
145
|
+
if (!match) return fallback;
|
|
146
|
+
const size = Number.parseFloat(match[1]!);
|
|
147
|
+
if (!Number.isFinite(size)) return fallback;
|
|
148
|
+
const unit = match[2] === '' ? 'pt' : match[2]!;
|
|
149
|
+
const factor = UNIT_TO_POINTS[unit];
|
|
150
|
+
return factor === undefined ? fallback : size * factor;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Expand a CSS margin shorthand into PDF's `[left, top, right, bottom]`.
|
|
155
|
+
*
|
|
156
|
+
* CSS orders shorthand clockwise from the top; pdfmake starts at the left.
|
|
157
|
+
* Getting that wrong swaps the margins on a non-square page, which is exactly
|
|
158
|
+
* the kind of error nobody notices until something prints.
|
|
159
|
+
*/
|
|
160
|
+
export function toMargins(value: string, fallback: number): [number, number, number, number] {
|
|
161
|
+
const parts = value.trim().split(/\s+/).filter(Boolean);
|
|
162
|
+
const points = parts.map((part) => toPoints(part, fallback));
|
|
163
|
+
const [
|
|
164
|
+
top = fallback,
|
|
165
|
+
right = points[0] ?? fallback,
|
|
166
|
+
bottom = points[0] ?? fallback,
|
|
167
|
+
left = points[1] ?? points[0] ?? fallback,
|
|
168
|
+
] = points;
|
|
169
|
+
return [left, top, right, bottom];
|
|
170
|
+
}
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { serializeDocument, type InvoiceData } from '@open-nav/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The data export an invoicing program must be able to produce.
|
|
6
|
+
*
|
|
7
|
+
* Decree 23/2014. (VI. 30.) NGM requires every invoicing program to have a
|
|
8
|
+
* built-in "adóhatósági ellenőrzési adatszolgáltatás" function that exports
|
|
9
|
+
* the invoices issued in a given date range, or in a given invoice number
|
|
10
|
+
* range, as XML.
|
|
11
|
+
*
|
|
12
|
+
* Two structures are permitted. Annexes 2 and 3 of the decree define one
|
|
13
|
+
* (`szamla.xsd`); section 13/A(1) permits the other — the structure published
|
|
14
|
+
* for the online invoice data service, which is `invoiceData.xsd`. This
|
|
15
|
+
* implementation produces the second, because it is the same schema this
|
|
16
|
+
* project already generates, validates and round-trips against NAV's own
|
|
17
|
+
* sample documents.
|
|
18
|
+
*
|
|
19
|
+
* That choice belongs to the taxpayer, not to this library. If your auditor
|
|
20
|
+
* asks for the Annex 3 structure specifically, this export is not it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface DataExportSelection {
|
|
24
|
+
/** First issue date to include, inclusive, as `yyyy-mm-dd`. */
|
|
25
|
+
issueDateFrom?: string;
|
|
26
|
+
/** Last issue date to include, inclusive. */
|
|
27
|
+
issueDateTo?: string;
|
|
28
|
+
/** First invoice number to include, inclusive. */
|
|
29
|
+
invoiceNumberFrom?: string;
|
|
30
|
+
/** Last invoice number to include, inclusive. */
|
|
31
|
+
invoiceNumberTo?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DataExportOptions extends DataExportSelection {
|
|
35
|
+
/**
|
|
36
|
+
* How invoice numbers are ordered when a number range is given.
|
|
37
|
+
*
|
|
38
|
+
* The default compares digit runs numerically, so `2024/9` sorts before
|
|
39
|
+
* `2024/10`. Plain text ordering puts them the other way round and would
|
|
40
|
+
* silently drop invoices from the middle of a requested range — which is
|
|
41
|
+
* the kind of mistake an audit is precisely designed to find.
|
|
42
|
+
*/
|
|
43
|
+
compareInvoiceNumbers?: (left: string, right: string) => number;
|
|
44
|
+
/** Injectable clock, so an export is reproducible in tests. */
|
|
45
|
+
now?: () => Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ExportedFile {
|
|
49
|
+
/** Path within the export, e.g. `invoices/2024-000123.xml`. */
|
|
50
|
+
name: string;
|
|
51
|
+
contents: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ExportedInvoice {
|
|
55
|
+
invoiceNumber: string;
|
|
56
|
+
invoiceIssueDate: string;
|
|
57
|
+
file: string;
|
|
58
|
+
/** SHA-256 of the file, so the export can be shown to be unaltered. */
|
|
59
|
+
sha256: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DataExportManifest {
|
|
63
|
+
/** What was asked for. */
|
|
64
|
+
selection: DataExportSelection;
|
|
65
|
+
/** The structure used, and the provision that permits it. */
|
|
66
|
+
structure: {
|
|
67
|
+
schema: string;
|
|
68
|
+
namespace: string;
|
|
69
|
+
basis: string;
|
|
70
|
+
};
|
|
71
|
+
generatedAt: string;
|
|
72
|
+
invoiceCount: number;
|
|
73
|
+
invoices: ExportedInvoice[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DataExportResult {
|
|
77
|
+
/** Every file in the export, manifest included. */
|
|
78
|
+
files: ExportedFile[];
|
|
79
|
+
manifest: DataExportManifest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Natural ordering for invoice numbers: digit runs compare as numbers.
|
|
84
|
+
*
|
|
85
|
+
* `2024/9` before `2024/10`, and `A-2` before `A-10`.
|
|
86
|
+
*/
|
|
87
|
+
export function compareInvoiceNumbersNaturally(left: string, right: string): number {
|
|
88
|
+
const chunk = /(\d+|\D+)/g;
|
|
89
|
+
const leftParts = left.match(chunk) ?? [];
|
|
90
|
+
const rightParts = right.match(chunk) ?? [];
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
|
|
93
|
+
const a = leftParts[index];
|
|
94
|
+
const b = rightParts[index];
|
|
95
|
+
if (a === undefined) return -1;
|
|
96
|
+
if (b === undefined) return 1;
|
|
97
|
+
|
|
98
|
+
const aNumeric = /^\d+$/.test(a);
|
|
99
|
+
const bNumeric = /^\d+$/.test(b);
|
|
100
|
+
if (aNumeric && bNumeric) {
|
|
101
|
+
// BigInt, because an invoice number can be longer than a safe integer.
|
|
102
|
+
const difference = BigInt(a) - BigInt(b);
|
|
103
|
+
if (difference !== 0n) return difference < 0n ? -1 : 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (a !== b) return a < b ? -1 : 1;
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether an invoice falls inside the requested selection. */
|
|
112
|
+
export function isSelected(invoice: InvoiceData, options: DataExportOptions): boolean {
|
|
113
|
+
const compare = options.compareInvoiceNumbers ?? compareInvoiceNumbersNaturally;
|
|
114
|
+
|
|
115
|
+
if (options.issueDateFrom && invoice.invoiceIssueDate < options.issueDateFrom) return false;
|
|
116
|
+
if (options.issueDateTo && invoice.invoiceIssueDate > options.issueDateTo) return false;
|
|
117
|
+
if (options.invoiceNumberFrom && compare(invoice.invoiceNumber, options.invoiceNumberFrom) < 0) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (options.invoiceNumberTo && compare(invoice.invoiceNumber, options.invoiceNumberTo) > 0) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build the export from a set of invoices.
|
|
128
|
+
*
|
|
129
|
+
* One XML file per invoice, plus a manifest recording what was selected and
|
|
130
|
+
* the SHA-256 of every file, so the export can later be shown to be the one
|
|
131
|
+
* that was produced.
|
|
132
|
+
*/
|
|
133
|
+
export function createDataExport(
|
|
134
|
+
invoices: InvoiceData[],
|
|
135
|
+
options: DataExportOptions = {},
|
|
136
|
+
): DataExportResult {
|
|
137
|
+
const now = options.now ?? (() => new Date());
|
|
138
|
+
const compare = options.compareInvoiceNumbers ?? compareInvoiceNumbersNaturally;
|
|
139
|
+
|
|
140
|
+
const selected = invoices
|
|
141
|
+
.filter((invoice) => isSelected(invoice, options))
|
|
142
|
+
.sort((left, right) => compare(left.invoiceNumber, right.invoiceNumber));
|
|
143
|
+
|
|
144
|
+
const files: ExportedFile[] = [];
|
|
145
|
+
const entries: ExportedInvoice[] = [];
|
|
146
|
+
const usedNames = new Set<string>();
|
|
147
|
+
|
|
148
|
+
for (const invoice of selected) {
|
|
149
|
+
const name = uniqueName(`invoices/${safeFileName(invoice.invoiceNumber)}.xml`, usedNames);
|
|
150
|
+
const contents = serializeDocument('InvoiceData', invoice, { indent: ' ' });
|
|
151
|
+
files.push({ name, contents });
|
|
152
|
+
entries.push({
|
|
153
|
+
invoiceNumber: invoice.invoiceNumber,
|
|
154
|
+
invoiceIssueDate: invoice.invoiceIssueDate,
|
|
155
|
+
file: name,
|
|
156
|
+
sha256: createHash('sha256').update(contents, 'utf8').digest('hex'),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const selection: DataExportSelection = {
|
|
161
|
+
...(options.issueDateFrom ? { issueDateFrom: options.issueDateFrom } : {}),
|
|
162
|
+
...(options.issueDateTo ? { issueDateTo: options.issueDateTo } : {}),
|
|
163
|
+
...(options.invoiceNumberFrom ? { invoiceNumberFrom: options.invoiceNumberFrom } : {}),
|
|
164
|
+
...(options.invoiceNumberTo ? { invoiceNumberTo: options.invoiceNumberTo } : {}),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const manifest: DataExportManifest = {
|
|
168
|
+
selection,
|
|
169
|
+
structure: {
|
|
170
|
+
schema: 'invoiceData.xsd',
|
|
171
|
+
namespace: 'http://schemas.nav.gov.hu/OSA/3.0/data',
|
|
172
|
+
basis:
|
|
173
|
+
'23/2014. (VI. 30.) NGM rendelet 13/A. § (1) — the data structure published ' +
|
|
174
|
+
'for the online invoice data service',
|
|
175
|
+
},
|
|
176
|
+
generatedAt: now().toISOString(),
|
|
177
|
+
invoiceCount: entries.length,
|
|
178
|
+
invoices: entries,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
files.push({ name: 'manifest.json', contents: `${JSON.stringify(manifest, null, 2)}\n` });
|
|
182
|
+
return { files, manifest };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** An invoice number can contain `/`, which a file name cannot. */
|
|
186
|
+
function safeFileName(invoiceNumber: string): string {
|
|
187
|
+
const cleaned = invoiceNumber.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
188
|
+
return cleaned === '' ? 'invoice' : cleaned;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Two invoice numbers can sanitise to the same name; keep them distinct. */
|
|
192
|
+
function uniqueName(name: string, used: Set<string>): string {
|
|
193
|
+
if (!used.has(name)) {
|
|
194
|
+
used.add(name);
|
|
195
|
+
return name;
|
|
196
|
+
}
|
|
197
|
+
for (let suffix = 2; ; suffix += 1) {
|
|
198
|
+
const candidate = name.replace(/\.xml$/, `-${suffix}.xml`);
|
|
199
|
+
if (!used.has(candidate)) {
|
|
200
|
+
used.add(candidate);
|
|
201
|
+
return candidate;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Decimal } from '@open-nav/core';
|
|
2
|
+
|
|
3
|
+
export type DocumentLanguage = 'hu' | 'en';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Format an exact decimal for display without going through a JavaScript
|
|
7
|
+
* number.
|
|
8
|
+
*
|
|
9
|
+
* The amounts on an invoice are exact decimal strings precisely so they do
|
|
10
|
+
* not lose precision; rendering them through `Number` to get thousands
|
|
11
|
+
* separators would throw that away at the top of NAV's 18 digit range.
|
|
12
|
+
*/
|
|
13
|
+
export function formatAmount(
|
|
14
|
+
value: string,
|
|
15
|
+
language: DocumentLanguage,
|
|
16
|
+
fractionDigits = 2,
|
|
17
|
+
): string {
|
|
18
|
+
const amount = Decimal.from(value).round(fractionDigits);
|
|
19
|
+
const text = amount.abs().toFixed(fractionDigits);
|
|
20
|
+
const [whole = '0', fraction = ''] = text.split('.');
|
|
21
|
+
|
|
22
|
+
// A non-breaking space, so a grouped amount never breaks across a line,
|
|
23
|
+
// which is the Hungarian typographic convention for thousands.
|
|
24
|
+
const groupSeparator = language === 'hu' ? '\u00a0' : ',';
|
|
25
|
+
const decimalSeparator = language === 'hu' ? ',' : '.';
|
|
26
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator);
|
|
27
|
+
|
|
28
|
+
const sign = amount.isNegative() ? '-' : '';
|
|
29
|
+
return fraction === '' ? `${sign}${grouped}` : `${sign}${grouped}${decimalSeparator}${fraction}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Format a VAT rate like `0.27` as a percentage. */
|
|
33
|
+
export function formatPercentage(value: string, language: DocumentLanguage): string {
|
|
34
|
+
const percent = Decimal.from(value).multiply('100');
|
|
35
|
+
const trimmed = percent
|
|
36
|
+
.toString()
|
|
37
|
+
.replace(/\.0+$/, '')
|
|
38
|
+
.replace(/(\.\d*?)0+$/, '$1');
|
|
39
|
+
return `${language === 'hu' ? trimmed.replace('.', ',') : trimmed}%`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Dates are ISO in the data; Hungarian documents write them with dots. */
|
|
43
|
+
export function formatDate(value: string, language: DocumentLanguage): string {
|
|
44
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
|
45
|
+
if (language === 'en') return value;
|
|
46
|
+
const [year, month, day] = value.split('-');
|
|
47
|
+
return `${year}. ${month}. ${day}.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Compose a tax number from its parts, in the written form. */
|
|
51
|
+
export function formatTaxNumber(parts: {
|
|
52
|
+
taxpayerId: string;
|
|
53
|
+
vatCode?: string;
|
|
54
|
+
countyCode?: string;
|
|
55
|
+
}): string {
|
|
56
|
+
return [parts.taxpayerId, parts.vatCode, parts.countyCode].filter(Boolean).join('-');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Escape text for HTML. */
|
|
60
|
+
export function escapeHtml(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/&/g, '&')
|
|
63
|
+
.replace(/</g, '<')
|
|
64
|
+
.replace(/>/g, '>')
|
|
65
|
+
.replace(/"/g, '"');
|
|
66
|
+
}
|