@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
@@ -0,0 +1,144 @@
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
+
7
+ /** An item amount that is no finite number, so no total can be computed from it */
8
+ export interface IInvalidItemAmount {
9
+ index: number;
10
+ field: 'unitQuantity' | 'unitNetPrice' | 'vatPercentage';
11
+ value: unknown;
12
+ }
13
+
14
+ /**
15
+ * The item amounts of a document that are no finite number. The totals cannot
16
+ * be computed while there is one: the encoders and the total getters refuse
17
+ * the document, and a validator reports the lines and skips the rules that
18
+ * need the totals.
19
+ * @param items The document's items
20
+ */
21
+ export const findInvalidItemAmounts = (items: TAccountingDoc['items'] | undefined): IInvalidItemAmount[] => {
22
+ const invalid: IInvalidItemAmount[] = [];
23
+ for (const [index, item] of (items ?? []).entries()) {
24
+ for (const field of ['unitQuantity', 'unitNetPrice', 'vatPercentage'] as const) {
25
+ const value: unknown = item[field];
26
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
27
+ invalid.push({ index, field, value });
28
+ }
29
+ }
30
+ }
31
+ return invalid;
32
+ };
33
+
34
+ /**
35
+ * The result a validator adds when it skips the rules that need the totals,
36
+ * because a line amount is no number (the line rules report the lines)
37
+ * @param invalid The invalid item amounts
38
+ * @param source The validator's source
39
+ */
40
+ export const getTotalsSkippedResult = (invalid: IInvalidItemAmount[], source: string): ValidationResult => ({
41
+ ruleId: 'TOTALS-NOT-CHECKED',
42
+ source,
43
+ severity: 'info',
44
+ message: `The rules on totals and the VAT breakdown were not checked: ${invalid
45
+ .map((entry) => `items[${entry.index}].${entry.field} is no number`)
46
+ .join(', ')}`,
47
+ field: invalid.map((entry) => `items[${entry.index}].${entry.field}`).join(', '),
48
+ });
49
+
50
+ /** The most decimals an amount may have in EN 16931 (BR-DEC-09 to BR-DEC-23) */
51
+ export const EN16931_MAX_AMOUNT_DECIMALS = 2;
52
+
53
+ /**
54
+ * The calculator every document amount is computed with: rounding half up to
55
+ * the currency's minor unit, at most two decimals as EN 16931 allows. A
56
+ * currency without minor unit (JPY) keeps 0 decimals; one with three (BHD,
57
+ * KWD, ...) is rounded to two.
58
+ * @param currency The document currency
59
+ */
60
+ export const getDocumentCalculator = (currency: TAccountingDoc['currency']): DecimalCurrencyCalculator =>
61
+ new DecimalCurrencyCalculator(currency, 'HALF_UP', { maxDecimals: EN16931_MAX_AMOUNT_DECIMALS });
62
+
63
+ /**
64
+ * The VAT breakdown of one rate (BG-23): the taxable amount (BT-116), the tax
65
+ * on it (BT-117) and the rate (BT-119).
66
+ */
67
+ export interface IDocumentVatGroup {
68
+ rate: number;
69
+ taxableAmount: Decimal;
70
+ taxAmount: Decimal;
71
+ }
72
+
73
+ /**
74
+ * The totals of a document as EN 16931 computes them, in decimal arithmetic.
75
+ * Every amount is rounded to the minor unit of the currency; all encoders
76
+ * write these values, so the XML is consistent in every syntax.
77
+ */
78
+ export interface IDocumentTotals {
79
+ /** decimals the amounts are rounded to and written with: the currency's minor unit, at most two */
80
+ minorUnits: number;
81
+ /** the Invoice line net amount (BT-131) of each item, in item order: quantity × net price, rounded */
82
+ lineNetAmounts: Decimal[];
83
+ /** Sum of Invoice line net amount (BT-106) */
84
+ lineTotal: Decimal;
85
+ /** Invoice total amount without VAT (BT-109): BT-106, as the envelope has no document level allowances or charges */
86
+ taxBasisTotal: Decimal;
87
+ /** VAT breakdown (BG-23), in the order the rates first appear */
88
+ vatGroups: IDocumentVatGroup[];
89
+ /** Invoice total VAT amount (BT-110): the sum of the rounded VAT category tax amounts */
90
+ taxTotal: Decimal;
91
+ /** Invoice total amount with VAT (BT-112): BT-109 + BT-110 */
92
+ grandTotal: Decimal;
93
+ /** Amount due for payment (BT-115): BT-112, as the envelope states no paid amount yet */
94
+ duePayable: Decimal;
95
+ }
96
+
97
+ /**
98
+ * Computes the totals of a document the way the EN 16931 business rules check
99
+ * them, every amount rounded half up to the currency's minor unit, at most two
100
+ * decimals (BR-DEC-*): each line net amount (BT-131) is quantity × net price
101
+ * rounded; the sum of line net amounts (BT-106) is their sum (BR-CO-10);
102
+ * each VAT category taxable amount (BT-116) is the sum of the line net amounts
103
+ * at that rate, and its tax amount (BT-117) is BT-116 × rate / 100 rounded
104
+ * (BR-CO-17); the total VAT (BT-110) is the sum of the category tax amounts
105
+ * (BR-CO-14); the total with VAT (BT-112) is BT-109 + BT-110 (BR-CO-15).
106
+ * @param accountingDoc The document
107
+ */
108
+ export const computeDocumentTotals = (accountingDoc: Pick<TAccountingDoc, 'currency' | 'items'>): IDocumentTotals => {
109
+ const calculator = getDocumentCalculator(accountingDoc.currency);
110
+ const minorUnits = calculator.getCurrencyInfo().minorUnits;
111
+ const lineNetAmounts: Decimal[] = [];
112
+ const taxableByRate = new Map<number, Decimal>();
113
+ // an amount that is no number cannot be computed with; it is refused, not treated as 0
114
+ const [firstInvalid] = findInvalidItemAmounts(accountingDoc.items);
115
+ if (firstInvalid) {
116
+ throw new EInvoiceFormatError(
117
+ `items[${firstInvalid.index}].${firstInvalid.field} is no number: ${String(firstInvalid.value)}`,
118
+ { unsupportedFeatures: [`items[${firstInvalid.index}].${firstInvalid.field}`] },
119
+ );
120
+ }
121
+ for (const item of accountingDoc.items ?? []) {
122
+ const lineNet = calculator.calculateLineNet(item.unitQuantity, item.unitNetPrice);
123
+ lineNetAmounts.push(lineNet);
124
+ taxableByRate.set(item.vatPercentage, (taxableByRate.get(item.vatPercentage) ?? Decimal.ZERO).add(lineNet));
125
+ }
126
+ const vatGroups: IDocumentVatGroup[] = [...taxableByRate.entries()].map(([rate, taxableAmount]) => ({
127
+ rate,
128
+ taxableAmount: calculator.round(taxableAmount),
129
+ taxAmount: calculator.calculateVAT(taxableAmount, rate),
130
+ }));
131
+ const lineTotal = calculator.round(Decimal.sum(lineNetAmounts));
132
+ const taxTotal = calculator.round(Decimal.sum(vatGroups.map((group) => group.taxAmount)));
133
+ const grandTotal = calculator.round(lineTotal.add(taxTotal));
134
+ return {
135
+ minorUnits,
136
+ lineNetAmounts,
137
+ lineTotal,
138
+ taxBasisTotal: lineTotal,
139
+ vatGroups,
140
+ taxTotal,
141
+ grandTotal,
142
+ duePayable: grandTotal,
143
+ };
144
+ };
@@ -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
@@ -4,6 +4,12 @@ import type { EInvoice } from '../../einvoice.js';
4
4
  import { CurrencyCalculator, areMonetaryValuesEqual } from '../utils/currency.utils.js';
