@fin.cx/einvoice 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/einvoice.d.ts +2 -0
  3. package/dist_ts/einvoice.js +7 -13
  4. package/dist_ts/formats/base/base.decoder.d.ts +9 -1
  5. package/dist_ts/formats/base/base.decoder.js +24 -12
  6. package/dist_ts/formats/cii/cii.decoder.d.ts +11 -3
  7. package/dist_ts/formats/cii/cii.decoder.js +27 -8
  8. package/dist_ts/formats/cii/cii.encoder.d.ts +12 -0
  9. package/dist_ts/formats/cii/cii.encoder.js +36 -1
  10. package/dist_ts/formats/cii/cii.types.js +2 -1
  11. package/dist_ts/formats/cii/facturx/facturx.encoder.js +12 -8
  12. package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +12 -8
  13. package/dist_ts/formats/semantic/semantic.adapter.d.ts +0 -4
  14. package/dist_ts/formats/semantic/semantic.adapter.js +7 -18
  15. package/dist_ts/formats/ubl/generic/ubl.encoder.d.ts +6 -1
  16. package/dist_ts/formats/ubl/generic/ubl.encoder.js +40 -8
  17. package/dist_ts/formats/ubl/ubl.decoder.d.ts +11 -0
  18. package/dist_ts/formats/ubl/ubl.decoder.js +30 -1
  19. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +3 -3
  20. package/dist_ts/formats/utils/date.value.d.ts +24 -0
  21. package/dist_ts/formats/utils/date.value.js +77 -12
  22. package/dist_ts/formats/utils/delivery.address.d.ts +18 -0
  23. package/dist_ts/formats/utils/delivery.address.js +30 -0
  24. package/dist_ts/formats/utils/document.totals.d.ts +5 -4
  25. package/dist_ts/formats/utils/document.totals.js +24 -8
  26. package/dist_ts/formats/utils/stated.values.js +4 -2
  27. package/dist_ts/formats/utils/vat.category.d.ts +146 -55
  28. package/dist_ts/formats/utils/vat.category.js +357 -69
  29. package/dist_ts/formats/validation/vat-categories.validator.d.ts +14 -15
  30. package/dist_ts/formats/validation/vat-categories.validator.js +123 -85
  31. package/package.json +5 -5
  32. package/readme.md +61 -20
  33. package/ts/00_commitinfo_data.ts +1 -1
  34. package/ts/einvoice.ts +8 -14
  35. package/ts/formats/base/base.decoder.ts +24 -17
  36. package/ts/formats/cii/cii.decoder.ts +28 -7
  37. package/ts/formats/cii/cii.encoder.ts +36 -0
  38. package/ts/formats/cii/cii.types.ts +1 -0
  39. package/ts/formats/cii/facturx/facturx.encoder.ts +12 -7
  40. package/ts/formats/cii/zugferd/zugferd.encoder.ts +12 -7
  41. package/ts/formats/semantic/semantic.adapter.ts +6 -15
  42. package/ts/formats/ubl/generic/ubl.encoder.ts +40 -7
  43. package/ts/formats/ubl/ubl.decoder.ts +31 -0
  44. package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +2 -2
  45. package/ts/formats/utils/date.value.ts +84 -11
  46. package/ts/formats/utils/delivery.address.ts +36 -0
  47. package/ts/formats/utils/document.totals.ts +38 -11
  48. package/ts/formats/utils/stated.values.ts +4 -1
  49. package/ts/formats/utils/vat.category.ts +426 -95
  50. package/ts/formats/validation/vat-categories.validator.ts +169 -88
@@ -8,6 +8,8 @@ import type { TPrecedingInvoiceReference } from '../utils/preceding.invoice.js';
8
8
  import type { IEInvoiceMetadata } from '../../interfaces/en16931-metadata.js';
9
9
  import type { IEInvoiceStatedValues } from '../../interfaces/stated.values.js';
10
10
  import { readCiiStatedValues } from '../utils/stated.values.js';
11
+ import { applyStatedVatCategories } from '../utils/vat.category.js';
12
+ import { deliveryAddressOf, withDeliveryAddress, type TDeliveryAddress } from '../utils/delivery.address.js';
11
13
 
