@fin.cx/einvoice 8.2.1 → 8.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) 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/facturx/facturx.encoder.js +40 -47
  4. package/dist_ts/formats/cii/zugferd/zugferd.encoder.js +31 -56
  5. package/dist_ts/formats/semantic/semantic.validator.js +20 -1
  6. package/dist_ts/formats/ubl/generic/ubl.encoder.js +23 -46
  7. package/dist_ts/formats/utils/currency.calculator.decimal.d.ts +9 -1
  8. package/dist_ts/formats/utils/currency.calculator.decimal.js +10 -3
  9. package/dist_ts/formats/utils/document.totals.d.ts +79 -0
  10. package/dist_ts/formats/utils/document.totals.js +93 -0
  11. package/dist_ts/formats/validation/codelist.validator.js +3 -11
  12. package/dist_ts/formats/validation/en16931.business-rules.validator.js +38 -44
  13. package/dist_ts/formats/validation/facturx.validator.js +19 -5
  14. package/dist_ts/formats/validation/vat-categories.validator.js +9 -1
  15. package/package.json +2 -2
  16. package/ts/00_commitinfo_data.ts +1 -1
  17. package/ts/einvoice.ts +11 -25
  18. package/ts/formats/cii/facturx/facturx.encoder.ts +41 -55
  19. package/ts/formats/cii/zugferd/zugferd.encoder.ts +31 -66
  20. package/ts/formats/semantic/semantic.validator.ts +20 -0
  21. package/ts/formats/ubl/generic/ubl.encoder.ts +28 -57
  22. package/ts/formats/utils/currency.calculator.decimal.ts +10 -2
  23. package/ts/formats/utils/document.totals.ts +144 -0
  24. package/ts/formats/validation/codelist.validator.ts +3 -12
  25. package/ts/formats/validation/en16931.business-rules.validator.ts +44 -51
  26. package/ts/formats/validation/facturx.validator.ts +19 -4
  27. 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
 
@@ -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
  /**
@@ -316,6 +314,26 @@ export class FacturXEncoder extends CIIBaseEncoder {
316
314
  currencyElement.textContent = invoice.currency;
317
315
  settlementElement.appendChild(currencyElement);
318
316
 
317
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
318
+ const totals = computeDocumentTotals(invoice);
319
+
320
+ // VAT breakdown (BG-23), one per rate, before the payment terms as the CII schema orders them;
321
+ // the category is the one the lines carry
322
+ for (const { rate, taxableAmount, taxAmount } of totals.vatGroups) {
323
+ const taxElement = doc.createElement('ram:ApplicableTradeTax');
324
+ const appendText = (name: string, text: string) => {
325
+ const element = doc.createElement(name);
326
+ element.textContent = text;
327
+ taxElement.appendChild(element);
328
+ };
329
+ appendText('ram:CalculatedAmount', taxAmount.toFixed(totals.minorUnits)); // BT-117
330
+ appendText('ram:TypeCode', 'VAT');
331
+ appendText('ram:BasisAmount', taxableAmount.toFixed(totals.minorUnits)); // BT-116
332
+ appendText('ram:CategoryCode', 'S'); // BT-118
333
+ appendText('ram:RateApplicablePercent', rate.toString()); // BT-119
334
+ settlementElement.appendChild(taxElement);
335
+ }
336
+
319
337
  // Add payment terms
320
338
  const paymentTermsElement = doc.createElement('ram:SpecifiedTradePaymentTerms');
321
339
 
@@ -333,50 +351,21 @@ export class FacturXEncoder extends CIIBaseEncoder {
333
351
 
334
352
  settlementElement.appendChild(paymentTermsElement);
335
353
 
336
- // Add totals
354
+ // Add totals, in the order of the CII schema
337
355
  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);
356
+ const appendAmount = (name: string, value: { toFixed(decimalPlaces: number): string }, withCurrency = false) => {
357
+ const element = doc.createElement(name);
358
+ element.textContent = value.toFixed(totals.minorUnits);
359
+ if (withCurrency) {
360
+ element.setAttribute('currencyID', invoice.currency);
353
361
  }
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);
362
+ monetarySummationElement.appendChild(element);
363
+ };
364
+ appendAmount('ram:LineTotalAmount', totals.lineTotal); // BT-106
365
+ appendAmount('ram:TaxBasisTotalAmount', totals.taxBasisTotal); // BT-109
366
+ appendAmount('ram:TaxTotalAmount', totals.taxTotal, true); // BT-110
367
+ appendAmount('ram:GrandTotalAmount', totals.grandTotal); // BT-112
368
+ appendAmount('ram:DuePayableAmount', totals.duePayable); // BT-115
380
369
 
381
370
  settlementElement.appendChild(monetarySummationElement);
382
371
  }
@@ -388,12 +377,12 @@ export class FacturXEncoder extends CIIBaseEncoder {
388
377
  * @param invoice Invoice data
389
378
  */