5
5
  import { DecimalCurrencyCalculator } from '../utils/currency.calculator.decimal.js';
6
6
  import { Decimal } from '../utils/decimal.js';
7
+ import {
8
+ computeDocumentTotals,
9
+ findInvalidItemAmounts,
10
+ getDocumentCalculator,
11
+ getTotalsSkippedResult,
12
+ } from '../utils/document.totals.js';
7
13
  import type { ValidationResult, ValidationOptions } from './validation.types.js';
8
14
 
9
15
  /**
@@ -24,24 +30,34 @@ export class EN16931BusinessRulesValidator {
24
30
  // Initialize currency calculators if currency is available
25
31
  if (invoice.currency) {
26
32
  this.currencyCalculator = new CurrencyCalculator(invoice.currency);
27
- this.decimalCalculator = new DecimalCurrencyCalculator(invoice.currency);
33
+ // the rounding the encoders use: the currency's minor unit, at most two decimals
34
+ this.decimalCalculator = getDocumentCalculator(invoice.currency);
28
35
  }
29
36
 
30
37
  // Document level rules (BR-01 to BR-65)
31
38
  this.validateDocumentRules(invoice);
32
39
 
33
- // Calculation rules (BR-CO-*)
34
- if (options.checkCalculations !== false) {
35
- this.validateCalculationRules(invoice);
36
- }
37
-
38
- // VAT rules (BR-S-*, BR-Z-*, BR-E-*, BR-AE-*, BR-IC-*, BR-G-*, BR-O-*)
39
- if (options.checkVAT !== false) {
40
- this.validateVATRules(invoice);
41
- }
42
-
43
- // Line level rules (BR-21 to BR-30)
40
+ // Line level rules (BR-21 to BR-30), first: they report a line amount that is no number
44
41
  this.validateLineRules(invoice);
42
+
43
+ // The totals are computed from the line amounts; while one is no number they cannot be,
44
+ // so the rules that need them are skipped and the result says so
45
+ const invalidAmounts = findInvalidItemAmounts(invoice.items);
46
+ if (invalidAmounts.length > 0) {
47
+ if (options.checkCalculations !== false || options.checkVAT !== false) {
48
+ this.results.push(getTotalsSkippedResult(invalidAmounts, 'EN16931'));
49
+ }
50
+ } else {
51
+ // Calculation rules (BR-CO-*)
52
+ if (options.checkCalculations !== false) {
53
+ this.validateCalculationRules(invoice);
54
+ }
55
+
56
+ // VAT rules (BR-S-*, BR-Z-*, BR-E-*, BR-AE-*, BR-IC-*, BR-G-*, BR-O-*)
57
+ if (options.checkVAT !== false) {
58
+ this.validateVATRules(invoice);
59
+ }
60
+ }
45
61
 
46
62
  // Allowances and charges rules
47
63
  if (options.checkAllowances !== false) {
@@ -292,18 +308,19 @@ export class EN16931BusinessRulesValidator {
292
308
  // BR-S-03: VAT category tax amount for standard rated
293
309
  vatGroups.forEach((group, rate) => {
294
310
  if (rate > 0) { // Standard rated
311
+ // the category amounts as EN 16931 computes them: the sum of the rounded line net
312
+ // amounts at the rate, and the tax on it rounded once (BR-S-08, BR-CO-17)
313
+ const documentGroup = useDecimal
314
+ ? computeDocumentTotals(invoice).vatGroups.find((groupArg) => groupArg.rate === rate)
315
+ : undefined;
295
316
  const expectedTaxableAmount = useDecimal
296
- ? group.reduce((sum, item) => {
297
- const unitPrice = new Decimal(item.unitNetPrice);
298
- const quantity = new Decimal(item.unitQuantity);
299
- return sum.add(unitPrice.multiply(quantity));
300
- }, Decimal.ZERO)
317
+ ? documentGroup?.taxableAmount ?? Decimal.ZERO
301
318
  : group.reduce((sum, item) =>
302
319
  sum + (item.unitNetPrice * item.unitQuantity), 0
303
320
  );
304
321
 
305
322
  const expectedTaxAmount = useDecimal
306
- ? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
323
+ ? documentGroup?.taxAmount ?? Decimal.ZERO
307
324
  : (expectedTaxableAmount as number) * (rate / 100);
308
325
 
309
326
  // Find corresponding breakdown
@@ -408,9 +425,10 @@ export class EN16931BusinessRulesValidator {
408
425
  );
409
426
  }
410
427
 
411
- // BR-24: Each Invoice line shall have an Invoice line net amount
412
- const lineNetAmount = item.unitNetPrice * item.unitQuantity;
413
- if (isNaN(lineNetAmount)) {
428
+ // BR-24: Each Invoice line shall have an Invoice line net amount; it is quantity × net
429
+ // price, so a quantity or net price that is no number leaves the line without one
430
+ const isAmount = (value: unknown) => typeof value === 'number' && Number.isFinite(value);
431
+ if (!isAmount(item.unitNetPrice) || !isAmount(item.unitQuantity)) {
414
432
  this.addError(
415
433
  'BR-24',
416
434
  `Invoice line ${index + 1} must have a valid net amount`,
@@ -418,8 +436,9 @@ export class EN16931BusinessRulesValidator {
418
436
  );
419
437
  }
420
438
 
421
- // BR-CO-04: Each Invoice line shall be categorized with an Invoiced item VAT category code
422
- if (item.vatPercentage === undefined) {
439
+ // BR-CO-04: Each Invoice line shall be categorized with an Invoiced item VAT category code;
440
+ // the envelope derives the category from the rate, which has to be a number
441
+ if (!isAmount(item.vatPercentage)) {
423
442
  this.addError(
424
443
  'BR-CO-04',
425
444
  `Invoice line ${index + 1} must have a VAT category code`,
@@ -579,34 +598,8 @@ export class EN16931BusinessRulesValidator {
579
598
  * Calculate total VAT using decimal arithmetic
580
599
  */
