@fin.cx/einvoice 8.2.2 → 8.2.4

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 (40) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/formats/cii/cii.decoder.d.ts +6 -0
  3. package/dist_ts/formats/cii/cii.decoder.js +11 -1
  4. package/dist_ts/formats/cii/cii.encoder.d.ts +10 -0
  5. package/dist_ts/formats/cii/cii.encoder.js +42 -2
  6. package/dist_ts/formats/cii/cii.types.d.ts +11 -0
  7. package/dist_ts/formats/cii/cii.types.js +41 -1
  8. package/dist_ts/formats/cii/facturx/facturx.decoder.js +8 -4
  9. package/dist_ts/formats/cii/facturx/facturx.encoder.js +30 -17
  10. package/dist_ts/formats/cii/zugferd/zugferd.decoder.js +8 -4
  11. package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +63 -43
  12. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.d.ts +5 -0
  13. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.js +18 -5
  14. package/dist_ts/formats/ubl/generic/ubl.encoder.js +14 -15
  15. package/dist_ts/formats/ubl/ubl.encoder.js +4 -1
  16. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.d.ts +4 -0
  17. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +12 -2
  18. package/dist_ts/formats/utils/document.totals.d.ts +16 -6
  19. package/dist_ts/formats/utils/document.totals.js +29 -11
  20. package/dist_ts/formats/utils/number.text.d.ts +9 -0
  21. package/dist_ts/formats/utils/number.text.js +26 -0
  22. package/dist_ts/formats/utils/vat.category.d.ts +50 -0
  23. package/dist_ts/formats/utils/vat.category.js +64 -0
  24. package/package.json +2 -2
  25. package/readme.md +25 -0
  26. package/ts/00_commitinfo_data.ts +1 -1
  27. package/ts/formats/cii/cii.decoder.ts +14 -0
  28. package/ts/formats/cii/cii.encoder.ts +42 -1
  29. package/ts/formats/cii/cii.types.ts +41 -0
  30. package/ts/formats/cii/facturx/facturx.decoder.ts +8 -3
  31. package/ts/formats/cii/facturx/facturx.encoder.ts +32 -16
  32. package/ts/formats/cii/zugferd/zugferd.decoder.ts +8 -3
  33. package/ts/formats/cii/zugferd/zugferd.encoder.ts +71 -46
  34. package/ts/formats/cii/zugferd/zugferd.v1.decoder.ts +22 -4
  35. package/ts/formats/ubl/generic/ubl.encoder.ts +13 -15
  36. package/ts/formats/ubl/ubl.encoder.ts +3 -0
  37. package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +15 -1
  38. package/ts/formats/utils/document.totals.ts +43 -14
  39. package/ts/formats/utils/number.text.ts +25 -0
  40. package/ts/formats/utils/vat.category.ts +89 -0
