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
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ PEPPOL (Pan-European Public Procurement On-Line) is a set of specifications that
## Features

- 🚀 Generate PEPPOL-compliant UBL XML invoices
- ✅ Validate UBL XML against a KoSIT validator service
- 📦 ESM and CommonJS builds for broad compatibility
- 🔷 Written in TypeScript with bundled type definitions
- 🧪 Built with fast-xml-parser for reliable XML generation
Expand Down Expand Up @@ -307,6 +308,31 @@ const creditNoteXml = toolkit.creditNoteToPeppolUBL(exampleCreditNote);
console.log(creditNoteXml);
```

### Validate UBL XML with KoSIT

You can validate generated (or external) UBL XML through a running KoSIT validator service.

```typescript
import { PeppolToolkit, exampleInvoice } from '@pixeldrive/peppol-toolkit';

const toolkit = new PeppolToolkit();
const xml = toolkit.invoiceToPeppolUBL(exampleInvoice);

const result = await toolkit.validateWithKosit(xml, {
endpoint: 'http://localhost:8081/',
});

console.log(result.valid); // true | false
console.log(result.errors); // validation errors
console.log(result.warnings); // validation warnings
```

Notes:

- Default endpoint is `http://localhost:8081/`.
- Requests are sent as `POST` with `Content-Type: application/xml`.
- Some KoSIT deployments can return HTTP `406` together with an XML report for invalid documents; this toolkit still parses that report and returns structured `errors`/`warnings`.

## API Reference

### `PeppolToolkit`
Expand All @@ -321,6 +347,7 @@ The main class that provides invoice and credit note conversion functionality.
| `creditNoteToPeppolUBL(creditNote: CreditNote): string` | Converts a `CreditNote` object to a PEPPOL-compliant UBL XML string |
| `peppolUBLToInvoice(xml: string): Invoice` | Parses a PEPPOL UBL XML string into an `Invoice` object |
| `peppolUBLToCreditNote(xml: string): CreditNote` | Parses a PEPPOL UBL XML string into a `CreditNote` object |
| `validateWithKosit(xml: string, options?: KositValidatorOptions): Promise<KositValidationResult>` | Sends XML to a KoSIT validator service and returns parsed validation outcome |

#### Static Helpers

