@fin.cx/einvoice 8.2.1 → 8.2.3
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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/einvoice.js +12 -23
- package/dist_ts/formats/cii/cii.decoder.d.ts +6 -0
- package/dist_ts/formats/cii/cii.decoder.js +11 -1
- package/dist_ts/formats/cii/cii.encoder.js +4 -1
- package/dist_ts/formats/cii/facturx/facturx.decoder.js +8 -4
- package/dist_ts/formats/cii/facturx/facturx.encoder.js +57 -57
- package/dist_ts/formats/cii/zugferd/zugferd.decoder.js +8 -4
- package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +55 -67
- package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.d.ts +5 -0
- package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.js +18 -5
- package/dist_ts/formats/semantic/semantic.validator.js +20 -1
- package/dist_ts/formats/ubl/generic/ubl.encoder.js +31 -57
- package/dist_ts/formats/ubl/ubl.encoder.js +4 -1
- package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.d.ts +4 -0
- package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +12 -2
- package/dist_ts/formats/utils/currency.calculator.decimal.d.ts +9 -1
- package/dist_ts/formats/utils/currency.calculator.decimal.js +10 -3
- package/dist_ts/formats/utils/document.totals.d.ts +89 -0
- package/dist_ts/formats/utils/document.totals.js +108 -0
- package/dist_ts/formats/utils/vat.category.d.ts +50 -0
- package/dist_ts/formats/utils/vat.category.js +64 -0
- package/dist_ts/formats/validation/codelist.validator.js +3 -11
- package/dist_ts/formats/validation/en16931.business-rules.validator.js +38 -44
- package/dist_ts/formats/validation/facturx.validator.js +19 -5
- package/dist_ts/formats/validation/vat-categories.validator.js +9 -1
- package/package.json +2 -2
- package/readme.md +25 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/einvoice.ts +11 -25
- package/ts/formats/cii/cii.decoder.ts +14 -0
- package/ts/formats/cii/cii.encoder.ts +3 -0
- package/ts/formats/cii/facturx/facturx.decoder.ts +8 -3
- package/ts/formats/cii/facturx/facturx.encoder.ts +59 -65
- package/ts/formats/cii/zugferd/zugferd.decoder.ts +8 -3
- package/ts/formats/cii/zugferd/zugferd.encoder.ts +58 -77
- package/ts/formats/cii/zugferd/zugferd.v1.decoder.ts +22 -4
- package/ts/formats/semantic/semantic.validator.ts +20 -0
- package/ts/formats/ubl/generic/ubl.encoder.ts +36 -69
- package/ts/formats/ubl/ubl.encoder.ts +3 -0
- package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +15 -1
- package/ts/formats/utils/currency.calculator.decimal.ts +10 -2
- package/ts/formats/utils/document.totals.ts +167 -0
- package/ts/formats/utils/vat.category.ts +89 -0
- package/ts/formats/validation/codelist.validator.ts +3 -12
- package/ts/formats/validation/en16931.business-rules.validator.ts +44 -51
- package/ts/formats/validation/facturx.validator.ts +19 -4
- package/ts/formats/validation/vat-categories.validator.ts +9 -0
|
@@ -6,6 +6,7 @@ import { getDocumentTypeCode } from '../../utils/document.typecode.js';
|
|
|
6
6
|
import { getPrecedingInvoiceReferences } from '../../utils/preceding.invoice.js';
|
|
7
7
|
import { getWritableDueDate } from '../../utils/date.value.js';
|
|
8
8
|
import { getPaymentTermsNote } from '../../utils/payment.terms.js';
|
|
9
|
+
import { computeDocumentTotals } from '../../utils/document.totals.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* UBL Encoder implementation
|
|
@@ -383,65 +384,45 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
383
384
|
private addTaxTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
|
|
384
385
|
const taxTotalNode = doc.createElement('cac:TaxTotal');
|
|
385
386
|
parentElement.appendChild(taxTotalNode);
|
|
386
|
-
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
// Calculate from items
|
|
392
|
-
if (invoice.items) {
|
|
393
|
-
for (const item of invoice.items) {
|
|
394
|
-
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
395
|
-
const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
|
|
396
|
-
const vatRate = item.vatPercentage;
|
|
397
|
-
|
|
398
|
-
totalTaxAmount += itemTaxAmount;
|
|
399
|
-
|
|
400
|
-
// Aggregate by VAT rate
|
|
401
|
-
const currentAmount = taxCategories.get(vatRate) || 0;
|
|
402
|
-
taxCategories.set(vatRate, currentAmount + itemNetAmount);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// Add total tax amount
|
|
387
|
+
|
|
388
|
+
// the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
|
|
389
|
+
const totals = computeDocumentTotals(invoice);
|
|
390
|
+
|
|
391
|
+
// Invoice total VAT amount (BT-110)
|
|
407
392
|
const taxAmountElement = doc.createElement('cbc:TaxAmount');
|
|
408
393
|
taxAmountElement.setAttribute('currencyID', invoice.currency);
|
|
409
|
-
taxAmountElement.textContent =
|
|
394
|
+
taxAmountElement.textContent = totals.taxTotal.toFixed(totals.minorUnits);
|
|
410
395
|
taxTotalNode.appendChild(taxAmountElement);
|
|
411
|
-
|
|
412
|
-
//
|
|
413
|
-
for (const
|
|
396
|
+
|
|
397
|
+
// VAT breakdown (BG-23), one subtotal per rate
|
|
398
|
+
for (const { category, rate, taxableAmount, taxAmount, exemption } of totals.vatGroups) {
|
|
414
399
|
const taxSubtotalNode = doc.createElement('cac:TaxSubtotal');
|
|
415
400
|
taxTotalNode.appendChild(taxSubtotalNode);
|
|
416
|
-
|
|
417
|
-
//
|
|
401
|
+
|
|
402
|
+
// VAT category taxable amount (BT-116)
|
|
418
403
|
const taxableAmountElement = doc.createElement('cbc:TaxableAmount');
|
|
419
404
|
taxableAmountElement.setAttribute('currencyID', invoice.currency);
|
|
420
|
-
taxableAmountElement.textContent =
|
|
405
|
+
taxableAmountElement.textContent = taxableAmount.toFixed(totals.minorUnits);
|
|
421
406
|
taxSubtotalNode.appendChild(taxableAmountElement);
|
|
422
|
-
|
|
423
|
-
//
|
|
424
|
-
const taxAmount = baseAmount * (rate / 100);
|
|
407
|
+
|
|
408
|
+
// VAT category tax amount (BT-117)
|
|
425
409
|
const subtotalTaxAmountElement = doc.createElement('cbc:TaxAmount');
|
|
426
410
|
subtotalTaxAmountElement.setAttribute('currencyID', invoice.currency);
|
|
427
|
-
subtotalTaxAmountElement.textContent = taxAmount.toFixed(
|
|
411
|
+
subtotalTaxAmountElement.textContent = taxAmount.toFixed(totals.minorUnits);
|
|
428
412
|
taxSubtotalNode.appendChild(subtotalTaxAmountElement);
|
|
429
413
|
|
|
430
414
|
// Tax category
|
|
431
415
|
const taxCategoryNode = doc.createElement('cac:TaxCategory');
|
|
432
416
|
taxSubtotalNode.appendChild(taxCategoryNode);
|
|
433
417
|
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
this.appendElement(doc, taxCategoryNode, 'cbc:ID', categoryId);
|
|
437
|
-
|
|
438
|
-
// Add percent with 2 decimal places
|
|
418
|
+
// VAT category code (BT-118) and rate (BT-119)
|
|
419
|
+
this.appendElement(doc, taxCategoryNode, 'cbc:ID', category);
|
|
439
420
|
this.appendElement(doc, taxCategoryNode, 'cbc:Percent', rate.toFixed(2));
|
|
440
421
|
|
|
441
|
-
//
|
|
442
|
-
if (
|
|
443
|
-
this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode',
|
|
444
|
-
this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason',
|
|
422
|
+
// VAT exemption reason code (BT-121) and text (BT-120), for reverse charge (BR-AE-10)
|
|
423
|
+
if (exemption) {
|
|
424
|
+
this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', exemption.code);
|
|
425
|
+
this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', exemption.reason);
|
|
445
426
|
}
|
|
446
427
|
|
|
447
428
|
// Add tax scheme
|
|
@@ -460,46 +441,33 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
460
441
|
private addLegalMonetaryTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
|
|
461
442
|
const legalMonetaryTotalNode = doc.createElement('cac:LegalMonetaryTotal');
|
|
462
443
|
parentElement.appendChild(legalMonetaryTotalNode);
|
|
463
|
-
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
// Calculate from items
|
|
469
|
-
if (invoice.items) {
|
|
470
|
-
for (const item of invoice.items) {
|
|
471
|
-
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
472
|
-
const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
|
|
473
|
-
|
|
474
|
-
totalNetAmount += itemNetAmount;
|
|
475
|
-
totalTaxAmount += itemTaxAmount;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const totalGrossAmount = totalNetAmount + totalTaxAmount;
|
|
444
|
+
|
|
445
|
+
// the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
|
|
446
|
+
const totals = computeDocumentTotals(invoice);
|
|
447
|
+
const amount = (value: { toFixed(decimalPlaces: number): string }) => value.toFixed(totals.minorUnits);
|
|
480
448
|
|
|
481
449
|
// Line extension amount (sum of line net amounts)
|
|
482
450
|
const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
|
|
483
451
|
lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
|
|
484
|
-
lineExtensionAmountElement.textContent =
|
|
452
|
+
lineExtensionAmountElement.textContent = amount(totals.lineTotal);
|
|
485
453
|
legalMonetaryTotalNode.appendChild(lineExtensionAmountElement);
|
|
486
454
|
|
|
487
455
|
// Tax exclusive amount
|
|
488
456
|
const taxExclusiveAmountElement = doc.createElement('cbc:TaxExclusiveAmount');
|
|
489
457
|
taxExclusiveAmountElement.setAttribute('currencyID', invoice.currency);
|
|
490
|
-
taxExclusiveAmountElement.textContent =
|
|
458
|
+
taxExclusiveAmountElement.textContent = amount(totals.taxBasisTotal);
|
|
491
459
|
legalMonetaryTotalNode.appendChild(taxExclusiveAmountElement);
|
|
492
460
|
|
|
493
461
|
// Tax inclusive amount
|
|
494
462
|
const taxInclusiveAmountElement = doc.createElement('cbc:TaxInclusiveAmount');
|
|
495
463
|
taxInclusiveAmountElement.setAttribute('currencyID', invoice.currency);
|
|
496
|
-
taxInclusiveAmountElement.textContent =
|
|
464
|
+
taxInclusiveAmountElement.textContent = amount(totals.grandTotal);
|
|
497
465
|
legalMonetaryTotalNode.appendChild(taxInclusiveAmountElement);
|
|
498
466
|
|
|
499
467
|
// Payable amount
|
|
500
468
|
const payableAmountElement = doc.createElement('cbc:PayableAmount');
|
|
501
469
|
payableAmountElement.setAttribute('currencyID', invoice.currency);
|
|
502
|
-
payableAmountElement.textContent =
|
|
470
|
+
payableAmountElement.textContent = amount(totals.duePayable);
|
|
503
471
|
legalMonetaryTotalNode.appendChild(payableAmountElement);
|
|
504
472
|
}
|
|
505
473
|
|
|
@@ -512,8 +480,9 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
512
480
|
private addInvoiceLines(doc: Document, parentElement: Element, invoice: TAccountingDoc, documentType: UBLDocumentType = UBLDocumentType.INVOICE): void {
|
|
513
481
|
if (!invoice.items) return;
|
|
514
482
|
const creditNote = documentType === UBLDocumentType.CREDIT_NOTE;
|
|
483
|
+
const totals = computeDocumentTotals(invoice);
|
|
515
484
|
|
|
516
|
-
for (const item of invoice.items) {
|
|
485
|
+
for (const [index, item] of invoice.items.entries()) {
|
|
517
486
|
const invoiceLineNode = doc.createElement(creditNote ? 'cac:CreditNoteLine' : 'cac:InvoiceLine');
|
|
518
487
|
parentElement.appendChild(invoiceLineNode);
|
|
519
488
|
|
|
@@ -529,11 +498,10 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
529
498
|
quantityElement.textContent = item.unitQuantity.toString();
|
|
530
499
|
invoiceLineNode.appendChild(quantityElement);
|
|
531
500
|
|
|
532
|
-
//
|
|
533
|
-
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
501
|
+
// Invoice line net amount (BT-131): quantity × net price, rounded
|
|
534
502
|
const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
|
|
535
503
|
lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
|
|
536
|
-
lineExtensionAmountElement.textContent =
|
|
504
|
+
lineExtensionAmountElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
|
|
537
505
|
invoiceLineNode.appendChild(lineExtensionAmountElement);
|
|
538
506
|
|
|
539
507
|
// Item information
|
|
@@ -556,9 +524,8 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
556
524
|
const classifiedTaxCategoryNode = doc.createElement('cac:ClassifiedTaxCategory');
|
|
557
525
|
itemNode.appendChild(classifiedTaxCategoryNode);
|
|
558
526
|
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', categoryId);
|
|
527
|
+
// Invoiced item VAT category code (BT-151)
|
|
528
|
+
this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', totals.lineVatCategories[index]);
|
|
562
529
|
|
|
563
530
|
// Tax percent with 2 decimal places
|
|
564
531
|
this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toFixed(2));
|
|
@@ -2,6 +2,7 @@ import { BaseEncoder } from '../base/base.encoder.js';
|
|
|
2
2
|
import type { TAccountingDoc, TCreditNote, TInvoiceDocument } from '../../interfaces/common.js';
|
|
3
3
|
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
|
|
4
4
|
import { getWritableDate } from '../utils/date.value.js';
|
|
5
|
+
import { assertVatCategoryWritable } from '../utils/vat.category.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The children of the document root in the order the UBL 2.1 schema requires
|
|
@@ -48,6 +49,8 @@ export abstract class UBLBaseEncoder extends BaseEncoder {
|
|
|
48
49
|
* @returns UBL XML string
|
|
49
50
|
*/
|
|
50
51
|
public async encode(invoice: TAccountingDoc): Promise<string> {
|
|
52
|
+
// a reverse charge document that lacks what EN 16931 requires of one is refused (BR-AE-02, BR-AE-05)
|
|
53
|
+
assertVatCategoryWritable(invoice, 'ubl');
|
|
51
54
|
// a credit note is a CreditNote document; an invoice, a debit note and a self-billed invoice are Invoice documents that differ in their type code
|
|
52
55
|
if (invoice.accountingDocType === 'creditnote') {
|
|
53
56
|
return this.encodeCreditNote(invoice);
|
|
@@ -261,7 +261,9 @@ export class XRechnungDecoder extends UBLBaseDecoder {
|
|
|
261
261
|
subject: subject,
|
|
262
262
|
items: items,
|
|
263
263
|
dueInDays: dueInDays,
|
|
264
|
-
|
|
264
|
+
// reverse charge when every line is VAT category AE; the envelope states it for the
|
|
265
|
+
// whole document, so a document mixing AE lines with others cannot say it
|
|
266
|
+
reverseCharge: this.isReverseChargeDocument(),
|
|
265
267
|
currency: currencyCode as finance.TCurrency,
|
|
266
268
|
notes: notes,
|
|
267
269
|
objectActions: [],
|
|
@@ -301,6 +303,18 @@ export class XRechnungDecoder extends UBLBaseDecoder {
|
|
|
301
303
|
}
|
|
302
304
|
}
|
|
303
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Whether every line of the document has the VAT category reverse charge (AE, BT-151)
|
|
308
|
+
*/
|
|
309
|
+
private isReverseChargeDocument(): boolean {
|
|
310
|
+
const categories = this.select(
|
|
311
|
+
'/*/cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID | /*/cac:CreditNoteLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID',
|
|
312
|
+
this.doc,
|
|
313
|
+
);
|
|
314
|
+
const codes = (Array.isArray(categories) ? categories : []).map((node) => (node.textContent ?? '').trim());
|
|
315
|
+
return codes.length > 0 && codes.every((code) => code === 'AE');
|
|
316
|
+
}
|
|
317
|
+
|
|
304
318
|
/**
|
|
305
319
|
* Reads the preceding invoice references (BG-3): the number (BT-25) and,
|
|
306
320
|
* when stated, the issue date (BT-26) of each.
|
|
@@ -15,12 +15,20 @@ export class DecimalCurrencyCalculator {
|
|
|
15
15
|
private readonly minorUnits: number;
|
|
16
16
|
private readonly roundingMode: RoundingMode;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* @param currency The currency, whose minor unit sets the rounding scale
|
|
20
|
+
* @param roundingMode How values are rounded
|
|
21
|
+
* @param options.maxDecimals The most decimals an amount may have, when fewer than the
|
|
22
|
+
* currency's minor unit (EN 16931 allows two, BR-DEC-*)
|
|
23
|
+
*/
|
|
18
24
|
constructor(
|
|
19
25
|
currency: TCurrency,
|
|
20
|
-
roundingMode: RoundingMode = 'HALF_UP'
|
|
26
|
+
roundingMode: RoundingMode = 'HALF_UP',
|
|
27
|
+
options: { maxDecimals?: number } = {}
|
|
21
28
|
) {
|
|
22
29
|
this.currency = currency;
|
|
23
|
-
|
|
30
|
+
const minorUnits = getCurrencyMinorUnits(currency);
|
|
31
|
+
this.minorUnits = options.maxDecimals === undefined ? minorUnits : Math.min(minorUnits, options.maxDecimals);
|
|
24
32
|
this.roundingMode = roundingMode;
|
|
25
33
|
}
|
|
26
34
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { TAccountingDoc } from '../../interfaces/common.js';
|
|
2
|
+
import { Decimal } from './decimal.js';
|
|
3
|
+
import { DecimalCurrencyCalculator } from './currency.calculator.decimal.js';
|
|
4
|
+
import { EInvoiceFormatError } from '../../errors.js';
|
|
5
|
+
import type { ValidationResult } from '../validation/validation.types.js';
|
|
6
|
+
import { getVatCategory, getVatExemption, type IVatExemption, type TVatCategoryCode } from './vat.category.js';
|
|
7
|
+
|
|
8
|
+
/** An item amount that is no finite number, so no total can be computed from it */
|
|
9
|
+
export interface IInvalidItemAmount {
|
|
10
|
+
index: number;
|
|
11
|
+
field: 'unitQuantity' | 'unitNetPrice' | 'vatPercentage';
|
|
12
|
+
value: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The item amounts of a document that are no finite number. The totals cannot
|
|
17
|
+
* be computed while there is one: the encoders and the total getters refuse
|
|
18
|
+
* the document, and a validator reports the lines and skips the rules that
|
|
19
|
+
* need the totals.
|
|
20
|
+
* @param items The document's items
|
|
21
|
+
*/
|
|
22
|
+
export const findInvalidItemAmounts = (items: TAccountingDoc['items'] | undefined): IInvalidItemAmount[] => {
|
|
23
|
+
const invalid: IInvalidItemAmount[] = [];
|
|
24
|
+
for (const [index, item] of (items ?? []).entries()) {
|
|
25
|
+
for (const field of ['unitQuantity', 'unitNetPrice', 'vatPercentage'] as const) {
|
|
26
|
+
const value: unknown = item[field];
|
|
27
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
28
|
+
invalid.push({ index, field, value });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return invalid;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The result a validator adds when it skips the rules that need the totals,
|
|
37
|
+
* because a line amount is no number (the line rules report the lines)
|
|
38
|
+
* @param invalid The invalid item amounts
|
|
39
|
+
* @param source The validator's source
|
|
40
|
+
*/
|
|
41
|
+
export const getTotalsSkippedResult = (invalid: IInvalidItemAmount[], source: string): ValidationResult => ({
|
|
42
|
+
ruleId: 'TOTALS-NOT-CHECKED',
|
|
43
|
+
source,
|
|
44
|
+
severity: 'info',
|
|
45
|
+
message: `The rules on totals and the VAT breakdown were not checked: ${invalid
|
|
46
|
+
.map((entry) => `items[${entry.index}].${entry.field} is no number`)
|
|
47
|
+
.join(', ')}`,
|
|
48
|
+
field: invalid.map((entry) => `items[${entry.index}].${entry.field}`).join(', '),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** The most decimals an amount may have in EN 16931 (BR-DEC-09 to BR-DEC-23) */
|
|
52
|
+
export const EN16931_MAX_AMOUNT_DECIMALS = 2;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The calculator every document amount is computed with: rounding half up to
|
|
56
|
+
* the currency's minor unit, at most two decimals as EN 16931 allows. A
|
|
57
|
+
* currency without minor unit (JPY) keeps 0 decimals; one with three (BHD,
|
|
58
|
+
* KWD, ...) is rounded to two.
|
|
59
|
+
* @param currency The document currency
|
|
60
|
+
*/
|
|
61
|
+
export const getDocumentCalculator = (currency: TAccountingDoc['currency']): DecimalCurrencyCalculator =>
|
|
62
|
+
new DecimalCurrencyCalculator(currency, 'HALF_UP', { maxDecimals: EN16931_MAX_AMOUNT_DECIMALS });
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The VAT breakdown of one VAT category and rate (BG-23): the category
|
|
66
|
+
* (BT-118), the rate (BT-119), the taxable amount (BT-116), the tax on it
|
|
67
|
+
* (BT-117) and, for a category that states one, the exemption reason
|
|
68
|
+
* (BT-121, BT-120).
|
|
69
|
+
*/
|
|
70
|
+
export interface IDocumentVatGroup {
|
|
71
|
+
category: TVatCategoryCode;
|
|
72
|
+
rate: number;
|
|
73
|
+
taxableAmount: Decimal;
|
|
74
|
+
taxAmount: Decimal;
|
|
75
|
+
exemption?: IVatExemption;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The totals of a document as EN 16931 computes them, in decimal arithmetic.
|
|
80
|
+
* Every amount is rounded to the minor unit of the currency; all encoders
|
|
81
|
+
* write these values, so the XML is consistent in every syntax.
|
|
82
|
+
*/
|
|
83
|
+
export interface IDocumentTotals {
|
|
84
|
+
/** decimals the amounts are rounded to and written with: the currency's minor unit, at most two */
|
|
85
|
+
minorUnits: number;
|
|
86
|
+
/** the Invoice line net amount (BT-131) of each item, in item order: quantity × net price, rounded */
|
|
87
|
+
lineNetAmounts: Decimal[];
|
|
88
|
+
/** the VAT category (BT-151) of each item, in item order */
|
|
89
|
+
lineVatCategories: TVatCategoryCode[];
|
|
90
|
+
/** Sum of Invoice line net amount (BT-106) */
|
|
91
|
+
lineTotal: Decimal;
|
|
92
|
+
/** Invoice total amount without VAT (BT-109): BT-106, as the envelope has no document level allowances or charges */
|
|
93
|
+
taxBasisTotal: Decimal;
|
|
94
|
+
/** VAT breakdown (BG-23), one per VAT category and rate, in the order they first appear */
|
|
95
|
+
vatGroups: IDocumentVatGroup[];
|
|
96
|
+
/** Invoice total VAT amount (BT-110): the sum of the rounded VAT category tax amounts */
|
|
97
|
+
taxTotal: Decimal;
|
|
98
|
+
/** Invoice total amount with VAT (BT-112): BT-109 + BT-110 */
|
|
99
|
+
grandTotal: Decimal;
|
|
100
|
+
/** Amount due for payment (BT-115): BT-112, as the envelope states no paid amount yet */
|
|
101
|
+
duePayable: Decimal;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Computes the totals of a document the way the EN 16931 business rules check
|
|
106
|
+
* them, every amount rounded half up to the currency's minor unit, at most two
|
|
107
|
+
* decimals (BR-DEC-*): each line net amount (BT-131) is quantity × net price
|
|
108
|
+
* rounded; the sum of line net amounts (BT-106) is their sum (BR-CO-10);
|
|
109
|
+
* each VAT category taxable amount (BT-116) is the sum of the line net amounts
|
|
110
|
+
* of that category and rate, and its tax amount (BT-117) is BT-116 × rate / 100
|
|
111
|
+
* rounded (BR-CO-17), 0 for reverse charge (BR-AE-09); the total VAT (BT-110) is the sum of the category tax amounts
|
|
112
|
+
* (BR-CO-14); the total with VAT (BT-112) is BT-109 + BT-110 (BR-CO-15).
|
|
113
|
+
* @param accountingDoc The document
|
|
114
|
+
*/
|
|
115
|
+
export const computeDocumentTotals = (
|
|
116
|
+
accountingDoc: Pick<TAccountingDoc, 'currency' | 'items'> & { reverseCharge?: boolean; language?: string },
|
|
117
|
+
): IDocumentTotals => {
|
|
118
|
+
const calculator = getDocumentCalculator(accountingDoc.currency);
|
|
119
|
+
const minorUnits = calculator.getCurrencyInfo().minorUnits;
|
|
120
|
+
const category = getVatCategory(accountingDoc);
|
|
121
|
+
const lineNetAmounts: Decimal[] = [];
|
|
122
|
+
const lineVatCategories: TVatCategoryCode[] = [];
|
|
123
|
+
const taxableByGroup = new Map<string, { category: TVatCategoryCode; rate: number; taxable: Decimal }>();
|
|
124
|
+
// an amount that is no number cannot be computed with; it is refused, not treated as 0
|
|
125
|
+
const [firstInvalid] = findInvalidItemAmounts(accountingDoc.items);
|
|
126
|
+
if (firstInvalid) {
|
|
127
|
+
throw new EInvoiceFormatError(
|
|
128
|
+
`items[${firstInvalid.index}].${firstInvalid.field} is no number: ${String(firstInvalid.value)}`,
|
|
129
|
+
{ unsupportedFeatures: [`items[${firstInvalid.index}].${firstInvalid.field}`] },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
for (const item of accountingDoc.items ?? []) {
|
|
133
|
+
const lineNet = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
|
|
134
|
+
lineNetAmounts.push(lineNet);
|
|
135
|
+
lineVatCategories.push(category);
|
|
136
|
+
// grouped by VAT category and rate: the same rate under two categories is two groups
|
|
137
|
+
const key = `${category}|${item.vatPercentage}`;
|
|
138
|
+
const group = taxableByGroup.get(key) ?? { category, rate: item.vatPercentage, taxable: Decimal.ZERO };
|
|
139
|
+
group.taxable = group.taxable.add(lineNet);
|
|
140
|
+
taxableByGroup.set(key, group);
|
|
141
|
+
}
|
|
142
|
+
const vatGroups: IDocumentVatGroup[] = [...taxableByGroup.values()].map((group) => {
|
|
143
|
+
const exemption = getVatExemption(group.category, accountingDoc.language);
|
|
144
|
+
return {
|
|
145
|
+
category: group.category,
|
|
146
|
+
rate: group.rate,
|
|
147
|
+
taxableAmount: calculator.round(group.taxable),
|
|
148
|
+
// reverse charge states no tax: the recipient owes it (BR-AE-09)
|
|
149
|
+
taxAmount: group.category === 'AE' ? calculator.round(Decimal.ZERO) : calculator.calculateVAT(group.taxable, group.rate),
|
|
150
|
+
...(exemption ? { exemption } : {}),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
const lineTotal = calculator.round(Decimal.sum(lineNetAmounts));
|
|
154
|
+
const taxTotal = calculator.round(Decimal.sum(vatGroups.map((group) => group.taxAmount)));
|
|
155
|
+
const grandTotal = calculator.round(lineTotal.add(taxTotal));
|
|
156
|
+
return {
|
|
157
|
+
minorUnits,
|
|
158
|
+
lineNetAmounts,
|
|
159
|
+
lineVatCategories,
|
|
160
|
+
lineTotal,
|
|
161
|
+
taxBasisTotal: lineTotal,
|
|
162
|
+
vatGroups,
|
|
163
|
+
taxTotal,
|
|
164
|
+
grandTotal,
|
|
165
|
+
duePayable: grandTotal,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { TAccountingDoc } from '../../interfaces/common.js';
|
|
2
|
+
import { EInvoiceFormatError } from '../../errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The VAT category codes (UNTDID 5305, EN 16931 BT-118/BT-151) the encoders
|
|
6
|
+
* write: standard rated, or reverse charge. The envelope states reverse charge
|
|
7
|
+
* for the whole document (`reverseCharge`); an item carries no category of its
|
|
8
|
+
* own, so a document cannot mix reverse charge lines with standard rated ones.
|
|
9
|
+
*/
|
|
10
|
+
export type TVatCategoryCode = 'S' | 'AE';
|
|
11
|
+
|
|
12
|
+
/** The VAT exemption reason (BT-121 code, BT-120 text) a VAT category states */
|
|
13
|
+
export interface IVatExemption {
|
|
14
|
+
code: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The VAT category of every line of a document: reverse charge (`AE`) when the
|
|
20
|
+
* document states it, standard rated (`S`) otherwise.
|
|
21
|
+
* @param accountingDoc The document
|
|
22
|
+
*/
|
|
23
|
+
export const getVatCategory = (accountingDoc: { reverseCharge?: boolean }): TVatCategoryCode =>
|
|
24
|
+
accountingDoc.reverseCharge ? 'AE' : 'S';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The exemption reason a reverse charge VAT breakdown states (BR-AE-10): the
|
|
28
|
+
* code `VATEX-EU-AE` and the wording the law prescribes, "Steuerschuldnerschaft
|
|
29
|
+
* des Leistungsempfängers" (§ 14a Abs. 1 and 5 UStG). A document in another
|
|
30
|
+
* language may use the wording of Article 226 Nr. 11a of the VAT Directive in
|
|
31
|
+
* that language, "Reverse charge" in English (Abschnitt 14a.1 Abs. 6 Satz 2
|
|
32
|
+
* UStAE). Other categories state none.
|
|
33
|
+
* @param category The VAT category
|
|
34
|
+
* @param language The document language
|
|
35
|
+
*/
|
|
36
|
+
export const getVatExemption = (category: TVatCategoryCode, language: string | undefined): IVatExemption | undefined =>
|
|
37
|
+
category === 'AE'
|
|
38
|
+
? {
|
|
39
|
+
code: 'VATEX-EU-AE',
|
|
40
|
+
reason: (language ?? '').toLowerCase().startsWith('de')
|
|
41
|
+
? 'Steuerschuldnerschaft des Leistungsempfängers'
|
|
42
|
+
: 'Reverse charge',
|
|
43
|
+
}
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Refuses a reverse charge document that lacks what EN 16931 requires of one,
|
|
48
|
+
* naming the rule; no value is put in place of a missing one.
|
|
49
|
+
* - BR-AE-05: every line of a reverse charge document has the VAT rate 0; the
|
|
50
|
+
* recipient owes the tax, and the invoice states none (§ 14a Abs. 5 Satz 2
|
|
51
|
+
* UStG; a stated amount would be owed under § 14c Abs. 1 UStG).
|
|
52
|
+
* - BR-AE-02: the seller states a VAT identifier (BT-31) and the buyer a VAT
|
|
53
|
+
* identifier (BT-48) or a legal registration identifier (BT-47). The rule
|
|
54
|
+
* also accepts the seller's tax registration identifier (BT-32, e.g. the
|
|
55
|
+
* Steuernummer) or a tax representative's VAT identifier (BT-63) instead of
|
|
56
|
+
* BT-31; the envelope can state neither, so a seller without a VAT
|
|
57
|
+
* identifier is refused. A domestic § 13b issuer that states only a
|
|
58
|
+
* Steuernummer, which § 14 Abs. 4 Satz 1 Nr. 2 UStG allows, is therefore
|
|
59
|
+
* refused until the envelope carries a tax number.
|
|
60
|
+
* @param accountingDoc The document
|
|
61
|
+
* @param targetFormat The format being written
|
|
62
|
+
*/
|
|
63
|
+
export const assertVatCategoryWritable = (accountingDoc: TAccountingDoc, targetFormat: string): void => {
|
|
64
|
+
if (!accountingDoc.reverseCharge) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const refuse = (rule: string, message: string): never => {
|
|
68
|
+
throw new EInvoiceFormatError(`${rule}: ${message}`, { targetFormat, unsupportedFeatures: [rule] });
|
|
69
|
+
};
|
|
70
|
+
for (const [index, item] of (accountingDoc.items ?? []).entries()) {
|
|
71
|
+
if (item.vatPercentage !== 0) {
|
|
72
|
+
refuse('BR-AE-05', `a reverse charge line has the VAT rate 0, items[${index}].vatPercentage is ${String(item.vatPercentage)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const seller = accountingDoc.from?.registrationDetails;
|
|
76
|
+
if (!seller?.vatId) {
|
|
77
|
+
refuse(
|
|
78
|
+
'BR-AE-02',
|
|
79
|
+
'a reverse charge document states the seller VAT identifier (BT-31), from.registrationDetails.vatId is missing; the rule would also accept a tax registration identifier (BT-32) or a tax representative (BT-63), which the envelope cannot state',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
const buyer = accountingDoc.to?.registrationDetails;
|
|
83
|
+
if (!buyer?.vatId && !buyer?.registrationId) {
|
|
84
|
+
refuse(
|
|
85
|
+
'BR-AE-02',
|
|
86
|
+
'a reverse charge document states the buyer VAT identifier (BT-48) or legal registration identifier (BT-47), to.registrationDetails.vatId and .registrationId are missing',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
@@ -146,18 +146,9 @@ export class CodeListValidator {
|
|
|
146
146
|
* Validate tax category codes (UNCL5305)
|
|
147
147
|
*/
|
|
148
148
|
private validateTaxCategories(invoice: EInvoice): void {
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
invoice.taxBreakdown?.forEach((breakdown, index) => {
|
|
153
|
-
// Since the computed taxBreakdown doesn't have metadata,
|
|
154
|
-
// we'll skip the tax category code validation for now
|
|
155
|
-
// This would need to be implemented differently to access the raw data
|
|
156
|
-
|
|
157
|
-
// TODO: Access raw tax breakdown data with metadata from invoice.metadata.taxBreakdown
|
|
158
|
-
// when that structure is implemented
|
|
159
|
-
});
|
|
160
|
-
|
|
149
|
+
// The document level VAT breakdown is computed from the lines (`taxBreakdown`) and
|
|
150
|
+
// carries no category code of its own, so only the lines' codes are checked here.
|
|
151
|
+
|
|
161
152
|
// Line level tax categories
|
|
162
153
|
invoice.items?.forEach((item, index) => {
|
|
163
154
|
// Cast to extended type to access metadata
|