581
600
  private calculateTotalVATDecimal(invoice: EInvoice): Decimal {
582
- let totalVAT = Decimal.ZERO;
583
-
584
- // Group items by VAT rate
585
- const vatGroups = new Map<string, Decimal>();
586
-
587
- for (const item of invoice.items || []) {
588
- const vatRate = item.vatPercentage || 0;
589
- const rateKey = vatRate.toString();
590
-
591
- const unitPrice = new Decimal(item.unitNetPrice || 0);
592
- const quantity = new Decimal(item.unitQuantity || 0);
593
- const lineNet = unitPrice.multiply(quantity);
594
-
595
- if (vatGroups.has(rateKey)) {
596
- vatGroups.set(rateKey, vatGroups.get(rateKey)!.add(lineNet));
597
- } else {
598
- vatGroups.set(rateKey, lineNet);
599
- }
600
- }
601
-
602
- // Calculate VAT for each group
603
- for (const [rateKey, baseAmount] of vatGroups) {
604
- const rate = new Decimal(rateKey);
605
- const vat = this.decimalCalculator!.calculateVAT(baseAmount, rate);
606
- totalVAT = totalVAT.add(vat);
607
- }
608
-
609
- return totalVAT;
601
+ // the sum of the VAT category tax amounts, each rounded once (BR-CO-14, BR-CO-17)
602
+ return computeDocumentTotals(invoice).taxTotal;
610
603
  }
