@fin.cx/einvoice 8.2.1 → 8.2.3

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 (48) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/einvoice.js +12 -23
  3. package/dist_ts/formats/cii/cii.decoder.d.ts +6 -0
  4. package/dist_ts/formats/cii/cii.decoder.js +11 -1
  5. package/dist_ts/formats/cii/cii.encoder.js +4 -1
  6. package/dist_ts/formats/cii/facturx/facturx.decoder.js +8 -4
  7. package/dist_ts/formats/cii/facturx/facturx.encoder.js +57 -57
  8. package/dist_ts/formats/cii/zugferd/zugferd.decoder.js +8 -4
  9. package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +55 -67
  10. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.d.ts +5 -0
  11. package/dist_ts/formats/cii/zugferd/zugferd.v1.decoder.js +18 -5
  12. package/dist_ts/formats/semantic/semantic.validator.js +20 -1
  13. package/dist_ts/formats/ubl/generic/ubl.encoder.js +31 -57
  14. package/dist_ts/formats/ubl/ubl.encoder.js +4 -1
  15. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.d.ts +4 -0
  16. package/dist_ts/formats/ubl/xrechnung/xrechnung.decoder.js +12 -2
  17. package/dist_ts/formats/utils/currency.calculator.decimal.d.ts +9 -1
  18. package/dist_ts/formats/utils/currency.calculator.decimal.js +10 -3
  19. package/dist_ts/formats/utils/document.totals.d.ts +89 -0
  20. package/dist_ts/formats/utils/document.totals.js +108 -0
  21. package/dist_ts/formats/utils/vat.category.d.ts +50 -0
  22. package/dist_ts/formats/utils/vat.category.js +64 -0
  23. package/dist_ts/formats/validation/codelist.validator.js +3 -11
  24. package/dist_ts/formats/validation/en16931.business-rules.validator.js +38 -44
  25. package/dist_ts/formats/validation/facturx.validator.js +19 -5
  26. package/dist_ts/formats/validation/vat-categories.validator.js +9 -1
  27. package/package.json +2 -2
  28. package/readme.md +25 -0
  29. package/ts/00_commitinfo_data.ts +1 -1
  30. package/ts/einvoice.ts +11 -25
  31. package/ts/formats/cii/cii.decoder.ts +14 -0
  32. package/ts/formats/cii/cii.encoder.ts +3 -0
  33. package/ts/formats/cii/facturx/facturx.decoder.ts +8 -3
  34. package/ts/formats/cii/facturx/facturx.encoder.ts +59 -65
  35. package/ts/formats/cii/zugferd/zugferd.decoder.ts +8 -3
  36. package/ts/formats/cii/zugferd/zugferd.encoder.ts +58 -77
  37. package/ts/formats/cii/zugferd/zugferd.v1.decoder.ts +22 -4
  38. package/ts/formats/semantic/semantic.validator.ts +20 -0
  39. package/ts/formats/ubl/generic/ubl.encoder.ts +36 -69
  40. package/ts/formats/ubl/ubl.encoder.ts +3 -0
  41. package/ts/formats/ubl/xrechnung/xrechnung.decoder.ts +15 -1
  42. package/ts/formats/utils/currency.calculator.decimal.ts +10 -2
  43. package/ts/formats/utils/document.totals.ts +167 -0
  44. package/ts/formats/utils/vat.category.ts +89 -0
  45. package/ts/formats/validation/codelist.validator.ts +3 -12
  46. package/ts/formats/validation/en16931.business-rules.validator.ts +44 -51
  47. package/ts/formats/validation/facturx.validator.ts +19 -4
  48. package/ts/formats/validation/vat-categories.validator.ts +9 -0
package/ts/einvoice.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  // Import enhanced validators
47
47
  import { EN16931Validator } from './formats/validation/en16931.validator.js';
48
48
  import { EN16931BusinessRulesValidator } from './formats/validation/en16931.business-rules.validator.js';
49
+ import { computeDocumentTotals } from './formats/utils/document.totals.js';
49
50
  import { CodeListValidator } from './formats/validation/codelist.validator.js';
50
51
  import type { ValidationOptions } from './formats/validation/validation.types.js';
