@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.
Files changed (59) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/einvoice.js +12 -23
  3. package/dist_ts/formats/base/base.decoder.js +4 -3
  4. package/dist_ts/formats/cii/cii.decoder.d.ts +15 -0
  5. package/dist_ts/formats/cii/cii.decoder.js +28 -1
  6. package/dist_ts/formats/cii/facturx/facturx.decoder.js +3 -5
  7. package/dist_ts/formats/cii/facturx/facturx.encoder.js +46 -53
  8. package/dist_ts/formats/cii/zugferd/zugferd.decoder.js +4 -7
  9. package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +42 -63
  10. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.d.ts +12 -0
  11. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.js +25 -7
  12. package/dist_ts/formats/semantic/semantic.validator.js +20 -1
  13. package/dist_ts/formats/ubl/generic/ubl.encoder.js +28 -52
  14. package/dist_ts/formats/ubl/ubl.types.d.ts +6 -0
  15. package/dist_ts/formats/ubl/ubl.types.js +9 -3
  16. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +10 -8
  17. package/dist_ts/formats/ubl/xrechnung/xrechnung.encoder.js +4 -11
  18. package/dist_ts/formats/utils/currency.calculator.decimal.d.ts +9 -1
  19. package/dist_ts/formats/utils/currency.calculator.decimal.js +10 -3
  20. package/dist_ts/formats/utils/date.value.d.ts +18 -0
  21. package/dist_ts/formats/utils/date.value.js +30 -1
  22. package/dist_ts/formats/utils/document.totals.d.ts +79 -0
  23. package/dist_ts/formats/utils/document.totals.js +93 -0
  24. package/dist_ts/formats/utils/format.detector.js +6 -4
  25. package/dist_ts/formats/utils/payment.terms.d.ts +18 -0
  26. package/dist_ts/formats/utils/payment.terms.js +32 -0
  27. package/dist_ts/formats/validation/codelist.validator.js +3 -11
  28. package/dist_ts/formats/validation/en16931.business-rules.validator.js +38 -44
  29. package/dist_ts/formats/validation/facturx.validator.js +19 -5
  30. package/dist_ts/formats/validation/integrated.validator.js +6 -3
  31. package/dist_ts/formats/validation/vat-categories.validator.js +9 -1
  32. package/dist_ts/formats/validation/xrechnung.validator.js +6 -4
  33. package/package.json +2 -2
  34. package/readme.md +17 -0
  35. package/ts/00_commitinfo_data.ts +1 -1
  36. package/ts/einvoice.ts +11 -25
  37. package/ts/formats/base/base.decoder.ts +3 -2
  38. package/ts/formats/cii/cii.decoder.ts +31 -0
  39. package/ts/formats/cii/facturx/facturx.decoder.ts +2 -4
  40. package/ts/formats/cii/facturx/facturx.encoder.ts +47 -61
  41. package/ts/formats/cii/zugferd/zugferd.decoder.ts +3 -6
  42. package/ts/formats/cii/zugferd/zugferd.encoder.ts +42 -73
  43. package/ts/formats/cii/zugferd/zugferd.v1.decoder.ts +28 -6
  44. package/ts/formats/semantic/semantic.validator.ts +20 -0
  45. package/ts/formats/ubl/generic/ubl.encoder.ts +33 -63
  46. package/ts/formats/ubl/ubl.types.ts +8 -2
  47. package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +11 -7
  48. package/ts/formats/ubl/xrechnung/xrechnung.encoder.ts +3 -11
  49. package/ts/formats/utils/currency.calculator.decimal.ts +10 -2
  50. package/ts/formats/utils/date.value.ts +31 -0
  51. package/ts/formats/utils/document.totals.ts +144 -0
  52. package/ts/formats/utils/format.detector.ts +5 -5
  53. package/ts/formats/utils/payment.terms.ts +37 -0
  54. package/ts/formats/validation/codelist.validator.ts +3 -12
  55. package/ts/formats/validation/en16931.business-rules.validator.ts +44 -51
  56. package/ts/formats/validation/facturx.validator.ts +19 -4
  57. package/ts/formats/validation/integrated.validator.ts +5 -2
  58. package/ts/formats/validation/vat-categories.validator.ts +9 -0
  59. 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 { getWritableDate } from '../../utils/date.value.js';
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 = getWritableDate(invoice.date, 'BT-2 invoice issue date', 'ubl');
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', `Due in ${invoice.dueInDays} days`);
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 = getWritableDate(invoice.date, 'BT-2 invoice issue date', 'ubl');
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
- // Calculate total tax amount
389
- let totalTaxAmount = 0;
390
- const taxCategories = new Map<number, number>(); // Map of VAT rate to net amount
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 = totalTaxAmount.toFixed(2);
394
+ taxAmountElement.textContent = totals.taxTotal.toFixed(totals.minorUnits);
411
395
  taxTotalNode.appendChild(taxAmountElement);
412
-
413
- // Add tax subtotals
414
- for (const [rate, baseAmount] of taxCategories.entries()) {
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
- // Taxable amount
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 = baseAmount.toFixed(2);
405
+ taxableAmountElement.textContent = taxableAmount.toFixed(totals.minorUnits);
422
406
  taxSubtotalNode.appendChild(taxableAmountElement);
423
-
424
- // Tax amount
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(2);
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
- // Calculate totals
466
- let totalNetAmount = 0;
467
- let totalTaxAmount = 0;
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 = totalNetAmount.toFixed(2);
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 = totalNetAmount.toFixed(2);
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 = totalGrossAmount.toFixed(2);
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 = totalGrossAmount.toFixed(2);
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
- // Line extension amount (line net amount)
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 = itemNetAmount.toFixed(2);
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
- XRECHNUNG: 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0',
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
- // Extract payment terms
64
- let dueInDays = 30; // Default
65
- const dueDateText = this.getText('//cac:PaymentTerms/cbc:PaymentDueDate', this.doc);
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
- const dueDateObj = new Date(dueDateText);
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
- // Update Customization ID to XRechnung 2.0
56
+ // Specification identifier (BT-24): XRechnung 3.0
56
57
  const customizationId = root.getElementsByTagName('cbc:CustomizationID')[0];
57
58
  if (customizationId) {
58
- customizationId.textContent = 'urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_2.0';
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
- this.minorUnits = getCurrencyMinorUnits(currency);
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
- if (/xrechnung/i.test(customizationId) || /urn:xoev-de:kosit:standard:xrechnung/i.test(customizationId)) {
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
- if (
137
- /xrechnung/i.test(sample) ||
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
- // Document level tax breakdown
150
- // Note: taxBreakdown is a computed property that doesn't have metadata
151
- // We would need to access the raw tax breakdown data from metadata if it exists
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