611
604
 
612
605
  private calculateDocumentAllowances(invoice: EInvoice): number {
@@ -3,6 +3,7 @@
3
3
  * Implements validation for MINIMUM, BASIC, EN16931, and EXTENDED profiles
4
4
  */
5
5
 
6
+ import { findInvalidItemAmounts, getTotalsSkippedResult } from '../utils/document.totals.js';
6
7
  import type { ValidationResult } from './validation.types.js';
7
8
  import type { EInvoice } from '../../einvoice.js';
8
9
 
@@ -211,9 +212,19 @@ export class FacturXValidator {
211
212
  return results;
212
213
  }
213
214
 
215
+ // The totals are computed from the line amounts; while one is no number the checks on
216
+ // totals are skipped and the result says so
217
+ const invalidAmounts = findInvalidItemAmounts(invoice.items);
218
+ const totalsComputable = invalidAmounts.length === 0;
219
+ if (!totalsComputable) {
220
+ results.push(getTotalsSkippedResult(invalidAmounts, 'FACTURX'));
221
+ }
222
+
214
223
  // Validate according to profile
215
- results.push(...this.validateProfileRequirements(invoice, detectedProfile));
216
- results.push(...this.validateProfileSpecificRules(invoice, detectedProfile));
224
+ results.push(...this.validateProfileRequirements(invoice, detectedProfile, totalsComputable));
225
+ if (totalsComputable) {
226
+ results.push(...this.validateProfileSpecificRules(invoice, detectedProfile));
227
+ }
217
228
 
218
229
  // Add profile-specific business rules
219
230
  if (detectedProfile === FacturXProfile.MINIMUM) {
@@ -267,12 +278,16 @@ export class FacturXValidator {
267
278
  /**
268
279
  * Validate field requirements for a specific profile
269
280
  */
270
- private validateProfileRequirements(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
281
+ private validateProfileRequirements(invoice: EInvoice, profile: FacturXProfile, totalsComputable = true): ValidationResult[] {
271
282
  const results: ValidationResult[] = [];
272
283
  const requirements = this.profileRequirements[profile];
284
+ const totalFields = ['totalInvoiceAmount', 'totalNetAmount', 'totalVatAmount'];
273
285
 
274
- // Check mandatory fields
286
+ // Check mandatory fields; the totals only when they can be computed
275
287
  for (const field of requirements.mandatory) {
288
+ if (!totalsComputable && totalFields.includes(field)) {
289
+ continue;
290
+ }
276
291
  const value = this.getFieldValue(invoice, field);
277
292
  if (value === undefined || value === null || value === '') {
278
293
  results.push({
@@ -1,4 +1,5 @@
1
1
  import * as plugins from '../../plugins.js';
2
+ import { findInvalidItemAmounts, getTotalsSkippedResult } from '../utils/document.totals.js';
2
3
  import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
3
4
  import type { EInvoice } from '../../einvoice.js';
4
5
  import { CurrencyCalculator } from '../utils/currency.utils.js';
@@ -50,6 +51,14 @@ export class VATCategoriesValidator {
50
51
  this.currencyCalculator = new CurrencyCalculator(invoice.currency);
51
52
  }
52
53
 
54
+ // The VAT breakdown is computed from the line amounts; while one is no number it cannot
55
+ // be, so these rules are skipped and the result says so (the line rules report the lines)
56
+ const invalidAmounts = findInvalidItemAmounts(invoice.items);
57
+ if (invalidAmounts.length > 0) {
58
+ this.results.push(getTotalsSkippedResult(invalidAmounts, 'EN16931'));
59
+ return this.results;
60
+ }
61
+
53
62
  // Group items by VAT category
54
63
  const itemsByCategory = this.groupItemsByVATCategory(invoice.items || []);
55
64
  const breakdownsByCategory = this.groupBreakdownsByCategory(invoice.taxBreakdown || []);