@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
@@ -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,65 +384,45 @@ 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 { category, rate, taxableAmount, taxAmount, exemption } 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
431
415
  const taxCategoryNode = doc.createElement('cac:TaxCategory');
432
416
  taxSubtotalNode.appendChild(taxCategoryNode);
433
417
 
434
- // Determine tax category ID based on reverse charge
435
- const categoryId = invoice.reverseCharge ? 'AE' : 'S';
436
- this.appendElement(doc, taxCategoryNode, 'cbc:ID', categoryId);
437
-
438
- // Add percent with 2 decimal places
418
+ // VAT category code (BT-118) and rate (BT-119)
419
+ this.appendElement(doc, taxCategoryNode, 'cbc:ID', category);
439
420
  this.appendElement(doc, taxCategoryNode, 'cbc:Percent', rate.toFixed(2));
440
421
 
441
- // Add tax exemption reason if reverse charge
442
- if (invoice.reverseCharge) {
443
- this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', 'VATEX-EU-IC');
444
- this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', 'Reverse charge');
422
+ // VAT exemption reason code (BT-121) and text (BT-120), for reverse charge (BR-AE-10)
423
+ if (exemption) {
424
+ this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReasonCode', exemption.code);
425
+ this.appendElement(doc, taxCategoryNode, 'cbc:TaxExemptionReason', exemption.reason);
445
426
  }
446
427
 
447
428
  // Add tax scheme
@@ -460,46 +441,33 @@ export class UBLEncoder extends UBLBaseEncoder {
460
441
  private addLegalMonetaryTotal(doc: Document, parentElement: Element, invoice: TAccountingDoc): void {
461
442
  const legalMonetaryTotalNode = doc.createElement('cac:LegalMonetaryTotal');
462
443
  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;
444
+
445
+ // the EN 16931 totals in decimal arithmetic, rounded as the business rules check them
446
+ const totals = computeDocumentTotals(invoice);
447
+ const amount = (value: { toFixed(decimalPlaces: number): string }) => value.toFixed(totals.minorUnits);
480
448
 
481
449
  // Line extension amount (sum of line net amounts)
482
450
  const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
483
451
  lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
484
- lineExtensionAmountElement.textContent = totalNetAmount.toFixed(2);
452
+ lineExtensionAmountElement.textContent = amount(totals.lineTotal);
485
453
  legalMonetaryTotalNode.appendChild(lineExtensionAmountElement);
486
454
 
487
455
  // Tax exclusive amount
488
456
  const taxExclusiveAmountElement = doc.createElement('cbc:TaxExclusiveAmount');
489
457
  taxExclusiveAmountElement.setAttribute('currencyID', invoice.currency);
490
- taxExclusiveAmountElement.textContent = totalNetAmount.toFixed(2);
458
+ taxExclusiveAmountElement.textContent = amount(totals.taxBasisTotal);
491
459
  legalMonetaryTotalNode.appendChild(taxExclusiveAmountElement);
492
460
 
493
461
  // Tax inclusive amount
494
462
  const taxInclusiveAmountElement = doc.createElement('cbc:TaxInclusiveAmount');
495
463
  taxInclusiveAmountElement.setAttribute('currencyID', invoice.currency);
496
- taxInclusiveAmountElement.textContent = totalGrossAmount.toFixed(2);
464
+ taxInclusiveAmountElement.textContent = amount(totals.grandTotal);
497
465
  legalMonetaryTotalNode.appendChild(taxInclusiveAmountElement);
498
466
 
499
467
  // Payable amount
500
468
  const payableAmountElement = doc.createElement('cbc:PayableAmount');
501
469
  payableAmountElement.setAttribute('currencyID', invoice.currency);
502
- payableAmountElement.textContent = totalGrossAmount.toFixed(2);
470
+ payableAmountElement.textContent = amount(totals.duePayable);
503
471
  legalMonetaryTotalNode.appendChild(payableAmountElement);
504
472
  }
505
473
 
@@ -512,8 +480,9 @@ export class UBLEncoder extends UBLBaseEncoder {
512
480
  private addInvoiceLines(doc: Document, parentElement: Element, invoice: TAccountingDoc, documentType: UBLDocumentType = UBLDocumentType.INVOICE): void {
513
481
  if (!invoice.items) return;
514
482
  const creditNote = documentType === UBLDocumentType.CREDIT_NOTE;
483
+ const totals = computeDocumentTotals(invoice);
515
484
 
516
- for (const item of invoice.items) {
485
+ for (const [index, item] of invoice.items.entries()) {
517
486
  const invoiceLineNode = doc.createElement(creditNote ? 'cac:CreditNoteLine' : 'cac:InvoiceLine');
518
487
  parentElement.appendChild(invoiceLineNode);
519
488
 
@@ -529,11 +498,10 @@ export class UBLEncoder extends UBLBaseEncoder {
529
498
  quantityElement.textContent = item.unitQuantity.toString();
530
499
  invoiceLineNode.appendChild(quantityElement);
531
500
 
532
- // Line extension amount (line net amount)
533
- const itemNetAmount = item.unitNetPrice * item.unitQuantity;
501
+ // Invoice line net amount (BT-131): quantity × net price, rounded
534
502
  const lineExtensionAmountElement = doc.createElement('cbc:LineExtensionAmount');
535
503
  lineExtensionAmountElement.setAttribute('currencyID', invoice.currency);
536
- lineExtensionAmountElement.textContent = itemNetAmount.toFixed(2);
504
+ lineExtensionAmountElement.textContent = totals.lineNetAmounts[index].toFixed(totals.minorUnits);
537
505
  invoiceLineNode.appendChild(lineExtensionAmountElement);
538
506
 
539
507
  // Item information
@@ -556,9 +524,8 @@ export class UBLEncoder extends UBLBaseEncoder {
556
524
  const classifiedTaxCategoryNode = doc.createElement('cac:ClassifiedTaxCategory');
557
525
  itemNode.appendChild(classifiedTaxCategoryNode);
558
526
 
559
- // Determine tax category ID based on reverse charge
560
- const categoryId = invoice.reverseCharge ? 'AE' : 'S';
561
- this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', categoryId);
527
+ // Invoiced item VAT category code (BT-151)
528
+ this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:ID', totals.lineVatCategories[index]);
562
529
 
563
530
  // Tax percent with 2 decimal places
564
531
  this.appendElement(doc, classifiedTaxCategoryNode, 'cbc:Percent', item.vatPercentage.toFixed(2));
@@ -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.
@@ -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
 
@@ -0,0 +1,167 @@
1
+ import type { TAccountingDoc } from '../../interfaces/common.js';
2
+ import { Decimal } from './decimal.js';
3
+ import { DecimalCurrencyCalculator } from './currency.calculator.decimal.js';
4
+ import { EInvoiceFormatError } from '../../errors.js';
5
+ import type { ValidationResult } from '../validation/validation.types.js';
6
+ import { getVatCategory, getVatExemption, type IVatExemption, type TVatCategoryCode } from './vat.category.js';
7
+
8
+ /** An item amount that is no finite number, so no total can be computed from it */
9
+ export interface IInvalidItemAmount {
10
+ index: number;
11
+ field: 'unitQuantity' | 'unitNetPrice' | 'vatPercentage';
12
+ value: unknown;
13
+ }
14
+
15
+ /**
16
+ * The item amounts of a document that are no finite number. The totals cannot
17
+ * be computed while there is one: the encoders and the total getters refuse
18
+ * the document, and a validator reports the lines and skips the rules that
19
+ * need the totals.
20
+ * @param items The document's items
21
+ */
22
+ export const findInvalidItemAmounts = (items: TAccountingDoc['items'] | undefined): IInvalidItemAmount[] => {
23
+ const invalid: IInvalidItemAmount[] = [];
24
+ for (const [index, item] of (items ?? []).entries()) {
25
+ for (const field of ['unitQuantity', 'unitNetPrice', 'vatPercentage'] as const) {
26
+ const value: unknown = item[field];
27
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
28
+ invalid.push({ index, field, value });
29
+ }
30
+ }
31
+ }
32
+ return invalid;
33
+ };
34
+
35
+ /**
36
+ * The result a validator adds when it skips the rules that need the totals,
37
+ * because a line amount is no number (the line rules report the lines)
38
+ * @param invalid The invalid item amounts
39
+ * @param source The validator's source
40
+ */
41
+ export const getTotalsSkippedResult = (invalid: IInvalidItemAmount[], source: string): ValidationResult => ({
42
+ ruleId: 'TOTALS-NOT-CHECKED',
43
+ source,
44
+ severity: 'info',
45
+ message: `The rules on totals and the VAT breakdown were not checked: ${invalid
46
+ .map((entry) => `items[${entry.index}].${entry.field} is no number`)
47
+ .join(', ')}`,
48
+ field: invalid.map((entry) => `items[${entry.index}].${entry.field}`).join(', '),
49
+ });
50
+
51
+ /** The most decimals an amount may have in EN 16931 (BR-DEC-09 to BR-DEC-23) */
52
+ export const EN16931_MAX_AMOUNT_DECIMALS = 2;
53
+
54
+ /**
55
+ * The calculator every document amount is computed with: rounding half up to
56
+ * the currency's minor unit, at most two decimals as EN 16931 allows. A
57
+ * currency without minor unit (JPY) keeps 0 decimals; one with three (BHD,
58
+ * KWD, ...) is rounded to two.
59
+ * @param currency The document currency
60
+ */
61
+ export const getDocumentCalculator = (currency: TAccountingDoc['currency']): DecimalCurrencyCalculator =>
62
+ new DecimalCurrencyCalculator(currency, 'HALF_UP', { maxDecimals: EN16931_MAX_AMOUNT_DECIMALS });
63
+
64
+ /**
65
+ * The VAT breakdown of one VAT category and rate (BG-23): the category
66
+ * (BT-118), the rate (BT-119), the taxable amount (BT-116), the tax on it
67
+ * (BT-117) and, for a category that states one, the exemption reason
68
+ * (BT-121, BT-120).
69
+ */
70
+ export interface IDocumentVatGroup {
71
+ category: TVatCategoryCode;
72
+ rate: number;
73
+ taxableAmount: Decimal;
74
+ taxAmount: Decimal;
75
+ exemption?: IVatExemption;
76
+ }
77
+
78
+ /**
79
+ * The totals of a document as EN 16931 computes them, in decimal arithmetic.
80
+ * Every amount is rounded to the minor unit of the currency; all encoders
81
+ * write these values, so the XML is consistent in every syntax.
82
+ */
83
+ export interface IDocumentTotals {
84
+ /** decimals the amounts are rounded to and written with: the currency's minor unit, at most two */
85
+ minorUnits: number;
86
+ /** the Invoice line net amount (BT-131) of each item, in item order: quantity × net price, rounded */
87
+ lineNetAmounts: Decimal[];
88
+ /** the VAT category (BT-151) of each item, in item order */
89
+ lineVatCategories: TVatCategoryCode[];
90
+ /** Sum of Invoice line net amount (BT-106) */
91
+ lineTotal: Decimal;
92
+ /** Invoice total amount without VAT (BT-109): BT-106, as the envelope has no document level allowances or charges */
93
+ taxBasisTotal: Decimal;
94
+ /** VAT breakdown (BG-23), one per VAT category and rate, in the order they first appear */
95
+ vatGroups: IDocumentVatGroup[];
96
+ /** Invoice total VAT amount (BT-110): the sum of the rounded VAT category tax amounts */
97
+ taxTotal: Decimal;
98
+ /** Invoice total amount with VAT (BT-112): BT-109 + BT-110 */
99
+ grandTotal: Decimal;
100
+ /** Amount due for payment (BT-115): BT-112, as the envelope states no paid amount yet */
101
+ duePayable: Decimal;
102
+ }
103
+
104
+ /**
105
+ * Computes the totals of a document the way the EN 16931 business rules check
106
+ * them, every amount rounded half up to the currency's minor unit, at most two
107
+ * decimals (BR-DEC-*): each line net amount (BT-131) is quantity × net price
108
+ * rounded; the sum of line net amounts (BT-106) is their sum (BR-CO-10);
109
+ * each VAT category taxable amount (BT-116) is the sum of the line net amounts
110
+ * of that category and rate, and its tax amount (BT-117) is BT-116 × rate / 100
111
+ * rounded (BR-CO-17), 0 for reverse charge (BR-AE-09); the total VAT (BT-110) is the sum of the category tax amounts
112
+ * (BR-CO-14); the total with VAT (BT-112) is BT-109 + BT-110 (BR-CO-15).
113
+ * @param accountingDoc The document
114
+ */
115
+ export const computeDocumentTotals = (
116
+ accountingDoc: Pick<TAccountingDoc, 'currency' | 'items'> & { reverseCharge?: boolean; language?: string },
117
+ ): IDocumentTotals => {
118
+ const calculator = getDocumentCalculator(accountingDoc.currency);
119
+ const minorUnits = calculator.getCurrencyInfo().minorUnits;
120
+ const category = getVatCategory(accountingDoc);
121
+ const lineNetAmounts: Decimal[] = [];
122
+ const lineVatCategories: TVatCategoryCode[] = [];
123
+ const taxableByGroup = new Map<string, { category: TVatCategoryCode; rate: number; taxable: Decimal }>();
124
+ // an amount that is no number cannot be computed with; it is refused, not treated as 0
125
+ const [firstInvalid] = findInvalidItemAmounts(accountingDoc.items);
126
+ if (firstInvalid) {
127
+ throw new EInvoiceFormatError(
128
+ `items[${firstInvalid.index}].${firstInvalid.field} is no number: ${String(firstInvalid.value)}`,
129
+ { unsupportedFeatures: [`items[${firstInvalid.index}].${firstInvalid.field}`] },
130
+ );
131
+ }
132
+ for (const item of accountingDoc.items ?? []) {
133
+ const lineNet = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
134
+ lineNetAmounts.push(lineNet);
135
+ lineVatCategories.push(category);
136
+ // grouped by VAT category and rate: the same rate under two categories is two groups
137
+ const key = `${category}|${item.vatPercentage}`;
138
+ const group = taxableByGroup.get(key) ?? { category, rate: item.vatPercentage, taxable: Decimal.ZERO };
139
+ group.taxable = group.taxable.add(lineNet);
140
+ taxableByGroup.set(key, group);
141
+ }
142
+ const vatGroups: IDocumentVatGroup[] = [...taxableByGroup.values()].map((group) => {
143
+ const exemption = getVatExemption(group.category, accountingDoc.language);
144
+ return {
145
+ category: group.category,
146
+ rate: group.rate,
147
+ taxableAmount: calculator.round(group.taxable),
148
+ // reverse charge states no tax: the recipient owes it (BR-AE-09)
149
+ taxAmount: group.category === 'AE' ? calculator.round(Decimal.ZERO) : calculator.calculateVAT(group.taxable, group.rate),
150
+ ...(exemption ? { exemption } : {}),
151
+ };
152
+ });
153
+ const lineTotal = calculator.round(Decimal.sum(lineNetAmounts));
154
+ const taxTotal = calculator.round(Decimal.sum(vatGroups.map((group) => group.taxAmount)));
155
+ const grandTotal = calculator.round(lineTotal.add(taxTotal));
156
+ return {
157
+ minorUnits,
158
+ lineNetAmounts,
159
+ lineVatCategories,
160
+ lineTotal,
161
+ taxBasisTotal: lineTotal,
162
+ vatGroups,
163
+ taxTotal,
164
+ grandTotal,
165
+ duePayable: grandTotal,
166
+ };
167
+ };
@@ -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
+ };
@@ -146,18 +146,9 @@ export class CodeListValidator {
146
146
  * Validate tax category codes (UNCL5305)
147
147
  */
148
148
  private validateTaxCategories(invoice: EInvoice): void {
149
- // Document level tax breakdown
150
- // Note: taxBreakdown is a computed property that doesn't have metadata
151
- // We would need to access the raw tax breakdown data from metadata if it exists
152
- invoice.taxBreakdown?.forEach((breakdown, index) => {
153
- // Since the computed taxBreakdown doesn't have metadata,
154
- // we'll skip the tax category code validation for now
155
- // This would need to be implemented differently to access the raw data
156
-
157
- // TODO: Access raw tax breakdown data with metadata from invoice.metadata.taxBreakdown
158
- // when that structure is implemented
159
- });
160
-
149
+ // The document level VAT breakdown is computed from the lines (`taxBreakdown`) and
150
+ // carries no category code of its own, so only the lines' codes are checked here.
151
+
161
152
  // Line level tax categories
162
153
  invoice.items?.forEach((item, index) => {
163
154
  // Cast to extended type to access metadata