Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
55 changes: 55 additions & 0 deletions src/documents/common/EASCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, EASCode> = {
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);
46 changes: 46 additions & 0 deletions src/helpers/computeTotals.ts
Original file line number Diff line number Diff line change
@@ -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<string, Decimal>();
const taxableAmountPerRate = new Map<string, Decimal>();
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),
};
};
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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, getEASFromTaxId } from './documents';
import { Invoice } from './documents';

Check failure on line 4 in src/index.ts

View workflow job for this annotation

GitHub Actions / lint

'./documents' import is duplicated
import { computeTotals } from './helpers/computeTotals';

export class PeppolToolkit {
private __builder = new DocumentBuilder();
Expand All @@ -22,6 +23,9 @@
public peppolUBLToCreditNote(xml: string): CreditNote {
return this.__parser.parseCreditNote(xml);
}

public static computeTotals = computeTotals;
public static getEASFromTaxId = getEASFromTaxId;
}

export function createToolkit() {
Expand Down
119 changes: 119 additions & 0 deletions tests/compute-totals.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
49 changes: 48 additions & 1 deletion tests/eas-codes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import {
EASCodeSchema,
getEASDescription,
isValidEASCode,
getEASFromTaxId,
EASCodes,
} from '../src/documents/common/EASCodes';
} from '../src';

describe('EAS Codes', () => {
describe('EASCodeSchema', () => {
Expand Down Expand Up @@ -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');
});
});
});
Loading