@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,548 @@
|
|
|
1
|
+
import type { InvoiceData, InvoiceType, LineType, SummaryType } from '../generated/types.js';
|
|
2
|
+
import { Decimal } from '../money/decimal.js';
|
|
3
|
+
import { checkInvoiceSummary, hasAmountBearingLines } from '../money/summary.js';
|
|
4
|
+
import type { NavFaultCode } from '../generated/fault-codes.js';
|
|
5
|
+
import type { IssueCollector } from './issue.js';
|
|
6
|
+
import { isValidCountyCode, isValidTaxpayerId } from './tax-number.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Business rules that are decidable from the document alone.
|
|
10
|
+
*
|
|
11
|
+
* Each rule reports NAV's own fault code where one exists, so a local failure
|
|
12
|
+
* names exactly what the service would have said. Rules that need state only
|
|
13
|
+
* NAV holds — whether an invoice number was used before, whether a referenced
|
|
14
|
+
* invoice exists, whether a tax number is registered — cannot be checked here
|
|
15
|
+
* and are listed in `LOCALLY_UNDECIDABLE` for the record.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface InvoiceValidationContext {
|
|
19
|
+
/**
|
|
20
|
+
* The operation the document will be submitted under.
|
|
21
|
+
*
|
|
22
|
+
* Several rules depend on it: an original invoice must not reference
|
|
23
|
+
* another document, and a modification must.
|
|
24
|
+
*/
|
|
25
|
+
operation?: 'CREATE' | 'MODIFY' | 'STORNO';
|
|
26
|
+
/**
|
|
27
|
+
* The tax number the request authenticates as. NAV rejects a report whose
|
|
28
|
+
* supplier is someone else.
|
|
29
|
+
*/
|
|
30
|
+
supplierTaxNumber?: string;
|
|
31
|
+
/** Today's date as `yyyy-mm-dd`, for the date rules. Defaults to the system date. */
|
|
32
|
+
today?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Current Hungarian VAT rates, for a plausibility warning only. */
|
|
36
|
+
const KNOWN_VAT_RATES = ['0', '0.05', '0.18', '0.27'];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Faults NAV reports that no local validator can decide, because they depend
|
|
40
|
+
* on NAV's own records. Documented so the coverage of this module is honest.
|
|
41
|
+
*/
|
|
42
|
+
export const LOCALLY_UNDECIDABLE = [
|
|
43
|
+
'INVOICE_NUMBER_NOT_UNIQUE',
|
|
44
|
+
'INVALID_INVOICE_REFERENCE',
|
|
45
|
+
'INVALID_ANNULMENT_REFERENCE',
|
|
46
|
+
'MULTIPLE_INVOICES_FOUND',
|
|
47
|
+
'MODIFICATION_INDEX_NOT_UNIQUE',
|
|
48
|
+
'MODIFY_WITHOUT_MASTER_MISMATCH',
|
|
49
|
+
'INVOICE_TYPE_MISMATCH',
|
|
50
|
+
'INVOICE_LINE_ALREADY_EXISTS',
|
|
51
|
+
'ANNULMENT_IN_PROGRESS',
|
|
52
|
+
'CUSTOMER_NOT_ASSIGNED',
|
|
53
|
+
'INCORRECT_HEAD_DATA_CUSTOMER_TAX_NUMBER',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
export function collectBusinessIssues(
|
|
57
|
+
document: InvoiceData,
|
|
58
|
+
collector: IssueCollector,
|
|
59
|
+
context: InvoiceValidationContext = {},
|
|
60
|
+
): void {
|
|
61
|
+
checkDocument(document, collector, context);
|
|
62
|
+
|
|
63
|
+
const single = document.invoiceMain.invoice;
|
|
64
|
+
if (single) {
|
|
65
|
+
checkInvoice(single, '', collector, context);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const batch = document.invoiceMain.batchInvoice ?? [];
|
|
70
|
+
// A batch modification document exists to amend several invoices at once;
|
|
71
|
+
// one entry means it should have been a plain modification.
|
|
72
|
+
if (batch.length === 1) {
|
|
73
|
+
collector.error(
|
|
74
|
+
'BATCH_INVOICE_CARDINALITY_ERROR',
|
|
75
|
+
'invoiceMain.batchInvoice',
|
|
76
|
+
'a batch document must amend at least two invoices, found 1',
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
let previous = 0;
|
|
80
|
+
for (const [position, entry] of batch.entries()) {
|
|
81
|
+
if (entry.batchIndex <= previous) {
|
|
82
|
+
collector.error(
|
|
83
|
+
'BATCH_INDEX_NOT_SEQUENTIAL',
|
|
84
|
+
`invoiceMain.batchInvoice.${position}.batchIndex`,
|
|
85
|
+
`must be strictly ascending, ${entry.batchIndex} follows ${previous}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
previous = entry.batchIndex;
|
|
89
|
+
checkInvoice(entry.invoice, `invoiceMain.batchInvoice.${position}.`, collector, context);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function checkDocument(
|
|
94
|
+
document: InvoiceData,
|
|
95
|
+
collector: IssueCollector,
|
|
96
|
+
context: InvoiceValidationContext,
|
|
97
|
+
): void {
|
|
98
|
+
const today = context.today ?? new Date().toISOString().slice(0, 10);
|
|
99
|
+
if (document.invoiceIssueDate > today) {
|
|
100
|
+
collector.error(
|
|
101
|
+
'INCORRECT_DATE_INVOICE_ISSUE_DATE_LATE',
|
|
102
|
+
'invoiceIssueDate',
|
|
103
|
+
`is in the future: ${document.invoiceIssueDate} (today is ${today})`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// completenessIndicator asserts that the report *is* the invoice, which only
|
|
108
|
+
// makes sense for an invoice that exists in electronic form.
|
|
109
|
+
if (document.completenessIndicator) {
|
|
110
|
+
const appearance = document.invoiceMain.invoice?.invoiceHead.invoiceDetail.invoiceAppearance;
|
|
111
|
+
if (appearance !== undefined && appearance !== 'ELECTRONIC') {
|
|
112
|
+
collector.error(
|
|
113
|
+
'INVOICE_COMPLETENESS_NOT_ALLOWED',
|
|
114
|
+
'completenessIndicator',
|
|
115
|
+
`is set, which requires invoiceAppearance ELECTRONIC, but it is ${appearance}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (document.invoiceMain.batchInvoice !== undefined) {
|
|
119
|
+
collector.error(
|
|
120
|
+
'INVOICE_COMPLETENESS_NOT_ALLOWED',
|
|
121
|
+
'completenessIndicator',
|
|
122
|
+
'cannot be set on a batch document',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function checkInvoice(
|
|
129
|
+
invoice: InvoiceType,
|
|
130
|
+
prefix: string,
|
|
131
|
+
collector: IssueCollector,
|
|
132
|
+
context: InvoiceValidationContext,
|
|
133
|
+
): void {
|
|
134
|
+
const at = (path: string): string => `${prefix}${path}`;
|
|
135
|
+
const head = invoice.invoiceHead;
|
|
136
|
+
const detail = head.invoiceDetail;
|
|
137
|
+
const lines = invoice.invoiceLines?.line ?? [];
|
|
138
|
+
const isModification = context.operation === 'MODIFY' || context.operation === 'STORNO';
|
|
139
|
+
|
|
140
|
+
// --- references -------------------------------------------------------
|
|
141
|
+
if (isModification && invoice.invoiceReference === undefined) {
|
|
142
|
+
collector.error(
|
|
143
|
+
'INVOICE_REFERENCE_EXPECTED',
|
|
144
|
+
at('invoiceReference'),
|
|
145
|
+
`is required for a ${context.operation} operation`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (context.operation === 'CREATE' && invoice.invoiceReference !== undefined) {
|
|
149
|
+
collector.error(
|
|
150
|
+
'INVOICE_REFERENCE_NOT_EXPECTED',
|
|
151
|
+
at('invoiceReference'),
|
|
152
|
+
'an original invoice must not reference another document',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- parties ----------------------------------------------------------
|
|
157
|
+
const supplierId = head.supplierInfo.supplierTaxNumber.taxpayerId;
|
|
158
|
+
if (context.supplierTaxNumber !== undefined && supplierId !== context.supplierTaxNumber) {
|
|
159
|
+
collector.error(
|
|
160
|
+
'SUPPLIER_TAX_NUMBER_MISMATCH',
|
|
161
|
+
at('invoiceHead.supplierInfo.supplierTaxNumber.taxpayerId'),
|
|
162
|
+
`is ${supplierId} but the request authenticates as ${context.supplierTaxNumber}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
checkTaxNumber(
|
|
166
|
+
supplierId,
|
|
167
|
+
head.supplierInfo.supplierTaxNumber.countyCode,
|
|
168
|
+
at('invoiceHead.supplierInfo.supplierTaxNumber'),
|
|
169
|
+
collector,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const customer = head.customerInfo;
|
|
173
|
+
const customerId = customer?.customerVatData?.customerTaxNumber?.taxpayerId;
|
|
174
|
+
if (customer) {
|
|
175
|
+
checkCustomer(customer, at('invoiceHead.customerInfo'), collector);
|
|
176
|
+
}
|
|
177
|
+
if (customerId !== undefined && customerId === supplierId) {
|
|
178
|
+
collector.error(
|
|
179
|
+
'SUPPLIER_CUSTOMER_MATCH_TAXPAYER',
|
|
180
|
+
at('invoiceHead.customerInfo'),
|
|
181
|
+
`the customer's tax number ${customerId} is the supplier's own`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- dates ------------------------------------------------------------
|
|
186
|
+
if (
|
|
187
|
+
detail.invoiceDeliveryPeriodStart !== undefined &&
|
|
188
|
+
detail.invoiceDeliveryPeriodEnd !== undefined &&
|
|
189
|
+
detail.invoiceDeliveryPeriodStart > detail.invoiceDeliveryPeriodEnd
|
|
190
|
+
) {
|
|
191
|
+
collector.error(
|
|
192
|
+
'INCORRECT_DATE_INVOICE_DELIVERY_TO_FROM',
|
|
193
|
+
at('invoiceHead.invoiceDetail.invoiceDeliveryPeriodStart'),
|
|
194
|
+
`${detail.invoiceDeliveryPeriodStart} is after the period end ${detail.invoiceDeliveryPeriodEnd}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- lines ------------------------------------------------------------
|
|
199
|
+
if (lines.length === 0 && context.operation === 'CREATE') {
|
|
200
|
+
collector.error('INVOICE_LINE_MISSING', at('invoiceLines'), 'an invoice must have lines');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let previousLineNumber = 0;
|
|
204
|
+
for (const [position, line] of lines.entries()) {
|
|
205
|
+
const linePath = at(`invoiceLines.line.${position}`);
|
|
206
|
+
if (line.lineNumber <= previousLineNumber) {
|
|
207
|
+
collector.error(
|
|
208
|
+
'LINE_NUMBER_NOT_SEQUENTIAL',
|
|
209
|
+
`${linePath}.lineNumber`,
|
|
210
|
+
`must be strictly ascending, ${line.lineNumber} follows ${previousLineNumber}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
previousLineNumber = line.lineNumber;
|
|
214
|
+
checkLine(line, linePath, detail.exchangeRate, collector, context);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- line and summary must describe the same kind of invoice ----------
|
|
218
|
+
checkLineSummaryConsistency(lines, invoice.invoiceSummary, at(''), collector);
|
|
219
|
+
|
|
220
|
+
// --- arithmetic -------------------------------------------------------
|
|
221
|
+
if (hasAmountBearingLines(invoice)) {
|
|
222
|
+
for (const issue of checkInvoiceSummary(invoice)) {
|
|
223
|
+
collector.error(summaryFaultCode(issue.path), at(issue.path), issue.message);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The NAV fault code for a summary discrepancy.
|
|
230
|
+
*
|
|
231
|
+
* NAV distinguishes these carefully and the suffixes carry the meaning: a
|
|
232
|
+
* `_LINE` code means the lines disagree with the summary, a `_SUMMARY` code
|
|
233
|
+
* means the per-rate figures disagree with the invoice total, and `_HUF`
|
|
234
|
+
* marks the forint counterpart. Picking the wrong one would attach NAV
|
|
235
|
+
* wording that describes a different fault, which is worse than attaching
|
|
236
|
+
* none, so the mapping is explicit rather than inferred.
|
|
237
|
+
*/
|
|
238
|
+
function summaryFaultCode(path: string): NavFaultCode {
|
|
239
|
+
const huf = path.endsWith('HUF');
|
|
240
|
+
const perRate = path.includes('summaryByVatRate');
|
|
241
|
+
const field = path.split('.').pop() ?? '';
|
|
242
|
+
|
|
243
|
+
if (perRate) {
|
|
244
|
+
// A per-rate figure is compared against the lines carrying that rate.
|
|
245
|
+
if (field.startsWith('vatRateNet')) {
|
|
246
|
+
return huf
|
|
247
|
+
? 'INCORRECT_SUMMARY_CALCULATION_VAT_RATE_NET_AMOUNT_HUF_LINE'
|
|
248
|
+
: 'INCORRECT_SUMMARY_CALCULATION_VAT_RATE_NET_AMOUNT_LINE';
|
|
249
|
+
}
|
|
250
|
+
if (field.startsWith('vatRateGross')) {
|
|
251
|
+
return 'INCORRECT_SUMMARY_CALCULATION_VAT_RATE_GROSS_AMOUNT_LINE';
|
|
252
|
+
}
|
|
253
|
+
return 'INCORRECT_SUMMARY_CALCULATION_VAT_RATE_VAT_AMOUNT_SUMMARY';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// An invoice total is compared against the sum of the per-rate figures.
|
|
257
|
+
if (field.startsWith('invoiceNet')) {
|
|
258
|
+
return huf
|
|
259
|
+
? 'INCORRECT_SUMMARY_CALCULATION_INVOICE_NET_AMOUNT_HUF'
|
|
260
|
+
: 'INCORRECT_SUMMARY_CALCULATION_VAT_RATE_NET_AMOUNT_SUMMARY';
|
|
261
|
+
}
|
|
262
|
+
if (field.startsWith('invoiceVat')) {
|
|
263
|
+
return huf
|
|
264
|
+
? 'INCORRECT_SUMMARY_CALCULATION_INVOICE_VAT_AMOUNT_HUF_SUMMARY'
|
|
265
|
+
: 'INCORRECT_SUMMARY_CALCULATION_INVOICE_VAT_AMOUNT_SUMMARY';
|
|
266
|
+
}
|
|
267
|
+
return huf
|
|
268
|
+
? 'INCORRECT_SUMMARY_CALCULATION_INVOICE_GROSS_AMOUNT_HUF_SUMMARY'
|
|
269
|
+
: 'INCORRECT_SUMMARY_CALCULATION_INVOICE_GROSS_AMOUNT_SUMMARY';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function checkCustomer(
|
|
273
|
+
customer: NonNullable<InvoiceType['invoiceHead']['customerInfo']>,
|
|
274
|
+
path: string,
|
|
275
|
+
collector: IssueCollector,
|
|
276
|
+
): void {
|
|
277
|
+
const status = customer.customerVatStatus;
|
|
278
|
+
const vatData = customer.customerVatData;
|
|
279
|
+
|
|
280
|
+
if (status === 'DOMESTIC') {
|
|
281
|
+
if (vatData?.customerTaxNumber === undefined) {
|
|
282
|
+
collector.error(
|
|
283
|
+
'MISSING_CUSTOMER_DOMESTIC_TAXNUMBER',
|
|
284
|
+
`${path}.customerVatData.customerTaxNumber`,
|
|
285
|
+
'a domestic customer must be identified by a Hungarian tax number',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (vatData?.communityVatNumber !== undefined) {
|
|
289
|
+
collector.error(
|
|
290
|
+
'CUSTOMER_COMMUNITY_TAXNUMBER_NOT_EXPECTED',
|
|
291
|
+
`${path}.customerVatData.communityVatNumber`,
|
|
292
|
+
'must not be supplied for a domestic customer',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (vatData?.thirdStateTaxId !== undefined) {
|
|
296
|
+
collector.error(
|
|
297
|
+
'CUSTOMER_THIRD_STATE_TAXNUMBER_NOT_EXPECTED',
|
|
298
|
+
`${path}.customerVatData.thirdStateTaxId`,
|
|
299
|
+
'must not be supplied for a domestic customer',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (status === 'PRIVATE_PERSON' && vatData !== undefined) {
|
|
305
|
+
// NAV's own private-person sample carries nothing but the status: no VAT
|
|
306
|
+
// data, no name, no address.
|
|
307
|
+
collector.error(
|
|
308
|
+
'CUSTOMER_DATA_NOT_EXPECTED',
|
|
309
|
+
`${path}.customerVatData`,
|
|
310
|
+
'must not be supplied for a private person',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const taxNumber = vatData?.customerTaxNumber;
|
|
315
|
+
if (taxNumber) {
|
|
316
|
+
checkTaxNumber(
|
|
317
|
+
taxNumber.taxpayerId,
|
|
318
|
+
taxNumber.countyCode,
|
|
319
|
+
`${path}.customerVatData.customerTaxNumber`,
|
|
320
|
+
collector,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function checkTaxNumber(
|
|
326
|
+
taxpayerId: string,
|
|
327
|
+
countyCode: string | undefined,
|
|
328
|
+
path: string,
|
|
329
|
+
collector: IssueCollector,
|
|
330
|
+
): void {
|
|
331
|
+
// A warning, not an error: NAV validates a tax number against its taxpayer
|
|
332
|
+
// registry rather than arithmetically, and two of the tax numbers in its own
|
|
333
|
+
// samples fail this check. Use queryTaxpayer for an authoritative answer.
|
|
334
|
+
if (!isValidTaxpayerId(taxpayerId)) {
|
|
335
|
+
collector.warn(
|
|
336
|
+
'LOCAL_TAXPAYER_ID_CHECK_DIGIT',
|
|
337
|
+
`${path}.taxpayerId`,
|
|
338
|
+
`${taxpayerId} fails the check digit; verify it with queryTaxpayer`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (countyCode !== undefined && !isValidCountyCode(countyCode)) {
|
|
342
|
+
collector.warn(
|
|
343
|
+
'LOCAL_COUNTY_CODE_UNKNOWN',
|
|
344
|
+
`${path}.countyCode`,
|
|
345
|
+
`${countyCode} is not a county code NAV is known to issue`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function checkLine(
|
|
351
|
+
line: LineType,
|
|
352
|
+
path: string,
|
|
353
|
+
exchangeRate: string | undefined,
|
|
354
|
+
collector: IssueCollector,
|
|
355
|
+
context: InvoiceValidationContext,
|
|
356
|
+
): void {
|
|
357
|
+
const isModification = context.operation === 'MODIFY' || context.operation === 'STORNO';
|
|
358
|
+
|
|
359
|
+
if (context.operation === 'CREATE' && line.lineModificationReference !== undefined) {
|
|
360
|
+
collector.error(
|
|
361
|
+
'LINE_MODIFICATION_NOT_EXPECTED',
|
|
362
|
+
`${path}.lineModificationReference`,
|
|
363
|
+
'an original invoice line must not reference a modification',
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
if (isModification && line.lineModificationReference === undefined) {
|
|
367
|
+
collector.error(
|
|
368
|
+
'LINE_MODIFICATION_EXPECTED',
|
|
369
|
+
`${path}.lineModificationReference`,
|
|
370
|
+
`is required on every line of a ${context.operation} document`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (line.referencesToOtherLines?.referenceToOtherLine.includes(line.lineNumber)) {
|
|
375
|
+
collector.error(
|
|
376
|
+
'INCORRECT_LINE_DATA_SELF_LINE_NUMBER',
|
|
377
|
+
`${path}.referencesToOtherLines`,
|
|
378
|
+
`line ${line.lineNumber} references itself`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (line.lineExpressionIndicator && line.quantity === undefined) {
|
|
383
|
+
collector.error(
|
|
384
|
+
'MISSING_LINE_DATA_QUANTITY',
|
|
385
|
+
`${path}.quantity`,
|
|
386
|
+
'is required when lineExpressionIndicator is set',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (line.unitOfMeasure === 'OWN' && line.unitOfMeasureOwn === undefined) {
|
|
391
|
+
collector.error(
|
|
392
|
+
'INCORRECT_LINE_DATA_UOM_INCOMPLETE',
|
|
393
|
+
`${path}.unitOfMeasureOwn`,
|
|
394
|
+
'is required when unitOfMeasure is OWN',
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (line.unitOfMeasure !== 'OWN' && line.unitOfMeasureOwn !== undefined) {
|
|
398
|
+
collector.error(
|
|
399
|
+
'INCORRECT_LINE_DATA_UOM',
|
|
400
|
+
`${path}.unitOfMeasureOwn`,
|
|
401
|
+
'may only be supplied when unitOfMeasure is OWN',
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const [index, productCode] of (line.productCodes?.productCode ?? []).entries()) {
|
|
406
|
+
const codePath = `${path}.productCodes.productCode.${index}`;
|
|
407
|
+
// NAV's rule runs one way only: productCodeOwnValue belongs to an OWN
|
|
408
|
+
// category. An OWN category may still carry an ordinary productCodeValue,
|
|
409
|
+
// as NAV's own simplified-invoice sample does — requiring the own-value
|
|
410
|
+
// field for every OWN category would reject eight of its thirty samples.
|
|
411
|
+
if (
|
|
412
|
+
productCode.productCodeOwnValue !== undefined &&
|
|
413
|
+
productCode.productCodeCategory !== 'OWN'
|
|
414
|
+
) {
|
|
415
|
+
collector.error(
|
|
416
|
+
'INCORRECT_PRODUCT_CODE_VALUE_OWN',
|
|
417
|
+
`${codePath}.productCodeOwnValue`,
|
|
418
|
+
`may only be supplied when productCodeCategory is OWN, but it is ${productCode.productCodeCategory}`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const normal = line.lineAmountsNormal;
|
|
424
|
+
const simplified = line.lineAmountsSimplified;
|
|
425
|
+
|
|
426
|
+
if (normal) {
|
|
427
|
+
if (normal.lineVatRate.vatContent !== undefined) {
|
|
428
|
+
collector.error(
|
|
429
|
+
'INVALID_LINE_VAT_RATE_NORMAL',
|
|
430
|
+
`${path}.lineAmountsNormal.lineVatRate.vatContent`,
|
|
431
|
+
'VAT content may only be used on a simplified invoice line',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
checkNormalLineAmounts(line, normal, path, exchangeRate, collector);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (simplified && simplified.lineVatRate.vatPercentage !== undefined) {
|
|
438
|
+
collector.error(
|
|
439
|
+
'INVALID_LINE_VAT_RATE_SIMPLIFIED',
|
|
440
|
+
`${path}.lineAmountsSimplified.lineVatRate.vatPercentage`,
|
|
441
|
+
'a simplified invoice line states VAT content, not a VAT percentage',
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const percentage = normal?.lineVatRate.vatPercentage;
|
|
446
|
+
if (
|
|
447
|
+
percentage !== undefined &&
|
|
448
|
+
!KNOWN_VAT_RATES.some((rate) => Decimal.from(rate).equals(percentage))
|
|
449
|
+
) {
|
|
450
|
+
collector.warn(
|
|
451
|
+
'LOCAL_VAT_PERCENTAGE_UNUSUAL',
|
|
452
|
+
`${path}.lineAmountsNormal.lineVatRate.vatPercentage`,
|
|
453
|
+
`${percentage} is not a current Hungarian VAT rate (${KNOWN_VAT_RATES.join(', ')})`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function checkNormalLineAmounts(
|
|
459
|
+
line: LineType,
|
|
460
|
+
normal: NonNullable<LineType['lineAmountsNormal']>,
|
|
461
|
+
path: string,
|
|
462
|
+
exchangeRate: string | undefined,
|
|
463
|
+
collector: IssueCollector,
|
|
464
|
+
): void {
|
|
465
|
+
const net = Decimal.from(normal.lineNetAmountData.lineNetAmount);
|
|
466
|
+
const vat = normal.lineVatData ? Decimal.from(normal.lineVatData.lineVatAmount) : undefined;
|
|
467
|
+
|
|
468
|
+
// net = quantity x unit price, unless a line discount was applied.
|
|
469
|
+
if (
|
|
470
|
+
line.lineExpressionIndicator &&
|
|
471
|
+
line.quantity !== undefined &&
|
|
472
|
+
line.unitPrice !== undefined &&
|
|
473
|
+
line.lineDiscountData === undefined
|
|
474
|
+
) {
|
|
475
|
+
const expected = Decimal.from(line.quantity).multiply(line.unitPrice).round(net.scale);
|
|
476
|
+
if (!expected.equals(net)) {
|
|
477
|
+
collector.error(
|
|
478
|
+
'INCORRECT_LINE_CALCULATION_NET_AMOUNT',
|
|
479
|
+
`${path}.lineAmountsNormal.lineNetAmountData.lineNetAmount`,
|
|
480
|
+
`is ${net.toString()} but quantity ${line.quantity} times unit price ${line.unitPrice} is ${expected.toString()}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// gross = net + VAT
|
|
486
|
+
const gross = normal.lineGrossAmountData?.lineGrossAmountNormal;
|
|
487
|
+
if (gross !== undefined && vat !== undefined) {
|
|
488
|
+
const expected = net.add(vat);
|
|
489
|
+
if (!expected.equals(gross)) {
|
|
490
|
+
collector.error(
|
|
491
|
+
'INCORRECT_LINE_CALCULATION_GROSS_AMOUNT',
|
|
492
|
+
`${path}.lineAmountsNormal.lineGrossAmountData.lineGrossAmountNormal`,
|
|
493
|
+
`is ${gross} but net ${net.toString()} plus VAT ${vat.toString()} is ${expected.toString()}`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Forint equivalents follow from the exchange rate.
|
|
499
|
+
if (exchangeRate !== undefined) {
|
|
500
|
+
const rate = Decimal.from(exchangeRate);
|
|
501
|
+
const netHuf = Decimal.from(normal.lineNetAmountData.lineNetAmountHUF);
|
|
502
|
+
const expectedNetHuf = net.multiply(rate).round(netHuf.scale);
|
|
503
|
+
if (!expectedNetHuf.equals(netHuf)) {
|
|
504
|
+
collector.error(
|
|
505
|
+
'INCORRECT_LINE_CALCULATION_LINE_NET_AMOUNT_HUF',
|
|
506
|
+
`${path}.lineAmountsNormal.lineNetAmountData.lineNetAmountHUF`,
|
|
507
|
+
`is ${netHuf.toString()} but ${net.toString()} at ${exchangeRate} is ${expectedNetHuf.toString()}`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* A normal invoice needs normal line amounts and a normal summary; a
|
|
515
|
+
* simplified one needs the simplified forms. Mixing them is the mistake NAV
|
|
516
|
+
* reports as a LINE_SUMMARY_TYPE_MISMATCH.
|
|
517
|
+
*/
|
|
518
|
+
function checkLineSummaryConsistency(
|
|
519
|
+
lines: LineType[],
|
|
520
|
+
summary: SummaryType,
|
|
521
|
+
prefix: string,
|
|
522
|
+
collector: IssueCollector,
|
|
523
|
+
): void {
|
|
524
|
+
const hasNormalLines = lines.some((line) => line.lineAmountsNormal !== undefined);
|
|
525
|
+
const hasSimplifiedLines = lines.some((line) => line.lineAmountsSimplified !== undefined);
|
|
526
|
+
|
|
527
|
+
if (hasSimplifiedLines && summary.summaryNormal !== undefined) {
|
|
528
|
+
collector.error(
|
|
529
|
+
'LINE_SUMMARY_TYPE_MISMATCH_SUMMARY_NORMAL',
|
|
530
|
+
`${prefix}invoiceSummary.summaryNormal`,
|
|
531
|
+
'the invoice has simplified lines, so the summary must be simplified too',
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
if (hasNormalLines && summary.summarySimplified !== undefined) {
|
|
535
|
+
collector.error(
|
|
536
|
+
'LINE_SUMMARY_TYPE_MISMATCH_SUMMARY_SIMPLIFIED',
|
|
537
|
+
`${prefix}invoiceSummary.summarySimplified`,
|
|
538
|
+
'the invoice has normal lines, so the summary must be normal too',
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
if (hasNormalLines && hasSimplifiedLines) {
|
|
542
|
+
collector.error(
|
|
543
|
+
'LINE_SUMMARY_TYPE_MISMATCH_LINE_NORMAL',
|
|
544
|
+
`${prefix}invoiceLines`,
|
|
545
|
+
'an invoice cannot mix normal and simplified line amounts',
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|