@fin.cx/einvoice 8.2.0 → 8.2.1

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 (41) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/formats/base/base.decoder.js +4 -3
  3. package/dist_ts/formats/cii/cii.decoder.d.ts +15 -0
  4. package/dist_ts/formats/cii/cii.decoder.js +28 -1
  5. package/dist_ts/formats/cii/facturx/facturx.decoder.js +3 -5
  6. package/dist_ts/formats/cii/facturx/facturx.encoder.js +7 -7
  7. package/dist_ts/formats/cii/zugferd/zugferd.decoder.js +4 -7
  8. package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +12 -8
  9. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.d.ts +12 -0
  10. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.js +25 -7
  11. package/dist_ts/formats/ubl/generic/ubl.encoder.js +6 -7
  12. package/dist_ts/formats/ubl/ubl.types.d.ts +6 -0
  13. package/dist_ts/formats/ubl/ubl.types.js +9 -3
  14. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +10 -8
  15. package/dist_ts/formats/ubl/xrechnung/xrechnung.encoder.js +4 -11
  16. package/dist_ts/formats/utils/date.value.d.ts +18 -0
  17. package/dist_ts/formats/utils/date.value.js +30 -1
  18. package/dist_ts/formats/utils/format.detector.js +6 -4
  19. package/dist_ts/formats/utils/payment.terms.d.ts +18 -0
  20. package/dist_ts/formats/utils/payment.terms.js +32 -0
  21. package/dist_ts/formats/validation/integrated.validator.js +6 -3
  22. package/dist_ts/formats/validation/xrechnung.validator.js +6 -4
  23. package/package.json +2 -2
  24. package/readme.md +17 -0
  25. package/ts/00_commitinfo_data.ts +1 -1
  26. package/ts/formats/base/base.decoder.ts +3 -2
  27. package/ts/formats/cii/cii.decoder.ts +31 -0
  28. package/ts/formats/cii/facturx/facturx.decoder.ts +2 -4
  29. package/ts/formats/cii/facturx/facturx.encoder.ts +6 -6
  30. package/ts/formats/cii/zugferd/zugferd.decoder.ts +3 -6
  31. package/ts/formats/cii/zugferd/zugferd.encoder.ts +11 -7
  32. package/ts/formats/cii/zugferd/zugferd.v1.decoder.ts +28 -6
  33. package/ts/formats/ubl/generic/ubl.encoder.ts +5 -6
  34. package/ts/formats/ubl/ubl.types.ts +8 -2
  35. package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +11 -7
  36. package/ts/formats/ubl/xrechnung/xrechnung.encoder.ts +3 -11
  37. package/ts/formats/utils/date.value.ts +31 -0
  38. package/ts/formats/utils/format.detector.ts +5 -5
  39. package/ts/formats/utils/payment.terms.ts +37 -0
  40. package/ts/formats/validation/integrated.validator.ts +5 -2
  41. package/ts/formats/validation/xrechnung.validator.ts +5 -3
@@ -59,14 +59,15 @@ export abstract class BaseDecoder {
59
59
  const year = parseInt(dateStr.substring(0, 4));
60
60
  const month = parseInt(dateStr.substring(4, 6)) - 1; // Month is 0-indexed in JS
61
61
  const day = parseInt(dateStr.substring(6, 8));
62
- return new Date(year, month, day).getTime();
62
+ // a calendar day is its UTC midnight, whatever the server's zone
63
+ return Date.UTC(year, month, day);
63
64
  }
64
65
 
65
66
  // Format 610 is YYYYMM
66
67
  if (format === '610' && dateStr.length === 6) {
67
68
  const year = parseInt(dateStr.substring(0, 4));
68
69
  const month = parseInt(dateStr.substring(4, 6)) - 1;
69
- return new Date(year, month, 1).getTime();
70
+ return Date.UTC(year, month, 1);
70
71
  }
71
72
 
72
73
  // Try to parse as ISO date or other standard formats
@@ -127,6 +127,37 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
127
127
  return references;
128
128
  }
129
129
 