@@ -84,8 +84,8 @@ export class ZUGFeRDDecoder extends CIIBaseDecoder {
84
84
  // Extract the actual delivery date, if stated
85
85
  const deliveryDate = this.extractDeliveryDate();
86
86
 
87
- // Check for reverse charge
88
- const reverseCharge = this.exists('//ram:SpecifiedTradeAllowanceCharge/ram:ReasonCode[text()="62"]');
87
+ // Reverse charge: every line has the VAT category AE
88
+ const reverseCharge = this.isReverseChargeDocument();
89
89
 
90
90
  // Create the common invoice data
91
91
  const invoiceData = {
@@ -161,7 +161,12 @@ export class ZUGFeRDDecoder extends CIIBaseDecoder {
161
161
  const vatId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]`) || '';
162
162
 
163
163
  // Extract registration ID
164
- const registrationId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) || '';
164
+ // the legal registration identifier (BT-30, BT-47); a document that states only a tax
165
+ // number (scheme FC, BT-32) keeps it here as before, the envelope has no field of its own for it
166
+ const registrationId =
167
+ this.getText(`${partyXPath}/ram:SpecifiedLegalOrganization/ram:ID`) ||
168
+ this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) ||
169
+ '';
165
170
 
166
171
  // Create contact object
167
172
  return {
@@ -7,6 +7,7 @@ import { getDocumentTypeCode } from '../../utils/document.typecode.js';
7
7
  import { getWritableDate, getWritableDueDate } from '../../utils/date.value.js';
8
8
  import { getPaymentTermsNote } from '../../utils/payment.terms.js';
9
9
  import { computeDocumentTotals } from '../../utils/document.totals.js';
10
+ import { toPlainDecimalString } from '../../utils/number.text.js';
10
11
 
11
12
  /**
12
13
  * Encoder for ZUGFeRD invoice format
@@ -34,6 +35,9 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
34
35
  this.addCommonInvoiceData(xmlDoc, creditNote);
35
36
 
36
37
  // Serialize to string
38
+ // the schema order of every element, whatever order the passes above added them in
39
+ this.orderCiiElements(xmlDoc);
40
+
37
41
  return new XMLSerializer().serializeToString(xmlDoc);
38
42
  }
39
43
 
@@ -53,6 +57,9 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
53
57
  this.addCommonInvoiceData(xmlDoc, invoice);
54
58
 
55
59
  // Serialize to string
60
+ // the schema order of every element, whatever order the passes above added them in
61
+ this.orderCiiElements(xmlDoc);
62
+
56
63
  return new XMLSerializer().serializeToString(xmlDoc);
57
64
  }
58
65
 
@@ -219,14 +226,15 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
219
226
  const sellerElement = doc.createElement('ram:SellerTradeParty');
220
227
  this.addPartyInfo(doc, sellerElement, invoice.from);
221
228
 
222
- // Add seller electronic address if available
229
+ // Seller electronic address (BT-34): ram:URIUniversalCommunication/ram:URIID of the party; a
230
+ // trade contact (ram:DefinedTradeContact) has no URIID
223
231
  if (invoice.electronicAddress && invoice.from.type === 'company') {
224
- const contactElement = doc.createElement('ram:DefinedTradeContact');
232
+ const communicationElement = doc.createElement('ram:URIUniversalCommunication');
225
233
  const uriElement = doc.createElement('ram:URIID');
226
234
  uriElement.setAttribute('schemeID', invoice.electronicAddress.scheme);
227
235
  uriElement.textContent = invoice.electronicAddress.value;
228
- contactElement.appendChild(uriElement);
229
- sellerElement.appendChild(contactElement);
236
+ communicationElement.appendChild(uriElement);
237
+ sellerElement.appendChild(communicationElement);
230
238
  }
231
239
 
232
240
  agreementElement.appendChild(sellerElement);
@@ -249,6 +257,17 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
249
257
  nameElement.textContent = party.name;
250
258
  partyElement.appendChild(nameElement);
251
259
 
260
+ // Legal registration identifier (BT-30 seller, BT-47 buyer), e.g. the commercial register
261
+ // number; in the schema order it follows the name. It is no tax registration: the scheme
262
+ // FC of ram:SpecifiedTaxRegistration is the seller's tax number (BT-32)
263
+ if (party.registrationDetails && party.registrationDetails.registrationId) {
264
+ const legalOrganizationElement = doc.createElement('ram:SpecifiedLegalOrganization');
265
+ const legalIdElement = doc.createElement('ram:ID');
266
+ legalIdElement.textContent = party.registrationDetails.registrationId;
267
+ legalOrganizationElement.appendChild(legalIdElement);
268
+ partyElement.appendChild(legalOrganizationElement);
269
+ }
270
+
252
271
  // Add postal address
253
272
  const addressElement = doc.createElement('ram:PostalTradeAddress');
254
273
 
@@ -299,15 +318,6 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
299
318
  partyElement.appendChild(taxRegistrationElement);
300
319
  }
301
320
 
302
- // Add registration ID if available
303
- if (party.registrationDetails && party.registrationDetails.registrationId) {
304
- const regRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration');
305
- const regIdElement = doc.createElement('ram:ID');
306
- regIdElement.setAttribute('schemeID', 'FC');
307
- regIdElement.textContent = party.registrationDetails.registrationId;
308
- regRegistrationElement.appendChild(regIdElement);
309
- partyElement.appendChild(regRegistrationElement);
310
- }
311
321
  }
312
322
 
313
323
  /**
@@ -332,33 +342,6 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
332
342
  deliveryDateElement.appendChild(occurrenceDateElement);
333
343
  deliveryElement.appendChild(deliveryDateElement);
334
344
  }
335
-
336
- // Add period of performance if available
337
- if (invoice.periodOfPerformance) {
338
- const periodElement = doc.createElement('ram:BillingSpecifiedPeriod');
339
-
340
- // Start date
341
- if (invoice.periodOfPerformance.from !== undefined) {
342
- const startDateElement = doc.createElement('ram:StartDateTime');
343
- const startDateStringElement = doc.createElement('udt:DateTimeString');
344
- startDateStringElement.setAttribute('format', '102'); // YYYYMMDD format
345
- startDateStringElement.textContent = this.formatDateYYYYMMDD(invoice.periodOfPerformance.from, 'BT-73 invoicing period start date');
346
- startDateElement.appendChild(startDateStringElement);
347
- periodElement.appendChild(startDateElement);
348
- }
349
-
350
- // End date
351
- if (invoice.periodOfPerformance.to !== undefined) {
352
- const endDateElement = doc.createElement('ram:EndDateTime');
353
- const endDateStringElement = doc.createElement('udt:DateTimeString');
354
- endDateStringElement.setAttribute('format', '102'); // YYYYMMDD format
355
- endDateStringElement.textContent = this.formatDateYYYYMMDD(invoice.periodOfPerformance.to, 'BT-74 invoicing period end date');
356
- endDateElement.appendChild(endDateStringElement);
357
- periodElement.appendChild(endDateElement);
358
- }
359
-
360
- deliveryElement.appendChild(periodElement);
361
- }
362
345
  }
363
346
 
364
347
  /**
@@ -446,6 +429,33 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
446
429
  // Add tax details
447
430
  this.addTaxDetails(doc, settlementElement, invoice);
448
431
 
432
+ // Invoicing period (BG-14): in the header settlement, where the CII schema puts it
433
+ if (invoice.periodOfPerformance) {
434
+ const periodElement = doc.createElement('ram:BillingSpecifiedPeriod');
435
+
436
+ // Start date
437
+ if (invoice.periodOfPerformance.from !== undefined) {
438
+ const startDateElement = doc.createElement('ram:StartDateTime');
439
+ const startDateStringElement = doc.createElement('udt:DateTimeString');
440
+ startDateStringElement.setAttribute('format', '102'); // YYYYMMDD format
441
+ startDateStringElement.textContent = this.formatDateYYYYMMDD(invoice.periodOfPerformance.from, 'BT-73 invoicing period start date');
442
+ startDateElement.appendChild(startDateStringElement);
443
+ periodElement.appendChild(startDateElement);
444
+ }
445
+
446
+ // End date
447
+ if (invoice.periodOfPerformance.to !== undefined) {
448
+ const endDateElement = doc.createElement('ram:EndDateTime');
449
+ const endDateStringElement = doc.createElement('udt:DateTimeString');
450
+ endDateStringElement.setAttribute('format', '102'); // YYYYMMDD format
451
+ endDateStringElement.textContent = this.formatDateYYYYMMDD(invoice.periodOfPerformance.to, 'BT-74 invoicing period end date');
452
+ endDateElement.appendChild(endDateStringElement);
453
+ periodElement.appendChild(endDateElement);
454
+ }
455
+
456
+ settlementElement.appendChild(periodElement);
457
+ }
458
+
449
459
  // Add totals
450
460
  this.addMonetarySummation(doc, settlementElement, invoice);
451
461
  }
@@ -460,8 +470,8 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
460
470
  // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
461
471
  const totals = computeDocumentTotals(invoice);
462
472
 
463
- // VAT breakdown (BG-23), one per rate
464
- for (const { rate, taxableAmount, taxAmount } of totals.vatGroups) {
473
+ // VAT breakdown (BG-23), one per VAT category and rate
474
+ for (const { category, rate, taxableAmount, taxAmount, exemption } of totals.vatGroups) {
465
475
  const taxElement = doc.createElement('ram:ApplicableTradeTax');
466
476
 
467
477
  // VAT category tax amount (BT-117)
@@ -473,6 +483,13 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
473
483
  const typeCodeElement = doc.createElement('ram:TypeCode');
474
484
  typeCodeElement.textContent = 'VAT';
475
485
  taxElement.appendChild(typeCodeElement);
486
+
487
+ // VAT exemption reason text (BT-120), for reverse charge (BR-AE-10)
488
+ if (exemption) {
489
+ const exemptionReasonElement = doc.createElement('ram:ExemptionReason');
490
+ exemptionReasonElement.textContent = exemption.reason;
491
+ taxElement.appendChild(exemptionReasonElement);
492
+ }
476
493
 
477
494
  // VAT category taxable amount (BT-116)
478
495
  const basisAmountElement = doc.createElement('ram:BasisAmount');
@@ -481,8 +498,15 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
481
498
 
482
499
  // Add category code
483
500
  const categoryCodeElement = doc.createElement('ram:CategoryCode');
484
- categoryCodeElement.textContent = invoice.reverseCharge ? 'AE' : 'S';
501
+ categoryCodeElement.textContent = category;
485
502
  taxElement.appendChild(categoryCodeElement);
503
+
504
+ // VAT exemption reason code (BT-121)
505
+ if (exemption) {
506
+ const exemptionReasonCodeElement = doc.createElement('ram:ExemptionReasonCode');
507
+ exemptionReasonCodeElement.textContent = exemption.code;
508
+ taxElement.appendChild(exemptionReasonCodeElement);
509
+ }
486
510
 
487
511
  // Add rate
488
512
  const rateElement = doc.createElement('ram:RateApplicablePercent');
@@ -565,7 +589,8 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
565
589
  const agreementElement = doc.createElement('ram:SpecifiedLineTradeAgreement');
566
590
  const priceElement = doc.createElement('ram:NetPriceProductTradePrice');
567
591
  const chargeAmountElement = doc.createElement('ram:ChargeAmount');
568
- chargeAmountElement.textContent = item.unitNetPrice.toFixed(2);
592
+ // Item net price (BT-146) with the precision it has; EN 16931 does not limit its decimals
593
+ chargeAmountElement.textContent = toPlainDecimalString(item.unitNetPrice);
569
594
  priceElement.appendChild(chargeAmountElement);
570
595
  agreementElement.appendChild(priceElement);
571
596
  lineItemElement.appendChild(agreementElement);
@@ -573,7 +598,7 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
573
598
  // Add delivery information (quantity)
574
599
  const deliveryElement = doc.createElement('ram:SpecifiedLineTradeDelivery');
575
600
  const quantityElement = doc.createElement('ram:BilledQuantity');
576
- quantityElement.textContent = item.unitQuantity.toString();
601
+ quantityElement.textContent = toPlainDecimalString(item.unitQuantity); // BT-129
577
602
  // a line without a unit is written without one, so that BR-23 reports it
578
603
  if (item.unitType) {
579
604
  quantityElement.setAttribute('unitCode', item.unitType);
@@ -594,7 +619,7 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
594
619
 
595
620
  // Add tax category code
596
621
  const taxCategoryCodeElement = doc.createElement('ram:CategoryCode');
597
- taxCategoryCodeElement.textContent = invoice.reverseCharge ? 'AE' : 'S';
622
+ taxCategoryCodeElement.textContent = totals.lineVatCategories[index]; // BT-151
598
623
  taxElement.appendChild(taxCategoryCodeElement);
599
624
 
600
625
  // Add tax rate
@@ -35,6 +35,19 @@ export class ZUGFeRDV1Decoder extends CIIBaseDecoder {
35
35
  };
36
36
  }
37
37
 
38
+ /**
39
+ * Whether every line of the v1 document has the VAT category reverse charge (AE), read with
40
+ * the v1 namespaces from the line settlement (`ram:SpecifiedSupplyChainTradeSettlement`)
41
+ */
42
+ protected override isReverseChargeDocument(): boolean {
43
+ const categories = zugferdV1Select(
44
+ '/rsm:CrossIndustryDocument/rsm:SpecifiedSupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedSupplyChainTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode',
45
+ this.doc,
46
+ );
47
+ const codes = (Array.isArray(categories) ? categories : []).map((node) => ((node as Node).textContent ?? '').trim());
48
+ return codes.length > 0 && codes.every((code) => code === 'AE');
49
+ }
50
+
38
51
  /**
39
52
  * Reads the issue date of the v1 header (`rsm:HeaderExchangedDocument`); a
40
53
  * missing or invalid one is refused with an `EInvoiceParsingError`.
@@ -147,8 +160,8 @@ export class ZUGFeRDV1Decoder extends CIIBaseDecoder {
147
160
  // Extract the actual delivery date, if stated
148
161
  const deliveryDate = this.extractDeliveryDate();
149
162
 
150
- // Check for reverse charge
151
- const reverseCharge = this.exists('//ram:SpecifiedTradeAllowanceCharge/ram:ReasonCode[text()="62"]');
163
+ // Reverse charge: every line has the VAT category AE
164
+ const reverseCharge = this.isReverseChargeDocument();
152
165
 
153
166
  // Create the common invoice data
154
167
  const invoiceData = {
@@ -217,8 +230,13 @@ export class ZUGFeRDV1Decoder extends CIIBaseDecoder {
217
230
  // Extract VAT ID
218
231
  const vatId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]`) || '';
219
232
 
220
- // Extract registration ID
221
- const registrationId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) || '';
233
+ // the legal registration identifier, and a tax number (scheme FC) as before when that is all
234
+ // the document states; the party is still read with the ZUGFeRD 2 namespaces (the known v1
235
+ // header defect), so for a v1 document this finds nothing yet
236
+ const registrationId =
237
+ this.getText(`${partyXPath}/ram:SpecifiedLegalOrganization/ram:ID`) ||
238
+ this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) ||
239
+ '';
222
240
 
