diff --git a/README.md b/README.md index 7f7f1b9..9c02283 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` @@ -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` | Sends XML to a KoSIT validator service and returns parsed validation outcome | #### Static Helpers @@ -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: @@ -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 diff --git a/package-lock.json b/package-lock.json index 907e552..25c5387 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pixeldrive/peppol-toolkit", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pixeldrive/peppol-toolkit", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "decimal.js": "^10.6.0", @@ -1136,6 +1136,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1185,6 +1186,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -1555,6 +1557,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1997,6 +2000,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2051,6 +2055,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3142,6 +3147,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3719,6 +3725,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3881,6 +3888,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3950,6 +3958,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index fd5e0c2..09ef55c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 8d84856..014e20b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); @@ -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 { + const validator = options + ? new KositValidator(options) + : (this.__kositValidator ??= new KositValidator()); + return await validator.validate(xml); + } + public static computeTotals = computeTotals; public static getEASFromTaxId = getEASFromTaxId; } @@ -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'; diff --git a/src/validator/KositValidator.ts b/src/validator/KositValidator.ts new file mode 100644 index 0000000..bd75cd3 --- /dev/null +++ b/src/validator/KositValidator.ts @@ -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 { + 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; + + const report = (parsed['report'] ?? parsed['createReportInput']) as + | Record + | undefined; + + if (!report) { + throw new Error( + 'Unexpected Kosit validator response: missing report element' + ); + } + + const assessment = report['assessment'] as + | Record + | 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): boolean { + const scenarioMatched = report['scenarioMatched'] as + | Record + | undefined; + + if (!scenarioMatched) { + return false; + } + + const steps = scenarioMatched['validationStepResult']; + const stepArray = Array.isArray(steps) ? steps : steps ? [steps] : []; + + return stepArray.every((step: Record) => { + const recommendation = step['recommendation'] as string | undefined; + return recommendation === 'accept'; + }); + } + + private extractMessages( + report: Record, + errors: KositValidationMessage[], + warnings: KositValidationMessage[] + ): void { + const scenarioMatched = report['scenarioMatched'] as + | Record + | undefined; + + if (!scenarioMatched) { + return; + } + + const steps = scenarioMatched['validationStepResult']; + const stepArray = Array.isArray(steps) ? steps : steps ? [steps] : []; + + for (const step of stepArray as Record[]) { + this.extractStepMessages(step, errors, warnings); + } + } + + private extractStepMessages( + step: Record, + errors: KositValidationMessage[], + warnings: KositValidationMessage[] + ): void { + const messages = step['message']; + const messageArray = Array.isArray(messages) + ? messages + : messages + ? [messages] + : []; + + for (const msg of messageArray as Record[]) { + 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); + } + } + } +} diff --git a/src/validator/index.ts b/src/validator/index.ts new file mode 100644 index 0000000..b76c112 --- /dev/null +++ b/src/validator/index.ts @@ -0,0 +1 @@ +export * from './KositValidator'; diff --git a/tests/kosit-validator.test.ts b/tests/kosit-validator.test.ts new file mode 100644 index 0000000..1cbd192 --- /dev/null +++ b/tests/kosit-validator.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KositValidator } from '../src/validator'; +import { PeppolToolkit } from '../src'; +import { basicInvoice } from '../src/data/basic-invoice'; + +const ACCEPTED_REPORT = ` + + + + accept + + + + + +`; + +const REJECTED_REPORT = ` + + + + reject + + An Invoice shall have a Specification identifier + + + + + + +`; + +const REPORT_WITH_WARNINGS = ` + + + + accept + + Delivery date should be provided + + + + + + +`; + +function mockFetch(body: string, status = 200, statusText = 'OK') { + return vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText, + text: () => Promise.resolve(body), + }); +} + +describe('KositValidator', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should use default endpoint when none is provided', async () => { + const fetchSpy = mockFetch(ACCEPTED_REPORT); + vi.stubGlobal('fetch', fetchSpy); + + const validator = new KositValidator(); + await validator.validate(''); + + expect(fetchSpy).toHaveBeenCalledWith('http://localhost:8081/', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: '', + }); + }); + + it('should use a custom endpoint when provided', async () => { + const fetchSpy = mockFetch(ACCEPTED_REPORT); + vi.stubGlobal('fetch', fetchSpy); + + const validator = new KositValidator({ + endpoint: 'http://my-validator:9090/validate', + }); + await validator.validate(''); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://my-validator:9090/validate', + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('should return valid=true for an accepted report', async () => { + vi.stubGlobal('fetch', mockFetch(ACCEPTED_REPORT)); + + const validator = new KositValidator(); + const result = await validator.validate(''); + + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + expect(result.rawXml).toBe(ACCEPTED_REPORT); + }); + + it('should return valid=false for a rejected report', async () => { + vi.stubGlobal('fetch', mockFetch(REJECTED_REPORT)); + + const validator = new KositValidator(); + const result = await validator.validate(''); + + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].id).toBe('BR-01'); + expect(result.errors[0].location).toBe('/Invoice'); + }); + + it('should parse warnings from the report', async () => { + vi.stubGlobal('fetch', mockFetch(REPORT_WITH_WARNINGS)); + + const validator = new KositValidator(); + const result = await validator.validate(''); + + expect(result.valid).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].id).toBe('UBL-SR-09'); + expect(result.errors).toHaveLength(0); + }); + + it('should throw on non-OK HTTP response', async () => { + vi.stubGlobal( + 'fetch', + mockFetch('Internal Server Error', 500, 'Internal Server Error') + ); + + const validator = new KositValidator(); + await expect(validator.validate('')).rejects.toThrow( + 'Kosit validator returned HTTP 500: Internal Server Error' + ); + }); + + it('should throw on unexpected response format', async () => { + vi.stubGlobal('fetch', mockFetch('')); + + const validator = new KositValidator(); + await expect(validator.validate('')).rejects.toThrow( + 'Unexpected Kosit validator response: missing report element' + ); + }); + + it('should include rawXml in the result', async () => { + vi.stubGlobal('fetch', mockFetch(REJECTED_REPORT)); + + const validator = new KositValidator(); + const result = await validator.validate(''); + + expect(result.rawXml).toBe(REJECTED_REPORT); + }); +}); + +describe('PeppolToolkit.validateWithKosit', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should validate an invoice XML via the Kosit service', async () => { + vi.stubGlobal('fetch', mockFetch(ACCEPTED_REPORT)); + + const toolkit = new PeppolToolkit(); + const xml = toolkit.invoiceToPeppolUBL(basicInvoice); + const result = await toolkit.validateWithKosit(xml); + + expect(result.valid).toBe(true); + }); + + it('should accept custom options', async () => { + const fetchSpy = mockFetch(ACCEPTED_REPORT); + vi.stubGlobal('fetch', fetchSpy); + + const toolkit = new PeppolToolkit(); + await toolkit.validateWithKosit('', { + endpoint: 'http://custom:1234/', + }); + + expect(fetchSpy).toHaveBeenCalledWith( + 'http://custom:1234/', + expect.objectContaining({ method: 'POST' }) + ); + }); +});