@fin.cx/einvoice 8.2.0 → 8.2.2
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/base/base.decoder.js +4 -3
- package/dist_ts/formats/cii/cii.decoder.d.ts +15 -0
- package/dist_ts/formats/cii/cii.decoder.js +28 -1
- package/dist_ts/formats/cii/facturx/facturx.decoder.js +3 -5
- package/dist_ts/formats/cii/facturx/facturx.encoder.js +46 -53
- package/dist_ts/formats/cii/zugferd/zugferd.decoder.js +4 -7
- package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +42 -63
- package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.d.ts +12 -0
- package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.js +25 -7
- package/dist_ts/formats/semantic/semantic.validator.js +20 -1
- package/dist_ts/formats/ubl/generic/ubl.encoder.js +28 -52
- package/dist_ts/formats/ubl/ubl.types.d.ts +6 -0
- package/dist_ts/formats/ubl/ubl.types.js +9 -3
- package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +10 -8
- package/dist_ts/formats/ubl/xrechnung/xrechnung.encoder.js +4 -11
- 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/date.value.d.ts +18 -0
- package/dist_ts/formats/utils/date.value.js +30 -1
- package/dist_ts/formats/utils/document.totals.d.ts +79 -0
- package/dist_ts/formats/utils/document.totals.js +93 -0
- package/dist_ts/formats/utils/format.detector.js +6 -4
- package/dist_ts/formats/utils/payment.terms.d.ts +18 -0
- package/dist_ts/formats/utils/payment.terms.js +32 -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/integrated.validator.js +6 -3
- package/dist_ts/formats/validation/vat-categories.validator.js +9 -1
- package/dist_ts/formats/validation/xrechnung.validator.js +6 -4
- package/package.json +2 -2
- package/readme.md +17 -0
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/einvoice.ts +11 -25
- package/ts/formats/base/base.decoder.ts +3 -2
- package/ts/formats/cii/cii.decoder.ts +31 -0
- package/ts/formats/cii/facturx/facturx.decoder.ts +2 -4
- package/ts/formats/cii/facturx/facturx.encoder.ts +47 -61
- package/ts/formats/cii/zugferd/zugferd.decoder.ts +3 -6
- package/ts/formats/cii/zugferd/zugferd.encoder.ts +42 -73
- package/ts/formats/cii/zugferd/zugferd.v1.decoder.ts +28 -6
- package/ts/formats/semantic/semantic.validator.ts +20 -0
- package/ts/formats/ubl/generic/ubl.encoder.ts +33 -63
- package/ts/formats/ubl/ubl.types.ts +8 -2
- package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +11 -7
- package/ts/formats/ubl/xrechnung/xrechnung.encoder.ts +3 -11
- package/ts/formats/utils/currency.calculator.decimal.ts +10 -2
- package/ts/formats/utils/date.value.ts +31 -0
- package/ts/formats/utils/document.totals.ts +144 -0
- package/ts/formats/utils/format.detector.ts +5 -5
- package/ts/formats/utils/payment.terms.ts +37 -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/integrated.validator.ts +5 -2
- package/ts/formats/validation/vat-categories.validator.ts +9 -0
- package/ts/formats/validation/xrechnung.validator.ts +5 -3
|
@@ -4,7 +4,9 @@ import { UBLDocumentType } from '../ubl.types.js';
|
|
|
4
4
|
import { DOMParser, XMLSerializer } from '../../../plugins.js';
|
|
5
5
|
import { getDocumentTypeCode } from '../../utils/document.typecode.js';
|
|
6
6
|
import { getPrecedingInvoiceReferences } from '../../utils/preceding.invoice.js';
|
|
7
|
-
import {
|
|
7
|
+
import { getWritableDueDate } from '../../utils/date.value.js';
|
|
8
|
+
import { getPaymentTermsNote } from '../../utils/payment.terms.js';
|
|
9
|
+
import { computeDocumentTotals } from '../../utils/document.totals.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* UBL Encoder implementation
|
|
@@ -81,8 +83,7 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
81
83
|
|
|
82
84
|
// Due Date - the CreditNote schema has no DueDate element; its payment due date lives in cac:PaymentMeans
|
|
83
85
|
if (documentType === UBLDocumentType.INVOICE) {
|
|
84
|
-
const dueDate =
|
|
85
|
-
dueDate.setDate(dueDate.getDate() + (invoice.dueInDays || 30));
|
|
86
|
+
const dueDate = getWritableDueDate(invoice.date, invoice.dueInDays, 'ubl');
|
|
86
87
|
this.appendElement(doc, root, 'cbc:DueDate', this.formatDate(dueDate.getTime(), 'BT-9 payment due date'));
|
|
87
88
|
}
|
|
88
89
|
// Document Type Code (380 invoice, 381 credit note, 383 debit note, 384 corrected invoice, 389 self-billed invoice; the element differs per schema)
|
|
@@ -319,7 +320,7 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
319
320
|
parentElement.appendChild(paymentTermsNode);
|
|
320
321
|
|
|
321
322
|
// Payment terms note
|
|
322
|
-
this.appendElement(doc, paymentTermsNode, 'cbc:Note',
|
|
323
|
+
this.appendElement(doc, paymentTermsNode, 'cbc:Note', getPaymentTermsNote(invoice.date, invoice.dueInDays, invoice.language, 'ubl'));
|
|
323
324
|
|
|
324
325
|
// Add payment means if available
|
|
325
326
|
if (invoice.paymentOptions) {
|
|
@@ -346,8 +347,7 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
346
347
|
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentMeansCode', '30');
|
|
347
348
|
|
|
348
349
|
// Payment due date, counted from the issue date the document states
|
|
349
|
-
const dueDate =
|
|
350
|
-
dueDate.setDate(dueDate.getDate() + (invoice.dueInDays || 30));
|
|
350
|
+
const dueDate = getWritableDueDate(invoice.date, invoice.dueInDays, 'ubl');
|
|
351
351
|
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentDueDate', this.formatDate(dueDate.getTime(), 'BT-9 payment due date'));
|
|
352
352
|
|
|
353
353
|
// Add payment channel code if available
|
|
@@ -384,48 +384,31 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
384
384
|
private addTaxTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
|
|
385
385
|
const taxTotalNode = doc.createElement('cac:TaxTotal');
|
|
386
386
|
parentElement.appendChild(taxTotalNode);
|
|
387
|
-
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// Calculate from items
|
|
393
|
-
if (invoice.items) {
|
|
394
|
-
for (const item of invoice.items) {
|
|
395
|
-
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
396
|
-
const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
|
|
397
|
-
const vatRate = item.vatPercentage;
|
|
398
|
-
|
|
399
|
-
totalTaxAmount += itemTaxAmount;
|
|
400
|
-
|
|
401
|
-
// Aggregate by VAT rate
|
|
402
|
-
const currentAmount = taxCategories.get(vatRate) || 0;
|
|
403
|
-
taxCategories.set(vatRate, currentAmount + itemNetAmount);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// 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)
|
|
408
392
|
const taxAmountElement = doc.createElement('cbc:TaxAmount');
|
|
409
393
|
taxAmountElement.setAttribute('currencyID', invoice.currency);
|
|
410
|
-
taxAmountElement.textContent =
|
|
394
|
+
taxAmountElement.textContent = totals.taxTotal.toFixed(totals.minorUnits);
|
|
411
395
|
taxTotalNode.appendChild(taxAmountElement);
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
for (const
|
|
396
|
+
|
|
397
|
+
// VAT breakdown (BG-23), one subtotal per rate
|
|
398
|
+
for (const { rate, taxableAmount, taxAmount } of totals.vatGroups) {
|
|
415
399
|
const taxSubtotalNode = doc.createElement('cac:TaxSubtotal');
|
|
416
400
|
taxTotalNode.appendChild(taxSubtotalNode);
|
|
417
|
-
|
|
418
|
-
//
|
|
401
|
+
|
|
402
|
+
// VAT category taxable amount (BT-116)
|
|
419
403
|
const taxableAmountElement = doc.createElement('cbc:TaxableAmount');
|
|
420
404
|
taxableAmountElement.setAttribute('currencyID', invoice.currency);
|
|
421
|
-
taxableAmountElement.textContent =
|
|
405
|
+
taxableAmountElement.textContent = taxableAmount.toFixed(totals.minorUnits);
|
|
422
406
|
taxSubtotalNode.appendChild(taxableAmountElement);
|
|
423
|
-
|
|
424
|
-
//
|
|
425
|
-
const taxAmount = baseAmount * (rate / 100);
|
|
407
|
+
|
|
408
|
+
// VAT category tax amount (BT-117)
|
|
426
409
|
const subtotalTaxAmountElement = doc.createElement('cbc:TaxAmount');
|
|
427
410
|
subtotalTaxAmountElement.setAttribute('currencyID', invoice.currency);
|
|
428
|
-
subtotalTaxAmountElement.textContent = taxAmount.toFixed(
|
|
411
|
+
subtotalTaxAmountElement.textContent = taxAmount.toFixed(totals.minorUnits);
|
|
429
412
|
taxSubtotalNode.appendChild(subtotalTaxAmountElement);
|
|
430
413
|
|
|
431
414
|
// Tax category
|
|
@@ -461,46 +444,33 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
461
444
|
private addLegalMonetaryTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
|
|
462
445
|
const legalMonetaryTotalNode = doc.createElement('cac:LegalMonetaryTotal');
|
|
463
446
|
parentElement.appendChild(legalMonetaryTotalNode);
|
|
464
|
-
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
// Calculate from items
|
|
470
|
-
if (invoice.items) {
|
|
471
|
-
for (const item of invoice.items) {
|
|
472
|
-
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
473
|
-
const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
|
|
474
|
-
|
|
475
|
-
totalNetAmount += itemNetAmount;
|
|
476
|
-
totalTaxAmount += itemTaxAmount;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const totalGrossAmount = totalNetAmount + totalTaxAmount;
|
|
447
|
+
|
|
448
|
+
// the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
|
|
449
|
+
const totals = computeDocumentTotals(invoice);
|
|
450
|
+
const amount = (value: { toFixed(decimalPlaces: number): string }) => value.toFixed(totals.minorUnits);
|
|
481
451
|
|
|
482
452
|
// Line extension amount (sum of line net amounts)
|
|
483
453
|
const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
|
|
484
454
|
lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
|
|
485
|
-
lineExtensionAmountElement.textContent =
|
|
455
|
+
lineExtensionAmountElement.textContent = amount(totals.lineTotal);
|
|
486
456
|
legalMonetaryTotalNode.appendChild(lineExtensionAmountElement);
|
|
487
457
|
|
|
488
458
|
// Tax exclusive amount
|
|
489
459
|
const taxExclusiveAmountElement = doc.createElement('cbc:TaxExclusiveAmount');
|
|
490
460
|
taxExclusiveAmountElement.setAttribute('currencyID', invoice.currency);
|
|
491
|
-
taxExclusiveAmountElement.textContent =
|
|
461
|
+
taxExclusiveAmountElement.textContent = amount(totals.taxBasisTotal);
|
|
492
462
|
legalMonetaryTotalNode.appendChild(taxExclusiveAmountElement);
|
|
493
463
|
|
|
494
464
|
// Tax inclusive amount
|
|
495
465
|
const taxInclusiveAmountElement = doc.createElement('cbc:TaxInclusiveAmount');
|
|
496
466
|
taxInclusiveAmountElement.setAttribute('currencyID', invoice.currency);
|
|
497
|
-
taxInclusiveAmountElement.textContent =
|
|
467
|
+
taxInclusiveAmountElement.textContent = amount(totals.grandTotal);
|
|
498
468
|
legalMonetaryTotalNode.appendChild(taxInclusiveAmountElement);
|
|
499
469
|
|
|
500
470
|
// Payable amount
|
|
501
471
|
const payableAmountElement = doc.createElement('cbc:PayableAmount');
|
|
502
472
|
payableAmountElement.setAttribute('currencyID', invoice.currency);
|
|
503
|
-
payableAmountElement.textContent =
|
|
473
|
+
payableAmountElement.textContent = amount(totals.duePayable);
|
|
504
474
|
legalMonetaryTotalNode.appendChild(payableAmountElement);
|
|
505
475
|
}
|
|
506
476
|
|
|
@@ -513,8 +483,9 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
513
483
|
private addInvoiceLines(doc: Document, parentElement: Element, invoice: TAccountingDoc, documentType: UBLDocumentType = UBLDocumentType.INVOICE): void {
|
|
514
484
|
if (!invoice.items) return;
|
|
515
485
|
const creditNote = documentType === UBLDocumentType.CREDIT_NOTE;
|
|
486
|
+
const totals = computeDocumentTotals(invoice);
|
|
516
487
|
|
|
517
|
-
for (const item of invoice.items) {
|
|
488
|
+
for (const [index, item] of invoice.items.entries()) {
|
|
518
489
|
const invoiceLineNode = doc.createElement(creditNote ? 'cac:CreditNoteLine' : 'cac:InvoiceLine');
|
|
519
490
|
parentElement.appendChild(invoiceLineNode);
|
|
520
491
|
|
|
@@ -530,11 +501,10 @@ export class UBLEncoder extends UBLBaseEncoder {
|
|
|
530
501
|
quantityElement.textContent = item.unitQuantity.toString();
|
|
531
502
|
invoiceLineNode.appendChild(quantityElement);
|
|
532
503
|
|
|
533
|
-
//
|
|
534
|
-
const itemNetAmount = item.unitNetPrice * item.unitQuantity;
|
|
504
|
+
// Invoice line net amount (BT-131): quantity × net price, rounded
|
|
535
505
|
const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
|
|
536
506
|
lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
|
|
537
|
-
lineExtensionAmountElement.textContent =
|
|
507
|
+
lineExtensionAmountElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
|
|
538
508
|
invoiceLineNode.appendChild(lineExtensionAmountElement);
|
|
539
509
|
|
|
540
510
|
// Item information
|
|
@@ -17,8 +17,14 @@ export enum UBLDocumentType {
|
|
|
17
17
|
CREDIT_NOTE = 'CreditNote'
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// UBL customization IDs for different formats
|
|
20
|
+
// UBL customization IDs (specification identifier, BT-24) for different formats
|
|
21
21
|
export const UBL_CUSTOMIZATION_IDS = {
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* XRechnung 3.0 (CIUS), as the KoSIT XRechnung Schematron defines it (`XR-CIUS-ID` in
|
|
24
|
+
* common.sch: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_' and
|
|
25
|
+
* the version '3.0'). XRechnung 1.x and 2.x used the prefix
|
|
26
|
+
* 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_'.
|
|
27
|
+
*/
|
|
28
|
+
XRECHNUNG: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
|
|
23
29
|
PEPPOL_BIS: 'urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0'
|
|
24
30
|
};
|
|
@@ -60,14 +60,18 @@ export class XRechnungDecoder extends UBLBaseDecoder {
|
|
|
60
60
|
const issueDate = this.parseRequiredUblDate(issueDateText);
|
|
61
61
|
const currencyCode = this.getText('//cbc:DocumentCurrencyCode', this.doc) || 'EUR';
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// The payment due date (BT-9): cbc:DueDate on an Invoice, cac:PaymentMeans/cbc:PaymentDueDate
|
|
64
|
+
// on a CreditNote, cac:PaymentTerms/cbc:PaymentDueDate as a last resort; the payment term in
|
|
65
|
+
// days is the calendar days from the issue date to it.
|
|
66
|
+
// no due date stated: `dueInDays` cannot express it yet (required field in @tsclass/tsclass); value unchanged: 30 days
|
|
67
|
+
let dueInDays = 30;
|
|
68
|
+
const dueDateText = (
|
|
69
|
+
this.getText('/*/cbc:DueDate', this.doc) ||
|
|
70
|
+
this.getText('/*/cac:PaymentMeans/cbc:PaymentDueDate', this.doc) ||
|
|
71
|
+
this.getText('/*/cac:PaymentTerms/cbc:PaymentDueDate', this.doc)
|
|
72
|
+
).trim();
|
|
66
73
|
if (dueDateText) {
|
|
67
|
-
|
|
68
|
-
const issueDateObj = new Date(issueDate);
|
|
69
|
-
const diffTime = Math.abs(dueDateObj.getTime() - issueDateObj.getTime());
|
|
70
|
-
dueInDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
74
|
+
dueInDays = Math.round((this.parseRequiredUblDate(dueDateText) - issueDate) / (1000 * 60 * 60 * 24));
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
// Extract items
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UBLEncoder } from '../generic/ubl.encoder.js';
|
|
2
2
|
import type { TAccountingDoc, TCreditNote, TInvoiceDocument } from '../../../interfaces/common.js';
|
|
3
3
|
import { DOMParser, XMLSerializer } from '../../../plugins.js';
|
|
4
|
+
import { UBL_CUSTOMIZATION_IDS } from '../ubl.types.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Encoder for XRechnung (UBL) format
|
|
@@ -52,10 +53,10 @@ export class XRechnungEncoder extends UBLEncoder {
|
|
|
52
53
|
// Extract metadata if available
|
|
53
54
|
const metadata = (invoice as any).metadata?.extensions;
|
|
54
55
|
|
|
55
|
-
//
|
|
56
|
+
// Specification identifier (BT-24): XRechnung 3.0
|
|
56
57
|
const customizationId = root.getElementsByTagName('cbc:CustomizationID')[0];
|
|
57
58
|
if (customizationId) {
|
|
58
|
-
customizationId.textContent =
|
|
59
|
+
customizationId.textContent = UBL_CUSTOMIZATION_IDS.XRECHNUNG;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
// Add or update Buyer Reference (required for XRechnung)
|
|
@@ -73,15 +74,6 @@ export class XRechnungEncoder extends UBLEncoder {
|
|
|
73
74
|
buyerRef.textContent = buyerReferenceValue;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
// Update payment terms to German
|
|
77
|
-
const paymentTermsNotes = root.getElementsByTagName('cac:PaymentTerms');
|
|
78
|
-
if (paymentTermsNotes.length > 0) {
|
|
79
|
-
const noteElement = paymentTermsNotes[0].getElementsByTagName('cbc:Note')[0];
|
|
80
|
-
if (noteElement && noteElement.textContent) {
|
|
81
|
-
noteElement.textContent = `Zahlung innerhalb von ${invoice.dueInDays || 30} Tagen`;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
77
|
// Add electronic address for parties if available
|
|
86
78
|
this.addElectronicAddressToParty(doc, 'cac:AccountingSupplierParty', invoice.from);
|
|
87
79
|
this.addElectronicAddressToParty(doc, 'cac:AccountingCustomerParty', invoice.to);
|
|
@@ -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
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { EInvoiceFormatError } from '../../errors.js';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Calendar dates (BT-2, BT-7, BT-9, BT-26, BT-72, BT-73, BT-74) carry no time
|
|
5
|
+
* of day and no time zone: UBL writes them as `xsd:date`, CII in format 102
|
|
6
|
+
* (`YYYYMMDD`). In the accounting document envelope such a day is the UTC
|
|
7
|
+
* midnight of that day, which is what the decoders return. The encoders write
|
|
8
|
+
* it with the UTC fields of the date and count days in UTC, so the day
|
|
9
|
+
* written does not depend on the time zone of the machine that writes it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
3
12
|
/**
|
|
4
13
|
* The date an encoder is about to write, as a `Date`. A value that is no
|
|
5
14
|
* finite timestamp, or lies outside the range of a JavaScript date, is refused
|
|
@@ -19,3 +28,25 @@ export const getWritableDate = (timestamp: unknown, field: string, targetFormat:
|
|
|
19
28
|
}
|
|
20
29
|
return date;
|
|
21
30
|
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The payment due date (BT-9) a document states: its issue date plus
|
|
34
|
+
* `dueInDays` calendar days. `0` is due on the issue date. A `dueInDays` that
|
|
35
|
+
* is no whole number of days is refused with an `EInvoiceFormatError`; no other
|
|
36
|
+
* term is put in its place.
|
|
37
|
+
* @param issueTimestamp The issue date (BT-2)
|
|
38
|
+
* @param dueInDays The payment term in days, as the document states it
|
|
39
|
+
* @param targetFormat The format being written
|
|
40
|
+
*/
|
|
41
|
+
export const getWritableDueDate = (issueTimestamp: unknown, dueInDays: unknown, targetFormat: string): Date => {
|
|
42
|
+
const dueDate = getWritableDate(issueTimestamp, 'BT-2 invoice issue date', targetFormat);
|
|
43
|
+
if (typeof dueInDays !== 'number' || !Number.isInteger(dueInDays)) {
|
|
44
|
+
throw new EInvoiceFormatError(`BT-9 payment due date: dueInDays is no whole number of days: ${String(dueInDays)}`, {
|
|
45
|
+
targetFormat,
|
|
46
|
+
unsupportedFeatures: ['BT-9 payment due date'],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// calendar days counted in UTC: a calendar day is its UTC midnight, whatever the server's zone
|
|
50
|
+
dueDate.setUTCDate(dueDate.getUTCDate() + dueInDays);
|
|
51
|
+
return dueDate;
|
|
52
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
|
|
7
|
+
/** An item amount that is no finite number, so no total can be computed from it */
|
|
8
|
+
export interface IInvalidItemAmount {
|
|
9
|
+
index: number;
|
|
10
|
+
field: 'unitQuantity' | 'unitNetPrice' | 'vatPercentage';
|
|
11
|
+
value: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The item amounts of a document that are no finite number. The totals cannot
|
|
16
|
+
* be computed while there is one: the encoders and the total getters refuse
|
|
17
|
+
* the document, and a validator reports the lines and skips the rules that
|
|
18
|
+
* need the totals.
|
|
19
|
+
* @param items The document's items
|
|
20
|
+
*/
|
|
21
|
+
export const findInvalidItemAmounts = (items: TAccountingDoc['items'] | undefined): IInvalidItemAmount[] => {
|
|
22
|
+
const invalid: IInvalidItemAmount[] = [];
|
|
23
|
+
for (const [index, item] of (items ?? []).entries()) {
|
|
24
|
+
for (const field of ['unitQuantity', 'unitNetPrice', 'vatPercentage'] as const) {
|
|
25
|
+
const value: unknown = item[field];
|
|
26
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
27
|
+
invalid.push({ index, field, value });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return invalid;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The result a validator adds when it skips the rules that need the totals,
|
|
36
|
+
* because a line amount is no number (the line rules report the lines)
|
|
37
|
+
* @param invalid The invalid item amounts
|
|
38
|
+
* @param source The validator's source
|
|
39
|
+
*/
|
|
40
|
+
export const getTotalsSkippedResult = (invalid: IInvalidItemAmount[], source: string): ValidationResult => ({
|
|
41
|
+
ruleId: 'TOTALS-NOT-CHECKED',
|
|
42
|
+
source,
|
|
43
|
+
severity: 'info',
|
|
44
|
+
message: `The rules on totals and the VAT breakdown were not checked: ${invalid
|
|
45
|
+
.map((entry) => `items[${entry.index}].${entry.field} is no number`)
|
|
46
|
+
.join(', ')}`,
|
|
47
|
+
field: invalid.map((entry) => `items[${entry.index}].${entry.field}`).join(', '),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** The most decimals an amount may have in EN 16931 (BR-DEC-09 to BR-DEC-23) */
|
|
51
|
+
export const EN16931_MAX_AMOUNT_DECIMALS = 2;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The calculator every document amount is computed with: rounding half up to
|
|
55
|
+
* the currency's minor unit, at most two decimals as EN 16931 allows. A
|
|
56
|
+
* currency without minor unit (JPY) keeps 0 decimals; one with three (BHD,
|
|
57
|
+
* KWD, ...) is rounded to two.
|
|
58
|
+
* @param currency The document currency
|
|
59
|
+
*/
|
|
60
|
+
export const getDocumentCalculator = (currency: TAccountingDoc['currency']): DecimalCurrencyCalculator =>
|
|
61
|
+
new DecimalCurrencyCalculator(currency, 'HALF_UP', { maxDecimals: EN16931_MAX_AMOUNT_DECIMALS });
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The VAT breakdown of one rate (BG-23): the taxable amount (BT-116), the tax
|
|
65
|
+
* on it (BT-117) and the rate (BT-119).
|
|
66
|
+
*/
|
|
67
|
+
export interface IDocumentVatGroup {
|
|
68
|
+
rate: number;
|
|
69
|
+
taxableAmount: Decimal;
|
|
70
|
+
taxAmount: Decimal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The totals of a document as EN 16931 computes them, in decimal arithmetic.
|
|
75
|
+
* Every amount is rounded to the minor unit of the currency; all encoders
|
|
76
|
+
* write these values, so the XML is consistent in every syntax.
|
|
77
|
+
*/
|
|
78
|
+
export interface IDocumentTotals {
|
|
79
|
+
/** decimals the amounts are rounded to and written with: the currency's minor unit, at most two */
|
|
80
|
+
minorUnits: number;
|
|
81
|
+
/** the Invoice line net amount (BT-131) of each item, in item order: quantity × net price, rounded */
|
|
82
|
+
lineNetAmounts: Decimal[];
|
|
83
|
+
/** Sum of Invoice line net amount (BT-106) */
|
|
84
|
+
lineTotal: Decimal;
|
|
85
|
+
/** Invoice total amount without VAT (BT-109): BT-106, as the envelope has no document level allowances or charges */
|
|
86
|
+
taxBasisTotal: Decimal;
|
|
87
|
+
/** VAT breakdown (BG-23), in the order the rates first appear */
|
|
88
|
+
vatGroups: IDocumentVatGroup[];
|
|
89
|
+
/** Invoice total VAT amount (BT-110): the sum of the rounded VAT category tax amounts */
|
|
90
|
+
taxTotal: Decimal;
|
|
91
|
+
/** Invoice total amount with VAT (BT-112): BT-109 + BT-110 */
|
|
92
|
+
grandTotal: Decimal;
|
|
93
|
+
/** Amount due for payment (BT-115): BT-112, as the envelope states no paid amount yet */
|
|
94
|
+
duePayable: Decimal;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Computes the totals of a document the way the EN 16931 business rules check
|
|
99
|
+
* them, every amount rounded half up to the currency's minor unit, at most two
|
|
100
|
+
* decimals (BR-DEC-*): each line net amount (BT-131) is quantity × net price
|
|
101
|
+
* rounded; the sum of line net amounts (BT-106) is their sum (BR-CO-10);
|
|
102
|
+
* each VAT category taxable amount (BT-116) is the sum of the line net amounts
|
|
103
|
+
* at that rate, and its tax amount (BT-117) is BT-116 × rate / 100 rounded
|
|
104
|
+
* (BR-CO-17); the total VAT (BT-110) is the sum of the category tax amounts
|
|
105
|
+
* (BR-CO-14); the total with VAT (BT-112) is BT-109 + BT-110 (BR-CO-15).
|
|
106
|
+
* @param accountingDoc The document
|
|
107
|
+
*/
|
|
108
|
+
export const computeDocumentTotals = (accountingDoc: Pick<TAccountingDoc, 'currency' | 'items'>): IDocumentTotals => {
|
|
109
|
+
const calculator = getDocumentCalculator(accountingDoc.currency);
|
|
110
|
+
const minorUnits = calculator.getCurrencyInfo().minorUnits;
|
|
111
|
+
const lineNetAmounts: Decimal[] = [];
|
|
112
|
+
const taxableByRate = new Map<number, Decimal>();
|
|
113
|
+
// an amount that is no number cannot be computed with; it is refused, not treated as 0
|
|
114
|
+
const [firstInvalid] = findInvalidItemAmounts(accountingDoc.items);
|
|
115
|
+
if (firstInvalid) {
|
|
116
|
+
throw new EInvoiceFormatError(
|
|
117
|
+
`items[${firstInvalid.index}].${firstInvalid.field} is no number: ${String(firstInvalid.value)}`,
|
|
118
|
+
{ unsupportedFeatures: [`items[${firstInvalid.index}].${firstInvalid.field}`] },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
for (const item of accountingDoc.items ?? []) {
|
|
122
|
+
const lineNet = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
|
|
123
|
+
lineNetAmounts.push(lineNet);
|
|
124
|
+
taxableByRate.set(item.vatPercentage, (taxableByRate.get(item.vatPercentage) ?? Decimal.ZERO).add(lineNet));
|
|
125
|
+
}
|
|
126
|
+
const vatGroups: IDocumentVatGroup[] = [...taxableByRate.entries()].map(([rate, taxableAmount]) => ({
|
|
127
|
+
rate,
|
|
128
|
+
taxableAmount: calculator.round(taxableAmount),
|
|
129
|
+
taxAmount: calculator.calculateVAT(taxableAmount, rate),
|
|
130
|
+
}));
|
|
131
|
+
const lineTotal = calculator.round(Decimal.sum(lineNetAmounts));
|
|
132
|
+
const taxTotal = calculator.round(Decimal.sum(vatGroups.map((group) => group.taxAmount)));
|
|
133
|
+
const grandTotal = calculator.round(lineTotal.add(taxTotal));
|
|
134
|
+
return {
|
|
135
|
+
minorUnits,
|
|
136
|
+
lineNetAmounts,
|
|
137
|
+
lineTotal,
|
|
138
|
+
taxBasisTotal: lineTotal,
|
|
139
|
+
vatGroups,
|
|
140
|
+
taxTotal,
|
|
141
|
+
grandTotal,
|
|
142
|
+
duePayable: grandTotal,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
@@ -80,7 +80,9 @@ export class FormatDetector {
|
|
|
80
80
|
);
|
|
81
81
|
const customizationId = customizationIdMatch?.[1] ?? '';
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
// both identifier families: urn:xeinkauf.de:kosit:xrechnung_3.0 (XRechnung 3.0) and
|
|
84
|
+
// urn:xoev-de:kosit:standard:xrechnung_x.y (XRechnung 1.x and 2.x)
|
|
85
|
+
if (/xrechnung/i.test(customizationId)) {
|
|
84
86
|
return InvoiceFormat.XRECHNUNG;
|
|
85
87
|
}
|
|
86
88
|
return InvoiceFormat.UBL;
|
|
@@ -133,10 +135,8 @@ export class FormatDetector {
|
|
|
133
135
|
}
|
|
134
136
|
|
|
135
137
|
// Check for obvious XRechnung indicators
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
/urn:xoev-de:kosit:standard:xrechnung/i.test(sample)
|
|
139
|
-
) {
|
|
138
|
+
// covers both identifier families (urn:xeinkauf.de:kosit:xrechnung_, urn:xoev-de:kosit:standard:xrechnung_)
|
|
139
|
+
if (/xrechnung/i.test(sample)) {
|
|
140
140
|
return InvoiceFormat.XRECHNUNG;
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getWritableDueDate } from './date.value.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The payment terms note (BT-20) the encoders write: the due date (BT-9) the
|
|
5
|
+
* document states, in the document language (German for `de`, English
|
|
6
|
+
* otherwise). It names the date rather than a count of days, so it reads
|
|
7
|
+
* correctly for every term:
|
|
8
|
+
* - a term of 0 days: "Zahlbar sofort" / "Payable immediately";
|
|
9
|
+
* - a later due date: "Zahlbar bis 04.10.2026" / "Payable by 2026-10-04";
|
|
10
|
+
* - an earlier due date, which is legitimate when the payment fell due before
|
|
11
|
+
* the invoice was issued, as with an agreed advance payment or an invoice
|
|
12
|
+
* issued after the contractual due date: "Fällig seit dem 03.09.2026" /
|
|
13
|
+
* "Due since 2026-09-03".
|
|
14
|
+
* A term that is no whole number of days is refused by `getWritableDueDate`.
|
|
15
|
+
* @param issueTimestamp The issue date (BT-2)
|
|
16
|
+
* @param dueInDays The payment term in days, as the document states it
|
|
17
|
+
* @param language The document language
|
|
18
|
+
* @param targetFormat The format being written
|
|
19
|
+
*/
|
|
20
|
+
export const getPaymentTermsNote = (
|
|
21
|
+
issueTimestamp: unknown,
|
|
22
|
+
dueInDays: unknown,
|
|
23
|
+
language: string | undefined,
|
|
24
|
+
targetFormat: string,
|
|
25
|
+
): string => {
|
|
26
|
+
const dueDate = getWritableDueDate(issueTimestamp, dueInDays, targetFormat);
|
|
27
|
+
const isoDate = dueDate.toISOString().slice(0, 10);
|
|
28
|
+
const german = (language ?? '').toLowerCase().startsWith('de');
|
|
29
|
+
const germanDate = `${isoDate.slice(8, 10)}.${isoDate.slice(5, 7)}.${isoDate.slice(0, 4)}`;
|
|
30
|
+
if (dueInDays === 0) {
|
|
31
|
+
return german ? 'Zahlbar sofort' : 'Payable immediately';
|
|
32
|
+
}
|
|
33
|
+
if ((dueInDays as number) > 0) {
|
|
34
|
+
return german ? `Zahlbar bis ${germanDate}` : `Payable by ${isoDate}`;
|
|
35
|
+
}
|
|
36
|
+
return german ? `Fällig seit dem ${germanDate}` : `Due since ${isoDate}`;
|
|
37
|
+
};
|
|
@@ -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
|