@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
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import type { AddressType, InvoiceData, InvoiceType, LineType, VatRateType } from '@open-nav/core';
|
|
5
|
+
import { toHexColor, toMargins, toPoints } from './color.js';
|
|
6
|
+
import { formatAmount, formatDate, formatPercentage, formatTaxNumber } from './format.js';
|
|
7
|
+
import { documentTitle, label, paymentMethodLabel, unitLabel } from './labels.js';
|
|
8
|
+
import { deriveMarkings } from './markings.js';
|
|
9
|
+
import { resolveTheme, type ResolvedTheme } from './theme.js';
|
|
10
|
+
import type { RenderOptions } from './html.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* PDF output without a browser.
|
|
14
|
+
*
|
|
15
|
+
* pdfmake does the layout and pdfkit the serialisation, and the fonts pdfmake
|
|
16
|
+
* bundles are Roboto — which covers Latin Extended-A, so `ő` and `ű` come out
|
|
17
|
+
* right. That was the whole reason a browser was needed: the PDF core fonts
|
|
18
|
+
* cannot represent those two characters, and Roboto embedded as a subset can.
|
|
19
|
+
*
|
|
20
|
+
* The output is smaller than a browser's and needs nothing installed. The
|
|
21
|
+
* cost is a second layout: this is pdfmake's document model, not CSS, so it
|
|
22
|
+
* follows the theme but will not match the HTML pixel for pixel.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export class NativePdfError extends Error {}
|
|
26
|
+
|
|
27
|
+
/** The slice of pdfmake's API used here, declared because it ships no types. */
|
|
28
|
+
interface PdfmakeFonts {
|
|
29
|
+
[family: string]: { normal: string; bold: string; italics: string; bolditalics: string };
|
|
30
|
+
}
|
|
31
|
+
interface PdfmakeInstance {
|
|
32
|
+
addFonts(fonts: PdfmakeFonts): void;
|
|
33
|
+
createPdf(definition: Record<string, unknown>): { getBuffer(): Promise<Buffer> };
|
|
34
|
+
/** Decides whether a remote resource may be fetched. */
|
|
35
|
+
setUrlAccessPolicy(callback: (url: string) => boolean): void;
|
|
36
|
+
/** Decides whether a local path may be read. */
|
|
37
|
+
setLocalAccessPolicy(callback: (path: string) => boolean): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A brand font for the PDF engine, as four files. */
|
|
41
|
+
export interface PdfFont {
|
|
42
|
+
/** Family name referenced in the document. */
|
|
43
|
+
name: string;
|
|
44
|
+
normal: string;
|
|
45
|
+
bold?: string;
|
|
46
|
+
italics?: string;
|
|
47
|
+
bolditalics?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NativePdfOptions extends RenderOptions {
|
|
51
|
+
/** Replace Roboto with your own font files. */
|
|
52
|
+
font?: PdfFont;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const require_ = createRequire(import.meta.url);
|
|
56
|
+
|
|
57
|
+
/** Locate the Roboto files inside the installed pdfmake package. */
|
|
58
|
+
function defaultFont(): PdfFont {
|
|
59
|
+
const root = dirname(require_.resolve('pdfmake/package.json'));
|
|
60
|
+
const dir = join(root, 'fonts', 'Roboto');
|
|
61
|
+
const font: PdfFont = {
|
|
62
|
+
name: 'Roboto',
|
|
63
|
+
normal: join(dir, 'Roboto-Regular.ttf'),
|
|
64
|
+
bold: join(dir, 'Roboto-Medium.ttf'),
|
|
65
|
+
italics: join(dir, 'Roboto-Italic.ttf'),
|
|
66
|
+
bolditalics: join(dir, 'Roboto-MediumItalic.ttf'),
|
|
67
|
+
};
|
|
68
|
+
if (!existsSync(font.normal)) {
|
|
69
|
+
throw new NativePdfError(
|
|
70
|
+
`pdfmake's bundled fonts were not found at ${dir}. Reinstall @open-nav/invoicing, ` +
|
|
71
|
+
'or pass your own font with the font option.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return font;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function loadPdfmake(font: PdfFont): PdfmakeInstance {
|
|
78
|
+
const pdfmake = require_('pdfmake') as PdfmakeInstance;
|
|
79
|
+
|
|
80
|
+
const fontPaths = new Set(
|
|
81
|
+
Object.entries(font)
|
|
82
|
+
.filter(([style, path]) => style !== 'name' && typeof path === 'string')
|
|
83
|
+
.map(([, path]) => path as string),
|
|
84
|
+
);
|
|
85
|
+
for (const path of fontPaths) {
|
|
86
|
+
if (!existsSync(path)) throw new NativePdfError(`Font file not found: ${path}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// pdfmake will fetch remote resources and read arbitrary local files unless
|
|
90
|
+
// told otherwise. An invoice document needs neither: images arrive as data
|
|
91
|
+
// URIs and the only local reads are the fonts registered right here. So the
|
|
92
|
+
// renderer refuses everything else rather than leaving a document able to
|
|
93
|
+
// reach the network or the filesystem.
|
|
94
|
+
pdfmake.setUrlAccessPolicy(() => false);
|
|
95
|
+
pdfmake.setLocalAccessPolicy((path: string) => fontPaths.has(path));
|
|
96
|
+
|
|
97
|
+
pdfmake.addFonts({
|
|
98
|
+
[font.name]: {
|
|
99
|
+
normal: font.normal,
|
|
100
|
+
bold: font.bold ?? font.normal,
|
|
101
|
+
italics: font.italics ?? font.normal,
|
|
102
|
+
bolditalics: font.bolditalics ?? font.bold ?? font.normal,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
return pdfmake;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Render an invoice to PDF bytes without a browser. */
|
|
109
|
+
export async function renderInvoicePdfNative(
|
|
110
|
+
document: InvoiceData,
|
|
111
|
+
options: NativePdfOptions = {},
|
|
112
|
+
): Promise<Buffer> {
|
|
113
|
+
const theme = resolveTheme(options.theme);
|
|
114
|
+
const font = options.font ?? defaultFont();
|
|
115
|
+
const pdfmake = loadPdfmake(font);
|
|
116
|
+
const definition = buildDefinition(document, theme, font, options);
|
|
117
|
+
return pdfmake.createPdf(definition).getBuffer();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface Palette {
|
|
121
|
+
accent: string;
|
|
122
|
+
ink: string;
|
|
123
|
+
muted: string;
|
|
124
|
+
panel: string;
|
|
125
|
+
rule: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildDefinition(
|
|
129
|
+
document: InvoiceData,
|
|
130
|
+
theme: ResolvedTheme,
|
|
131
|
+
font: PdfFont,
|
|
132
|
+
options: NativePdfOptions,
|
|
133
|
+
): Record<string, unknown> {
|
|
134
|
+
const language = options.language ?? 'hu';
|
|
135
|
+
const base = toPoints(theme.baseFontSize, 10);
|
|
136
|
+
const palette: Palette = {
|
|
137
|
+
accent: toHexColor(theme.accentColor, '#16181d'),
|
|
138
|
+
ink: toHexColor(theme.inkColor, '#16181d'),
|
|
139
|
+
muted: toHexColor(theme.mutedColor, '#5b6270'),
|
|
140
|
+
panel: toHexColor(theme.panelColor, '#f4f5f8'),
|
|
141
|
+
rule: toHexColor(theme.borderColor, '#c9cdd6'),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const invoices = document.invoiceMain.invoice
|
|
145
|
+
? [document.invoiceMain.invoice]
|
|
146
|
+
: (document.invoiceMain.batchInvoice ?? []).map((entry) => entry.invoice);
|
|
147
|
+
|
|
148
|
+
const content: unknown[] = [];
|
|
149
|
+
for (const [index, invoice] of invoices.entries()) {
|
|
150
|
+
if (index > 0) content.push({ text: '', pageBreak: 'before' });
|
|
151
|
+
content.push(...invoiceContent(document, invoice, theme, palette, base, language, options));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { pageSize, pageOrientation } = pageSetup(theme);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
pageSize,
|
|
158
|
+
...(pageOrientation ? { pageOrientation } : {}),
|
|
159
|
+
pageMargins: toMargins(theme.pageMargin, 40),
|
|
160
|
+
defaultStyle: { font: font.name, fontSize: base, color: palette.ink },
|
|
161
|
+
info: { title: document.invoiceNumber },
|
|
162
|
+
content,
|
|
163
|
+
footer: footerFactory(theme, palette, base, language, options),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pageSetup(theme: ResolvedTheme): {
|
|
168
|
+
pageSize: string | { width: number; height: number };
|
|
169
|
+
pageOrientation?: string;
|
|
170
|
+
} {
|
|
171
|
+
const parts = theme.pageSize.trim().split(/\s+/);
|
|
172
|
+
if (parts.length === 2 && /[a-z]{2}$/.test(parts[0]!) && /^[0-9.]/.test(parts[0]!)) {
|
|
173
|
+
return {
|
|
174
|
+
pageSize: { width: toPoints(parts[0]!, 595), height: toPoints(parts[1]!, 842) },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const [name, orientation] = parts;
|
|
178
|
+
return {
|
|
179
|
+
pageSize: (name ?? 'A4').toUpperCase(),
|
|
180
|
+
...(orientation ? { pageOrientation: orientation.toLowerCase() } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function footerFactory(
|
|
185
|
+
theme: ResolvedTheme,
|
|
186
|
+
palette: Palette,
|
|
187
|
+
base: number,
|
|
188
|
+
language: 'hu' | 'en',
|
|
189
|
+
options: NativePdfOptions,
|
|
190
|
+
) {
|
|
191
|
+
const showProvenance = options.provenanceNote ?? theme.provenanceNote;
|
|
192
|
+
const lines = [...theme.footerLines, ...(showProvenance ? [label('notReported', language)] : [])];
|
|
193
|
+
const margins = toMargins(theme.pageMargin, 40);
|
|
194
|
+
|
|
195
|
+
return (currentPage: number, pageCount: number): Record<string, unknown> => ({
|
|
196
|
+
margin: [margins[0], 6, margins[2], 0],
|
|
197
|
+
columns: [
|
|
198
|
+
{
|
|
199
|
+
stack: lines.map((line) => ({ text: line })),
|
|
200
|
+
fontSize: base * 0.8,
|
|
201
|
+
color: palette.muted,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
// Page numbers matter once an invoice runs to several pages.
|
|
205
|
+
text: pageCount > 1 ? `${currentPage} / ${pageCount}` : '',
|
|
206
|
+
alignment: 'right',
|
|
207
|
+
width: 60,
|
|
208
|
+
fontSize: base * 0.8,
|
|
209
|
+
color: palette.muted,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function invoiceContent(
|
|
216
|
+
document: InvoiceData,
|
|
217
|
+
invoice: InvoiceType,
|
|
218
|
+
theme: ResolvedTheme,
|
|
219
|
+
palette: Palette,
|
|
220
|
+
base: number,
|
|
221
|
+
language: 'hu' | 'en',
|
|
222
|
+
options: NativePdfOptions,
|
|
223
|
+
): unknown[] {
|
|
224
|
+
const detail = invoice.invoiceHead.invoiceDetail;
|
|
225
|
+
const markings = deriveMarkings(invoice, language);
|
|
226
|
+
const isModification = invoice.invoiceReference !== undefined;
|
|
227
|
+
const content: unknown[] = [];
|
|
228
|
+
|
|
229
|
+
// --- header -----------------------------------------------------------
|
|
230
|
+
const brand: unknown[] = [];
|
|
231
|
+
if (theme.logo?.src.startsWith('data:image/')) {
|
|
232
|
+
// pdfmake reads a data URI directly; an http URL it cannot fetch, and an
|
|
233
|
+
// SVG it cannot rasterise, so both are skipped rather than failing.
|
|
234
|
+
if (!theme.logo.src.startsWith('data:image/svg')) {
|
|
235
|
+
brand.push({
|
|
236
|
+
image: theme.logo.src,
|
|
237
|
+
...(theme.logo.width ? { width: toPoints(theme.logo.width, 120) } : { fit: [170, 60] }),
|
|
238
|
+
margin: [0, 0, 0, 6],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
brand.push({
|
|
243
|
+
text: documentTitle(detail.invoiceCategory, isModification, language),
|
|
244
|
+
fontSize: base * 2,
|
|
245
|
+
bold: true,
|
|
246
|
+
color: palette.accent,
|
|
247
|
+
characterSpacing: base * 0.08,
|
|
248
|
+
});
|
|
249
|
+
if (markings.length > 0) {
|
|
250
|
+
brand.push({
|
|
251
|
+
text: markings.map((marking) => marking.text).join(' · '),
|
|
252
|
+
fontSize: base * 0.9,
|
|
253
|
+
color: palette.muted,
|
|
254
|
+
margin: [0, 3, 0, 0],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
content.push({
|
|
259
|
+
columns: [
|
|
260
|
+
{ width: '*', stack: brand },
|
|
261
|
+
{
|
|
262
|
+
width: 'auto',
|
|
263
|
+
table: { body: metaRows(document, invoice, language) },
|
|
264
|
+
layout: noBorders(),
|
|
265
|
+
fontSize: base * 0.95,
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
columnGap: 20,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// --- parties ----------------------------------------------------------
|
|
272
|
+
content.push({
|
|
273
|
+
margin: [0, 16, 0, 0],
|
|
274
|
+
columns: [
|
|
275
|
+
partyBox(label('supplier', language), supplierLines(invoice, language, theme), palette, base),
|
|
276
|
+
partyBox(label('customer', language), customerLines(invoice, language), palette, base),
|
|
277
|
+
],
|
|
278
|
+
columnGap: 14,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// --- lines ------------------------------------------------------------
|
|
282
|
+
const lines = invoice.invoiceLines?.line ?? [];
|
|
283
|
+
if (lines.length > 0) {
|
|
284
|
+
content.push({
|
|
285
|
+
margin: [0, 16, 0, 0],
|
|
286
|
+
table: {
|
|
287
|
+
headerRows: 1,
|
|
288
|
+
// The header repeats on every page, which is what makes a long
|
|
289
|
+
// invoice readable in print.
|
|
290
|
+
widths: ['auto', '*', 'auto', 'auto', 'auto', 'auto', 'auto', 'auto', 'auto'],
|
|
291
|
+
body: [
|
|
292
|
+
lineHeader(detail.currencyCode, language, palette, base),
|
|
293
|
+
...lines.map((line) => lineRow(line, language)),
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
layout: tableLayout(palette, theme.zebraRows),
|
|
297
|
+
fontSize: base * 0.9,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- totals -----------------------------------------------------------
|
|
302
|
+
const totals = totalRows(invoice, language, palette, base);
|
|
303
|
+
if (totals.length > 0) {
|
|
304
|
+
content.push({
|
|
305
|
+
margin: [0, 14, 0, 0],
|
|
306
|
+
columns: [
|
|
307
|
+
{ width: '*', text: '' },
|
|
308
|
+
{ width: 'auto', table: { body: totals }, layout: noBorders(), fontSize: base * 0.95 },
|
|
309
|
+
],
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- VAT summary ------------------------------------------------------
|
|
314
|
+
const byRate = invoice.invoiceSummary.summaryNormal?.summaryByVatRate ?? [];
|
|
315
|
+
if (byRate.length > 1) {
|
|
316
|
+
content.push(
|
|
317
|
+
sectionHeading(label('summaryByVatRate', language), detail.currencyCode, palette, base),
|
|
318
|
+
{
|
|
319
|
+
table: {
|
|
320
|
+
headerRows: 1,
|
|
321
|
+
widths: ['*', 'auto', 'auto', 'auto'],
|
|
322
|
+
body: [
|
|
323
|
+
[
|
|
324
|
+
headerCell(label('vatRate', language), palette, base),
|
|
325
|
+
headerCell(label('netAmount', language), palette, base, 'right'),
|
|
326
|
+
headerCell(label('vatAmount', language), palette, base, 'right'),
|
|
327
|
+
headerCell(label('grossAmount', language), palette, base, 'right'),
|
|
328
|
+
],
|
|
329
|
+
...byRate.map((entry) => [
|
|
330
|
+
{ text: vatRateText(entry.vatRate, language) },
|
|
331
|
+
num(formatAmount(entry.vatRateNetData.vatRateNetAmount, language)),
|
|
332
|
+
num(formatAmount(entry.vatRateVatData.vatRateVatAmount, language)),
|
|
333
|
+
num(
|
|
334
|
+
entry.vatRateGrossData
|
|
335
|
+
? formatAmount(entry.vatRateGrossData.vatRateGrossAmount, language)
|
|
336
|
+
: '',
|
|
337
|
+
),
|
|
338
|
+
]),
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
layout: tableLayout(palette, theme.zebraRows),
|
|
342
|
+
fontSize: base * 0.9,
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --- statutory markings ----------------------------------------------
|
|
348
|
+
if (markings.length > 0) {
|
|
349
|
+
content.push(sectionHeading(label('markings', language), undefined, palette, base), {
|
|
350
|
+
table: {
|
|
351
|
+
widths: ['*'],
|
|
352
|
+
body: [
|
|
353
|
+
[
|
|
354
|
+
{
|
|
355
|
+
fillColor: palette.panel,
|
|
356
|
+
margin: [8, 6, 8, 6],
|
|
357
|
+
stack: markings.map((marking) => ({
|
|
358
|
+
text: [
|
|
359
|
+
{ text: `• ${marking.text}` },
|
|
360
|
+
...(marking.detail ? [{ text: ` — ${marking.detail}` }] : []),
|
|
361
|
+
{ text: ` (${marking.reference})`, color: palette.muted, fontSize: base * 0.85 },
|
|
362
|
+
],
|
|
363
|
+
margin: [0, 0, 0, 2],
|
|
364
|
+
})),
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
layout: noBorders(),
|
|
370
|
+
fontSize: base * 0.9,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (options.note) {
|
|
375
|
+
content.push({ text: options.note, margin: [0, 12, 0, 0], fontSize: base * 0.9 });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return content;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function metaRows(document: InvoiceData, invoice: InvoiceType, language: 'hu' | 'en'): unknown[][] {
|
|
382
|
+
const detail = invoice.invoiceHead.invoiceDetail;
|
|
383
|
+
const rows: Array<[string, string]> = [
|
|
384
|
+
[label('invoiceNumber', language), document.invoiceNumber],
|
|
385
|
+
[label('issueDate', language), formatDate(document.invoiceIssueDate, language)],
|
|
386
|
+
];
|
|
387
|
+
if (detail.invoiceDeliveryDate) {
|
|
388
|
+
rows.push([label('deliveryDate', language), formatDate(detail.invoiceDeliveryDate, language)]);
|
|
389
|
+
}
|
|
390
|
+
if (detail.invoiceDeliveryPeriodStart && detail.invoiceDeliveryPeriodEnd) {
|
|
391
|
+
rows.push([
|
|
392
|
+
label('deliveryPeriod', language),
|
|
393
|
+
`${formatDate(detail.invoiceDeliveryPeriodStart, language)} – ${formatDate(detail.invoiceDeliveryPeriodEnd, language)}`,
|
|
394
|
+
]);
|
|
395
|
+
}
|
|
396
|
+
if (detail.paymentDate) {
|
|
397
|
+
rows.push([label('paymentDate', language), formatDate(detail.paymentDate, language)]);
|
|
398
|
+
}
|
|
399
|
+
if (detail.paymentMethod) {
|
|
400
|
+
rows.push([
|
|
401
|
+
label('paymentMethod', language),
|
|
402
|
+
paymentMethodLabel(detail.paymentMethod, language),
|
|
403
|
+
]);
|
|
404
|
+
}
|
|
405
|
+
rows.push([label('currency', language), detail.currencyCode]);
|
|
406
|
+
if (detail.exchangeRate && detail.currencyCode !== 'HUF') {
|
|
407
|
+
rows.push([
|
|
408
|
+
label('exchangeRate', language),
|
|
409
|
+
`${detail.exchangeRate} HUF/${detail.currencyCode}`,
|
|
410
|
+
]);
|
|
411
|
+
}
|
|
412
|
+
if (invoice.invoiceReference) {
|
|
413
|
+
rows.push([
|
|
414
|
+
label('originalInvoiceNumber', language),
|
|
415
|
+
invoice.invoiceReference.originalInvoiceNumber,
|
|
416
|
+
]);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return rows.map(([name, value]) => [
|
|
420
|
+
{ text: `${name}:`, alignment: 'right' },
|
|
421
|
+
{ text: value, alignment: 'right', bold: true },
|
|
422
|
+
]);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function partyBox(
|
|
426
|
+
heading: string,
|
|
427
|
+
lines: { name: string; address: string; rows: Array<[string, string]>; contact: string[] },
|
|
428
|
+
palette: Palette,
|
|
429
|
+
base: number,
|
|
430
|
+
): unknown {
|
|
431
|
+
const stack: unknown[] = [
|
|
432
|
+
{
|
|
433
|
+
text: heading.toUpperCase(),
|
|
434
|
+
fontSize: base * 0.8,
|
|
435
|
+
bold: true,
|
|
436
|
+
color: palette.muted,
|
|
437
|
+
characterSpacing: base * 0.1,
|
|
438
|
+
},
|
|
439
|
+
{ text: lines.name, fontSize: base * 1.1, bold: true, margin: [0, 3, 0, 1] },
|
|
440
|
+
];
|
|
441
|
+
if (lines.address) stack.push({ text: lines.address, fontSize: base * 0.9 });
|
|
442
|
+
if (lines.rows.length > 0) {
|
|
443
|
+
stack.push({
|
|
444
|
+
margin: [0, 3, 0, 0],
|
|
445
|
+
table: {
|
|
446
|
+
body: lines.rows.map(([key, value]) => [
|
|
447
|
+
{ text: key, color: palette.muted },
|
|
448
|
+
{ text: value },
|
|
449
|
+
]),
|
|
450
|
+
},
|
|
451
|
+
layout: noBorders(),
|
|
452
|
+
fontSize: base * 0.9,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (lines.contact.length > 0) {
|
|
456
|
+
stack.push({
|
|
457
|
+
margin: [0, 3, 0, 0],
|
|
458
|
+
stack: lines.contact.map((line) => ({ text: line })),
|
|
459
|
+
fontSize: base * 0.9,
|
|
460
|
+
color: palette.muted,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
width: '*',
|
|
466
|
+
table: { widths: ['*'], body: [[{ stack, margin: [8, 6, 8, 6] }]] },
|
|
467
|
+
layout: boxLayout(palette),
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function formatAddress(address: AddressType | undefined): string {
|
|
472
|
+
if (!address) return '';
|
|
473
|
+
if ('simpleAddress' in address && address.simpleAddress) {
|
|
474
|
+
const simple = address.simpleAddress;
|
|
475
|
+
return [simple.postalCode, simple.city, simple.additionalAddressDetail, simple.countryCode]
|
|
476
|
+
.filter(Boolean)
|
|
477
|
+
.join(', ');
|
|
478
|
+
}
|
|
479
|
+
if ('detailedAddress' in address && address.detailedAddress) {
|
|
480
|
+
const detailed = address.detailedAddress;
|
|
481
|
+
const street = [
|
|
482
|
+
detailed.streetName,
|
|
483
|
+
detailed.publicPlaceCategory,
|
|
484
|
+
detailed.number,
|
|
485
|
+
detailed.building,
|
|
486
|
+
detailed.floor,
|
|
487
|
+
detailed.door,
|
|
488
|
+
]
|
|
489
|
+
.filter(Boolean)
|
|
490
|
+
.join(' ');
|
|
491
|
+
return [detailed.postalCode, detailed.city, street, detailed.countryCode]
|
|
492
|
+
.filter(Boolean)
|
|
493
|
+
.join(', ');
|
|
494
|
+
}
|
|
495
|
+
return '';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function supplierLines(invoice: InvoiceType, language: 'hu' | 'en', theme: ResolvedTheme) {
|
|
499
|
+
const supplier = invoice.invoiceHead.supplierInfo;
|
|
500
|
+
const rows: Array<[string, string]> = [
|
|
501
|
+
[label('taxNumber', language), formatTaxNumber(supplier.supplierTaxNumber)],
|
|
502
|
+
];
|
|
503
|
+
if (supplier.groupMemberTaxNumber) {
|
|
504
|
+
rows.push([
|
|
505
|
+
label('groupMemberTaxNumber', language),
|
|
506
|
+
formatTaxNumber(supplier.groupMemberTaxNumber),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
if (supplier.communityVatNumber) {
|
|
510
|
+
rows.push([label('communityVatNumber', language), supplier.communityVatNumber]);
|
|
511
|
+
}
|
|
512
|
+
if (supplier.supplierBankAccountNumber) {
|
|
513
|
+
rows.push([label('bankAccount', language), supplier.supplierBankAccountNumber]);
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
name: supplier.supplierName,
|
|
517
|
+
address: formatAddress(supplier.supplierAddress),
|
|
518
|
+
rows,
|
|
519
|
+
contact: theme.issuerContact,
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function customerLines(invoice: InvoiceType, language: 'hu' | 'en') {
|
|
524
|
+
const customer = invoice.invoiceHead.customerInfo;
|
|
525
|
+
if (!customer) return { name: '—', address: '', rows: [], contact: [] };
|
|
526
|
+
|
|
527
|
+
const rows: Array<[string, string]> = [];
|
|
528
|
+
const vatData = customer.customerVatData;
|
|
529
|
+
if (vatData?.customerTaxNumber) {
|
|
530
|
+
rows.push([label('taxNumber', language), formatTaxNumber(vatData.customerTaxNumber)]);
|
|
531
|
+
}
|
|
532
|
+
if (vatData?.communityVatNumber) {
|
|
533
|
+
rows.push([label('communityVatNumber', language), vatData.communityVatNumber]);
|
|
534
|
+
}
|
|
535
|
+
if (vatData?.thirdStateTaxId) {
|
|
536
|
+
rows.push([label('thirdStateTaxId', language), vatData.thirdStateTaxId]);
|
|
537
|
+
}
|
|
538
|
+
if (customer.customerBankAccountNumber) {
|
|
539
|
+
rows.push([label('bankAccount', language), customer.customerBankAccountNumber]);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
// A private person is deliberately not named in the reported data.
|
|
544
|
+
name: customer.customerName ?? '—',
|
|
545
|
+
address: formatAddress(customer.customerAddress),
|
|
546
|
+
rows,
|
|
547
|
+
contact: [],
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function vatRateText(rate: VatRateType | undefined, language: 'hu' | 'en'): string {
|
|
552
|
+
if (!rate) return '';
|
|
553
|
+
if (rate.vatPercentage !== undefined) return formatPercentage(rate.vatPercentage, language);
|
|
554
|
+
if (rate.vatContent !== undefined) return formatPercentage(rate.vatContent, language);
|
|
555
|
+
if (rate.vatExemption) return `${label('exempt', language)} (${rate.vatExemption.case})`;
|
|
556
|
+
if (rate.vatOutOfScope) return `${label('outOfScope', language)} (${rate.vatOutOfScope.case})`;
|
|
557
|
+
if (rate.vatDomesticReverseCharge) return label('reverseCharge', language);
|
|
558
|
+
if (rate.marginSchemeIndicator) return label('exempt', language);
|
|
559
|
+
return '';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function lineHeader(
|
|
563
|
+
currency: string,
|
|
564
|
+
language: 'hu' | 'en',
|
|
565
|
+
palette: Palette,
|
|
566
|
+
base: number,
|
|
567
|
+
): unknown[] {
|
|
568
|
+
return [
|
|
569
|
+
headerCell(label('lineNumber', language), palette, base, 'right'),
|
|
570
|
+
headerCell(label('description', language), palette, base),
|
|
571
|
+
headerCell(label('quantity', language), palette, base, 'right'),
|
|
572
|
+
headerCell(label('unit', language), palette, base),
|
|
573
|
+
headerCell(label('unitPrice', language), palette, base, 'right'),
|
|
574
|
+
headerCell(`${label('netAmount', language)} (${currency})`, palette, base, 'right'),
|
|
575
|
+
headerCell(label('vatRate', language), palette, base, 'right'),
|
|
576
|
+
headerCell(label('vatAmount', language), palette, base, 'right'),
|
|
577
|
+
headerCell(label('grossAmount', language), palette, base, 'right'),
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function lineRow(line: LineType, language: 'hu' | 'en'): unknown[] {
|
|
582
|
+
const normal = line.lineAmountsNormal;
|
|
583
|
+
const simplified = line.lineAmountsSimplified;
|
|
584
|
+
const rate = normal?.lineVatRate ?? simplified?.lineVatRate;
|
|
585
|
+
const gross =
|
|
586
|
+
normal?.lineGrossAmountData?.lineGrossAmountNormal ?? simplified?.lineGrossAmountSimplified;
|
|
587
|
+
|
|
588
|
+
return [
|
|
589
|
+
num(String(line.lineNumber)),
|
|
590
|
+
{ text: line.lineDescription ?? '' },
|
|
591
|
+
num(line.quantity ? formatAmount(line.quantity, language, decimalsOf(line.quantity)) : ''),
|
|
592
|
+
{ text: unitLabel(line.unitOfMeasure, line.unitOfMeasureOwn, language) },
|
|
593
|
+
num(line.unitPrice ? formatAmount(line.unitPrice, language, decimalsOf(line.unitPrice)) : ''),
|
|
594
|
+
num(normal ? formatAmount(normal.lineNetAmountData.lineNetAmount, language) : ''),
|
|
595
|
+
num(vatRateText(rate, language)),
|
|
596
|
+
num(normal?.lineVatData ? formatAmount(normal.lineVatData.lineVatAmount, language) : ''),
|
|
597
|
+
num(gross ? formatAmount(gross, language) : ''),
|
|
598
|
+
];
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Keep a quantity's own precision rather than forcing two decimals. */
|
|
602
|
+
function decimalsOf(value: string): number {
|
|
603
|
+
const fraction = value.split('.')[1];
|
|
604
|
+
return fraction ? fraction.replace(/0+$/, '').length : 0;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function totalRows(
|
|
608
|
+
invoice: InvoiceType,
|
|
609
|
+
language: 'hu' | 'en',
|
|
610
|
+
palette: Palette,
|
|
611
|
+
base: number,
|
|
612
|
+
): unknown[][] {
|
|
613
|
+
const summary = invoice.invoiceSummary;
|
|
614
|
+
const currency = invoice.invoiceHead.invoiceDetail.currencyCode;
|
|
615
|
+
const rows: unknown[][] = [];
|
|
616
|
+
|
|
617
|
+
if (summary.summaryNormal) {
|
|
618
|
+
rows.push(
|
|
619
|
+
totalRow(
|
|
620
|
+
label('totalNet', language),
|
|
621
|
+
formatAmount(summary.summaryNormal.invoiceNetAmount, language),
|
|
622
|
+
currency,
|
|
623
|
+
),
|
|
624
|
+
totalRow(
|
|
625
|
+
label('totalVat', language),
|
|
626
|
+
formatAmount(summary.summaryNormal.invoiceVatAmount, language),
|
|
627
|
+
currency,
|
|
628
|
+
),
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (summary.summaryGrossData) {
|
|
632
|
+
rows.push([
|
|
633
|
+
{
|
|
634
|
+
text: label('totalGross', language),
|
|
635
|
+
bold: true,
|
|
636
|
+
fontSize: base * 1.15,
|
|
637
|
+
color: palette.accent,
|
|
638
|
+
margin: [0, 4, 0, 0],
|
|
639
|
+
border: [false, true, false, false],
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
text: formatAmount(summary.summaryGrossData.invoiceGrossAmount, language),
|
|
643
|
+
alignment: 'right',
|
|
644
|
+
bold: true,
|
|
645
|
+
fontSize: base * 1.15,
|
|
646
|
+
color: palette.accent,
|
|
647
|
+
margin: [0, 4, 0, 0],
|
|
648
|
+
border: [false, true, false, false],
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
text: currency,
|
|
652
|
+
alignment: 'right',
|
|
653
|
+
bold: true,
|
|
654
|
+
fontSize: base * 1.15,
|
|
655
|
+
color: palette.accent,
|
|
656
|
+
margin: [0, 4, 0, 0],
|
|
657
|
+
border: [false, true, false, false],
|
|
658
|
+
},
|
|
659
|
+
]);
|
|
660
|
+
if (currency !== 'HUF') {
|
|
661
|
+
rows.push(
|
|
662
|
+
totalRow(
|
|
663
|
+
label('inHuf', language),
|
|
664
|
+
formatAmount(summary.summaryGrossData.invoiceGrossAmountHUF, language),
|
|
665
|
+
'HUF',
|
|
666
|
+
),
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return rows;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function totalRow(name: string, value: string, currency: string): unknown[] {
|
|
674
|
+
return [
|
|
675
|
+
{ text: name },
|
|
676
|
+
{ text: value, alignment: 'right' },
|
|
677
|
+
{ text: currency, alignment: 'right' },
|
|
678
|
+
];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function sectionHeading(
|
|
682
|
+
text: string,
|
|
683
|
+
suffix: string | undefined,
|
|
684
|
+
palette: Palette,
|
|
685
|
+
base: number,
|
|
686
|
+
): unknown {
|
|
687
|
+
return {
|
|
688
|
+
text: `${text.toUpperCase()}${suffix ? ` (${suffix})` : ''}`,
|
|
689
|
+
fontSize: base * 0.8,
|
|
690
|
+
bold: true,
|
|
691
|
+
color: palette.muted,
|
|
692
|
+
characterSpacing: base * 0.1,
|
|
693
|
+
margin: [0, 16, 0, 4],
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function headerCell(
|
|
698
|
+
text: string,
|
|
699
|
+
palette: Palette,
|
|
700
|
+
base: number,
|
|
701
|
+
alignment: 'left' | 'right' = 'left',
|
|
702
|
+
): unknown {
|
|
703
|
+
return {
|
|
704
|
+
text: text.toUpperCase(),
|
|
705
|
+
bold: true,
|
|
706
|
+
fontSize: base * 0.8,
|
|
707
|
+
color: palette.muted,
|
|
708
|
+
alignment,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function num(text: string): unknown {
|
|
713
|
+
return { text, alignment: 'right', noWrap: true };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Horizontal rules only, matching the HTML document's restraint. */
|
|
717
|
+
function tableLayout(palette: Palette, zebra: boolean): Record<string, unknown> {
|
|
718
|
+
return {
|
|
719
|
+
hLineWidth: (index: number, node: { table: { body: unknown[] } }) =>
|
|
720
|
+
index === 0 || index === 1 || index === node.table.body.length ? 0.8 : 0.4,
|
|
721
|
+
vLineWidth: () => 0,
|
|
722
|
+
hLineColor: () => palette.rule,
|
|
723
|
+
fillColor: (rowIndex: number) =>
|
|
724
|
+
zebra && rowIndex > 0 && rowIndex % 2 === 0 ? palette.panel : null,
|
|
725
|
+
paddingLeft: () => 4,
|
|
726
|
+
paddingRight: () => 4,
|
|
727
|
+
paddingTop: () => 4,
|
|
728
|
+
paddingBottom: () => 4,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function boxLayout(palette: Palette): Record<string, unknown> {
|
|
733
|
+
return {
|
|
734
|
+
hLineWidth: () => 0.6,
|
|
735
|
+
vLineWidth: () => 0.6,
|
|
736
|
+
hLineColor: () => palette.rule,
|
|
737
|
+
vLineColor: () => palette.rule,
|
|
738
|
+
paddingLeft: () => 0,
|
|
739
|
+
paddingRight: () => 0,
|
|
740
|
+
paddingTop: () => 0,
|
|
741
|
+
paddingBottom: () => 0,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function noBorders(): Record<string, unknown> {
|
|
746
|
+
return {
|
|
747
|
+
hLineWidth: () => 0,
|
|
748
|
+
vLineWidth: () => 0,
|
|
749
|
+
paddingLeft: (index: number) => (index === 0 ? 0 : 6),
|
|
750
|
+
paddingRight: () => 0,
|
|
751
|
+
paddingTop: () => 1,
|
|
752
|
+
paddingBottom: () => 1,
|
|
753
|
+
};
|
|
754
|
+
}
|