12
14
  /**
13
15
  * The invoicing period (BG-14) a CII document states: its start date (BT-73),
@@ -79,10 +81,29 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
79
81
  const accountingDocType = getAccountingDocType(
80
82
  this.getText('/rsm:CrossIndustryInvoice/rsm:ExchangedDocument/ram:TypeCode'),
81
83
  );
82
- if (accountingDocType === 'creditnote') {
83
- return this.decodeCreditNote();
84
- }
85
- return this.decodeInvoice(accountingDocType);
84
+ const document = accountingDocType === 'creditnote' ? await this.decodeCreditNote() : await this.decodeInvoice(accountingDocType);
85
+ // every item with the VAT category its line states and its breakdown's exemption reason
86
+ applyStatedVatCategories(document.items, this.statedValues);
87
+ return withDeliveryAddress(document, this.readDeliveryAddress());
88
+ }
89
+
90
+ /**
91
+ * Reads the deliver to address (BG-15) of the header delivery: line one and
92
+ * two as the street (BT-75) and the house number, the city (BT-77), the post
93
+ * code (BT-78), the country subdivision (BT-79) and the country (BT-80);
94
+ * undefined when the document states no address with a country
95
+ */
96
+ protected readDeliveryAddress(): TDeliveryAddress | undefined {
97
+ const address =
98
+ '/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:ApplicableHeaderTradeDelivery/ram:ShipToTradeParty/ram:PostalTradeAddress';
99
+ return deliveryAddressOf({
100
+ streetName: this.getText(`${address}/ram:LineOne`),
101
+ houseNumber: this.getText(`${address}/ram:LineTwo`),
102
+ city: this.getText(`${address}/ram:CityName`),
103
+ postalCode: this.getText(`${address}/ram:PostcodeCode`),
104
+ countrySubdivision: this.getText(`${address}/ram:CountrySubDivisionName`),
105
+ countryCode: this.getText(`${address}/ram:CountryID`),
106
+ });
86
107
  }
87
108
 
88
109
  /**
@@ -158,9 +179,9 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
158
179
  }
159
180
 
160
181
  /**
161
- * Whether every line of the document has the VAT category reverse charge (AE, BT-151); the
162
- * envelope states it for the whole document, so a document mixing AE lines with others
163
- * cannot say it
182
+ * Whether every line of the document has the VAT category reverse charge (AE, BT-151); a
183
+ * document mixing AE lines with others is not, and its items carry their categories
184
+ * (`applyStatedVatCategories`)
164
185
  */