223
241
  // Create contact object
224
242
  return {
@@ -7,6 +7,7 @@ import { getPrecedingInvoiceReferences } from '../../utils/preceding.invoice.js'
7
7
  import { getWritableDueDate } from '../../utils/date.value.js';
8
8
  import { getPaymentTermsNote } from '../../utils/payment.terms.js';
9
9
  import { computeDocumentTotals } from '../../utils/document.totals.js';
10
+ import { toPlainDecimalString } from '../../utils/number.text.js';
10
11
 
11
12
  /**
12
13
  * UBL Encoder implementation
@@ -395,7 +396,7 @@ export class UBLEncoder extends UBLBaseEncoder {
395
396
  taxTotalNode.appendChild(taxAmountElement);
396
397
 
397
398
  // VAT breakdown (BG-23), one subtotal per rate
398
- for (const { rate, taxableAmount, taxAmount } of totals.vatGroups) {
399
+ for (const { category, rate, taxableAmount, taxAmount, exemption } of totals.vatGroups) {
399
400
  const taxSubtotalNode = doc.createElement('cac:TaxSubtotal');
400
401
  taxTotalNode.appendChild(taxSubtotalNode);
401
402
 
@@ -415,17 +416,14 @@ export class UBLEncoder extends UBLBaseEncoder {
415
416
  const taxCategoryNode = doc.createElement('cac:TaxCategory');
416
417
  taxSubtotalNode.appendChild(taxCategoryNode);
417
418
 
418
- // Determine tax category ID based on reverse charge
419
- const categoryId = invoice.reverseCharge ? 'AE' : 'S';
420
- this.appendElement(doc, taxCategoryNode, 'cbc:ID', categoryId);
421
-
422
- // Add percent with 2 decimal places
419
+ // VAT category code (BT-118) and rate (BT-119)
420
+ this.appendElement(doc, taxCategoryNode, 'cbc:ID', category);
423
421
  this.appendElement(doc, taxCategoryNode, 'cbc:Percent', rate.toFixed(2));
424
422
 
425
- // Add tax exemption reason if reverse charge
426
- if (invoice.reverseCharge) {
427
- this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', 'VATEX-EU-IC');
428
- this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', 'Reverse charge');
423
+ // VAT exemption reason code (BT-121) and text (BT-120), for reverse charge (BR-AE-10)
424
+ if (exemption) {
425
+ this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', exemption.code);
426
+ this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', exemption.reason);
429
427
  }
430
428
 
431
429
  // Add tax scheme
@@ -498,7 +496,7 @@ export class UBLEncoder extends UBLBaseEncoder {
498
496
  if (item.unitType) {
499
497
  quantityElement.setAttribute('unitCode', item.unitType);
500
498
  }
501
- quantityElement.textContent = item.unitQuantity.toString();
499
+ quantityElement.textContent = toPlainDecimalString(item.unitQuantity); // BT-129
502
500
  invoiceLineNode.appendChild(quantityElement);
503
501
 
504
502
  // Invoice line net amount (BT-131): quantity × net price, rounded
@@ -527,9 +525,8 @@ export class UBLEncoder extends UBLBaseEncoder {
527
525
  const classifiedTaxCategoryNode = doc.createElement('cac:ClassifiedTaxCategory');
528
526
  itemNode.appendChild(classifiedTaxCategoryNode);
529
527
 
530
- // Determine tax category ID based on reverse charge
531
- const categoryId = invoice.reverseCharge ? 'AE' : 'S';
532
- this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', categoryId);
528
+ // Invoiced item VAT category code (BT-151)
529
+ this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', totals.lineVatCategories[index]);
533
530
 
534
531
  // Tax percent with 2 decimal places
535
532
  this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toFixed(2));
@@ -546,7 +543,8 @@ export class UBLEncoder extends UBLBaseEncoder {
546
543
  // Price amount
547
544
  const priceAmountElement = doc.createElement('cbc:PriceAmount');
548
545
  priceAmountElement.setAttribute('currencyID', invoice.currency);
549
- priceAmountElement.textContent = item.unitNetPrice.toFixed(2);
546
+ // Item net price (BT-146) with the precision it has; EN 16931 does not limit its decimals
547
+ priceAmountElement.textContent = toPlainDecimalString(item.unitNetPrice);
550
548
  priceNode.appendChild(priceAmountElement);
551
549
  }
552
550
  }
@@ -2,6 +2,7 @@ import { BaseEncoder } from '../base/base.encoder.js';
2
2
  import type { TAccountingDoc, TCreditNote, TInvoiceDocument } from '../../interfaces/common.js';
3
3
  import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
4
4
  import { getWritableDate } from '../utils/date.value.js';
5
+ import { assertVatCategoryWritable } from '../utils/vat.category.js';
5
6
 
6
7
  /**
7
8
  * The children of the document root in the order the UBL 2.1 schema requires
@@ -48,6 +49,8 @@ export abstract class UBLBaseEncoder extends BaseEncoder {
48
49
  * @returns UBL XML string
49
50
  */
50
51
  public async encode(invoice: TAccountingDoc): Promise<string> {
52
+ // a reverse charge document that lacks what EN 16931 requires of one is refused (BR-AE-02, BR-AE-05)
53
+ assertVatCategoryWritable(invoice, 'ubl');
51
54
  // a credit note is a CreditNote document; an invoice, a debit note and a self-billed invoice are Invoice documents that differ in their type code
52
55
  if (invoice.accountingDocType === 'creditnote') {
53
56
  return this.encodeCreditNote(invoice);
@@ -261,7 +261,9 @@ export class XRechnungDecoder extends UBLBaseDecoder {
261
261
  subject: subject,
262
262
  items: items,
263
263
  dueInDays: dueInDays,
264
- reverseCharge: false,
264
+ // reverse charge when every line is VAT category AE; the envelope states it for the
265
+ // whole document, so a document mixing AE lines with others cannot say it
266
+ reverseCharge: this.isReverseChargeDocument(),
265
267
  currency: currencyCode as finance.TCurrency,
266
268
  notes: notes,
267
269
  objectActions: [],
@@ -301,6 +303,18 @@ export class XRechnungDecoder extends UBLBaseDecoder {
301
303
  }
302
304
  }
303
305
 
306
+ /**
307
+ * Whether every line of the document has the VAT category reverse charge (AE, BT-151)
308
+ */
309
+ private isReverseChargeDocument(): boolean {
310
+ const categories = this.select(
311
+ '/*/cac:InvoiceLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID | /*/cac:CreditNoteLine/cac:Item/cac:ClassifiedTaxCategory/cbc:ID',
312
+ this.doc,
313
+ );
314
+ const codes = (Array.isArray(categories) ? categories : []).map((node) => (node.textContent ?? '').trim());
315
+ return codes.length > 0 && codes.every((code) => code === 'AE');
316
+ }
317
+
304
318
  /**
305
319
  * Reads the preceding invoice references (BG-3): the number (BT-25) and,
306
320
  * when stated, the issue date (BT-26) of each.
@@ -1,8 +1,10 @@
1
1
  import type { TAccountingDoc } from '../../interfaces/common.js';
2
2
  import { Decimal } from './decimal.js';
3
3
  import { DecimalCurrencyCalculator } from './currency.calculator.decimal.js';
4
+ import { toPlainDecimalString } from './number.text.js';
4
5
  import { EInvoiceFormatError } from '../../errors.js';
5
6
  import type { ValidationResult } from '../validation/validation.types.js';
7
+ import { getVatCategory, getVatExemption, type IVatExemption, type TVatCategoryCode } from './vat.category.js';
6
8
 
7
9
  /** An item amount that is no finite number, so no total can be computed from it */
8
10
  export interface IInvalidItemAmount {
@@ -61,13 +63,17 @@ export const getDocumentCalculator = (currency: TAccountingDoc['currency']): Dec
61
63
  new DecimalCurrencyCalculator(currency, 'HALF_UP', { maxDecimals: EN16931_MAX_AMOUNT_DECIMALS });
62
64
 
63
65
  /**
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
+ * The VAT breakdown of one VAT category and rate (BG-23): the category
67
+ * (BT-118), the rate (BT-119), the taxable amount (BT-116), the tax on it
68
+ * (BT-117) and, for a category that states one, the exemption reason
69
+ * (BT-121, BT-120).
66
70
  */
67
71
  export interface IDocumentVatGroup {
72
+ category: TVatCategoryCode;
68
73
  rate: number;
69
74
  taxableAmount: Decimal;
70
75
  taxAmount: Decimal;
76
+ exemption?: IVatExemption;
71
77
  }
72
78
 
73
79
  /**
@@ -80,11 +86,13 @@ export interface IDocumentTotals {
80
86
  minorUnits: number;
81
87
  /** the Invoice line net amount (BT-131) of each item, in item order: quantity × net price, rounded */
82
88
  lineNetAmounts: Decimal[];
89
+ /** the VAT category (BT-151) of each item, in item order */
90
+ lineVatCategories: TVatCategoryCode[];
83
91
  /** Sum of Invoice line net amount (BT-106) */
84
92
  lineTotal: Decimal;
85
93
  /** Invoice total amount without VAT (BT-109): BT-106, as the envelope has no document level allowances or charges */
86
94
  taxBasisTotal: Decimal;
87
- /** VAT breakdown (BG-23), in the order the rates first appear */
95
+ /** VAT breakdown (BG-23), one per VAT category and rate, in the order they first appear */
88
96
  vatGroups: IDocumentVatGroup[];
89
97
  /** Invoice total VAT amount (BT-110): the sum of the rounded VAT category tax amounts */
90
98
  taxTotal: Decimal;
@@ -100,16 +108,20 @@ export interface IDocumentTotals {
100
108
  * decimals (BR-DEC-*): each line net amount (BT-131) is quantity × net price
101
109
  * rounded; the sum of line net amounts (BT-106) is their sum (BR-CO-10);
102
110
  * 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
111
+ * of that category and rate, and its tax amount (BT-117) is BT-116 × rate / 100
112
+ * rounded (BR-CO-17), 0 for reverse charge (BR-AE-09); the total VAT (BT-110) is the sum of the category tax amounts
105
113
  * (BR-CO-14); the total with VAT (BT-112) is BT-109 + BT-110 (BR-CO-15).
106
114
  * @param accountingDoc The document
107
115
  */
108
- export const computeDocumentTotals = (accountingDoc: Pick<TAccountingDoc, 'currency' | 'items'>): IDocumentTotals => {
116
+ export const computeDocumentTotals = (
117
+ accountingDoc: Pick<TAccountingDoc, 'currency' | 'items'> & { reverseCharge?: boolean; language?: string },
118
+ ): IDocumentTotals => {
109
119
  const calculator = getDocumentCalculator(accountingDoc.currency);
110
120
  const minorUnits = calculator.getCurrencyInfo().minorUnits;
121
+ const category = getVatCategory(accountingDoc);
111
122
  const lineNetAmounts: Decimal[] = [];
112
- const taxableByRate = new Map<number, Decimal>();
123
+ const lineVatCategories: TVatCategoryCode[] = [];
124
+ const taxableByGroup = new Map<string, { category: TVatCategoryCode; rate: number; taxable: Decimal }>();
113
125
  // an amount that is no number cannot be computed with; it is refused, not treated as 0
114
126
  const [firstInvalid] = findInvalidItemAmounts(accountingDoc.items);
115
127
  if (firstInvalid) {
@@ -119,21 +131,38 @@ export const computeDocumentTotals = (accountingDoc: Pick<TAccountingDoc, 'curre
119
131
  );
120
132
  }
121
133
  for (const item of accountingDoc.items ?? []) {
122
- const lineNet = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
134
+ // quantity × net price from the numerals the encoders write, so the line net amount is the
135
+ // product of the written values, at their full precision
136
+ const lineNet = calculator.calculateLineNet(
137
+ toPlainDecimalString(item.unitQuantity),
138
+ toPlainDecimalString(item.unitNetPrice),
139
+ );
123
140
  lineNetAmounts.push(lineNet);
124
- taxableByRate.set(item.vatPercentage, (taxableByRate.get(item.vatPercentage) ?? Decimal.ZERO).add(lineNet));
141
+ lineVatCategories.push(category);
142
+ // grouped by VAT category and rate: the same rate under two categories is two groups
143
+ const key = `${category}|${item.vatPercentage}`;
144
+ const group = taxableByGroup.get(key) ?? { category, rate: item.vatPercentage, taxable: Decimal.ZERO };
145
+ group.taxable = group.taxable.add(lineNet);
146
+ taxableByGroup.set(key, group);
125
147
  }
126
- const vatGroups: IDocumentVatGroup[] = [...taxableByRate.entries()].map(([rate, taxableAmount]) => ({
127
- rate,
128
- taxableAmount: calculator.round(taxableAmount),
129
- taxAmount: calculator.calculateVAT(taxableAmount, rate),
130
- }));
148
+ const vatGroups: IDocumentVatGroup[] = [...taxableByGroup.values()].map((group) => {
149
+ const exemption = getVatExemption(group.category, accountingDoc.language);
150
+ return {
151
+ category: group.category,
152
+ rate: group.rate,
153
+ taxableAmount: calculator.round(group.taxable),
154
+ // reverse charge states no tax: the recipient owes it (BR-AE-09)
155
+ taxAmount: group.category === 'AE' ? calculator.round(Decimal.ZERO) : calculator.calculateVAT(group.taxable, group.rate),
156
+ ...(exemption ? { exemption } : {}),
157
+ };
158
+ });
131
159
  const lineTotal = calculator.round(Decimal.sum(lineNetAmounts));
132
160
  const taxTotal = calculator.round(Decimal.sum(vatGroups.map((group) => group.taxAmount)));
133
161
  const grandTotal = calculator.round(lineTotal.add(taxTotal));
134
162
  return {
135
163
  minorUnits,
136
164
  lineNetAmounts,
165
+ lineVatCategories,
137
166
  lineTotal,
138
167
  taxBasisTotal: lineTotal,
139
168
  vatGroups,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A finite number as a plain decimal numeral (`xsd:decimal`), the shortest one
3
+ * that reads back as the same number, without an exponent: `0.335`, `1234.5`,
4
+ * `0.0000001` (not `1e-7`). Quantities (BT-129) and net prices (BT-146) are
5
+ * written this way, with the precision they have; EN 16931 limits neither to
6
+ * two decimals (BR-DEC-* cover amounts only).
7
+ * @param value A finite number
8
+ */
9
+ export const toPlainDecimalString = (value: number): string => {
10
+ const text = String(value);
11
+ const match = /^(-?)(\d+)(?:\.(\d+))?e([+-]\d+)$/i.exec(text);
12
+ if (!match) {
13
+ return text;
14
+ }
15
+ const [, sign, integerDigits, fractionDigits = '', exponentText] = match;
16
+ const digits = integerDigits + fractionDigits;
17
+ const point = integerDigits.length + Number(exponentText);
18
+ if (point <= 0) {
19
+ return `${sign}0.${'0'.repeat(-point)}${digits}`;
20
+ }
21
+ if (point >= digits.length) {
22
+ return `${sign}${digits}${'0'.repeat(point - digits.length)}`;
23
+ }
24
+ return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`;
25
+ };
@@ -0,0 +1,89 @@
1
+ import type { TAccountingDoc } from '../../interfaces/common.js';
2
+ import { EInvoiceFormatError } from '../../errors.js';
3
+
4
+ /**
5
+ * The VAT category codes (UNTDID 5305, EN 16931 BT-118/BT-151) the encoders
6
+ * write: standard rated, or reverse charge. The envelope states reverse charge
7
+ * for the whole document (`reverseCharge`); an item carries no category of its
8
+ * own, so a document cannot mix reverse charge lines with standard rated ones.
9
+ */
10
+ export type TVatCategoryCode = 'S' | 'AE';
11
+
12
+ /** The VAT exemption reason (BT-121 code, BT-120 text) a VAT category states */
13
+ export interface IVatExemption {
14
+ code: string;
15
+ reason: string;
16
+ }
17
+
18
+ /**
19
+ * The VAT category of every line of a document: reverse charge (`AE`) when the
20
+ * document states it, standard rated (`S`) otherwise.
21
+ * @param accountingDoc The document
22
+ */
23
+ export const getVatCategory = (accountingDoc: { reverseCharge?: boolean }): TVatCategoryCode =>
24
+ accountingDoc.reverseCharge ? 'AE' : 'S';
25
+
26
+ /**
27
+ * The exemption reason a reverse charge VAT breakdown states (BR-AE-10): the
28
+ * code `VATEX-EU-AE` and the wording the law prescribes, "Steuerschuldnerschaft
29
+ * des Leistungsempfängers" (§ 14a Abs. 1 and 5 UStG). A document in another
30
+ * language may use the wording of Article 226 Nr. 11a of the VAT Directive in
31
+ * that language, "Reverse charge" in English (Abschnitt 14a.1 Abs. 6 Satz 2
32
+ * UStAE). Other categories state none.
33
+ * @param category The VAT category
34
+ * @param language The document language
35
+ */
36
+ export const getVatExemption = (category: TVatCategoryCode, language: string | undefined): IVatExemption | undefined =>
37
+ category === 'AE'
38
+ ? {
39
+ code: 'VATEX-EU-AE',
40
+ reason: (language ?? '').toLowerCase().startsWith('de')
41
+ ? 'Steuerschuldnerschaft des Leistungsempfängers'
42
+ : 'Reverse charge',
43
+ }
44
+ : undefined;
45
+
46
+ /**
47
+ * Refuses a reverse charge document that lacks what EN 16931 requires of one,
48
+ * naming the rule; no value is put in place of a missing one.
49
+ * - BR-AE-05: every line of a reverse charge document has the VAT rate 0; the
50
+ * recipient owes the tax, and the invoice states none (§ 14a Abs. 5 Satz 2
51
+ * UStG; a stated amount would be owed under § 14c Abs. 1 UStG).
52
+ * - BR-AE-02: the seller states a VAT identifier (BT-31) and the buyer a VAT
53
+ * identifier (BT-48) or a legal registration identifier (BT-47). The rule
54
+ * also accepts the seller's tax registration identifier (BT-32, e.g. the
55
+ * Steuernummer) or a tax representative's VAT identifier (BT-63) instead of
56
+ * BT-31; the envelope can state neither, so a seller without a VAT
57
+ * identifier is refused. A domestic § 13b issuer that states only a
58
+ * Steuernummer, which § 14 Abs. 4 Satz 1 Nr. 2 UStG allows, is therefore
59
+ * refused until the envelope carries a tax number.
60
+ * @param accountingDoc The document
61
+ * @param targetFormat The format being written
62
+ */
63
+ export const assertVatCategoryWritable = (accountingDoc: TAccountingDoc, targetFormat: string): void => {
64
+ if (!accountingDoc.reverseCharge) {
65
+ return;
66
+ }
67
+ const refuse = (rule: string, message: string): never => {
68
+ throw new EInvoiceFormatError(`${rule}: ${message}`, { targetFormat, unsupportedFeatures: [rule] });
69
+ };
70
+ for (const [index, item] of (accountingDoc.items ?? []).entries()) {
71
+ if (item.vatPercentage !== 0) {
72
+ refuse('BR-AE-05', `a reverse charge line has the VAT rate 0, items[${index}].vatPercentage is ${String(item.vatPercentage)}`);
73
+ }
74
+ }
75
+ const seller = accountingDoc.from?.registrationDetails;
76
+ if (!seller?.vatId) {
77
+ refuse(
78
+ 'BR-AE-02',
79
+ 'a reverse charge document states the seller VAT identifier (BT-31), from.registrationDetails.vatId is missing; the rule would also accept a tax registration identifier (BT-32) or a tax representative (BT-63), which the envelope cannot state',
80
+ );
81
+ }
82
+ const buyer = accountingDoc.to?.registrationDetails;
83
+ if (!buyer?.vatId && !buyer?.registrationId) {
84
+ refuse(
85
+ 'BR-AE-02',
86
+ 'a reverse charge document states the buyer VAT identifier (BT-48) or legal registration identifier (BT-47), to.registrationDetails.vatId and .registrationId are missing',
87
+ );
88
+ }
89
+ };