@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.
Files changed (133) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +132 -0
  3. package/dist/constants.d.ts +49 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +58 -0
  6. package/dist/constants.js.map +1 -0
  7. package/dist/crypto/hash.d.ts +16 -0
  8. package/dist/crypto/hash.d.ts.map +1 -0
  9. package/dist/crypto/hash.js +30 -0
  10. package/dist/crypto/hash.js.map +1 -0
  11. package/dist/crypto/node-provider.d.ts +9 -0
  12. package/dist/crypto/node-provider.d.ts.map +1 -0
  13. package/dist/crypto/node-provider.js +18 -0
  14. package/dist/crypto/node-provider.js.map +1 -0
  15. package/dist/crypto/provider.d.ts +28 -0
  16. package/dist/crypto/provider.d.ts.map +1 -0
  17. package/dist/crypto/provider.js +14 -0
  18. package/dist/crypto/provider.js.map +1 -0
  19. package/dist/crypto/request-id.d.ts +16 -0
  20. package/dist/crypto/request-id.d.ts.map +1 -0
  21. package/dist/crypto/request-id.js +59 -0
  22. package/dist/crypto/request-id.js.map +1 -0
  23. package/dist/crypto/signature.d.ts +40 -0
  24. package/dist/crypto/signature.d.ts.map +1 -0
  25. package/dist/crypto/signature.js +38 -0
  26. package/dist/crypto/signature.js.map +1 -0
  27. package/dist/crypto/token.d.ts +14 -0
  28. package/dist/crypto/token.d.ts.map +1 -0
  29. package/dist/crypto/token.js +82 -0
  30. package/dist/crypto/token.js.map +1 -0
  31. package/dist/crypto/web-provider.d.ts +18 -0
  32. package/dist/crypto/web-provider.d.ts.map +1 -0
  33. package/dist/crypto/web-provider.js +31 -0
  34. package/dist/crypto/web-provider.js.map +1 -0
  35. package/dist/errors.d.ts +75 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +71 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/generated/fault-codes.d.ts +1343 -0
  40. package/dist/generated/fault-codes.d.ts.map +1 -0
  41. package/dist/generated/fault-codes.js +1331 -0
  42. package/dist/generated/fault-codes.js.map +1 -0
  43. package/dist/generated/index.d.ts +4 -0
  44. package/dist/generated/index.d.ts.map +1 -0
  45. package/dist/generated/index.js +5 -0
  46. package/dist/generated/index.js.map +1 -0
  47. package/dist/generated/schema.d.ts +13 -0
  48. package/dist/generated/schema.d.ts.map +1 -0
  49. package/dist/generated/schema.js +3756 -0
  50. package/dist/generated/schema.js.map +1 -0
  51. package/dist/generated/types.d.ts +5580 -0
  52. package/dist/generated/types.d.ts.map +1 -0
  53. package/dist/generated/types.js +6 -0
  54. package/dist/generated/types.js.map +1 -0
  55. package/dist/index.d.ts +23 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +23 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/invoice/payload.d.ts +40 -0
  60. package/dist/invoice/payload.d.ts.map +1 -0
  61. package/dist/invoice/payload.js +66 -0
  62. package/dist/invoice/payload.js.map +1 -0
  63. package/dist/money/decimal.d.ts +69 -0
  64. package/dist/money/decimal.d.ts.map +1 -0
  65. package/dist/money/decimal.js +190 -0
  66. package/dist/money/decimal.js.map +1 -0
  67. package/dist/money/summary.d.ts +60 -0
  68. package/dist/money/summary.d.ts.map +1 -0
  69. package/dist/money/summary.js +236 -0
  70. package/dist/money/summary.js.map +1 -0
  71. package/dist/time.d.ts +16 -0
  72. package/dist/time.d.ts.map +1 -0
  73. package/dist/time.js +41 -0
  74. package/dist/time.js.map +1 -0
  75. package/dist/validation/issue.d.ts +67 -0
  76. package/dist/validation/issue.d.ts.map +1 -0
  77. package/dist/validation/issue.js +40 -0
  78. package/dist/validation/issue.js.map +1 -0
  79. package/dist/validation/rules.d.ts +34 -0
  80. package/dist/validation/rules.d.ts.map +1 -0
  81. package/dist/validation/rules.js +290 -0
  82. package/dist/validation/rules.js.map +1 -0
  83. package/dist/validation/schema.d.ts +14 -0
  84. package/dist/validation/schema.d.ts.map +1 -0
  85. package/dist/validation/schema.js +231 -0
  86. package/dist/validation/schema.js.map +1 -0
  87. package/dist/validation/tax-number.d.ts +39 -0
  88. package/dist/validation/tax-number.d.ts.map +1 -0
  89. package/dist/validation/tax-number.js +79 -0
  90. package/dist/validation/tax-number.js.map +1 -0
  91. package/dist/validation/validate.d.ts +29 -0
  92. package/dist/validation/validate.d.ts.map +1 -0
  93. package/dist/validation/validate.js +33 -0
  94. package/dist/validation/validate.js.map +1 -0
  95. package/dist/xml/descriptor.d.ts +82 -0
  96. package/dist/xml/descriptor.d.ts.map +1 -0
  97. package/dist/xml/descriptor.js +32 -0
  98. package/dist/xml/descriptor.js.map +1 -0
  99. package/dist/xml/read.d.ts +25 -0
  100. package/dist/xml/read.d.ts.map +1 -0
  101. package/dist/xml/read.js +230 -0
  102. package/dist/xml/read.js.map +1 -0
  103. package/dist/xml/write.d.ts +28 -0
  104. package/dist/xml/write.d.ts.map +1 -0
  105. package/dist/xml/write.js +299 -0
  106. package/dist/xml/write.js.map +1 -0
  107. package/package.json +55 -0
  108. package/src/constants.ts +76 -0
  109. package/src/crypto/hash.ts +35 -0
  110. package/src/crypto/node-provider.ts +19 -0
  111. package/src/crypto/provider.ts +37 -0
  112. package/src/crypto/request-id.ts +63 -0
  113. package/src/crypto/signature.ts +58 -0
  114. package/src/crypto/token.ts +86 -0
  115. package/src/crypto/web-provider.ts +32 -0
  116. package/src/errors.ts +109 -0
  117. package/src/generated/fault-codes.ts +1338 -0
  118. package/src/generated/index.ts +4 -0
  119. package/src/generated/schema.ts +3760 -0
  120. package/src/generated/types.ts +5829 -0
  121. package/src/index.ts +22 -0
  122. package/src/invoice/payload.ts +102 -0
  123. package/src/money/decimal.ts +218 -0
  124. package/src/money/summary.ts +380 -0
  125. package/src/time.ts +44 -0
  126. package/src/validation/issue.ts +102 -0
  127. package/src/validation/rules.ts +548 -0
  128. package/src/validation/schema.ts +315 -0
  129. package/src/validation/tax-number.ts +96 -0
  130. package/src/validation/validate.ts +45 -0
  131. package/src/xml/descriptor.ts +112 -0
  132. package/src/xml/read.ts +303 -0
  133. 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
+ }