390
379
  private addLineItems(doc: Document, transactionElement: Element, invoice: TAccountingDoc): void {
391
- const calculator = new DecimalCurrencyCalculator(invoice.currency);
392
- const minorUnits = getCurrencyMinorUnits(invoice.currency);
380
+ // the line net amounts of the one EN 16931 totals computation
381
+ const totals = computeDocumentTotals(invoice);
393
382
 
394
383
  // Add each line item
395
384
  if (invoice.items) {
396
- for (const item of invoice.items) {
385
+ for (const [index, item] of invoice.items.entries()) {
397
386
  // Create line item element
398
387
  const lineItemElement = doc.createElement('ram:IncludedSupplyChainTradeLineItem');
399
388
 
@@ -464,15 +453,12 @@ export class FacturXEncoder extends CIIBaseEncoder {
464
453
 
465
454
  settlementElement.appendChild(taxElement);
466
455
 
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);
456
+ // Line monetary summation, as the CII schema names it
457
+ const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementLineMonetarySummation');
472
458
 
473
- // Add line total amount
459
+ // Invoice line net amount (BT-131): quantity × net price, rounded
474
460
  const lineTotalElement = doc.createElement('ram:LineTotalAmount');
475
- lineTotalElement.textContent = itemNetAmount.toFixed(minorUnits);
461
+ lineTotalElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
476
462
  monetarySummationElement.appendChild(lineTotalElement);
477
463
 
478
464
  settlementElement.appendChild(monetarySummationElement);
@@ -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
@@ -456,30 +457,16 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
456
457
  * @param invoice Invoice data
457
458
  */
458
459
  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
460
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
461
+ const totals = computeDocumentTotals(invoice);
461
462
 
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()) {
463
+ // VAT breakdown (BG-23), one per rate
464
+ for (const { rate, taxableAmount, taxAmount } of totals.vatGroups) {
475
465
  const taxElement = doc.createElement('ram:ApplicableTradeTax');
476
-
477
- // Calculate tax amount
478
- const taxAmount = baseAmount * (rate / 100);
479
-
480
- // Add calculated amount
466
+
467
+ // VAT category tax amount (BT-117)
481
468
  const calculatedAmountElement = doc.createElement('ram:CalculatedAmount');
482
- calculatedAmountElement.textContent = taxAmount.toFixed(2);
469
+ calculatedAmountElement.textContent = taxAmount.toFixed(totals.minorUnits);
483
470
  taxElement.appendChild(calculatedAmountElement);
484
471
 
485
472
  // Add type code (VAT)
@@ -487,9 +474,9 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
487
474
  typeCodeElement.textContent = 'VAT';
488
475
  taxElement.appendChild(typeCodeElement);
489
476
 
490
- // Add basis amount
477
+ // VAT category taxable amount (BT-116)
491
478
  const basisAmountElement = doc.createElement('ram:BasisAmount');
492
- basisAmountElement.textContent = baseAmount.toFixed(2);
479
+ basisAmountElement.textContent = taxableAmount.toFixed(totals.minorUnits);
493
480
  taxElement.appendChild(basisAmountElement);
494
481
 
495
482
  // Add category code
@@ -515,43 +502,23 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
515
502
  private addMonetarySummation(doc: Document, settlementElement: Element, invoice: TAccountingDoc): void {
516
503
  const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementHeaderMonetarySummation');
517
504
 
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;
505
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
506
+ const totals = computeDocumentTotals(invoice);
507
+ const appendAmount = (name: string, value: { toFixed(decimalPlaces: number): string }, withCurrency = false) => {
508
+ const element = doc.createElement(name);
509
+ element.textContent = value.toFixed(totals.minorUnits);
510
+ if (withCurrency) {
511
+ element.setAttribute('currencyID', invoice.currency);
530
512
  }
531
- }
532
-
533
- const totalGrossAmount = totalNetAmount + totalTaxAmount;
513
+ monetarySummationElement.appendChild(element);
514
+ };
534
515
 
535
- // Add line total amount
536
- const lineTotalElement = doc.createElement('ram:LineTotalAmount');
537
- lineTotalElement.textContent = totalNetAmount.toFixed(2);
538
- monetarySummationElement.appendChild(lineTotalElement);
539
-
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);
516
+ // in the order of the CII schema
517
+ appendAmount('ram:LineTotalAmount', totals.lineTotal); // BT-106
518
+ appendAmount('ram:TaxBasisTotalAmount', totals.taxBasisTotal); // BT-109
519
+ appendAmount('ram:TaxTotalAmount', totals.taxTotal, true); // BT-110
520
+ appendAmount('ram:GrandTotalAmount', totals.grandTotal); // BT-112
521
+ appendAmount('ram:DuePayableAmount', totals.duePayable); // BT-115
555
522
 
556
523
  settlementElement.appendChild(monetarySummationElement);
557
524
  }
@@ -565,7 +532,8 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
565
532
  private addLineItems(doc: Document, transactionElement: Element, invoice: TAccountingDoc): void {
566
533
  // Add each line item
567
534
  if (invoice.items) {
568
- for (const item of invoice.items) {
535
+ const totals = computeDocumentTotals(invoice);
536
+ for (const [index, item] of invoice.items.entries()) {
569
537
  // Create line item element
570
538
  const lineItemElement = doc.createElement('ram:IncludedSupplyChainTradeLineItem');
571
539
 
@@ -636,15 +604,12 @@ export class ZUGFeRDEncoder extends CIIBaseEncoder {
636
604
 
637
605
  settlementElement.appendChild(taxElement);
638
606
 
639
- // Add monetary summation
640
- const monetarySummationElement = doc.createElement('ram:SpecifiedLineTradeSettlementMonetarySummation');
641
-
642
- // Calculate item total
643
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
607
+ // Line monetary summation, as the CII schema names it
608
+ const monetarySummationElement = doc.createElement('ram:SpecifiedTradeSettlementLineMonetarySummation');
644
609
 
645
- // Add line total amount
610
+ // Invoice line net amount (BT-131): quantity × net price, rounded
646
611
  const lineTotalElement = doc.createElement('ram:LineTotalAmount');
647
- lineTotalElement.textContent = itemNetAmount.toFixed(2);
612
+ lineTotalElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
648
613
  monetarySummationElement.appendChild(lineTotalElement);
649
614
 
650
615
  settlementElement.appendChild(monetarySummationElement);
@@ -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);
@@ -6,6 +6,7 @@ import { getDocumentTypeCode } from '../../utils/document.typecode.js';
6
6
  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
+ import { computeDocumentTotals } from '../../utils/document.totals.js';
9
10
 
10
11
  /**
11
12
  * UBL Encoder implementation
@@ -383,48 +384,31 @@ export class UBLEncoder extends UBLBaseEncoder {
383
384
  private addTaxTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
384
385
  const taxTotalNode = doc.createElement('cac:TaxTotal');
385
386
  parentElement.appendChild(taxTotalNode);
386
-
387
- // Calculate total tax amount
388
- let totalTaxAmount = 0;
389
- const taxCategories = new Map<number, number>(); // Map of VAT rate to net amount
390
-
391
- // Calculate from items
392
- if (invoice.items) {
393
- for (const item of invoice.items) {
394
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
395
- const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
396
- const vatRate = item.vatPercentage;
397
-
398
- totalTaxAmount += itemTaxAmount;
399
-
400
- // Aggregate by VAT rate
401
- const currentAmount = taxCategories.get(vatRate) || 0;
402
- taxCategories.set(vatRate, currentAmount + itemNetAmount);
403
- }
404
- }
405
-
406
- // Add total tax amount
387
+
388
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
389
+ const totals = computeDocumentTotals(invoice);
390
+
391
+ // Invoice total VAT amount (BT-110)
407
392
  const taxAmountElement = doc.createElement('cbc:TaxAmount');
408
393
  taxAmountElement.setAttribute('currencyID', invoice.currency);
409
- taxAmountElement.textContent = totalTaxAmount.toFixed(2);
394
+ taxAmountElement.textContent = totals.taxTotal.toFixed(totals.minorUnits);
410
395
  taxTotalNode.appendChild(taxAmountElement);
411
-
412
- // Add tax subtotals
413
- for (const [rate, baseAmount] of taxCategories.entries()) {
396
+
397
+ // VAT breakdown (BG-23), one subtotal per rate
398
+ for (const { rate, taxableAmount, taxAmount } of totals.vatGroups) {
414
399
  const taxSubtotalNode = doc.createElement('cac:TaxSubtotal');
415
400
  taxTotalNode.appendChild(taxSubtotalNode);
416
-
417
- // Taxable amount
401
+
402
+ // VAT category taxable amount (BT-116)
418
403
  const taxableAmountElement = doc.createElement('cbc:TaxableAmount');
419
404
  taxableAmountElement.setAttribute('currencyID', invoice.currency);
420
- taxableAmountElement.textContent = baseAmount.toFixed(2);
405
+ taxableAmountElement.textContent = taxableAmount.toFixed(totals.minorUnits);
421
406
  taxSubtotalNode.appendChild(taxableAmountElement);
422
-
423
- // Tax amount
424
- const taxAmount = baseAmount * (rate / 100);
407
+
408
+ // VAT category tax amount (BT-117)
425
409
  const subtotalTaxAmountElement = doc.createElement('cbc:TaxAmount');
426
410
  subtotalTaxAmountElement.setAttribute('currencyID', invoice.currency);
427
- subtotalTaxAmountElement.textContent = taxAmount.toFixed(2);
411
+ subtotalTaxAmountElement.textContent = taxAmount.toFixed(totals.minorUnits);
428
412
  taxSubtotalNode.appendChild(subtotalTaxAmountElement);
429
413
 
430
414
  // Tax category
@@ -460,46 +444,33 @@ export class UBLEncoder extends UBLBaseEncoder {
460
444
  private addLegalMonetaryTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
461
445
  const legalMonetaryTotalNode = doc.createElement('cac:LegalMonetaryTotal');
462
446
  parentElement.appendChild(legalMonetaryTotalNode);
463
-
464
- // Calculate totals
465
- let totalNetAmount = 0;
466
- let totalTaxAmount = 0;
467
-
468
- // Calculate from items
469
- if (invoice.items) {
470
- for (const item of invoice.items) {
471
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
472
- const itemTaxAmount = itemNetAmount * (item.vatPercentage / 100);
473
-
474
- totalNetAmount += itemNetAmount;
475
- totalTaxAmount += itemTaxAmount;
476
- }
477
- }
478
-
479
- const totalGrossAmount = totalNetAmount + totalTaxAmount;
447
+
448
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
449
+ const totals = computeDocumentTotals(invoice);
450
+ const amount = (value: { toFixed(decimalPlaces: number): string }) => value.toFixed(totals.minorUnits);
480
451
 
481
452
  // Line extension amount (sum of line net amounts)
482
453
  const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
483
454
  lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
484
- lineExtensionAmountElement.textContent = totalNetAmount.toFixed(2);
455
+ lineExtensionAmountElement.textContent = amount(totals.lineTotal);
485
456
  legalMonetaryTotalNode.appendChild(lineExtensionAmountElement);
486
457
 
487
458
  // Tax exclusive amount
488
459
  const taxExclusiveAmountElement = doc.createElement('cbc:TaxExclusiveAmount');
489
460
  taxExclusiveAmountElement.setAttribute('currencyID', invoice.currency);
490
- taxExclusiveAmountElement.textContent = totalNetAmount.toFixed(2);
461
+ taxExclusiveAmountElement.textContent = amount(totals.taxBasisTotal);
491
462
  legalMonetaryTotalNode.appendChild(taxExclusiveAmountElement);
492
463
 
493
464
  // Tax inclusive amount
494
465
  const taxInclusiveAmountElement = doc.createElement('cbc:TaxInclusiveAmount');
495
466
  taxInclusiveAmountElement.setAttribute('currencyID', invoice.currency);
496
- taxInclusiveAmountElement.textContent = totalGrossAmount.toFixed(2);
467
+ taxInclusiveAmountElement.textContent = amount(totals.grandTotal);
497
468
  legalMonetaryTotalNode.appendChild(taxInclusiveAmountElement);
498
469
 
499
470
  // Payable amount
500
471
  const payableAmountElement = doc.createElement('cbc:PayableAmount');
501
472
  payableAmountElement.setAttribute('currencyID', invoice.currency);
502
- payableAmountElement.textContent = totalGrossAmount.toFixed(2);
473
+ payableAmountElement.textContent = amount(totals.duePayable);
503
474
  legalMonetaryTotalNode.appendChild(payableAmountElement);
504
475
  }
505
476
 
@@ -512,8 +483,9 @@ export class UBLEncoder extends UBLBaseEncoder {
512
483
  private addInvoiceLines(doc: Document, parentElement: Element, invoice: TAccountingDoc, documentType: UBLDocumentType = UBLDocumentType.INVOICE): void {
513
484
  if (!invoice.items) return;
514
485
  const creditNote = documentType === UBLDocumentType.CREDIT_NOTE;
486
+ const totals = computeDocumentTotals(invoice);
515
487
 
516
- for (const item of invoice.items) {
488
+ for (const [index, item] of invoice.items.entries()) {
517
489
  const invoiceLineNode = doc.createElement(creditNote ? 'cac:CreditNoteLine' : 'cac:InvoiceLine');
518
490
  parentElement.appendChild(invoiceLineNode);
519
491
 
@@ -529,11 +501,10 @@ export class UBLEncoder extends UBLBaseEncoder {
529
501
  quantityElement.textContent = item.unitQuantity.toString();
530
502
  invoiceLineNode.appendChild(quantityElement);
531
503
 
532
- // Line extension amount (line net amount)
533
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
504
+ // Invoice line net amount (BT-131): quantity × net price, rounded
534
505
  const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
535
506
  lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
536
- lineExtensionAmountElement.textContent = itemNetAmount.toFixed(2);
507
+ lineExtensionAmountElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
537
508
  invoiceLineNode.appendChild(lineExtensionAmountElement);
538
509
 
539
510
  // Item information
@@ -15,12 +15,20 @@ export class DecimalCurrencyCalculator {
15
15
  private readonly minorUnits: number;
16
16
  private readonly roundingMode: RoundingMode;
17
17
 
18
+ /**
19
+ * @param currency The currency, whose minor unit sets the rounding scale
20
+ * @param roundingMode How values are rounded
21
+ * @param options.maxDecimals The most decimals an amount may have, when fewer than the
22
+ * currency's minor unit (EN 16931 allows two, BR-DEC-*)
23
+ */
18
24
  constructor(
19
25
  currency: TCurrency,
20
- roundingMode: RoundingMode = 'HALF_UP'
26
+ roundingMode: RoundingMode = 'HALF_UP',
27
+ options: { maxDecimals?: number } = {}
21
28
  ) {
22
29
  this.currency = currency;
23
- this.minorUnits = getCurrencyMinorUnits(currency);
30
+ const minorUnits = getCurrencyMinorUnits(currency);
31
+ this.minorUnits = options.maxDecimals === undefined ? minorUnits : Math.min(minorUnits, options.maxDecimals);
24
32
  this.roundingMode = roundingMode;
25
33
  }
26
34