165
186
  protected isReverseChargeDocument(): boolean {
166
187
  const categories = this.select(
@@ -284,6 +284,42 @@ export abstract class CIIBaseEncoder extends BaseEncoder {
284
284
  agreementElement.appendChild(buyerReferenceElement);
285
285
  }
286
286
 
287
+ /**
288
+ * Adds the deliver to address (BG-15) of `metadata.deliveryAddress` as
289
+ * `ram:ShipToTradeParty/ram:PostalTradeAddress` of the header delivery, when
290
+ * it states a country (BT-80, which BR-57 requires of the address): the
291
+ * street and house number as line one and two (BT-75, BT-76), the city
292
+ * (BT-77), the post code (BT-78), the country subdivision (BT-79) and the
293
+ * country. An intra-community supply states it (BR-IC-12).
294
+ * @param doc XML document
295
+ * @param deliveryElement The header trade delivery
296
+ * @param invoice Invoice data
297
+ */
298
+ protected addDeliverToAddress(doc: Document, deliveryElement: Element, invoice: TAccountingDoc): void {
299
+ const address = (invoice as TAccountingDoc & { metadata?: IEInvoiceMetadata }).metadata?.deliveryAddress;
300
+ const countryCode = address?.countryCode?.trim();
301
+ if (!address || !countryCode) {
302
+ return;
303
+ }
304
+ const shipTo = doc.createElement('ram:ShipToTradeParty');
305
+ const postal = doc.createElement('ram:PostalTradeAddress');
306
+ const append = (name: string, text: string | undefined) => {
307
+ if (text?.trim()) {
308
+ const element = doc.createElement(name);
309
+ element.textContent = text.trim();
310
+ postal.appendChild(element);
311
+ }
312
+ };
313
+ append('ram:PostcodeCode', address.postalCode);
314
+ append('ram:LineOne', address.streetName);
315
+ append('ram:LineTwo', address.houseNumber);
316
+ append('ram:CityName', address.city);
317
+ append('ram:CountryID', countryCode);
318
+ append('ram:CountrySubDivisionName', address.countrySubdivision);
319
+ shipTo.appendChild(postal);
320
+ deliveryElement.insertBefore(shipTo, deliveryElement.firstChild);
321
+ }
322
+
287
323
  /**
288
324
  * Adds the invoicing period (BG-14) as `ram:BillingSpecifiedPeriod` of the
289
325
  * header settlement, where the CII schema puts it: the start date (BT-73) and
@@ -59,6 +59,7 @@ export const CII_CHILD_SEQUENCES: Readonly<Record<string, readonly string[]>> =
59
59
  ApplicableHeaderTradeAgreement: ['Reference', 'BuyerReference', 'SellerTradeParty', 'BuyerTradeParty', 'SalesAgentTradeParty', 'BuyerRequisitionerTradeParty', 'BuyerAssignedAccountantTradeParty', 'SellerAssignedAccountantTradeParty', 'BuyerTaxRepresentativeTradeParty', 'SellerTaxRepresentativeTradeParty', 'ProductEndUserTradeParty', 'ApplicableTradeDeliveryTerms', 'SellerOrderReferencedDocument', 'BuyerOrderReferencedDocument', 'QuotationReferencedDocument', 'OrderResponseReferencedDocument', 'ContractReferencedDocument', 'DemandForecastReferencedDocument', 'SupplyInstructionReferencedDocument', 'PromotionalDealReferencedDocument', 'PriceListReferencedDocument', 'AdditionalReferencedDocument', 'RequisitionerReferencedDocument', 'BuyerAgentTradeParty', 'PurchaseConditionsReferencedDocument', 'SpecifiedProcuringProject', 'UltimateCustomerOrderReferencedDocument'],
60
60
  SellerTradeParty: ['ID', 'GlobalID', 'Name', 'RoleCode', 'Description', 'SpecifiedLegalOrganization', 'DefinedTradeContact', 'PostalTradeAddress', 'URIUniversalCommunication', 'SpecifiedTaxRegistration', 'EndPointURIUniversalCommunication', 'LogoAssociatedSpecifiedBinaryFile'],
61
61
  BuyerTradeParty: ['ID', 'GlobalID', 'Name', 'RoleCode', 'Description', 'SpecifiedLegalOrganization', 'DefinedTradeContact', 'PostalTradeAddress', 'URIUniversalCommunication', 'SpecifiedTaxRegistration', 'EndPointURIUniversalCommunication', 'LogoAssociatedSpecifiedBinaryFile'],
62
+ ShipToTradeParty: ['ID', 'GlobalID', 'Name', 'RoleCode', 'Description', 'SpecifiedLegalOrganization', 'DefinedTradeContact', 'PostalTradeAddress', 'URIUniversalCommunication', 'SpecifiedTaxRegistration', 'EndPointURIUniversalCommunication', 'LogoAssociatedSpecifiedBinaryFile'],
62
63
  PostalTradeAddress: ['ID', 'PostcodeCode', 'PostOfficeBox', 'BuildingName', 'LineOne', 'LineTwo', 'LineThree', 'LineFour', 'LineFive', 'StreetName', 'CityName', 'CitySubDivisionName', 'CountryID', 'CountryName', 'CountrySubDivisionID', 'CountrySubDivisionName', 'AttentionOf', 'CareOf', 'BuildingNumber', 'DepartmentName', 'AdditionalStreetName'],
63
64
  DefinedTradeContact: ['ID', 'PersonName', 'DepartmentName', 'TypeCode', 'JobTitle', 'Responsibility', 'PersonID', 'TelephoneUniversalCommunication', 'DirectTelephoneUniversalCommunication', 'MobileTelephoneUniversalCommunication', 'FaxUniversalCommunication', 'EmailURIUniversalCommunication', 'TelexUniversalCommunication', 'VOIPUniversalCommunication', 'InstantMessagingUniversalCommunication', 'SpecifiedNote', 'SpecifiedContactPerson'],
64
65
  SpecifiedLegalOrganization: ['LegalClassificationCode', 'Name', 'ID', 'TradingBusinessName', 'PostalTradeAddress', 'AuthorizedLegalRegistration'],
@@ -308,6 +308,9 @@ export class FacturXEncoder extends CIIBaseEncoder {
308
308
  const deliveryElement = doc.createElement('ram:ApplicableHeaderTradeDelivery');
309
309
  transactionElement.appendChild(deliveryElement);
310
310
 
311
+ // Deliver to address (BG-15), when the document states its country (BT-80)
312
+ this.addDeliverToAddress(doc, deliveryElement, invoice);
313
+
311
314
  // Add delivery date if available
312
315
  if (invoice.deliveryDate !== undefined) {
313
316
  const deliveryDateElement = doc.createElement('ram:ActualDeliverySupplyChainEvent');
@@ -351,12 +354,12 @@ export class FacturXEncoder extends CIIBaseEncoder {
351
354
  };
352
355
  appendText('ram:CalculatedAmount', taxAmount.toFixed(totals.minorUnits)); // BT-117
353
356
  appendText('ram:TypeCode', 'VAT');
354
- if (exemption) {
355
- appendText('ram:ExemptionReason', exemption.reason); // BT-120 (BR-AE-10)
357
+ if (exemption?.reason) {
358
+ appendText('ram:ExemptionReason', exemption.reason); // BT-120 (BR-x-10)
356
359
  }
357
360
  appendText('ram:BasisAmount', taxableAmount.toFixed(totals.minorUnits)); // BT-116
358
361
  appendText('ram:CategoryCode', category); // BT-118
359
- if (exemption) {
362
+ if (exemption?.code) {
360
363
  appendText('ram:ExemptionReasonCode', exemption.code); // BT-121
361
364
  }
362
365
  appendText('ram:RateApplicablePercent', rate.toString()); // BT-119
@@ -483,10 +486,12 @@ export class FacturXEncoder extends CIIBaseEncoder {
483
486
  taxCategoryCodeElement.textContent = totals.lineVatCategories[index]; // BT-151
484
487
  taxElement.appendChild(taxCategoryCodeElement);
485
488
 
486
- // Add tax rate
487
- const taxRateElement = doc.createElement('ram:RateApplicablePercent');
488
- taxRateElement.textContent = item.vatPercentage.toString();
489
- taxElement.appendChild(taxRateElement);
489
+ // Invoiced item VAT rate (BT-152); an item not subject to VAT has none (BR-O-05)
490
+ if (totals.lineVatCategories[index] !== 'O') {
491
+ const taxRateElement = doc.createElement('ram:RateApplicablePercent');
492
+ taxRateElement.textContent = item.vatPercentage.toString();
493
+ taxElement.appendChild(taxRateElement);
494
+ }
490
495
 
491
496
  settlementElement.appendChild(taxElement);
492
497
 
@@ -320,6 +320,9 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
320
320
  const deliveryElement = doc.createElement('ram:ApplicableHeaderTradeDelivery');
321
321
  transactionElement.appendChild(deliveryElement);
322
322
 
323
+ // Deliver to address (BG-15), when the document states its country (BT-80)
324
+ this.addDeliverToAddress(doc, deliveryElement, invoice);
325
+
323
326
  // Add delivery date if available
324
327
  if (invoice.deliveryDate !== undefined) {
325
328
  const deliveryDateElement = doc.createElement('ram:ActualDeliverySupplyChainEvent');
@@ -449,8 +452,8 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
449
452
  typeCodeElement.textContent = 'VAT';
450
453
  taxElement.appendChild(typeCodeElement);
451
454
 
452
- // VAT exemption reason text (BT-120), for reverse charge (BR-AE-10)
453
- if (exemption) {
455
+ // VAT exemption reason text (BT-120), for the categories that state one (BR-x-10)
456
+ if (exemption?.reason) {
454
457
  const exemptionReasonElement = doc.createElement('ram:ExemptionReason');
455
458
  exemptionReasonElement.textContent = exemption.reason;
456
459
  taxElement.appendChild(exemptionReasonElement);
@@ -467,7 +470,7 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
467
470
  taxElement.appendChild(categoryCodeElement);
468
471
 
469
472
  // VAT exemption reason code (BT-121)
470
- if (exemption) {
473
+ if (exemption?.code) {
471
474
  const exemptionReasonCodeElement = doc.createElement('ram:ExemptionReasonCode');
472
475
  exemptionReasonCodeElement.textContent = exemption.code;
473
476
  taxElement.appendChild(exemptionReasonCodeElement);
@@ -591,10 +594,12 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
591
594
  taxCategoryCodeElement.textContent = totals.lineVatCategories[index]; // BT-151
592
595
  taxElement.appendChild(taxCategoryCodeElement);
593
596
 
594
- // Add tax rate
595
- const taxRateElement = doc.createElement('ram:RateApplicablePercent');
596
- taxRateElement.textContent = item.vatPercentage.toString();
597
- taxElement.appendChild(taxRateElement);
597
+ // Invoiced item VAT rate (BT-152); an item not subject to VAT has none (BR-O-05)
598
+ if (totals.lineVatCategories[index] !== 'O') {
599
+ const taxRateElement = doc.createElement('ram:RateApplicablePercent');
600
+ taxRateElement.textContent = item.vatPercentage.toString();
601
+ taxElement.appendChild(taxRateElement);
602
+ }
598
603
 
599
604
  settlementElement.appendChild(taxElement);
600
605
 
@@ -7,6 +7,7 @@ import { EInvoice } from '../../einvoice.js';
7
7
  import { getAccountingDocType, getDocumentTypeCode } from '../utils/document.typecode.js';
8
8
  import { computeDocumentTotals } from '../utils/document.totals.js';
9
9
  import { getPaymentTotals } from '../utils/paid.amount.js';
10
+ import { getItemVatCategory } from '../utils/vat.category.js';
10
11
  import type {
11
12
  EN16931SemanticModel,
12
13
  Seller,
@@ -119,7 +120,7 @@ export class SemanticModelAdapter {
119
120
  additionalDocuments: invoice.metadata?.extensions?.supportingDocuments,
120
121
 
121
122
  // Invoice lines
122
- invoiceLines: this.mapInvoiceLines(invoice.items || [])
123
+ invoiceLines: this.mapInvoiceLines(invoice)
123
124
  };
124
125
  }
125
126
 
@@ -443,9 +444,8 @@ export class SemanticModelAdapter {
443
444
  /**
444
445
  * Map invoice lines
445
446
  */
446
- private mapInvoiceLines(items: EInvoice['items']): InvoiceLine[] {
447
- if (!items) return [];
448
-
447
+ private mapInvoiceLines(invoice: EInvoice): InvoiceLine[] {
448
+ const items = invoice.items ?? [];
449
449
  return items.map((item, index) => ({
450
450
  identifier: (index + 1).toString(),
451
451
  note: (item as any).description || (item as any).text || '',
@@ -464,7 +464,8 @@ export class SemanticModelAdapter {
464
464
  itemPriceBaseQuantity: (item as any).priceBaseQuantity || 1
465
465
  },
466
466
  vatInformation: {
467
- categoryCode: this.mapVATCategory(item.vatPercentage),
467
+ // the category the encoders write: the item's own, else AE or S (BT-151)
468
+ categoryCode: getItemVatCategory(invoice, item),
468
469
  rate: item.vatPercentage
469
470
  },
470
471
  itemInformation: {
@@ -480,16 +481,6 @@ export class SemanticModelAdapter {
480
481
  }));
481
482
  }
482
483
 
483
- /**
484
- * Map VAT category from percentage
485
- */
486
- private mapVATCategory(percentage?: number): string {
487
- if (percentage === undefined || percentage === null) return 'S';
488
- if (percentage === 0) return 'Z';
489
- if (percentage > 0) return 'S';
490
- return 'E'; // Exempt
491
- }
492
-
493
484
  /**
494
485
  * Reverse map seller
495
486
  */
@@ -11,6 +11,7 @@ import { getPaymentTotals } from '../../utils/paid.amount.js';
11
11
  import { toPlainDecimalString } from '../../utils/number.text.js';
12
12
  import { resolveCountryCode } from '../../utils/country.code.js';
13
13
  import { getPartyIdentifiers } from '../../utils/party.identifier.js';
14
+ import type { IEInvoiceMetadata } from '../../../interfaces/en16931-metadata.js';
14
15
 
15
16
  /**
16
17
  * UBL Encoder implementation
@@ -189,17 +190,45 @@ export class UBLEncoder extends UBLBaseEncoder {
189
190
  }
190
191
 
191
192
  /**
192
- * Writes the actual delivery date (BT-72) the document states.
193
+ * Writes the actual delivery date (BT-72) and the deliver to address (BG-15)
194
+ * the document states. The address is `metadata.deliveryAddress` when it
195
+ * states a country (BT-80, which BR-57 requires of the address): the street
196
+ * (BT-75), the house number, the city (BT-77), the post code (BT-78), the
197
+ * country subdivision (BT-79) and the country. An intra-community supply
198
+ * states it (BR-IC-12).
193
199
  * @param doc XML document
194
200
  * @param root Document root
195
201
  * @param invoice Accounting document
196
202
  */
197
203
  private addDelivery(doc: Document, root: Element, invoice: TAccountingDoc): void {
198
- if (invoice.deliveryDate === undefined) {
204
+ const address = (invoice as TAccountingDoc & { metadata?: IEInvoiceMetadata }).metadata?.deliveryAddress;
205
+ const countryCode = address?.countryCode?.trim();
206
+ if (invoice.deliveryDate === undefined && !countryCode) {
199
207
  return;
200
208
  }
201
209
  const delivery = doc.createElement('cac:Delivery');
202
- this.appendElement(doc, delivery, 'cbc:ActualDeliveryDate', this.formatDate(invoice.deliveryDate, 'BT-72 actual delivery date'));
210
+ if (invoice.deliveryDate !== undefined) {
211
+ this.appendElement(doc, delivery, 'cbc:ActualDeliveryDate', this.formatDate(invoice.deliveryDate, 'BT-72 actual delivery date'));
212
+ }
213
+ if (address && countryCode) {
214
+ const location = doc.createElement('cac:DeliveryLocation');
215
+ const addressNode = doc.createElement('cac:Address');
216
+ const append = (name: string, text: string | undefined) => {
217
+ if (text?.trim()) {
218
+ this.appendElement(doc, addressNode, name, text.trim());
219
+ }
220
+ };
221
+ append('cbc:StreetName', address.streetName);
222
+ append('cbc:BuildingNumber', address.houseNumber);
223
+ append('cbc:CityName', address.city);
224
+ append('cbc:PostalZone', address.postalCode);
225
+ append('cbc:CountrySubentity', address.countrySubdivision);
226
+ const country = doc.createElement('cac:Country');
227
+ this.appendElement(doc, country, 'cbc:IdentificationCode', countryCode);
228
+ addressNode.appendChild(country);
229
+ location.appendChild(addressNode);
230
+ delivery.appendChild(location);
231
+ }
203
232
  root.appendChild(delivery);
204
233
  }
205
234
 
@@ -456,9 +485,11 @@ export class UBLEncoder extends UBLBaseEncoder {
456
485
  this.appendElement(doc, taxCategoryNode, 'cbc:ID', category);
457
486
  this.appendElement(doc, taxCategoryNode, 'cbc:Percent', rate.toFixed(2));
458
487
 
459
- // VAT exemption reason code (BT-121) and text (BT-120), for reverse charge (BR-AE-10)
460
- if (exemption) {
488
+ // VAT exemption reason code (BT-121) and text (BT-120), for the categories that state one (BR-x-10)
489
+ if (exemption?.code) {
461
490
  this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', exemption.code);
491
+ }
492
+ if (exemption?.reason) {
462
493
  this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', exemption.reason);
463
494
  }
464
495
 
@@ -573,8 +604,10 @@ export class UBLEncoder extends UBLBaseEncoder {
573
604
  // Invoiced item VAT category code (BT-151)
574
605
  this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', totals.lineVatCategories[index]);
575
606
 
576
- // Tax percent with 2 decimal places
577
- this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toFixed(2));
607
+ // Invoiced item VAT rate (BT-152) with 2 decimal places; an item not subject to VAT has none (BR-O-05)
608
+ if (totals.lineVatCategories[index] !== 'O') {
609
+ this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toFixed(2));
610
+ }
578
611
 
579
612
  // Tax scheme
580
613
  const taxSchemeNode = doc.createElement('cac:TaxScheme');
@@ -5,6 +5,8 @@ import { DOMParser, xpath } from '../../plugins.js';
5
5
  import { EInvoiceParsingError } from '../../errors.js';
6
6
  import { getAccountingDocType } from '../utils/document.typecode.js';
7
7
  import { readUblStatedValues } from '../utils/stated.values.js';
8
+ import { applyStatedVatCategories } from '../utils/vat.category.js';
9
+ import { deliveryAddressOf, withDeliveryAddress, type TDeliveryAddress } from '../utils/delivery.address.js';
8
10
  import type { IEInvoiceStatedValues } from '../../interfaces/stated.values.js';
9
11
 
10
12
  const ublParser = new DOMParser();
@@ -54,6 +56,35 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
54
56
  // the values the document states, read first: an amount that is no number is refused
55
57
  this.statedValues = this.assertStatedValuesReadable(readUblStatedValues(this.doc), 'ubl');
56
58
 
59
+ // a CreditNote root is a credit note whatever its type code says
60
+ const document = await this.decodeByType();
61
+ // every item with the VAT category its line states and its breakdown's exemption reason
62
+ applyStatedVatCategories(document.items, this.statedValues);
63
+ return withDeliveryAddress(document, this.readDeliveryAddress());
64
+ }
65
+
66
+ /**
67
+ * Reads the deliver to address (BG-15) of the first delivery: the street
68
+ * (BT-75), the house number, the city (BT-77), the post code (BT-78), the
69
+ * country subdivision (BT-79) and the country (BT-80); undefined when the
70
+ * document states no address with a country
71
+ */
72
+ private readDeliveryAddress(): TDeliveryAddress | undefined {
73
+ const address = '/*/cac:Delivery[1]/cac:DeliveryLocation/cac:Address';
74
+ return deliveryAddressOf({
75
+ streetName: this.getText(`${address}/cbc:StreetName`, this.doc),
76
+ houseNumber: this.getText(`${address}/cbc:BuildingNumber`, this.doc),
77
+ city: this.getText(`${address}/cbc:CityName`, this.doc),
78
+ postalCode: this.getText(`${address}/cbc:PostalZone`, this.doc),
79
+ countrySubdivision: this.getText(`${address}/cbc:CountrySubentity`, this.doc),
80
+ countryCode: this.getText(`${address}/cac:Country/cbc:IdentificationCode`, this.doc),
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Decodes the document by its root and document type code (BT-3)
86
+ */
87
+ private async decodeByType(): Promise<TAccountingDoc> {
57
88
  // a CreditNote root is a credit note whatever its type code says
58
89
  if (this.getDocumentType() === UBLDocumentType.CREDIT_NOTE) {
59
90
  return this.decodeCreditNote();
@@ -265,8 +265,8 @@ export class XRechnungDecoder extends UBLBaseDecoder {
265
265
  subject: subject,
266
266
  items: items,
267
267
  dueInDays: dueInDays,
268
- // reverse charge when every line is VAT category AE; the envelope states it for the
269
- // whole document, so a document mixing AE lines with others cannot say it
268
+ // reverse charge when every line is VAT category AE; in a document mixing AE lines with
269
+ // others each item carries its category (applyStatedVatCategories)
270
270
  reverseCharge: this.isReverseChargeDocument(),
271
271
  currency: currencyCode as finance.TCurrency,
272
272
  notes: notes,
@@ -51,6 +51,42 @@ export const getWritableDueDate = (issueTimestamp: unknown, dueInDays: unknown,
51
51
  return dueDate;
52
52
  };
53
53
 
54
+ /**
55
+ * The UTC midnight of a calendar day, or undefined when there is no such day
56
+ * @param year The year, 1 or later
57
+ * @param month The month, 1 to 12
58
+ * @param day The day of the month
59
+ */
60
+ const utcMidnightOf = (year: number, month: number, day: number): number | undefined => {
61
+ if (year < 1) {
62
+ return undefined;
63
+ }
64
+ const date = new Date(0);
65
+ date.setUTCFullYear(year, month - 1, day);
66
+ if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
67
+ return undefined;
68
+ }
69
+ return date.getTime();
70
+ };
71
+
72
+ /**
73
+ * Whether a time of day is one: hours 0 to 23, minutes and seconds 0 to 59
74
+ * @param hours The hours
75
+ * @param minutes The minutes
76
+ * @param seconds The seconds, 0 when not stated
77
+ */
78
+ const isTimeOfDay = (hours: number, minutes: number, seconds: number): boolean =>
79
+ hours <= 23 && minutes <= 59 && seconds <= 59;
80
+
81
+ /**
82
+ * Whether a time zone offset is one an `xsd:date` or `xsd:dateTime` may carry:
83
+ * at most 14:00
84
+ * @param hours The hours of the offset
85
+ * @param minutes The minutes of the offset
86
+ */
87
+ const isZoneOffset = (hours: number, minutes: number): boolean =>
88
+ hours < 14 ? minutes <= 59 : hours === 14 && minutes === 0;
89
+
54
90
  /**
55
91
  * The calendar day a UBL `xsd:date` names (BT-2, BT-9, BT-26, BT-72, BT-73,
56
92
  * BT-74), as the UTC midnight of that day; undefined when the value is no such
@@ -66,20 +102,57 @@ export const parseXsdDateDay = (value: string): number | undefined => {
66
102
  if (!match) {
67
103
  return undefined;
68
104
  }
69
- const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
70
- if (match[4] !== undefined) {
71
- const [zoneHours, zoneMinutes] = [Number(match[4]), Number(match[5])];
72
- if (zoneHours > 14 || zoneMinutes > 59 || (zoneHours === 14 && zoneMinutes !== 0)) {
73
- return undefined;
74
- }
105
+ if (match[4] !== undefined && !isZoneOffset(Number(match[4]), Number(match[5]))) {
106
+ return undefined;
75
107
  }
76
- if (year < 1) {
108
+ return utcMidnightOf(Number(match[1]), Number(match[2]), Number(match[3]));
109
+ };
110
+
111
+ /**
112
+ * The calendar day an ISO 8601 date, or date and time, names, as the UTC
113
+ * midnight of that day; undefined when the value is no such date. A CII date
114
+ * without a format code may be stated so. The value is `YYYY-MM-DD`,
115
+ * optionally followed by a time of day `Thh:mm`, `Thh:mm:ss` or
116
+ * `Thh:mm:ss.fff`, and then optionally by a time zone: `Z`, or `+hh:mm` /
117
+ * `-hh:mm` of at most 14:00. Neither the time of day nor the zone moves the
118
+ * day the document states: `2026-09-11T00:30:00+02:00` is 22:30 UTC on
119
+ * 10 September, but the document states 11 September, and that is the day an
120
+ * invoice is dated with (§ 14 Abs. 4 Satz 1 Nr. 3 UStG asks for the issue date,
121
+ * a calendar day).
122
+ * @param value The date as the document states it
123
+ */
124
+ export const parseIsoDateTimeDay = (value: string): number | undefined => {
125
+ const match =
126
+ /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?(?:Z|[+-](\d{2}):(\d{2}))?$/.exec(value.trim());
127
+ if (!match) {
77
128
  return undefined;
78
129
  }
79
- const date = new Date(0);
80
- date.setUTCFullYear(year, month - 1, day);
81
- if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
130
+ if (match[4] !== undefined && !isTimeOfDay(Number(match[4]), Number(match[5]), Number(match[6] ?? 0))) {
82
131
  return undefined;
83
132
  }
84
- return date.getTime();
133
+ if (match[7] !== undefined && !isZoneOffset(Number(match[7]), Number(match[8]))) {
134
+ return undefined;
135
+ }
136
+ return utcMidnightOf(Number(match[1]), Number(match[2]), Number(match[3]));
137
+ };
138
+
139
+ /**
140
+ * The calendar day a CII date in format 102 (`YYYYMMDD`), 203
141
+ * (`YYYYMMDDhhmm`) or 204 (`YYYYMMDDhhmmss`) names, as the UTC midnight of that
142
+ * day; undefined when the value is no such date in that format. A time of day
143
+ * in formats 203 and 204 does not move the day stated; the formats carry no
144
+ * time zone.
145
+ * @param value The date as the document states it
146
+ * @param format The format code: '102', '203' or '204'
147
+ */
148
+ export const parseCiiDateDay = (value: string, format: '102' | '203' | '204'): number | undefined => {
149
+ const timeDigits = { '102': '', '203': '(\\d{2})(\\d{2})', '204': '(\\d{2})(\\d{2})(\\d{2})' }[format];
150
+ const match = new RegExp(`^(\\d{4})(\\d{2})(\\d{2})${timeDigits}$`).exec(value.trim());
151
+ if (!match) {
152
+ return undefined;
153
+ }
154
+ if (format !== '102' && !isTimeOfDay(Number(match[4]), Number(match[5]), Number(match[6] ?? 0))) {
155
+ return undefined;
156
+ }
157
+ return utcMidnightOf(Number(match[1]), Number(match[2]), Number(match[3]));
85
158
  };
@@ -0,0 +1,36 @@
1
+ import type { TAccountingDoc } from '../../interfaces/common.js';
2
+ import type { IEInvoiceMetadata } from '../../interfaces/en16931-metadata.js';
3
+
4
+ /** The deliver to address (BG-15) of a document, as `metadata.deliveryAddress` carries it */
5
+ export type TDeliveryAddress = NonNullable<IEInvoiceMetadata['deliveryAddress']>;
6
+
7
+ /**
8
+ * The deliver to address from the texts a document states, each trimmed and
9
+ * left out when empty; undefined when it states no country (BT-80), which
10
+ * every deliver to address has (BR-57)
11
+ * @param texts The texts read, '' where the document states none
12
+ */
13
+ export const deliveryAddressOf = (texts: Record<keyof TDeliveryAddress, string>): TDeliveryAddress | undefined => {
14
+ const address: TDeliveryAddress = {};
15
+ for (const [field, text] of Object.entries(texts) as Array<[keyof TDeliveryAddress, string]>) {
16
+ if (text.trim()) {
17
+ address[field] = text.trim();
18
+ }
19
+ }
20
+ return address.countryCode ? address : undefined;
21
+ };
22
+
23
+ /**
24
+ * The decoded document with the deliver to address in its metadata, or
25
+ * unchanged when it states none
26
+ * @param document The decoded document
27
+ * @param address The deliver to address read
28
+ */
29
+ export const withDeliveryAddress = <TDocument extends TAccountingDoc>(document: TDocument, address: TDeliveryAddress | undefined): TDocument => {
30
+ if (!address) {
31
+ return document;
32
+ }
33
+ const withMetadata = document as TDocument & { metadata?: IEInvoiceMetadata };
34
+ withMetadata.metadata = { ...withMetadata.metadata, deliveryAddress: address };
35
+ return withMetadata;
36
+ };