130
+ /**
131
+ * Reads the invoice issue date (BT-2); a missing or invalid one is refused
132
+ * with an `EInvoiceParsingError`, as no other date can stand in for it.
133
+ */
134
+ protected extractIssueDate(): number {
135
+ const issueDatePath = '/rsm:CrossIndustryInvoice/rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString';
136
+ return this.parseRequiredCIIDate(this.getText(issueDatePath), this.getText(`${issueDatePath}/@format`).trim());
137
+ }
138
+
139
+ /**
140
+ * Reads the payment due date (BT-9) of the header payment terms; undefined
141
+ * when the document states none.
142
+ */
143
+ protected extractDueDate(): number | undefined {
144
+ const dueDatePath =
145
+ '/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString';
146
+ const dueDate = this.getText(dueDatePath).trim();
147
+ if (!dueDate) {
148
+ return undefined;
149
+ }
150
+ return this.parseRequiredCIIDate(dueDate, this.getText(`${dueDatePath}/@format`).trim());
151
+ }
152
+
153
+ /**
154
+ * The payment term in days from the issue date to the stated due date, or
155
+ * `absentDueInDays` when the document states no due date.
156
+ */
157
+ protected dueInDaysOf(issueDate: number, dueDate: number | undefined, absentDueInDays: number): number {
158
+ return dueDate === undefined ? absentDueInDays : Math.round((dueDate - issueDate) / (1000 * 60 * 60 * 24));
159
+ }
160
+
130
161
  /**
131
162
  * Reads the actual delivery date (BT-72) of the header delivery; undefined
132
163
  * when the document does not state one.
@@ -67,10 +67,8 @@ export class FacturXDecoder extends CIIBaseDecoder {
67
67
  const items = this.extractItems();
68
68
 
69
69
  // Extract due date
70
- const dueDateStr = this.getText('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString');
71
- const dueDateFormat = this.getText('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString/@format');
72
- const dueDate = dueDateStr ? this.parseCIIDate(dueDateStr, dueDateFormat) : issueDate;
73
- const dueInDays = Math.round((dueDate - issueDate) / (1000 * 60 * 60 * 24));
70
+ // no due date stated: `dueInDays` cannot express it yet (required field in @tsclass/tsclass); value unchanged: 0 days
71
+ const dueInDays = this.dueInDaysOf(issueDate, this.extractDueDate(), 0);
74
72
 
75
73
  // Extract currency
76
74
  const currencyCode = this.getText('//ram:InvoiceCurrencyCode') || 'EUR';
@@ -6,7 +6,7 @@ import { Decimal } from '../../utils/decimal.js';
6
6
  import { DecimalCurrencyCalculator } from '../../utils/currency.calculator.decimal.js';
7
7
  import { getCurrencyMinorUnits } from '../../utils/currency.utils.js';
8
8
  import { getDocumentTypeCode } from '../../utils/document.typecode.js';
9
- import { getWritableDate } from '../../utils/date.value.js';
9
+ import { getWritableDate, getWritableDueDate } from '../../utils/date.value.js';
10
10
 
11
11
  /**
12
12
  * Encoder for Factur-X invoice format
@@ -325,8 +325,7 @@ export class FacturXEncoder extends CIIBaseEncoder {
325
325
  dateStringElement.setAttribute('format', '102'); // YYYYMMDD format
326
326
 
327
327
  // Calculate due date
328
- const dueDate = new Date(invoice.date);
329
- dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
328
+ const dueDate = getWritableDueDate(invoice.date, invoice.dueInDays, 'cii');
330
329
 
331
330
  dateStringElement.textContent = this.formatDateYYYYMMDD(dueDate.getTime(), 'BT-9 payment due date');
332
331
  dueDateElement.appendChild(dateStringElement);
@@ -493,9 +492,10 @@ export class FacturXEncoder extends CIIBaseEncoder {
493
492
  */
494
493
  private formatDateYYYYMMDD(timestamp: number, field: string): string {
495
494
  const date = getWritableDate(timestamp, field, 'cii');
496
- const year = date.getFullYear();
497
- const month = (date.getMonth() + 1).toString().padStart(2, '0');
498
- const day = date.getDate().toString().padStart(2, '0');
495
+ // the UTC fields: a calendar day is its UTC midnight, whatever the server's zone
496
+ const year = date.getUTCFullYear();
497
+ const month = (date.getUTCMonth() + 1).toString().padStart(2, '0');
498
+ const day = date.getUTCDate().toString().padStart(2, '0');
499
499
  return `${year}${month}${day}`;
500
500
  }