Expand All @@ -339,6 +366,15 @@ import { createToolkit } from '@pixeldrive/peppol-toolkit';
const toolkit = createToolkit();
```

### `KositValidator` and Related Types

Also exported for direct use:

- `KositValidator`
- `KositValidatorOptions`
- `KositValidationResult`
- `KositValidationMessage`

## PEPPOL BIS UBL Invoice Elements Checklist

This toolkit supports the following PEPPOL BIS UBL invoice elements based on the current implementation:
Expand Down Expand Up @@ -456,13 +492,13 @@ Starting from version 1.0.0, this library will follow [Semantic Versioning (SemV
- [x] Support CreditNote documents
- [ ] Implement UBL 2.1 schema validation (offline)
- [ ] Implement PEPPOL BIS profile validation (offline)
- [ ] Enable online validation against remote services
- [x] Enable online validation against remote services
- [ ] Support attachments/binary objects embedding (e.g., PDF)
- [ ] CLI: Convert JSON invoices to UBL XML
- [x] Documentation: Examples and recipe-style guides
- [ ] QA: Expand unit tests

Last updated: 2026-03-10
Last updated: 2026-03-31

## Development Scripts

Expand Down
13 changes: 11 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.5.2",
"version": "0.6.0",
"description": "A TypeScript toolkit for building and reading peppol UBL documents",
"keywords": [
"typescript",
Expand Down
23 changes: 23 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@ import { DocumentBuilder } from './builder';
import { DocumentParser } from './parser';
import { CreditNote, getEASFromTaxId, Invoice } from './documents';
import { computeTotals } from './helpers/computeTotals';
import {
KositValidator,
KositValidatorOptions,
KositValidationResult,
} from './validator';

export class PeppolToolkit {
private __builder = new DocumentBuilder();
private __parser = new DocumentParser();
private __kositValidator: KositValidator | null = null;

public invoiceToPeppolUBL(invoice: Invoice): string {
return this.__builder.generatePeppolInvoice(invoice);
Expand All @@ -23,6 +29,22 @@ export class PeppolToolkit {
return this.__parser.parseCreditNote(xml);
}

/**
* Validates a UBL XML document using the Kosit validator service.
* @param xml The XML document to validate.
* @param options Optional configuration for the validator endpoint.
* @returns The validation result including validity status, errors, and warnings.
*/
public async validateWithKosit(
xml: string,
options?: KositValidatorOptions
): Promise<KositValidationResult> {
const validator = options
? new KositValidator(options)
: (this.__kositValidator ??= new KositValidator());
return await validator.validate(xml);
}

public static computeTotals = computeTotals;
public static getEASFromTaxId = getEASFromTaxId;
}
Expand All @@ -40,5 +62,6 @@ export default {
export * from './documents';
export * from './builder';
export * from './parser';
export * from './validator';
export { basicInvoice as exampleInvoice } from './data/basic-invoice';
export { basicCreditNote as exampleCreditNote } from './data/basic-creditNote';
160 changes: 160 additions & 0 deletions src/validator/KositValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { XMLParser } from 'fast-xml-parser';

export interface KositValidatorOptions {
/**
* The URL of the Kosit validator service.
* @default 'http://localhost:8081/'
*/
endpoint?: string;
}

export interface KositValidationMessage {
id: string;
text: string;
location: string;
}

export interface KositValidationResult {
valid: boolean;
errors: KositValidationMessage[];
warnings: KositValidationMessage[];
rawXml: string;
}

export class KositValidator {
private readonly endpoint: string;
private readonly parser: XMLParser;

constructor(options?: KositValidatorOptions) {
this.endpoint = options?.endpoint ?? 'http://localhost:8081/';
this.parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '$',
parseTagValue: false,
removeNSPrefix: true,
});
}

/**
* Validates a UBL XML document against the Kosit validator service.
* @param xml The XML document to validate.
* @returns The validation result including validity status, errors, and warnings.
*/
public async validate(xml: string): Promise<KositValidationResult> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/xml',
},
body: xml,
});

if (!response.ok && response.status !== 406) {
throw new Error(
`Kosit validator returned HTTP ${response.status}: ${response.statusText}`
);
}

const rawXml = await response.text();
return this.parseReport(rawXml);
}

private parseReport(rawXml: string): KositValidationResult {
const parsed = this.parser.parse(rawXml) as Record<string, unknown>;

const report = (parsed['report'] ?? parsed['createReportInput']) as
| Record<string, unknown>
| undefined;

if (!report) {
throw new Error(
'Unexpected Kosit validator response: missing report element'
);
}

const assessment = report['assessment'] as
| Record<string, unknown>
| undefined;

const valid = assessment
? 'accept' in assessment
: this.inferValidityFromResults(report);

const errors: KositValidationMessage[] = [];
const warnings: KositValidationMessage[] = [];

this.extractMessages(report, errors, warnings);

return { valid, errors, warnings, rawXml };
}

private inferValidityFromResults(report: Record<string, unknown>): boolean {
const scenarioMatched = report['scenarioMatched'] as
| Record<string, unknown>
| undefined;

if (!scenarioMatched) {
return false;
}

const steps = scenarioMatched['validationStepResult'];
const stepArray = Array.isArray(steps) ? steps : steps ? [steps] : [];

return stepArray.every((step: Record<string, unknown>) => {
const recommendation = step['recommendation'] as string | undefined;
return recommendation === 'accept';
});
}

private extractMessages(
report: Record<string, unknown>,
errors: KositValidationMessage[],
warnings: KositValidationMessage[]
): void {
const scenarioMatched = report['scenarioMatched'] as
| Record<string, unknown>
| undefined;

if (!scenarioMatched) {
return;
}

const steps = scenarioMatched['validationStepResult'];
const stepArray = Array.isArray(steps) ? steps : steps ? [steps] : [];

for (const step of stepArray as Record<string, unknown>[]) {
this.extractStepMessages(step, errors, warnings);
}
}

private extractStepMessages(
step: Record<string, unknown>,
errors: KositValidationMessage[],
warnings: KositValidationMessage[]
): void {
const messages = step['message'];
const messageArray = Array.isArray(messages)
? messages
: messages
? [messages]
: [];

for (const msg of messageArray as Record<string, unknown>[]) {
const entry: KositValidationMessage = {
id: String(msg['$id'] ?? msg['id'] ?? ''),
text: String(msg['#text'] ?? msg['text'] ?? ''),
location: String(msg['$location'] ?? msg['location'] ?? ''),
};

const level = String(
msg['$level'] ?? msg['level'] ?? ''
).toLowerCase();

if (level === 'warning') {
warnings.push(entry);
} else {
errors.push(entry);
}
}
}
}
1 change: 1 addition & 0 deletions src/validator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './KositValidator';
Loading
Loading