From 06c564b7a8e6e3fd87b83f0bdf1832897b8f8b88 Mon Sep 17 00:00:00 2001 From: SimonLoir Date: Tue, 10 Mar 2026 22:04:07 +0100 Subject: [PATCH 1/3] feat: add `computeTotals` utility and comprehensive tests for Peppol EN16931 compliance - Introduced `computeTotals` in the toolkit to compute base amounts, tax amounts, and totals for invoices following EN16931 rules. - Added detailed unit tests to validate calculations for various scenarios, including rounding behavior, group tax computation, and handling empty or mixed data types for prices. --- src/helpers/computeTotals.ts | 46 ++++++++++++++ src/index.ts | 7 ++- tests/compute-totals.test.ts | 119 +++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/helpers/computeTotals.ts create mode 100644 tests/compute-totals.test.ts diff --git a/src/helpers/computeTotals.ts b/src/helpers/computeTotals.ts new file mode 100644 index 0000000..5aae951 --- /dev/null +++ b/src/helpers/computeTotals.ts @@ -0,0 +1,46 @@ +import Decimal from 'decimal.js'; + +export type UBLLineItem = { + price: number | Decimal | string; + quantity: number | Decimal | string; + taxPercent: number | Decimal | string; +}; + +/** + * Compute the totals of a list of items based on their base price and invoicedQuantity following EN16931 rules + * @param items + */ +export const computeTotals = (items: UBLLineItem[]) => { + const lines = items.map((item) => + new Decimal(item.price) + .mul(new Decimal(item.quantity)) + .toDecimalPlaces(2, Decimal.ROUND_HALF_UP) + ); + + const baseAmount = lines.reduce((acc, amt) => acc.add(amt), new Decimal(0)); + const baseAmountPerRate = new Map(); + const taxableAmountPerRate = new Map(); + items.forEach((item, i) => { + const lineAmt = lines[i] ?? new Decimal(0); + const rate = item.taxPercent.toString(); + const existing = baseAmountPerRate.get(rate) ?? new Decimal(0); + baseAmountPerRate.set(rate, existing.add(lineAmt)); + }); + + let taxAmount = new Decimal(0); + for (const [rate, taxableAmount] of baseAmountPerRate) { + const groupTax = taxableAmount + .mul(rate) + .div(100) + .toDecimalPlaces(2, Decimal.ROUND_HALF_UP); + taxableAmountPerRate.set(rate, taxableAmount); + taxAmount = taxAmount.add(groupTax); + } + + return { + baseAmount, + taxAmount, + taxableAmountPerRate, + totalAmount: baseAmount.add(taxAmount), + }; +}; diff --git a/src/index.ts b/src/index.ts index 1cbe79b..6dc2847 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,8 @@ import { DocumentBuilder } from './builder'; import { DocumentParser } from './parser'; -import { CreditNote } from './documents/invoices/CreditNote'; -import { Invoice } from './documents/invoices/Invoice'; +import { CreditNote } from './documents'; +import { Invoice } from './documents'; +import { computeTotals } from './helpers/computeTotals'; export class PeppolToolkit { private __builder = new DocumentBuilder(); @@ -22,6 +23,8 @@ export class PeppolToolkit { public peppolUBLToCreditNote(xml: string): CreditNote { return this.__parser.parseCreditNote(xml); } + + public static computeTotals = computeTotals; } export function createToolkit() { diff --git a/tests/compute-totals.test.ts b/tests/compute-totals.test.ts new file mode 100644 index 0000000..81085e7 --- /dev/null +++ b/tests/compute-totals.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { PeppolToolkit } from '../src'; + +const computePeppolTotals = PeppolToolkit.computeTotals; + +describe('computePeppolTotals', () => { + describe('single item', () => { + it('computes correct totals for a simple item', () => { + const { baseAmount, taxAmount, totalAmount } = computePeppolTotals([ + { price: 10, quantity: 1, taxPercent: 21 }, + ]); + expect(baseAmount.toString()).toBe('10'); + expect(taxAmount.toString()).toBe('2.1'); + expect(totalAmount.toString()).toBe('12.1'); + }); + + it('rounds the line extension amount to 2 decimal places (ROUND_HALF_UP)', () => { + const { baseAmount } = computePeppolTotals([ + { price: '1.115', quantity: 1, taxPercent: 0 }, + ]); + expect(baseAmount.toString()).toBe('1.12'); + }); + + it('rounds the tax amount to 2 decimal places (ROUND_HALF_UP)', () => { + const { taxAmount } = computePeppolTotals([ + { price: '1.11', quantity: 1, taxPercent: 21 }, + ]); + expect(taxAmount.toString()).toBe('0.23'); + }); + + it('handles zero VAT rate', () => { + const { baseAmount, taxAmount, totalAmount } = computePeppolTotals([ + { price: '50.00', quantity: 2, taxPercent: 0 }, + ]); + expect(baseAmount.toString()).toBe('100'); + expect(taxAmount.toString()).toBe('0'); + expect(totalAmount.toString()).toBe('100'); + }); + }); + + describe('multiple items with the same VAT rate', () => { + it('groups items by VAT rate and rounds tax per group, not per line', () => { + const { taxAmount, baseAmount, totalAmount } = computePeppolTotals([ + { price: '1.11', quantity: 1, taxPercent: 21 }, + { price: '1.11', quantity: 1, taxPercent: 21 }, + ]); + expect(baseAmount.toString()).toBe('2.22'); + expect(taxAmount.toString()).toBe('0.47'); + expect(totalAmount.toString()).toBe('2.69'); + }); + }); + + describe('multiple items with different VAT rates', () => { + it('computes tax per VAT-rate group independently', () => { + const { baseAmount, taxAmount, totalAmount } = computePeppolTotals([ + { price: '5.00', quantity: 1, taxPercent: 6 }, + { price: '10.00', quantity: 1, taxPercent: 21 }, + ]); + expect(baseAmount.toString()).toBe('15'); + expect(taxAmount.toString()).toBe('2.4'); + expect(totalAmount.toString()).toBe('17.4'); + }); + + it('produces Peppol-correct totals that may differ from naive per-line summation', () => { + // item1: 1.01 at 6% -> per-line tax = 0.0606 + // item2: 1.07 at 21% -> per-line tax = 0.2247 + // Naive sum (no rounding): 0.0606 + 0.2247 = 0.2853 -> rounds to 0.29 + // Peppol (per-group): + // group 6%: base=1.01, tax = round(0.0606, 2) = 0.06 + // group 21%: base=1.07, tax = round(0.2247, 2) = 0.22 + // total tax = 0.06 + 0.22 = 0.28 <-- differs from naive 0.29 + const { baseAmount, taxAmount, totalAmount } = computePeppolTotals([ + { price: '1.01', quantity: 1, taxPercent: 6 }, + { price: '1.07', quantity: 1, taxPercent: 21 }, + ]); + expect(baseAmount.toString()).toBe('2.08'); + expect(taxAmount.toString()).toBe('0.28'); + expect(totalAmount.toString()).toBe('2.36'); + }); + }); + + describe('invoicedQuantity handling', () => { + it('multiplies price by invoicedQuantity before rounding', () => { + // 3 units at 10.00 -> lineAmount = 30.00 + const { baseAmount, taxAmount } = computePeppolTotals([ + { price: '10.00', quantity: 3, taxPercent: 21 }, + ]); + expect(baseAmount.toString()).toBe('30'); + expect(taxAmount.toString()).toBe('6.3'); + }); + }); + + describe('string and number price', () => { + it('accepts string price', () => { + const { baseAmount } = computePeppolTotals([ + { price: '9.99', quantity: 1, taxPercent: 0 }, + ]); + expect(baseAmount.toString()).toBe('9.99'); + }); + + it('accepts number price', () => { + const { baseAmount } = computePeppolTotals([ + { price: 9.99, quantity: 1, taxPercent: 0 }, + ]); + expect(baseAmount.toString()).toBe('9.99'); + }); + }); + + describe('empty items', () => { + it('returns zero totals for empty items array', () => { + const { baseAmount, taxAmount, totalAmount } = computePeppolTotals( + [] + ); + expect(baseAmount.toString()).toBe('0'); + expect(taxAmount.toString()).toBe('0'); + expect(totalAmount.toString()).toBe('0'); + }); + }); +}); From 0da306f6e4f2f357475be623e799c25bc7a6d235 Mon Sep 17 00:00:00 2001 From: SimonLoir Date: Tue, 10 Mar 2026 22:22:07 +0100 Subject: [PATCH 2/3] feat: add `getEASFromTaxId` utility and test coverage - Introduced `getEASFromTaxId` function to map tax IDs to corresponding EAS codes based on country. - Added `CountryVATScheme` with predefined EAS codes for various countries. - Exported `getEASFromTaxId` through the toolkit. - Implemented comprehensive tests for different tax ID formats and edge cases. --- src/documents/common/EASCodes.ts | 55 ++++++++++++++++++++++++++++++++ src/index.ts | 3 +- tests/eas-codes.test.ts | 49 +++++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/documents/common/EASCodes.ts b/src/documents/common/EASCodes.ts index 4e0e2ff..a9a6585 100644 --- a/src/documents/common/EASCodes.ts +++ b/src/documents/common/EASCodes.ts @@ -119,6 +119,61 @@ export function isValidEASCode(code: string): code is EASCode { return code in EASCodes; } +/** + * Mapping of country codes to their default VAT-related EAS codes + * @see https://docs.peppol.eu/poacc/billing/3.0/codelist/eas/ + */ +const CountryVATScheme: Record = { + AD: '9922', // Andorra VAT number + AL: '9923', // Albania VAT number + AT: '9914', // Österreichische Umsatzsteuer-Identifikationsnummer + BA: '9924', // Bosnia and Herzegovina VAT number + BE: '9925', // Belgium VAT number + BG: '9926', // Bulgaria VAT number + CH: '9927', // Switzerland VAT number + CY: '9928', // Cyprus VAT number + CZ: '9929', // Czech Republic VAT number + DE: '9930', // Germany VAT number + EE: '9931', // Estonia VAT number + ES: '9920', // Agencia Española de Administración Tributaria + FI: '0213', // Finnish Organization Value Add Tax Identifier + FR: '9957', // French VAT number + GB: '9932', // United Kingdom VAT number + GR: '9933', // Greece VAT number + HR: '9934', // Croatia VAT number + HU: '9910', // Hungary VAT number + IE: '9935', // Ireland VAT number + IT: '0211', // PARTITA IVA + LI: '9936', // Liechtenstein VAT number + LT: '9937', // Lithuania VAT number + LU: '9938', // Luxemburg VAT number + LV: '9939', // Latvia VAT number + MC: '9940', // Monaco VAT number + ME: '9941', // Montenegro VAT number + MK: '9942', // Macedonia, the former Yugoslav Republic of VAT number + MT: '9943', // Malta VAT number + NL: '9944', // Netherlands VAT number + PL: '9945', // Poland VAT number + PT: '9946', // Portugal VAT number + RO: '9947', // Romania VAT number + RS: '9948', // Serbia VAT number + SI: '9949', // Slovenia VAT number + SK: '9950', // Slovakia VAT number + SM: '9951', // San Marino VAT number + TR: '9952', // Turkey VAT number + VA: '9953', // Holy See (Vatican City State) VAT number +}; + +/** + * Get the EAS code for a given tax ID + * @param taxId The tax ID to get the EAS code for (e.g. "BE0123456789") + * @returns The EAS code for the tax ID, or undefined if not found + */ +export function getEASFromTaxId(taxId: string): EASCode | undefined { + const countryCode = taxId.substring(0, 2).toUpperCase(); + return CountryVATScheme[countryCode]; +} + // Zod schema for EAS codes const easCodeKeys = Object.keys(EASCodes) as unknown as [EASCode, ...EASCode[]]; export const EASCodeSchema = z.enum(easCodeKeys); diff --git a/src/index.ts b/src/index.ts index 6dc2847..bf1d7f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import { DocumentBuilder } from './builder'; import { DocumentParser } from './parser'; -import { CreditNote } from './documents'; +import { CreditNote, getEASFromTaxId } from './documents'; import { Invoice } from './documents'; import { computeTotals } from './helpers/computeTotals'; @@ -25,6 +25,7 @@ export class PeppolToolkit { } public static computeTotals = computeTotals; + public static getEASFromTaxId = getEASFromTaxId; } export function createToolkit() { diff --git a/tests/eas-codes.test.ts b/tests/eas-codes.test.ts index bba22bd..fc3e652 100644 --- a/tests/eas-codes.test.ts +++ b/tests/eas-codes.test.ts @@ -3,8 +3,9 @@ import { EASCodeSchema, getEASDescription, isValidEASCode, + getEASFromTaxId, EASCodes, -} from '../src/documents/common/EASCodes'; +} from '../src'; describe('EAS Codes', () => { describe('EASCodeSchema', () => { @@ -156,4 +157,50 @@ describe('EAS Codes', () => { }); }); }); + + describe('getEASFromTaxId function', () => { + it('should return correct EAS code for Belgian VAT number', () => { + expect(getEASFromTaxId('BE0123456789')).toBe('9925'); + }); + + it('should return correct EAS code for French VAT number', () => { + expect(getEASFromTaxId('FR12345678901')).toBe('9957'); + }); + + it('should return correct EAS code for Italian VAT number', () => { + expect(getEASFromTaxId('IT12345678901')).toBe('0211'); + }); + + it('should return correct EAS code for Finnish VAT number', () => { + expect(getEASFromTaxId('FI12345678')).toBe('0213'); + }); + + it('should return correct EAS code for Spanish VAT number', () => { + expect(getEASFromTaxId('ESB12345678')).toBe('9920'); + }); + + it('should handle lowercase country codes', () => { + expect(getEASFromTaxId('be0123456789')).toBe('9925'); + }); + + it('should return undefined for unknown country codes', () => { + expect(getEASFromTaxId('XX123456789')).toBeUndefined(); + }); + + it('should return undefined for short strings', () => { + expect(getEASFromTaxId('A')).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(getEASFromTaxId('')).toBeUndefined(); + }); + + it('should return correct EAS code for German VAT number', () => { + expect(getEASFromTaxId('DE123456789')).toBe('9930'); + }); + + it('should return correct EAS code for Dutch VAT number', () => { + expect(getEASFromTaxId('NL123456789B01')).toBe('9944'); + }); + }); }); From 48b4c1f489617252963d2fffeb82570dfd19ad4a Mon Sep 17 00:00:00 2001 From: SimonLoir Date: Tue, 10 Mar 2026 22:22:36 +0100 Subject: [PATCH 3/3] chore: bump version to `0.5.0` --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e912f6..907e552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pixeldrive/peppol-toolkit", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pixeldrive/peppol-toolkit", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "decimal.js": "^10.6.0", diff --git a/package.json b/package.json index 260177d..3621e4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pixeldrive/peppol-toolkit", - "version": "0.4.1", + "version": "0.5.0", "description": "A TypeScript toolkit for building and reading peppol UBL documents", "keywords": [ "typescript",