501
501
  }
@@ -44,9 +44,7 @@ export class ZUGFeRDDecoder extends CIIBaseDecoder {
44
44
  const invoiceId = this.getText('//rsm:ExchangedDocument/ram:ID');
45
45
 
46
46
  // Extract issue date
47
- const issueDateStr = this.getText('//ram:IssueDateTime/udt:DateTimeString');
48
- const issueDateFormat = this.getText('//ram:IssueDateTime/udt:DateTimeString/@format');
49
- const issueDate = this.parseCIIDate(issueDateStr, issueDateFormat);
47
+ const issueDate = this.extractIssueDate();
50
48
 
51
49
  // Extract seller information
52
50
  const seller = this.extractParty('//ram:SellerTradeParty');
@@ -58,9 +56,8 @@ export class ZUGFeRDDecoder extends CIIBaseDecoder {
58
56
  const items = this.extractItems();
59
57
 
60
58
  // Extract due date
61
- const dueDateStr = this.getText('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString');
62
- const dueDate = dueDateStr ? new Date(dueDateStr).getTime() : Date.now();
63
- const dueInDays = Math.round((dueDate - issueDate) / (1000 * 60 * 60 * 24));
59
+ // no due date stated: `dueInDays` cannot express it yet (required field in @tsclass/tsclass); value unchanged: the days from the issue date to the moment of decoding
60
+ const dueInDays = this.dueInDaysOf(issueDate, this.extractDueDate(), Math.round((Date.now() - issueDate) / (1000 * 60 * 60 * 24)));
64
61
 
65
62
  // Extract currency
66
63
  const currencyCode = this.getText('//ram:InvoiceCurrencyCode') || 'EUR';
@@ -4,7 +4,8 @@ import { ZUGFERD_PROFILE_IDS } from './zugferd.types.js';
4
4
  import { CIIProfile } from '../cii.types.js';
5
5
  import { DOMParser, XMLSerializer } from '../../../plugins.js';
6
6
  import { getDocumentTypeCode } from '../../utils/document.typecode.js';
7
- import { getWritableDate } from '../../utils/date.value.js';
7
+ import { getWritableDate, getWritableDueDate } from '../../utils/date.value.js';
8
+ import { getPaymentTermsNote } from '../../utils/payment.terms.js';
8
9
 
9
10
  /**
10
11
  * Encoder for ZUGFeRD invoice format
@@ -382,7 +383,10 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
382
383
  if (invoice.paymentOptions) {
383
384
  // Add payment instructions as description - this is generic enough to work with any payment type
384
385
  const descriptionElement = doc.createElement('ram:Description');
385
- descriptionElement.textContent = `Due in ${invoice.dueInDays} days. ${invoice.paymentOptions.description || ''}`;
386
+ descriptionElement.textContent = [
387
+ getPaymentTermsNote(invoice.date, invoice.dueInDays, invoice.language, 'cii'),
388
+ invoice.paymentOptions.description,
389
+ ].filter(Boolean).join('. ');
386
390
  paymentTermsElement.appendChild(descriptionElement);
387
391
  }
388
392
 
@@ -392,8 +396,7 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
392
396
  dateStringElement.setAttribute('format', '102'); // YYYYMMDD format
393
397
 
394
398
  // Calculate due date
395
- const dueDate = new Date(invoice.date);
396
- dueDate.setDate(dueDate.getDate() + invoice.dueInDays);
399
+ const dueDate = getWritableDueDate(invoice.date, invoice.dueInDays, 'cii');
397
400
 
398
401
  dateStringElement.textContent = this.formatDateYYYYMMDD(dueDate.getTime(), 'BT-9 payment due date');
399
402
  dueDateElement.appendChild(dateStringElement);
@@ -661,9 +664,10 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
661
664
  */
662
665
  private formatDateYYYYMMDD(timestamp: number, field: string): string {
663
666
  const date = getWritableDate(timestamp, field, 'cii');
664
- const year = date.getFullYear();
665
- const month = (date.getMonth() + 1).toString().padStart(2, '0');
666
- const day = date.getDate().toString().padStart(2, '0');
667
+ // the UTC fields: a calendar day is its UTC midnight, whatever the server's zone
668
+ const year = date.getUTCFullYear();
669
+ const month = (date.getUTCMonth() + 1).toString().padStart(2, '0');
670
+ const day = date.getUTCDate().toString().padStart(2, '0');
667
671
  return `${year}${month}${day}`;
668
672
  }
669
673
  }
@@ -35,6 +35,31 @@ export class ZUGFeRDV1Decoder extends CIIBaseDecoder {
35
35
  };
36
36
  }
