@open-nav/core 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 +132 -0
- package/dist/constants.d.ts +49 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +58 -0
- package/dist/constants.js.map +1 -0
- package/dist/crypto/hash.d.ts +16 -0
- package/dist/crypto/hash.d.ts.map +1 -0
- package/dist/crypto/hash.js +30 -0
- package/dist/crypto/hash.js.map +1 -0
- package/dist/crypto/node-provider.d.ts +9 -0
- package/dist/crypto/node-provider.d.ts.map +1 -0
- package/dist/crypto/node-provider.js +18 -0
- package/dist/crypto/node-provider.js.map +1 -0
- package/dist/crypto/provider.d.ts +28 -0
- package/dist/crypto/provider.d.ts.map +1 -0
- package/dist/crypto/provider.js +14 -0
- package/dist/crypto/provider.js.map +1 -0
- package/dist/crypto/request-id.d.ts +16 -0
- package/dist/crypto/request-id.d.ts.map +1 -0
- package/dist/crypto/request-id.js +59 -0
- package/dist/crypto/request-id.js.map +1 -0
- package/dist/crypto/signature.d.ts +40 -0
- package/dist/crypto/signature.d.ts.map +1 -0
- package/dist/crypto/signature.js +38 -0
- package/dist/crypto/signature.js.map +1 -0
- package/dist/crypto/token.d.ts +14 -0
- package/dist/crypto/token.d.ts.map +1 -0
- package/dist/crypto/token.js +82 -0
- package/dist/crypto/token.js.map +1 -0
- package/dist/crypto/web-provider.d.ts +18 -0
- package/dist/crypto/web-provider.d.ts.map +1 -0
- package/dist/crypto/web-provider.js +31 -0
- package/dist/crypto/web-provider.js.map +1 -0
- package/dist/errors.d.ts +75 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/fault-codes.d.ts +1343 -0
- package/dist/generated/fault-codes.d.ts.map +1 -0
- package/dist/generated/fault-codes.js +1331 -0
- package/dist/generated/fault-codes.js.map +1 -0
- package/dist/generated/index.d.ts +4 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/index.js +5 -0
- package/dist/generated/index.js.map +1 -0
- package/dist/generated/schema.d.ts +13 -0
- package/dist/generated/schema.d.ts.map +1 -0
- package/dist/generated/schema.js +3756 -0
- package/dist/generated/schema.js.map +1 -0
- package/dist/generated/types.d.ts +5580 -0
- package/dist/generated/types.d.ts.map +1 -0
- package/dist/generated/types.js +6 -0
- package/dist/generated/types.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/invoice/payload.d.ts +40 -0
- package/dist/invoice/payload.d.ts.map +1 -0
- package/dist/invoice/payload.js +66 -0
- package/dist/invoice/payload.js.map +1 -0
- package/dist/money/decimal.d.ts +69 -0
- package/dist/money/decimal.d.ts.map +1 -0
- package/dist/money/decimal.js +190 -0
- package/dist/money/decimal.js.map +1 -0
- package/dist/money/summary.d.ts +60 -0
- package/dist/money/summary.d.ts.map +1 -0
- package/dist/money/summary.js +236 -0
- package/dist/money/summary.js.map +1 -0
- package/dist/time.d.ts +16 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +41 -0
- package/dist/time.js.map +1 -0
- package/dist/validation/issue.d.ts +67 -0
- package/dist/validation/issue.d.ts.map +1 -0
- package/dist/validation/issue.js +40 -0
- package/dist/validation/issue.js.map +1 -0
- package/dist/validation/rules.d.ts +34 -0
- package/dist/validation/rules.d.ts.map +1 -0
- package/dist/validation/rules.js +290 -0
- package/dist/validation/rules.js.map +1 -0
- package/dist/validation/schema.d.ts +14 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +231 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/tax-number.d.ts +39 -0
- package/dist/validation/tax-number.d.ts.map +1 -0
- package/dist/validation/tax-number.js +79 -0
- package/dist/validation/tax-number.js.map +1 -0
- package/dist/validation/validate.d.ts +29 -0
- package/dist/validation/validate.d.ts.map +1 -0
- package/dist/validation/validate.js +33 -0
- package/dist/validation/validate.js.map +1 -0
- package/dist/xml/descriptor.d.ts +82 -0
- package/dist/xml/descriptor.d.ts.map +1 -0
- package/dist/xml/descriptor.js +32 -0
- package/dist/xml/descriptor.js.map +1 -0
- package/dist/xml/read.d.ts +25 -0
- package/dist/xml/read.d.ts.map +1 -0
- package/dist/xml/read.js +230 -0
- package/dist/xml/read.js.map +1 -0
- package/dist/xml/write.d.ts +28 -0
- package/dist/xml/write.d.ts.map +1 -0
- package/dist/xml/write.js +299 -0
- package/dist/xml/write.js.map +1 -0
- package/package.json +55 -0
- package/src/constants.ts +76 -0
- package/src/crypto/hash.ts +35 -0
- package/src/crypto/node-provider.ts +19 -0
- package/src/crypto/provider.ts +37 -0
- package/src/crypto/request-id.ts +63 -0
- package/src/crypto/signature.ts +58 -0
- package/src/crypto/token.ts +86 -0
- package/src/crypto/web-provider.ts +32 -0
- package/src/errors.ts +109 -0
- package/src/generated/fault-codes.ts +1338 -0
- package/src/generated/index.ts +4 -0
- package/src/generated/schema.ts +3760 -0
- package/src/generated/types.ts +5829 -0
- package/src/index.ts +22 -0
- package/src/invoice/payload.ts +102 -0
- package/src/money/decimal.ts +218 -0
- package/src/money/summary.ts +380 -0
- package/src/time.ts +44 -0
- package/src/validation/issue.ts +102 -0
- package/src/validation/rules.ts +548 -0
- package/src/validation/schema.ts +315 -0
- package/src/validation/tax-number.ts +96 -0
- package/src/validation/validate.ts +45 -0
- package/src/xml/descriptor.ts +112 -0
- package/src/xml/read.ts +303 -0
- package/src/xml/write.ts +391 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { ValidationIssue } from '../errors.js';
|
|
2
|
+
import type {
|
|
3
|
+
InvoiceData,
|
|
4
|
+
InvoiceType,
|
|
5
|
+
LineType,
|
|
6
|
+
SummaryByVatRateType,
|
|
7
|
+
SummarySimplifiedType,
|
|
8
|
+
SummaryType,
|
|
9
|
+
VatRateType,
|
|
10
|
+
} from '../generated/types.js';
|
|
11
|
+
import { Decimal, sum, type RoundingMode } from './decimal.js';
|
|
12
|
+
|
|
13
|
+
/** Decimal places NAV's MonetaryType permits. */
|
|
14
|
+
const MONETARY_SCALE = 2;
|
|
15
|
+
|
|
16
|
+
export interface SummaryOptions {
|
|
17
|
+
/** Decimal places for computed amounts. Defaults to 2, per MonetaryType. */
|
|
18
|
+
scale?: number;
|
|
19
|
+
mode?: RoundingMode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute the invoice summary from the lines.
|
|
24
|
+
*
|
|
25
|
+
* The summary is not free-form: `summaryByVatRate` must carry one entry per
|
|
26
|
+
* distinct VAT rate appearing on the lines, its net and VAT amounts must be
|
|
27
|
+
* the sums of the corresponding line amounts, and the invoice totals must be
|
|
28
|
+
* the sums of those. NAV rejects the whole batch when they disagree, so this
|
|
29
|
+
* derives them rather than trusting a caller to keep them in step.
|
|
30
|
+
*
|
|
31
|
+
* Lines are grouped by their entire `lineVatRate` value, not by percentage
|
|
32
|
+
* alone: a 27% line and an exempt line are different rates, and so are two
|
|
33
|
+
* exempt lines citing different legal grounds.
|
|
34
|
+
*/
|
|
35
|
+
export function computeInvoiceSummary(
|
|
36
|
+
invoice: InvoiceType,
|
|
37
|
+
options: SummaryOptions = {},
|
|
38
|
+
): SummaryType {
|
|
39
|
+
const scale = options.scale ?? MONETARY_SCALE;
|
|
40
|
+
const mode = options.mode ?? 'half-up';
|
|
41
|
+
const lines = invoice.invoiceLines?.line ?? [];
|
|
42
|
+
|
|
43
|
+
const normal = lines.filter((line) => line.lineAmountsNormal !== undefined);
|
|
44
|
+
const simplified = lines.filter((line) => line.lineAmountsSimplified !== undefined);
|
|
45
|
+
|
|
46
|
+
const summary: SummaryType = {};
|
|
47
|
+
|
|
48
|
+
if (simplified.length > 0) {
|
|
49
|
+
summary.summarySimplified = groupBy(
|
|
50
|
+
simplified,
|
|
51
|
+
(line) => line.lineAmountsSimplified!.lineVatRate,
|
|
52
|
+
).map(([vatRate, group]): SummarySimplifiedType => ({
|
|
53
|
+
vatRate,
|
|
54
|
+
vatContentGrossAmount: total(
|
|
55
|
+
group.map((line) => line.lineAmountsSimplified!.lineGrossAmountSimplified),
|
|
56
|
+
scale,
|
|
57
|
+
mode,
|
|
58
|
+
),
|
|
59
|
+
vatContentGrossAmountHUF: total(
|
|
60
|
+
group.map((line) => line.lineAmountsSimplified!.lineGrossAmountSimplifiedHUF),
|
|
61
|
+
scale,
|
|
62
|
+
mode,
|
|
63
|
+
),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (normal.length > 0 || simplified.length === 0) {
|
|
68
|
+
const byVatRate = groupBy(normal, (line) => line.lineAmountsNormal!.lineVatRate).map(
|
|
69
|
+
([vatRate, group]): SummaryByVatRateType => ({
|
|
70
|
+
vatRate,
|
|
71
|
+
vatRateNetData: {
|
|
72
|
+
vatRateNetAmount: total(
|
|
73
|
+
group.map((line) => line.lineAmountsNormal!.lineNetAmountData.lineNetAmount),
|
|
74
|
+
scale,
|
|
75
|
+
mode,
|
|
76
|
+
),
|
|
77
|
+
vatRateNetAmountHUF: total(
|
|
78
|
+
group.map((line) => line.lineAmountsNormal!.lineNetAmountData.lineNetAmountHUF),
|
|
79
|
+
scale,
|
|
80
|
+
mode,
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
vatRateVatData: {
|
|
84
|
+
vatRateVatAmount: total(
|
|
85
|
+
group.map((line) => lineVatAmount(line, 'amount', scale, mode)),
|
|
86
|
+
scale,
|
|
87
|
+
mode,
|
|
88
|
+
),
|
|
89
|
+
vatRateVatAmountHUF: total(
|
|
90
|
+
group.map((line) => lineVatAmount(line, 'huf', scale, mode)),
|
|
91
|
+
scale,
|
|
92
|
+
mode,
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
summary.summaryNormal = {
|
|
99
|
+
summaryByVatRate: byVatRate,
|
|
100
|
+
invoiceNetAmount: total(
|
|
101
|
+
byVatRate.map((entry) => entry.vatRateNetData.vatRateNetAmount),
|
|
102
|
+
scale,
|
|
103
|
+
mode,
|
|
104
|
+
),
|
|
105
|
+
invoiceNetAmountHUF: total(
|
|
106
|
+
byVatRate.map((entry) => entry.vatRateNetData.vatRateNetAmountHUF),
|
|
107
|
+
scale,
|
|
108
|
+
mode,
|
|
109
|
+
),
|
|
110
|
+
invoiceVatAmount: total(
|
|
111
|
+
byVatRate.map((entry) => entry.vatRateVatData.vatRateVatAmount),
|
|
112
|
+
scale,
|
|
113
|
+
mode,
|
|
114
|
+
),
|
|
115
|
+
invoiceVatAmountHUF: total(
|
|
116
|
+
byVatRate.map((entry) => entry.vatRateVatData.vatRateVatAmountHUF),
|
|
117
|
+
scale,
|
|
118
|
+
mode,
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
summary.summaryGrossData = {
|
|
124
|
+
invoiceGrossAmount: grossTotal(summary, 'amount', scale, mode),
|
|
125
|
+
invoiceGrossAmountHUF: grossTotal(summary, 'huf', scale, mode),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return summary;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Check that an invoice's stated summary agrees with its lines.
|
|
133
|
+
*
|
|
134
|
+
* Returns the discrepancies rather than throwing, because a report that lists
|
|
135
|
+
* every mismatch at once is far more useful than the first one NAV happens to
|
|
136
|
+
* complain about.
|
|
137
|
+
*/
|
|
138
|
+
export function checkInvoiceSummary(
|
|
139
|
+
invoice: InvoiceType,
|
|
140
|
+
options: SummaryOptions = {},
|
|
141
|
+
): ValidationIssue[] {
|
|
142
|
+
if (!hasAmountBearingLines(invoice)) return [];
|
|
143
|
+
|
|
144
|
+
const issues: ValidationIssue[] = [];
|
|
145
|
+
const stated = invoice.invoiceSummary;
|
|
146
|
+
const expected = computeInvoiceSummary(invoice, options);
|
|
147
|
+
|
|
148
|
+
const compare = (path: string, actual: string | undefined, wanted: string | undefined): void => {
|
|
149
|
+
if (actual === undefined || wanted === undefined) return;
|
|
150
|
+
if (!Decimal.from(actual).equals(Decimal.from(wanted))) {
|
|
151
|
+
issues.push({
|
|
152
|
+
path,
|
|
153
|
+
code: 'SUMMARY_MISMATCH',
|
|
154
|
+
message: `is ${actual} but the lines total ${wanted}`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (stated.summaryNormal && expected.summaryNormal) {
|
|
160
|
+
const statedNormal = stated.summaryNormal;
|
|
161
|
+
const expectedNormal = expected.summaryNormal;
|
|
162
|
+
compare(
|
|
163
|
+
'invoiceSummary.summaryNormal.invoiceNetAmount',
|
|
164
|
+
statedNormal.invoiceNetAmount,
|
|
165
|
+
expectedNormal.invoiceNetAmount,
|
|
166
|
+
);
|
|
167
|
+
compare(
|
|
168
|
+
'invoiceSummary.summaryNormal.invoiceNetAmountHUF',
|
|
169
|
+
statedNormal.invoiceNetAmountHUF,
|
|
170
|
+
expectedNormal.invoiceNetAmountHUF,
|
|
171
|
+
);
|
|
172
|
+
compare(
|
|
173
|
+
'invoiceSummary.summaryNormal.invoiceVatAmount',
|
|
174
|
+
statedNormal.invoiceVatAmount,
|
|
175
|
+
expectedNormal.invoiceVatAmount,
|
|
176
|
+
);
|
|
177
|
+
compare(
|
|
178
|
+
'invoiceSummary.summaryNormal.invoiceVatAmountHUF',
|
|
179
|
+
statedNormal.invoiceVatAmountHUF,
|
|
180
|
+
expectedNormal.invoiceVatAmountHUF,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
if (statedNormal.summaryByVatRate.length !== expectedNormal.summaryByVatRate.length) {
|
|
184
|
+
issues.push({
|
|
185
|
+
path: 'invoiceSummary.summaryNormal.summaryByVatRate',
|
|
186
|
+
code: 'SUMMARY_VAT_RATE_COUNT',
|
|
187
|
+
message:
|
|
188
|
+
`has ${statedNormal.summaryByVatRate.length} entries but the lines use ` +
|
|
189
|
+
`${expectedNormal.summaryByVatRate.length} distinct VAT rates`,
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
// Order is not significant, so match entries by their VAT rate.
|
|
193
|
+
for (const [index, statedEntry] of statedNormal.summaryByVatRate.entries()) {
|
|
194
|
+
const key = vatRateKey(statedEntry.vatRate);
|
|
195
|
+
const expectedEntry = expectedNormal.summaryByVatRate.find(
|
|
196
|
+
(candidate) => vatRateKey(candidate.vatRate) === key,
|
|
197
|
+
);
|
|
198
|
+
const path = `invoiceSummary.summaryNormal.summaryByVatRate.${index}`;
|
|
199
|
+
if (!expectedEntry) {
|
|
200
|
+
issues.push({
|
|
201
|
+
path,
|
|
202
|
+
code: 'SUMMARY_VAT_RATE_UNKNOWN',
|
|
203
|
+
message: 'states a VAT rate that no line uses',
|
|
204
|
+
});
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
compare(
|
|
208
|
+
`${path}.vatRateNetData.vatRateNetAmount`,
|
|
209
|
+
statedEntry.vatRateNetData.vatRateNetAmount,
|
|
210
|
+
expectedEntry.vatRateNetData.vatRateNetAmount,
|
|
211
|
+
);
|
|
212
|
+
compare(
|
|
213
|
+
`${path}.vatRateVatData.vatRateVatAmount`,
|
|
214
|
+
statedEntry.vatRateVatData.vatRateVatAmount,
|
|
215
|
+
expectedEntry.vatRateVatData.vatRateVatAmount,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (stated.summaryGrossData && expected.summaryGrossData) {
|
|
222
|
+
compare(
|
|
223
|
+
'invoiceSummary.summaryGrossData.invoiceGrossAmount',
|
|
224
|
+
stated.summaryGrossData.invoiceGrossAmount,
|
|
225
|
+
expected.summaryGrossData.invoiceGrossAmount,
|
|
226
|
+
);
|
|
227
|
+
compare(
|
|
228
|
+
'invoiceSummary.summaryGrossData.invoiceGrossAmountHUF',
|
|
229
|
+
stated.summaryGrossData.invoiceGrossAmountHUF,
|
|
230
|
+
expected.summaryGrossData.invoiceGrossAmountHUF,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Check every invoice in a document, including batch modification documents.
|
|
239
|
+
*
|
|
240
|
+
* Paths are prefixed with the batch index so a caller can tell which invoice
|
|
241
|
+
* of a multi-invoice document is out.
|
|
242
|
+
*/
|
|
243
|
+
export function checkDocumentSummaries(
|
|
244
|
+
document: InvoiceData,
|
|
245
|
+
options: SummaryOptions = {},
|
|
246
|
+
): ValidationIssue[] {
|
|
247
|
+
const single = document.invoiceMain.invoice;
|
|
248
|
+
if (single) return checkInvoiceSummary(single, options);
|
|
249
|
+
|
|
250
|
+
return (document.invoiceMain.batchInvoice ?? []).flatMap((entry) =>
|
|
251
|
+
checkInvoiceSummary(entry.invoice, options).map((issue) => ({
|
|
252
|
+
...issue,
|
|
253
|
+
path: `batchInvoice.${entry.batchIndex}.${issue.path}`,
|
|
254
|
+
})),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function grossTotal(
|
|
259
|
+
summary: SummaryType,
|
|
260
|
+
which: 'amount' | 'huf',
|
|
261
|
+
scale: number,
|
|
262
|
+
mode: RoundingMode,
|
|
263
|
+
): string {
|
|
264
|
+
const parts: string[] = [];
|
|
265
|
+
if (summary.summaryNormal) {
|
|
266
|
+
parts.push(
|
|
267
|
+
which === 'amount'
|
|
268
|
+
? summary.summaryNormal.invoiceNetAmount
|
|
269
|
+
: summary.summaryNormal.invoiceNetAmountHUF,
|
|
270
|
+
which === 'amount'
|
|
271
|
+
? summary.summaryNormal.invoiceVatAmount
|
|
272
|
+
: summary.summaryNormal.invoiceVatAmountHUF,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
for (const entry of summary.summarySimplified ?? []) {
|
|
276
|
+
parts.push(which === 'amount' ? entry.vatContentGrossAmount : entry.vatContentGrossAmountHUF);
|
|
277
|
+
}
|
|
278
|
+
return total(parts, scale, mode);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* VAT of a line, stated or derived.
|
|
283
|
+
*
|
|
284
|
+
* `lineVatData` is optional in the schema, and NAV's own advance-invoice
|
|
285
|
+
* samples omit it: the line states its net amount and its rate, and the VAT
|
|
286
|
+
* follows from the two. Treating an absent `lineVatData` as zero would make
|
|
287
|
+
* every such invoice look unbalanced.
|
|
288
|
+
*
|
|
289
|
+
* When the rate is not a percentage — exempt, out of scope, reverse charge —
|
|
290
|
+
* there is no VAT to derive and the contribution is zero.
|
|
291
|
+
*/
|
|
292
|
+
function lineVatAmount(
|
|
293
|
+
line: LineType,
|
|
294
|
+
which: 'amount' | 'huf',
|
|
295
|
+
scale: number,
|
|
296
|
+
mode: RoundingMode,
|
|
297
|
+
): string {
|
|
298
|
+
const amounts = line.lineAmountsNormal!;
|
|
299
|
+
const stated =
|
|
300
|
+
which === 'amount' ? amounts.lineVatData?.lineVatAmount : amounts.lineVatData?.lineVatAmountHUF;
|
|
301
|
+
if (stated !== undefined) return stated;
|
|
302
|
+
|
|
303
|
+
const percentage = amounts.lineVatRate.vatPercentage;
|
|
304
|
+
if (percentage === undefined) return '0';
|
|
305
|
+
|
|
306
|
+
const net =
|
|
307
|
+
which === 'amount'
|
|
308
|
+
? amounts.lineNetAmountData.lineNetAmount
|
|
309
|
+
: amounts.lineNetAmountData.lineNetAmountHUF;
|
|
310
|
+
return Decimal.from(net).multiply(percentage).round(scale, mode).toString();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Whether any line carries amounts at all.
|
|
315
|
+
*
|
|
316
|
+
* A line-data modification document can reference lines of the original
|
|
317
|
+
* invoice without restating amounts. There is nothing to reconcile then, and
|
|
318
|
+
* reporting a mismatch would be a false positive.
|
|
319
|
+
*/
|
|
320
|
+
export function hasAmountBearingLines(invoice: InvoiceType): boolean {
|
|
321
|
+
return (invoice.invoiceLines?.line ?? []).some(
|
|
322
|
+
(line) => line.lineAmountsNormal !== undefined || line.lineAmountsSimplified !== undefined,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function total(values: string[], scale: number, mode: RoundingMode): string {
|
|
327
|
+
return sum(values).round(scale, mode).toString();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function groupBy(
|
|
331
|
+
lines: LineType[],
|
|
332
|
+
rateOf: (line: LineType) => VatRateType,
|
|
333
|
+
): Array<[VatRateType, LineType[]]> {
|
|
334
|
+
const groups = new Map<string, [VatRateType, LineType[]]>();
|
|
335
|
+
for (const line of lines) {
|
|
336
|
+
const rate = rateOf(line);
|
|
337
|
+
const key = vatRateKey(rate);
|
|
338
|
+
const existing = groups.get(key);
|
|
339
|
+
if (existing) existing[1].push(line);
|
|
340
|
+
else groups.set(key, [rate, [line]]);
|
|
341
|
+
}
|
|
342
|
+
return [...groups.values()];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Stable identity of a VAT rate.
|
|
347
|
+
*
|
|
348
|
+
* Numeric rates are compared by value, so `0.27` and `0.2700` group together.
|
|
349
|
+
*
|
|
350
|
+
* Exemption and out-of-scope rates are identified by their `case` — the legal
|
|
351
|
+
* ground code — and *not* by the accompanying `reason`, which is free text.
|
|
352
|
+
* NAV's own new-means-of-transport sample makes the point: the line cites
|
|
353
|
+
* case `KBAUK` with reason "Áfa tv. 89. §" while the summary cites the same
|
|
354
|
+
* case with reason "Adómentes Közösségen belüli új közlekedési eszköz
|
|
355
|
+
* értékesítés". Same rate, different prose. Grouping on the prose would split
|
|
356
|
+
* one rate into two and report a spurious mismatch.
|
|
357
|
+
*/
|
|
358
|
+
export function vatRateKey(rate: VatRateType): string {
|
|
359
|
+
const parts: string[] = [];
|
|
360
|
+
for (const key of Object.keys(rate).sort()) {
|
|
361
|
+
const value = (rate as Record<string, unknown>)[key];
|
|
362
|
+
if (value === undefined) continue;
|
|
363
|
+
if ((key === 'vatPercentage' || key === 'vatContent') && typeof value === 'string') {
|
|
364
|
+
parts.push(`${key}=${Decimal.from(value).round(6).toString()}`);
|
|
365
|
+
} else if (isDetailedReason(value)) {
|
|
366
|
+
parts.push(`${key}=${value.case}`);
|
|
367
|
+
} else {
|
|
368
|
+
parts.push(`${key}=${JSON.stringify(value)}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return parts.join('|');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isDetailedReason(value: unknown): value is { case: string } {
|
|
375
|
+
return (
|
|
376
|
+
typeof value === 'object' &&
|
|
377
|
+
value !== null &&
|
|
378
|
+
typeof (value as { case?: unknown }).case === 'string'
|
|
379
|
+
);
|
|
380
|
+
}
|
package/src/time.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NavValidationError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render the `header/timestamp` value: UTC, ISO 8601, millisecond precision,
|
|
5
|
+
* e.g. `2026-09-03T14:25:11.482Z`.
|
|
6
|
+
*/
|
|
7
|
+
export function toHeaderTimestamp(value: Date | string = new Date()): string {
|
|
8
|
+
return toDate(value).toISOString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render a timestamp the way the request signature consumes it: UTC,
|
|
13
|
+
* `yyyyMMddHHmmss`, truncated to whole seconds, no separators.
|
|
14
|
+
*
|
|
15
|
+
* This must correspond to the same instant as the `header/timestamp` of the
|
|
16
|
+
* request, otherwise NAV rejects the signature.
|
|
17
|
+
*/
|
|
18
|
+
export function toSignatureTimestamp(value: Date | string = new Date()): string {
|
|
19
|
+
const iso = toDate(value).toISOString();
|
|
20
|
+
// 2026-09-03T14:25:11.482Z -> 20260903142511
|
|
21
|
+
return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Render a NAV `date` field (`xs:date`) as `yyyy-MM-dd` in UTC. */
|
|
25
|
+
export function toNavDate(value: Date | string = new Date()): string {
|
|
26
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return toDate(value).toISOString().slice(0, 10);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toDate(value: Date | string): Date {
|
|
33
|
+
const date = typeof value === 'string' ? new Date(value) : value;
|
|
34
|
+
if (Number.isNaN(date.getTime())) {
|
|
35
|
+
throw new NavValidationError('Invalid timestamp', [
|
|
36
|
+
{
|
|
37
|
+
path: 'timestamp',
|
|
38
|
+
code: 'INVALID_TIMESTAMP',
|
|
39
|
+
message: `cannot be parsed as a date: ${JSON.stringify(value)}`,
|
|
40
|
+
},
|
|
41
|
+
]);
|
|
42
|
+
}
|
|
43
|
+
return date;
|
|
44
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { NavFaultCode } from '../generated/fault-codes.js';
|
|
2
|
+
import { NAV_FAULT_MESSAGES } from '../generated/fault-codes.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How much a finding matters.
|
|
6
|
+
*
|
|
7
|
+
* `error` means NAV would reject the report. `warning` means the data is
|
|
8
|
+
* accepted but suspect — a tax number whose check digit is wrong, say, which
|
|
9
|
+
* NAV validates against its taxpayer registry rather than arithmetically.
|
|
10
|
+
* Keeping the two apart is what makes the validator usable: a validator that
|
|
11
|
+
* cries wolf gets switched off.
|
|
12
|
+
*/
|
|
13
|
+
export type Severity = 'error' | 'warning';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Findings this library reports that NAV has no code for.
|
|
17
|
+
*
|
|
18
|
+
* Kept separate from NAV's codes rather than forced into an approximate one,
|
|
19
|
+
* so it is always clear whether a finding means "the service would say this"
|
|
20
|
+
* or "we noticed this".
|
|
21
|
+
*/
|
|
22
|
+
export type LocalFaultCode =
|
|
23
|
+
/** A tax number's check digit does not match. NAV checks its registry instead. */
|
|
24
|
+
| 'LOCAL_TAXPAYER_ID_CHECK_DIGIT'
|
|
25
|
+
/** A county code outside the set NAV is known to issue. */
|
|
26
|
+
| 'LOCAL_COUNTY_CODE_UNKNOWN'
|
|
27
|
+
/** A VAT percentage that is not a current Hungarian rate. */
|
|
28
|
+
| 'LOCAL_VAT_PERCENTAGE_UNUSUAL';
|
|
29
|
+
|
|
30
|
+
export type FaultCode = NavFaultCode | LocalFaultCode;
|
|
31
|
+
|
|
32
|
+
export interface InvoiceValidationIssue {
|
|
33
|
+
/**
|
|
34
|
+
* The fault code. Where NAV defines one it is used verbatim, so a local
|
|
35
|
+
* failure names exactly what the service would have said, and the compiler
|
|
36
|
+
* checks it against the generated catalogue.
|
|
37
|
+
*/
|
|
38
|
+
code: FaultCode;
|
|
39
|
+
/** `nav` for a code NAV defines, `local` for one of ours. */
|
|
40
|
+
origin: 'nav' | 'local';
|
|
41
|
+
severity: Severity;
|
|
42
|
+
/** Dotted path to the offending value, e.g. `invoiceLines.line.2.quantity`. */
|
|
43
|
+
path: string;
|
|
44
|
+
/** What is wrong, with the offending values quoted. */
|
|
45
|
+
message: string;
|
|
46
|
+
/** NAV's own description of the fault, in the requested language. */
|
|
47
|
+
navMessage?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ValidationReport {
|
|
51
|
+
/** True when there are no errors. Warnings do not make a report invalid. */
|
|
52
|
+
valid: boolean;
|
|
53
|
+
errors: InvoiceValidationIssue[];
|
|
54
|
+
warnings: InvoiceValidationIssue[];
|
|
55
|
+
/** Everything, in the order found. */
|
|
56
|
+
issues: InvoiceValidationIssue[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MessageLanguage = 'en' | 'hu' | 'de';
|
|
60
|
+
|
|
61
|
+
/** Collects findings while rules run, then assembles the report. */
|
|
62
|
+
export class IssueCollector {
|
|
63
|
+
private readonly collected: InvoiceValidationIssue[] = [];
|
|
64
|
+
|
|
65
|
+
constructor(private readonly language: MessageLanguage = 'en') {}
|
|
66
|
+
|
|
67
|
+
add(code: FaultCode, severity: Severity, path: string, message: string): void {
|
|
68
|
+
const navMessage = isNavCode(code) ? NAV_FAULT_MESSAGES[code][this.language] : undefined;
|
|
69
|
+
this.collected.push({
|
|
70
|
+
code,
|
|
71
|
+
origin: isNavCode(code) ? 'nav' : 'local',
|
|
72
|
+
severity,
|
|
73
|
+
path,
|
|
74
|
+
message,
|
|
75
|
+
...(navMessage ? { navMessage } : {}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
error(code: FaultCode, path: string, message: string): void {
|
|
80
|
+
this.add(code, 'error', path, message);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
warn(code: FaultCode, path: string, message: string): void {
|
|
84
|
+
this.add(code, 'warning', path, message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
report(): ValidationReport {
|
|
88
|
+
const errors = this.collected.filter((issue) => issue.severity === 'error');
|
|
89
|
+
const warnings = this.collected.filter((issue) => issue.severity === 'warning');
|
|
90
|
+
return { valid: errors.length === 0, errors, warnings, issues: [...this.collected] };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether a code is one NAV defines, as opposed to one of ours. */
|
|
95
|
+
export function isNavCode(code: FaultCode): code is NavFaultCode {
|
|
96
|
+
return code in NAV_FAULT_MESSAGES;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** NAV's description of a fault code, for surfacing in your own UI. */
|
|
100
|
+
export function faultMessage(code: FaultCode, language: MessageLanguage = 'en'): string {
|
|
101
|
+
return isNavCode(code) ? (NAV_FAULT_MESSAGES[code][language] ?? '') : '';
|
|
102
|
+
}
|