@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,315 @@
1
+ import { ROOTS, TYPES } from '../generated/schema.js';
2
+ import { Decimal } from '../money/decimal.js';
3
+ import { XS_PRIMITIVES, type PrimitiveKind, type SimpleDescriptor } from '../xml/descriptor.js';
4
+ import { IssueCollector } from './issue.js';
5
+
6
+ /**
7
+ * Validate a document against the schema, using the generated descriptors.
8
+ *
9
+ * This covers what an XSD validator would: mandatory elements, value
10
+ * constraints, cardinality and choice exclusivity. Doing it from the same
11
+ * descriptor table that drives serialisation means the two cannot disagree,
12
+ * and it needs no native XML library.
13
+ *
14
+ * NAV reports all of these as `SCHEMA_VIOLATION`, except a missing mandatory
15
+ * element which it reports as `MANDATORY_CONTENT_MISSING`.
16
+ */
17
+ export function collectSchemaIssues(
18
+ rootName: string,
19
+ value: unknown,
20
+ collector: IssueCollector,
21
+ ): void {
22
+ const root = ROOTS[rootName];
23
+ if (!root) {
24
+ collector.error('SCHEMA_VIOLATION', '', `unknown document root ${rootName}`);
25
+ return;
26
+ }
27
+ walk(root.type, value, '', collector);
28
+ }
29
+
30
+ function walk(typeKey: string, value: unknown, path: string, collector: IssueCollector): void {
31
+ const primitive = XS_PRIMITIVES[typeKey];
32
+ if (primitive) {
33
+ checkPrimitive(primitive, undefined, value, path, collector);
34
+ return;
35
+ }
36
+
37
+ const descriptor = TYPES[typeKey];
38
+ if (!descriptor) {
39
+ collector.error('SCHEMA_VIOLATION', path, `unknown type ${typeKey}`);
40
+ return;
41
+ }
42
+
43
+ if (descriptor.kind === 'simple') {
44
+ checkPrimitive(descriptor.primitive, descriptor, value, path, collector);
45
+ return;
46
+ }
47
+
48
+ if (descriptor.content === 'simple') {
49
+ if (!isRecord(value)) {
50
+ collector.error('SCHEMA_VIOLATION', path, `expected an object, got ${describe(value)}`);
51
+ return;
52
+ }
53
+ const contentType = descriptor.contentType;
54
+ if (contentType) walk(contentType, value['value'], `${path}.value`, collector);
55
+ for (const attribute of descriptor.attributes ?? []) {
56
+ const attributeValue = value[attribute.name];
57
+ if (attributeValue === undefined || attributeValue === null) {
58
+ if (attribute.required) {
59
+ collector.error(
60
+ 'MANDATORY_CONTENT_MISSING',
61
+ `${path}@${attribute.name}`,
62
+ 'attribute is required',
63
+ );
64
+ }
65
+ continue;
66
+ }
67
+ walk(attribute.type, attributeValue, `${path}@${attribute.name}`, collector);
68
+ }
69
+ return;
70
+ }
71
+
72
+ if (!isRecord(value)) {
73
+ collector.error('SCHEMA_VIOLATION', path, `expected an object, got ${describe(value)}`);
74
+ return;
75
+ }
76
+
77
+ for (const field of descriptor.fields) {
78
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
79
+ const fieldValue = value[field.name];
80
+
81
+ if (fieldValue === undefined || fieldValue === null) {
82
+ if (!field.optional) {
83
+ collector.error('MANDATORY_CONTENT_MISSING', fieldPath, 'is required');
84
+ }
85
+ continue;
86
+ }
87
+
88
+ if (field.repeated) {
89
+ if (!Array.isArray(fieldValue)) {
90
+ collector.error('SCHEMA_VIOLATION', fieldPath, 'is repeatable and expects an array');
91
+ continue;
92
+ }
93
+ if (fieldValue.length === 0) {
94
+ if (!field.optional) {
95
+ collector.error('MANDATORY_CONTENT_MISSING', fieldPath, 'must have at least one entry');
96
+ }
97
+ continue;
98
+ }
99
+ if (field.maxOccurs !== undefined && fieldValue.length > field.maxOccurs) {
100
+ collector.error(
101
+ 'SCHEMA_VIOLATION',
102
+ fieldPath,
103
+ `allows at most ${field.maxOccurs} entries, got ${fieldValue.length}`,
104
+ );
105
+ }
106
+ for (const [index, item] of fieldValue.entries()) {
107
+ walk(field.type, item, `${fieldPath}.${index}`, collector);
108
+ }
109
+ continue;
110
+ }
111
+
112
+ if (Array.isArray(fieldValue)) {
113
+ collector.error('SCHEMA_VIOLATION', fieldPath, 'occurs at most once');
114
+ continue;
115
+ }
116
+
117
+ walk(field.type, fieldValue, fieldPath, collector);
118
+ }
119
+
120
+ for (const key of Object.keys(value)) {
121
+ if (!descriptor.fields.some((field) => field.name === key)) {
122
+ const isAttribute = (descriptor.attributes ?? []).some((attribute) => attribute.name === key);
123
+ if (!isAttribute) {
124
+ collector.error(
125
+ 'SCHEMA_VIOLATION',
126
+ path ? `${path}.${key}` : key,
127
+ `is not part of ${descriptor.name}`,
128
+ );
129
+ }
130
+ }
131
+ }
132
+
133
+ // Choice groups: NAV nests these inside sequences, so they are checked
134
+ // against the flattened field list rather than by structural position.
135
+ for (const group of descriptor.choiceGroups ?? []) {
136
+ const present = group.members.filter(
137
+ (member) => value[member] !== undefined && value[member] !== null,
138
+ );
139
+ if (present.length > 1) {
140
+ collector.error(
141
+ 'SCHEMA_VIOLATION',
142
+ path,
143
+ `only one of ${group.members.join(', ')} may be set, found ${present.join(' and ')}`,
144
+ );
145
+ } else if (present.length === 0 && group.required) {
146
+ collector.error(
147
+ 'MANDATORY_CONTENT_MISSING',
148
+ path,
149
+ `one of ${group.members.join(', ')} is required`,
150
+ );
151
+ }
152
+ }
153
+ }
154
+
155
+ function checkPrimitive(
156
+ kind: PrimitiveKind,
157
+ descriptor: SimpleDescriptor | undefined,
158
+ value: unknown,
159
+ path: string,
160
+ collector: IssueCollector,
161
+ ): void {
162
+ switch (kind) {
163
+ case 'boolean':
164
+ if (typeof value !== 'boolean') {
165
+ collector.error('SCHEMA_VIOLATION', path, `expected a boolean, got ${describe(value)}`);
166
+ }
167
+ return;
168
+
169
+ case 'integer': {
170
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
171
+ collector.error('SCHEMA_VIOLATION', path, `expected an integer, got ${describe(value)}`);
172
+ return;
173
+ }
174
+ // Numeric comparison, not lexicographic: '2' sorts after '100' as text.
175
+ checkBounds(String(value), descriptor, path, collector, true);
176
+ if (descriptor?.totalDigits !== undefined) {
177
+ const digits = Math.abs(value).toString().length;
178
+ if (digits > descriptor.totalDigits) {
179
+ collector.error(
180
+ 'SCHEMA_VIOLATION',
181
+ path,
182
+ `has ${digits} digits, at most ${descriptor.totalDigits} allowed`,
183
+ );
184
+ }
185
+ }
186
+ return;
187
+ }
188
+
189
+ case 'decimal': {
190
+ if (typeof value !== 'string' && typeof value !== 'number') {
191
+ collector.error('SCHEMA_VIOLATION', path, `expected a decimal, got ${describe(value)}`);
192
+ return;
193
+ }
194
+ let amount: Decimal;
195
+ try {
196
+ amount = Decimal.from(value);
197
+ } catch {
198
+ collector.error('SCHEMA_VIOLATION', path, `${JSON.stringify(value)} is not a decimal`);
199
+ return;
200
+ }
201
+ if (descriptor?.fractionDigits !== undefined && amount.scale > descriptor.fractionDigits) {
202
+ collector.error(
203
+ 'SCHEMA_VIOLATION',
204
+ path,
205
+ `has ${amount.scale} decimal places, at most ${descriptor.fractionDigits} allowed`,
206
+ );
207
+ }
208
+ if (descriptor?.totalDigits !== undefined && amount.totalDigits() > descriptor.totalDigits) {
209
+ collector.error(
210
+ 'SCHEMA_VIOLATION',
211
+ path,
212
+ `has ${amount.totalDigits()} significant digits, at most ${descriptor.totalDigits} allowed`,
213
+ );
214
+ }
215
+ checkBounds(amount.toString(), descriptor, path, collector, true);
216
+ return;
217
+ }
218
+
219
+ default: {
220
+ if (typeof value !== 'string') {
221
+ collector.error('SCHEMA_VIOLATION', path, `expected a string, got ${describe(value)}`);
222
+ return;
223
+ }
224
+ if (!descriptor) return;
225
+ if (descriptor.enumValues && !descriptor.enumValues.includes(value)) {
226
+ collector.error(
227
+ 'SCHEMA_VIOLATION',
228
+ path,
229
+ `${JSON.stringify(value)} is not one of ${descriptor.enumValues.join(', ')}`,
230
+ );
231
+ }
232
+ if (descriptor.length !== undefined && value.length !== descriptor.length) {
233
+ collector.error(
234
+ 'SCHEMA_VIOLATION',
235
+ path,
236
+ `must be exactly ${descriptor.length} characters, got ${value.length}`,
237
+ );
238
+ }
239
+ if (descriptor.minLength !== undefined && value.length < descriptor.minLength) {
240
+ collector.error(
241
+ 'SCHEMA_VIOLATION',
242
+ path,
243
+ `must be at least ${descriptor.minLength} characters, got ${value.length}`,
244
+ );
245
+ }
246
+ if (descriptor.maxLength !== undefined && value.length > descriptor.maxLength) {
247
+ collector.error(
248
+ 'SCHEMA_VIOLATION',
249
+ path,
250
+ `must be at most ${descriptor.maxLength} characters, got ${value.length}`,
251
+ );
252
+ }
253
+ if (descriptor.pattern !== undefined && !anchored(descriptor.pattern).test(value)) {
254
+ collector.error(
255
+ 'SCHEMA_VIOLATION',
256
+ path,
257
+ `${JSON.stringify(value)} does not match ${descriptor.pattern}`,
258
+ );
259
+ }
260
+ // Dates and timestamps carry their bounds as text, and NAV's are all
261
+ // lexicographically comparable ISO forms.
262
+ checkBounds(value, descriptor, path, collector);
263
+ return;
264
+ }
265
+ }
266
+ }
267
+
268
+ function checkBounds(
269
+ value: string,
270
+ descriptor: SimpleDescriptor | undefined,
271
+ path: string,
272
+ collector: IssueCollector,
273
+ numeric = false,
274
+ ): void {
275
+ if (!descriptor) return;
276
+ const compare = numeric
277
+ ? (left: string, right: string) => Decimal.from(left).compare(right)
278
+ : (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
279
+
280
+ const checks: Array<[keyof SimpleDescriptor, (result: number) => boolean, string]> = [
281
+ ['minInclusive', (result) => result < 0, 'must not be less than'],
282
+ ['maxInclusive', (result) => result > 0, 'must not be greater than'],
283
+ ['minExclusive', (result) => result <= 0, 'must be greater than'],
284
+ ['maxExclusive', (result) => result >= 0, 'must be less than'],
285
+ ];
286
+
287
+ for (const [facet, violates, wording] of checks) {
288
+ const bound = descriptor[facet];
289
+ if (typeof bound !== 'string') continue;
290
+ if (violates(compare(value, bound))) {
291
+ collector.error('SCHEMA_VIOLATION', path, `${wording} ${bound}, got ${value}`);
292
+ }
293
+ }
294
+ }
295
+
296
+ const anchoredCache = new Map<string, RegExp>();
297
+
298
+ /** XSD patterns match the whole value, unlike JavaScript regular expressions. */
299
+ function anchored(pattern: string): RegExp {
300
+ const cached = anchoredCache.get(pattern);
301
+ if (cached) return cached;
302
+ const compiled = new RegExp(`^(?:${pattern})$`, 'u');
303
+ anchoredCache.set(pattern, compiled);
304
+ return compiled;
305
+ }
306
+
307
+ function isRecord(value: unknown): value is Record<string, unknown> {
308
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
309
+ }
310
+
311
+ function describe(value: unknown): string {
312
+ if (value === null) return 'null';
313
+ if (Array.isArray(value)) return 'an array';
314
+ return typeof value;
315
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Hungarian tax number checks.
3
+ *
4
+ * A tax number is eleven digits in three parts: an eight digit core
5
+ * (törzsszám) whose last digit is a check digit, a one digit VAT code
6
+ * (áfakód), and a two digit county code (megyekód). NAV's schemas carry the
7
+ * three parts separately, in `taxpayerId`, `vatCode` and `countyCode`.
8
+ */
9
+
10
+ /** Weights applied to the first seven digits of the core number. */
11
+ const CHECK_WEIGHTS = [9, 7, 3, 1, 9, 7, 3] as const;
12
+
13
+ /**
14
+ * County codes NAV issues.
15
+ *
16
+ * This set is **not** in the XSD, which only constrains the field to two
17
+ * digits, so it is best-effort and is reported as a warning rather than an
18
+ * error. Budapest and Pest county use 41 to 44; 51 is used for taxpayers
19
+ * registered by the large taxpayers directorate.
20
+ */
21
+ const COUNTY_CODES = new Set([
22
+ ...range(2, 20), // the counties, alphabetically
23
+ ...range(22, 44), // remaining counties plus the capital
24
+ 51,
25
+ ]);
26
+
27
+ function range(from: number, to: number): number[] {
28
+ return Array.from({ length: to - from + 1 }, (_, index) => from + index);
29
+ }
30
+
31
+ /**
32
+ * Whether the check digit of an eight digit core tax number is consistent.
33
+ *
34
+ * Verified against the real tax numbers in NAV's published samples. Note that
35
+ * two of the four tax numbers those samples use (`98765432`, `87654321`) are
36
+ * obvious placeholders and do *not* satisfy the check — evidence that NAV
37
+ * validates a tax number against its taxpayer registry rather than
38
+ * arithmetically. Treat a failure here as a likely typo, and use
39
+ * `queryTaxpayer` when you need an authoritative answer.
40
+ */
41
+ export function isValidTaxpayerId(taxpayerId: string): boolean {
42
+ if (!/^\d{8}$/.test(taxpayerId)) return false;
43
+ const sum = CHECK_WEIGHTS.reduce(
44
+ (total, weight, index) => total + Number(taxpayerId[index]) * weight,
45
+ 0,
46
+ );
47
+ return (10 - (sum % 10)) % 10 === Number(taxpayerId[7]);
48
+ }
49
+
50
+ /** The check digit an eight digit core tax number should end with. */
51
+ export function taxpayerIdCheckDigit(firstSevenDigits: string): number {
52
+ if (!/^\d{7}$/.test(firstSevenDigits)) {
53
+ throw new Error(`Expected seven digits, got ${JSON.stringify(firstSevenDigits)}`);
54
+ }
55
+ const sum = CHECK_WEIGHTS.reduce(
56
+ (total, weight, index) => total + Number(firstSevenDigits[index]) * weight,
57
+ 0,
58
+ );
59
+ return (10 - (sum % 10)) % 10;
60
+ }
61
+
62
+ /** Whether a VAT code is one NAV defines. */
63
+ export function isValidVatCode(vatCode: string): boolean {
64
+ return /^[1-5]$/.test(vatCode);
65
+ }
66
+
67
+ /** Whether a county code is one NAV issues. Best-effort; see COUNTY_CODES. */
68
+ export function isValidCountyCode(countyCode: string): boolean {
69
+ return /^\d{2}$/.test(countyCode) && COUNTY_CODES.has(Number(countyCode));
70
+ }
71
+
72
+ export interface ParsedTaxNumber {
73
+ taxpayerId: string;
74
+ vatCode?: string;
75
+ countyCode?: string;
76
+ }
77
+
78
+ /**
79
+ * Split an eleven digit tax number into the three parts NAV's schema wants.
80
+ *
81
+ * Accepts the common written forms — `12345678-2-41`, `12345678 2 41` and
82
+ * `12345678241` — because that is how the number appears on paper, and
83
+ * pasting it whole into `taxpayerId` is the mistake this avoids.
84
+ */
85
+ export function parseTaxNumber(input: string): ParsedTaxNumber {
86
+ const digits = input.replace(/[\s-]/g, '');
87
+ if (/^\d{8}$/.test(digits)) return { taxpayerId: digits };
88
+ if (/^\d{11}$/.test(digits)) {
89
+ return {
90
+ taxpayerId: digits.slice(0, 8),
91
+ vatCode: digits.slice(8, 9),
92
+ countyCode: digits.slice(9, 11),
93
+ };
94
+ }
95
+ throw new Error(`Expected an 8 or 11 digit Hungarian tax number, got ${JSON.stringify(input)}`);
96
+ }
@@ -0,0 +1,45 @@
1
+ import type { InvoiceData } from '../generated/types.js';
2
+ import { IssueCollector, type MessageLanguage, type ValidationReport } from './issue.js';
3
+ import { collectSchemaIssues } from './schema.js';
4
+ import { collectBusinessIssues, type InvoiceValidationContext } from './rules.js';
5
+
6
+ export interface ValidateInvoiceOptions extends InvoiceValidationContext {
7
+ /** Language for NAV's own fault descriptions. Defaults to English. */
8
+ language?: MessageLanguage;
9
+ /** Skip the business rules and check only the schema. */
10
+ schemaOnly?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Check an invoice data report before sending it.
15
+ *
16
+ * Two layers run: the schema, derived from NAV's XSDs, and the business rules
17
+ * that are decidable from the document alone. Every finding carries NAV's own
18
+ * fault code where one exists, so a failure here reads the same as the
19
+ * rejection it prevents.
20
+ *
21
+ * Warnings do not make a report invalid. The distinction matters: a tax number
22
+ * whose check digit is wrong is worth flagging, but NAV validates tax numbers
23
+ * against its registry, so treating it as an error would reject documents the
24
+ * service accepts.
25
+ *
26
+ * ```ts
27
+ * const report = validateInvoice(invoice, { operation: 'CREATE' });
28
+ * if (!report.valid) console.error(report.errors);
29
+ * ```
30
+ */
31
+ export function validateInvoice(
32
+ document: InvoiceData,
33
+ options: ValidateInvoiceOptions = {},
34
+ ): ValidationReport {
35
+ const collector = new IssueCollector(options.language ?? 'en');
36
+ collectSchemaIssues('InvoiceData', document, collector);
37
+
38
+ const report = collector.report();
39
+ // Business rules read the document as typed data; running them over a
40
+ // structurally invalid document would produce noise, not findings.
41
+ if (options.schemaOnly || report.errors.length > 0) return report;
42
+
43
+ collectBusinessIssues(document, collector, options);
44
+ return collector.report();
45
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Runtime description of the NAV schemas.
3
+ *
4
+ * The generated `TYPES` table in `../generated/schema.js` is the single source
5
+ * of truth for element order, namespaces, cardinality, choice exclusivity and
6
+ * value constraints. Serialisation, parsing and validation are all driven from
7
+ * it, so moving to a future interface version is a regeneration rather than a
8
+ * rewrite of three separate hand-maintained code paths.
9
+ */
10
+
11
+ /** Short key for each namespace in the Online Számla schemas. */
12
+ export type NsKey = 'common' | 'base' | 'data' | 'api' | 'annul' | 'metrics';
13
+
14
+ export const NAMESPACE_URIS: Record<NsKey, string> = {
15
+ common: 'http://schemas.nav.gov.hu/NTCA/1.0/common',
16
+ base: 'http://schemas.nav.gov.hu/OSA/3.0/base',
17
+ data: 'http://schemas.nav.gov.hu/OSA/3.0/data',
18
+ api: 'http://schemas.nav.gov.hu/OSA/3.0/api',
19
+ annul: 'http://schemas.nav.gov.hu/OSA/3.0/annul',
20
+ metrics: 'http://schemas.nav.gov.hu/OSA/3.0/metrics',
21
+ };
22
+
23
+ /** How a value is carried in XML. */
24
+ export type PrimitiveKind =
25
+ 'string' | 'decimal' | 'integer' | 'boolean' | 'date' | 'dateTime' | 'base64Binary';
26
+
27
+ export interface FieldDescriptor {
28
+ /** Element local name. */
29
+ name: string;
30
+ /** Namespace that *declares* this element, which may differ from its parent's. */
31
+ ns: NsKey;
32
+ /** Key into the `TYPES` table, or an `xs:` primitive. */
33
+ type: string;
34
+ /** `minOccurs="0"`. Choice members are always optional. */
35
+ optional?: boolean;
36
+ /** `maxOccurs` greater than one, or unbounded. */
37
+ repeated?: boolean;
38
+ /** Upper bound when the schema states a finite one. */
39
+ maxOccurs?: number;
40
+ }
41
+
42
+ export interface ChoiceGroupDescriptor {
43
+ members: string[];
44
+ /** `true` when exactly one member must be present; `false` when at most one may be. */
45
+ required: boolean;
46
+ }
47
+
48
+ export interface AttributeDescriptor {
49
+ name: string;
50
+ type: string;
51
+ required: boolean;
52
+ }
53
+
54
+ export interface ComplexDescriptor {
55
+ kind: 'complex';
56
+ name: string;
57
+ ns: NsKey;
58
+ /** Name of the corresponding generated TypeScript type. */
59
+ tsName: string;
60
+ /** `element` for child elements, `simple` for text content plus attributes. */
61
+ content: 'element' | 'simple';
62
+ /** Type of the text value when `content` is `simple`. */
63
+ contentType?: string;
64
+ /** Child elements in schema order — which is the order NAV requires. */
65
+ fields: FieldDescriptor[];
66
+ choiceGroups?: ChoiceGroupDescriptor[];
67
+ attributes?: AttributeDescriptor[];
68
+ }
69
+
70
+ export interface SimpleDescriptor {
71
+ kind: 'simple';
72
+ name: string;
73
+ ns: NsKey;
74
+ tsName: string;
75
+ primitive: PrimitiveKind;
76
+ /** Permitted values, when the type is an enumeration. */
77
+ enumValues?: string[];
78
+ /** Most derived `xs:pattern` of the restriction chain. */
79
+ pattern?: string;
80
+ length?: number;
81
+ minLength?: number;
82
+ maxLength?: number;
83
+ totalDigits?: number;
84
+ fractionDigits?: number;
85
+ minInclusive?: string;
86
+ maxInclusive?: string;
87
+ minExclusive?: string;
88
+ maxExclusive?: string;
89
+ }
90
+
91
+ export type Descriptor = ComplexDescriptor | SimpleDescriptor;
92
+
93
+ export interface RootDescriptor {
94
+ name: string;
95
+ ns: NsKey;
96
+ type: string;
97
+ }
98
+
99
+ /** Primitive kinds of the `xs:` built-ins referenced directly by the schemas. */
100
+ export const XS_PRIMITIVES: Record<string, PrimitiveKind> = {
101
+ 'xs:string': 'string',
102
+ 'xs:decimal': 'decimal',
103
+ 'xs:int': 'integer',
104
+ 'xs:integer': 'integer',
105
+ 'xs:long': 'integer',
106
+ 'xs:nonNegativeInteger': 'integer',
107
+ 'xs:positiveInteger': 'integer',
108
+ 'xs:boolean': 'boolean',
109
+ 'xs:date': 'date',
110
+ 'xs:dateTime': 'dateTime',
111
+ 'xs:base64Binary': 'base64Binary',
112
+ };