37
37
 
38
+ /**
39
+ * Reads the issue date of the v1 header (`rsm:HeaderExchangedDocument`); a
40
+ * missing or invalid one is refused with an `EInvoiceParsingError`.
41
+ */
42
+ protected override extractIssueDate(): number {
43
+ const issueDatePath = '/rsm:CrossIndustryDocument/rsm:HeaderExchangedDocument/ram:IssueDateTime/udt:DateTimeString';
44
+ return this.parseRequiredCIIDate(this.v1Text(issueDatePath), this.v1Text(`${issueDatePath}/@format`));
45
+ }
46
+
47
+ /**
48
+ * Reads the payment due date of the v1 header payment terms; undefined when
49
+ * the document states none.
50
+ */
51
+ protected override extractDueDate(): number | undefined {
52
+ const dueDatePath =
53
+ '/rsm:CrossIndustryDocument/rsm:SpecifiedSupplyChainTradeTransaction/ram:ApplicableSupplyChainTradeSettlement/ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString';
54
+ const dueDate = this.v1Text(dueDatePath);
55
+ return dueDate ? this.parseRequiredCIIDate(dueDate, this.v1Text(`${dueDatePath}/@format`)) : undefined;
56
+ }
57
+
58
+ /** The trimmed text of the first node a v1 path selects, or '' */
59
+ private v1Text(path: string): string {
60
+ return String(zugferdV1Select(`string(${path})`, this.doc)).trim();
61
+ }
62
+
38
63
  /**
39
64
  * Reads the actual delivery date of the v1 header delivery
40
65
  * (`ram:ApplicableSupplyChainTradeDelivery`); undefined when the document
@@ -82,9 +107,7 @@ export class ZUGFeRDV1Decoder extends CIIBaseDecoder {
82
107
  const invoiceId = this.getText('//ram:ID');
83
108
 
84
109
  // Extract issue date
85
- const issueDateStr = this.getText('//ram:IssueDateTime/udt:DateTimeString');
86
- const issueDateFormat = this.getText('//ram:IssueDateTime/udt:DateTimeString/@format');
87
- const issueDate = this.parseCIIDate(issueDateStr, issueDateFormat);
110
+ const issueDate = this.extractIssueDate();
88
111
 
89
112
  // Extract seller information
90
113
  const seller = this.extractParty('//ram:SellerTradeParty');
@@ -96,9 +119,8 @@ export class ZUGFeRDV1Decoder extends CIIBaseDecoder {
96
119
  const items = this.extractItems();
97
120
 
98
121
  // Extract due date
99
- const dueDateStr = this.getText('//ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString');
100
- const dueDate = dueDateStr ? new Date(dueDateStr).getTime() : Date.now();
101
- const dueInDays = Math.round((dueDate - issueDate) / (1000 * 60 * 60 * 24));
122
+ // no due date stated: `dueInDays` cannot express it yet (required field in @tsclass/tsclass); value unchanged: 0 days, what this decoder returned before it read the v1 dates
123
+ const dueInDays = this.dueInDaysOf(issueDate, this.extractDueDate(), 0);
102
124
 
103
125
  // Extract currency
104
126
  const currencyCode = this.getText('//ram:InvoiceCurrencyCode') || 'EUR';
@@ -4,7 +4,8 @@ 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';
8
9
 
9
10
  /**
10
11
  * UBL Encoder implementation
@@ -81,8 +82,7 @@ export class UBLEncoder extends UBLBaseEncoder {
81
82
 
82
83
  // Due Date - the CreditNote schema has no DueDate element; its payment due date lives in cac:PaymentMeans
83
84
  if (documentType === UBLDocumentType.INVOICE) {
84
- const dueDate = getWritableDate(invoice.date, 'BT-2 invoice issue date', 'ubl');
85
- dueDate.setDate(dueDate.getDate() + (invoice.dueInDays || 30));
85
+ const dueDate = getWritableDueDate(invoice.date, invoice.dueInDays, 'ubl');
86
86
  this.appendElement(doc, root, 'cbc:DueDate', this.formatDate(dueDate.getTime(), 'BT-9 payment due date'));
87
87
  }
88
88
  // 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 +319,7 @@ export class UBLEncoder extends UBLBaseEncoder {
319
319
  parentElement.appendChild(paymentTermsNode);
320
320
 
321
321
  // Payment terms note
322
- this.appendElement(doc, paymentTermsNode, 'cbc:Note', `Due in ${invoice.dueInDays} days`);
322
+ this.appendElement(doc, paymentTermsNode, 'cbc:Note', getPaymentTermsNote(invoice.date, invoice.dueInDays, invoice.language, 'ubl'));
323
323
 
324
324
  // Add payment means if available
325
325
  if (invoice.paymentOptions) {
@@ -346,8 +346,7 @@ export class UBLEncoder extends UBLBaseEncoder {
346
346
  this.appendElement(doc, paymentMeansNode, 'cbc:PaymentMeansCode', '30');
347
347
 
348
348
  // 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));
349
+ const dueDate = getWritableDueDate(invoice.date, invoice.dueInDays, 'ubl');
351
350
  this.appendElement(doc, paymentMeansNode, 'cbc:PaymentDueDate', this.formatDate(dueDate.getTime(), 'BT-9 payment due date'));
352
351
 
353
352
  // Add payment channel code if available
@@ -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);
@@ -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
+ };
@@ -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
+ };
@@ -11,6 +11,7 @@ import { EN16931BusinessRulesValidator } from './en16931.business-rules.validato
11
11
  import { CodeListValidator } from './codelist.validator.js';
12
12
  import type { ValidationResult, ValidationOptions, ValidationReport } from './validation.types.js';
13
13
  import type { EInvoice } from '../../einvoice.js';
14
+ import { UBL_CUSTOMIZATION_IDS } from '../ubl/ubl.types.js';
14
15
 
15
16
  /**
16
17
  * Main validator that combines all validation capabilities
@@ -196,9 +197,11 @@ export class MainValidator {
196
197
  const profileId = invoice.metadata?.profileId || '';
197
198
  const customizationId = invoice.metadata?.customizationId || '';
198
199
 
200
+ // XRechnung 3.0 (its extension and CVD identifiers begin with it), the identifier
201
+ // family of XRechnung 1.x and 2.x, and any identifier naming XRechnung
199
202
  const xrechnungProfiles = [
200
- 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung',
201
- 'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung',
203
+ UBL_CUSTOMIZATION_IDS.XRECHNUNG,
204
+ 'urn:xoev-de:kosit:standard:xrechnung_',
202
205
  'xrechnung'
203
206
  ];
204
207
 
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { EInvoice } from '../../einvoice.js';
10
10
  import type { ValidationResult } from './validation.types.js';
11
+ import { UBL_CUSTOMIZATION_IDS } from '../ubl/ubl.types.js';
11
12
 
12
13
  /**
13
14
  * XRechnung-specific validator implementing German CIUS rules
@@ -62,10 +63,11 @@ export class XRechnungValidator {
62
63
  const customizationId = invoice.metadata?.customizationId || '';
63
64
 
64
65
  // XRechnung profile identifiers
66
+ // XRechnung 3.0 (its extension and CVD identifiers begin with it), the identifier
67
+ // family of XRechnung 1.x and 2.x, and any identifier naming XRechnung
65
68
  const xrechnungProfiles = [
66
- 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
67
- 'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung_3.0',
68
- 'urn:cen.eu:en16931:2017:xrechnung',
69
+ UBL_CUSTOMIZATION_IDS.XRECHNUNG,
70
+ 'urn:xoev-de:kosit:standard:xrechnung_',
69
71
  'xrechnung'
70
72
  ];
71
73