51
52
 
@@ -211,7 +212,7 @@ export class EInvoice implements TAccountingDocEnvelope<TAccountingDocType, {}>
211
212
  }
212
213
 
213
214
  public get totalGross(): number {
214
- return this.totalNet + this.totalVat;
215
+ return computeDocumentTotals(this).grandTotal.toNumber();
215
216
  }
216
217
 
217
218
  public get taxBreakdown(): Array<{ taxPercent: number; netAmount: number; taxAmount: number }> {
@@ -795,42 +796,27 @@ export class EInvoice implements TAccountingDocEnvelope<TAccountingDocType, {}>
795
796
  * Calculates the total net amount
796
797
  */
797
798
  private calculateTotalNet(): number {
798
- return this.items.reduce((sum, item) => {
799
- return sum + (item.unitQuantity * item.unitNetPrice);
800
- }, 0);
799
+ // the sum of line net amounts (BT-106) the encoders write
800
+ return computeDocumentTotals(this).lineTotal.toNumber();
801
801
  }
802
802
 
803
803
  /**
804
804
  * Calculates the total VAT amount
805
805
  */
806
806
  private calculateTotalVat(): number {
807
- return this.items.reduce((sum, item) => {
808
- const net = item.unitQuantity * item.unitNetPrice;
809
- return sum + (net * item.vatPercentage / 100);
810
- }, 0);
807
+ // the total VAT (BT-110) the encoders write
808
+ return computeDocumentTotals(this).taxTotal.toNumber();
811
809
  }
812
810
 
813
811
  /**
814
812
  * Calculates tax breakdown by rate
815
813
  */
816
814
  private calculateTaxBreakdown(): Array<{ taxPercent: number; netAmount: number; taxAmount: number }> {
817
- const breakdown = new Map<number, { net: number; tax: number }>();
818
-
819
- this.items.forEach(item => {
820
- const net = item.unitQuantity * item.unitNetPrice;
821
- const tax = net * item.vatPercentage / 100;
822
-
823
- const current = breakdown.get(item.vatPercentage) || { net: 0, tax: 0 };
824
- breakdown.set(item.vatPercentage, {
825
- net: current.net + net,
826
- tax: current.tax + tax
827
- });
828
- });
829
-
830
- return Array.from(breakdown.entries()).map(([rate, amounts]) => ({
831
- taxPercent: rate,
832
- netAmount: amounts.net,
833
- taxAmount: amounts.tax
815
+ // the VAT breakdown (BG-23) the encoders write
816
+ return computeDocumentTotals(this).vatGroups.map((group) => ({
817
+ taxPercent: group.rate,
818
+ netAmount: group.taxableAmount.toNumber(),
819
+ taxAmount: group.taxAmount.toNumber(),
834
820
  }));
835
821
  }
836
822
 
@@ -158,6 +158,20 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
158
158
  return dueDate === undefined ? absentDueInDays : Math.round((dueDate - issueDate) / (1000 * 60 * 60 * 24));
159
159
  }
160
160
 
161
+ /**
162
+ * Whether every line of the document has the VAT category reverse charge (AE, BT-151); the
163
+ * envelope states it for the whole document, so a document mixing AE lines with others
164
+ * cannot say it
165
+ */
166
+ protected isReverseChargeDocument(): boolean {
167
+ const categories = this.select(
168
+ '/rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode',
169
+ this.doc,
170
+ );
171
+ const codes = (Array.isArray(categories) ? categories : []).map((node) => ((node as Node).textContent ?? '').trim());
172
+ return codes.length > 0 && codes.every((code) => code === 'AE');
173
+ }
174
+
161
175
  /**
162
176
  * Reads the actual delivery date (BT-72) of the header delivery; undefined
163
177
  * when the document does not state one.
@@ -3,6 +3,7 @@ import type { TAccountingDoc, TCreditNote, TInvoiceDocument } from '../../interf
3
3
  import { EInvoiceFormatError } from '../../errors.js';
4
4
  import { getPrecedingInvoiceReferences } from '../utils/preceding.invoice.js';
5
5
  import { getWritableDate } from '../utils/date.value.js';
6
+ import { assertVatCategoryWritable } from '../utils/vat.category.js';
6
7
  import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
7
8
 
8
9
  /**
@@ -25,6 +26,8 @@ export abstract class CIIBaseEncoder extends BaseEncoder {
25
26
  * @returns CII XML string
26
27
  */
27
28
  public async encode(invoice: TAccountingDoc): Promise<string> {
29
+ // a reverse charge document that lacks what EN 16931 requires of one is refused (BR-AE-02, BR-AE-05)
30
+ assertVatCategoryWritable(invoice, 'cii');
28
31
  // MINIMUM has no preceding invoice reference (BG-3): a reference the document states cannot be written there
29
32
  if (this.profile === CIIProfile.MINIMUM && getPrecedingInvoiceReferences(invoice).length > 0) {
30
33
  throw new EInvoiceFormatError(
@@ -94,8 +94,8 @@ export class FacturXDecoder extends CIIBaseDecoder {
94
94
  // Extract the actual delivery date, if stated
95
95
  const deliveryDate = this.extractDeliveryDate();
96
96
 
97
- // Check for reverse charge
98
- const reverseCharge = this.exists('//ram:SpecifiedTradeAllowanceCharge/ram:ReasonCode[text()="62"]');
97
+ // Reverse charge: every line has the VAT category AE
98
+ const reverseCharge = this.isReverseChargeDocument();
99
99
 
100
100
  // Create the common invoice data
101
101
  const invoiceData = {
@@ -158,7 +158,12 @@ export class FacturXDecoder extends CIIBaseDecoder {
158
158
  const vatId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="VA"]`) || '';
159
159
 
160
160
  // Extract registration ID
161
- const registrationId = this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) || '';
161
+ // the legal registration identifier (BT-30, BT-47); a document that states only a tax
162
+ // number (scheme FC, BT-32) keeps it here as before, the envelope has no field of its own for it
163
+ const registrationId =
164
+ this.getText(`${partyXPath}/ram:SpecifiedLegalOrganization/ram:ID`) ||
165
+ this.getText(`${partyXPath}/ram:SpecifiedTaxRegistration/ram:ID[@schemeID="FC"]`) ||
166
+ '';
162
167
 
163
168
  // Create contact object
164
169
  return {
@@ -2,10 +2,8 @@ import { CIIBaseEncoder } from '../cii.encoder.js';
2
2
  import type { TAccountingDoc, TCreditNote, TInvoiceDocument } from '../../../interfaces/common.js';
3
3
  import { FACTURX_PROFILE_IDS } from './facturx.types.js';
4
4
  import { DOMParser, XMLSerializer } from '../../../plugins.js';
5
- import { Decimal } from '../../utils/decimal.js';
6
- import { DecimalCurrencyCalculator } from '../../utils/currency.calculator.decimal.js';
7
- import { getCurrencyMinorUnits } from '../../utils/currency.utils.js';
8
5
  import { getDocumentTypeCode } from '../../utils/document.typecode.js';
6
+ import { computeDocumentTotals } from '../../utils/document.totals.js';
9
7
  import { getWritableDate, getWritableDueDate } from '../../utils/date.value.js';
10
8
 
11
9
  /**
@@ -225,6 +223,17 @@ export class FacturXEncoder extends CIIBaseEncoder {
225
223
  nameElement.textContent = party.name;
226
224
  partyElement.appendChild(nameElement);
227
225
 
226
+ // Legal registration identifier (BT-30 seller, BT-47 buyer), e.g. the commercial register
227
+ // number; in the schema order it follows the name. It is no tax registration: the scheme
228
+ // FC of ram:SpecifiedTaxRegistration is the seller's tax number (BT-32)
229
+ if (party.registrationDetails && party.registrationDetails.registrationId) {
230
+ const legalOrganizationElement = doc.createElement('ram:SpecifiedLegalOrganization');
231
+ const legalIdElement = doc.createElement('ram:ID');
232
+ legalIdElement.textContent = party.registrationDetails.registrationId;
233
+ legalOrganizationElement.appendChild(legalIdElement);
234
+ partyElement.appendChild(legalOrganizationElement);
235
+ }
236
+
228
237
  // Add postal address
229
238
  const addressElement = doc.createElement('ram:PostalTradeAddress');
230
239
 
@@ -265,15 +274,6 @@ export class FacturXEncoder extends CIIBaseEncoder {
265
274
  partyElement.appendChild(taxRegistrationElement);
266
275
  }
267
276
 
268
- // Add registration ID if available
269
- if (party.registrationDetails && party.registrationDetails.registrationId) {
270
- const regRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration');
271
- const regIdElement = doc.createElement('ram:ID');
272
- regIdElement.setAttribute('schemeID', 'FC');
273
- regIdElement.textContent = party.registrationDetails.registrationId;
274
- regRegistrationElement.appendChild(regIdElement);
275
- partyElement.appendChild(regRegistrationElement);
276
- }
277
277
  }
278
278
 
279
279
  /**
@@ -316,6 +316,32 @@ export class FacturXEncoder extends CIIBaseEncoder {
316
316
  currencyElement.textContent = invoice.currency;
317
317
  settlementElement.appendChild(currencyElement);
318
318
 
319
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
320
+ const totals = computeDocumentTotals(invoice);
321
+
322
+ // VAT breakdown (BG-23), one per VAT category and rate, before the payment terms as the CII
323
+ // schema orders them
324
+ for (const { category, rate, taxableAmount, taxAmount, exemption } of totals.vatGroups) {
325
+ const taxElement = doc.createElement('ram:ApplicableTradeTax');
326
+ const appendText = (name: string, text: string) => {
327
+ const element = doc.createElement(name);
328
+ element.textContent = text;
329
+ taxElement.appendChild(element);
330
+ };
331
+ appendText('ram:CalculatedAmount', taxAmount.toFixed(totals.minorUnits)); // BT-117
332
+ appendText('ram:TypeCode', 'VAT');
333
+ if (exemption) {
334
+ appendText('ram:ExemptionReason', exemption.reason); // BT-120 (BR-AE-10)
335
+ }
336
+ appendText('ram:BasisAmount', taxableAmount.toFixed(totals.minorUnits)); // BT-116
337
+ appendText('ram:CategoryCode', category); // BT-118
338
+ if (exemption) {
339
+ appendText('ram:ExemptionReasonCode', exemption.code); // BT-121
340
+ }
341
+ appendText('ram:RateApplicablePercent', rate.toString()); // BT-119
342
+ settlementElement.appendChild(taxElement);
343
+ }
344
+
319
345
  // Add payment terms
320
346
  const paymentTermsElement = doc.createElement('ram:SpecifiedTradePaymentTerms');
321
347
 
@@ -333,50 +359,21 @@ export class FacturXEncoder extends CIIBaseEncoder {
333
359
 
334
360
  settlementElement.appendChild(paymentTermsElement);
335
361
 
336
- // Add totals
362
+ // Add totals, in the order of the CII schema
337
363
  const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
338
-
339
- // Calculate totals with decimal arithmetic to avoid floating point drift.
340
- const calculator = new DecimalCurrencyCalculator(invoice.currency);
341
- const minorUnits = getCurrencyMinorUnits(invoice.currency);
342
- let totalNetAmount = Decimal.ZERO;
343
- let totalTaxAmount = Decimal.ZERO;
344
-
345
- // Calculate from items
346
- if (invoice.items) {
347
- for (const item of invoice.items) {
348
- const itemNetAmount = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
349
- const itemTaxAmount = calculator.calculateVAT(itemNetAmount, item.vatPercentage);
350
-
351
- totalNetAmount = totalNetAmount.add(itemNetAmount);
352
- totalTaxAmount = totalTaxAmount.add(itemTaxAmount);
364
+ const appendAmount = (name: string, value: { toFixed(decimalPlaces: number): string }, withCurrency = false) => {
365
+ const element = doc.createElement(name);
366
+ element.textContent = value.toFixed(totals.minorUnits);
367
+ if (withCurrency) {
368
+ element.setAttribute('currencyID', invoice.currency);
353
369
  }
354
- }
355
-
356
- totalNetAmount = calculator.round(totalNetAmount);
357
- totalTaxAmount = calculator.round(totalTaxAmount);
358
- const totalGrossAmount = calculator.calculateGrossAmount(totalNetAmount, totalTaxAmount);
359
-
360
- // Add line total amount
361
- const lineTotalElement = doc.createElement('ram:LineTotalAmount');
362
- lineTotalElement.textContent = totalNetAmount.toFixed(minorUnits);
363
- monetarySummationElement.appendChild(lineTotalElement);
364
-
365
- // Add tax total amount
366
- const taxTotalElement = doc.createElement('ram:TaxTotalAmount');
367
- taxTotalElement.textContent = totalTaxAmount.toFixed(minorUnits);
368
- taxTotalElement.setAttribute('currencyID', invoice.currency);
369
- monetarySummationElement.appendChild(taxTotalElement);
370
-
371
- // Add grand total amount
372
- const grandTotalElement = doc.createElement('ram:GrandTotalAmount');
373
- grandTotalElement.textContent = totalGrossAmount.toFixed(minorUnits);
374
- monetarySummationElement.appendChild(grandTotalElement);
375
-
376
- // Add due payable amount
377
- const duePayableElement = doc.createElement('ram:DuePayableAmount');
378
- duePayableElement.textContent = totalGrossAmount.toFixed(minorUnits);
379
- monetarySummationElement.appendChild(duePayableElement);
370
+ monetarySummationElement.appendChild(element);
371
+ };
372
+ appendAmount('ram:LineTotalAmount', totals.lineTotal); // BT-106
373
+ appendAmount('ram:TaxBasisTotalAmount', totals.taxBasisTotal); // BT-109
374
+ appendAmount('ram:TaxTotalAmount', totals.taxTotal, true); // BT-110
375
+ appendAmount('ram:GrandTotalAmount', totals.grandTotal); // BT-112
376
+ appendAmount('ram:DuePayableAmount', totals.duePayable); // BT-115
380
377
 
381
378
  settlementElement.appendChild(monetarySummationElement);
382
379
  }
@@ -388,12 +385,12 @@ export class FacturXEncoder extends CIIBaseEncoder {
388
385
  * @param invoice Invoice data
389
386
  */
390
387
  private addLineItems(doc: Document, transactionElement: Element, invoice: TAccountingDoc): void {
391
- const calculator = new DecimalCurrencyCalculator(invoice.currency);
392
- const minorUnits = getCurrencyMinorUnits(invoice.currency);
388
+ // the line net amounts of the one EN 16931 totals computation
389
+ const totals = computeDocumentTotals(invoice);
393
390
 
394
391
  // Add each line item
395
392
  if (invoice.items) {
396
- for (const item of invoice.items) {
393
+ for (const [index, item] of invoice.items.entries()) {
397
394
  // Create line item element
398
395
  const lineItemElement = doc.createElement('ram:IncludedSupplyChainTradeLineItem');
399
396
 
@@ -454,7 +451,7 @@ export class FacturXEncoder extends CIIBaseEncoder {
454
451
 
455
452
  // Add tax category code
456
453
  const taxCategoryCodeElement = doc.createElement('ram:CategoryCode');
457
- taxCategoryCodeElement.textContent = 'S';
454
+ taxCategoryCodeElement.textContent = totals.lineVatCategories[index]; // BT-151
458
455
  taxElement.appendChild(taxCategoryCodeElement);
459
456
 
460
457
  // Add tax rate
@@ -464,15 +461,12 @@ export class FacturXEncoder extends CIIBaseEncoder {
464
461
 
465
462
  settlementElement.appendChild(taxElement);
466
463
 
467
- // Add monetary summation
468
- const monetarySummationElement = doc.createElement('ram:SpecifiedLineTradeSettlementMonetarySummation');
469
-
470
- // Calculate item total with decimal arithmetic and currency rounding.
471
- const itemNetAmount = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
464
+ // Line monetary summation, as the CII schema names it
465
+ const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementLineMonetarySummation');
472
466
 
473
- // Add line total amount
467
+ // Invoice line net amount (BT-131): quantity × net price, rounded
474
468
  const lineTotalElement = doc.createElement('ram:LineTotalAmount');
475
- lineTotalElement.textContent = itemNetAmount.toFixed(minorUnits);
469
+ lineTotalElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
476
470
  monetarySummationElement.appendChild(lineTotalElement);
477
471
 
478
472
  settlementElement.appendChild(monetarySummationElement);
@@ -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 {
@@ -6,6 +6,7 @@ import { DOMParser, XMLSerializer } from '../../../plugins.js';
6
6
  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
+ import { computeDocumentTotals } from '../../utils/document.totals.js';
9
10
 
10
11
  /**
11
12
  * Encoder for ZUGFeRD invoice format
@@ -248,6 +249,17 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
248
249
  nameElement.textContent = party.name;
249
250
  partyElement.appendChild(nameElement);
250
251
 
252
+ // Legal registration identifier (BT-30 seller, BT-47 buyer), e.g. the commercial register
253
+ // number; in the schema order it follows the name. It is no tax registration: the scheme
254
+ // FC of ram:SpecifiedTaxRegistration is the seller's tax number (BT-32)
255
+ if (party.registrationDetails && party.registrationDetails.registrationId) {
256
+ const legalOrganizationElement = doc.createElement('ram:SpecifiedLegalOrganization');
257
+ const legalIdElement = doc.createElement('ram:ID');
258
+ legalIdElement.textContent = party.registrationDetails.registrationId;
259
+ legalOrganizationElement.appendChild(legalIdElement);
260
+ partyElement.appendChild(legalOrganizationElement);
261
+ }
262
+
251
263
  // Add postal address
252
264
  const addressElement = doc.createElement('ram:PostalTradeAddress');
253
265
 
@@ -298,15 +310,6 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
298
310
  partyElement.appendChild(taxRegistrationElement);
299
311
  }
300
312
 
301
- // Add registration ID if available
302
- if (party.registrationDetails && party.registrationDetails.registrationId) {
303
- const regRegistrationElement = doc.createElement('ram:SpecifiedTaxRegistration');
304
- const regIdElement = doc.createElement('ram:ID');
305
- regIdElement.setAttribute('schemeID', 'FC');
306
- regIdElement.textContent = party.registrationDetails.registrationId;
307
- regRegistrationElement.appendChild(regIdElement);
308
- partyElement.appendChild(regRegistrationElement);
309
- }
310
313
  }
311
314
 
312
315
  /**
@@ -456,46 +459,46 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
456
459
  * @param invoice Invoice data
457
460
  */
458
461
  private addTaxDetails(doc: Document, settlementElement: Element, invoice: TAccountingDoc): void {
459
- // Calculate tax categories and totals
460
- const taxCategories = new Map<number, number>(); // Map of VAT rate to net amount
462
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
463
+ const totals = computeDocumentTotals(invoice);
461
464
 
462
- // Calculate from items
463
- if (invoice.items) {
464
- for (const item of invoice.items) {
465
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
466
- const vatRate = item.vatPercentage;
467
-
468
- const currentAmount = taxCategories.get(vatRate) || 0;
469
- taxCategories.set(vatRate, currentAmount + itemNetAmount);
470
- }
471
- }
472
-
473
- // Add each tax category
474
- for (const [rate, baseAmount] of taxCategories.entries()) {
465
+ // VAT breakdown (BG-23), one per VAT category and rate
466
+ for (const { category, rate, taxableAmount, taxAmount, exemption } of totals.vatGroups) {
475
467
  const taxElement = doc.createElement('ram:ApplicableTradeTax');
476
-
477
- // Calculate tax amount
478
- const taxAmount = baseAmount * (rate / 100);
479
-
480
- // Add calculated amount
468
+
469
+ // VAT category tax amount (BT-117)
481
470
  const calculatedAmountElement = doc.createElement('ram:CalculatedAmount');
482
- calculatedAmountElement.textContent = taxAmount.toFixed(2);
471
+ calculatedAmountElement.textContent = taxAmount.toFixed(totals.minorUnits);
483
472
  taxElement.appendChild(calculatedAmountElement);
484
473
 
485
474
  // Add type code (VAT)
486
475
  const typeCodeElement = doc.createElement('ram:TypeCode');
487
476
  typeCodeElement.textContent = 'VAT';
488
477
  taxElement.appendChild(typeCodeElement);
478
+
479
+ // VAT exemption reason text (BT-120), for reverse charge (BR-AE-10)
480
+ if (exemption) {
481
+ const exemptionReasonElement = doc.createElement('ram:ExemptionReason');
482
+ exemptionReasonElement.textContent = exemption.reason;
483
+ taxElement.appendChild(exemptionReasonElement);
484
+ }
489
485
 
490
- // Add basis amount
486
+ // VAT category taxable amount (BT-116)
491
487
  const basisAmountElement = doc.createElement('ram:BasisAmount');
492
- basisAmountElement.textContent = baseAmount.toFixed(2);
488
+ basisAmountElement.textContent = taxableAmount.toFixed(totals.minorUnits);
493
489
  taxElement.appendChild(basisAmountElement);
494
490
 
495
491
  // Add category code
496
492
  const categoryCodeElement = doc.createElement('ram:CategoryCode');
497
- categoryCodeElement.textContent = invoice.reverseCharge ? 'AE' : 'S';
493
+ categoryCodeElement.textContent = category;
498
494
  taxElement.appendChild(categoryCodeElement);
495
+
496
+ // VAT exemption reason code (BT-121)
497
+ if (exemption) {
498
+ const exemptionReasonCodeElement = doc.createElement('ram:ExemptionReasonCode');
499
+ exemptionReasonCodeElement.textContent = exemption.code;
500
+ taxElement.appendChild(exemptionReasonCodeElement);
501
+ }
499
502
 
500
503
  // Add rate
501
504
  const rateElement = doc.createElement('ram:RateApplicablePercent');
@@ -515,43 +518,23 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
515
518
  private addMonetarySummation(doc: Document, settlementElement: Element, invoice: TAccountingDoc): void {
516
519
  const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
517
520
 
518
- // Calculate totals
519
- let totalNetAmount = 0;
520
- let totalTaxAmount = 0;
521
-
522
- // Calculate from items
523
- if (invoice.items) {
524
- for (const item of invoice.items) {
525
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
526
- const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
527
-
528
- totalNetAmount += itemNetAmount;
529
- totalTaxAmount += itemTaxAmount;
521
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
522
+ const totals = computeDocumentTotals(invoice);
523
+ const appendAmount = (name: string, value: { toFixed(decimalPlaces: number): string }, withCurrency = false) => {
524
+ const element = doc.createElement(name);
525
+ element.textContent = value.toFixed(totals.minorUnits);
526
+ if (withCurrency) {
527
+ element.setAttribute('currencyID', invoice.currency);
530
528
  }
531
- }
532
-
533
- const totalGrossAmount = totalNetAmount + totalTaxAmount;
534
-
535
- // Add line total amount
536
- const lineTotalElement = doc.createElement('ram:LineTotalAmount');
537
- lineTotalElement.textContent = totalNetAmount.toFixed(2);
538
- monetarySummationElement.appendChild(lineTotalElement);
529
+ monetarySummationElement.appendChild(element);
530
+ };
539
531
 
540
- // Add tax total amount
541
- const taxTotalElement = doc.createElement('ram:TaxTotalAmount');
542
- taxTotalElement.textContent = totalTaxAmount.toFixed(2);
543
- taxTotalElement.setAttribute('currencyID', invoice.currency);
544
- monetarySummationElement.appendChild(taxTotalElement);
545
-
546
- // Add grand total amount
547
- const grandTotalElement = doc.createElement('ram:GrandTotalAmount');
548
- grandTotalElement.textContent = totalGrossAmount.toFixed(2);
549
- monetarySummationElement.appendChild(grandTotalElement);
550
-
551
- // Add due payable amount
552
- const duePayableElement = doc.createElement('ram:DuePayableAmount');
553
- duePayableElement.textContent = totalGrossAmount.toFixed(2);
554
- monetarySummationElement.appendChild(duePayableElement);
532
+ // in the order of the CII schema
533
+ appendAmount('ram:LineTotalAmount', totals.lineTotal); // BT-106
534
+ appendAmount('ram:TaxBasisTotalAmount', totals.taxBasisTotal); // BT-109
535
+ appendAmount('ram:TaxTotalAmount', totals.taxTotal, true); // BT-110
536
+ appendAmount('ram:GrandTotalAmount', totals.grandTotal); // BT-112
537
+ appendAmount('ram:DuePayableAmount', totals.duePayable); // BT-115
555
538
 
556
539
  settlementElement.appendChild(monetarySummationElement);
557
540
  }
@@ -565,7 +548,8 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
565
548
  private addLineItems(doc: Document, transactionElement: Element, invoice: TAccountingDoc): void {
566
549
  // Add each line item
567
550
  if (invoice.items) {
568
- for (const item of invoice.items) {
551
+ const totals = computeDocumentTotals(invoice);
552
+ for (const [index, item] of invoice.items.entries()) {
569
553
  // Create line item element
570
554
  const lineItemElement = doc.createElement('ram:IncludedSupplyChainTradeLineItem');
571
555
 
@@ -626,7 +610,7 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
626
610
 
627
611
  // Add tax category code
628
612
  const taxCategoryCodeElement = doc.createElement('ram:CategoryCode');
629
- taxCategoryCodeElement.textContent = invoice.reverseCharge ? 'AE' : 'S';
613
+ taxCategoryCodeElement.textContent = totals.lineVatCategories[index]; // BT-151
630
614
  taxElement.appendChild(taxCategoryCodeElement);
631
615
 
632
616
  // Add tax rate
@@ -636,15 +620,12 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
636
620
 
637
621
  settlementElement.appendChild(taxElement);
638
622
 
639
- // Add monetary summation
640
- const monetarySummationElement = doc.createElement('ram:SpecifiedLineTradeSettlementMonetarySummation');
641
-
642
- // Calculate item total
643
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
623
+ // Line monetary summation, as the CII schema names it
624
+ const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementLineMonetarySummation');
644
625
 
645
- // Add line total amount
626
+ // Invoice line net amount (BT-131): quantity × net price, rounded
646
627
  const lineTotalElement = doc.createElement('ram:LineTotalAmount');
647
- lineTotalElement.textContent = itemNetAmount.toFixed(2);
628
+ lineTotalElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
648
629
  monetarySummationElement.appendChild(lineTotalElement);
649
630
 
650
631
  settlementElement.appendChild(monetarySummationElement);
@@ -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 {
@@ -3,6 +3,7 @@
3
3
  * Validates invoices against EN16931 Business Terms and Business Groups
4
4
  */
5
5
 
6
+ import { findInvalidItemAmounts, getTotalsSkippedResult } from '../utils/document.totals.js';
6
7
  import type { ValidationResult } from '../validation/validation.types.js';
7
8
  import type { EN16931SemanticModel, BusinessTerms, BusinessGroups } from './bt-bg.model.js';
8
9
  import type { EInvoice } from '../../einvoice.js';
@@ -36,6 +37,25 @@ export class SemanticModelValidator {
36
37
  */
37
38
  public validate(invoice: EInvoice): ValidationResult[] {
38
39
  const results: ValidationResult[] = [];
40
+
41
+ // The semantic model carries the totals, which are computed from the line amounts; while
42
+ // one is no number the model cannot be built, so each such line is reported (BR-24 for the
43
+ // net amount, BR-CO-04 for the VAT rate) and the model rules are skipped
44
+ const invalidAmounts = findInvalidItemAmounts(invoice.items);
45
+ if (invalidAmounts.length > 0) {
46
+ for (const entry of invalidAmounts) {
47
+ results.push({
48
+ ruleId: entry.field === 'vatPercentage' ? 'BR-CO-04' : 'BR-24',
49
+ source: 'SEMANTIC',
50
+ severity: 'error',
51
+ message: `Invoice line ${entry.index + 1}: ${entry.field} is no number`,
52
+ field: `items[${entry.index}].${entry.field}`,
53
+ value: entry.value,
54
+ });
55
+ }
56
+ results.push(getTotalsSkippedResult(invalidAmounts, 'SEMANTIC'));
57
+ return results;
58
+ }
39
59
 
40
60
  // Convert to semantic model
41
61
  const model = this.adapter.toSemanticModel(invoice);