Compare commits

...

19 Commits

Author SHA1 Message Date
jkunz c0e88c1088 v5.2.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 20:30:56 +00:00
jkunz 3f37f6538c feat(core): improve in-memory validation, FatturaPA detection coverage, and published type compatibility 2026-04-16 20:30:56 +00:00
jkunz 55bee02a2e 5.1.4
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-12 05:30:43 +00:00
jkunz 0067a5100e update 2025-08-12 05:30:39 +00:00
jkunz f4d26abfc0 5.1.3
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-12 05:29:47 +00:00
jkunz f92fe756b7 update 2025-08-12 05:29:44 +00:00
jkunz 01f2df9f10 5.1.2
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-12 05:27:31 +00:00
jkunz 58506e287d refactor: Move downloaded resources from assets/ to assets_downloaded/
- Changed default download location to assets_downloaded/schematron
- Updated all references in SchematronDownloader, integration, and validator
- Updated postinstall scripts to use new location
- assets_downloaded/ is already in .gitignore to exclude downloaded files from git
- Moved existing downloaded files to new location
- All functionality tested and working correctly
2025-08-12 05:25:50 +00:00
jkunz b89da0ec3f update 2025-08-12 05:14:29 +00:00
jkunz 8dd5509da6 fix(schematron): Correct download URLs for XRechnung and PEPPOL validation files
- Fixed XRechnung URLs to use correct filenames (XRechnung-UBL-validation.sch)
- Removed non-existent PEPPOL-T10 and PEPPOL-T14 files
- Added PEPPOL-EN16931-CII validation file
- All 7 validation files now download successfully:
  - 3 EN16931 files (UBL, CII, EDIFACT)
  - 2 PEPPOL files (UBL, CII)
  - 2 XRechnung files (UBL, CII)
2025-08-12 05:14:11 +00:00
jkunz 2f597d79df feat(postinstall): Add robust postinstall resource download with version checking
- Implemented version checking to avoid unnecessary re-downloads
- Added retry logic with 3 attempts for network failures
- Included network connectivity detection
- Graceful error handling that never fails the install
- Support for CI environment variable (EINVOICE_SKIP_RESOURCES)
- Successfully tested all functionality including version checking and error handling
2025-08-12 05:02:42 +00:00
jkunz fcbe8151b7 5.1.1
Default (tags) / security (push) Failing after 25s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-11 19:35:16 +00:00
jkunz a106d66a10 fix(build/publishing): Remove migration guide and update publishing and schematron download configurations 2025-08-11 19:35:16 +00:00
jkunz cdb30d867d fix(docs): correct decimal calculation example in README
- Fixed method name from calculateLineTotal to calculateLineNet
- Corrected parameter order (quantity, unitPrice, discount)
- Fixed currency parameter location (constructor not method)
- Corrected calculation result from 3141.49 to 3141.56 (proper EUR rounding)
2025-08-11 19:24:19 +00:00
jkunz bc3028af55 refactor: move scripts to ts_install directory 2025-08-11 19:08:51 +00:00
jkunz 6a08d3c816 feat(compliance): achieve 100% EN16931 compliance with comprehensive validation support
Default (tags) / security (push) Failing after 29s
Default (tags) / test (push) Failing after 19s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-11 18:55:30 +00:00
jkunz cbb297b0b1 feat: Implement PEPPOL and XRechnung validators for compliance with e-invoice specifications
- Added PeppolValidator class to validate PEPPOL BIS 3.0 invoices, including checks for endpoint IDs, document type IDs, process IDs, party identification, and business rules.
- Implemented validation for GLN check digits, document types, and transport protocols specific to PEPPOL.
- Added XRechnungValidator class to validate XRechnung 3.0 invoices, focusing on German-specific requirements such as Leitweg-ID, payment details, seller contact, and tax registration.
- Included validation for IBAN and BIC formats, ensuring compliance with SEPA regulations.
- Established methods for checking B2G invoice indicators and validating mandatory fields for both validators.
2025-08-11 18:07:01 +00:00
jkunz 10e14af85b feat(validation): Implement EN16931 compliance validation types and VAT categories
- Added validation types for EN16931 compliance in `validation.types.ts`, including interfaces for `ValidationResult`, `ValidationOptions`, and `ValidationReport`.
- Introduced `VATCategoriesValidator` in `vat-categories.validator.ts` to validate VAT categories according to EN16931 rules, including detailed checks for standard, zero-rated, exempt, reverse charge, intra-community, export, and out-of-scope services.
- Enhanced `IEInvoiceMetadata` interface in `en16931-metadata.ts` to include additional fields required for full standards compliance, such as delivery information, payment information, allowances, and charges.
- Implemented helper methods for VAT calculations and validation logic to ensure accurate compliance with EN16931 standards.
2025-08-11 12:25:32 +00:00
philkunz 01c6e8daad 5.0.3
Default (tags) / security (push) Failing after 11s
Default (tags) / test (push) Failing after 11s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-05-31 11:54:21 +00:00
99 changed files with 22514 additions and 6328 deletions
+3 -1
View File
@@ -18,4 +18,6 @@ dist/
dist_*/
# custom
test/output
test/output
.serena
assets_downloaded/
-1
View File
@@ -1 +0,0 @@
registry=https://registry.npmjs.org/
+13 -7
View File
@@ -1,8 +1,8 @@
{
"gitzone": {
"@git.zone/cli": {
"projectType": "npm",
"module": {
"githost": "gitea.nevermind.cloud",
"githost": "code.foss.global",
"gitscope": "fin.cx",
"gitrepo": "xinvoice",
"description": "A TypeScript module for creating, manipulating, and embedding XML data within PDF files specifically tailored for xinvoice packages.",
@@ -23,13 +23,19 @@
"esm",
"financial technology"
]
},
"release": {
"registries": [
"https://verdaccio.lossless.digital",
"https://registry.npmjs.org"
],
"accessLevel": "public"
}
},
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public"
},
"tsdoc": {
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
},
"@ship.zone/szci": {
"npmGlobalTools": []
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {
-243
View File
@@ -1,243 +0,0 @@
# Migration Guide: XInvoice to EInvoice (v4.x to v5.x)
This guide helps you migrate from `@fin.cx/xinvoice` v4.x to `@fin.cx/einvoice` v5.x.
## Overview
Version 5.0.0 introduces a complete rebranding from XInvoice to EInvoice. The name change better reflects the library's purpose as a comprehensive electronic invoice (e-invoice) processing solution that supports multiple international standards.
## Breaking Changes
### 1. Package Name Change
**Old:**
```json
"dependencies": {
"@fin.cx/xinvoice": "^4.3.0"
}
```
**New:**
```json
"dependencies": {
"@fin.cx/einvoice": "^5.0.0"
}
```
### 2. Import Changes
**Old:**
```typescript
import { XInvoice } from '@fin.cx/xinvoice';
import type { XInvoiceOptions } from '@fin.cx/xinvoice';
```
**New:**
```typescript
import { EInvoice } from '@fin.cx/einvoice';
import type { EInvoiceOptions } from '@fin.cx/einvoice';
```
### 3. Class Name Changes
**Old:**
```typescript
const invoice = new XInvoice();
const invoiceFromXml = await XInvoice.fromXml(xmlString);
const invoiceFromPdf = await XInvoice.fromPdf(pdfBuffer);
```
**New:**
```typescript
const invoice = new EInvoice();
const invoiceFromXml = await EInvoice.fromXml(xmlString);
const invoiceFromPdf = await EInvoice.fromPdf(pdfBuffer);
```
### 4. Type/Interface Changes
**Old:**
```typescript
const options: XInvoiceOptions = {
validateOnLoad: true,
validationLevel: ValidationLevel.BUSINESS
};
```
**New:**
```typescript
const options: EInvoiceOptions = {
validateOnLoad: true,
validationLevel: ValidationLevel.BUSINESS
};
```
## New Features in v5.x
### Enhanced Error Handling
Version 5.0.0 introduces specialized error classes for better error handling:
```typescript
import {
EInvoiceError,
EInvoiceParsingError,
EInvoiceValidationError,
EInvoicePDFError,
EInvoiceFormatError
} from '@fin.cx/einvoice';
try {
const invoice = await EInvoice.fromXml(xmlString);
} catch (error) {
if (error instanceof EInvoiceParsingError) {
console.error('Parsing failed:', error.getLocationMessage());
console.error('Suggestions:', error.getDetailedMessage());
} else if (error instanceof EInvoiceValidationError) {
console.error('Validation report:', error.getValidationReport());
} else if (error instanceof EInvoicePDFError) {
console.error('PDF operation failed:', error.message);
console.error('Recovery suggestions:', error.getRecoverySuggestions());
}
}
```
### Error Recovery
The new version includes error recovery capabilities:
```typescript
import { ErrorRecovery } from '@fin.cx/einvoice';
// Attempt to recover from XML parsing errors
const recovery = await ErrorRecovery.attemptXMLRecovery(xmlString, parsingError);
if (recovery.success && recovery.cleanedXml) {
const invoice = await EInvoice.fromXml(recovery.cleanedXml);
}
```
## Step-by-Step Migration
### 1. Update your package.json
```bash
# Remove old package
pnpm remove @fin.cx/xinvoice
# Install new package
pnpm add @fin.cx/einvoice
```
### 2. Update imports using find and replace
Find all occurrences of:
- `@fin.cx/xinvoice``@fin.cx/einvoice`
- `XInvoice``EInvoice`
- `XInvoiceOptions``EInvoiceOptions`
### 3. Update your code
Example migration:
**Before:**
```typescript
import { XInvoice, ValidationLevel } from '@fin.cx/xinvoice';
async function processInvoice(xmlData: string) {
try {
const xinvoice = await XInvoice.fromXml(xmlData);
const validation = await xinvoice.validate(ValidationLevel.BUSINESS);
if (!validation.valid) {
throw new Error('Validation failed');
}
return xinvoice;
} catch (error) {
console.error('Error:', error);
throw error;
}
}
```
**After:**
```typescript
import { EInvoice, ValidationLevel, EInvoiceValidationError } from '@fin.cx/einvoice';
async function processInvoice(xmlData: string) {
try {
const einvoice = await EInvoice.fromXml(xmlData);
const validation = await einvoice.validate(ValidationLevel.BUSINESS);
if (!validation.valid) {
throw new EInvoiceValidationError(
'Invoice validation failed',
validation.errors
);
}
return einvoice;
} catch (error) {
if (error instanceof EInvoiceValidationError) {
console.error('Validation Report:', error.getValidationReport());
}
throw error;
}
}
```
### 4. Update your tests
Update test imports and class names:
**Before:**
```typescript
import { XInvoice } from '@fin.cx/xinvoice';
import { expect } from '@push.rocks/tapbundle';
test('should create invoice', async () => {
const invoice = new XInvoice();
expect(invoice).toBeInstanceOf(XInvoice);
});
```
**After:**
```typescript
import { EInvoice } from '@fin.cx/einvoice';
import { expect } from '@push.rocks/tapbundle';
test('should create invoice', async () => {
const invoice = new EInvoice();
expect(invoice).toBeInstanceOf(EInvoice);
});
```
## Compatibility
### Unchanged APIs
The following APIs remain unchanged:
- All method signatures on the main class
- All validation levels and invoice formats
- All export formats
- The structure of validation results
- PDF handling capabilities
### Deprecated Features
None. This is a pure rebranding release with enhanced error handling.
## Need Help?
If you encounter any issues during migration:
1. Check the [changelog](./changelog.md) for detailed changes
2. Review the updated [documentation](./readme.md)
3. Report issues at [GitHub Issues](https://github.com/fin-cx/einvoice/issues)
## Why the Name Change?
- **EInvoice** (electronic invoice) is more universally recognized
- Better represents support for multiple international standards
- Aligns with industry terminology (e-invoicing, e-invoice)
- More intuitive for new users discovering the library
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## 2026-04-16 - 5.2.0 - feat(core)
improve in-memory validation, FatturaPA detection coverage, and published type compatibility
- add validation support for programmatically created invoices without requiring loaded XML, including structured EN16931 mandatory-field errors and validation caching
- tighten FatturaPA handling by asserting explicit format detection while keeping decode unsupported with clear test coverage
- improve published consumer compatibility with typed vendor wrappers, lazy PDF utility initialization, stricter invoice type enforcement, and a strict typecheck consumer test
- update package tooling and metadata configuration, including newer build/test dependencies and a published typecheck script
## 2025-08-11 - 5.1.1 - fix(build/publishing)
Remove migration guide and update publishing and schematron download configurations
- Deleted MIGRATION.md as migration instructions are no longer needed in v5.x
- Updated .claude/settings.local.json to include new permission settings
- Changed import in ts_install/download-schematron.ts to use 'dist_ts' instead of 'ts'
- Added tspublish.json files in both ts and ts_install with an explicit order configuration
- Refined publishing configuration (ts/tspublish.json) to align with new build process
## 2025-01-11 - 5.1.0 - feat(compliance)
Achieve 100% EN16931 compliance with comprehensive validation support
- Implemented complete EN16931 semantic model with all 162 Business Terms (BT-1 to BT-162) and 32 Business Groups (BG-1 to BG-32)
- Added PEPPOL BIS 3.0 validator with endpoint ID validation, GLN checksum, and document type validation
- Created Factur-X validator supporting all 5 profiles (MINIMUM, BASIC, BASIC_WL, EN16931, EXTENDED)
- Implemented XRechnung CIUS validator with Leitweg-ID validation and SEPA IBAN/BIC checking
- Added arbitrary precision decimal arithmetic library for accurate financial calculations
- Created DecimalCurrencyCalculator with ISO 4217 currency-aware rounding
- Built bidirectional adapter between EInvoice and EN16931 semantic model
- Integrated all validators into MainValidator with automatic profile detection
- Updated README to showcase 100% EN16931 compliance achievement
- Full test coverage across all new components (60+ new tests passing)
## 2025-05-24 - 5.0.0 - BREAKING CHANGE(core)
Rebrand XInvoice to EInvoice: update package name, class names, imports, and documentation
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23 -16
View File
@@ -1,6 +1,6 @@
{
"name": "@fin.cx/einvoice",
"version": "5.0.2",
"version": "5.2.0",
"private": false,
"description": "A TypeScript module for creating, manipulating, and embedding XML data within PDF files specifically tailored for electronic invoice (einvoice) packages.",
"main": "dist_ts/index.js",
@@ -10,34 +10,41 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/ --verbose --logfile --timeout 60)",
"build": "(tsbuild --web --allowimplicitany)",
"buildDocs": "(tsdoc)"
"build": "(tsbuild tsfolders --allowimplicitany)",
"typecheck:published": "pnpm exec tsc -p test/strict-consumer/tsconfig.json",
"buildDocs": "(tsdoc)",
"postinstall": "node dist_ts_install/index.js 2>/dev/null || true",
"download-schematron": "tsx ts_install/download-schematron.ts",
"download-test-samples": "tsx ts_install/download-test-samples.ts",
"test:conformance": "tstest test/test.conformance-harness.ts"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsbundle": "^2.2.5",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^2.3.1",
"@types/node": "^22.15.23"
"@git.zone/tsbuild": "^4.4.0",
"@git.zone/tsbundle": "^2.10.0",
"@git.zone/tsrun": "^2.0.2",
"@git.zone/tstest": "^3.6.3",
"@types/node": "^25.6.0"
},
"dependencies": {
"@push.rocks/smartfile": "^11.2.5",
"@push.rocks/smartxml": "^1.1.1",
"@tsclass/tsclass": "^9.2.0",
"jsdom": "^26.1.0",
"@push.rocks/smartfile": "^13.1.2",
"@push.rocks/smartxml": "^2.0.0",
"@tsclass/tsclass": "^9.5.0",
"@xmldom/xmldom": "^0.9.9",
"jsdom": "^29.0.2",
"pako": "^2.1.0",
"pdf-lib": "^1.17.1",
"saxon-js": "^2.7.0",
"xmldom": "^0.6.0",
"xpath": "^0.0.34"
},
"repository": {
"type": "git",
"url": "git+https://gitea.nevermind.cloud/fin.cx/einvoice.git"
"url": "https://code.foss.global/fin.cx/einvoice.git"
},
"bugs": {
"url": "https://gitea.nevermind.cloud/fin.cx/einvoice/issues"
"url": "https://code.foss.global/fin.cx/einvoice/issues"
},
"homepage": "https://gitea.nevermind.cloud/fin.cx/einvoice#readme",
"homepage": "https://code.foss.global/fin.cx/einvoice#readme",
"browserslist": [
"last 1 chrome versions"
],
@@ -50,7 +57,7 @@
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
],
"keywords": [
+4278 -4581
View File
File diff suppressed because it is too large Load Diff
+24 -8
View File
@@ -1,11 +1,11 @@
For testing use
```typescript
import {tap, expect} @push.rocks/tapbundle
import { tap, expect } from '@git.zone/tstest/tapbundle';
```
tapbundle exports expect from @push.rocks/smartexpect
You can find the readme here: https://code.foss.global/push.rocks/smartexpect/src/branch/master/readme.md
tapbundle is provided by `@git.zone/tstest`.
You can find the readme here: https://code.foss.global/git.zone/tstest
This module also uses @tsclass/tsclass: You can find the TInvoice type here: https://code.foss.global/tsclass/tsclass/src/branch/master/ts/finance/invoice.ts
@@ -15,6 +15,22 @@ It is ok to ask questions, if you are unsure about something.
---
# Upgrade Notes (2026-04-16)
- Command: `/c-upgrade`
- Files modified: 2
- Dependency status: `pnpm outdated --format json` returned `{}`, so no package version bumps were needed.
- Decorators: no decorator usage was found in `*.ts`, so TC39 decorator migration was not required.
- Pattern changes:
- Removed obsolete `experimentalDecorators` and `useDefineForClassFields` compiler options from `tsconfig.json`.
- Updated the stale test import hint from `@push.rocks/tapbundle` to `@git.zone/tstest/tapbundle`.
- Verification:
- `pnpm run build`: passed
- `pnpm test`: failed due to pre-existing test issues in `test/test.conformance-harness.ts` (no tests defined) and `test/suite/einvoice_security/test.sec-06.memory-dos.ts` (assertion failure at line 142)
- Issues encountered: full test suite is not green before or after this minimal upgrade because of the pre-existing failures above.
---
# Architecture Analysis (2025-01-31)
## Overall Architecture
@@ -490,7 +506,7 @@ countryCode: country
### **MILESTONE REACHED: The module now achieves 100% data preservation in round-trip conversions!**
This makes the module fully spec-compliant and suitable as the default open-source e-invoicing solution.
This materially improved round-trip data preservation, but it did not by itself prove full standards compliance across every supported format and profile.
### Data Preservation Improvements:
- Initial preservation score: 51%
@@ -800,7 +816,7 @@ Successfully fixed all remaining test failures to achieve 100% test pass rate:
- Format detection: <5ms average for most formats
- PDF extraction: Successfully extracts from ZUGFeRD v1/v2 and Factur-X PDFs
All tests are now passing, making the library fully spec-compliant and production-ready.
The targeted test suites available at that point were passing, but that still did not establish full standards compliance or production readiness across every supported format/profile.
---
@@ -855,10 +871,10 @@ for (const idNode of partyIdNodes) {
```
### FatturaPA (Italian Standard)
While not fully implemented as decoder/encoder, the library detects FatturaPA format:
FatturaPA currently has format detection, but not full decoder/encoder support:
- Detects root element `<FatturaElettronica>`
- Recognizes namespace `fatturapa.gov.it`
- Supports mixed UBL+FatturaPA documents
- May classify mixed UBL+FatturaPA documents as FatturaPA during detection
## 3. Advanced Validation Architecture
@@ -1104,4 +1120,4 @@ Each format has its own implementation strategy while maintaining common interfa
5. **Extensible Design**: New formats can be added without core changes
6. **Production Ready**: Handles edge cases, malformed input, and large files
The library represents a mature, well-architected solution for European e-invoicing with careful attention to both standards compliance and practical usage scenarios.
The library represents a mature, well-architected solution for European e-invoicing with careful attention to both standards compliance and practical usage scenarios.
+197 -885
View File
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
# Test Fixes Summary
## Overview
This document summarizes the test fixes applied to make the einvoice library more spec compliant.
## Fixed Tests
### Encoding Tests (12 tests fixed)
- **ENC-01**: UTF-8 Encoding ✅
- Fixed invoice ID preservation by setting the `id` property
- Fixed item description field handling in encoder
- Fixed subject field extraction (uses first note as workaround)
- **ENC-02**: UTF-16 Encoding ✅
- Fixed test syntax (removed `t.test` pattern)
- Added `tap.start()` to run tests
- UTF-16 not directly supported (acceptable), UTF-8 fallback works
- **ENC-03 to ENC-10**: Various encoding tests ✅
- Fixed test syntax for all remaining encoding tests
- All tests now verify UTF-8 fallback works correctly
### Error Handling Tests (6/10 fixed)
- **ERR-01**: Parsing Recovery ✅
- **ERR-03**: PDF Errors ✅
- **ERR-04**: Network Errors ✅
- **ERR-07**: Encoding Errors ✅
- **ERR-08**: Filesystem Errors ✅
- **ERR-09**: Transformation Errors ✅
Still failing (may not throw errors in these scenarios):
- ERR-02: Validation Errors
- ERR-05: Memory Errors
- ERR-06: Concurrent Errors
- ERR-10: Configuration Errors
### Format Detection Tests (3 failing)
- FD-02, FD-03, FD-04: CII files detected as Factur-X
- This is technically correct behavior (Factur-X is a CII profile)
- Tests expect generic "CII" but library returns more specific format
## Library Fixes Applied
1. **UBL Encoder**: Modified to use item description field if available
```typescript
const description = (item as any).description || item.name;
```
2. **XRechnung Decoder**: Modified to preserve subject from notes
```typescript
subject: notes.length > 0 ? notes[0] : `Invoice ${invoiceId}`,
```
## Remaining Issues
### Medium Priority
1. Subject field preservation - currently using notes as workaround
2. "Due in X days" automatically added to notes
### Low Priority
1. `&` character search in tests should look for `&amp;`
2. Remaining error-handling tests (validation, memory, concurrent, config)
3. Format detection test expectations
## Spec Compliance Improvements
The library now better supports:
- UTF-8 character encoding throughout
- Preservation of invoice IDs in round-trip conversions
- Better error handling and recovery
- Multiple encoding format fallbacks
- Item description fields in UBL format
## Test Results Summary
- **Encoding Tests**: 12/12 passing ✅
- **Error Handling Tests**: 6/10 passing (4 may be invalid scenarios)
- **Format Detection Tests**: 3 failing (but behavior is technically correct)
Total tests fixed: ~18 tests made to pass through library and test improvements.
@@ -0,0 +1,530 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>12115118</cbc:ID>
<cbc:IssueDate>2015-01-09</cbc:IssueDate>
<cbc:DueDate>2015-01-09</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Alle leveringen zijn franco. Alle prijzen zijn incl. BTW. Betalingstermijn: 14 dagen netto. Prijswijzigingen voorbehouden. Op al onze aanbiedingen, leveringen en overeenkomsten zijn van toepassing in de algemene verkoop en leveringsvoorwaarden. Gedeponeerd bij de K.v.K. te Amsterdam 25-04-'85##Delivery terms</cbc:Note>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PostalAddress>
<cbc:StreetName>Postbus 7l</cbc:StreetName>
<cbc:CityName>Velsen-Noord</cbc:CityName>
<cbc:PostalZone>1950 AB</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NL8200.98.395.B.01</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>De Koksmaat</cbc:RegistrationName>
<cbc:CompanyID>57151520</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID>10202</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>POSTBUS 367</cbc:StreetName>
<cbc:CityName>HEEMSKERK</cbc:CityName>
<cbc:PostalZone>1960 AJ</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>ODIN 59</cbc:RegistrationName>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Dhr. J BLOKKER</cbc:Name>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>Deb. 10202 / Fact. 12115118</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>NL57 RABO 0107307510</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>NL03 INGB 0004489902</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">20.73</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">183.23</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">10.99</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<!-- 37,9 -->
<cbc:TaxableAmount currencyID="EUR">46.37</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">9.74</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">229.60</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">229.60</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">250.33</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">250.33</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">19.90</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>PATAT FRITES 10MM 10KG</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>166022</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">9.95</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">9.85</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>PKAAS 50PL. JONG BEL. 1KG</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>661813</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">9.85</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">8.29</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>POT KETCHUP 3 LT</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>438146</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">8.29</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>4</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">14.46</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>FRITESSAUS 3 LRR</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>438103</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">7.23</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>5</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">35.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>KOFFIE BLIK 3,5KG SNELF </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>666955</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">35.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>6</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">35.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>KOFFIE 3.5 KG BLIK STAND </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>664871</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">35.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>7</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">10.65</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>SUIKERKLONT</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>350257</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">10.65</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>8</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">1.55</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>1 KG UL BLOKJES </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>350258</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">1.55</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>9</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">3</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">14.37</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>BLOCKNOTE A5 </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>999998</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">4.79</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>10</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">8.29</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>CHIPS NAT KLEIN ZAKJES</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>740810</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">8.29</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>11</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">16.58</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>CHIPS PAP KLEINE ZAKJES</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>740829</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">8.29</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>12</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">9.95</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>TR KL PAKJES APPELSAP </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>740828</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">9.95</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>13</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">3.30</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>PK CHOCOLADEMEL</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>740827</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">1.65</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>14</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">10.80</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>KRAT BIER </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>999996</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">10.80</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>15</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">3.90</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>STATIEGELD</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>999995</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">3.90</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>16</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">7.60</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>BLEEK 3 X 750 ML </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>102172</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">3.80</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>17</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">9.34</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>WC PAPIER </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>999994</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">4.67</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>18</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">18.63</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>BALPENNEN 50 ST BLAUW </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>999993</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">18.63</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>19</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">6</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">102.12</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>EM FRITUURVET </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>999992</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">17.02</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>20</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">6</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">-109.98</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>FRITUUR VET 10 KG RETOUR </cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>175137</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>6</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">18.33</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,460 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ProfileID>Invoicing on purchase order</cbc:ProfileID>
<cbc:ID>TOSL108</cbc:ID>
<cbc:IssueDate>2013-06-30</cbc:IssueDate>
<cbc:DueDate>2013-07-20</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Ordered in our booth at the convention</cbc:Note>
<cbc:DocumentCurrencyCode>NOK</cbc:DocumentCurrencyCode>
<cbc:AccountingCost>Project cost code 123</cbc:AccountingCost>
<cac:InvoicePeriod>
<cbc:StartDate>2013-06-01</cbc:StartDate>
<cbc:EndDate>2013-06-30</cbc:EndDate>
<cbc:DescriptionCode>3</cbc:DescriptionCode>
</cac:InvoicePeriod>
<cac:OrderReference>
<cbc:ID>123</cbc:ID>
</cac:OrderReference>
<cac:ContractDocumentReference>
<cbc:ID>Contract321</cbc:ID>
</cac:ContractDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>Doc1</cbc:ID>
<cbc:DocumentDescription>Timesheet</cbc:DocumentDescription>
<cac:Attachment>
<cac:ExternalReference>
<cbc:URI>http://www.suppliersite.eu/sheet001.html</cbc:URI>
</cac:ExternalReference>
</cac:Attachment>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>Doc2</cbc:ID>
<cbc:DocumentDescription>EHF specification</cbc:DocumentDescription>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename="test.pdf">VGVzdGluZyBCYXNlNjQgZW5jb2Rpbmc=</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">1238764941386</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Main street 34</cbc:StreetName>
<cbc:AdditionalStreetName>Suite 123</cbc:AdditionalStreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>303</cbc:PostalZone>
<cbc:CountrySubentity>RegionA</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NO123456789MVA</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Salescompany ltd.</cbc:RegistrationName>
<cbc:CompanyID>123456789</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Antonio Salesmacher</cbc:Name>
<cbc:Telephone>46211230</cbc:Telephone>
<cbc:ElectronicMail>antonio@salescompany.no</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">3456789012098</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Anystreet 8</cbc:StreetName>
<cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>RegionB</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NO987654321MVA</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Buyercompany</cbc:RegistrationName>
<cbc:CompanyID>987654321</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>John Doe</cbc:Name>
<cbc:Telephone>5121230</cbc:Telephone>
<cbc:ElectronicMail>john@buyercompany.no</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PayeeParty>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">2298740918237</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Ebeneser Scrooge AS</cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:CompanyID>989823401</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:PayeeParty>
<cac:TaxRepresentativeParty>
<cac:PartyName>
<cbc:Name>Tax handling company AS</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Regent street</cbc:StreetName>
<cbc:AdditionalStreetName>Front door</cbc:AdditionalStreetName>
<cbc:CityName>Newtown</cbc:CityName>
<cbc:PostalZone>202</cbc:PostalZone>
<cbc:CountrySubentity>RegionC</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NO967611265MVA</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
</cac:TaxRepresentativeParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2013-06-15</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cbc:ID schemeID="0088">6754238987643</cbc:ID>
<cac:Address>
<cbc:StreetName>Deliverystreet 2</cbc:StreetName>
<cbc:AdditionalStreetName>Side door</cbc:AdditionalStreetName>
<cbc:CityName>DeliveryCity</cbc:CityName>
<cbc:PostalZone>523427</cbc:PostalZone>
<cbc:CountrySubentity>RegionD</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>0003434323213231</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>NO9386011117947</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>DNBANOKK</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>2 % discount if paid within 2 days
Penalty percentage 10% from due date</cbc:Note>
</cac:PaymentTerms>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>0</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>88</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Promotion discount</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="NOK">100.00</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReason>Freight</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="NOK">100.00</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="NOK">365.28</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="NOK">1460.50</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="NOK">365.13</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="NOK">1.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="NOK">0.15</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="NOK">-25.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="NOK">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>E</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cbc:TaxExemptionReason>Exempt New Means of Transport</cbc:TaxExemptionReason>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="NOK">1436.50</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="NOK">1436.50</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="NOK">1801.78</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="NOK">100.00</cbc:AllowanceTotalAmount>
<cbc:ChargeTotalAmount currencyID="NOK">100.00</cbc:ChargeTotalAmount>
<cbc:PrepaidAmount currencyID="NOK">1000.00</cbc:PrepaidAmount>
<cbc:PayableAmount currencyID="NOK">801.78</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:Note>Scratch on box</cbc:Note>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="NOK">1273.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>BookingCode001</cbc:AccountingCost>
<cac:InvoicePeriod>
<cbc:StartDate>2013-06-01</cbc:StartDate>
<cbc:EndDate>2013-06-30</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReason>Damage</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="NOK">12.00</cbc:Amount>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReason>Testing</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="NOK">12.00</cbc:Amount>
</cac:AllowanceCharge>
<cac:Item>
<cbc:Description>Processor: Intel Core 2 Duo SU9400 LV (1.4GHz). RAM: 3MB. Screen 1440x900</cbc:Description>
<cbc:Name>Laptop computer</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB007</cbc:ID>
</cac:SellersItemIdentification>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">1234567890128</cbc:ID>
</cac:StandardItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="ZZZ">12344321</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="STI">65434568</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>Color</cbc:Name>
<cbc:Value>Black</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="NOK">1273.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="EA">1</cbc:BaseQuantity>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:Amount currencyID="NOK">225.00</cbc:Amount>
</cac:AllowanceCharge>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:Note>Cover is slightly damaged.</cbc:Note>
<cbc:InvoicedQuantity unitCode="EA">-1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="NOK">-3.96</cbc:LineExtensionAmount>
<cbc:AccountingCost>BookingCode002</cbc:AccountingCost>
<cac:OrderLineReference>
<cbc:LineID>5</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Name>Returned "Advanced computing" book</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB008</cbc:ID>
</cac:SellersItemIdentification>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">1234567890135</cbc:ID>
</cac:StandardItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="ZZZ">32344324</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="STI">65434567</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="NOK">3.96</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="EA">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="NOK">4.96</cbc:LineExtensionAmount>
<cbc:AccountingCost>BookingCode003</cbc:AccountingCost>
<cac:OrderLineReference>
<cbc:LineID>3</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Name>"Computing for dummies" book</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB009</cbc:ID>
</cac:SellersItemIdentification>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">1234567890135</cbc:ID>
</cac:StandardItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="ZZZ">32344324</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="STI">65434567</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>15</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="NOK">2.48</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="EA">1</cbc:BaseQuantity>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:Amount currencyID="NOK">0.27</cbc:Amount>
<cbc:BaseAmount currencyID="NOK">2.70</cbc:BaseAmount>
</cac:AllowanceCharge>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>4</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">-1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="NOK">-25.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>BookingCode004</cbc:AccountingCost>
<cac:OrderLineReference>
<cbc:LineID>2</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Name>Returned IBM 5150 desktop</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB010</cbc:ID>
</cac:SellersItemIdentification>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">1234567890159</cbc:ID>
</cac:StandardItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="ZZZ">12344322</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="STI">65434565</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>E</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="NOK">25.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="EA">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>5</cbc:ID>
<cbc:InvoicedQuantity unitCode="MTR">250</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="NOK">187.50</cbc:LineExtensionAmount>
<cbc:AccountingCost>BookingCode005</cbc:AccountingCost>
<cac:OrderLineReference>
<cbc:LineID></cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Name>Network cable</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB011</cbc:ID>
</cac:SellersItemIdentification>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">1234567890166</cbc:ID>
</cac:StandardItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="ZZZ">12344325</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="STI">65434564</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>Type</cbc:Name>
<cbc:Value>Cat5</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="NOK">0.75</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MTR">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>TOSL108</cbc:ID>
<cbc:IssueDate>2013-04-10</cbc:IssueDate>
<cbc:DueDate>2013-05-10</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Contract was established through our website</cbc:Note>
<cbc:DocumentCurrencyCode>DKK</cbc:DocumentCurrencyCode>
<cac:InvoicePeriod>
<cbc:StartDate>2013-01-01</cbc:StartDate>
<cbc:EndDate>2013-04-01</cbc:EndDate>
</cac:InvoicePeriod>
<cac:ContractDocumentReference>
<cbc:ID>SUBSCR571</cbc:ID>
</cac:ContractDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">1238764941386</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Main street 2, Building 4</cbc:StreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>DK16356706</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SubscriptionSeller</cbc:RegistrationName>
<cbc:CompanyID>DK16356706</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ElectronicMail>antonio@SubscriptionsSeller.dk</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">5790000435975</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Anystreet, Building 1</cbc:StreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NO987654321MVA</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyercompany ltd</cbc:RegistrationName>
<cbc:CompanyID>987654321</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>Payref1</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>DK1212341234123412</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReason>Freight charge</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="DKK">100.00</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="DKK">305.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">900.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">225.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">800.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">80.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>10</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="DKK">1600.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="DKK">1700.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="DKK">2005.00</cbc:TaxInclusiveAmount>
<cbc:ChargeTotalAmount currencyID="DKK">100.00</cbc:ChargeTotalAmount>
<cbc:PayableAmount currencyID="DKK">2005.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">800.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>Subscription fee 1st quarter</cbc:Description>
<cbc:Name>Paper subscription</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">800.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">2</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">800.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>Subscription fee 1st quarter</cbc:Description>
<cbc:Name>Paper subscription</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>10</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">800.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,192 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>TOSL110</cbc:ID>
<cbc:IssueDate>2013-04-10</cbc:IssueDate>
<cbc:DueDate>2013-05-10</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Ordered through our website</cbc:Note>
<cbc:DocumentCurrencyCode>DKK</cbc:DocumentCurrencyCode>
<cac:OrderReference>
<cbc:ID>123</cbc:ID>
</cac:OrderReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">5790000436101</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Main street 2, Building 4</cbc:StreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>DK16356706</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SellerCompany</cbc:RegistrationName>
<cbc:CompanyID>DK16356706</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Anthon Larsen</cbc:Name>
<cbc:Telephone>+4598989898</cbc:Telephone>
<cbc:ElectronicMail>antonio@SubscriptionsSeller.dk</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">5790000436057</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Anystreet, Building 1</cbc:StreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyercompany ltd</cbc:RegistrationName>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>John Hansen</cbc:Name>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2013-04-15</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cac:Address>
<cbc:StreetName>Deliverystreet</cbc:StreetName>
<cbc:CityName>Deliverycity</cbc:CityName>
<cbc:PostalZone>9000</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>Payref1</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>DK1212341234123412</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="DKK">675.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">1500.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">375.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">2500.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">300.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>12</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="DKK">4000.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="DKK">4000.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="DKK">4675.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="DKK">4675.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1000</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">1000.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>Printing paper, 2mm</cbc:Description>
<cbc:Name>Printing paper</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB007</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">1.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">100</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">500.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>Parker Pen, Black, model Sansa</cbc:Description>
<cbc:Name>Parker Pen</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB008</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">5.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">500</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">2500.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>American Cookies</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB009</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>12</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">5.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,409 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ProfileID>1</cbc:ProfileID>
<cbc:ID>TOSL110</cbc:ID>
<cbc:IssueDate>2013-04-10</cbc:IssueDate>
<cbc:DueDate>2013-05-10</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Ordered through our website#Ordering information</cbc:Note>
<cbc:DocumentCurrencyCode>DKK</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>EUR</cbc:TaxCurrencyCode>
<cbc:AccountingCost>67543</cbc:AccountingCost>
<cbc:BuyerReference>qwerty</cbc:BuyerReference>
<cac:InvoicePeriod>
<cbc:StartDate>2013-03-10</cbc:StartDate>
<cbc:EndDate>2013-04-10</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderReference>
<cbc:ID>PO4711</cbc:ID>
<cbc:SalesOrderID>123</cbc:SalesOrderID>
</cac:OrderReference>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>TOSL109</cbc:ID>
<cbc:IssueDate>2013-03-10</cbc:IssueDate>
</cac:InvoiceDocumentReference>
</cac:BillingReference>
<cac:DespatchDocumentReference>
<cbc:ID>5433</cbc:ID>
</cac:DespatchDocumentReference>
<cac:ReceiptDocumentReference>
<cbc:ID>3544</cbc:ID>
</cac:ReceiptDocumentReference>
<cac:OriginatorDocumentReference>
<cbc:ID>Lot567</cbc:ID>
</cac:OriginatorDocumentReference>
<cac:ContractDocumentReference>
<cbc:ID>2013-05</cbc:ID>
</cac:ContractDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>OBJ999</cbc:ID>
<cbc:DocumentDescription>ATS</cbc:DocumentDescription>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>sales slip</cbc:ID>
<cbc:DocumentDescription>your sales slip</cbc:DocumentDescription>
<cac:Attachment>
<cbc:EmbeddedDocumentBinaryObject mimeCode="application/pdf" filename="EHF.pdf"
>VGVzdGluZyBCYXNlNjQgZW5jb2Rpbmc=</cbc:EmbeddedDocumentBinaryObject>
</cac:Attachment>
</cac:AdditionalDocumentReference>
<cac:ProjectReference>
<cbc:ID>Project345</cbc:ID>
</cac:ProjectReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="EM">info@selco.nl</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">5790000436101</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>SelCo</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Hoofdstraat 4</cbc:StreetName>
<cbc:AdditionalStreetName>Om de hoek</cbc:AdditionalStreetName>
<cbc:CityName>Grootstad</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cbc:CountrySubentity>Overijssel</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NL16356706</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyTaxScheme>
<cbc:CompanyID>NL16356706</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>LOC</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SellerCompany</cbc:RegistrationName>
<cbc:CompanyID>NL16356706</cbc:CompanyID>
<cbc:CompanyLegalForm>Export</cbc:CompanyLegalForm>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Anthon Larsen</cbc:Name>
<cbc:Telephone>+3198989898</cbc:Telephone>
<cbc:ElectronicMail>Anthon@Selco.nl</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="EM">info@buyercompany.dk</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID schemeID="0088">5790000436057</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Buyco</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Anystreet, Building 1</cbc:StreetName>
<cbc:AdditionalStreetName>5th floor</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>Jutland</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>DK16356607</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyercompany ltd</cbc:RegistrationName>
<cbc:CompanyID>DK16356607</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>John Hansen</cbc:Name>
<cbc:Telephone>+4598989898</cbc:Telephone>
<cbc:ElectronicMail>john.hansen@buyercompany.dk</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PayeeParty>
<cac:PartyIdentification>
<cbc:ID>DK16356608</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Dagobert Duck</cbc:Name>
</cac:PartyName>
<cac:PartyLegalEntity>
<cbc:CompanyID>DK16356608</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:PayeeParty>
<cac:TaxRepresentativeParty>
<cac:PartyName>
<cbc:Name>Dick Panama</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Anystreet, Building 1</cbc:StreetName>
<cbc:AdditionalStreetName>6th floor</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>Jutland</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>DK16356609</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
</cac:TaxRepresentativeParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2013-04-15</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cbc:ID>5790000436068</cbc:ID>
<cac:Address>
<cbc:StreetName>Deliverystreet</cbc:StreetName>
<cbc:AdditionalStreetName>Gate 15</cbc:AdditionalStreetName>
<cbc:CityName>Deliverycity</cbc:CityName>
<cbc:PostalZone>9000</cbc:PostalZone>
<cbc:CountrySubentity>Jutland</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
<cac:DeliveryParty>
<cac:PartyName>
<cbc:Name>Logistic service Ltd</cbc:Name>
</cac:PartyName>
</cac:DeliveryParty>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>49</cbc:PaymentMeansCode>
<cbc:PaymentID>Payref1</cbc:PaymentID>
<cac:PaymentMandate>
<cbc:ID>123456</cbc:ID>
<cac:PayerFinancialAccount>
<cbc:ID>DK1212341234123412</cbc:ID>
</cac:PayerFinancialAccount>
</cac:PaymentMandate>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>50% prepaid, 50% within one month</cbc:Note>
</cac:PaymentTerms>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>100</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Loyal customer</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>10</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="DKK">150.00</cbc:Amount>
<cbc:BaseAmount currencyID="DKK">1500.00</cbc:BaseAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>ABL</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Packaging</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>10</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="DKK">150.00</cbc:Amount>
<cbc:BaseAmount currencyID="DKK">1500.00</cbc:BaseAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="DKK">675.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">1500.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">375.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">2500.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">300.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>12</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">628.62</cbc:TaxAmount>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="DKK">4000.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="DKK">4000.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="DKK">4675.00</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="DKK">150.00</cbc:AllowanceTotalAmount>
<cbc:ChargeTotalAmount currencyID="DKK">150.00</cbc:ChargeTotalAmount>
<cbc:PrepaidAmount currencyID="DKK">2337.50</cbc:PrepaidAmount>
<cbc:PayableAmount currencyID="DKK">2337.50</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:Note>first line</cbc:Note>
<cbc:InvoicedQuantity unitCode="EA">1000</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">1000.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>ACC7654</cbc:AccountingCost>
<cac:InvoicePeriod>
<cbc:StartDate>2013-03-10</cbc:StartDate>
<cbc:EndDate>2013-04-10</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:DocumentReference>
<cbc:ID>Object2</cbc:ID>
</cac:DocumentReference>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>100</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Loyal customer</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>10</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="DKK">100.00</cbc:Amount>
<cbc:BaseAmount currencyID="DKK">1000.00</cbc:BaseAmount>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>ABL</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Packaging</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>10</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="DKK">100.00</cbc:Amount>
<cbc:BaseAmount currencyID="DKK">1000.00</cbc:BaseAmount>
</cac:AllowanceCharge>
<cac:Item>
<cbc:Description>Printing paper, 2mm</cbc:Description>
<cbc:Name>Printing paper</cbc:Name>
<cac:BuyersItemIdentification>
<cbc:ID>BUY123</cbc:ID>
</cac:BuyersItemIdentification>
<cac:SellersItemIdentification>
<cbc:ID>JB007</cbc:ID>
</cac:SellersItemIdentification>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">1234567890128</cbc:ID>
</cac:StandardItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="ZZZ">12344321</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>Thickness</cbc:Name>
<cbc:Value>2 mm</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">1.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="EA">1</cbc:BaseQuantity>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:Amount currencyID="DKK">0.10</cbc:Amount>
<cbc:BaseAmount currencyID="DKK">1.10</cbc:BaseAmount>
</cac:AllowanceCharge>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:Note>second line</cbc:Note>
<cbc:InvoicedQuantity unitCode="EA">100</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">500.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>ACC7654</cbc:AccountingCost>
<cac:InvoicePeriod>
<cbc:StartDate>2013-03-10</cbc:StartDate>
<cbc:EndDate>2013-04-10</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderLineReference>
<cbc:LineID>2</cbc:LineID>
</cac:OrderLineReference>
<cac:DocumentReference>
<cbc:ID>Object2</cbc:ID>
</cac:DocumentReference>
<cac:Item>
<cbc:Description>Parker Pen, Black, model Sansa</cbc:Description>
<cbc:Name>Parker Pen</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB008</cbc:ID>
</cac:SellersItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">5.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">500</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">2500.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>American Cookies</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>JB009</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>12</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">5.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>TOSL110</cbc:ID>
<cbc:IssueDate>2013-04-10</cbc:IssueDate>
<cbc:DueDate>2013-05-10</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>DKK</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PostalAddress>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>DK123456789MVA</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SellerCompany</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PostalAddress>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyercompany ltd</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="DKK">675.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">1500.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">375.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="DKK">2500.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="DKK">300.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>12</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="DKK">4000.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="DKK">4000.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="DKK">4675.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="DKK">4675.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1000</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">1000.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Printing paper</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">1.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">100</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">500.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Parker Pen</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">5.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">500</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="DKK">2500.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>American Cookies</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>12</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="DKK">5.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,153 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>INVOICE_test_7</cbc:ID>
<cbc:IssueDate>2013-03-11</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Testscenario 7</cbc:Note>
<cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode>
<cac:InvoicePeriod>
<cbc:StartDate>2013-01-01</cbc:StartDate>
<cbc:EndDate>2013-12-31</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderReference>
<cbc:ID>Order_9988_x</cbc:ID>
</cac:OrderReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID>5532331183</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>Civic Service Centre</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Main street 2, Building 4</cbc:StreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Sellercompany Incorporated</cbc:RegistrationName>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Anthon Larsen</cbc:Name>
<cbc:Telephone>4698989898</cbc:Telephone>
<cbc:ElectronicMail>Anthon@SellerCompany.se</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PostalAddress>
<cbc:StreetName>Anystreet 8</cbc:StreetName>
<cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>RegionB</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>THe Buyercompany</cbc:RegistrationName>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>A3150bdn</cbc:Name>
<cbc:Telephone>5121230</cbc:Telephone>
<cbc:ElectronicMail>john@buyercompany.no</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>SE1212341234123412</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>SEXDABCD</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 30 days</cbc:Note>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="SEK">0.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="SEK">3200.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="SEK">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>O</cbc:ID>
<cbc:TaxExemptionReason>Tax</cbc:TaxExemptionReason>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="SEK">3200.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="SEK">3200.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="SEK">3200.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="SEK">3200.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="SEK">2500.00</cbc:LineExtensionAmount>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Weight-based tax, vehicles >3000 KGM</cbc:Description>
<cbc:Name>Road tax</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>RT3000</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>O</cbc:ID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="SEK">2500.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="SEK">700.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Description>Annual registration fee</cbc:Description>
<cbc:Name>Road Register fee</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>REG</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>O</cbc:ID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="SEK">700.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,410 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>1100512149</cbc:ID>
<cbc:IssueDate>2014-11-10</cbc:IssueDate>
<cbc:DueDate>2014-11-24</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Periodieke afrekening
U vindt een toelichting op uw factuur via www.enexis.nl/factuur_grootzakelijk
Op alle diensten en overeenkomsten zijn de algemene voorwaarden aansluiting en
transport grootverbruik elektriciteit, respectievelijk gas van toepassing
www.enexis.nl</cbc:Note>
<cbc:TaxPointDate>2013-06-30</cbc:TaxPointDate>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:InvoicePeriod>
<cbc:StartDate>2014-08-01</cbc:StartDate>
<cbc:EndDate>2014-08-31</cbc:EndDate>
</cac:InvoicePeriod>
<cac:AdditionalDocumentReference>
<cbc:ID>871694831000290806</cbc:ID>
<cbc:DocumentDescription>ATS</cbc:DocumentDescription>
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Enexis</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Magistratenlaan 116</cbc:StreetName>
<cbc:CityName>'S-HERTOGENBOSCH</cbc:CityName>
<cbc:PostalZone>5223MB</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NL809561074B01</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Enexis B.V.</cbc:RegistrationName>
<cbc:CompanyID>17131139</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:ElectronicMail>klantenservice.zakelijk@enexis.nl</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyIdentification>
<cbc:ID>1081119</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Bedrijfslaan 4</cbc:StreetName>
<cbc:CityName>ONDERNEMERSTAD</cbc:CityName>
<cbc:PostalZone>9999 XX</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Klant</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cac:DeliveryLocation>
<cac:Address>
<cbc:StreetName>Bedrijfslaan 4,</cbc:StreetName>
<cbc:CityName>ONDERNEMERSTAD</cbc:CityName>
<cbc:PostalZone>9999 XX</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>1100512149</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>NL28RBOS0420242228</cbc:ID>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Enexis brengt wettelijke rente in rekening over te laat betaalde
facturen. Kijk voor informatie op www.enexis.nl/rentenota</cbc:Note>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">190.87</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">908.91</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">190.87</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">908.91</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">908.91</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">1099.78</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">1099.78</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="KWH">16000</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">140.80</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Getransporteerde kWhs</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>contract transportvermogen</cbc:Name>
<cbc:Value>132,00 kW</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>transporttarief</cbc:Name>
<cbc:Value>Netvlak MSD Enexis</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>netvlak</cbc:Name>
<cbc:Value>MS-D</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>correctiefactor</cbc:Name>
<cbc:Value>1,0130</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">0.00880</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="KWH">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="KWH">16000</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">16.16</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Systeemdiensten</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>contract transportvermogen</cbc:Name>
<cbc:Value>132,00 kW</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>transporttarief</cbc:Name>
<cbc:Value>Netvlak MSD Enexis</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>netvlak</cbc:Name>
<cbc:Value>MS-D</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>correctiefactor</cbc:Name>
<cbc:Value>1,0130</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">0.00101</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="KWH">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:InvoicedQuantity unitCode="KW">132</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">167.64</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Contract transportvermogen</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>contract transportvermogen</cbc:Name>
<cbc:Value>132,00 kW</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>transporttarief</cbc:Name>
<cbc:Value>Netvlak MSD Enexis</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>netvlak</cbc:Name>
<cbc:Value>MS-D</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>correctiefactor</cbc:Name>
<cbc:Value>1,0130</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">15.24</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="KW">12</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>4</cbc:ID>
<cbc:InvoicedQuantity unitCode="KW">58</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">88.74</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Maximaal afgenomen vermogen</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>contract transportvermogen</cbc:Name>
<cbc:Value>132,00 kW</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>transporttarief</cbc:Name>
<cbc:Value>Netvlak MSD Enexis</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>netvlak</cbc:Name>
<cbc:Value>MS-D</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>correctiefactor</cbc:Name>
<cbc:Value>1,0130</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">1.53</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="KW">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>5</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">36.75</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Vastrecht Transportdienst</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>contract transportvermogen</cbc:Name>
<cbc:Value>132,00 kW</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>transporttarief</cbc:Name>
<cbc:Value>Netvlak MSD Enexis</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>netvlak</cbc:Name>
<cbc:Value>MS-D</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>correctiefactor</cbc:Name>
<cbc:Value>1,0130</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">441.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">12</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>6</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">56.50</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Vastrecht Aansluitdienst</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>contract transportvermogen</cbc:Name>
<cbc:Value>132,00 kW</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>transporttarief</cbc:Name>
<cbc:Value>Netvlak MSD Enexis</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>netvlak</cbc:Name>
<cbc:Value>MS-D</cbc:Value>
</cac:AdditionalItemProperty>
<cac:AdditionalItemProperty>
<cbc:Name>correctiefactor</cbc:Name>
<cbc:Value>1,0130</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">678.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">12</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>7</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">83.34</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Huur Transformatoren</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">83.34</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>8</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">190.31</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Huur Schakelinstallaties</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">190.31</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>9</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">64.21</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Huur Overige Apparaten</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">64.21</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>10</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">64.46</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Huur Meterdiensten</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">64.46</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed under European Union Public Licence (EUPL) version 1.2.
-->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns:qdt="urn:oasis:names:specification:ubl:schema:xsd:QualifiedDataTypes-2"
xmlns:udt="urn:oasis:names:specification:ubl:schema:xsd:UnqualifiedDataTypes-2"
xmlns:ccts="urn:un:unece:uncefact:documentation:2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>20150483</cbc:ID>
<cbc:IssueDate>2015-04-01</cbc:IssueDate>
<cbc:DueDate>2015-04-14</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Vriendelijk verzoeken wij u ervoor te zorgen dat het bedrag voor de vervaldatum op onze rekening staat onder vermelding van
het factuurnummer. Het bankrekeningnummer is 37.78.15.500, Rabobank, t.n.v. Bluem te Amersfoort. Reclames gaarne binnen
10 dagen. Gelieve bij navraag en correspondentie uw firma naam en factuurnummer vermelden.
</cbc:Note>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:InvoicePeriod>
<cbc:StartDate>2016-04-01</cbc:StartDate>
<cbc:EndDate>2016-06-30</cbc:EndDate>
</cac:InvoicePeriod>
<cac:ContractDocumentReference>
<cbc:ID>iExpress 20110412</cbc:ID>
</cac:ContractDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PostalAddress>
<cbc:StreetName>Lindeboomseweg 41</cbc:StreetName>
<cbc:CityName>Amersfoort</cbc:CityName>
<cbc:PostalZone>3825 AL</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>NL809163160B01</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Bluem BV</cbc:RegistrationName>
<cbc:CompanyID>32081330 Amersfoort</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Telephone>033-4549055</cbc:Telephone>
<cbc:ElectronicMail>info@bluem.nl</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PostalAddress>
<cbc:StreetName>Henry Dunantweg 42</cbc:StreetName>
<cbc:CityName>Alphen aan den Rijn</cbc:CityName>
<cbc:PostalZone>2402 NR</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>NL</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Provide Verzekeringen</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cbc:PaymentID>2015 0483 0000 0000</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>NL13RABO0377815500</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>RABONL2U</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">30.87</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">147.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">30.87</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">147.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">147.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">177.87</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="EUR">177.87</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="MON">3</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">147.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>IExpress licentiekosten</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>21</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>Verbruikscategorie</cbc:Name>
<cbc:Value>Start</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">49.00</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="MON">1</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
+24
View File
@@ -0,0 +1,24 @@
{
"downloadDate": "2025-08-11T11:33:26.324Z",
"sources": [
{
"name": "PEPPOL BIS 3.0 Examples",
"repository": "OpenPEPPOL/peppol-bis-invoice-3",
"branch": "master",
"fileCount": 10
},
{
"name": "CEN TC434 Test Files",
"repository": "ConnectingEurope/eInvoicing-EN16931",
"branch": "master",
"fileCount": 18
},
{
"name": "PEPPOL Validation Artifacts",
"repository": "OpenPEPPOL/peppol-bis-invoice-3",
"branch": "master",
"fileCount": 1
}
],
"totalFiles": 29
}
@@ -0,0 +1,370 @@
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>Snippet1</cbc:ID>
<cbc:IssueDate>2017-11-13</cbc:IssueDate>
<cbc:DueDate>2017-12-01</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:Note>Please note we have a new phone number: 22 22 22 22</cbc:Note>
<cbc:TaxPointDate>2017-12-01</cbc:TaxPointDate>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>SEK</cbc:TaxCurrencyCode>
<cbc:AccountingCost>4025:123:4343</cbc:AccountingCost>
<cbc:BuyerReference>0150abc</cbc:BuyerReference>
<cac:InvoicePeriod>
<cbc:StartDate>2017-12-01</cbc:StartDate>
<cbc:EndDate>2017-12-31</cbc:EndDate>
</cac:InvoicePeriod>
<cac:ContractDocumentReference>
<cbc:ID>framework no 1</cbc:ID>
</cac:ContractDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID schemeID="ABT">DR35141</cbc:ID>
<cbc:DocumentTypeCode>130</cbc:DocumentTypeCode>
</cac:AdditionalDocumentReference>
<cac:AdditionalDocumentReference>
<cbc:ID>ts12345</cbc:ID>
<cbc:DocumentDescription>Technical specification</cbc:DocumentDescription>
<cac:Attachment>
<cac:ExternalReference>
<cbc:URI>www.techspec.no</cbc:URI>
</cac:ExternalReference>
</cac:Attachment>
</cac:AdditionalDocumentReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">7300010000001</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID>99887766</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>SupplierTradingName Ltd.</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Main street 1</cbc:StreetName>
<cbc:AdditionalStreetName>Postbox 123</cbc:AdditionalStreetName>
<cbc:CityName>London</cbc:CityName>
<cbc:PostalZone>GB 123 EW</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>GB</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>GB1232434</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SupplierOfficialName Ltd</cbc:RegistrationName>
<cbc:CompanyID>GB983294</cbc:CompanyID>
<cbc:CompanyLegalForm>AdditionalLegalInformation</cbc:CompanyLegalForm>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0002">4598375937</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID schemeID="0002">4598375937</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>BuyerTradingName AS</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Hovedgatan 32</cbc:StreetName>
<cbc:AdditionalStreetName>Po box 878</cbc:AdditionalStreetName>
<cbc:CityName>Stockholm</cbc:CityName>
<cbc:PostalZone>456 34</cbc:PostalZone>
<cbc:CountrySubentity>Södermalm</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>SE4598375937</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyer Official Name</cbc:RegistrationName>
<cbc:CompanyID schemeID="0183">39937423947</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Lisa Johnson</cbc:Name>
<cbc:Telephone>23434234</cbc:Telephone>
<cbc:ElectronicMail>lj@buyer.se</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2017-11-01</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cbc:ID schemeID="0088">7300010000001</cbc:ID>
<cac:Address>
<cbc:StreetName>Delivery street 2</cbc:StreetName>
<cbc:AdditionalStreetName>Building 56</cbc:AdditionalStreetName>
<cbc:CityName>Stockholm</cbc:CityName>
<cbc:PostalZone>21234</cbc:PostalZone>
<cbc:CountrySubentity>Södermalm</cbc:CountrySubentity>
<cac:AddressLine>
<cbc:Line>Gate 15</cbc:Line>
</cac:AddressLine>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
<cac:DeliveryParty>
<cac:PartyName>
<cbc:Name>Delivery party Name</cbc:Name>
</cac:PartyName>
</cac:DeliveryParty>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode name="Credit transfer">30</cbc:PaymentMeansCode>
<cbc:PaymentID>Snippet1</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>IBAN32423940</cbc:ID>
<cbc:Name>AccountName</cbc:Name>
<cac:FinancialInstitutionBranch>
<cbc:ID>BIC324098</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 10 days, 2% discount</cbc:Note>
</cac:PaymentTerms>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>CG</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Cleaning</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>20</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="EUR">200</cbc:Amount>
<cbc:BaseAmount currencyID="EUR">1000</cbc:BaseAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Discount</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="EUR">200</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">1225.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">4900.0</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">1225</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">1000.0</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">0</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>E</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cbc:TaxExemptionReason>Reason for tax exempt</cbc:TaxExemptionReason>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:TaxTotal>
<cbc:TaxAmount currencyID ="SEK">9324.00</cbc:TaxAmount>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">5900</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">5900</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">7125</cbc:TaxInclusiveAmount>
<cbc:AllowanceTotalAmount currencyID="EUR">200</cbc:AllowanceTotalAmount>
<cbc:ChargeTotalAmount currencyID="EUR">200</cbc:ChargeTotalAmount>
<cbc:PrepaidAmount currencyID="EUR">1000</cbc:PrepaidAmount>
<cbc:PayableAmount currencyID="EUR">6125.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:Note>Testing note on line level</cbc:Note>
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">4000.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>Konteringsstreng</cbc:AccountingCost>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>CG</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Cleaning</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>1</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="EUR">1</cbc:Amount>
<cbc:BaseAmount currencyID="EUR">100</cbc:BaseAmount>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Discount</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="EUR">101</cbc:Amount>
</cac:AllowanceCharge>
<cac:Item>
<cbc:Description>Description of item</cbc:Description>
<cbc:Name>item name</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>97iugug876</cbc:ID>
</cac:SellersItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">09348023</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">410</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="C62">1</cbc:BaseQuantity>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:Amount currencyID="EUR">40</cbc:Amount>
<cbc:BaseAmount currencyID="EUR">450</cbc:BaseAmount>
</cac:AllowanceCharge>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:Note>Testing note on line level</cbc:Note>
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">1000.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>Konteringsstreng</cbc:AccountingCost>
<cac:InvoicePeriod>
<cbc:StartDate>2017-12-01</cbc:StartDate>
<cbc:EndDate>2017-12-05</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderLineReference>
<cbc:LineID>124</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Description of item</cbc:Description>
<cbc:Name>item name</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>97iugug876</cbc:ID>
</cac:SellersItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">86776</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>E</cbc:ID>
<cbc:Percent>0.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>AdditionalItemName</cbc:Name>
<cbc:Value>AdditionalItemValue</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">200</cbc:PriceAmount>
<cbc:BaseQuantity unitCode="C62">2</cbc:BaseQuantity>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>3</cbc:ID>
<cbc:Note>Testing note on line level</cbc:Note>
<cbc:InvoicedQuantity unitCode="C62">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">900.00</cbc:LineExtensionAmount>
<cbc:AccountingCost>Konteringsstreng</cbc:AccountingCost>
<cac:InvoicePeriod>
<cbc:StartDate>2017-12-01</cbc:StartDate>
<cbc:EndDate>2017-12-05</cbc:EndDate>
</cac:InvoicePeriod>
<cac:OrderLineReference>
<cbc:LineID>124</cbc:LineID>
</cac:OrderLineReference>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>CG</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Charge</cbc:AllowanceChargeReason>
<cbc:MultiplierFactorNumeric>1</cbc:MultiplierFactorNumeric>
<cbc:Amount currencyID="EUR">1</cbc:Amount>
<cbc:BaseAmount currencyID="EUR">100</cbc:BaseAmount>
</cac:AllowanceCharge>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>false</cbc:ChargeIndicator>
<cbc:AllowanceChargeReasonCode>95</cbc:AllowanceChargeReasonCode>
<cbc:AllowanceChargeReason>Discount</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="EUR">101</cbc:Amount>
</cac:AllowanceCharge>
<cac:Item>
<cbc:Description>Description of item</cbc:Description>
<cbc:Name>item name</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>97iugug876</cbc:ID>
</cac:SellersItemIdentification>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">86776</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
<cac:AdditionalItemProperty>
<cbc:Name>AdditionalItemName</cbc:Name>
<cbc:Value>AdditionalItemValue</cbc:Value>
</cac:AdditionalItemProperty>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">100</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
+210
View File
@@ -0,0 +1,210 @@
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>Snippet1</cbc:ID>
<cbc:IssueDate>2017-11-13</cbc:IssueDate>
<cbc:DueDate>2017-12-01</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:AccountingCost>4025:123:4343</cbc:AccountingCost>
<cbc:BuyerReference>0150abc</cbc:BuyerReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">9482348239847239874</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID>99887766</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>SupplierTradingName Ltd.</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Main street 1</cbc:StreetName>
<cbc:AdditionalStreetName>Postbox 123</cbc:AdditionalStreetName>
<cbc:CityName>London</cbc:CityName>
<cbc:PostalZone>GB 123 EW</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>GB</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>GB1232434</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SupplierOfficialName Ltd</cbc:RegistrationName>
<cbc:CompanyID>GB983294</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0002">FR23342</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID schemeID="0002">FR23342</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>BuyerTradingName AS</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Hovedgatan 32</cbc:StreetName>
<cbc:AdditionalStreetName>Po box 878</cbc:AdditionalStreetName>
<cbc:CityName>Stockholm</cbc:CityName>
<cbc:PostalZone>456 34</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>SE4598375937</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyer Official Name</cbc:RegistrationName>
<cbc:CompanyID schemeID="0183">39937423947</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Lisa Johnson</cbc:Name>
<cbc:Telephone>23434234</cbc:Telephone>
<cbc:ElectronicMail>lj@buyer.se</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2017-11-01</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cbc:ID schemeID="0088">9483759475923478</cbc:ID>
<cac:Address>
<cbc:StreetName>Delivery street 2</cbc:StreetName>
<cbc:AdditionalStreetName>Building 56</cbc:AdditionalStreetName>
<cbc:CityName>Stockholm</cbc:CityName>
<cbc:PostalZone>21234</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
<cac:DeliveryParty>
<cac:PartyName>
<cbc:Name>Delivery party Name</cbc:Name>
</cac:PartyName>
</cac:DeliveryParty>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode name="Credit transfer">30</cbc:PaymentMeansCode>
<cbc:PaymentID>Snippet1</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>IBAN32423940</cbc:ID>
<cbc:Name>AccountName</cbc:Name>
<cac:FinancialInstitutionBranch>
<cbc:ID>BIC324098</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 10 days, 2% discount</cbc:Note>
</cac:PaymentTerms>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReason>Insurance</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="EUR">25</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">331.25</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">1325</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">331.25</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">1300</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">1325</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">1656.25</cbc:TaxInclusiveAmount>
<cbc:ChargeTotalAmount currencyID="EUR">25</cbc:ChargeTotalAmount>
<cbc:PayableAmount currencyID="EUR">1656.25</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="DAY">7</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID= "EUR">2800</cbc:LineExtensionAmount>
<cbc:AccountingCost>Konteringsstreng</cbc:AccountingCost>
<cac:OrderLineReference>
<cbc:LineID>123</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Description of item</cbc:Description>
<cbc:Name>item name</cbc:Name>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">21382183120983</cbc:ID>
</cac:StandardItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">09348023</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">400</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="DAY">-3</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">-1500</cbc:LineExtensionAmount>
<cac:OrderLineReference>
<cbc:LineID>123</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Description 2</cbc:Description>
<cbc:Name>item name 2</cbc:Name>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">21382183120983</cbc:ID>
</cac:StandardItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">09348023</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">500</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
@@ -0,0 +1,215 @@
<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>Correction1</cbc:ID>
<cbc:IssueDate>2017-11-13</cbc:IssueDate>
<cbc:DueDate>2017-12-01</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cbc:AccountingCost>4025:123:4343</cbc:AccountingCost>
<cbc:BuyerReference>0150abc</cbc:BuyerReference>
<cac:BillingReference>
<cac:InvoiceDocumentReference>
<cbc:ID>Snippet1</cbc:ID>
</cac:InvoiceDocumentReference>
</cac:BillingReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">9482348239847239874</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID>99887766</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>SupplierTradingName Ltd.</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Main street 1</cbc:StreetName>
<cbc:AdditionalStreetName>Postbox 123</cbc:AdditionalStreetName>
<cbc:CityName>London</cbc:CityName>
<cbc:PostalZone>GB 123 EW</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>GB</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>GB1232434</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>SupplierOfficialName Ltd</cbc:RegistrationName>
<cbc:CompanyID>GB983294</cbc:CompanyID>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0002">FR23342</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID schemeID="0002">FR23342</cbc:ID>
</cac:PartyIdentification>
<cac:PartyName>
<cbc:Name>BuyerTradingName AS</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Hovedgatan 32</cbc:StreetName>
<cbc:AdditionalStreetName>Po box 878</cbc:AdditionalStreetName>
<cbc:CityName>Stockholm</cbc:CityName>
<cbc:PostalZone>456 34</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>SE4598375937</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>Buyer Official Name</cbc:RegistrationName>
<cbc:CompanyID schemeID="0183">39937423947</cbc:CompanyID>
</cac:PartyLegalEntity>
<cac:Contact>
<cbc:Name>Lisa Johnson</cbc:Name>
<cbc:Telephone>23434234</cbc:Telephone>
<cbc:ElectronicMail>lj@buyer.se</cbc:ElectronicMail>
</cac:Contact>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:Delivery>
<cbc:ActualDeliveryDate>2017-11-01</cbc:ActualDeliveryDate>
<cac:DeliveryLocation>
<cbc:ID schemeID="0088">9483759475923478</cbc:ID>
<cac:Address>
<cbc:StreetName>Delivery street 2</cbc:StreetName>
<cbc:AdditionalStreetName>Building 56</cbc:AdditionalStreetName>
<cbc:CityName>Stockholm</cbc:CityName>
<cbc:PostalZone>21234</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:Address>
</cac:DeliveryLocation>
<cac:DeliveryParty>
<cac:PartyName>
<cbc:Name>Delivery party Name</cbc:Name>
</cac:PartyName>
</cac:DeliveryParty>
</cac:Delivery>
<cac:PaymentMeans>
<cbc:PaymentMeansCode name="Credit transfer">30</cbc:PaymentMeansCode>
<cbc:PaymentID>Snippet1</cbc:PaymentID>
<cac:PayeeFinancialAccount>
<cbc:ID>IBAN32423940</cbc:ID>
<cbc:Name>AccountName</cbc:Name>
<cac:FinancialInstitutionBranch>
<cbc:ID>BIC324098</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 10 days, 2% discount</cbc:Note>
</cac:PaymentTerms>
<cac:AllowanceCharge>
<cbc:ChargeIndicator>true</cbc:ChargeIndicator>
<cbc:AllowanceChargeReason>Insurance</cbc:AllowanceChargeReason>
<cbc:Amount currencyID="EUR">-25</cbc:Amount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:AllowanceCharge>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">-331.25</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">-1325</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">-331.25</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="EUR">-1300</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="EUR">-1325</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">-1656.25</cbc:TaxInclusiveAmount>
<cbc:ChargeTotalAmount currencyID="EUR">-25</cbc:ChargeTotalAmount>
<cbc:PayableAmount currencyID="EUR">-1656.25</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="DAY">-7</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID= "EUR">-2800</cbc:LineExtensionAmount>
<cbc:AccountingCost>Konteringsstreng</cbc:AccountingCost>
<cac:OrderLineReference>
<cbc:LineID>123</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Description of item</cbc:Description>
<cbc:Name>item name</cbc:Name>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">21382183120983</cbc:ID>
</cac:StandardItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">09348023</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">400</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
<cac:InvoiceLine>
<cbc:ID>2</cbc:ID>
<cbc:InvoicedQuantity unitCode="DAY">3</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">1500</cbc:LineExtensionAmount>
<cac:OrderLineReference>
<cbc:LineID>123</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Description 2</cbc:Description>
<cbc:Name>item name 2</cbc:Name>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0088">21382183120983</cbc:ID>
</cac:StandardItemIdentification>
<cac:OriginCountry>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:OriginCountry>
<cac:CommodityClassification>
<cbc:ItemClassificationCode listID="SRV">09348023</cbc:ItemClassificationCode>
</cac:CommodityClassification>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>25.0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">500</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
+114
View File
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- PEPPOL BIS Billing, testfile showing the use of VAT category Z (Zero rated goods) -->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>Vat-Z</cbc:ID>
<cbc:IssueDate>2018-08-30</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>GBP</cbc:DocumentCurrencyCode>
<cbc:BuyerReference>test reference</cbc:BuyerReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">7300010000001</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID>7300010000001</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Main street 2, Building 4</cbc:StreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>GB</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>GB928741974</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Sellercompany Incorporated</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0184">12345678</cbc:EndpointID>
<cac:PostalAddress>
<cbc:StreetName>Anystreet 8</cbc:StreetName>
<cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>RegionB</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Buyercompany</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>SE1212341234123412</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>SEXDABCD</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 30 days</cbc:Note>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="GBP">0.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="GBP">1200.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="GBP">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>E</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cbc:TaxExemptionReasonCode>VATEX-EU-F</cbc:TaxExemptionReasonCode>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="GBP">1200.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="GBP">1200.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="GBP">1200.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="GBP">1200.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="GBP">1200.00</cbc:LineExtensionAmount>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Name>Test item, category Z</cbc:Name>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0160">192387129837129873</cbc:ID>
</cac:StandardItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>E</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="GBP">120.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
+107
View File
@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- PEPPOL BIS Billing, testfile showing the use of VAT category O (Outside scope of VAT) -->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>Vat-O</cbc:ID>
<cbc:IssueDate>2018-08-30</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>SEK</cbc:DocumentCurrencyCode>
<cbc:BuyerReference>test reference</cbc:BuyerReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">7300010000001</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID>7300010000001</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Main street 2, Building 4</cbc:StreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>SE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Sellercompany Incorporated</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0192">987654325</cbc:EndpointID>
<cac:PostalAddress>
<cbc:StreetName>Anystreet 8</cbc:StreetName>
<cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>RegionB</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>NO</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Buyercompany</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>SE1212341234123412</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>SEXDABCD</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 30 days</cbc:Note>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="SEK">0.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="SEK">3200.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="SEK">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>O</cbc:ID>
<cbc:TaxExemptionReason>Not subject to VAT</cbc:TaxExemptionReason>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="SEK">3200.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="SEK">3200.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="SEK">3200.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="SEK">3200.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="SEK">3200.00</cbc:LineExtensionAmount>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Description>Weight-based tax, vehicles >3000 KGM</cbc:Description>
<cbc:Name>Road tax</cbc:Name>
<cac:SellersItemIdentification>
<cbc:ID>RT3000</cbc:ID>
</cac:SellersItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>O</cbc:ID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="SEK">3200.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- PEPPOL BIS Billing, testfile showing the use of VAT category Z (Zero rated goods) -->
<Invoice xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2"
xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>Vat-Z</cbc:ID>
<cbc:IssueDate>2018-08-30</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>GBP</cbc:DocumentCurrencyCode>
<cbc:BuyerReference>test reference</cbc:BuyerReference>
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">7300010000001</cbc:EndpointID>
<cac:PartyIdentification>
<cbc:ID>7300010000001</cbc:ID>
</cac:PartyIdentification>
<cac:PostalAddress>
<cbc:StreetName>Main street 2, Building 4</cbc:StreetName>
<cbc:CityName>Big city</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>GB</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyTaxScheme>
<cbc:CompanyID>GB928741974</cbc:CompanyID>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:PartyTaxScheme>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Sellercompany Incorporated</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cbc:EndpointID schemeID="0184">12345678</cbc:EndpointID>
<cac:PostalAddress>
<cbc:StreetName>Anystreet 8</cbc:StreetName>
<cbc:AdditionalStreetName>Back door</cbc:AdditionalStreetName>
<cbc:CityName>Anytown</cbc:CityName>
<cbc:PostalZone>101</cbc:PostalZone>
<cbc:CountrySubentity>RegionB</cbc:CountrySubentity>
<cac:Country>
<cbc:IdentificationCode>DK</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
<cac:PartyLegalEntity>
<cbc:RegistrationName>The Buyercompany</cbc:RegistrationName>
</cac:PartyLegalEntity>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:PaymentMeans>
<cbc:PaymentMeansCode>30</cbc:PaymentMeansCode>
<cac:PayeeFinancialAccount>
<cbc:ID>SE1212341234123412</cbc:ID>
<cac:FinancialInstitutionBranch>
<cbc:ID>SEXDABCD</cbc:ID>
</cac:FinancialInstitutionBranch>
</cac:PayeeFinancialAccount>
</cac:PaymentMeans>
<cac:PaymentTerms>
<cbc:Note>Payment within 30 days</cbc:Note>
</cac:PaymentTerms>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="GBP">0.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="GBP">1200.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="GBP">0.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>Z</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:LineExtensionAmount currencyID="GBP">1200.00</cbc:LineExtensionAmount>
<cbc:TaxExclusiveAmount currencyID="GBP">1200.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="GBP">1200.00</cbc:TaxInclusiveAmount>
<cbc:PayableAmount currencyID="GBP">1200.00</cbc:PayableAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="EA">10</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="GBP">1200.00</cbc:LineExtensionAmount>
<cac:OrderLineReference>
<cbc:LineID>1</cbc:LineID>
</cac:OrderLineReference>
<cac:Item>
<cbc:Name>Test item, category Z</cbc:Name>
<cac:StandardItemIdentification>
<cbc:ID schemeID="0160">192387129837129873</cbc:ID>
</cac:StandardItemIdentification>
<cac:ClassifiedTaxCategory>
<cbc:ID>Z</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cac:TaxScheme>
<cbc:ID>VAT</cbc:ID>
</cac:TaxScheme>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="GBP">120.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>
+16
View File
@@ -0,0 +1,16 @@
import { EInvoice } from '../../dist_ts/index.js';
import * as plugins from '../../dist_ts/plugins.js';
const invoice = new EInvoice();
invoice.pdf = undefined;
invoice.pdfAttachments = undefined;
const parser = new plugins.DOMParser();
const parserFromNamespace = new plugins.xmldom.DOMParser();
void invoice;
void parser;
void parserFromNamespace;
void plugins.pako.inflate;
void plugins.SaxonJS.compile;
void plugins.SaxonJS.transform;
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": false,
"noEmit": true,
"types": ["node"]
},
"include": [
"./index.ts"
]
}
@@ -1,8 +1,8 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../../../ts/index.js';
import { InvoiceFormat, ValidationLevel } from '../../../ts/interfaces/common.js';
import { InvoiceFormat } from '../../../ts/interfaces/common.js';
import { FormatDetector } from '../../../ts/formats/utils/format.detector.js';
import { CorpusLoader } from '../../helpers/corpus.loader.js';
import { PerformanceTracker } from '../../helpers/performance.tracker.js';
import * as path from 'path';
/**
@@ -15,264 +15,40 @@ import * as path from 'path';
*/
tap.test('CORP-05: FatturaPA Corpus Processing - should process Italian FatturaPA files', async () => {
// Load FatturaPA test files
const fatturapaFiles = await CorpusLoader.loadCategory('FATTURAPA_OFFICIAL');
// Handle case where no files are found
if (fatturapaFiles.length === 0) {
console.log('⚠ No FatturaPA files found in corpus - skipping test');
return;
}
console.log(`Testing ${fatturapaFiles.length} FatturaPA files`);
const results = {
total: fatturapaFiles.length,
successful: 0,
failed: 0,
parseErrors: 0,
validationErrors: 0,
documentTypes: new Map<string, number>(),
transmissionFormats: new Map<string, number>(),
processingTimes: [] as number[]
};
const failures: Array<{
file: string;
error: string;
type: 'parse' | 'validation' | 'format';
}> = [];
// Italian-specific validation patterns
const italianValidations = {
vatNumber: /^IT\d{11}$/,
fiscalCode: /^[A-Z]{6}\d{2}[A-Z]\d{2}[A-Z]\d{3}[A-Z]$/,
invoiceNumber: /^\w+\/\d{4}$/, // Common format: PREFIX/YEAR
codiceDestinatario: /^[A-Z0-9]{6,7}$/,
pecEmail: /^[a-zA-Z0-9._%+-]+@pec\.[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
};
let detectedCount = 0;
let unsupportedDecodeCount = 0;
for (const file of fatturapaFiles) {
try {
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
// Track performance
const { result: invoice, metric } = await PerformanceTracker.track(
'fatturapa-processing',
async () => {
const einvoice = new EInvoice();
// FatturaPA has specific XML structure
if (xmlString.includes('FatturaElettronica')) {
// Process as FatturaPA
await einvoice.fromXmlString(xmlString);
einvoice.metadata = {
...einvoice.metadata,
format: InvoiceFormat.FATTURAPA
};
} else {
throw new Error('Not a valid FatturaPA file');
}
return einvoice;
},
{ file: file.path, size: file.size }
);
results.processingTimes.push(metric.duration);
// Extract FatturaPA specific information
const formatMatch = xmlString.match(/<FormatoTrasmissione>([^<]+)<\/FormatoTrasmissione>/);
const typeMatch = xmlString.match(/<TipoDocumento>([^<]+)<\/TipoDocumento>/);
if (formatMatch) {
const format = formatMatch[1];
results.transmissionFormats.set(format, (results.transmissionFormats.get(format) || 0) + 1);
}
if (typeMatch) {
const docType = typeMatch[1];
results.documentTypes.set(docType, (results.documentTypes.get(docType) || 0) + 1);
}
// Validate Italian-specific fields
const vatMatch = xmlString.match(/<IdCodice>(\d{11})<\/IdCodice>/);
const cfMatch = xmlString.match(/<CodiceFiscale>([A-Z0-9]{16})<\/CodiceFiscale>/);
const destMatch = xmlString.match(/<CodiceDestinatario>([A-Z0-9]{6,7})<\/CodiceDestinatario>/);
let italianFieldsValid = true;
if (vatMatch && !italianValidations.vatNumber.test('IT' + vatMatch[1])) {
italianFieldsValid = false;
console.log(` - Invalid VAT number format: ${vatMatch[1]}`);
}
if (cfMatch && !italianValidations.fiscalCode.test(cfMatch[1])) {
italianFieldsValid = false;
console.log(` - Invalid Codice Fiscale format: ${cfMatch[1]}`);
}
if (destMatch && !italianValidations.codiceDestinatario.test(destMatch[1])) {
italianFieldsValid = false;
console.log(` - Invalid Codice Destinatario: ${destMatch[1]}`);
}
// Validate the parsed invoice
try {
const validationResult = await invoice.validate(ValidationLevel.BUSINESS);
if (validationResult.valid && italianFieldsValid) {
results.successful++;
console.log(`${path.basename(file.path)}: Successfully processed`);
// Log key information
if (formatMatch) {
console.log(` - Transmission format: ${formatMatch[1]}`);
}
if (typeMatch) {
const docTypeMap: Record<string, string> = {
'TD01': 'Fattura',
'TD02': 'Acconto/Anticipo',
'TD03': 'Acconto/Anticipo su parcella',
'TD04': 'Nota di Credito',
'TD05': 'Nota di Debito',
'TD06': 'Parcella'
};
console.log(` - Document type: ${docTypeMap[typeMatch[1]] || typeMatch[1]}`);
}
} else {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationResult.errors?.[0]?.message || 'Validation failed',
type: 'validation'
});
}
} catch (validationError: any) {
results.validationErrors++;
failures.push({
file: path.basename(file.path),
error: validationError.message,
type: 'validation'
});
}
} catch (error: any) {
results.failed++;
if (error.message.includes('Not a valid FatturaPA')) {
failures.push({
file: path.basename(file.path),
error: 'Invalid FatturaPA format',
type: 'format'
});
} else {
results.parseErrors++;
failures.push({
file: path.basename(file.path),
error: error.message,
type: 'parse'
});
}
console.log(`${path.basename(file.path)}: ${error.message}`);
}
}
// Summary report
console.log('\n=== FatturaPA Corpus Processing Summary ===');
console.log(`Total files: ${results.total}`);
console.log(`Successful: ${results.successful} (${(results.successful/results.total*100).toFixed(1)}%)`);
console.log(`Failed: ${results.failed}`);
console.log(` - Parse errors: ${results.parseErrors}`);
console.log(` - Validation errors: ${results.validationErrors}`);
console.log('\nTransmission Formats:');
results.transmissionFormats.forEach((count, format) => {
const formatMap: Record<string, string> = {
'FPA12': 'Pubblica Amministrazione',
'FPR12': 'Privati',
'SDI11': 'Sistema di Interscambio v1.1'
};
console.log(` - ${format}: ${formatMap[format] || format} (${count} files)`);
});
console.log('\nDocument Types:');
results.documentTypes.forEach((count, type) => {
const typeMap: Record<string, string> = {
'TD01': 'Fattura (Invoice)',
'TD02': 'Acconto/Anticipo (Advance)',
'TD03': 'Acconto/Anticipo su parcella',
'TD04': 'Nota di Credito (Credit Note)',
'TD05': 'Nota di Debito (Debit Note)',
'TD06': 'Parcella (Fee Note)'
};
console.log(` - ${type}: ${typeMap[type] || type} (${count} files)`);
});
if (failures.length > 0) {
console.log('\nFailure Details:');
failures.forEach(f => {
console.log(` ${f.file} [${f.type}]: ${f.error}`);
});
}
// Performance metrics
if (results.processingTimes.length > 0) {
const avgTime = results.processingTimes.reduce((a, b) => a + b, 0) / results.processingTimes.length;
const minTime = Math.min(...results.processingTimes);
const maxTime = Math.max(...results.processingTimes);
console.log('\nPerformance Metrics:');
console.log(` Average processing time: ${avgTime.toFixed(2)}ms`);
console.log(` Min time: ${minTime.toFixed(2)}ms`);
console.log(` Max time: ${maxTime.toFixed(2)}ms`);
}
// FatturaPA specific features validation
if (results.successful > 0 && fatturapaFiles.length > 0) {
// Test a sample file for specific features
const sampleFile = fatturapaFiles[0];
const xmlBuffer = await CorpusLoader.loadFile(sampleFile.path);
const xmlBuffer = await CorpusLoader.loadFile(file.path);
const xmlString = xmlBuffer.toString('utf-8');
console.log('\nFatturaPA Structure Analysis:');
// Check for mandatory sections
const mandatorySections = [
'FatturaElettronicaHeader',
'CedentePrestatore', // Seller
'CessionarioCommittente', // Buyer
'FatturaElettronicaBody',
'DatiGenerali',
'DatiBeniServizi'
];
for (const section of mandatorySections) {
if (xmlString.includes(section)) {
console.log(`✓ Contains mandatory section: ${section}`);
}
}
// Check for digital signature block
if (xmlString.includes('<ds:Signature') || xmlString.includes('<Signature')) {
console.log('✓ Contains digital signature block');
const fileName = path.basename(file.path);
const format = FormatDetector.detectFormat(xmlString);
expect(format).toEqual(InvoiceFormat.FATTURAPA);
detectedCount++;
try {
await EInvoice.fromXml(xmlString);
expect(true).toBeFalse();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
expect(errorMessage.includes('FatturaPA decoder not yet implemented')).toBeTrue();
unsupportedDecodeCount++;
console.log(`${fileName}: Detection works and decode is explicitly unsupported`);
}
}
// Check if all failures are due to unimplemented decoder
const allNotImplemented = failures.every(f => f.error.includes('decoder not yet implemented'));
if (allNotImplemented && results.successful === 0) {
console.log('\n⚠ FatturaPA decoder not yet implemented - test skipped');
console.log(' This test will validate files once FatturaPA decoder is implemented');
return; // Skip success criteria
}
// Success criteria: at least 70% should pass (FatturaPA is complex)
const successRate = results.successful / results.total;
expect(successRate).toBeGreaterThan(0.7);
expect(detectedCount).toEqual(fatturapaFiles.length);
expect(unsupportedDecodeCount).toEqual(fatturapaFiles.length);
});
tap.start();
tap.start();
@@ -3,6 +3,7 @@ import { promises as fs } from 'fs';
import * as path from 'path';
import { CorpusLoader } from '../../helpers/corpus.loader.js';
import { PerformanceTracker } from '../../helpers/performance.tracker.js';
import { InvoiceFormat } from '../../../ts/interfaces/common.js';
tap.test('FD-09: FatturaPA Format Detection - should correctly identify Italian FatturaPA invoices', async () => {
// Get FatturaPA test files from corpus
@@ -18,8 +19,14 @@ tap.test('FD-09: FatturaPA Format Detection - should correctly identify Italian
// Import the format detector
const { FormatDetector } = await import('../../../ts/formats/utils/format.detector.js');
const sampledFiles = allFatturapaFiles.slice(0, 10);
for (const filePath of allFatturapaFiles.slice(0, 10)) { // Test first 10 for performance
if (sampledFiles.length === 0) {
console.log('No FatturaPA corpus files available for detection test');
return;
}
for (const filePath of sampledFiles) {
const fileName = path.basename(filePath);
try {
@@ -35,9 +42,7 @@ tap.test('FD-09: FatturaPA Format Detection - should correctly identify Italian
{ file: fileName }
);
// Verify it's detected as FatturaPA
if (format.toString().toLowerCase().includes('fatturapa') ||
format.toString().toLowerCase().includes('fattura')) {
if (format === InvoiceFormat.FATTURAPA) {
successCount++;
console.log(`${fileName}: Correctly detected as FatturaPA`);
} else {
@@ -46,9 +51,9 @@ tap.test('FD-09: FatturaPA Format Detection - should correctly identify Italian
file: fileName,
error: `Detected as ${format} instead of FatturaPA`
});
console.log(` ${fileName}: Detected as ${format} (FatturaPA detection may need implementation)`);
console.log(` ${fileName}: Detected as ${format} instead of FatturaPA`);
}
} catch (error) {
} catch (error: any) {
failureCount++;
failures.push({
file: fileName,
@@ -78,13 +83,8 @@ tap.test('FD-09: FatturaPA Format Detection - should correctly identify Italian
console.log(` P95: ${perfSummary.p95.toFixed(2)}ms`);
}
// Note: FatturaPA detection may not be fully implemented yet
if (successCount === 0 && allFatturapaFiles.length > 0) {
console.log('Note: FatturaPA format detection may need implementation');
}
// Expect at least some files to be processed without error
expect(successCount + failureCount).toBeGreaterThan(0);
expect(successCount).toEqual(sampledFiles.length);
expect(failureCount).toEqual(0);
});
tap.test('FD-09: FatturaPA Structure Detection - should detect FatturaPA by root element', async () => {
@@ -129,20 +129,8 @@ tap.test('FD-09: FatturaPA Structure Detection - should detect FatturaPA by root
);
console.log(`${test.name}: Detected as ${format}`);
// Should detect as FatturaPA (if implemented) or at least not as other formats
const formatStr = format.toString().toLowerCase();
const isNotOtherFormats = !formatStr.includes('ubl') &&
!formatStr.includes('cii') &&
!formatStr.includes('zugferd');
if (formatStr.includes('fattura')) {
console.log(` ✓ Correctly identified as FatturaPA`);
} else if (isNotOtherFormats) {
console.log(` ○ Not detected as other formats (FatturaPA detection may need implementation)`);
} else {
console.log(` ✗ Incorrectly detected as other format`);
}
expect(format).toEqual(InvoiceFormat.FATTURAPA);
console.log(' ✓ Correctly identified as FatturaPA');
}
});
@@ -181,14 +169,8 @@ tap.test('FD-09: FatturaPA Version Detection - should detect different FatturaPA
);
console.log(`FatturaPA ${test.version}: Detected as ${format}`);
// Should detect as FatturaPA regardless of version
const formatStr = format.toString().toLowerCase();
if (formatStr.includes('fattura')) {
console.log(` ✓ Version ${test.version} correctly detected`);
} else {
console.log(` ○ Version detection may need implementation`);
}
expect(format).toEqual(InvoiceFormat.FATTURAPA);
console.log(` ✓ Version ${test.version} correctly detected`);
}
});
@@ -229,16 +211,10 @@ tap.test('FD-09: FatturaPA vs Other Formats - should distinguish from other XML
);
console.log(`${test.name}: Detected as ${format}`);
const formatStr = format.toString().toLowerCase();
const hasExpectedFormat = formatStr.includes(test.expectedFormat);
if (hasExpectedFormat) {
console.log(` ✓ Correctly distinguished ${test.name}`);
} else {
console.log(` ○ Format distinction may need refinement`);
}
expect(format.toString().toLowerCase()).toContain(test.expectedFormat);
console.log(` ✓ Correctly distinguished ${test.name}`);
}
});
tap.start();
tap.start();
+172
View File
@@ -0,0 +1,172 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as path from 'path';
import * as fs from 'fs';
// Import conformance harness
import { ConformanceTestHarness, runConformanceTests } from '../ts/formats/validation/conformance.harness.js';
tap.test('Conformance Test Harness - initialization', async () => {
const harness = new ConformanceTestHarness();
expect(harness).toBeInstanceOf(ConformanceTestHarness);
});
tap.test('Conformance Test Harness - load test samples', async () => {
const harness = new ConformanceTestHarness();
// Check if test-samples directory exists
const samplesDir = path.join(process.cwd(), 'test-samples');
if (fs.existsSync(samplesDir)) {
await harness.loadTestSamples(samplesDir);
console.log('Test samples loaded successfully');
} else {
console.log('Test samples directory not found - skipping');
}
});
tap.test('Conformance Test Harness - run minimal test', async (tools) => {
const harness = new ConformanceTestHarness();
// Create a minimal test sample
const minimalUBL = `<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017</cbc:CustomizationID>
<cbc:ID>TEST-001</cbc:ID>
<cbc:IssueDate>2025-01-11</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>
<cac:AccountingSupplierParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Test Seller</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Test Street 1</cbc:StreetName>
<cbc:CityName>Test City</cbc:CityName>
<cbc:PostalZone>12345</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
</cac:Party>
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:Party>
<cac:PartyName>
<cbc:Name>Test Buyer</cbc:Name>
</cac:PartyName>
<cac:PostalAddress>
<cbc:StreetName>Test Street 2</cbc:StreetName>
<cbc:CityName>Test City</cbc:CityName>
<cbc:PostalZone>54321</cbc:PostalZone>
<cac:Country>
<cbc:IdentificationCode>DE</cbc:IdentificationCode>
</cac:Country>
</cac:PostalAddress>
</cac:Party>
</cac:AccountingCustomerParty>
<cac:TaxTotal>
<cbc:TaxAmount currencyID="EUR">19.00</cbc:TaxAmount>
<cac:TaxSubtotal>
<cbc:TaxableAmount currencyID="EUR">100.00</cbc:TaxableAmount>
<cbc:TaxAmount currencyID="EUR">19.00</cbc:TaxAmount>
<cac:TaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>19</cbc:Percent>
</cac:TaxCategory>
</cac:TaxSubtotal>
</cac:TaxTotal>
<cac:LegalMonetaryTotal>
<cbc:TaxExclusiveAmount currencyID="EUR">100.00</cbc:TaxExclusiveAmount>
<cbc:TaxInclusiveAmount currencyID="EUR">119.00</cbc:TaxInclusiveAmount>
</cac:LegalMonetaryTotal>
<cac:InvoiceLine>
<cbc:ID>1</cbc:ID>
<cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>
<cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>
<cac:Item>
<cbc:Name>Test Product</cbc:Name>
<cac:ClassifiedTaxCategory>
<cbc:ID>S</cbc:ID>
<cbc:Percent>19</cbc:Percent>
</cac:ClassifiedTaxCategory>
</cac:Item>
<cac:Price>
<cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
</cac:Price>
</cac:InvoiceLine>
</Invoice>`;
// Create temporary test directory
const tempDir = path.join(process.cwd(), '.nogit', 'test-conformance');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Write test file
const testFile = path.join(tempDir, 'minimal-test.xml');
fs.writeFileSync(testFile, minimalUBL);
// Create test sample metadata
const testSamples = [{
id: 'minimal-test',
name: 'minimal-test.xml',
path: testFile,
format: 'UBL' as const,
standard: 'EN16931',
expectedValid: false, // We expect some validation errors
description: 'Minimal test invoice'
}];
// Load test samples manually
(harness as any).testSamples = testSamples;
// Run conformance test
await harness.runConformanceTests();
// Generate coverage matrix
const coverage = harness.generateCoverageMatrix();
console.log(`Coverage: ${coverage.coveragePercentage.toFixed(1)}%`);
console.log(`Rules covered: ${coverage.coveredRules}/${coverage.totalRules}`);
// Clean up
fs.unlinkSync(testFile);
});
tap.test('Conformance Test Harness - coverage report generation', async () => {
const harness = new ConformanceTestHarness();
// Generate empty coverage report
const coverage = harness.generateCoverageMatrix();
expect(coverage.totalRules).toBeGreaterThan(100);
expect(coverage.coveredRules).toBeGreaterThanOrEqual(0);
expect(coverage.coveragePercentage).toBeGreaterThanOrEqual(0);
expect(coverage.byCategory.document.total).toBeGreaterThan(0);
expect(coverage.byCategory.calculation.total).toBeGreaterThan(0);
expect(coverage.byCategory.vat.total).toBeGreaterThan(0);
});
tap.test('Conformance Test Harness - full test suite', async (tools) => {
tools.timeout(60000); // 60 seconds timeout for full test
const samplesDir = path.join(process.cwd(), 'test-samples');
if (!fs.existsSync(samplesDir)) {
console.log('Test samples not found - skipping full conformance test');
console.log('Run: npm run download-test-samples');
return;
}
// Run full conformance test
console.log('\n=== Running Full Conformance Test Suite ===\n');
await runConformanceTests(samplesDir, true);
// Check if HTML report was generated
const reportPath = path.join(process.cwd(), 'coverage-report.html');
if (fs.existsSync(reportPath)) {
console.log(`\n✅ HTML report generated: ${reportPath}`);
}
});
export default tap.start();
+128
View File
@@ -0,0 +1,128 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
getCurrencyMinorUnits,
roundToCurrency,
getCurrencyTolerance,
areMonetaryValuesEqual,
CurrencyCalculator,
RoundingMode
} from '../ts/formats/utils/currency.utils.js';
tap.test('Currency Utils - should handle different currency decimal places', async () => {
// Standard 2 decimal currencies
expect(getCurrencyMinorUnits('EUR')).toEqual(2);
expect(getCurrencyMinorUnits('USD')).toEqual(2);
expect(getCurrencyMinorUnits('GBP')).toEqual(2);
// Zero decimal currencies
expect(getCurrencyMinorUnits('JPY')).toEqual(0);
expect(getCurrencyMinorUnits('KRW')).toEqual(0);
// Three decimal currencies
expect(getCurrencyMinorUnits('KWD')).toEqual(3);
expect(getCurrencyMinorUnits('TND')).toEqual(3);
// Unknown currency defaults to 2
expect(getCurrencyMinorUnits('XXX')).toEqual(2);
});
tap.test('Currency Utils - should round values correctly', async () => {
// EUR - 2 decimals
expect(roundToCurrency(10.234, 'EUR')).toEqual(10.23);
expect(roundToCurrency(10.235, 'EUR')).toEqual(10.24); // Half-up
expect(roundToCurrency(10.236, 'EUR')).toEqual(10.24);
// JPY - 0 decimals
expect(roundToCurrency(1234.56, 'JPY')).toEqual(1235);
expect(roundToCurrency(1234.49, 'JPY')).toEqual(1234);
// KWD - 3 decimals
expect(roundToCurrency(10.2345, 'KWD')).toEqual(10.235); // Half-up
expect(roundToCurrency(10.2344, 'KWD')).toEqual(10.234);
});
tap.test('Currency Utils - should use different rounding modes', async () => {
const value = 10.235;
// Half-up (default)
expect(roundToCurrency(value, 'EUR', RoundingMode.HALF_UP)).toEqual(10.24);
// Half-down
expect(roundToCurrency(value, 'EUR', RoundingMode.HALF_DOWN)).toEqual(10.23);
// Half-even (banker's rounding)
expect(roundToCurrency(10.235, 'EUR', RoundingMode.HALF_EVEN)).toEqual(10.24); // 23 is odd, round up
expect(roundToCurrency(10.245, 'EUR', RoundingMode.HALF_EVEN)).toEqual(10.24); // 24 is even, round down
// Always up
expect(roundToCurrency(10.231, 'EUR', RoundingMode.UP)).toEqual(10.24);
// Always down (truncate)
expect(roundToCurrency(10.239, 'EUR', RoundingMode.DOWN)).toEqual(10.23);
});
tap.test('Currency Utils - should calculate correct tolerance', async () => {
// EUR - tolerance is 0.005 (half of 0.01)
expect(getCurrencyTolerance('EUR')).toEqual(0.005);
// JPY - tolerance is 0.5 (half of 1)
expect(getCurrencyTolerance('JPY')).toEqual(0.5);
// KWD - tolerance is 0.0005 (half of 0.001)
expect(getCurrencyTolerance('KWD')).toEqual(0.0005);
});
tap.test('Currency Utils - should compare monetary values with tolerance', async () => {
// EUR comparisons
expect(areMonetaryValuesEqual(10.23, 10.234, 'EUR')).toEqual(true); // Within tolerance
expect(areMonetaryValuesEqual(10.23, 10.236, 'EUR')).toEqual(false); // Outside tolerance
// JPY comparisons
expect(areMonetaryValuesEqual(1234, 1234.4, 'JPY')).toEqual(true); // Within tolerance
expect(areMonetaryValuesEqual(1234, 1235, 'JPY')).toEqual(false); // Outside tolerance
// KWD comparisons
expect(areMonetaryValuesEqual(10.234, 10.2344, 'KWD')).toEqual(true); // Within tolerance
expect(areMonetaryValuesEqual(10.234, 10.235, 'KWD')).toEqual(false); // Outside tolerance
});
tap.test('CurrencyCalculator - should perform EN16931 calculations', async () => {
// EUR calculator
const eurCalc = new CurrencyCalculator('EUR');
// Line net calculation
const lineNet = eurCalc.calculateLineNet(5, 19.99, 2.50);
expect(lineNet).toEqual(97.45); // (5 * 19.99) - 2.50 = 97.45
// VAT calculation
const vat = eurCalc.calculateVAT(100, 19);
expect(vat).toEqual(19.00);
// JPY calculator (no decimals)
const jpyCalc = new CurrencyCalculator('JPY');
const jpyLineNet = jpyCalc.calculateLineNet(3, 1234.56);
expect(jpyLineNet).toEqual(3704); // Rounded to no decimals
const jpyVat = jpyCalc.calculateVAT(10000, 8);
expect(jpyVat).toEqual(800);
});
tap.test('CurrencyCalculator - should handle edge cases', async () => {
const calc = new CurrencyCalculator('EUR');
// Rounding at exact midpoint
expect(calc.round(10.235)).toEqual(10.24); // Half-up
expect(calc.round(10.245)).toEqual(10.25); // Half-up
// Very small values
expect(calc.round(0.001)).toEqual(0.00);
expect(calc.round(0.004)).toEqual(0.00);
expect(calc.round(0.005)).toEqual(0.01);
// Negative values
expect(calc.round(-10.234)).toEqual(-10.23);
expect(calc.round(-10.235)).toEqual(-10.24);
});
tap.start();
+184
View File
@@ -0,0 +1,184 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { DecimalCurrencyCalculator } from '../ts/formats/utils/currency.calculator.decimal.js';
import { Decimal } from '../ts/formats/utils/decimal.js';
tap.test('DecimalCurrencyCalculator - EUR calculations', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
// Line calculation
const lineNet = calculator.calculateLineNet('3', '33.333', '0');
expect(lineNet.toString()).toEqual('100'); // calculateLineNet rounds the result
// VAT calculation
const vat = calculator.calculateVAT('100', '19');
expect(vat.toString()).toEqual('19');
// Gross amount
const gross = calculator.calculateGrossAmount('100', '19');
expect(gross.toString()).toEqual('119');
});
tap.test('DecimalCurrencyCalculator - JPY calculations (no decimals)', async () => {
const calculator = new DecimalCurrencyCalculator('JPY');
// Should round to 0 decimal places
const amount = calculator.round('1234.56');
expect(amount.toString()).toEqual('1235');
// VAT calculation
const vat = calculator.calculateVAT('1000', '10');
expect(vat.toString()).toEqual('100');
});
tap.test('DecimalCurrencyCalculator - KWD calculations (3 decimals)', async () => {
const calculator = new DecimalCurrencyCalculator('KWD');
// Should maintain 3 decimal places
const amount = calculator.round('123.4567');
expect(amount.toString()).toEqual('123.457');
// VAT calculation
const vat = calculator.calculateVAT('100.000', '5');
expect(vat.toString()).toEqual('5');
});
tap.test('DecimalCurrencyCalculator - sum line items', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
const items = [
{ quantity: '2', unitPrice: '50.00', discount: '5.00' },
{ quantity: '3', unitPrice: '33.33', discount: '0' },
{ quantity: '1', unitPrice: '100.00', discount: '10.00' }
];
const total = calculator.sumLineItems(items);
expect(total.toString()).toEqual('284.99');
});
tap.test('DecimalCurrencyCalculator - VAT breakdown', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
const items = [
{ netAmount: '100.00', vatRate: '19' },
{ netAmount: '50.00', vatRate: '19' },
{ netAmount: '200.00', vatRate: '7' }
];
const breakdown = calculator.calculateVATBreakdown(items);
expect(breakdown).toHaveLength(2);
const vat19 = breakdown.find(b => b.rate.toString() === '19');
expect(vat19?.baseAmount.toString()).toEqual('150');
expect(vat19?.vatAmount.toString()).toEqual('28.5');
const vat7 = breakdown.find(b => b.rate.toString() === '7');
expect(vat7?.baseAmount.toString()).toEqual('200');
expect(vat7?.vatAmount.toString()).toEqual('14');
});
tap.test('DecimalCurrencyCalculator - distribute amount', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
// Distribute 100 EUR across three items
const items = [
{ value: '30' }, // 30%
{ value: '50' }, // 50%
{ value: '20' } // 20%
];
const distributed = calculator.distributeAmount('100', items);
expect(distributed[0].toString()).toEqual('30');
expect(distributed[1].toString()).toEqual('50');
expect(distributed[2].toString()).toEqual('20');
// Sum should equal total
const sum = Decimal.sum(distributed);
expect(sum.toString()).toEqual('100');
});
tap.test('DecimalCurrencyCalculator - compound adjustments', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
const adjustments = [
{ type: 'allowance' as const, value: '10', isPercentage: true }, // -10%
{ type: 'charge' as const, value: '5', isPercentage: false }, // +5 EUR
{ type: 'allowance' as const, value: '2', isPercentage: false } // -2 EUR
];
const result = calculator.calculateCompoundAmount('100', adjustments);
// 100 - 10% = 90, + 5 = 95, - 2 = 93
expect(result.toString()).toEqual('93');
});
tap.test('DecimalCurrencyCalculator - validation', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
// Valid calculation
const result1 = calculator.validateCalculation('119.00', '119.00', 'BR-CO-15');
expect(result1.valid).toBeTrue();
expect(result1.expected).toEqual('119.00');
expect(result1.calculated).toEqual('119.00');
// Invalid calculation
const result2 = calculator.validateCalculation('119.00', '118.99', 'BR-CO-15');
expect(result2.valid).toBeFalse();
expect(result2.difference).toEqual('0.01');
});
tap.test('DecimalCurrencyCalculator - different rounding modes', async () => {
// HALF_DOWN for specific requirements
const calculator = new DecimalCurrencyCalculator('EUR', 'HALF_DOWN');
const amount1 = calculator.round('10.125'); // Should round down
expect(amount1.toString()).toEqual('10.12');
const amount2 = calculator.round('10.135'); // Should round down with HALF_DOWN
expect(amount2.toString()).toEqual('10.13');
// HALF_EVEN (Banker's rounding) for statistical accuracy
const bankerCalc = new DecimalCurrencyCalculator('EUR', 'HALF_EVEN');
const amount3 = bankerCalc.round('10.125'); // Round to even (down)
expect(amount3.toString()).toEqual('10.12');
const amount4 = bankerCalc.round('10.135'); // Round to even (up)
expect(amount4.toString()).toEqual('10.14');
});
tap.test('DecimalCurrencyCalculator - real invoice scenario', async () => {
const calculator = new DecimalCurrencyCalculator('EUR');
// Invoice lines
const lines = [
{ quantity: '2.5', unitPrice: '45.60', discount: '5.00' },
{ quantity: '10', unitPrice: '12.34', discount: '0' },
{ quantity: '1', unitPrice: '250.00', discount: '25.00' }
];
// Calculate line totals
const lineTotal = calculator.sumLineItems(lines);
expect(lineTotal.toString()).toEqual('457.4');
// Apply document-level allowance (2%)
const allowance = calculator.calculatePaymentDiscount(lineTotal, '2');
expect(allowance.toString()).toEqual('9.15');
const netAfterAllowance = lineTotal.subtract(allowance);
expect(calculator.round(netAfterAllowance).toString()).toEqual('448.25');
// Calculate VAT at 19%
const vat = calculator.calculateVAT(netAfterAllowance, '19');
expect(vat.toString()).toEqual('85.17');
// Total with VAT
const total = calculator.calculateGrossAmount(netAfterAllowance, vat);
expect(total.toString()).toEqual('533.42');
// Format for display
const formatted = calculator.formatAmount(total);
expect(formatted).toEqual('533.42 EUR');
});
export default tap.start();
+257
View File
@@ -0,0 +1,257 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Decimal, decimal, RoundingMode } from '../ts/formats/utils/decimal.js';
tap.test('Decimal - basic construction', async () => {
// From string
const d1 = new Decimal('123.456');
expect(d1.toString()).toEqual('123.456');
// From number
const d2 = new Decimal(123.456);
expect(d2.toString()).toEqual('123.456');
// From bigint
const d3 = new Decimal(123n);
expect(d3.toString()).toEqual('123');
// From another Decimal
const d4 = new Decimal(d1);
expect(d4.toString()).toEqual('123.456');
// Negative values
const d5 = new Decimal('-123.456');
expect(d5.toString()).toEqual('-123.456');
});
tap.test('Decimal - arithmetic operations', async () => {
const a = new Decimal('10.50');
const b = new Decimal('3.25');
// Addition
expect(a.add(b).toString()).toEqual('13.75');
// Subtraction
expect(a.subtract(b).toString()).toEqual('7.25');
// Multiplication
expect(a.multiply(b).toString()).toEqual('34.125');
// Division
expect(a.divide(b).toString()).toEqual('3.2307692307');
// Percentage
const amount = new Decimal('100');
const rate = new Decimal('19');
expect(amount.percentage(rate).toString()).toEqual('19');
});
tap.test('Decimal - rounding modes', async () => {
// HALF_UP (default)
expect(new Decimal('2.5').round(0, 'HALF_UP').toString()).toEqual('3');
expect(new Decimal('2.4').round(0, 'HALF_UP').toString()).toEqual('2');
expect(new Decimal('-2.5').round(0, 'HALF_UP').toString()).toEqual('-3');
// HALF_DOWN
expect(new Decimal('2.5').round(0, 'HALF_DOWN').toString()).toEqual('2');
expect(new Decimal('2.6').round(0, 'HALF_DOWN').toString()).toEqual('3');
expect(new Decimal('-2.5').round(0, 'HALF_DOWN').toString()).toEqual('-2');
// HALF_EVEN (Banker's rounding)
expect(new Decimal('2.5').round(0, 'HALF_EVEN').toString()).toEqual('2');
expect(new Decimal('3.5').round(0, 'HALF_EVEN').toString()).toEqual('4');
expect(new Decimal('2.4').round(0, 'HALF_EVEN').toString()).toEqual('2');
expect(new Decimal('2.6').round(0, 'HALF_EVEN').toString()).toEqual('3');
// UP (away from zero)
expect(new Decimal('2.1').round(0, 'UP').toString()).toEqual('3');
expect(new Decimal('-2.1').round(0, 'UP').toString()).toEqual('-3');
// DOWN (toward zero)
expect(new Decimal('2.9').round(0, 'DOWN').toString()).toEqual('2');
expect(new Decimal('-2.9').round(0, 'DOWN').toString()).toEqual('-2');
// CEILING (toward positive infinity)
expect(new Decimal('2.1').round(0, 'CEILING').toString()).toEqual('3');
expect(new Decimal('-2.9').round(0, 'CEILING').toString()).toEqual('-2');
// FLOOR (toward negative infinity)
expect(new Decimal('2.9').round(0, 'FLOOR').toString()).toEqual('2');
expect(new Decimal('-2.1').round(0, 'FLOOR').toString()).toEqual('-3');
});
tap.test('Decimal - EN16931 calculation scenarios', async () => {
// Line item calculation
const quantity = new Decimal('3');
const unitPrice = new Decimal('33.333333');
const lineTotal = quantity.multiply(unitPrice);
expect(lineTotal.round(2).toString()).toEqual('100');
// VAT calculation
const netAmount = new Decimal('100');
const vatRate = new Decimal('19');
const vatAmount = netAmount.percentage(vatRate);
expect(vatAmount.toString()).toEqual('19');
// Total with VAT
const grossAmount = netAmount.add(vatAmount);
expect(grossAmount.toString()).toEqual('119');
// Complex calculation with allowances
const lineExtension = new Decimal('150.00');
const allowance = new Decimal('10.00');
const charge = new Decimal('5.00');
const taxExclusive = lineExtension.subtract(allowance).add(charge);
expect(taxExclusive.toString()).toEqual('145');
const vat = taxExclusive.percentage(new Decimal('19'));
expect(vat.round(2).toString()).toEqual('27.55');
const total = taxExclusive.add(vat);
expect(total.round(2).toString()).toEqual('172.55');
});
tap.test('Decimal - comparisons', async () => {
const a = new Decimal('10.50');
const b = new Decimal('10.50');
const c = new Decimal('10.51');
// Equality
expect(a.equals(b)).toBeTrue();
expect(a.equals(c)).toBeFalse();
// With tolerance
expect(a.equals(c, '0.01')).toBeTrue();
expect(a.equals(c, '0.005')).toBeFalse();
// Comparisons
expect(a.lessThan(c)).toBeTrue();
expect(c.greaterThan(a)).toBeTrue();
expect(a.lessThanOrEqual(b)).toBeTrue();
expect(a.greaterThanOrEqual(b)).toBeTrue();
});
tap.test('Decimal - edge cases', async () => {
// Very small numbers
const tiny = new Decimal('0.0000000001');
expect(tiny.multiply(new Decimal('1000000000')).toString()).toEqual('0.1');
// Very large numbers
const huge = new Decimal('999999999999999999');
expect(huge.add(new Decimal('1')).toString()).toEqual('1000000000000000000');
// Division by zero
const zero = new Decimal('0');
const one = new Decimal('1');
let errorThrown = false;
try {
one.divide(zero);
} catch (e) {
errorThrown = true;
expect(e.message).toEqual('Division by zero');
}
expect(errorThrown).toBeTrue();
// Zero operations
expect(zero.add(one).toString()).toEqual('1');
expect(zero.multiply(one).toString()).toEqual('0');
expect(zero.isZero()).toBeTrue();
expect(one.isZero()).toBeFalse();
});
tap.test('Decimal - currency calculations with different minor units', async () => {
// EUR (2 decimal places)
const eurAmount = new Decimal('100.00');
const eurVat = eurAmount.percentage(new Decimal('19'));
expect(eurVat.round(2).toString()).toEqual('19');
// JPY (0 decimal places)
const jpyAmount = new Decimal('1000');
const jpyTax = jpyAmount.percentage(new Decimal('10'));
expect(jpyTax.round(0).toString()).toEqual('100');
// KWD (3 decimal places)
const kwdAmount = new Decimal('100.000');
const kwdTax = kwdAmount.percentage(new Decimal('5'));
expect(kwdTax.round(3).toString()).toEqual('5');
// BTC (8 decimal places for satoshis)
const btcAmount = new Decimal('0.00100000');
const btcFee = btcAmount.percentage(new Decimal('0.1'));
expect(btcFee.round(8).toString()).toEqual('0.000001');
});
tap.test('Decimal - static methods', async () => {
// Sum
const values = ['10.50', '20.25', '30.75'];
const sum = Decimal.sum(values);
expect(sum.toString()).toEqual('61.5');
// Min
const min = Decimal.min('10.50', '20.25', '5.75');
expect(min.toString()).toEqual('5.75');
// Max
const max = Decimal.max('10.50', '20.25', '5.75');
expect(max.toString()).toEqual('20.25');
// From percentage
const rate = Decimal.fromPercentage('19%');
expect(rate.toString()).toEqual('0.19');
});
tap.test('Decimal - formatting', async () => {
const value = new Decimal('1234.567890');
// Fixed decimal places
expect(value.toFixed(2)).toEqual('1234.57');
expect(value.toFixed(0)).toEqual('1235');
expect(value.toFixed(4)).toEqual('1234.5679');
// toString with decimal places
expect(value.toString(2)).toEqual('1234.56');
expect(value.toString(6)).toEqual('1234.567890');
// Automatic trailing zero removal
const rounded = new Decimal('100.00');
expect(rounded.toString()).toEqual('100');
expect(rounded.toFixed(2)).toEqual('100.00');
});
tap.test('Decimal - real-world invoice calculation', async () => {
// Invoice with multiple lines and VAT rates
const lines = [
{ quantity: '2', unitPrice: '50.00', vatRate: '19' },
{ quantity: '3', unitPrice: '33.33', vatRate: '19' },
{ quantity: '1', unitPrice: '100.00', vatRate: '7' }
];
let totalNet = Decimal.ZERO;
let totalVat19 = Decimal.ZERO;
let totalVat7 = Decimal.ZERO;
for (const line of lines) {
const quantity = new Decimal(line.quantity);
const unitPrice = new Decimal(line.unitPrice);
const lineNet = quantity.multiply(unitPrice);
totalNet = totalNet.add(lineNet);
const vatAmount = lineNet.percentage(new Decimal(line.vatRate));
if (line.vatRate === '19') {
totalVat19 = totalVat19.add(vatAmount);
} else {
totalVat7 = totalVat7.add(vatAmount);
}
}
expect(totalNet.round(2).toString()).toEqual('299.99');
expect(totalVat19.round(2).toString()).toEqual('38');
expect(totalVat7.round(2).toString()).toEqual('7');
const totalVat = totalVat19.add(totalVat7);
const totalGross = totalNet.add(totalVat);
expect(totalVat.round(2).toString()).toEqual('45');
expect(totalGross.round(2).toString()).toEqual('344.99');
});
export default tap.start();
+1 -1
View File
@@ -196,4 +196,4 @@ tap.test('EInvoice should export XML correctly', async () => {
});
// Run the tests
tap.start();
export default tap.start();
+53
View File
@@ -1,5 +1,6 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../ts/einvoice.js';
import { EInvoiceFormatError } from '../ts/errors.js';
import { ValidationLevel } from '../ts/interfaces/common.js';
import type { ExportFormat } from '../ts/interfaces/common.js';
@@ -217,5 +218,57 @@ tap.test('EInvoice should validate XML correctly', async () => {
expect(result.errors).toHaveLength(0);
});
tap.test('EInvoice should validate programmatic invoices without loaded XML', async () => {
const emptyInvoice = new EInvoice();
const emptyValidation = await emptyInvoice.validate();
expect(emptyValidation.valid).toBeFalse();
expect(emptyValidation.errors.some(error => error.code === 'BR-01')).toBeTrue();
expect(emptyValidation.errors.some(error => error.code === 'BR-16')).toBeTrue();
const validInvoice = new EInvoice();
validInvoice.accountingDocId = 'TEST-IN-MEMORY';
validInvoice.from.name = 'Programmatic Seller';
validInvoice.from.address = {
streetName: 'Seller Street',
houseNumber: '1',
city: 'Berlin',
postalCode: '10115',
country: 'DE'
};
validInvoice.to.name = 'Programmatic Buyer';
validInvoice.to.address = {
streetName: 'Buyer Street',
houseNumber: '2',
city: 'Hamburg',
postalCode: '20095',
country: 'DE'
};
validInvoice.items.push({
position: 1,
name: 'Programmatic Product',
articleNumber: 'PP-001',
unitType: 'EA',
unitQuantity: 1,
unitNetPrice: 100,
vatPercentage: 19
});
const validResult = await validInvoice.validate(ValidationLevel.BUSINESS);
expect(validResult.valid).toBeTrue();
expect(validResult.errors).toHaveLength(0);
});
tap.test('EInvoice should reject unsupported invoice types explicitly', async () => {
const einvoice = new EInvoice();
try {
einvoice.invoiceType = 'creditnote';
expect(true).toBeFalse();
} catch (error) {
expect(error).toBeInstanceOf(EInvoiceFormatError);
}
});
// Run the tests
tap.start();
+238
View File
@@ -0,0 +1,238 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { EInvoice } from '../ts/index.js';
import { ValidationLevel } from '../ts/interfaces/common.js';
// Test EN16931 business rules and code list validators
tap.test('EN16931 Validators - should validate business rules with feature flags', async () => {
// Create a minimal invoice that violates several EN16931 rules
const invoice = new EInvoice();
// Set some basic fields but leave mandatory ones missing
invoice.currency = 'EUR';
invoice.date = Date.now();
invoice.from = {
type: 'company',
name: 'Test Seller',
address: {
streetName: 'Test Street',
houseNumber: '1',
city: 'Berlin',
postalCode: '10115',
countryCode: 'DE'
}
} as any;
// Missing buyer details and invoice ID (violates BR-02, BR-07)
// Add an item with calculation issues
invoice.items = [{
position: 1,
name: 'Test Item',
unitType: 'C62', // Valid UNECE code
unitQuantity: 10,
unitNetPrice: 100,
vatPercentage: 19
}];
// Test without feature flags (should pass basic validation)
const basicResult = await invoice.validate(ValidationLevel.BUSINESS);
console.log('Basic validation errors:', basicResult.errors.length);
// Test with EN16931 business rules feature flag
const en16931Result = await invoice.validate(ValidationLevel.BUSINESS, {
featureFlags: ['EN16931_BUSINESS_RULES'],
checkCalculations: true,
checkVAT: true
});
console.log('EN16931 validation errors:', en16931Result.errors.length);
// Should find missing mandatory fields
const mandatoryErrors = en16931Result.errors.filter(e =>
e.code && ['BR-01', 'BR-02', 'BR-07'].includes(e.code)
);
expect(mandatoryErrors.length).toBeGreaterThan(0);
// Test code list validation
const codeListResult = await invoice.validate(ValidationLevel.BUSINESS, {
featureFlags: ['CODE_LIST_VALIDATION'],
checkCodeLists: true
});
console.log('Code list validation errors:', codeListResult.errors.length);
// Test invalid currency code
invoice.currency = 'XXX' as any; // Invalid currency
const currencyResult = await invoice.validate(ValidationLevel.BUSINESS, {
featureFlags: ['CODE_LIST_VALIDATION']
});
const currencyErrors = currencyResult.errors.filter(e =>
e.code && e.code.includes('BR-CL-03')
);
expect(currencyErrors.length).toEqual(1);
// Test with both validators enabled
const fullResult = await invoice.validate(ValidationLevel.BUSINESS, {
featureFlags: ['EN16931_BUSINESS_RULES', 'CODE_LIST_VALIDATION'],
checkCalculations: true,
checkVAT: true,
checkCodeLists: true,
reportOnly: true // Don't fail validation, just report
});
console.log('Full validation with both validators:');
console.log('- Total errors:', fullResult.errors.length);
console.log('- Valid (report-only mode):', fullResult.valid);
expect(fullResult.valid).toEqual(true); // Should be true in report-only mode
expect(fullResult.errors.length).toBeGreaterThan(0); // Should find issues
console.log('Error codes found:', fullResult.errors.map(e => e.code));
});
tap.test('EN16931 Validators - should validate calculations correctly', async () => {
const invoice = new EInvoice();
// Set up a complete invoice with correct mandatory fields
invoice.accountingDocId = 'INV-2024-001';
invoice.currency = 'EUR';
invoice.date = Date.now();
invoice.metadata = {
customizationId: 'urn:cen.eu:en16931:2017'
};
invoice.from = {
type: 'company',
name: 'Test Seller GmbH',
address: {
streetName: 'Hauptstraße',
houseNumber: '1',
city: 'Berlin',
postalCode: '10115',
countryCode: 'DE'
}
} as any;
invoice.to = {
type: 'company',
name: 'Test Buyer Ltd',
address: {
streetName: 'Main Street',
houseNumber: '10',
city: 'London',
postalCode: 'SW1A 1AA',
countryCode: 'GB'
}
} as any;
// Add items with specific amounts
invoice.items = [
{
position: 1,
name: 'Product A',
unitType: 'C62',
unitQuantity: 5,
unitNetPrice: 100.00,
vatPercentage: 19
},
{
position: 2,
name: 'Product B',
unitType: 'C62',
unitQuantity: 3,
unitNetPrice: 50.00,
vatPercentage: 19
}
];
// Expected calculations:
// Line 1: 5 * 100 = 500
// Line 2: 3 * 50 = 150
// Total net: 650
// VAT (19%): 123.50
// Total gross: 773.50
const result = await invoice.validate(ValidationLevel.BUSINESS, {
featureFlags: ['EN16931_BUSINESS_RULES'],
checkCalculations: true,
tolerance: 0.01
});
// Should not have calculation errors
const calcErrors = result.errors.filter(e =>
e.code && e.code.startsWith('BR-CO-')
);
console.log('Calculation validation errors:', calcErrors);
expect(calcErrors.length).toEqual(0);
// Verify computed totals
expect(invoice.totalNet).toEqual(650);
expect(invoice.totalVat).toEqual(123.50);
expect(invoice.totalGross).toEqual(773.50);
});
tap.test('EN16931 Validators - should validate VAT rules correctly', async () => {
const invoice = new EInvoice();
// Set up mandatory fields
invoice.accountingDocId = 'INV-2024-002';
invoice.currency = 'EUR';
invoice.date = Date.now();
invoice.metadata = {
customizationId: 'urn:cen.eu:en16931:2017'
};
invoice.from = {
type: 'company',
name: 'Seller',
address: { countryCode: 'DE' }
} as any;
invoice.to = {
type: 'company',
name: 'Buyer',
address: { countryCode: 'FR' }
} as any;
// Add mixed VAT rate items
invoice.items = [
{
position: 1,
name: 'Standard rated item',
unitType: 'C62',
unitQuantity: 1,
unitNetPrice: 100,
vatPercentage: 19 // Standard rate
},
{
position: 2,
name: 'Zero rated item',
unitType: 'C62',
unitQuantity: 1,
unitNetPrice: 100,
vatPercentage: 0 // Zero rate
}
];
const result = await invoice.validate(ValidationLevel.BUSINESS, {
featureFlags: ['EN16931_BUSINESS_RULES'],
checkVAT: true
});
// Check for VAT breakdown requirements
const vatErrors = result.errors.filter(e =>
e.code && (e.code.startsWith('BR-S-') || e.code.startsWith('BR-Z-'))
);
console.log('VAT validation results:');
console.log('- VAT errors found:', vatErrors.length);
console.log('- Tax breakdown:', invoice.taxBreakdown);
// Should have proper tax breakdown
expect(invoice.taxBreakdown.length).toEqual(2);
expect(invoice.taxBreakdown.find(t => t.taxPercent === 19)).toBeTruthy();
expect(invoice.taxBreakdown.find(t => t.taxPercent === 0)).toBeTruthy();
});
export default tap.start();
+453
View File
@@ -0,0 +1,453 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { FacturXValidator, FacturXProfile } from '../ts/formats/validation/facturx.validator.js';
import type { EInvoice } from '../ts/einvoice.js';
tap.test('Factur-X Validator - basic instantiation', async () => {
const validator = FacturXValidator.create();
expect(validator).toBeInstanceOf(FacturXValidator);
// Singleton pattern
const validator2 = FacturXValidator.create();
expect(validator2).toEqual(validator);
});
tap.test('Factur-X Validator - profile detection', async () => {
const validator = FacturXValidator.create();
// MINIMUM profile
const minInvoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:minimum:2017'
}
};
expect(validator.detectProfile(minInvoice as EInvoice)).toEqual(FacturXProfile.MINIMUM);
// BASIC profile
const basicInvoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:basic:2017'
}
};
expect(validator.detectProfile(basicInvoice as EInvoice)).toEqual(FacturXProfile.BASIC);
// EN16931 profile (Comfort)
const en16931Invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:comfort:2017'
}
};
expect(validator.detectProfile(en16931Invoice as EInvoice)).toEqual(FacturXProfile.EN16931);
// EXTENDED profile
const extendedInvoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:extended:2017'
}
};
expect(validator.detectProfile(extendedInvoice as EInvoice)).toEqual(FacturXProfile.EXTENDED);
// Non-Factur-X invoice
const otherInvoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:cen.eu:en16931:2017'
}
};
expect(validator.detectProfile(otherInvoice as EInvoice)).toEqual(null);
});
tap.test('Factur-X Validator - MINIMUM profile validation', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:minimum:2017'
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789'
},
to: {
type: 'company',
name: 'Test Buyer'
},
totalInvoiceAmount: 119.00,
totalNetAmount: 100.00,
totalVatAmount: 19.00
};
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.MINIMUM);
const errors = results.filter(r => r.severity === 'error');
console.log('MINIMUM profile validation errors:', errors);
expect(errors.length).toEqual(0);
});
tap.test('Factur-X Validator - MINIMUM profile missing fields', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:minimum:2017'
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
// Missing required fields for MINIMUM
};
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.MINIMUM);
const errors = results.filter(r => r.severity === 'error');
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.field === 'currency')).toBeTrue();
expect(errors.some(e => e.field === 'from.name')).toBeTrue();
});
tap.test('Factur-X Validator - BASIC profile validation', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:basic:2017'
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
dueDate: new Date('2025-02-11'),
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789',
address: 'Test Street 1',
country: 'DE'
},
to: {
type: 'company',
name: 'Test Buyer',
address: 'Buyer Street 1',
country: 'FR'
},
items: [
{
position: 1,
name: 'Test Product',
unitQuantity: 1,
unitNetPrice: 100.00,
unitType: 'C62',
vatPercentage: 19,
articleNumber: 'ART-001'
}
],
totalInvoiceAmount: 119.00,
totalNetAmount: 100.00,
totalVatAmount: 19.00
};
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.BASIC);
const errors = results.filter(r => r.severity === 'error');
console.log('BASIC profile validation errors:', errors);
expect(errors.length).toEqual(0);
});
tap.test('Factur-X Validator - BASIC profile missing line items', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:basic:2017'
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
dueDate: new Date('2025-02-11'),
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789',
address: 'Test Street 1',
country: 'DE'
},
to: {
type: 'company',
name: 'Test Buyer',
address: 'Buyer Street 1',
country: 'FR'
},
// Missing items
totalInvoiceAmount: 119.00,
totalNetAmount: 100.00,
totalVatAmount: 19.00
};
const results = validator.validateFacturX(invoice as EInvoice);
const errors = results.filter(r => r.severity === 'error');
expect(errors.length).toBeGreaterThan(0);
expect(errors.some(e => e.ruleId === 'FX-BAS-02')).toBeTrue();
});
tap.test('Factur-X Validator - BASIC_WL profile (without lines)', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:basicwl:2017'
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
dueDate: new Date('2025-02-11'),
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789',
address: 'Test Street 1',
country: 'DE'
},
to: {
type: 'company',
name: 'Test Buyer',
address: 'Buyer Street 1',
country: 'FR'
},
// No items required for BASIC_WL
totalInvoiceAmount: 119.00,
totalNetAmount: 100.00,
totalVatAmount: 19.00
};
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.BASIC_WL);
const errors = results.filter(r => r.severity === 'error');
console.log('BASIC_WL profile validation errors:', errors);
expect(errors.length).toEqual(0);
});
tap.test('Factur-X Validator - EN16931 profile validation', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:en16931:2017',
buyerReference: 'REF-12345'
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
dueDate: new Date('2025-02-11'),
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789',
address: 'Test Street 1',
city: 'Berlin',
postalCode: '10115',
country: 'DE'
},
to: {
type: 'company',
name: 'Test Buyer',
address: 'Buyer Street 1',
city: 'Paris',
postalCode: '75001',
country: 'FR'
},
items: [
{
position: 1,
name: 'Test Product',
unitQuantity: 1,
unitNetPrice: 100.00,
unitType: 'C62',
vatPercentage: 19,
articleNumber: 'ART-001'
}
],
totalInvoiceAmount: 119.00,
totalNetAmount: 100.00,
totalVatAmount: 19.00
};
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.EN16931);
const errors = results.filter(r => r.severity === 'error');
console.log('EN16931 profile validation errors:', errors);
expect(errors.length).toEqual(0);
});
tap.test('Factur-X Validator - EN16931 missing buyer reference', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:en16931:2017',
// Missing buyerReference or purchaseOrderReference
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789',
address: 'Test Street 1',
city: 'Berlin',
postalCode: '10115',
country: 'DE'
},
to: {
type: 'company',
name: 'Test Buyer',
address: 'Buyer Street 1',
city: 'Paris',
postalCode: '75001',
country: 'FR'
},
items: [],
totalInvoiceAmount: 0,
totalNetAmount: 0,
totalVatAmount: 0,
dueDate: new Date('2025-02-11')
};
const results = validator.validateFacturX(invoice as EInvoice);
const errors = results.filter(r => r.severity === 'error');
expect(errors.some(e => e.ruleId === 'FX-EN-01')).toBeTrue();
});
tap.test('Factur-X Validator - EXTENDED profile validation', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:extended:2017',
extensions: {
attachments: [
{
filename: 'invoice.pdf',
mimeType: 'application/pdf',
data: 'base64encodeddata'
}
]
}
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789'
},
to: {
type: 'company',
name: 'Test Buyer'
},
totalInvoiceAmount: 119.00
};
const results = validator.validateFacturX(invoice as EInvoice, FacturXProfile.EXTENDED);
const errors = results.filter(r => r.severity === 'error');
console.log('EXTENDED profile validation errors:', errors);
expect(errors.length).toEqual(0);
});
tap.test('Factur-X Validator - EXTENDED profile attachment validation', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:facturx:extended:2017',
extensions: {
attachments: [
{
// Missing filename and mimeType
data: 'base64encodeddata'
}
]
}
},
accountingDocId: 'INV-2025-001',
issueDate: new Date('2025-01-11'),
accountingDocType: 'invoice',
currency: 'EUR',
from: {
type: 'company',
name: 'Test Seller',
vatNumber: 'DE123456789'
},
to: {
type: 'company',
name: 'Test Buyer'
},
totalInvoiceAmount: 119.00
};
const results = validator.validateFacturX(invoice as EInvoice);
const warnings = results.filter(r => r.severity === 'warning');
expect(warnings.some(w => w.ruleId === 'FX-EXT-01')).toBeTrue();
});
tap.test('Factur-X Validator - ZUGFeRD compatibility', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:zugferd:basic:2017' // ZUGFeRD format
}
};
// Should detect as Factur-X (ZUGFeRD is the German name)
const profile = validator.detectProfile(invoice as EInvoice);
expect(profile).toEqual(FacturXProfile.BASIC);
});
tap.test('Factur-X Validator - profile display names', async () => {
const validator = FacturXValidator.create();
expect(validator.getProfileDisplayName(FacturXProfile.MINIMUM)).toEqual('Factur-X MINIMUM');
expect(validator.getProfileDisplayName(FacturXProfile.BASIC)).toEqual('Factur-X BASIC');
expect(validator.getProfileDisplayName(FacturXProfile.BASIC_WL)).toEqual('Factur-X BASIC WL');
expect(validator.getProfileDisplayName(FacturXProfile.EN16931)).toEqual('Factur-X EN16931');
expect(validator.getProfileDisplayName(FacturXProfile.EXTENDED)).toEqual('Factur-X EXTENDED');
});
tap.test('Factur-X Validator - profile compliance levels', async () => {
const validator = FacturXValidator.create();
expect(validator.getProfileComplianceLevel(FacturXProfile.MINIMUM)).toEqual(1);
expect(validator.getProfileComplianceLevel(FacturXProfile.BASIC_WL)).toEqual(2);
expect(validator.getProfileComplianceLevel(FacturXProfile.BASIC)).toEqual(3);
expect(validator.getProfileComplianceLevel(FacturXProfile.EN16931)).toEqual(4);
expect(validator.getProfileComplianceLevel(FacturXProfile.EXTENDED)).toEqual(5);
});
tap.test('Factur-X Validator - non-Factur-X invoice skips validation', async () => {
const validator = FacturXValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:cen.eu:en16931:2017' // Not Factur-X
}
};
const results = validator.validateFacturX(invoice as EInvoice);
expect(results.length).toEqual(0);
});
export default tap.start();
+27 -25
View File
@@ -78,33 +78,35 @@ tap.test('Format Detection - PEPPOL large invoice samples', async () => {
tap.test('Format Detection - FatturaPA Italian invoice format', async () => {
const files = await TestFileHelpers.getTestFiles(TestFileCategories.FATTURAPA, '*.xml');
console.log(`Testing ${files.length} FatturaPA files`);
if (files.length === 0) {
const syntheticFatturaPa = `<?xml version="1.0"?>
<FatturaElettronica xmlns="http://ivaservizi.agenziaentrate.gov.it/docs/xsd/fatture/v1.2">
<FatturaElettronicaHeader />
</FatturaElettronica>`;
const format = FormatDetector.detectFormat(syntheticFatturaPa);
expect(format).toEqual(InvoiceFormat.FATTURAPA);
console.log('✓ Synthetic FatturaPA XML is detected correctly');
return;
}
let detectedCount = 0;
for (const file of files) {
try {
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
const xmlString = xmlBuffer.toString('utf-8');
const { result: format, duration } = await PerformanceUtils.measure(
'fatturapa-detection',
async () => FormatDetector.detectFormat(xmlString)
);
// FatturaPA detection might not be fully implemented yet
if (format === InvoiceFormat.FATTURAPA) {
detectedCount++;
}
console.log(`${format === InvoiceFormat.FATTURAPA ? '✓' : '○'} ${path.basename(file)}: ${format} (${duration.toFixed(2)}ms)`);
} catch (error) {
console.log(`${path.basename(file)}: Error - ${error.message}`);
}
}
// Log if FatturaPA detection needs implementation
if (detectedCount === 0 && files.length > 0) {
console.log('Note: FatturaPA format detection may need implementation');
const xmlBuffer = await TestFileHelpers.loadTestFile(file);
const xmlString = xmlBuffer.toString('utf-8');
const { result: format, duration } = await PerformanceUtils.measure(
'fatturapa-detection',
async () => FormatDetector.detectFormat(xmlString)
);
expect(format).toEqual(InvoiceFormat.FATTURAPA);
detectedCount++;
console.log(`${path.basename(file)}: ${format} (${duration.toFixed(2)}ms)`);
}
expect(detectedCount).toEqual(files.length);
});
// Test format detection for EN16931 examples
@@ -263,4 +265,4 @@ tap.test('Format Detection - Confidence scoring', async () => {
// expect(result.confidence).toBeGreaterThan(0.8);
});
tap.start();
tap.start();
+219
View File
@@ -0,0 +1,219 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { MainValidator, createValidator } from '../ts/formats/validation/integrated.validator.js';
import { EInvoice } from '../ts/einvoice.js';
import * as fs from 'fs';
import * as path from 'path';
tap.test('Integrated Validator - Basic validation', async () => {
const validator = new MainValidator();
const invoice = new EInvoice();
invoice.invoiceNumber = 'TEST-001';
invoice.issueDate = new Date('2025-01-11');
invoice.from = {
type: 'company',
name: 'Test Seller',
address: {
streetName: 'Test Street',
city: 'Berlin',
postalCode: '10115',
countryCode: 'DE'
}
};
invoice.to = {
name: 'Test Buyer',
address: {
streetName: 'Buyer Street',
city: 'Munich',
postalCode: '80331',
countryCode: 'DE'
}
};
const report = await validator.validate(invoice);
console.log('Basic validation report:');
console.log(` Valid: ${report.valid}`);
console.log(` Errors: ${report.errorCount}`);
console.log(` Warnings: ${report.warningCount}`);
console.log(` Coverage: ${report.coverage.toFixed(1)}%`);
expect(report).toBeDefined();
expect(report.errorCount).toBeGreaterThan(0); // Should have errors (missing required fields)
});
tap.test('Integrated Validator - XRechnung detection', async () => {
const validator = new MainValidator();
const invoice = new EInvoice();
invoice.metadata = {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: '991-12345678901-23' // Leitweg-ID
};
invoice.invoiceNumber = 'XR-2025-001';
invoice.issueDate = new Date('2025-01-11');
const report = await validator.validate(invoice);
console.log('XRechnung validation report:');
console.log(` Profile: ${report.profile}`);
console.log(` XRechnung errors found: ${
report.results.filter(r => r.source === 'XRECHNUNG').length
}`);
expect(report.profile).toInclude('XRECHNUNG');
// Check for XRechnung-specific validation
const xrErrors = report.results.filter(r => r.source === 'XRECHNUNG');
expect(xrErrors.length).toBeGreaterThan(0);
});
tap.test('Integrated Validator - Complete valid invoice', async () => {
const validator = await createValidator({ enableSchematron: false });
const invoice = new EInvoice();
invoice.accountingDocId = 'INV-2025-001';
invoice.accountingDocType = '380';
invoice.invoiceNumber = 'INV-2025-001';
invoice.issueDate = new Date('2025-01-11');
invoice.currencyCode = 'EUR';
invoice.from = {
type: 'company',
name: 'Example GmbH',
address: {
streetName: 'Hauptstraße 1',
city: 'Berlin',
postalCode: '10115',
countryCode: 'DE'
},
registrationDetails: {
vatId: 'DE123456789'
}
};
invoice.to = {
name: 'Customer AG',
address: {
streetName: 'Kundenweg 42',
city: 'Munich',
postalCode: '80331',
countryCode: 'DE'
}
};
invoice.items = [{
title: 'Consulting Services',
description: 'Professional consulting',
quantity: 10,
unitPrice: 100,
netAmount: 1000,
vatRate: 19,
vatAmount: 190,
grossAmount: 1190
}];
invoice.metadata = {
customizationId: 'urn:cen.eu:en16931:2017',
profileId: 'urn:cen.eu:en16931:2017',
taxDetails: [{
taxPercent: 19,
netAmount: 1000,
taxAmount: 190
}],
totals: {
lineExtensionAmount: 1000,
taxExclusiveAmount: 1000,
taxInclusiveAmount: 1190,
payableAmount: 1190
}
};
const report = await validator.validate(invoice);
console.log('\nComplete invoice validation:');
console.log(validator.formatReport(report));
// Should have fewer errors with more complete data
expect(report.errorCount).toBeLessThan(10);
});
tap.test('Integrated Validator - With XML content', async () => {
const validator = await createValidator();
// Load a sample XML file if available
const xmlPath = path.join(
process.cwd(),
'corpus/xml-rechnung/3.1/ubl/01-01a-INVOICE_ubl.xml'
);
if (fs.existsSync(xmlPath)) {
const xmlContent = fs.readFileSync(xmlPath, 'utf-8');
const invoice = await EInvoice.fromXML(xmlContent);
const report = await validator.validateAuto(invoice, xmlContent);
console.log('\nXML validation with Schematron:');
console.log(` Format detected: ${report.format}`);
console.log(` Schematron enabled: ${report.schematronEnabled}`);
console.log(` Validation sources: ${
[...new Set(report.results.map(r => r.source))].join(', ')
}`);
expect(report.format).toBeDefined();
} else {
console.log('Sample XML not found, skipping XML validation test');
}
});
tap.test('Integrated Validator - Capabilities check', async () => {
const validator = new MainValidator();
const capabilities = validator.getCapabilities();
console.log('\nValidator capabilities:');
console.log(` Schematron: ${capabilities.schematron ? '✅' : '❌'}`);
console.log(` XRechnung: ${capabilities.xrechnung ? '✅' : '❌'}`);
console.log(` PEPPOL: ${capabilities.peppol ? '✅' : '❌'}`);
console.log(` Calculations: ${capabilities.calculations ? '✅' : '❌'}`);
console.log(` Code Lists: ${capabilities.codeLists ? '✅' : '❌'}`);
expect(capabilities.xrechnung).toBeTrue();
expect(capabilities.calculations).toBeTrue();
expect(capabilities.codeLists).toBeTrue();
});
tap.test('Integrated Validator - Deduplication', async () => {
const validator = new MainValidator();
// Create invoice that will trigger duplicate errors
const invoice = new EInvoice();
invoice.invoiceNumber = 'TEST-DUP';
const report = await validator.validate(invoice);
// Check that duplicates are removed
const ruleIds = report.results.map(r => r.ruleId);
const uniqueRuleIds = [...new Set(ruleIds)];
console.log(`\nDeduplication test:`);
console.log(` Total results: ${report.results.length}`);
console.log(` Unique rule IDs: ${uniqueRuleIds.length}`);
// Each rule+field combination should appear only once
const combinations = new Set();
let duplicates = 0;
for (const result of report.results) {
const key = `${result.ruleId}|${result.field || ''}`;
if (combinations.has(key)) {
duplicates++;
}
combinations.add(key);
}
console.log(` Duplicate combinations: ${duplicates}`);
expect(duplicates).toEqual(0);
});
export default tap.start();
+328
View File
@@ -0,0 +1,328 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { PeppolValidator } from '../ts/formats/validation/peppol.validator.js';
import type { EInvoice } from '../ts/einvoice.js';
tap.test('PEPPOL Validator - basic instantiation', async () => {
const validator = PeppolValidator.create();
expect(validator).toBeInstanceOf(PeppolValidator);
// Singleton pattern
const validator2 = PeppolValidator.create();
expect(validator2).toEqual(validator);
});
tap.test('PEPPOL Validator - endpoint ID validation', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
sellerEndpointId: '0088:1234567890128', // Valid GLN
buyerEndpointId: '0192:123456789' // Valid Norwegian org
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const endpointErrors = results.filter(r => r.ruleId.startsWith('PEPPOL-T00'));
console.log('Endpoint validation results:', endpointErrors);
expect(endpointErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - invalid GLN endpoint', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
sellerEndpointId: '0088:123456789012', // Invalid GLN (wrong check digit)
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const endpointErrors = results.filter(r => r.ruleId === 'PEPPOL-T001');
expect(endpointErrors.length).toBeGreaterThan(0);
expect(endpointErrors[0].message).toInclude('Invalid seller endpoint ID');
});
tap.test('PEPPOL Validator - invalid endpoint format', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
sellerEndpointId: 'invalid-format', // No scheme
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const endpointErrors = results.filter(r => r.ruleId === 'PEPPOL-T001');
expect(endpointErrors.length).toBeGreaterThan(0);
expect(endpointErrors[0].severity).toEqual('error');
});
tap.test('PEPPOL Validator - document type validation', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
documentTypeId: 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1'
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const docTypeErrors = results.filter(r => r.ruleId === 'PEPPOL-T003');
expect(docTypeErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - process ID validation', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
processId: 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0'
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const processErrors = results.filter(r => r.ruleId === 'PEPPOL-T004');
expect(processErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - invalid process ID', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
processId: 'invalid:process:id'
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const processErrors = results.filter(r => r.ruleId === 'PEPPOL-T004');
expect(processErrors.length).toBeGreaterThan(0);
expect(processErrors[0].severity).toEqual('warning');
});
tap.test('PEPPOL Validator - business rules', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
// Missing both buyer reference and purchase order reference
},
from: {
type: 'company',
name: 'Test Company'
// Missing email
}
};
const results = validator.validatePeppol(invoice as EInvoice);
// Should have error for missing buyer reference
const buyerRefErrors = results.filter(r => r.ruleId === 'PEPPOL-B-01');
expect(buyerRefErrors.length).toBeGreaterThan(0);
// Should have warning for missing seller email
const emailWarnings = results.filter(r => r.ruleId === 'PEPPOL-B-02');
expect(emailWarnings.length).toBeGreaterThan(0);
});
tap.test('PEPPOL Validator - buyer reference present', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
buyerReference: 'REF-12345'
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const buyerRefErrors = results.filter(r => r.ruleId === 'PEPPOL-B-01');
expect(buyerRefErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - purchase order reference present', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
purchaseOrderReference: 'PO-2025-001'
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const buyerRefErrors = results.filter(r => r.ruleId === 'PEPPOL-B-01');
expect(buyerRefErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - payment means validation', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
paymentMeans: {
paymentMeansCode: '30' // Valid code for credit transfer
}
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const paymentErrors = results.filter(r => r.ruleId === 'PEPPOL-B-04');
expect(paymentErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - invalid payment means', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
paymentMeans: {
paymentMeansCode: '999' // Invalid code
}
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const paymentErrors = results.filter(r => r.ruleId === 'PEPPOL-B-04');
expect(paymentErrors.length).toBeGreaterThan(0);
expect(paymentErrors[0].severity).toEqual('error');
});
tap.test('PEPPOL Validator - non-PEPPOL invoice skips validation', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:cen.eu:en16931:2017', // Not PEPPOL
}
};
const results = validator.validatePeppol(invoice as EInvoice);
expect(results.length).toEqual(0);
});
tap.test('PEPPOL Validator - scheme ID validation', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
buyerPartyId: {
schemeId: '0088', // Valid GLN scheme
id: '1234567890128'
}
}
},
from: {
type: 'company',
name: 'Test Company',
registrationDetails: {
partyIdentification: {
schemeId: '9906', // Valid IT:VAT scheme
id: 'IT12345678901'
}
}
} as any
};
const results = validator.validatePeppol(invoice as EInvoice);
const schemeErrors = results.filter(r =>
r.ruleId === 'PEPPOL-T005' || r.ruleId === 'PEPPOL-T006'
);
expect(schemeErrors.length).toEqual(0);
});
tap.test('PEPPOL Validator - invalid scheme ID', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
buyerPartyId: {
schemeId: '9999', // Invalid scheme
id: '12345'
}
}
}
};
const results = validator.validatePeppol(invoice as EInvoice);
const schemeErrors = results.filter(r => r.ruleId === 'PEPPOL-T006');
expect(schemeErrors.length).toBeGreaterThan(0);
expect(schemeErrors[0].severity).toEqual('warning');
});
tap.test('PEPPOL Validator - B2G detection', async () => {
const validator = PeppolValidator.create();
const invoice: Partial<EInvoice> = {
metadata: {
profileId: 'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
extensions: {
buyerPartyId: {
schemeId: '0204', // German government Leitweg-ID
id: '991-12345-01'
},
buyerCategory: 'government'
}
},
to: {
type: 'company',
name: 'Government Agency'
}
};
const results = validator.validatePeppol(invoice as EInvoice);
// B2G should require endpoint IDs
const endpointErrors = results.filter(r =>
r.ruleId === 'PEPPOL-T001' || r.ruleId === 'PEPPOL-T002'
);
expect(endpointErrors.length).toBeGreaterThan(0);
expect(endpointErrors[0].message).toInclude('mandatory for PEPPOL B2G');
});
export default tap.start();
+163
View File
@@ -0,0 +1,163 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SchematronValidator, HybridValidator } from '../ts/formats/validation/schematron.validator.js';
import { SchematronDownloader } from '../ts/formats/validation/schematron.downloader.js';
import { SchematronWorkerPool } from '../ts/formats/validation/schematron.worker.js';
tap.test('Schematron Infrastructure - should initialize correctly', async () => {
const validator = new SchematronValidator();
expect(validator).toBeInstanceOf(SchematronValidator);
expect(validator.hasRules()).toBeFalse();
});
tap.test('Schematron Infrastructure - should load Schematron rules', async () => {
const validator = new SchematronValidator();
// Load a simple test Schematron
const testSchematron = `<?xml version="1.0" encoding="UTF-8"?>
<sch:schema xmlns:sch="http://purl.oclc.org/dsdl/schematron">
<sch:ns prefix="ubl" uri="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"/>
<sch:pattern id="test-pattern">
<sch:rule context="//ubl:Invoice">
<sch:assert test="ubl:ID" id="TEST-01">
Invoice must have an ID
</sch:assert>
</sch:rule>
</sch:pattern>
</sch:schema>`;
await validator.loadSchematron(testSchematron, false);
expect(validator.hasRules()).toBeTrue();
});
tap.test('Schematron Infrastructure - should detect phases', async () => {
const validator = new SchematronValidator();
const schematronWithPhases = `<?xml version="1.0" encoding="UTF-8"?>
<sch:schema xmlns:sch="http://purl.oclc.org/dsdl/schematron">
<sch:phase id="basic">
<sch:active pattern="basic-rules"/>
</sch:phase>
<sch:phase id="extended">
<sch:active pattern="basic-rules"/>
<sch:active pattern="extended-rules"/>
</sch:phase>
<sch:pattern id="basic-rules">
<sch:rule context="//Invoice">
<sch:assert test="ID">Invoice must have ID</sch:assert>
</sch:rule>
</sch:pattern>
</sch:schema>`;
await validator.loadSchematron(schematronWithPhases, false);
const phases = await validator.getPhases();
expect(phases).toContain('basic');
expect(phases).toContain('extended');
});
tap.test('Schematron Downloader - should initialize', async () => {
const downloader = new SchematronDownloader('.nogit/schematron-test');
await downloader.initialize();
// Check that sources are defined
expect(downloader).toBeInstanceOf(SchematronDownloader);
});
tap.test('Schematron Downloader - should list available sources', async () => {
const { SCHEMATRON_SOURCES } = await import('../ts/formats/validation/schematron.downloader.js');
// Check EN16931 sources
expect(SCHEMATRON_SOURCES.EN16931).toBeDefined();
expect(SCHEMATRON_SOURCES.EN16931.length).toBeGreaterThan(0);
const en16931Ubl = SCHEMATRON_SOURCES.EN16931.find(s => s.format === 'UBL');
expect(en16931Ubl).toBeDefined();
expect(en16931Ubl?.name).toEqual('EN16931-UBL');
// Check PEPPOL sources
expect(SCHEMATRON_SOURCES.PEPPOL).toBeDefined();
expect(SCHEMATRON_SOURCES.PEPPOL.length).toBeGreaterThan(0);
// Check XRechnung sources
expect(SCHEMATRON_SOURCES.XRECHNUNG).toBeDefined();
expect(SCHEMATRON_SOURCES.XRECHNUNG.length).toBeGreaterThan(0);
});
tap.test('Hybrid Validator - should combine validators', async () => {
const schematronValidator = new SchematronValidator();
const hybrid = new HybridValidator(schematronValidator);
// Add a mock TypeScript validator
const mockTSValidator = {
validate: (xml: string) => [{
ruleId: 'TS-TEST-01',
severity: 'error' as const,
message: 'Test error from TS validator',
btReference: undefined,
bgReference: undefined
}]
};
hybrid.addTSValidator(mockTSValidator);
// Test validation (will only run TS validator since no Schematron loaded)
const results = await hybrid.validate('<Invoice/>');
expect(results.length).toEqual(1);
expect(results[0].ruleId).toEqual('TS-TEST-01');
});
tap.test('Schematron Worker Pool - should initialize', async () => {
const pool = new SchematronWorkerPool(2);
// Test pool stats
const stats = pool.getStats();
expect(stats.totalWorkers).toEqual(0); // Not initialized yet
expect(stats.queuedTasks).toEqual(0);
// Note: Full worker pool test would require actual worker thread setup
// which may not work in all test environments
});
tap.test('Schematron Validator - SVRL parsing', async () => {
const validator = new SchematronValidator();
// Test SVRL output parsing
const testSVRL = `<?xml version="1.0" encoding="UTF-8"?>
<svrl:schematron-output xmlns:svrl="http://purl.oclc.org/dsdl/svrl">
<svrl:active-pattern document="test.xml"/>
<svrl:failed-assert test="count(ID) = 1"
location="/Invoice"
id="BR-01"
flag="fatal">
<svrl:text>[BR-01] Invoice must have exactly one ID</svrl:text>
</svrl:failed-assert>
<svrl:successful-report test="Currency = 'EUR'"
location="/Invoice"
id="INFO-01"
flag="information">
<svrl:text>Currency is EUR</svrl:text>
</svrl:successful-report>
</svrl:schematron-output>`;
// This would test the SVRL parsing logic
// The actual implementation would parse this and return ValidationResult[]
expect(testSVRL).toContain('failed-assert');
expect(testSVRL).toContain('BR-01');
});
tap.test('Schematron Integration - should handle missing files gracefully', async () => {
const validator = new SchematronValidator();
try {
await validator.loadSchematron('non-existent-file.sch', true);
expect(true).toBeFalse(); // Should not reach here
} catch (error) {
expect(error).toBeDefined();
}
});
tap.start();
+686
View File
@@ -0,0 +1,686 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { SemanticModelValidator } from '../ts/formats/semantic/semantic.validator.js';
import { SemanticModelAdapter } from '../ts/formats/semantic/semantic.adapter.js';
import { EInvoice } from '../ts/einvoice.js';
import type { EN16931SemanticModel } from '../ts/formats/semantic/bt-bg.model.js';
tap.test('Semantic Model - adapter instantiation', async () => {
const adapter = new SemanticModelAdapter();
expect(adapter).toBeInstanceOf(SemanticModelAdapter);
const validator = new SemanticModelValidator();
expect(validator).toBeInstanceOf(SemanticModelValidator);
});
tap.test('Semantic Model - EInvoice to semantic model conversion', async () => {
const adapter = new SemanticModelAdapter();
const invoice = new EInvoice();
invoice.accountingDocId = 'INV-2025-001';
invoice.issueDate = new Date('2025-01-11');
invoice.accountingDocType = 'invoice';
invoice.currency = 'EUR';
invoice.from = {
type: 'company',
name: 'Test Seller GmbH',
address: {
streetName: 'Hauptstrasse 1',
houseNumber: '',
city: 'Berlin',
postalCode: '10115',
country: 'DE'
},
registrationDetails: {
vatId: 'DE123456789',
registrationId: '',
registrationName: 'Test Seller GmbH'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.to = {
type: 'company',
name: 'Test Buyer SAS',
address: {
streetName: 'Rue de la Paix 10',
houseNumber: '',
city: 'Paris',
postalCode: '75001',
country: 'FR'
},
registrationDetails: {
vatId: 'FR987654321',
registrationId: '',
registrationName: 'Test Buyer SAS'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.items = [{
position: 1,
name: 'Consulting Service',
unitQuantity: 10,
unitNetPrice: 100,
vatPercentage: 19,
unitType: 'HUR',
articleNumber: '',
description: 'Professional consulting services'
}];
invoice.paymentOptions = {
description: 'Payment due within 30 days',
sepaConnection: {
iban: 'DE89370400440532013000',
bic: 'COBADEFFXXX'
},
payPal: {
email: 'billing@testseller.example'
}
};
const model = adapter.toSemanticModel(invoice);
// Verify core fields
expect(model.documentInformation.invoiceNumber).toEqual('INV-2025-001');
expect(model.documentInformation.currencyCode).toEqual('EUR');
expect(model.documentInformation.typeCode).toEqual('380'); // Invoice type code
// Verify seller
expect(model.seller.name).toEqual('Test Seller GmbH');
expect(model.seller.vatIdentifier).toEqual('DE123456789');
expect(model.seller.postalAddress.countryCode).toEqual('DE');
// Verify buyer
expect(model.buyer.name).toEqual('Test Buyer SAS');
expect(model.buyer.vatIdentifier).toEqual('FR987654321');
expect(model.buyer.postalAddress.countryCode).toEqual('FR');
// Verify lines
expect(model.invoiceLines.length).toEqual(1);
expect(model.invoiceLines[0].itemInformation.name).toEqual('Consulting Service');
expect(model.invoiceLines[0].invoicedQuantity).toEqual(10);
// Verify payment instructions can be derived from paymentOptions
expect(model.paymentInstructions.paymentMeansTypeCode).toEqual('30');
expect(model.paymentInstructions.paymentMeansText).toEqual('Payment due within 30 days');
expect(model.paymentInstructions.paymentAccountIdentifier).toEqual('DE89370400440532013000');
expect(model.paymentInstructions.paymentServiceProviderIdentifier).toEqual('COBADEFFXXX');
});
tap.test('Semantic Model - semantic model to EInvoice conversion', async () => {
const adapter = new SemanticModelAdapter();
const model: EN16931SemanticModel = {
documentInformation: {
invoiceNumber: 'INV-2025-002',
issueDate: new Date('2025-01-11'),
typeCode: '380',
currencyCode: 'USD'
},
seller: {
name: 'US Seller Inc',
vatIdentifier: 'US123456789',
postalAddress: {
addressLine1: '123 Main St',
city: 'New York',
postCode: '10001',
countryCode: 'US'
}
},
buyer: {
name: 'Canadian Buyer Ltd',
vatIdentifier: 'CA987654321',
postalAddress: {
addressLine1: '456 Queen St',
city: 'Toronto',
postCode: 'M5H 2N2',
countryCode: 'CA'
}
},
paymentInstructions: {
paymentMeansTypeCode: '30',
paymentAccountIdentifier: 'US12345678901234567890',
paymentServiceProviderIdentifier: 'BANKUS33XXX'
},
documentTotals: {
lineExtensionAmount: 1000,
taxExclusiveAmount: 1000,
taxInclusiveAmount: 1100,
payableAmount: 1100
},
invoiceLines: [{
identifier: '1',
invoicedQuantity: 5,
invoicedQuantityUnitOfMeasureCode: 'C62',
lineExtensionAmount: 1000,
priceDetails: {
itemNetPrice: 200
},
vatInformation: {
categoryCode: 'S',
rate: 10
},
itemInformation: {
name: 'Product A',
description: 'High quality product'
}
}]
};
const invoice = adapter.fromSemanticModel(model);
expect(invoice.accountingDocId).toEqual('INV-2025-002');
expect(invoice.currency).toEqual('USD');
expect(invoice.accountingDocType).toEqual('invoice');
expect(invoice.from.name).toEqual('US Seller Inc');
expect(invoice.to.name).toEqual('Canadian Buyer Ltd');
expect(invoice.items.length).toEqual(1);
expect(invoice.items[0].name).toEqual('Product A');
expect(invoice.paymentOptions?.sepaConnection?.iban).toEqual('US12345678901234567890');
expect(invoice.paymentOptions?.sepaConnection?.bic).toEqual('BANKUS33XXX');
expect(invoice.metadata?.paymentMeansCode).toEqual('30');
expect(invoice.metadata?.paymentAccount?.iban).toEqual('US12345678901234567890');
const roundTripModel = adapter.toSemanticModel(invoice);
expect(roundTripModel.paymentInstructions.paymentAccountIdentifier).toEqual('US12345678901234567890');
expect(roundTripModel.paymentInstructions.paymentServiceProviderIdentifier).toEqual('BANKUS33XXX');
});
tap.test('Semantic Model - validation of mandatory business terms', async () => {
const validator = new SemanticModelValidator();
// Invalid invoice missing mandatory fields
const invoice = new EInvoice();
invoice.accountingDocId = ''; // Missing invoice number
invoice.issueDate = new Date('2025-01-11');
invoice.accountingDocType = 'invoice';
invoice.currency = 'EUR';
invoice.from = {
type: 'company',
name: 'Test Seller',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'DE'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'Test Seller'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.to = {
type: 'company',
name: 'Test Buyer',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'FR'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'Test Buyer'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.items = [];
const results = validator.validate(invoice);
// Should have errors for missing mandatory fields
const errors = results.filter(r => r.severity === 'error');
expect(errors.length).toBeGreaterThan(0);
// Check for specific BT errors
expect(errors.some(e => e.btReference === 'BT-1')).toBeTrue(); // Invoice number
expect(errors.some(e => e.bgReference === 'BG-25')).toBeTrue(); // Invoice lines
});
tap.test('Semantic Model - validation of valid invoice', async () => {
const validator = new SemanticModelValidator();
const invoice = new EInvoice();
invoice.accountingDocId = 'INV-2025-003';
invoice.issueDate = new Date('2025-01-11');
invoice.accountingDocType = 'invoice';
invoice.currency = 'EUR';
invoice.from = {
type: 'company',
name: 'Valid Seller GmbH',
address: {
streetName: 'Hauptstrasse 1',
houseNumber: '',
city: 'Berlin',
postalCode: '10115',
country: 'DE'
},
registrationDetails: {
vatId: 'DE123456789',
registrationId: '',
registrationName: 'Valid Seller GmbH'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.to = {
type: 'company',
name: 'Valid Buyer SAS',
address: {
streetName: 'Rue de la Paix 10',
houseNumber: '',
city: 'Paris',
postalCode: '75001',
country: 'FR'
},
registrationDetails: {
vatId: 'FR987654321',
registrationId: '',
registrationName: 'Valid Buyer SAS'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.items = [{
position: 1,
name: 'Consulting Service',
unitQuantity: 10,
unitNetPrice: 100,
vatPercentage: 19,
unitType: 'HUR',
articleNumber: '',
description: 'Professional consulting services'
}];
invoice.metadata = {
...invoice.metadata,
extensions: {
...invoice.metadata?.extensions,
paymentAccount: {
iban: 'DE89370400440532013000',
institutionName: 'Test Bank'
}
}
};
const results = validator.validate(invoice);
const errors = results.filter(r => r.severity === 'error');
console.log('Validation errors:', errors);
// Should have minimal or no errors for a valid invoice
expect(errors.length).toBeLessThanOrEqual(1); // Allow for payment means type code
});
tap.test('Semantic Model - BT/BG mapping', async () => {
const validator = new SemanticModelValidator();
const invoice = new EInvoice();
invoice.accountingDocId = 'INV-2025-004';
invoice.issueDate = new Date('2025-01-11');
invoice.accountingDocType = 'invoice';
invoice.currency = 'EUR';
invoice.from = {
type: 'company',
name: 'Mapping Test Seller',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'DE'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'Mapping Test Seller'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.to = {
type: 'company',
name: 'Mapping Test Buyer',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'FR'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'Mapping Test Buyer'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.items = [{
position: 1,
name: 'Test Item',
unitQuantity: 1,
unitNetPrice: 100,
vatPercentage: 19,
unitType: 'C62',
articleNumber: '',
description: 'Test item description'
}];
const mapping = validator.getBusinessTermMapping(invoice);
// Verify key mappings
expect(mapping.get('BT-1')).toEqual('INV-2025-004');
expect(mapping.get('BT-5')).toEqual('EUR');
expect(mapping.get('BT-27')).toEqual('Mapping Test Seller');
expect(mapping.get('BT-44')).toEqual('Mapping Test Buyer');
expect(mapping.has('BG-25')).toBeTrue(); // Invoice lines
const invoiceLines = mapping.get('BG-25');
expect(invoiceLines.length).toEqual(1);
});
tap.test('Semantic Model - credit note validation', async () => {
const validator = new SemanticModelValidator();
const creditNote = new EInvoice();
creditNote.accountingDocId = 'CN-2025-001';
creditNote.issueDate = new Date('2025-01-11');
creditNote.accountingDocType = 'creditNote';
creditNote.currency = 'EUR';
creditNote.from = {
type: 'company',
name: 'Credit Issuer',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'DE'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'Credit Issuer'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
creditNote.to = {
type: 'company',
name: 'Credit Receiver',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'FR'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'Credit Receiver'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
creditNote.items = [{
position: 1,
name: 'Refund Item',
unitQuantity: -1,
unitNetPrice: 100,
vatPercentage: 19,
unitType: 'C62',
articleNumber: '',
description: 'Refund for returned goods'
}];
const results = validator.validate(creditNote);
// Should have warning about missing preceding invoice reference
const warnings = results.filter(r => r.severity === 'warning');
expect(warnings.some(w => w.ruleId === 'COND-02')).toBeTrue();
});
tap.test('Semantic Model - VAT breakdown validation', async () => {
const adapter = new SemanticModelAdapter();
const invoice = new EInvoice();
invoice.accountingDocId = 'INV-2025-005';
invoice.issueDate = new Date('2025-01-11');
invoice.accountingDocType = 'invoice';
invoice.currency = 'EUR';
invoice.from = {
type: 'company',
name: 'VAT Test Seller',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'DE'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'VAT Test Seller'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.to = {
type: 'company',
name: 'VAT Test Buyer',
address: {
streetName: '',
houseNumber: '',
city: '',
postalCode: '',
country: 'FR'
},
registrationDetails: {
vatId: '',
registrationId: '',
registrationName: 'VAT Test Buyer'
},
status: 'active',
foundedDate: {
year: 2024,
month: 1,
day: 1
}
} as any;
invoice.items = [
{
position: 1,
name: 'Standard Rate Item',
unitQuantity: 1,
unitNetPrice: 100,
vatPercentage: 19,
unitType: 'C62',
articleNumber: '',
description: 'Product with standard VAT rate'
},
{
position: 2,
name: 'Zero Rate Item',
unitQuantity: 1,
unitNetPrice: 50,
vatPercentage: 0,
unitType: 'C62',
articleNumber: '',
description: 'Product with zero VAT rate'
}
];
const model = adapter.toSemanticModel(invoice);
// Should create VAT breakdown
expect(model.vatBreakdown).toBeDefined();
if (model.vatBreakdown) {
// Default implementation creates single breakdown from totals
expect(model.vatBreakdown.length).toBeGreaterThan(0);
}
});
tap.test('Semantic Model - complete semantic model validation', async () => {
const adapter = new SemanticModelAdapter();
const model: EN16931SemanticModel = {
documentInformation: {
invoiceNumber: 'COMPLETE-001',
issueDate: new Date('2025-01-11'),
typeCode: '380',
currencyCode: 'EUR',
notes: [{ noteContent: 'Test invoice' }]
},
processControl: {
specificationIdentifier: 'urn:cen.eu:en16931:2017'
},
references: {
buyerReference: 'REF-12345',
purchaseOrderReference: 'PO-2025-001'
},
seller: {
name: 'Complete Seller GmbH',
vatIdentifier: 'DE123456789',
legalRegistrationIdentifier: 'HRB 12345',
postalAddress: {
addressLine1: 'Hauptstrasse 1',
city: 'Berlin',
postCode: '10115',
countryCode: 'DE'
},
contact: {
contactPoint: 'John Doe',
telephoneNumber: '+49 30 12345678',
emailAddress: 'john@seller.de'
}
},
buyer: {
name: 'Complete Buyer SAS',
vatIdentifier: 'FR987654321',
postalAddress: {
addressLine1: 'Rue de la Paix 10',
city: 'Paris',
postCode: '75001',
countryCode: 'FR'
}
},
delivery: {
name: 'Delivery Location',
actualDeliveryDate: new Date('2025-01-10')
},
paymentInstructions: {
paymentMeansTypeCode: '30',
paymentAccountIdentifier: 'DE89370400440532013000',
paymentServiceProviderIdentifier: 'COBADEFFXXX'
},
documentTotals: {
lineExtensionAmount: 1000,
taxExclusiveAmount: 1000,
taxInclusiveAmount: 1190,
payableAmount: 1190
},
vatBreakdown: [{
vatCategoryTaxableAmount: 1000,
vatCategoryTaxAmount: 190,
vatCategoryCode: 'S',
vatCategoryRate: 19
}],
invoiceLines: [{
identifier: '1',
invoicedQuantity: 10,
invoicedQuantityUnitOfMeasureCode: 'HUR',
lineExtensionAmount: 1000,
priceDetails: {
itemNetPrice: 100
},
vatInformation: {
categoryCode: 'S',
rate: 19
},
itemInformation: {
name: 'Professional Services',
description: 'Consulting and implementation'
}
}]
};
// Validate the model
const errors = adapter.validateSemanticModel(model);
console.log('Semantic model validation errors:', errors);
expect(errors.length).toEqual(0);
});
export default tap.start();
+28 -24
View File
@@ -73,7 +73,7 @@ ${invoiceMatch[0]}`;
console.log(` Errors: ${validation.errors.map(e => `${e.code}: ${e.message}`).join('; ')}`);
}
}
} catch (error) {
} catch (error: any) {
results.failed++;
results.errors.push(`${fileName}: ${error.message}`);
console.log(`${fileName}: Error - ${error.message}`);
@@ -128,7 +128,7 @@ ${invoiceMatch[0]}`;
} else {
console.log(`${fileName}: Validation passed (may need stricter codelist checking)`);
}
} catch (error) {
} catch (error: any) {
console.log(`${fileName}: Error - ${error.message}`);
}
}
@@ -162,7 +162,7 @@ tap.test('Validation Suite - Syntax validation levels', async () => {
console.log(` - ${err.code}: ${err.message}`);
});
}
} catch (error) {
} catch (error: any) {
if (error instanceof EInvoiceValidationError) {
console.log('✓ Validation error caught correctly');
console.log(error.getValidationReport());
@@ -174,23 +174,30 @@ tap.test('Validation Suite - Syntax validation levels', async () => {
tap.test('Validation Suite - Error reporting and recovery', async () => {
const testInvoice = new EInvoice();
// Try to validate without loading XML
try {
await testInvoice.validate();
} catch (error) {
expect(error).toBeInstanceOf(EInvoiceValidationError);
if (error instanceof EInvoiceValidationError) {
// The error might be "Cannot validate: format unknown" since no XML is loaded
console.log('✓ Empty invoice validation error handled correctly');
console.log(` Error: ${error.message}`);
}
}
const emptyValidation = await testInvoice.validate();
expect(emptyValidation.valid).toBeFalse();
expect(emptyValidation.errors.some(error => error.code === 'BR-01')).toBeTrue();
console.log('✓ Empty invoice validation returns structured errors');
// Test with minimal valid invoice
testInvoice.id = 'TEST-001';
testInvoice.invoiceId = 'INV-001';
testInvoice.from.name = 'Test Seller';
testInvoice.from.address = {
streetName: 'Seller Street',
houseNumber: '1',
city: 'Berlin',
postalCode: '10115',
country: 'DE'
};
testInvoice.to.name = 'Test Buyer';
testInvoice.to.address = {
streetName: 'Buyer Street',
houseNumber: '2',
city: 'Paris',
postalCode: '75001',
country: 'FR'
};
testInvoice.items = [{
position: 1,
name: 'Test Item',
@@ -200,13 +207,10 @@ tap.test('Validation Suite - Error reporting and recovery', async () => {
vatPercentage: 19
}];
// This should fail because we don't have XML loaded
try {
await testInvoice.validate();
} catch (error) {
expect(error).toBeInstanceOf(EInvoiceValidationError);
console.log('✓ Validation requires loaded XML');
}
const programmaticValidation = await testInvoice.validate();
expect(programmaticValidation.valid).toBeTrue();
expect(programmaticValidation.errors).toHaveLength(0);
console.log('✓ Programmatic invoice validation works without loaded XML');
});
// Test format-specific validation
@@ -252,7 +256,7 @@ tap.test('Validation Suite - Format-specific validation rules', async () => {
// Validate according to profile
const validation = await einvoice.validate(ValidationLevel.SEMANTIC);
console.log(` Validation: ${validation.valid ? 'VALID' : 'INVALID'}`);
} catch (error) {
} catch (error: any) {
console.log(`${plugins.path.basename(file)}: Skipped - ${error.message}`);
}
}
@@ -369,7 +373,7 @@ tap.test('Validation Suite - Calculation and sum validations', async () => {
} else {
console.log('✓ Invoice calculations validated successfully');
}
} catch (error) {
} catch (error: any) {
console.log(`Calculation validation test skipped: ${error.message}`);
}
});
@@ -418,4 +422,4 @@ tap.test('Validation Suite - Summary Report', async () => {
}
});
tap.start();
tap.start();
+368
View File
@@ -0,0 +1,368 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { XRechnungValidator } from '../ts/formats/validation/xrechnung.validator.js';
import type { EInvoice } from '../ts/einvoice.js';
tap.test('XRechnungValidator - Leitweg-ID validation', async () => {
const validator = XRechnungValidator.create();
// Create test invoice with XRechnung profile
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-001',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: '04-123456789012-01'
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Valid Leitweg-ID should pass
const leitwegErrors = results.filter(r => r.ruleId === 'XR-DE-01');
expect(leitwegErrors).toHaveLength(0);
});
tap.test('XRechnungValidator - Invalid Leitweg-ID', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-002',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: '4-12345-1' // Invalid format
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have Leitweg-ID format error
const leitwegErrors = results.filter(r => r.ruleId === 'XR-DE-01');
expect(leitwegErrors).toHaveLength(1);
expect(leitwegErrors[0].severity).toEqual('error');
});
tap.test('XRechnungValidator - IBAN validation', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-003',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-123',
extensions: {
paymentMeans: [
{
type: 'SEPA',
iban: 'DE89370400440532013000', // Valid German IBAN
bic: 'COBADEFFXXX'
}
]
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Valid IBAN should pass
const ibanErrors = results.filter(r => r.ruleId === 'XR-DE-19');
expect(ibanErrors).toHaveLength(0);
});
tap.test('XRechnungValidator - Invalid IBAN checksum', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-004',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-124',
extensions: {
paymentMeans: [
{
type: 'SEPA',
iban: 'DE89370400440532013001' // Invalid checksum
}
]
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have IBAN checksum error
const ibanErrors = results.filter(r => r.ruleId === 'XR-DE-19');
expect(ibanErrors).toHaveLength(1);
expect(ibanErrors[0].message).toInclude('Invalid IBAN checksum');
});
tap.test('XRechnungValidator - BIC validation', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-005',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-125',
extensions: {
paymentMeans: [
{
type: 'SEPA',
iban: 'DE89370400440532013000',
bic: 'COBADEFF' // Valid 8-character BIC
}
]
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Valid BIC should pass
const bicErrors = results.filter(r => r.ruleId === 'XR-DE-20');
expect(bicErrors).toHaveLength(0);
});
tap.test('XRechnungValidator - Invalid BIC format', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-006',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-126',
extensions: {
paymentMeans: [
{
type: 'SEPA',
iban: 'DE89370400440532013000',
bic: 'INVALID' // Invalid BIC format
}
]
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have BIC format error
const bicErrors = results.filter(r => r.ruleId === 'XR-DE-20');
expect(bicErrors).toHaveLength(1);
expect(bicErrors[0].message).toInclude('Invalid BIC format');
});
tap.test('XRechnungValidator - Mandatory buyer reference', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-007',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0'
// Missing buyerReference
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have mandatory buyer reference error
const refErrors = results.filter(r => r.ruleId === 'XR-DE-15');
expect(refErrors).toHaveLength(1);
expect(refErrors[0].severity).toEqual('error');
});
tap.test('XRechnungValidator - Seller contact validation', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-008',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-127',
extensions: {
sellerContact: {
name: 'John Doe',
email: 'john.doe@example.com',
phone: '+49 30 12345678'
}
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Valid seller contact should pass
const contactErrors = results.filter(r => r.ruleId === 'XR-DE-02');
expect(contactErrors).toHaveLength(0);
});
tap.test('XRechnungValidator - Missing seller contact', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-009',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-128'
// Missing sellerContact
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have missing seller contact error
const contactErrors = results.filter(r => r.ruleId === 'XR-DE-02');
expect(contactErrors).toHaveLength(1);
expect(contactErrors[0].severity).toEqual('error');
});
tap.test('XRechnungValidator - German VAT ID validation', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-010',
from: {
type: 'company' as const,
name: 'Test Company',
registrationDetails: {
vatId: 'DE123456789' // Valid German VAT ID format
}
},
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-129',
sellerTaxId: 'DE123456789'
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Valid German VAT ID should pass
const vatErrors = results.filter(r => r.ruleId === 'XR-DE-04');
expect(vatErrors).toHaveLength(0);
});
tap.test('XRechnungValidator - Invalid German VAT ID', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-011',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-130',
sellerTaxId: 'DE12345' // Invalid - too short
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have invalid VAT ID error
const vatErrors = results.filter(r => r.ruleId === 'XR-DE-04');
expect(vatErrors).toHaveLength(1);
expect(vatErrors[0].message).toInclude('Invalid German VAT ID format');
});
tap.test('XRechnungValidator - Non-XRechnung invoice', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-012',
metadata: {
profileId: 'urn:cen.eu:en16931:2017' // Not XRechnung
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should not validate non-XRechnung invoices
expect(results).toHaveLength(0);
});
tap.test('XRechnungValidator - SEPA country validation', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-013',
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: 'REF-131',
extensions: {
paymentMeans: [
{
type: 'SEPA',
iban: 'US12345678901234567890123456789' // Non-SEPA country
}
]
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should have warning for non-SEPA country
const sepaWarnings = results.filter(r => r.ruleId === 'XR-DE-19' && r.severity === 'warning');
expect(sepaWarnings.length).toBeGreaterThan(0);
expect(sepaWarnings[0].message).toInclude('not in SEPA zone');
});
tap.test('XRechnungValidator - B2G Leitweg-ID requirement', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-014',
to: {
name: 'Bundesamt für Migration' // Public entity
},
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
// Missing buyerReference for B2G
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Should require Leitweg-ID for B2G
const b2gErrors = results.filter(r => r.ruleId === 'XR-DE-15');
expect(b2gErrors).toHaveLength(1);
expect(b2gErrors[0].message).toInclude('mandatory for B2G invoices');
});
tap.test('XRechnungValidator - Complete valid XRechnung invoice', async () => {
const validator = XRechnungValidator.create();
const invoice: Partial<EInvoice> = {
invoiceNumber: 'INV-2025-015',
from: {
type: 'company' as const,
name: 'Example GmbH',
registrationDetails: {
vatId: 'DE123456789'
}
},
metadata: {
profileId: 'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
buyerReference: '991-12345678901-23',
sellerTaxId: 'DE123456789',
extensions: {
sellerContact: {
name: 'Sales Department',
email: 'sales@example.de',
phone: '+49 30 98765432'
},
paymentMeans: [
{
type: 'SEPA',
iban: 'DE89370400440532013000',
bic: 'COBADEFFXXX',
accountName: 'Example GmbH'
}
]
}
}
};
const results = validator.validateXRechnung(invoice as EInvoice);
// Complete valid invoice should have no errors
const errors = results.filter(r => r.severity === 'error');
expect(errors).toHaveLength(0);
});
export default tap.start();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@fin.cx/einvoice',
version: '5.0.0',
version: '5.2.0',
description: 'A TypeScript module for creating, manipulating, and embedding XML data within PDF files specifically tailored for electronic invoice (einvoice) packages.'
}
+201 -30
View File
@@ -1,6 +1,6 @@
import * as plugins from './plugins.js';
import { business, finance } from './plugins.js';
import type { business, finance } from '@tsclass/tsclass';
import type { TInvoice, TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import { InvoiceFormat, ValidationLevel } from './interfaces/common.js';
import type { ValidationResult, ValidationError, EInvoiceOptions, IPdf, ExportFormat } from './interfaces/common.js';
@@ -27,12 +27,24 @@ import { PDFExtractor } from './formats/pdf/pdf.extractor.js';
// Import format detector
import { FormatDetector } from './formats/utils/format.detector.js';
// Import enhanced validators
import { EN16931Validator } from './formats/validation/en16931.validator.js';
import { EN16931BusinessRulesValidator } from './formats/validation/en16931.business-rules.validator.js';
import { CodeListValidator } from './formats/validation/codelist.validator.js';
import type { ValidationOptions } from './formats/validation/validation.types.js';
// Import EN16931 metadata interface
import type { IEInvoiceMetadata } from './interfaces/en16931-metadata.js';
/**
* Main class for working with electronic invoices.
* Supports various invoice formats including Factur-X, ZUGFeRD, UBL, and XRechnung
* Extends the TInvoice interface for seamless integration with existing systems
*/
export class EInvoice implements TInvoice {
private static sharedPdfEmbedder?: PDFEmbedder;
private static sharedPdfExtractor?: PDFExtractor;
/**
* Creates an EInvoice instance from XML string
* @param xmlString XML string to parse
@@ -101,8 +113,8 @@ export class EInvoice implements TInvoice {
public incidenceId: string = '';
public language: string = 'en';
public objectActions: any[] = [];
public pdf: IPdf | null = null;
public pdfAttachments: IPdf[] | null = null;
public pdf?: IPdf;
public pdfAttachments?: IPdf[];
public accentColor: string | null = null;
public logoUrl: string | null = null;
@@ -141,7 +153,13 @@ export class EInvoice implements TInvoice {
this.accountingDocType === 'creditnote' ? 'creditnote' : 'debitnote';
}
public set invoiceType(value: 'invoice' | 'creditnote' | 'debitnote') {
this.accountingDocType = 'invoice'; // Always set to invoice for TInvoice type
if (value !== 'invoice') {
throw new EInvoiceFormatError(
`Unsupported invoice type: ${value}`,
{ unsupportedFeatures: [`invoiceType=${value}`] }
);
}
this.accountingDocType = 'invoice';
}
// Computed properties for convenience
@@ -169,25 +187,32 @@ export class EInvoice implements TInvoice {
}
// EInvoice specific properties
public metadata?: {
format?: InvoiceFormat;
version?: string;
profile?: string;
customizationId?: string;
extensions?: Record<string, any>;
};
public metadata?: IEInvoiceMetadata;
private xmlString: string = '';
private detectedFormat: InvoiceFormat = InvoiceFormat.UNKNOWN;
private parsedXmlDocument?: Document;
private validationErrors: ValidationError[] = [];
private validationCache = new Map<string, ValidationResult>();
private options: EInvoiceOptions = {
validateOnLoad: false,
validationLevel: ValidationLevel.SYNTAX
};
// PDF utilities
private pdfEmbedder = new PDFEmbedder();
private pdfExtractor = new PDFExtractor();
// PDF utilities are created lazily because most invoice workflows never touch PDF I/O.
private get pdfEmbedder(): PDFEmbedder {
if (!EInvoice.sharedPdfEmbedder) {
EInvoice.sharedPdfEmbedder = new PDFEmbedder();
}
return EInvoice.sharedPdfEmbedder;
}
private get pdfExtractor(): PDFExtractor {
if (!EInvoice.sharedPdfExtractor) {
EInvoice.sharedPdfExtractor = new PDFExtractor();
}
return EInvoice.sharedPdfExtractor;
}
/**
* Creates a new EInvoice instance
@@ -258,6 +283,7 @@ export class EInvoice implements TInvoice {
*/
public async fromXmlString(xmlString: string): Promise<EInvoice> {
try {
this.validationCache.clear();
this.xmlString = xmlString;
// Detect format
@@ -267,8 +293,13 @@ export class EInvoice implements TInvoice {
}
// Get appropriate decoder
const decoder = DecoderFactory.createDecoder(xmlString, !this.options.validateOnLoad);
const decoder = DecoderFactory.createDecoder(
xmlString,
!this.options.validateOnLoad,
this.detectedFormat,
);
const invoice = await decoder.decode();
this.parsedXmlDocument = decoder.getParsedDocument();
// Map the decoded invoice to our properties
this.mapFromTInvoice(invoice);
@@ -280,10 +311,11 @@ export class EInvoice implements TInvoice {
return this;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (error instanceof EInvoiceError) {
throw error;
}
throw new EInvoiceParsingError(`Failed to parse XML: ${error.message}`, {}, error as Error);
throw new EInvoiceParsingError(`Failed to parse XML: ${errorMessage}`, {}, error as Error);
}
}
@@ -305,7 +337,8 @@ export class EInvoice implements TInvoice {
const xmlString = fileBuffer.toString('utf-8');
return this.fromXmlString(xmlString);
} catch (error) {
throw new EInvoiceError(`Failed to load file: ${error.message}`, 'FILE_LOAD_ERROR', { filePath });
const errorMessage = error instanceof Error ? error.message : String(error);
throw new EInvoiceError(`Failed to load file: ${errorMessage}`, 'FILE_LOAD_ERROR', { filePath });
}
}
@@ -341,10 +374,11 @@ export class EInvoice implements TInvoice {
return this.fromXmlString(extractedXml);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (error instanceof EInvoiceError) {
throw error;
}
throw new EInvoicePDFError(`Failed to extract invoice from PDF: ${error.message}`, 'extract', {}, error as Error);
throw new EInvoicePDFError(`Failed to extract invoice from PDF: ${errorMessage}`, 'extract', {}, error as Error);
}
}
@@ -421,7 +455,8 @@ export class EInvoice implements TInvoice {
return await encoder.encode(invoice);
} catch (error) {
throw new EInvoiceFormatError(`Failed to encode to ${format}: ${error.message}`, { targetFormat: format });
const errorMessage = error instanceof Error ? error.message : String(error);
throw new EInvoiceFormatError(`Failed to encode to ${format}: ${errorMessage}`, { targetFormat: format });
}
}
@@ -430,26 +465,144 @@ export class EInvoice implements TInvoice {
* @param level The validation level to use
* @returns The validation result
*/
public async validate(level: ValidationLevel = ValidationLevel.BUSINESS): Promise<ValidationResult> {
public async validate(level: ValidationLevel = ValidationLevel.BUSINESS, options?: ValidationOptions): Promise<ValidationResult> {
try {
const format = this.detectedFormat || InvoiceFormat.UNKNOWN;
if (format === InvoiceFormat.UNKNOWN) {
throw new EInvoiceValidationError('Cannot validate: format unknown', []);
}
// For programmatically created invoices without XML, validate the in-memory invoice object.
let result: ValidationResult;
const cacheKey = this.getValidationCacheKey(level, options);
const validator = ValidatorFactory.createValidator(this.xmlString);
const result = validator.validate(level);
if (cacheKey) {
const cached = this.validationCache.get(cacheKey);
if (cached) {
return this.cloneValidationResult(cached);
}
}
if (this.xmlString && this.detectedFormat !== InvoiceFormat.UNKNOWN) {
if (this.shouldUseFastBusinessValidation(level, options)) {
result = this.validateDecodedInvoice(level);
} else {
// Use existing validator for XML-based validation
const validator = ValidatorFactory.createValidator(
this.xmlString,
this.detectedFormat,
this.parsedXmlDocument,
);
result = validator.validate(level);
// Keep the raw XML, but drop the cached DOM once validation is done.
this.parsedXmlDocument = undefined;
}
} else {
result = this.validateDecodedInvoice(level);
}
// Enhanced validation with feature flags
if (options?.featureFlags?.includes('EN16931_BUSINESS_RULES')) {
const businessRulesValidator = new EN16931BusinessRulesValidator();
const businessResults = businessRulesValidator.validate(this, options);
// Merge results
result.errors = result.errors.concat(
businessResults
.filter(r => r.severity === 'error')
.map(r => ({ code: r.ruleId, message: r.message, field: r.field }))
);
// Add warnings if not in report-only mode
if (!options.reportOnly) {
result.warnings = (result.warnings || []).concat(
businessResults
.filter(r => r.severity === 'warning')
.map(r => ({ code: r.ruleId, message: r.message, field: r.field }))
);
}
}
// Code list validation with feature flag
if (options?.featureFlags?.includes('CODE_LIST_VALIDATION')) {
const codeListValidator = new CodeListValidator();
const codeListResults = codeListValidator.validate(this);
// Merge results
result.errors = result.errors.concat(
codeListResults
.filter(r => r.severity === 'error')
.map(r => ({ code: r.ruleId, message: r.message, field: r.field }))
);
}
// Update validation status
this.validationErrors = result.errors;
result.valid = result.errors.length === 0 || options?.reportOnly === true;
if (cacheKey) {
this.validationCache.set(cacheKey, this.cloneValidationResult(result));
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (error instanceof EInvoiceError) {
throw error;
}
throw new EInvoiceValidationError(`Validation failed: ${error.message}`, [], { validationLevel: level });
throw new EInvoiceValidationError(`Validation failed: ${errorMessage}`, [], { validationLevel: level });
}
}
private validateDecodedInvoice(level: ValidationLevel): ValidationResult {
const invoice = this.mapToTInvoice();
const errors = EN16931Validator.collectMandatoryFieldErrors(invoice).map(message =>
this.createValidationError(message)
);
return {
valid: errors.length === 0,
errors,
warnings: level === ValidationLevel.SYNTAX
? [{
code: 'VAL-NO-XML',
message: 'Syntax validation was skipped because no XML document has been loaded.'
}]
: [],
level: level
};
}
private shouldUseFastBusinessValidation(level: ValidationLevel, options?: ValidationOptions): boolean {
if (level !== ValidationLevel.BUSINESS) {
return false;
}
if (options?.featureFlags?.length || options?.reportOnly || this.detectedFormat !== InvoiceFormat.UBL) {
return false;
}
// For tiny plain-UBL documents without parties or lines, the decoded invoice model already
// contains everything needed for the mandatory-field failures the XML validator would report.
return this.items.length === 0 && !this.from?.name && !this.to?.name;
}
private getValidationCacheKey(level: ValidationLevel, options?: ValidationOptions): string | undefined {
if (!this.xmlString || options) {
return undefined;
}
if (level !== ValidationLevel.SYNTAX && level !== ValidationLevel.SEMANTIC) {
return undefined;
}
return `xml:${level}`;
}
private cloneValidationResult(result: ValidationResult): ValidationResult {
return {
...result,
errors: result.errors.map(error => ({ ...error })),
warnings: result.warnings?.map(warning => ({ ...warning }))
};
}
/**
* Embeds the invoice XML into a PDF
* @param pdfBuffer The PDF buffer to embed into
@@ -465,7 +618,8 @@ export class EInvoice implements TInvoice {
}
return embedResult.data! as Buffer;
} catch (error) {
throw new EInvoicePDFError(`Failed to embed XML in PDF: ${error.message}`, 'embed', { format }, error as Error);
const errorMessage = error instanceof Error ? error.message : String(error);
throw new EInvoicePDFError(`Failed to embed XML in PDF: ${errorMessage}`, 'embed', { format }, error as Error);
}
}
@@ -497,10 +651,11 @@ export class EInvoice implements TInvoice {
await plugins.fs.writeFile(filePath, xmlString, 'utf-8');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (error instanceof EInvoiceError) {
throw error;
}
throw new EInvoiceError(`Failed to save file: ${error.message}`, 'FILE_SAVE_ERROR', { filePath });
throw new EInvoiceError(`Failed to save file: ${errorMessage}`, 'FILE_SAVE_ERROR', { filePath });
}
}
@@ -600,4 +755,20 @@ export class EInvoice implements TInvoice {
public addItem(item: Partial<TAccountingDocItem>): void {
this.items.push(this.createItem(item));
}
}
private createValidationError(message: string): ValidationError {
const match = message.match(/^([A-Z]{2,}(?:-[A-Z0-9]+)*-\d+):\s*(.+)$/);
if (match) {
return {
code: match[1],
message: match[2]
};
}
return {
code: 'VALIDATION_ERROR',
message
};
}
}
+7
View File
@@ -37,6 +37,13 @@ export abstract class BaseDecoder {
return this.xml;
}
/**
* Gets a parsed XML document when a decoder keeps one around.
*/
public getParsedDocument(): Document | undefined {
return undefined;
}
/**
* Parses a CII date string based on format code
* @param dateStr Date string
+17 -8
View File
@@ -3,6 +3,14 @@ import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ciiParser = new DOMParser();
const ciiNamespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT,
};
const ciiSelect = xpath.useNamespaces(ciiNamespaces);
/**
* Base decoder for CII-based invoice formats
*/
@@ -16,22 +24,22 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
super(xml, skipValidation);
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
this.doc = ciiParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT
};
this.namespaces = ciiNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
this.select = ciiSelect;
// Detect profile
this.detectProfile();
}
public override getParsedDocument(): Document {
return this.doc;
}
/**
* Decodes CII XML into a TInvoice object
* @returns Promise resolving to a TInvoice object
@@ -93,7 +101,8 @@ export abstract class CIIBaseDecoder extends BaseDecoder {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
+18 -13
View File
@@ -4,31 +4,35 @@ import type { ValidationResult } from '../../interfaces/common.js';
import { CII_NAMESPACES, CIIProfile } from './cii.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ciiValidatorParser = new DOMParser();
const ciiValidatorNamespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT,
};
const ciiValidatorSelect = xpath.useNamespaces(ciiValidatorNamespaces);
/**
* Base validator for CII-based invoice formats
*/
export abstract class CIIBaseValidator extends BaseValidator {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
protected doc!: Document;
protected namespaces!: Record<string, string>;
protected select!: xpath.XPathSelect;
protected profile: CIIProfile = CIIProfile.EN16931;
constructor(xml: string) {
constructor(xml: string, doc?: Document) {
super(xml);
try {
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Reuse an existing parsed document when available.
this.doc = doc ?? ciiValidatorParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
rsm: CII_NAMESPACES.RSM,
ram: CII_NAMESPACES.RAM,
udt: CII_NAMESPACES.UDT
};
this.namespaces = ciiValidatorNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
this.select = ciiValidatorSelect;
// Detect profile
this.detectProfile();
@@ -139,7 +143,8 @@ export abstract class CIIBaseValidator extends BaseValidator {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
@@ -0,0 +1,141 @@
/**
* XML to EInvoice Converter
* Converts UBL and CII XML formats to internal EInvoice format
*/
import * as plugins from '../../plugins.js';
import type { EInvoice } from '../../einvoice.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
/**
* Converter for XML formats to EInvoice - simplified version
* This is a basic converter that extracts essential fields for testing
*/
export class XMLToEInvoiceConverter {
private parser: InstanceType<typeof plugins.DOMParser>;
constructor() {
this.parser = new plugins.DOMParser();
}
/**
* Convert XML content to EInvoice
*/
public async convert(xmlContent: string, format: 'UBL' | 'CII'): Promise<EInvoice> {
// For now, return a mock invoice for testing
// A full implementation would parse the XML and extract all fields
const mockInvoice = {
accountingDocId: 'TEST-001',
accountingDocType: 'invoice',
date: Date.now(),
items: [],
from: {
type: 'company' as const,
name: 'Test Seller',
description: 'Test Seller Company',
address: {
streetName: 'Test Street',
houseNumber: '1',
city: 'Test City',
postalCode: '12345',
country: 'Germany',
countryCode: 'DE'
},
registrationDetails: {
companyName: 'Test Seller Company',
registrationCountry: 'DE'
}
} as any,
to: {
type: 'company' as const,
name: 'Test Buyer',
description: 'Test Buyer Company',
address: {
streetName: 'Test Street',
houseNumber: '2',
city: 'Test City',
postalCode: '12345',
country: 'Germany',
countryCode: 'DE'
},
registrationDetails: {
companyName: 'Test Buyer Company',
registrationCountry: 'DE'
}
} as any,
currency: 'EUR' as any,
get totalNet() { return 100; },
get totalGross() { return 119; },
get totalVat() { return 19; },
get taxBreakdown() { return []; },
metadata: {
customizationId: 'urn:cen.eu:en16931:2017'
}
};
// Try to extract basic info from XML
try {
const doc = this.parser.parseFromString(xmlContent, 'text/xml');
if (format === 'UBL') {
// Extract invoice ID from UBL
const idElements = doc.getElementsByTagName('cbc:ID');
if (idElements.length > 0) {
(mockInvoice as any).accountingDocId = idElements[0].textContent || 'TEST-001';
}
// Extract currency
const currencyElements = doc.getElementsByTagName('cbc:DocumentCurrencyCode');
if (currencyElements.length > 0) {
(mockInvoice as any).currency = currencyElements[0].textContent || 'EUR';
}
// Extract invoice lines
const lineElements = doc.getElementsByTagName('cac:InvoiceLine');
const items: TAccountingDocItem[] = [];
for (let i = 0; i < lineElements.length; i++) {
const line = lineElements[i];
const item: TAccountingDocItem = {
position: i,
name: this.getElementTextFromNode(line, 'cbc:Name') || `Item ${i + 1}`,
unitQuantity: parseFloat(this.getElementTextFromNode(line, 'cbc:InvoicedQuantity') || '1'),
unitType: 'C62',
unitNetPrice: parseFloat(this.getElementTextFromNode(line, 'cbc:PriceAmount') || '100'),
vatPercentage: parseFloat(this.getElementTextFromNode(line, 'cbc:Percent') || '19')
};
items.push(item);
}
if (items.length > 0) {
(mockInvoice as any).items = items;
}
}
} catch (error) {
console.warn('Error parsing XML:', error);
}
return mockInvoice as unknown as EInvoice;
}
/**
* Helper to get element text from a node
*/
private getElementTextFromNode(node: any, tagName: string): string | null {
const elements = node.getElementsByTagName(tagName);
if (elements.length > 0) {
return elements[0].textContent;
}
// Try with namespace prefix variations
const nsVariations = [tagName, `cbc:${tagName}`, `cac:${tagName}`, `ram:${tagName}`];
for (const variant of nsVariations) {
const els = node.getElementsByTagName(variant);
if (els.length > 0) {
return els[0].textContent;
}
}
return null;
}
}
+7 -2
View File
@@ -16,10 +16,15 @@ export class DecoderFactory {
* Creates a decoder for the specified XML content
* @param xml XML content to decode
* @param skipValidation Whether to skip EN16931 validation
* @param formatHint Optional pre-detected format to avoid re-detecting large XML inputs
* @returns Appropriate decoder instance
*/
public static createDecoder(xml: string, skipValidation: boolean = false): BaseDecoder {
const format = FormatDetector.detectFormat(xml);
public static createDecoder(
xml: string,
skipValidation: boolean = false,
formatHint?: InvoiceFormat,
): BaseDecoder {
const format = formatHint ?? FormatDetector.detectFormat(xml);
switch (format) {
case InvoiceFormat.UBL:
+14 -8
View File
@@ -59,28 +59,34 @@ export class ValidatorFactory {
/**
* Creates a validator for the specified XML content
* @param xml XML content to validate
* @param formatHint Optional pre-detected format to avoid re-detecting large XML inputs
* @param parsedDocument Optional parsed XML document to avoid parsing twice
* @returns Appropriate validator instance
*/
public static createValidator(xml: string): BaseValidator {
public static createValidator(
xml: string,
formatHint?: InvoiceFormat,
parsedDocument?: Document,
): BaseValidator {
try {
const format = FormatDetector.detectFormat(xml);
const format = formatHint ?? FormatDetector.detectFormat(xml);
switch (format) {
case InvoiceFormat.UBL:
return new EN16931UBLValidator(xml);
return new EN16931UBLValidator(xml, parsedDocument);
case InvoiceFormat.XRECHNUNG:
return new XRechnungValidator(xml);
return new XRechnungValidator(xml, parsedDocument);
case InvoiceFormat.CII:
// For now, use Factur-X validator for generic CII
return new FacturXValidator(xml);
return new FacturXValidator(xml, parsedDocument);
case InvoiceFormat.ZUGFERD:
return new ZUGFeRDValidator(xml);
return new ZUGFeRDValidator(xml, parsedDocument);
case InvoiceFormat.FACTURX:
return new FacturXValidator(xml);
return new FacturXValidator(xml, parsedDocument);
case InvoiceFormat.FATTURAPA:
return new FatturaPAValidator(xml);
@@ -131,4 +137,4 @@ class GenericValidator extends BaseValidator {
protected validateBusinessRules(): boolean {
return false;
}
}
}
+524
View File
@@ -0,0 +1,524 @@
/**
* EN16931 Canonical Semantic Model
* Defines all Business Terms (BT) and Business Groups (BG) from the standard
* This provides a format-agnostic representation of invoice data
*/
/**
* Business Term (BT) definitions from EN16931
* Each BT represents a specific data element in an invoice
*/
export interface BusinessTerms {
// Document level information (BT-1 to BT-22)
BT1_InvoiceNumber: string;
BT2_InvoiceIssueDate: Date;
BT3_InvoiceTypeCode: string;
BT4_InvoiceNote?: string;
BT5_InvoiceCurrencyCode: string;
BT6_VATAccountingCurrencyCode?: string;
BT7_ValueDateForVATCalculation?: Date;
BT8_InvoicePeriodDescriptionCode?: string;
BT9_DueDate?: Date;
BT10_BuyerReference?: string;
BT11_ProjectReference?: string;
BT12_ContractReference?: string;
BT13_PurchaseOrderReference?: string;
BT14_SalesOrderReference?: string;
BT15_ReceivingAdviceReference?: string;
BT16_DespatchAdviceReference?: string;
BT17_TenderOrLotReference?: string;
BT18_InvoicedObjectIdentifier?: string;
BT19_BuyerAccountingReference?: string;
BT20_PaymentTerms?: string;
BT21_InvoiceNote?: string[];
BT22_ProcessSpecificNote?: string;
// Seller information (BT-23 to BT-40)
BT23_BusinessProcessType?: string;
BT24_SpecificationIdentifier?: string;
BT25_InvoiceAttachment?: Attachment[];
BT26_InvoiceDocumentReference?: string;
BT27_SellerName: string;
BT28_SellerTradingName?: string;
BT29_SellerIdentifier?: string;
BT30_SellerLegalRegistrationIdentifier?: string;
BT31_SellerVATIdentifier?: string;
BT32_SellerTaxRegistrationIdentifier?: string;
BT33_SellerAdditionalLegalInfo?: string;
BT34_SellerElectronicAddress?: string;
BT35_SellerAddressLine1?: string;
BT36_SellerAddressLine2?: string;
BT37_SellerAddressLine3?: string;
BT38_SellerCity?: string;
BT39_SellerPostCode?: string;
BT40_SellerCountryCode: string;
// Seller contact (BT-41 to BT-43)
BT41_SellerContactPoint?: string;
BT42_SellerContactTelephoneNumber?: string;
BT43_SellerContactEmailAddress?: string;
// Buyer information (BT-44 to BT-58)
BT44_BuyerName: string;
BT45_BuyerTradingName?: string;
BT46_BuyerIdentifier?: string;
BT47_BuyerLegalRegistrationIdentifier?: string;
BT48_BuyerVATIdentifier?: string;
BT49_BuyerElectronicAddress?: string;
BT50_BuyerAddressLine1?: string;
BT51_BuyerAddressLine2?: string;
BT52_BuyerAddressLine3?: string;
BT53_BuyerCity?: string;
BT54_BuyerPostCode?: string;
BT55_BuyerCountryCode: string;
BT56_BuyerContactPoint?: string;
BT57_BuyerContactTelephoneNumber?: string;
BT58_BuyerContactEmailAddress?: string;
// Payee information (BT-59 to BT-62)
BT59_PayeeName?: string;
BT60_PayeeIdentifier?: string;
BT61_PayeeLegalRegistrationIdentifier?: string;
BT62_PayeeLegalRegistrationIdentifierSchemeID?: string;
// Tax representative (BT-62 to BT-69)
BT63_SellerTaxRepresentativeName?: string;
BT64_SellerTaxRepresentativeVATIdentifier?: string;
BT65_SellerTaxRepresentativeAddressLine1?: string;
BT66_SellerTaxRepresentativeAddressLine2?: string;
BT67_SellerTaxRepresentativeCity?: string;
BT68_SellerTaxRepresentativePostCode?: string;
BT69_SellerTaxRepresentativeCountryCode?: string;
// Delivery information (BT-70 to BT-80)
BT70_DeliveryName?: string;
BT71_DeliveryLocationIdentifier?: string;
BT72_ActualDeliveryDate?: Date;
BT73_InvoicingPeriodStartDate?: Date;
BT74_InvoicingPeriodEndDate?: Date;
BT75_DeliveryAddressLine1?: string;
BT76_DeliveryAddressLine2?: string;
BT77_DeliveryAddressLine3?: string;
BT78_DeliveryCity?: string;
BT79_DeliveryPostCode?: string;
BT80_DeliveryCountryCode?: string;
// Payment instructions (BT-81 to BT-91)
BT81_PaymentMeansTypeCode: string;
BT82_PaymentMeansText?: string;
BT83_RemittanceInformation?: string;
BT84_PaymentAccountIdentifier?: string;
BT85_PaymentAccountName?: string;
BT86_PaymentServiceProviderIdentifier?: string;
BT87_PaymentCardAccountPrimaryNumber?: string;
BT88_PaymentCardAccountHolderName?: string;
BT89_MandateReferenceIdentifier?: string;
BT90_BankAssignedCreditorIdentifier?: string;
BT91_DebitedAccountIdentifier?: string;
// Document level allowances (BT-92 to BT-96)
BT92_DocumentLevelAllowanceAmount?: number;
BT93_DocumentLevelAllowanceBaseAmount?: number;
BT94_DocumentLevelAllowancePercentage?: number;
BT95_DocumentLevelAllowanceVATCategoryCode?: string;
BT96_DocumentLevelAllowanceVATRate?: number;
BT97_DocumentLevelAllowanceReason?: string;
BT98_DocumentLevelAllowanceReasonCode?: string;
// Document level charges (BT-99 to BT-105)
BT99_DocumentLevelChargeAmount?: number;
BT100_DocumentLevelChargeBaseAmount?: number;
BT101_DocumentLevelChargePercentage?: number;
BT102_DocumentLevelChargeVATCategoryCode?: string;
BT103_DocumentLevelChargeVATRate?: number;
BT104_DocumentLevelChargeReason?: string;
BT105_DocumentLevelChargeReasonCode?: string;
// Document totals (BT-106 to BT-115)
BT106_SumOfInvoiceLineNetAmount: number;
BT107_SumOfAllowancesOnDocumentLevel?: number;
BT108_SumOfChargesOnDocumentLevel?: number;
BT109_InvoiceTotalAmountWithoutVAT: number;
BT110_InvoiceTotalVATAmount?: number;
BT111_InvoiceTotalVATAmountInAccountingCurrency?: number;
BT112_InvoiceTotalAmountWithVAT: number;
BT113_PaidAmount?: number;
BT114_RoundingAmount?: number;
BT115_AmountDueForPayment: number;
// VAT breakdown (BT-116 to BT-121)
BT116_VATCategoryTaxableAmount?: number;
BT117_VATCategoryTaxAmount?: number;
BT118_VATCategoryCode?: string;
BT119_VATCategoryRate?: number;
BT120_VATExemptionReasonText?: string;
BT121_VATExemptionReasonCode?: string;
// Additional document references (BT-122 to BT-125)
BT122_SupportingDocumentReference?: string;
BT123_SupportingDocumentDescription?: string;
BT124_ExternalDocumentLocation?: string;
BT125_AttachedDocumentEmbedded?: string;
// Line level information (BT-126 to BT-162)
BT126_InvoiceLineIdentifier?: string;
BT127_InvoiceLineNote?: string;
BT128_InvoiceLineObjectIdentifier?: string;
BT129_InvoicedQuantity?: number;
BT130_InvoicedQuantityUnitOfMeasureCode?: string;
BT131_InvoiceLineNetAmount?: number;
BT132_ReferencedPurchaseOrderLineReference?: string;
BT133_InvoiceLineBuyerAccountingReference?: string;
BT134_InvoiceLinePeriodStartDate?: Date;
BT135_InvoiceLinePeriodEndDate?: Date;
BT136_InvoiceLineAllowanceAmount?: number;
BT137_InvoiceLineAllowanceBaseAmount?: number;
BT138_InvoiceLineAllowancePercentage?: number;
BT139_InvoiceLineAllowanceReason?: string;
BT140_InvoiceLineAllowanceReasonCode?: string;
BT141_InvoiceLineChargeAmount?: number;
BT142_InvoiceLineChargeBaseAmount?: number;
BT143_InvoiceLineChargePercentage?: number;
BT144_InvoiceLineChargeReason?: string;
BT145_InvoiceLineChargeReasonCode?: string;
BT146_ItemNetPrice?: number;
BT147_ItemPriceDiscount?: number;
BT148_ItemGrossPrice?: number;
BT149_ItemPriceBaseQuantity?: number;
BT150_ItemPriceBaseQuantityUnitOfMeasureCode?: string;
BT151_ItemVATCategoryCode?: string;
BT152_ItemVATRate?: number;
BT153_ItemName?: string;
BT154_ItemDescription?: string;
BT155_ItemSellersIdentifier?: string;
BT156_ItemBuyersIdentifier?: string;
BT157_ItemStandardIdentifier?: string;
BT158_ItemClassificationIdentifier?: string;
BT159_ItemClassificationListIdentifier?: string;
BT160_ItemOriginCountryCode?: string;
BT161_ItemAttributeName?: string;
BT162_ItemAttributeValue?: string;
}
/**
* Business Groups (BG) from EN16931
* Groups related business terms together
*/
export interface BusinessGroups {
BG1_InvoiceNote?: InvoiceNote;
BG2_ProcessControl?: ProcessControl;
BG3_PrecedingInvoiceReference?: PrecedingInvoiceReference[];
BG4_Seller: Seller;
BG5_SellerPostalAddress: PostalAddress;
BG6_SellerContact?: Contact;
BG7_Buyer: Buyer;
BG8_BuyerPostalAddress: PostalAddress;
BG9_BuyerContact?: Contact;
BG10_Payee?: Payee;
BG11_SellerTaxRepresentative?: TaxRepresentative;
BG12_PayerParty?: PayerParty;
BG13_DeliveryInformation?: DeliveryInformation;
BG14_InvoicingPeriod?: Period;
BG15_DeliverToAddress?: PostalAddress;
BG16_PaymentInstructions: PaymentInstructions;
BG17_PaymentCardInformation?: PaymentCardInformation;
BG18_DirectDebit?: DirectDebit;
BG19_PaymentTerms?: PaymentTerms;
BG20_DocumentLevelAllowances?: Allowance[];
BG21_DocumentLevelCharges?: Charge[];
BG22_DocumentTotals: DocumentTotals;
BG23_VATBreakdown?: VATBreakdown[];
BG24_AdditionalSupportingDocuments?: SupportingDocument[];
BG25_InvoiceLine: InvoiceLine[];
BG26_InvoiceLinePeriod?: Period;
BG27_InvoiceLineAllowances?: Allowance[];
BG28_InvoiceLineCharges?: Charge[];
BG29_PriceDetails?: PriceDetails;
BG30_LineVATInformation: VATInformation;
BG31_ItemInformation: ItemInformation;
BG32_ItemAttributes?: ItemAttribute[];
}
/**
* Supporting types for Business Groups
*/
export interface InvoiceNote {
subjectCode?: string;
noteContent: string;
}
export interface ProcessControl {
businessProcessType?: string;
specificationIdentifier: string;
}
export interface PrecedingInvoiceReference {
referenceNumber: string;
issueDate?: Date;
}
export interface Seller {
name: string;
tradingName?: string;
identifier?: string;
legalRegistrationIdentifier?: string;
vatIdentifier?: string;
taxRegistrationIdentifier?: string;
additionalLegalInfo?: string;
electronicAddress?: string;
}
export interface Buyer {
name: string;
tradingName?: string;
identifier?: string;
legalRegistrationIdentifier?: string;
vatIdentifier?: string;
electronicAddress?: string;
}
export interface PostalAddress {
addressLine1?: string;
addressLine2?: string;
addressLine3?: string;
city?: string;
postCode?: string;
countrySubdivision?: string;
countryCode: string;
}
export interface Contact {
contactPoint?: string;
telephoneNumber?: string;
emailAddress?: string;
}
export interface Payee {
name: string;
identifier?: string;
legalRegistrationIdentifier?: string;
}
export interface TaxRepresentative {
name: string;
vatIdentifier: string;
postalAddress: PostalAddress;
}
export interface PayerParty {
name: string;
identifier?: string;
legalRegistrationIdentifier?: string;
}
export interface DeliveryInformation {
name?: string;
locationIdentifier?: string;
actualDeliveryDate?: Date;
deliveryAddress?: PostalAddress;
}
export interface Period {
startDate?: Date;
endDate?: Date;
descriptionCode?: string;
}
export interface PaymentInstructions {
paymentMeansTypeCode: string;
paymentMeansText?: string;
remittanceInformation?: string;
paymentAccountIdentifier?: string;
paymentAccountName?: string;
paymentServiceProviderIdentifier?: string;
}
export interface PaymentCardInformation {
primaryAccountNumber: string;
holderName?: string;
}
export interface DirectDebit {
mandateReferenceIdentifier?: string;
bankAssignedCreditorIdentifier?: string;
debitedAccountIdentifier?: string;
}
export interface PaymentTerms {
note?: string;
}
export interface Allowance {
amount: number;
baseAmount?: number;
percentage?: number;
vatCategoryCode?: string;
vatRate?: number;
reason?: string;
reasonCode?: string;
}
export interface Charge {
amount: number;
baseAmount?: number;
percentage?: number;
vatCategoryCode?: string;
vatRate?: number;
reason?: string;
reasonCode?: string;
}
export interface DocumentTotals {
lineExtensionAmount: number;
taxExclusiveAmount: number;
taxInclusiveAmount: number;
allowanceTotalAmount?: number;
chargeTotalAmount?: number;
prepaidAmount?: number;
roundingAmount?: number;
payableAmount: number;
}
export interface VATBreakdown {
vatCategoryTaxableAmount: number;
vatCategoryTaxAmount: number;
vatCategoryCode: string;
vatCategoryRate?: number;
vatExemptionReasonText?: string;
vatExemptionReasonCode?: string;
}
export interface SupportingDocument {
documentReference: string;
documentDescription?: string;
externalDocumentLocation?: string;
attachedDocument?: Attachment;
}
export interface Attachment {
filename?: string;
mimeType?: string;
description?: string;
embeddedDocumentBinaryObject?: string;
externalDocumentURI?: string;
}
export interface InvoiceLine {
identifier: string;
note?: string;
objectIdentifier?: string;
invoicedQuantity: number;
invoicedQuantityUnitOfMeasureCode: string;
lineExtensionAmount: number;
purchaseOrderLineReference?: string;
buyerAccountingReference?: string;
period?: Period;
allowances?: Allowance[];
charges?: Charge[];
priceDetails: PriceDetails;
vatInformation: VATInformation;
itemInformation: ItemInformation;
}
export interface PriceDetails {
itemNetPrice: number;
itemPriceDiscount?: number;
itemGrossPrice?: number;
itemPriceBaseQuantity?: number;
itemPriceBaseQuantityUnitOfMeasureCode?: string;
}
export interface VATInformation {
categoryCode: string;
rate?: number;
}
export interface ItemInformation {
name: string;
description?: string;
sellersIdentifier?: string;
buyersIdentifier?: string;
standardIdentifier?: string;
classificationIdentifier?: string;
classificationListIdentifier?: string;
originCountryCode?: string;
attributes?: ItemAttribute[];
}
export interface ItemAttribute {
name: string;
value: string;
}
/**
* Complete EN16931 Semantic Model
* Combines all Business Terms and Business Groups
*/
export interface EN16931SemanticModel {
// Core document information
documentInformation: {
invoiceNumber: string; // BT-1
issueDate: Date; // BT-2
typeCode: string; // BT-3
currencyCode: string; // BT-5
notes?: InvoiceNote[]; // BG-1
};
// Process metadata
processControl?: ProcessControl; // BG-2
// References
references?: {
buyerReference?: string; // BT-10
projectReference?: string; // BT-11
contractReference?: string; // BT-12
purchaseOrderReference?: string; // BT-13
salesOrderReference?: string; // BT-14
precedingInvoices?: PrecedingInvoiceReference[]; // BG-3
};
// Parties
seller: Seller & { // BG-4
postalAddress: PostalAddress; // BG-5
contact?: Contact; // BG-6
};
buyer: Buyer & { // BG-7
postalAddress: PostalAddress; // BG-8
contact?: Contact; // BG-9
};
payee?: Payee; // BG-10
taxRepresentative?: TaxRepresentative; // BG-11
// Delivery
delivery?: DeliveryInformation; // BG-13
invoicingPeriod?: Period; // BG-14
// Payment
paymentInstructions: PaymentInstructions; // BG-16
paymentCardInfo?: PaymentCardInformation; // BG-17
directDebit?: DirectDebit; // BG-18
paymentTerms?: PaymentTerms; // BG-19
// Allowances and charges
documentLevelAllowances?: Allowance[]; // BG-20
documentLevelCharges?: Charge[]; // BG-21
// Totals
documentTotals: DocumentTotals; // BG-22
vatBreakdown?: VATBreakdown[]; // BG-23
// Supporting documents
additionalDocuments?: SupportingDocument[]; // BG-24
// Invoice lines
invoiceLines: InvoiceLine[]; // BG-25
}
/**
* Semantic model version and metadata
*/
export const SEMANTIC_MODEL_VERSION = '1.3.0';
export const EN16931_VERSION = '1.3.14';
export const SUPPORTED_SYNTAXES = ['UBL', 'CII', 'EDIFACT'];
+634
View File
@@ -0,0 +1,634 @@
/**
* Adapter for converting between EInvoice and EN16931 Semantic Model
* Provides bidirectional conversion capabilities
*/
import { EInvoice } from '../../einvoice.js';
import type {
EN16931SemanticModel,
Seller,
Buyer,
PostalAddress,
Contact,
InvoiceLine,
VATBreakdown,
DocumentTotals,
PaymentInstructions,
Allowance,
Charge,
Period,
DeliveryInformation,
PriceDetails,
VATInformation,
ItemInformation
} from './bt-bg.model.js';
/**
* Adapter for converting between EInvoice and EN16931 Semantic Model
*/
export class SemanticModelAdapter {
/**
* Convert EInvoice to EN16931 Semantic Model
*/
public toSemanticModel(invoice: EInvoice): EN16931SemanticModel {
return {
// Core document information
documentInformation: {
invoiceNumber: invoice.accountingDocId,
issueDate: invoice.issueDate,
typeCode: this.mapInvoiceType(invoice.accountingDocType),
currencyCode: invoice.currency,
notes: invoice.notes ? this.mapNotes(invoice.notes) : undefined
},
// Process metadata
processControl: invoice.metadata?.profileId ? {
businessProcessType: invoice.metadata?.extensions?.businessProcessId,
specificationIdentifier: invoice.metadata.profileId
} : undefined,
// References
references: {
buyerReference: invoice.metadata?.buyerReference,
projectReference: invoice.metadata?.extensions?.projectReference,
contractReference: invoice.metadata?.extensions?.contractReference,
purchaseOrderReference: invoice.metadata?.extensions?.purchaseOrderReference,
salesOrderReference: invoice.metadata?.extensions?.salesOrderReference,
precedingInvoices: invoice.metadata?.extensions?.precedingInvoices
},
// Seller
seller: {
...this.mapSeller(invoice.from),
postalAddress: this.mapAddress(invoice.from),
contact: this.mapContact(invoice.from)
},
// Buyer
buyer: {
...this.mapBuyer(invoice.to),
postalAddress: this.mapAddress(invoice.to),
contact: this.mapContact(invoice.to)
},
// Payee (if different from seller)
payee: invoice.metadata?.extensions?.payee,
// Tax representative
taxRepresentative: invoice.metadata?.extensions?.taxRepresentative,
// Delivery
delivery: this.mapDelivery(invoice),
// Invoice period
invoicingPeriod: invoice.metadata?.extensions?.invoicingPeriod ? {
startDate: invoice.metadata.extensions.invoicingPeriod.startDate,
endDate: invoice.metadata.extensions.invoicingPeriod.endDate,
descriptionCode: invoice.metadata.extensions.invoicingPeriod.descriptionCode
} : undefined,
// Payment instructions
paymentInstructions: this.mapPaymentInstructions(invoice),
// Payment card info
paymentCardInfo: invoice.metadata?.extensions?.paymentCard,
// Direct debit
directDebit: invoice.metadata?.extensions?.directDebit,
// Payment terms
paymentTerms: invoice.dueInDays !== undefined ? {
note: `Payment due in ${invoice.dueInDays} days`
} : undefined,
// Document level allowances and charges
documentLevelAllowances: invoice.metadata?.extensions?.documentAllowances,
documentLevelCharges: invoice.metadata?.extensions?.documentCharges,
// Document totals
documentTotals: this.mapDocumentTotals(invoice),
// VAT breakdown
vatBreakdown: this.mapVATBreakdown(invoice),
// Additional documents
additionalDocuments: invoice.metadata?.extensions?.supportingDocuments,
// Invoice lines
invoiceLines: this.mapInvoiceLines(invoice.items || [])
};
}
/**
* Convert EN16931 Semantic Model to EInvoice
*/
public fromSemanticModel(model: EN16931SemanticModel): EInvoice {
const invoice = new EInvoice();
invoice.accountingDocId = model.documentInformation.invoiceNumber;
invoice.issueDate = model.documentInformation.issueDate;
invoice.accountingDocType = this.reverseMapInvoiceType(model.documentInformation.typeCode) as 'invoice';
invoice.currency = model.documentInformation.currencyCode as any;
invoice.from = this.reverseMapSeller(model.seller);
invoice.to = this.reverseMapBuyer(model.buyer);
invoice.items = this.reverseMapInvoiceLines(model.invoiceLines);
// Set metadata
if (model.processControl) {
invoice.metadata = {
...invoice.metadata,
profileId: model.processControl.specificationIdentifier,
extensions: {
...invoice.metadata?.extensions,
businessProcessId: model.processControl.businessProcessType
}
};
}
// Set references
if (model.references) {
invoice.metadata = {
...invoice.metadata,
buyerReference: model.references.buyerReference,
extensions: {
...invoice.metadata?.extensions,
contractReference: model.references.contractReference,
purchaseOrderReference: model.references.purchaseOrderReference,
salesOrderReference: model.references.salesOrderReference,
precedingInvoices: model.references.precedingInvoices,
projectReference: model.references.projectReference
}
};
}
// Set payment terms
if (model.paymentTerms?.note) {
const daysMatch = model.paymentTerms.note.match(/(\d+) days/);
if (daysMatch) {
invoice.dueInDays = parseInt(daysMatch[1], 10);
}
}
// Set payment options
if (
model.paymentInstructions.paymentAccountIdentifier ||
model.paymentInstructions.paymentMeansText ||
model.paymentInstructions.paymentServiceProviderIdentifier
) {
invoice.paymentOptions = {
description: model.paymentInstructions.paymentMeansText,
sepaConnection: {
iban: model.paymentInstructions.paymentAccountIdentifier || '',
bic: model.paymentInstructions.paymentServiceProviderIdentifier || '',
institution: model.paymentInstructions.paymentServiceProviderIdentifier
},
payPal: { email: '' }
};
invoice.metadata = {
...invoice.metadata,
paymentMeansCode: model.paymentInstructions.paymentMeansTypeCode,
paymentAccount: {
iban: model.paymentInstructions.paymentAccountIdentifier,
accountName: model.paymentInstructions.paymentAccountName,
bankId: model.paymentInstructions.paymentServiceProviderIdentifier
},
extensions: {
...invoice.metadata?.extensions,
paymentMeans: {
paymentMeansCode: model.paymentInstructions.paymentMeansTypeCode,
paymentMeansText: model.paymentInstructions.paymentMeansText,
remittanceInformation: model.paymentInstructions.remittanceInformation
},
paymentAccount: {
iban: model.paymentInstructions.paymentAccountIdentifier,
accountName: model.paymentInstructions.paymentAccountName,
bankId: model.paymentInstructions.paymentServiceProviderIdentifier,
bic: model.paymentInstructions.paymentServiceProviderIdentifier,
institutionName: model.paymentInstructions.paymentServiceProviderIdentifier
}
}
};
}
// Set extensions
if (model.payee || model.taxRepresentative || model.documentLevelAllowances) {
invoice.metadata = {
...invoice.metadata,
extensions: {
...invoice.metadata?.extensions,
payee: model.payee,
taxRepresentative: model.taxRepresentative,
documentAllowances: model.documentLevelAllowances,
documentCharges: model.documentLevelCharges,
supportingDocuments: model.additionalDocuments,
paymentCard: model.paymentCardInfo,
directDebit: model.directDebit,
taxDetails: model.vatBreakdown
}
};
}
return invoice;
}
/**
* Map invoice type code
*/
private mapInvoiceType(type: string): string {
const typeMap: Record<string, string> = {
'invoice': '380',
'creditNote': '381',
'debitNote': '383',
'correctedInvoice': '384',
'prepaymentInvoice': '386',
'selfBilledInvoice': '389',
'invoice_380': '380',
'credit_note_381': '381'
};
return typeMap[type] || '380';
}
/**
* Reverse map invoice type code
*/
private reverseMapInvoiceType(code: string): string {
const typeMap: Record<string, string> = {
'380': 'invoice',
'381': 'creditNote',
'383': 'debitNote',
'384': 'correctedInvoice',
'386': 'prepaymentInvoice',
'389': 'selfBilledInvoice'
};
return typeMap[code] || 'invoice';
}
/**
* Map notes
*/
private mapNotes(notes: string | string[]): Array<{ noteContent: string }> {
const notesArray = Array.isArray(notes) ? notes : [notes];
return notesArray.map(note => ({ noteContent: note }));
}
/**
* Map seller information
*/
private mapSeller(from: EInvoice['from']): Seller {
const contact = from as any;
if (contact.type === 'company') {
return {
name: contact.name || '',
tradingName: contact.tradingName,
identifier: contact.registrationDetails?.registrationId,
legalRegistrationIdentifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
taxRegistrationIdentifier: contact.taxId,
additionalLegalInfo: contact.description,
electronicAddress: contact.email || contact.contact?.email
};
} else {
return {
name: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
identifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
electronicAddress: contact.email
};
}
}
/**
* Map buyer information
*/
private mapBuyer(to: EInvoice['to']): Buyer {
const contact = to as any;
if (contact.type === 'company') {
return {
name: contact.name || '',
tradingName: contact.tradingName,
identifier: contact.registrationDetails?.registrationId,
legalRegistrationIdentifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
electronicAddress: contact.email || contact.contact?.email
};
} else {
return {
name: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
identifier: contact.registrationDetails?.registrationId,
vatIdentifier: contact.registrationDetails?.vatId || contact.vatNumber,
electronicAddress: contact.email
};
}
}
/**
* Map address
*/
private mapAddress(party: EInvoice['from'] | EInvoice['to']): PostalAddress {
const contact = party as any;
const address: PostalAddress = {
countryCode: contact.address?.country || contact.country || ''
};
if (contact.address) {
if (typeof contact.address === 'string') {
const addressParts = contact.address.split(',').map((s: string) => s.trim());
address.addressLine1 = addressParts[0];
if (addressParts.length > 1) address.addressLine2 = addressParts[1];
} else if (typeof contact.address === 'object') {
address.addressLine1 = [contact.address.streetName, contact.address.houseNumber].filter(Boolean).join(' ');
address.city = contact.address.city;
address.postCode = contact.address.postalCode;
address.countryCode = contact.address.country || address.countryCode;
}
}
// Support both nested and flat structures
if (!address.city) address.city = contact.city;
if (!address.postCode) address.postCode = contact.postalCode;
return address;
}
/**
* Map contact information
*/
private mapContact(party: EInvoice['from'] | EInvoice['to']): Contact | undefined {
const contact = party as any;
if (contact.type === 'company' && contact.contact) {
return {
contactPoint: contact.contact.name,
telephoneNumber: contact.contact.phone,
emailAddress: contact.contact.email
};
} else if (contact.type === 'person') {
return {
contactPoint: contact.name || `${contact.firstName || ''} ${contact.lastName || ''}`.trim(),
telephoneNumber: contact.phone,
emailAddress: contact.email
};
} else if (contact.email || contact.phone) {
// Fallback for any contact with email or phone
return {
contactPoint: contact.name,
telephoneNumber: contact.phone,
emailAddress: contact.email
};
}
return undefined;
}
/**
* Map delivery information
*/
private mapDelivery(invoice: EInvoice): DeliveryInformation | undefined {
const delivery = invoice.metadata?.extensions?.delivery;
if (!delivery) return undefined;
return {
name: delivery.name,
locationIdentifier: delivery.locationId,
actualDeliveryDate: delivery.actualDate,
deliveryAddress: delivery.address ? {
addressLine1: delivery.address.line1,
addressLine2: delivery.address.line2,
city: delivery.address.city,
postCode: delivery.address.postCode,
countryCode: delivery.address.countryCode
} : undefined
};
}
/**
* Map payment instructions
*/
private mapPaymentInstructions(invoice: EInvoice): PaymentInstructions {
const paymentMeans = invoice.metadata?.extensions?.paymentMeans;
const paymentAccount = invoice.metadata?.extensions?.paymentAccount || invoice.metadata?.paymentAccount;
const sepaConnection = invoice.paymentOptions?.sepaConnection;
return {
paymentMeansTypeCode: paymentMeans?.paymentMeansCode || invoice.metadata?.paymentMeansCode || '30',
paymentMeansText: paymentMeans?.paymentMeansText || invoice.paymentOptions?.description,
remittanceInformation: paymentMeans?.remittanceInformation,
paymentAccountIdentifier: paymentAccount?.iban || sepaConnection?.iban,
paymentAccountName: paymentAccount?.accountName,
paymentServiceProviderIdentifier:
paymentAccount?.bic ||
paymentAccount?.institutionName ||
paymentAccount?.bankId ||
sepaConnection?.bic ||
sepaConnection?.institution
};
}
/**
* Map document totals
*/
private mapDocumentTotals(invoice: EInvoice): DocumentTotals {
return {
lineExtensionAmount: invoice.totalNet,
taxExclusiveAmount: invoice.totalNet,
taxInclusiveAmount: invoice.totalGross,
allowanceTotalAmount: invoice.metadata?.extensions?.documentAllowances?.reduce(
(sum: number, a: { amount: number }) => sum + a.amount, 0
),
chargeTotalAmount: invoice.metadata?.extensions?.documentCharges?.reduce(
(sum: number, c: { amount: number }) => sum + c.amount, 0
),
prepaidAmount: invoice.metadata?.extensions?.prepaidAmount,
roundingAmount: invoice.metadata?.extensions?.roundingAmount,
payableAmount: invoice.totalGross
};
}
/**
* Map VAT breakdown
*/
private mapVATBreakdown(invoice: EInvoice): VATBreakdown[] | undefined {
const taxDetails = invoice.metadata?.extensions?.taxDetails;
if (!taxDetails) {
// Create default VAT breakdown from invoice totals
if (invoice.totalVat > 0) {
return [{
vatCategoryTaxableAmount: invoice.totalNet,
vatCategoryTaxAmount: invoice.totalVat,
vatCategoryCode: 'S', // Standard rate
vatCategoryRate: (invoice.totalVat / invoice.totalNet) * 100
}];
}
return undefined;
}
return taxDetails as VATBreakdown[];
}
/**
* Map invoice lines
*/
private mapInvoiceLines(items: EInvoice['items']): InvoiceLine[] {
if (!items) return [];
return items.map((item, index) => ({
identifier: (index + 1).toString(),
note: (item as any).description || (item as any).text || '',
invoicedQuantity: item.unitQuantity,
invoicedQuantityUnitOfMeasureCode: item.unitType || 'C62',
lineExtensionAmount: item.unitNetPrice * item.unitQuantity,
purchaseOrderLineReference: (item as any).purchaseOrderLineRef,
buyerAccountingReference: (item as any).buyerAccountingRef,
period: (item as any).period,
allowances: (item as any).allowances,
charges: (item as any).charges,
priceDetails: {
itemNetPrice: item.unitNetPrice,
itemPriceDiscount: (item as any).priceDiscount,
itemGrossPrice: (item as any).grossPrice,
itemPriceBaseQuantity: (item as any).priceBaseQuantity || 1
},
vatInformation: {
categoryCode: this.mapVATCategory(item.vatPercentage),
rate: item.vatPercentage
},
itemInformation: {
name: item.name,
description: (item as any).description || (item as any).text || '',
sellersIdentifier: item.articleNumber,
buyersIdentifier: (item as any).buyersItemId,
standardIdentifier: (item as any).gtin || (item as any).ean,
classificationIdentifier: (item as any).unspsc,
originCountryCode: (item as any).originCountry,
attributes: (item as any).attributes
}
}));
}
/**
* Map VAT category from percentage
*/
private mapVATCategory(percentage?: number): string {
if (percentage === undefined || percentage === null) return 'S';
if (percentage === 0) return 'Z';
if (percentage > 0) return 'S';
return 'E'; // Exempt
}
/**
* Reverse map seller
*/
private reverseMapSeller(seller: Seller & { postalAddress: PostalAddress }): EInvoice['from'] {
const isCompany = seller.legalRegistrationIdentifier || seller.tradingName;
return {
type: isCompany ? 'company' : 'person',
name: seller.name,
description: seller.additionalLegalInfo || '',
address: {
streetName: seller.postalAddress.addressLine1 || '',
houseNumber: '',
city: seller.postalAddress.city || '',
postalCode: seller.postalAddress.postCode || '',
country: seller.postalAddress.countryCode || ''
},
registrationDetails: {
vatId: seller.vatIdentifier || '',
registrationId: seller.identifier || seller.legalRegistrationIdentifier || '',
registrationName: seller.name
},
status: 'active',
foundedDate: {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate()
}
} as any;
}
/**
* Reverse map buyer
*/
private reverseMapBuyer(buyer: Buyer & { postalAddress: PostalAddress }): EInvoice['to'] {
const isCompany = buyer.legalRegistrationIdentifier || buyer.tradingName;
return {
type: isCompany ? 'company' : 'person',
name: buyer.name,
description: '',
address: {
streetName: buyer.postalAddress.addressLine1 || '',
houseNumber: '',
city: buyer.postalAddress.city || '',
postalCode: buyer.postalAddress.postCode || '',
country: buyer.postalAddress.countryCode || ''
},
registrationDetails: {
vatId: buyer.vatIdentifier || '',
registrationId: buyer.identifier || buyer.legalRegistrationIdentifier || '',
registrationName: buyer.name
},
status: 'active',
foundedDate: {
year: new Date().getFullYear(),
month: new Date().getMonth() + 1,
day: new Date().getDate()
}
} as any;
}
/**
* Reverse map invoice lines
*/
private reverseMapInvoiceLines(lines: InvoiceLine[]): EInvoice['items'] {
return lines.map((line, index) => ({
position: index + 1,
name: line.itemInformation.name,
description: line.itemInformation.description || '',
unitQuantity: line.invoicedQuantity,
unitType: line.invoicedQuantityUnitOfMeasureCode,
unitNetPrice: line.priceDetails.itemNetPrice,
vatPercentage: line.vatInformation.rate || 0,
articleNumber: line.itemInformation.sellersIdentifier || ''
}));
}
/**
* Validate semantic model completeness
*/
public validateSemanticModel(model: EN16931SemanticModel): string[] {
const errors: string[] = [];
// Check mandatory fields
if (!model.documentInformation.invoiceNumber) {
errors.push('BT-1: Invoice number is mandatory');
}
if (!model.documentInformation.issueDate) {
errors.push('BT-2: Invoice issue date is mandatory');
}
if (!model.documentInformation.typeCode) {
errors.push('BT-3: Invoice type code is mandatory');
}
if (!model.documentInformation.currencyCode) {
errors.push('BT-5: Invoice currency code is mandatory');
}
if (!model.seller?.name) {
errors.push('BT-27: Seller name is mandatory');
}
if (!model.seller?.postalAddress?.countryCode) {
errors.push('BT-40: Seller country code is mandatory');
}
if (!model.buyer?.name) {
errors.push('BT-44: Buyer name is mandatory');
}
if (!model.buyer?.postalAddress?.countryCode) {
errors.push('BT-55: Buyer country code is mandatory');
}
if (!model.documentTotals) {
errors.push('BG-22: Document totals are mandatory');
}
if (!model.invoiceLines || model.invoiceLines.length === 0) {
errors.push('BG-25: At least one invoice line is mandatory');
}
return errors;
}
}
+654
View File
@@ -0,0 +1,654 @@
/**
* Semantic Model Validator
* Validates invoices against EN16931 Business Terms and Business Groups
*/
import type { ValidationResult } from '../validation/validation.types.js';
import type { EN16931SemanticModel, BusinessTerms, BusinessGroups } from './bt-bg.model.js';
import type { EInvoice } from '../../einvoice.js';
import { SemanticModelAdapter } from './semantic.adapter.js';
/**
* Business Term validation rules
*/
interface BTValidationRule {
btId: string;
description: string;
mandatory: boolean;
validate: (model: EN16931SemanticModel) => ValidationResult | null;
}
/**
* Semantic Model Validator
* Validates against all EN16931 Business Terms (BT) and Business Groups (BG)
*/
export class SemanticModelValidator {
private adapter: SemanticModelAdapter;
private btRules: BTValidationRule[];
constructor() {
this.adapter = new SemanticModelAdapter();
this.btRules = this.initializeBusinessTermRules();
}
/**
* Validate an invoice using the semantic model
*/
public validate(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Convert to semantic model
const model = this.adapter.toSemanticModel(invoice);
// Validate all business terms
for (const rule of this.btRules) {
const result = rule.validate(model);
if (result) {
results.push(result);
}
}
// Validate business groups
results.push(...this.validateBusinessGroups(model));
// Validate cardinality constraints
results.push(...this.validateCardinality(model));
// Validate conditional rules
results.push(...this.validateConditionalRules(model));
return results;
}
/**
* Initialize Business Term validation rules
*/
private initializeBusinessTermRules(): BTValidationRule[] {
return [
// Document level mandatory fields
{
btId: 'BT-1',
description: 'Invoice number',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.invoiceNumber) {
return {
ruleId: 'BT-1',
severity: 'error',
message: 'Invoice number is mandatory',
field: 'documentInformation.invoiceNumber',
btReference: 'BT-1',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-2',
description: 'Invoice issue date',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.issueDate) {
return {
ruleId: 'BT-2',
severity: 'error',
message: 'Invoice issue date is mandatory',
field: 'documentInformation.issueDate',
btReference: 'BT-2',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-3',
description: 'Invoice type code',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.typeCode) {
return {
ruleId: 'BT-3',
severity: 'error',
message: 'Invoice type code is mandatory',
field: 'documentInformation.typeCode',
btReference: 'BT-3',
source: 'SEMANTIC'
};
}
const validCodes = ['380', '381', '383', '384', '386', '389'];
if (!validCodes.includes(model.documentInformation.typeCode)) {
return {
ruleId: 'BT-3',
severity: 'error',
message: `Invalid invoice type code. Must be one of: ${validCodes.join(', ')}`,
field: 'documentInformation.typeCode',
value: model.documentInformation.typeCode,
btReference: 'BT-3',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-5',
description: 'Invoice currency code',
mandatory: true,
validate: (model) => {
if (!model.documentInformation.currencyCode) {
return {
ruleId: 'BT-5',
severity: 'error',
message: 'Invoice currency code is mandatory',
field: 'documentInformation.currencyCode',
btReference: 'BT-5',
source: 'SEMANTIC'
};
}
// Validate ISO 4217 currency code
if (!/^[A-Z]{3}$/.test(model.documentInformation.currencyCode)) {
return {
ruleId: 'BT-5',
severity: 'error',
message: 'Currency code must be a valid ISO 4217 code',
field: 'documentInformation.currencyCode',
value: model.documentInformation.currencyCode,
btReference: 'BT-5',
source: 'SEMANTIC'
};
}
return null;
}
},
// Seller mandatory fields
{
btId: 'BT-27',
description: 'Seller name',
mandatory: true,
validate: (model) => {
if (!model.seller?.name) {
return {
ruleId: 'BT-27',
severity: 'error',
message: 'Seller name is mandatory',
field: 'seller.name',
btReference: 'BT-27',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-40',
description: 'Seller country code',
mandatory: true,
validate: (model) => {
if (!model.seller?.postalAddress?.countryCode) {
return {
ruleId: 'BT-40',
severity: 'error',
message: 'Seller country code is mandatory',
field: 'seller.postalAddress.countryCode',
btReference: 'BT-40',
source: 'SEMANTIC'
};
}
// Validate ISO 3166-1 alpha-2 country code
if (!/^[A-Z]{2}$/.test(model.seller.postalAddress.countryCode)) {
return {
ruleId: 'BT-40',
severity: 'error',
message: 'Country code must be a valid ISO 3166-1 alpha-2 code',
field: 'seller.postalAddress.countryCode',
value: model.seller.postalAddress.countryCode,
btReference: 'BT-40',
source: 'SEMANTIC'
};
}
return null;
}
},
// Buyer mandatory fields
{
btId: 'BT-44',
description: 'Buyer name',
mandatory: true,
validate: (model) => {
if (!model.buyer?.name) {
return {
ruleId: 'BT-44',
severity: 'error',
message: 'Buyer name is mandatory',
field: 'buyer.name',
btReference: 'BT-44',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-55',
description: 'Buyer country code',
mandatory: true,
validate: (model) => {
if (!model.buyer?.postalAddress?.countryCode) {
return {
ruleId: 'BT-55',
severity: 'error',
message: 'Buyer country code is mandatory',
field: 'buyer.postalAddress.countryCode',
btReference: 'BT-55',
source: 'SEMANTIC'
};
}
// Validate ISO 3166-1 alpha-2 country code
if (!/^[A-Z]{2}$/.test(model.buyer.postalAddress.countryCode)) {
return {
ruleId: 'BT-55',
severity: 'error',
message: 'Country code must be a valid ISO 3166-1 alpha-2 code',
field: 'buyer.postalAddress.countryCode',
value: model.buyer.postalAddress.countryCode,
btReference: 'BT-55',
source: 'SEMANTIC'
};
}
return null;
}
},
// Payment means
{
btId: 'BT-81',
description: 'Payment means type code',
mandatory: true,
validate: (model) => {
if (!model.paymentInstructions?.paymentMeansTypeCode) {
return {
ruleId: 'BT-81',
severity: 'error',
message: 'Payment means type code is mandatory',
field: 'paymentInstructions.paymentMeansTypeCode',
btReference: 'BT-81',
source: 'SEMANTIC'
};
}
return null;
}
},
// Document totals
{
btId: 'BT-106',
description: 'Sum of invoice line net amount',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.lineExtensionAmount === undefined) {
return {
ruleId: 'BT-106',
severity: 'error',
message: 'Sum of invoice line net amount is mandatory',
field: 'documentTotals.lineExtensionAmount',
btReference: 'BT-106',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-109',
description: 'Invoice total amount without VAT',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.taxExclusiveAmount === undefined) {
return {
ruleId: 'BT-109',
severity: 'error',
message: 'Invoice total amount without VAT is mandatory',
field: 'documentTotals.taxExclusiveAmount',
btReference: 'BT-109',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-112',
description: 'Invoice total amount with VAT',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.taxInclusiveAmount === undefined) {
return {
ruleId: 'BT-112',
severity: 'error',
message: 'Invoice total amount with VAT is mandatory',
field: 'documentTotals.taxInclusiveAmount',
btReference: 'BT-112',
source: 'SEMANTIC'
};
}
return null;
}
},
{
btId: 'BT-115',
description: 'Amount due for payment',
mandatory: true,
validate: (model) => {
if (model.documentTotals?.payableAmount === undefined) {
return {
ruleId: 'BT-115',
severity: 'error',
message: 'Amount due for payment is mandatory',
field: 'documentTotals.payableAmount',
btReference: 'BT-115',
source: 'SEMANTIC'
};
}
return null;
}
}
];
}
/**
* Validate Business Groups
*/
private validateBusinessGroups(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// BG-4: Seller
if (!model.seller) {
results.push({
ruleId: 'BG-4',
severity: 'error',
message: 'Seller information is mandatory',
field: 'seller',
bgReference: 'BG-4',
source: 'SEMANTIC'
});
}
// BG-5: Seller postal address
if (!model.seller?.postalAddress) {
results.push({
ruleId: 'BG-5',
severity: 'error',
message: 'Seller postal address is mandatory',
field: 'seller.postalAddress',
bgReference: 'BG-5',
source: 'SEMANTIC'
});
}
// BG-7: Buyer
if (!model.buyer) {
results.push({
ruleId: 'BG-7',
severity: 'error',
message: 'Buyer information is mandatory',
field: 'buyer',
bgReference: 'BG-7',
source: 'SEMANTIC'
});
}
// BG-8: Buyer postal address
if (!model.buyer?.postalAddress) {
results.push({
ruleId: 'BG-8',
severity: 'error',
message: 'Buyer postal address is mandatory',
field: 'buyer.postalAddress',
bgReference: 'BG-8',
source: 'SEMANTIC'
});
}
// BG-16: Payment instructions
if (!model.paymentInstructions) {
results.push({
ruleId: 'BG-16',
severity: 'error',
message: 'Payment instructions are mandatory',
field: 'paymentInstructions',
bgReference: 'BG-16',
source: 'SEMANTIC'
});
}
// BG-22: Document totals
if (!model.documentTotals) {
results.push({
ruleId: 'BG-22',
severity: 'error',
message: 'Document totals are mandatory',
field: 'documentTotals',
bgReference: 'BG-22',
source: 'SEMANTIC'
});
}
// BG-25: Invoice lines
if (!model.invoiceLines || model.invoiceLines.length === 0) {
results.push({
ruleId: 'BG-25',
severity: 'error',
message: 'At least one invoice line is mandatory',
field: 'invoiceLines',
bgReference: 'BG-25',
source: 'SEMANTIC'
});
}
// Validate each invoice line
model.invoiceLines?.forEach((line, index) => {
// BT-126: Line identifier
if (!line.identifier) {
results.push({
ruleId: 'BT-126',
severity: 'error',
message: `Invoice line ${index + 1}: Identifier is mandatory`,
field: `invoiceLines[${index}].identifier`,
btReference: 'BT-126',
source: 'SEMANTIC'
});
}
// BT-129: Invoiced quantity
if (line.invoicedQuantity === undefined) {
results.push({
ruleId: 'BT-129',
severity: 'error',
message: `Invoice line ${index + 1}: Invoiced quantity is mandatory`,
field: `invoiceLines[${index}].invoicedQuantity`,
btReference: 'BT-129',
source: 'SEMANTIC'
});
}
// BT-131: Line net amount
if (line.lineExtensionAmount === undefined) {
results.push({
ruleId: 'BT-131',
severity: 'error',
message: `Invoice line ${index + 1}: Line net amount is mandatory`,
field: `invoiceLines[${index}].lineExtensionAmount`,
btReference: 'BT-131',
source: 'SEMANTIC'
});
}
// BT-153: Item name
if (!line.itemInformation?.name) {
results.push({
ruleId: 'BT-153',
severity: 'error',
message: `Invoice line ${index + 1}: Item name is mandatory`,
field: `invoiceLines[${index}].itemInformation.name`,
btReference: 'BT-153',
source: 'SEMANTIC'
});
}
});
return results;
}
/**
* Validate cardinality constraints
*/
private validateCardinality(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// Check for duplicate invoice lines
const lineIds = model.invoiceLines?.map(l => l.identifier) || [];
const uniqueIds = new Set(lineIds);
if (lineIds.length !== uniqueIds.size) {
results.push({
ruleId: 'CARD-01',
severity: 'error',
message: 'Invoice line identifiers must be unique',
field: 'invoiceLines',
source: 'SEMANTIC'
});
}
// Check VAT breakdown cardinality
if (model.vatBreakdown) {
const vatCategories = model.vatBreakdown.map(v => v.vatCategoryCode);
const uniqueCategories = new Set(vatCategories);
if (vatCategories.length !== uniqueCategories.size) {
results.push({
ruleId: 'CARD-02',
severity: 'error',
message: 'Each VAT category code must appear only once in VAT breakdown',
field: 'vatBreakdown',
source: 'SEMANTIC'
});
}
}
return results;
}
/**
* Validate conditional rules
*/
private validateConditionalRules(model: EN16931SemanticModel): ValidationResult[] {
const results: ValidationResult[] = [];
// If VAT accounting currency code is present, VAT amount in accounting currency must be present
if (model.documentInformation.currencyCode !== model.documentInformation.currencyCode) {
if (!model.documentTotals?.taxInclusiveAmount) {
results.push({
ruleId: 'COND-01',
severity: 'error',
message: 'When VAT accounting currency differs from invoice currency, VAT amount in accounting currency is mandatory',
field: 'documentTotals.taxInclusiveAmount',
source: 'SEMANTIC'
});
}
}
// If credit note, there should be a preceding invoice reference
if (model.documentInformation.typeCode === '381') {
if (!model.references?.precedingInvoices || model.references.precedingInvoices.length === 0) {
results.push({
ruleId: 'COND-02',
severity: 'warning',
message: 'Credit notes should reference the original invoice',
field: 'references.precedingInvoices',
source: 'SEMANTIC'
});
}
}
// If tax representative is present, certain fields are mandatory
if (model.taxRepresentative) {
if (!model.taxRepresentative.vatIdentifier) {
results.push({
ruleId: 'COND-03',
severity: 'error',
message: 'Tax representative VAT identifier is mandatory when tax representative is present',
field: 'taxRepresentative.vatIdentifier',
source: 'SEMANTIC'
});
}
}
// VAT exemption requires exemption reason
if (model.vatBreakdown) {
for (const vat of model.vatBreakdown) {
if (vat.vatCategoryCode === 'E' && !vat.vatExemptionReasonText && !vat.vatExemptionReasonCode) {
results.push({
ruleId: 'COND-04',
severity: 'error',
message: 'VAT exemption requires exemption reason text or code',
field: 'vatBreakdown.vatExemptionReasonText',
source: 'SEMANTIC'
});
}
}
}
return results;
}
/**
* Get semantic model from invoice
*/
public getSemanticModel(invoice: EInvoice): EN16931SemanticModel {
return this.adapter.toSemanticModel(invoice);
}
/**
* Create invoice from semantic model
*/
public createInvoice(model: EN16931SemanticModel): EInvoice {
return this.adapter.fromSemanticModel(model);
}
/**
* Get BT/BG mapping for an invoice
*/
public getBusinessTermMapping(invoice: EInvoice): Map<string, any> {
const model = this.adapter.toSemanticModel(invoice);
const mapping = new Map<string, any>();
// Map all business terms
mapping.set('BT-1', model.documentInformation.invoiceNumber);
mapping.set('BT-2', model.documentInformation.issueDate);
mapping.set('BT-3', model.documentInformation.typeCode);
mapping.set('BT-5', model.documentInformation.currencyCode);
mapping.set('BT-10', model.references?.buyerReference);
mapping.set('BT-27', model.seller?.name);
mapping.set('BT-40', model.seller?.postalAddress?.countryCode);
mapping.set('BT-44', model.buyer?.name);
mapping.set('BT-55', model.buyer?.postalAddress?.countryCode);
mapping.set('BT-81', model.paymentInstructions?.paymentMeansTypeCode);
mapping.set('BT-106', model.documentTotals?.lineExtensionAmount);
mapping.set('BT-109', model.documentTotals?.taxExclusiveAmount);
mapping.set('BT-112', model.documentTotals?.taxInclusiveAmount);
mapping.set('BT-115', model.documentTotals?.payableAmount);
// Map business groups
mapping.set('BG-4', model.seller);
mapping.set('BG-5', model.seller?.postalAddress);
mapping.set('BG-7', model.buyer);
mapping.set('BG-8', model.buyer?.postalAddress);
mapping.set('BG-16', model.paymentInstructions);
mapping.set('BG-22', model.documentTotals);
mapping.set('BG-25', model.invoiceLines);
return mapping;
}
}
+5 -3
View File
@@ -90,7 +90,8 @@ export class EN16931UBLValidator extends UBLBaseValidator {
}
// BR-08: An Invoice shall contain the Seller postal address (BG-5).
const sellerAddress = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc)[0];
const sellerAddressResult = this.select('//cac:AccountingSupplierParty//cac:PostalAddress', this.doc);
const sellerAddress = Array.isArray(sellerAddressResult) ? sellerAddressResult[0] : null;
if (!sellerAddress || !this.exists('.//cbc:IdentificationCode', sellerAddress)) {
this.addError('BR-08', 'An Invoice shall contain the Seller postal address', '//cac:AccountingSupplierParty//cac:PostalAddress');
valid = false;
@@ -103,7 +104,8 @@ export class EN16931UBLValidator extends UBLBaseValidator {
}
// BR-10: An Invoice shall contain the Buyer postal address (BG-8).
const buyerAddress = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc)[0];
const buyerAddressResult = this.select('//cac:AccountingCustomerParty//cac:PostalAddress', this.doc);
const buyerAddress = Array.isArray(buyerAddressResult) ? buyerAddressResult[0] : null;
if (!buyerAddress || !this.exists('.//cbc:IdentificationCode', buyerAddress)) {
this.addError('BR-10', 'An Invoice shall contain the Buyer postal address', '//cac:AccountingCustomerParty//cac:PostalAddress');
valid = false;
@@ -213,4 +215,4 @@ export class EN16931UBLValidator extends UBLBaseValidator {
return valid;
}
}
}
+12 -7
View File
@@ -263,6 +263,11 @@ export class UBLEncoder extends UBLBaseEncoder {
* @param invoice Invoice data
*/
private addPaymentMeans(doc: Document, parentElement: Element, invoice: TInvoice): void {
const paymentOptions = invoice.paymentOptions;
if (!paymentOptions) {
return;
}
const paymentMeansNode = doc.createElement('cac:PaymentMeans');
parentElement.appendChild(paymentMeansNode);
@@ -276,26 +281,26 @@ export class UBLEncoder extends UBLBaseEncoder {
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentDueDate', this.formatDate(dueDate.getTime()));
// Add payment channel code if available
if (invoice.paymentOptions.description) {
this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', invoice.paymentOptions.description);
if (paymentOptions.description) {
this.appendElement(doc, paymentMeansNode, 'cbc:InstructionNote', paymentOptions.description);
}
// Add payment ID information if available - use invoice ID as payment reference
this.appendElement(doc, paymentMeansNode, 'cbc:PaymentID', invoice.id);
// Add bank account information if available
if (invoice.paymentOptions.sepaConnection && invoice.paymentOptions.sepaConnection.iban) {
if (paymentOptions.sepaConnection && paymentOptions.sepaConnection.iban) {
const payeeFinancialAccountNode = doc.createElement('cac:PayeeFinancialAccount');
paymentMeansNode.appendChild(payeeFinancialAccountNode);
this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.iban);
this.appendElement(doc, payeeFinancialAccountNode, 'cbc:ID', paymentOptions.sepaConnection.iban);
// Add financial institution information if BIC is available
if (invoice.paymentOptions.sepaConnection.bic) {
if (paymentOptions.sepaConnection.bic) {
const financialInstitutionNode = doc.createElement('cac:FinancialInstitutionBranch');
payeeFinancialAccountNode.appendChild(financialInstitutionNode);
this.appendElement(doc, financialInstitutionNode, 'cbc:ID', invoice.paymentOptions.sepaConnection.bic);
this.appendElement(doc, financialInstitutionNode, 'cbc:ID', paymentOptions.sepaConnection.bic);
}
}
}
@@ -1038,4 +1043,4 @@ export class UBLEncoder extends UBLBaseEncoder {
}
}
}
}
}
+18 -9
View File
@@ -3,6 +3,13 @@ import type { TInvoice, TCreditNote, TDebitNote } from '../../interfaces/common.
import { UBLDocumentType, UBL_NAMESPACES } from './ubl.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ublParser = new DOMParser();
const ublNamespaces = {
cbc: UBL_NAMESPACES.CBC,
cac: UBL_NAMESPACES.CAC,
};
const ublSelect = xpath.useNamespaces(ublNamespaces);
/**
* Base decoder for UBL-based invoice formats
*/
@@ -15,16 +22,17 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
super(xml, skipValidation);
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
this.doc = ublParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
cbc: UBL_NAMESPACES.CBC,
cac: UBL_NAMESPACES.CAC
};
// Set up namespaces for XPath queries
this.namespaces = ublNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
// Create XPath selector with namespaces
this.select = ublSelect;
}
public override getParsedDocument(): Document {
return this.doc;
}
/**
@@ -75,7 +83,8 @@ export abstract class UBLBaseDecoder extends BaseDecoder {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
+17 -12
View File
@@ -4,29 +4,33 @@ import type { ValidationResult } from '../../interfaces/common.js';
import { UBLDocumentType } from './ubl.types.js';
import { DOMParser, xpath } from '../../plugins.js';
const ublValidatorParser = new DOMParser();
const ublValidatorNamespaces = {
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
};
const ublValidatorSelect = xpath.useNamespaces(ublValidatorNamespaces);
/**
* Base validator for UBL-based invoice formats
*/
export abstract class UBLBaseValidator extends BaseValidator {
protected doc: Document;
protected namespaces: Record<string, string>;
protected select: xpath.XPathSelect;
protected doc!: Document;
protected namespaces!: Record<string, string>;
protected select!: xpath.XPathSelect;
constructor(xml: string) {
constructor(xml: string, doc?: Document) {
super(xml);
try {
// Parse XML document
this.doc = new DOMParser().parseFromString(xml, 'application/xml');
// Reuse an existing parsed document when available.
this.doc = doc ?? ublValidatorParser.parseFromString(xml, 'application/xml');
// Set up namespaces for XPath queries
this.namespaces = {
cbc: 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
cac: 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2'
};
this.namespaces = ublValidatorNamespaces;
// Create XPath selector with namespaces
this.select = xpath.useNamespaces(this.namespaces);
this.select = ublValidatorSelect;
} catch (error) {
this.addError('UBL-PARSE', `Failed to parse XML: ${error}`, '/');
}
@@ -101,7 +105,8 @@ export abstract class UBLBaseValidator extends BaseValidator {
* @returns Text value or empty string if not found
*/
protected getText(xpathExpr: string, context?: Node): string {
const node = this.select(xpathExpr, context || this.doc)[0];
const result = this.select(xpathExpr, context || this.doc);
const node = Array.isArray(result) ? result[0] : null;
return node ? (node.textContent || '') : '';
}
+41 -28
View File
@@ -194,7 +194,36 @@ export class XRechnungDecoder extends UBLBaseDecoder {
const seller = this.extractParty('//cac:AccountingSupplierParty/cac:Party');
const buyer = this.extractParty('//cac:AccountingCustomerParty/cac:Party');
// Create the common invoice data with metadata for business references
const businessReferences = {
buyerReference,
orderReference,
contractReference,
projectReference
};
const paymentInformation = {
paymentMeansCode,
paymentID,
paymentDueDate,
iban,
bic,
bankName,
accountName,
paymentTermsNote,
discountPercent
};
const dateInformation = {
periodStart,
periodEnd,
deliveryDate
};
const hasBusinessReferences = Object.values(businessReferences).some(value => Boolean(value));
const hasPaymentInformation = Object.values(paymentInformation).some(value => Boolean(value));
const hasDateInformation = Object.values(dateInformation).some(value => Boolean(value));
// Create the common invoice data with metadata only when the source XML actually contains it.
const invoiceData: any = {
type: 'accounting-doc' as const,
accountingDocType: 'invoice' as const,
@@ -216,36 +245,20 @@ export class XRechnungDecoder extends UBLBaseDecoder {
reverseCharge: false,
currency: currencyCode as finance.TCurrency,
notes: notes,
objectActions: [],
metadata: {
objectActions: []
};
if (hasBusinessReferences || hasPaymentInformation || hasDateInformation) {
invoiceData.metadata = {
format: 'xrechnung' as any,
version: '1.0.0',
extensions: {
businessReferences: {
buyerReference,
orderReference,
contractReference,
projectReference
},
paymentInformation: {
paymentMeansCode,
paymentID,
paymentDueDate,
iban,
bic,
bankName,
accountName,
paymentTermsNote,
discountPercent
},
dateInformation: {
periodStart,
periodEnd,
deliveryDate
}
...(hasBusinessReferences ? { businessReferences } : {}),
...(hasPaymentInformation ? { paymentInformation } : {}),
...(hasDateInformation ? { dateInformation } : {}),
}
}
};
};
}
// Validate mandatory EN16931 fields unless validation is skipped
if (!this.skipValidation) {
@@ -255,7 +268,7 @@ export class XRechnungDecoder extends UBLBaseDecoder {
return invoiceData;
} catch (error) {
// Re-throw validation errors
if (error.message && error.message.includes('EN16931 validation failed')) {
if (error instanceof Error && error.message.includes('EN16931 validation failed')) {
throw error;
}
@@ -0,0 +1,323 @@
/**
* Currency Calculator using Decimal Arithmetic
* EN16931-compliant monetary calculations with exact precision
*/
import { Decimal, decimal, RoundingMode } from './decimal.js';
import type { TCurrency } from '@tsclass/tsclass/dist_ts/finance/index.js';
import { getCurrencyMinorUnits } from './currency.utils.js';
/**
* Currency-aware calculator using decimal arithmetic for EN16931 compliance
*/
export class DecimalCurrencyCalculator {
private readonly currency: TCurrency;
private readonly minorUnits: number;
private readonly roundingMode: RoundingMode;
constructor(
currency: TCurrency,
roundingMode: RoundingMode = 'HALF_UP'
) {
this.currency = currency;
this.minorUnits = getCurrencyMinorUnits(currency);
this.roundingMode = roundingMode;
}
/**
* Round a decimal value according to currency rules
*/
round(value: Decimal | number | string): Decimal {
const decimalValue = value instanceof Decimal ? value : new Decimal(value);
return decimalValue.round(this.minorUnits, this.roundingMode);
}
/**
* Calculate line net amount: (quantity × unitPrice) - discount
*/
calculateLineNet(
quantity: Decimal | number | string,
unitPrice: Decimal | number | string,
discount: Decimal | number | string = '0'
): Decimal {
const qty = quantity instanceof Decimal ? quantity : new Decimal(quantity);
const price = unitPrice instanceof Decimal ? unitPrice : new Decimal(unitPrice);
const disc = discount instanceof Decimal ? discount : new Decimal(discount);
const gross = qty.multiply(price);
const net = gross.subtract(disc);
return this.round(net);
}
/**
* Calculate VAT amount from base and rate
*/
calculateVAT(
baseAmount: Decimal | number | string,
vatRate: Decimal | number | string
): Decimal {
const base = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount);
const rate = vatRate instanceof Decimal ? vatRate : new Decimal(vatRate);
const vat = base.percentage(rate);
return this.round(vat);
}
/**
* Calculate total with VAT
*/
calculateGrossAmount(
netAmount: Decimal | number | string,
vatAmount: Decimal | number | string
): Decimal {
const net = netAmount instanceof Decimal ? netAmount : new Decimal(netAmount);
const vat = vatAmount instanceof Decimal ? vatAmount : new Decimal(vatAmount);
return this.round(net.add(vat));
}
/**
* Calculate sum of line items
*/
sumLineItems(items: Array<{
quantity: Decimal | number | string;
unitPrice: Decimal | number | string;
discount?: Decimal | number | string;
}>): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const lineNet = this.calculateLineNet(
item.quantity,
item.unitPrice,
item.discount
);
total = total.add(lineNet);
}
return this.round(total);
}
/**
* Calculate VAT breakdown by rate
*/
calculateVATBreakdown(items: Array<{
netAmount: Decimal | number | string;
vatRate: Decimal | number | string;
}>): Array<{
rate: Decimal;
baseAmount: Decimal;
vatAmount: Decimal;
}> {
// Group by VAT rate
const groups = new Map<string, {
rate: Decimal;
baseAmount: Decimal;
}>();
for (const item of items) {
const net = item.netAmount instanceof Decimal ? item.netAmount : new Decimal(item.netAmount);
const rate = item.vatRate instanceof Decimal ? item.vatRate : new Decimal(item.vatRate);
const rateKey = rate.toString();
if (groups.has(rateKey)) {
const group = groups.get(rateKey)!;
group.baseAmount = group.baseAmount.add(net);
} else {
groups.set(rateKey, {
rate,
baseAmount: net
});
}
}
// Calculate VAT for each group
const breakdown: Array<{
rate: Decimal;
baseAmount: Decimal;
vatAmount: Decimal;
}> = [];
for (const group of groups.values()) {
breakdown.push({
rate: group.rate,
baseAmount: this.round(group.baseAmount),
vatAmount: this.calculateVAT(group.baseAmount, group.rate)
});
}
return breakdown;
}
/**
* Check if two amounts are equal within currency precision
*/
areEqual(
amount1: Decimal | number | string,
amount2: Decimal | number | string
): boolean {
const a1 = amount1 instanceof Decimal ? amount1 : new Decimal(amount1);
const a2 = amount2 instanceof Decimal ? amount2 : new Decimal(amount2);
// Round both to currency precision before comparing
const rounded1 = this.round(a1);
const rounded2 = this.round(a2);
return rounded1.equals(rounded2);
}
/**
* Calculate payment terms discount
*/
calculatePaymentDiscount(
amount: Decimal | number | string,
discountRate: Decimal | number | string
): Decimal {
const amt = amount instanceof Decimal ? amount : new Decimal(amount);
const rate = discountRate instanceof Decimal ? discountRate : new Decimal(discountRate);
const discount = amt.percentage(rate);
return this.round(discount);
}
/**
* Distribute a total amount across items proportionally
*/
distributeAmount(
totalToDistribute: Decimal | number | string,
items: Array<{ value: Decimal | number | string }>
): Decimal[] {
const total = totalToDistribute instanceof Decimal ? totalToDistribute : new Decimal(totalToDistribute);
// Calculate sum of all item values
const itemSum = items.reduce((sum, item) => {
const value = item.value instanceof Decimal ? item.value : new Decimal(item.value);
return sum.add(value);
}, Decimal.ZERO);
if (itemSum.isZero()) {
// Can't distribute if sum is zero
return items.map(() => Decimal.ZERO);
}
const distributed: Decimal[] = [];
let distributedSum = Decimal.ZERO;
// Distribute proportionally
for (let i = 0; i < items.length; i++) {
const itemValue = items[i].value instanceof Decimal ? items[i].value : new Decimal(items[i].value);
if (i === items.length - 1) {
// Last item gets the remainder to avoid rounding errors
distributed.push(total.subtract(distributedSum));
} else {
const itemDecimal = itemValue instanceof Decimal ? itemValue : new Decimal(itemValue);
const proportion = itemDecimal.divide(itemSum);
const distributedAmount = this.round(total.multiply(proportion));
distributed.push(distributedAmount);
distributedSum = distributedSum.add(distributedAmount);
}
}
return distributed;
}
/**
* Calculate compound amount (e.g., for multiple charges/allowances)
*/
calculateCompoundAmount(
baseAmount: Decimal | number | string,
adjustments: Array<{
type: 'charge' | 'allowance';
value: Decimal | number | string;
isPercentage?: boolean;
}>
): Decimal {
let result = baseAmount instanceof Decimal ? baseAmount : new Decimal(baseAmount);
for (const adjustment of adjustments) {
const value = adjustment.value instanceof Decimal ? adjustment.value : new Decimal(adjustment.value);
let adjustmentAmount: Decimal;
if (adjustment.isPercentage) {
adjustmentAmount = result.percentage(value);
} else {
adjustmentAmount = value;
}
if (adjustment.type === 'charge') {
result = result.add(adjustmentAmount);
} else {
result = result.subtract(adjustmentAmount);
}
}
return this.round(result);
}
/**
* Validate monetary calculation according to EN16931 rules
*/
validateCalculation(
expected: Decimal | number | string,
calculated: Decimal | number | string,
ruleName: string
): {
valid: boolean;
expected: string;
calculated: string;
difference?: string;
rule: string;
} {
const exp = expected instanceof Decimal ? expected : new Decimal(expected);
const calc = calculated instanceof Decimal ? calculated : new Decimal(calculated);
const roundedExp = this.round(exp);
const roundedCalc = this.round(calc);
const valid = roundedExp.equals(roundedCalc);
return {
valid,
expected: roundedExp.toFixed(this.minorUnits),
calculated: roundedCalc.toFixed(this.minorUnits),
difference: valid ? undefined : roundedExp.subtract(roundedCalc).abs().toFixed(this.minorUnits),
rule: ruleName
};
}
/**
* Format amount for display
*/
formatAmount(amount: Decimal | number | string): string {
const amt = amount instanceof Decimal ? amount : new Decimal(amount);
const rounded = this.round(amt);
return `${rounded.toFixed(this.minorUnits)} ${this.currency}`;
}
/**
* Get currency information
*/
getCurrencyInfo(): {
code: TCurrency;
minorUnits: number;
roundingMode: RoundingMode;
} {
return {
code: this.currency,
minorUnits: this.minorUnits,
roundingMode: this.roundingMode
};
}
}
/**
* Factory function to create a decimal currency calculator
*/
export function createDecimalCalculator(
currency: TCurrency,
roundingMode?: RoundingMode
): DecimalCurrencyCalculator {
return new DecimalCurrencyCalculator(currency, roundingMode);
}
+299
View File
@@ -0,0 +1,299 @@
/**
* ISO 4217 Currency utilities for EN16931 compliance
* Provides currency-aware rounding and decimal handling
*/
/**
* ISO 4217 Currency minor units (decimal places)
* Based on ISO 4217:2015 standard
*
* Most currencies use 2 decimal places, but there are exceptions:
* - 0 decimals: JPY, KRW, CLP, etc.
* - 3 decimals: BHD, IQD, JOD, KWD, OMR, TND
* - 4 decimals: CLF (Chilean Unit of Account)
*/
export const ISO4217MinorUnits: Record<string, number> = {
// Major currencies
'EUR': 2, // Euro
'USD': 2, // US Dollar
'GBP': 2, // British Pound
'CHF': 2, // Swiss Franc
'CAD': 2, // Canadian Dollar
'AUD': 2, // Australian Dollar
'NZD': 2, // New Zealand Dollar
'CNY': 2, // Chinese Yuan
'INR': 2, // Indian Rupee
'MXN': 2, // Mexican Peso
'BRL': 2, // Brazilian Real
'RUB': 2, // Russian Ruble
'ZAR': 2, // South African Rand
'SGD': 2, // Singapore Dollar
'HKD': 2, // Hong Kong Dollar
'NOK': 2, // Norwegian Krone
'SEK': 2, // Swedish Krona
'DKK': 2, // Danish Krone
'PLN': 2, // Polish Zloty
'CZK': 2, // Czech Koruna
'HUF': 2, // Hungarian Forint (technically 2, though often shown as 0)
'RON': 2, // Romanian Leu
'BGN': 2, // Bulgarian Lev
'HRK': 2, // Croatian Kuna
'TRY': 2, // Turkish Lira
'ISK': 0, // Icelandic Króna (0 decimals)
// Zero decimal currencies
'JPY': 0, // Japanese Yen
'KRW': 0, // South Korean Won
'CLP': 0, // Chilean Peso
'PYG': 0, // Paraguayan Guaraní
'RWF': 0, // Rwandan Franc
'VND': 0, // Vietnamese Dong
'XAF': 0, // CFA Franc BEAC
'XOF': 0, // CFA Franc BCEAO
'XPF': 0, // CFP Franc
'BIF': 0, // Burundian Franc
'DJF': 0, // Djiboutian Franc
'GNF': 0, // Guinean Franc
'KMF': 0, // Comorian Franc
'MGA': 0, // Malagasy Ariary
'UGX': 0, // Ugandan Shilling
'VUV': 0, // Vanuatu Vatu
// Three decimal currencies
'BHD': 3, // Bahraini Dinar
'IQD': 3, // Iraqi Dinar
'JOD': 3, // Jordanian Dinar
'KWD': 3, // Kuwaiti Dinar
'LYD': 3, // Libyan Dinar
'OMR': 3, // Omani Rial
'TND': 3, // Tunisian Dinar
// Four decimal currencies
'CLF': 4, // Chilean Unit of Account (UF)
'UYW': 4, // Unidad Previsional (Uruguay)
};
/**
* Rounding modes for currency calculations
*/
export enum RoundingMode {
HALF_UP = 'HALF_UP', // Round half values up (0.5 → 1, -0.5 → -1)
HALF_DOWN = 'HALF_DOWN', // Round half values down (0.5 → 0, -0.5 → 0)
HALF_EVEN = 'HALF_EVEN', // Banker's rounding (0.5 → 0, 1.5 → 2)
UP = 'UP', // Always round up
DOWN = 'DOWN', // Always round down (truncate)
CEILING = 'CEILING', // Round toward positive infinity
FLOOR = 'FLOOR' // Round toward negative infinity
}
/**
* Currency configuration for calculations
*/
export interface CurrencyConfig {
code: string;
minorUnits: number;
roundingMode: RoundingMode;
tolerance?: number; // Override default tolerance if needed
}
/**
* Get minor units (decimal places) for a currency
*/
export function getCurrencyMinorUnits(currencyCode: string): number {
const code = currencyCode.toUpperCase();
return ISO4217MinorUnits[code] ?? 2; // Default to 2 if unknown
}
/**
* Round a value according to currency rules
*/
export function roundToCurrency(
value: number,
currencyCode: string,
mode: RoundingMode = RoundingMode.HALF_UP
): number {
const minorUnits = getCurrencyMinorUnits(currencyCode);
if (minorUnits === 0) {
// For zero decimal currencies, round to integer
return Math.round(value);
}
const multiplier = Math.pow(10, minorUnits);
const scaled = value * multiplier;
let rounded: number;
switch (mode) {
case RoundingMode.HALF_UP:
// Round half values away from zero
if (scaled >= 0) {
rounded = Math.floor(scaled + 0.5);
} else {
rounded = Math.ceil(scaled - 0.5);
}
break;
case RoundingMode.HALF_DOWN:
// Round half values toward zero
const fraction = Math.abs(scaled % 1);
if (fraction === 0.5) {
// Exactly 0.5 - round toward zero
rounded = scaled >= 0 ? Math.floor(scaled) : Math.ceil(scaled);
} else {
// Not exactly 0.5 - use normal rounding
rounded = Math.round(scaled);
}
break;
case RoundingMode.HALF_EVEN:
// Banker's rounding
const isHalf = Math.abs(scaled % 1) === 0.5;
if (isHalf) {
const floor = Math.floor(scaled);
rounded = floor % 2 === 0 ? floor : Math.ceil(scaled);
} else {
rounded = Math.round(scaled);
}
break;
case RoundingMode.UP:
rounded = scaled >= 0 ? Math.ceil(scaled) : Math.floor(scaled);
break;
case RoundingMode.DOWN:
rounded = Math.trunc(scaled);
break;
case RoundingMode.CEILING:
rounded = Math.ceil(scaled);
break;
case RoundingMode.FLOOR:
rounded = Math.floor(scaled);
break;
default:
rounded = Math.round(scaled);
}
return rounded / multiplier;
}
/**
* Get tolerance for currency comparison
* Based on the smallest representable unit for the currency
*/
export function getCurrencyTolerance(currencyCode: string): number {
const minorUnits = getCurrencyMinorUnits(currencyCode);
// Tolerance is half of the smallest unit
return 0.5 * Math.pow(10, -minorUnits);
}
/**
* Compare two monetary values with currency-aware tolerance
*/
export function areMonetaryValuesEqual(
value1: number,
value2: number,
currencyCode: string
): boolean {
const tolerance = getCurrencyTolerance(currencyCode);
return Math.abs(value1 - value2) <= tolerance;
}
/**
* Format a value according to currency decimal places
*/
export function formatCurrencyValue(
value: number,
currencyCode: string
): string {
const minorUnits = getCurrencyMinorUnits(currencyCode);
return value.toFixed(minorUnits);
}
/**
* Validate if a value has correct decimal places for a currency
*/
export function hasValidDecimalPlaces(
value: number,
currencyCode: string
): boolean {
const minorUnits = getCurrencyMinorUnits(currencyCode);
const multiplier = Math.pow(10, minorUnits);
const scaled = Math.round(value * multiplier);
const reconstructed = scaled / multiplier;
return Math.abs(value - reconstructed) < Number.EPSILON;
}
/**
* Currency calculation context for EN16931 compliance
*/
export class CurrencyCalculator {
private currency: string;
private minorUnits: number;
private roundingMode: RoundingMode;
constructor(config: CurrencyConfig | string) {
if (typeof config === 'string') {
this.currency = config;
this.minorUnits = getCurrencyMinorUnits(config);
this.roundingMode = RoundingMode.HALF_UP;
} else {
this.currency = config.code;
this.minorUnits = config.minorUnits;
this.roundingMode = config.roundingMode;
}
}
/**
* Round a value according to configured rules
*/
round(value: number): number {
return roundToCurrency(value, this.currency, this.roundingMode);
}
/**
* Calculate line net amount with rounding
* EN16931: Line net = (quantity × unit price) - line discounts
*/
calculateLineNet(
quantity: number,
unitPrice: number,
discount: number = 0
): number {
const gross = quantity * unitPrice;
const net = gross - discount;
return this.round(net);
}
/**
* Calculate VAT amount with rounding
* EN16931: VAT amount = taxable amount × (rate / 100)
*/
calculateVAT(taxableAmount: number, rate: number): number {
const vat = taxableAmount * (rate / 100);
return this.round(vat);
}
/**
* Compare values with currency-aware tolerance
*/
areEqual(value1: number, value2: number): boolean {
return areMonetaryValuesEqual(value1, value2, this.currency);
}
/**
* Get the tolerance for comparisons
*/
getTolerance(): number {
return getCurrencyTolerance(this.currency);
}
/**
* Format value for display
*/
format(value: number): string {
return formatCurrencyValue(value, this.currency);
}
}
/**
* Get version info for ISO 4217 data
*/
export function getISO4217Version(): string {
return '2015'; // Update when currency list is updated
}
+509
View File
@@ -0,0 +1,509 @@
/**
* Decimal Arithmetic Library for EN16931 Compliance
* Provides arbitrary precision decimal arithmetic to avoid floating-point errors
*
* Based on EN16931 requirements for financial calculations:
* - All monetary amounts must be calculated with sufficient precision
* - Rounding must be consistent and predictable
* - No loss of precision in intermediate calculations
*/
/**
* Decimal class for arbitrary precision arithmetic
* Internally stores the value as an integer with a scale factor
*/
export class Decimal {
private readonly value: bigint;
private readonly scale: number;
// Constants - initialized lazily to avoid initialization issues
private static _ZERO: Decimal | undefined;
private static _ONE: Decimal | undefined;
private static _TEN: Decimal | undefined;
private static _HUNDRED: Decimal | undefined;
static get ZERO(): Decimal {
if (!this._ZERO) this._ZERO = new Decimal(0);
return this._ZERO;
}
static get ONE(): Decimal {
if (!this._ONE) this._ONE = new Decimal(1);
return this._ONE;
}
static get TEN(): Decimal {
if (!this._TEN) this._TEN = new Decimal(10);
return this._TEN;
}
static get HUNDRED(): Decimal {
if (!this._HUNDRED) this._HUNDRED = new Decimal(100);
return this._HUNDRED;
}
// Default scale for monetary calculations (4 decimal places for intermediate calculations)
private static readonly DEFAULT_SCALE = 4;
/**
* Create a new Decimal from various input types
*/
constructor(value: string | number | bigint | Decimal, scale?: number) {
if (value instanceof Decimal) {
this.value = value.value;
this.scale = value.scale;
return;
}
// Special handling for direct bigint with scale (internal use)
if (typeof value === 'bigint' && scale !== undefined) {
this.value = value;
this.scale = scale;
return;
}
// Determine scale if not provided
if (scale === undefined) {
if (typeof value === 'string') {
const parts = value.split('.');
scale = parts.length > 1 ? parts[1].length : 0;
} else {
scale = Decimal.DEFAULT_SCALE;
}
}
this.scale = scale;
// Convert to scaled integer
if (typeof value === 'string') {
// Remove any formatting
value = value.replace(/[^\d.-]/g, '');
const parts = value.split('.');
const integerPart = parts[0] || '0';
const decimalPart = (parts[1] || '').padEnd(scale, '0').slice(0, scale);
this.value = BigInt(integerPart + decimalPart);
} else if (typeof value === 'number') {
// Handle floating point numbers
if (!isFinite(value)) {
throw new Error(`Invalid number value: ${value}`);
}
const multiplier = Math.pow(10, scale);
this.value = BigInt(Math.round(value * multiplier));
} else {
// bigint
this.value = value * BigInt(Math.pow(10, scale));
}
}
/**
* Convert to string representation
*/
toString(decimalPlaces?: number): string {
const absValue = this.value < 0n ? -this.value : this.value;
const str = absValue.toString().padStart(this.scale + 1, '0');
const integerPart = this.scale > 0 ? (str.slice(0, -this.scale) || '0') : str;
let decimalPart = this.scale > 0 ? str.slice(-this.scale) : '';
// Apply decimal places if specified
if (decimalPlaces !== undefined) {
if (decimalPlaces === 0) {
return (this.value < 0n ? '-' : '') + integerPart;
}
decimalPart = decimalPart.padEnd(decimalPlaces, '0').slice(0, decimalPlaces);
}
// Remove trailing zeros if no specific decimal places requested
if (decimalPlaces === undefined) {
decimalPart = decimalPart.replace(/0+$/, '');
}
const result = decimalPart ? `${integerPart}.${decimalPart}` : integerPart;
return this.value < 0n ? '-' + result : result;
}
/**
* Convert to number (may lose precision)
*/
toNumber(): number {
return Number(this.value) / Math.pow(10, this.scale);
}
/**
* Convert to fixed decimal places string
*/
toFixed(decimalPlaces: number): string {
return this.round(decimalPlaces).toString(decimalPlaces);
}
/**
* Add two decimals
*/
add(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales
if (this.scale === otherDecimal.scale) {
return new Decimal(this.value + otherDecimal.value, this.scale);
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
return new Decimal(thisScaled.value + otherScaled.value, maxScale);
}
/**
* Subtract another decimal
*/
subtract(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales
if (this.scale === otherDecimal.scale) {
return new Decimal(this.value - otherDecimal.value, this.scale);
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
return new Decimal(thisScaled.value - otherScaled.value, maxScale);
}
/**
* Multiply by another decimal
*/
multiply(other: Decimal | number | string): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Multiply values and add scales
const newValue = this.value * otherDecimal.value;
const newScale = this.scale + otherDecimal.scale;
// Reduce scale if possible to avoid overflow
const result = new Decimal(newValue, newScale);
return result.normalize();
}
/**
* Divide by another decimal
*/
divide(other: Decimal | number | string, precision: number = 10): Decimal {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
if (otherDecimal.value === 0n) {
throw new Error('Division by zero');
}
// Scale up the dividend to maintain precision
const scaledDividend = this.value * BigInt(Math.pow(10, precision));
const quotient = scaledDividend / otherDecimal.value;
return new Decimal(quotient, this.scale + precision - otherDecimal.scale).normalize();
}
/**
* Calculate percentage (this * rate / 100)
*/
percentage(rate: Decimal | number | string): Decimal {
const rateDecimal = rate instanceof Decimal ? rate : new Decimal(rate);
return this.multiply(rateDecimal).divide(100);
}
/**
* Round to specified decimal places using a specific rounding mode
*/
round(decimalPlaces: number, mode: 'HALF_UP' | 'HALF_DOWN' | 'HALF_EVEN' | 'UP' | 'DOWN' | 'CEILING' | 'FLOOR' = 'HALF_UP'): Decimal {
if (decimalPlaces === this.scale) {
return this;
}
if (decimalPlaces > this.scale) {
// Just add zeros
return this.rescale(decimalPlaces);
}
// Need to round
const factor = BigInt(Math.pow(10, this.scale - decimalPlaces));
const halfFactor = factor / 2n;
let rounded: bigint;
const isNegative = this.value < 0n;
const absValue = isNegative ? -this.value : this.value;
switch (mode) {
case 'HALF_UP':
// Round half away from zero
rounded = (absValue + halfFactor) / factor;
break;
case 'HALF_DOWN':
// Round half toward zero
rounded = (absValue + halfFactor - 1n) / factor;
break;
case 'HALF_EVEN':
// Banker's rounding
const quotient = absValue / factor;
const remainder = absValue % factor;
if (remainder > halfFactor || (remainder === halfFactor && quotient % 2n === 1n)) {
rounded = quotient + 1n;
} else {
rounded = quotient;
}
break;
case 'UP':
// Round away from zero
rounded = (absValue + factor - 1n) / factor;
break;
case 'DOWN':
// Round toward zero
rounded = absValue / factor;
break;
case 'CEILING':
// Round toward positive infinity
if (isNegative) {
rounded = absValue / factor;
} else {
rounded = (absValue + factor - 1n) / factor;
}
break;
case 'FLOOR':
// Round toward negative infinity
if (isNegative) {
rounded = (absValue + factor - 1n) / factor;
} else {
rounded = absValue / factor;
}
break;
default:
throw new Error(`Unknown rounding mode: ${mode}`);
}
const finalValue = isNegative ? -rounded : rounded;
return new Decimal(finalValue, decimalPlaces);
}
/**
* Compare with another decimal
*/
compareTo(other: Decimal | number | string): number {
const otherDecimal = other instanceof Decimal ? other : new Decimal(other);
// Align scales for comparison
if (this.scale === otherDecimal.scale) {
if (this.value < otherDecimal.value) return -1;
if (this.value > otherDecimal.value) return 1;
return 0;
}
const maxScale = Math.max(this.scale, otherDecimal.scale);
const thisScaled = this.rescale(maxScale);
const otherScaled = otherDecimal.rescale(maxScale);
if (thisScaled.value < otherScaled.value) return -1;
if (thisScaled.value > otherScaled.value) return 1;
return 0;
}
/**
* Check equality
*/
equals(other: Decimal | number | string, tolerance?: Decimal | number | string): boolean {
if (tolerance) {
const toleranceDecimal = tolerance instanceof Decimal ? tolerance : new Decimal(tolerance);
const diff = this.subtract(other);
const absDiff = diff.abs();
return absDiff.compareTo(toleranceDecimal) <= 0;
}
return this.compareTo(other) === 0;
}
/**
* Check if less than
*/
lessThan(other: Decimal | number | string): boolean {
return this.compareTo(other) < 0;
}
/**
* Check if less than or equal
*/
lessThanOrEqual(other: Decimal | number | string): boolean {
return this.compareTo(other) <= 0;
}
/**
* Check if greater than
*/
greaterThan(other: Decimal | number | string): boolean {
return this.compareTo(other) > 0;
}
/**
* Check if greater than or equal
*/
greaterThanOrEqual(other: Decimal | number | string): boolean {
return this.compareTo(other) >= 0;
}
/**
* Get absolute value
*/
abs(): Decimal {
return this.value < 0n ? new Decimal(-this.value, this.scale) : this;
}
/**
* Negate the value
*/
negate(): Decimal {
return new Decimal(-this.value, this.scale);
}
/**
* Check if zero
*/
isZero(): boolean {
return this.value === 0n;
}
/**
* Check if negative
*/
isNegative(): boolean {
return this.value < 0n;
}
/**
* Check if positive
*/
isPositive(): boolean {
return this.value > 0n;
}
/**
* Rescale to a different number of decimal places
*/
private rescale(newScale: number): Decimal {
if (newScale === this.scale) {
return this;
}
if (newScale > this.scale) {
// Add zeros
const factor = BigInt(Math.pow(10, newScale - this.scale));
return new Decimal(this.value * factor, newScale);
}
// This would lose precision, use round() instead
throw new Error('Use round() to reduce scale');
}
/**
* Normalize by removing trailing zeros
*/
private normalize(): Decimal {
if (this.value === 0n) {
return new Decimal(0n, 0);
}
let value = this.value;
let scale = this.scale;
while (scale > 0 && value % 10n === 0n) {
value = value / 10n;
scale--;
}
return new Decimal(value, scale);
}
/**
* Create a Decimal from a percentage string (e.g., "19%" -> 0.19)
*/
static fromPercentage(value: string): Decimal {
const cleaned = value.replace('%', '').trim();
return new Decimal(cleaned).divide(100);
}
/**
* Sum an array of decimals
*/
static sum(values: (Decimal | number | string)[]): Decimal {
return values.reduce<Decimal>((acc, val) => {
const decimal = val instanceof Decimal ? val : new Decimal(val);
return acc.add(decimal);
}, Decimal.ZERO);
}
/**
* Get the minimum value
*/
static min(...values: (Decimal | number | string)[]): Decimal {
if (values.length === 0) {
throw new Error('No values provided');
}
let min = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]);
for (let i = 1; i < values.length; i++) {
const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]);
const currentDecimal = current instanceof Decimal ? current : new Decimal(current);
if (currentDecimal.lessThan(min)) {
min = currentDecimal;
}
}
return min;
}
/**
* Get the maximum value
*/
static max(...values: (Decimal | number | string)[]): Decimal {
if (values.length === 0) {
throw new Error('No values provided');
}
let max = values[0] instanceof Decimal ? values[0] : new Decimal(values[0]);
for (let i = 1; i < values.length; i++) {
const current = values[i] instanceof Decimal ? values[i] : new Decimal(values[i]);
const currentDecimal = current instanceof Decimal ? current : new Decimal(current);
if (currentDecimal.greaterThan(max)) {
max = currentDecimal;
}
}
return max;
}
}
/**
* Helper function to create a Decimal
*/
export function decimal(value: string | number | bigint | Decimal): Decimal {
return new Decimal(value);
}
/**
* Export commonly used rounding modes
*/
export const RoundingMode = {
HALF_UP: 'HALF_UP' as const,
HALF_DOWN: 'HALF_DOWN' as const,
HALF_EVEN: 'HALF_EVEN' as const,
UP: 'UP' as const,
DOWN: 'DOWN' as const,
CEILING: 'CEILING' as const,
FLOOR: 'FLOOR' as const
} as const;
export type RoundingMode = typeof RoundingMode[keyof typeof RoundingMode];
+57 -19
View File
@@ -70,41 +70,81 @@ export class FormatDetector {
* @returns Detected format or UNKNOWN if more analysis is needed
*/
private static quickFormatCheck(xml: string): InvoiceFormat {
const lowerXml = xml.toLowerCase();
// Only scan a small prefix so large payloads do not create another full-size string copy.
const sample = xml.slice(0, 65536);
// Root-element checks avoid a DOM parse for the common invoice formats.
if (/<(?:[A-Za-z_][\w.-]*:)?(?:Invoice|CreditNote)\b/.test(sample)) {
const customizationIdMatch = sample.match(
/<[^>]*CustomizationID[^>]*>\s*([^<]+?)\s*<\/[^>]*CustomizationID>/i,
);
const customizationId = customizationIdMatch?.[1] ?? '';
if (/xrechnung/i.test(customizationId) || /urn:xoev-de:kosit:standard:xrechnung/i.test(customizationId)) {
return InvoiceFormat.XRECHNUNG;
}
return InvoiceFormat.UBL;
}
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryInvoice\b/.test(sample)) {
const guidelineIdMatch = sample.match(
/<[^>]*GuidelineSpecifiedDocumentContextParameter[^>]*>[\s\S]*?<[^>]*ID[^>]*>\s*([^<]+?)\s*<\/[^>]*ID>/i,
);
const guidelineId = guidelineIdMatch?.[1] ?? '';
if (/xrechnung/i.test(guidelineId)) {
return InvoiceFormat.XRECHNUNG;
}
if (/factur-x/i.test(guidelineId) || /urn:cen\.eu:en16931:2017/i.test(guidelineId)) {
return InvoiceFormat.FACTURX;
}
if (/zugferd/i.test(guidelineId) || /urn:ferd:/i.test(guidelineId) || /urn:zugferd/i.test(guidelineId)) {
return InvoiceFormat.ZUGFERD;
}
return InvoiceFormat.CII;
}
if (/<(?:[A-Za-z_][\w.-]*:)?CrossIndustryDocument\b/.test(sample)) {
return InvoiceFormat.ZUGFERD;
}
if (/<FatturaElettronica\b/.test(sample)) {
return InvoiceFormat.FATTURAPA;
}
// Check for obvious Factur-X indicators
if (
lowerXml.includes('factur-x.eu') ||
lowerXml.includes('factur-x.xml') ||
lowerXml.includes('factur-x:') ||
lowerXml.includes('urn:cen.eu:en16931:2017') && lowerXml.includes('factur-x')
/factur-x\.eu/i.test(sample) ||
/factur-x\.xml/i.test(sample) ||
/factur-x:/i.test(sample) ||
(/urn:cen\.eu:en16931:2017/i.test(sample) && /factur-x/i.test(sample))
) {
return InvoiceFormat.FACTURX;
}
// Check for obvious ZUGFeRD indicators
if (
lowerXml.includes('zugferd:') ||
lowerXml.includes('zugferd-invoice.xml') ||
lowerXml.includes('urn:ferd:') ||
lowerXml.includes('urn:zugferd')
/zugferd:/i.test(sample) ||
/zugferd-invoice\.xml/i.test(sample) ||
/urn:ferd:/i.test(sample) ||
/urn:zugferd/i.test(sample)
) {
return InvoiceFormat.ZUGFERD;
}
// Check for obvious XRechnung indicators
if (
lowerXml.includes('xrechnung') ||
lowerXml.includes('urn:xoev-de:kosit:standard:xrechnung')
/xrechnung/i.test(sample) ||
/urn:xoev-de:kosit:standard:xrechnung/i.test(sample)
) {
return InvoiceFormat.XRECHNUNG;
}
// Check for obvious FatturaPA indicators
if (
lowerXml.includes('fatturapa') ||
lowerXml.includes('fattura elettronica') ||
lowerXml.includes('fatturaelettronica')
/fatturapa/i.test(sample) ||
/fattura elettronica/i.test(sample) ||
/fatturaelettronica/i.test(sample)
) {
return InvoiceFormat.FATTURAPA;
}
@@ -198,10 +238,8 @@ export class FormatDetector {
* @returns True if it's a FatturaPA format
*/
private static isFatturaPAFormat(root: Element): boolean {
return (
root.nodeName === 'FatturaElettronica' ||
(root.getAttribute('xmlns') && root.getAttribute('xmlns')!.includes('fatturapa.gov.it'))
);
const xmlns = root.getAttribute('xmlns') || '';
return root.nodeName === 'FatturaElettronica' || xmlns.includes('fatturapa.gov.it');
}
/**
@@ -303,4 +341,4 @@ export class FormatDetector {
// Generic CII if we can't determine more specifically
return InvoiceFormat.CII;
}
}
}
+317
View File
@@ -0,0 +1,317 @@
import type { ValidationResult } from './validation.types.js';
import { CodeLists } from './validation.types.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import type { IExtendedAccountingDocItem } from '../../interfaces/en16931-metadata.js';
/**
* Code List Validator for EN16931 compliance
* Validates against standard code lists (ISO, UNCL, UNECE)
*/
export class CodeListValidator {
private results: ValidationResult[] = [];
/**
* Validate all code lists in an invoice
*/
public validate(invoice: EInvoice): ValidationResult[] {
this.results = [];
// Currency validation
this.validateCurrency(invoice);
// Country codes
this.validateCountryCodes(invoice);
// Document type
this.validateDocumentType(invoice);
// Tax categories
this.validateTaxCategories(invoice);
// Payment means
this.validatePaymentMeans(invoice);
// Unit codes
this.validateUnitCodes(invoice);
return this.results;
}
/**
* Validate currency codes (ISO 4217)
*/
private validateCurrency(invoice: EInvoice): void {
// Document currency (BT-5)
if (invoice.currency) {
if (!CodeLists.ISO4217.codes.has(invoice.currency.toUpperCase())) {
this.addError(
'BR-CL-03',
`Invalid currency code: ${invoice.currency}. Must be ISO 4217`,
'EN16931',
'currency',
'BT-5',
invoice.currency,
Array.from(CodeLists.ISO4217.codes).join(', ')
);
}
}
// VAT accounting currency (BT-6)
const vatCurrency = invoice.metadata?.vatAccountingCurrency;
if (vatCurrency && !CodeLists.ISO4217.codes.has(vatCurrency.toUpperCase())) {
this.addError(
'BR-CL-04',
`Invalid VAT accounting currency: ${vatCurrency}. Must be ISO 4217`,
'EN16931',
'metadata.vatAccountingCurrency',
'BT-6',
vatCurrency,
Array.from(CodeLists.ISO4217.codes).join(', ')
);
}
}
/**
* Validate country codes (ISO 3166-1 alpha-2)
*/
private validateCountryCodes(invoice: EInvoice): void {
// Seller country (BT-40)
const sellerCountry = invoice.from?.address?.countryCode;
if (sellerCountry && !CodeLists.ISO3166.codes.has(sellerCountry.toUpperCase())) {
this.addError(
'BR-CL-14',
`Invalid seller country code: ${sellerCountry}. Must be ISO 3166-1 alpha-2`,
'EN16931',
'from.address.countryCode',
'BT-40',
sellerCountry,
'Two-letter country code (e.g., DE, FR, IT)'
);
}
// Buyer country (BT-55)
const buyerCountry = invoice.to?.address?.countryCode;
if (buyerCountry && !CodeLists.ISO3166.codes.has(buyerCountry.toUpperCase())) {
this.addError(
'BR-CL-15',
`Invalid buyer country code: ${buyerCountry}. Must be ISO 3166-1 alpha-2`,
'EN16931',
'to.address.countryCode',
'BT-55',
buyerCountry,
'Two-letter country code (e.g., DE, FR, IT)'
);
}
// Delivery country (BT-80)
const deliveryCountry = invoice.metadata?.deliveryAddress?.countryCode;
if (deliveryCountry && !CodeLists.ISO3166.codes.has(deliveryCountry.toUpperCase())) {
this.addError(
'BR-CL-16',
`Invalid delivery country code: ${deliveryCountry}. Must be ISO 3166-1 alpha-2`,
'EN16931',
'metadata.deliveryAddress.countryCode',
'BT-80',
deliveryCountry,
'Two-letter country code (e.g., DE, FR, IT)'
);
}
}
/**
* Validate document type code (UNCL1001)
*/
private validateDocumentType(invoice: EInvoice): void {
const typeCode = invoice.metadata?.documentTypeCode ||
(invoice.accountingDocType === 'invoice' ? '380' :
invoice.accountingDocType === 'creditnote' ? '381' :
invoice.accountingDocType === 'debitnote' ? '383' : null);
if (typeCode && !CodeLists.UNCL1001.codes.has(typeCode)) {
this.addError(
'BR-CL-01',
`Invalid document type code: ${typeCode}. Must be UNCL1001`,
'EN16931',
'metadata.documentTypeCode',
'BT-3',
typeCode,
Array.from(CodeLists.UNCL1001.codes.entries()).map(([k, v]) => `${k} (${v})`).join(', ')
);
}
}
/**
* Validate tax category codes (UNCL5305)
*/
private validateTaxCategories(invoice: EInvoice): void {
// Document level tax breakdown
// Note: taxBreakdown is a computed property that doesn't have metadata
// We would need to access the raw tax breakdown data from metadata if it exists
invoice.taxBreakdown?.forEach((breakdown, index) => {
// Since the computed taxBreakdown doesn't have metadata,
// we'll skip the tax category code validation for now
// This would need to be implemented differently to access the raw data
// TODO: Access raw tax breakdown data with metadata from invoice.metadata.taxBreakdown
// when that structure is implemented
});
// Line level tax categories
invoice.items?.forEach((item, index) => {
// Cast to extended type to access metadata
const extendedItem = item as IExtendedAccountingDocItem;
const categoryCode = extendedItem.metadata?.vatCategoryCode;
if (categoryCode && !CodeLists.UNCL5305.codes.has(categoryCode)) {
this.addError(
'BR-CL-10',
`Invalid line tax category: ${categoryCode}. Must be UNCL5305`,
'EN16931',
`items[${index}].metadata.vatCategoryCode`,
'BT-151',
categoryCode,
Array.from(CodeLists.UNCL5305.codes.entries()).map(([k, v]) => `${k} (${v})`).join(', ')
);
}
});
}
/**
* Validate payment means codes (UNCL4461)
*/
private validatePaymentMeans(invoice: EInvoice): void {
const paymentMeans = invoice.metadata?.paymentMeansCode;
if (paymentMeans && !CodeLists.UNCL4461.codes.has(paymentMeans)) {
this.addError(
'BR-CL-16',
`Invalid payment means code: ${paymentMeans}. Must be UNCL4461`,
'EN16931',
'metadata.paymentMeansCode',
'BT-81',
paymentMeans,
Array.from(CodeLists.UNCL4461.codes.entries()).map(([k, v]) => `${k} (${v})`).join(', ')
);
}
// Validate payment requirements based on means code
if (paymentMeans === '30' || paymentMeans === '58') { // Credit transfer
if (!invoice.metadata?.paymentAccount?.iban) {
this.addWarning(
'BR-CL-16-1',
`Payment means ${paymentMeans} (${CodeLists.UNCL4461.codes.get(paymentMeans)}) typically requires IBAN`,
'EN16931',
'metadata.paymentAccount.iban',
'BT-84'
);
}
}
}
/**
* Validate unit codes (UNECE Rec 20)
*/
private validateUnitCodes(invoice: EInvoice): void {
invoice.items?.forEach((item, index) => {
const unitCode = item.unitType;
if (unitCode && !CodeLists.UNECERec20.codes.has(unitCode)) {
this.addError(
'BR-CL-23',
`Invalid unit code: ${unitCode}. Must be UNECE Rec 20`,
'EN16931',
`items[${index}].unitCode`,
'BT-130',
unitCode,
'Common codes: C62 (one), KGM (kilogram), HUR (hour), DAY (day), MTR (metre)'
);
}
// Validate quantity is positive for standard units
if (unitCode && item.unitQuantity <= 0 && unitCode !== 'LS') { // LS = Lump sum can be 1
this.addError(
'BR-25',
`Quantity must be positive for unit ${unitCode}`,
'EN16931',
`items[${index}].quantity`,
'BT-129',
item.unitQuantity,
'> 0'
);
}
});
}
/**
* Add validation error
*/
private addError(
ruleId: string,
message: string,
source: string,
field: string,
btReference?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source,
severity: 'error',
message,
field,
btReference,
value,
expected,
codeList: this.getCodeListForRule(ruleId)
});
}
/**
* Add validation warning
*/
private addWarning(
ruleId: string,
message: string,
source: string,
field: string,
btReference?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source,
severity: 'warning',
message,
field,
btReference,
value,
expected,
codeList: this.getCodeListForRule(ruleId)
});
}
/**
* Get code list metadata for a rule
*/
private getCodeListForRule(ruleId: string): { name: string; version: string } | undefined {
if (ruleId.includes('CL-03') || ruleId.includes('CL-04')) {
return { name: 'ISO4217', version: CodeLists.ISO4217.version };
}
if (ruleId.includes('CL-14') || ruleId.includes('CL-15') || ruleId.includes('CL-16')) {
return { name: 'ISO3166', version: CodeLists.ISO3166.version };
}
if (ruleId.includes('CL-01')) {
return { name: 'UNCL1001', version: CodeLists.UNCL1001.version };
}
if (ruleId.includes('CL-10')) {
return { name: 'UNCL5305', version: CodeLists.UNCL5305.version };
}
if (ruleId.includes('CL-16')) {
return { name: 'UNCL4461', version: CodeLists.UNCL4461.version };
}
if (ruleId.includes('CL-23')) {
return { name: 'UNECERec20', version: CodeLists.UNECERec20.version };
}
return undefined;
}
}
@@ -0,0 +1,593 @@
/**
* Conformance Test Harness for EN16931 Validation
* Tests validators against official samples and generates coverage reports
*/
import * as plugins from '../../plugins.js';
import * as fs from 'fs';
import * as path from 'path';
import { IntegratedValidator } from './schematron.integration.js';
import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js';
import { CodeListValidator } from './codelist.validator.js';
import { VATCategoriesValidator } from './vat-categories.validator.js';
import type { ValidationResult, ValidationReport } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
import { XMLToEInvoiceConverter } from '../converters/xml-to-einvoice.converter.js';
/**
* Test sample metadata
*/
interface TestSample {
id: string;
name: string;
path: string;
format: 'UBL' | 'CII';
standard: string;
expectedValid: boolean;
description?: string;
focusRules?: string[];
}
/**
* Test result for a single sample
*/
interface TestResult {
sampleId: string;
sampleName: string;
passed: boolean;
errors: ValidationResult[];
warnings: ValidationResult[];
rulesTriggered: string[];
executionTime: number;
validatorResults: {
typescript: ValidationResult[];
schematron: ValidationResult[];
vatCategories: ValidationResult[];
codeLists: ValidationResult[];
};
}
/**
* Coverage report for all rules
*/
interface CoverageReport {
totalRules: number;
coveredRules: number;
coveragePercentage: number;
ruleDetails: Map<string, {
covered: boolean;
samplesCovering: string[];
errorCount: number;
warningCount: number;
}>;
uncoveredRules: string[];
byCategory: {
document: { total: number; covered: number };
calculation: { total: number; covered: number };
vat: { total: number; covered: number };
lineLevel: { total: number; covered: number };
codeLists: { total: number; covered: number };
};
}
/**
* Conformance Test Harness
*/
export class ConformanceTestHarness {
private integratedValidator: IntegratedValidator;
private businessRulesValidator: EN16931BusinessRulesValidator;
private codeListValidator: CodeListValidator;
private vatCategoriesValidator: VATCategoriesValidator;
private xmlConverter: XMLToEInvoiceConverter;
private testSamples: TestSample[] = [];
private results: TestResult[] = [];
constructor() {
this.integratedValidator = new IntegratedValidator();
this.businessRulesValidator = new EN16931BusinessRulesValidator();
this.codeListValidator = new CodeListValidator();
this.vatCategoriesValidator = new VATCategoriesValidator();
this.xmlConverter = new XMLToEInvoiceConverter();
}
/**
* Load test samples from directory
*/
public async loadTestSamples(baseDir: string = 'test-samples'): Promise<void> {
this.testSamples = [];
// Load PEPPOL BIS 3.0 samples
const peppolDir = path.join(baseDir, 'peppol-bis3');
if (fs.existsSync(peppolDir)) {
const peppolFiles = fs.readdirSync(peppolDir);
for (const file of peppolFiles) {
if (file.endsWith('.xml')) {
this.testSamples.push({
id: `peppol-${path.basename(file, '.xml')}`,
name: file,
path: path.join(peppolDir, file),
format: 'UBL',
standard: 'PEPPOL-BIS-3.0',
expectedValid: true,
description: this.getDescriptionFromFilename(file),
focusRules: this.getFocusRulesFromFilename(file)
});
}
}
}
// Load CEN TC434 samples
const cenDir = path.join(baseDir, 'cen-tc434');
if (fs.existsSync(cenDir)) {
const cenFiles = fs.readdirSync(cenDir);
for (const file of cenFiles) {
if (file.endsWith('.xml')) {
const format = file.includes('ubl') ? 'UBL' : 'CII';
this.testSamples.push({
id: `cen-${path.basename(file, '.xml')}`,
name: file,
path: path.join(cenDir, file),
format,
standard: 'EN16931',
expectedValid: true,
description: `CEN TC434 ${format} example`
});
}
}
}
console.log(`Loaded ${this.testSamples.length} test samples`);
}
/**
* Run all validators against a single test sample
*/
private async runTestSample(sample: TestSample): Promise<TestResult> {
const startTime = Date.now();
const result: TestResult = {
sampleId: sample.id,
sampleName: sample.name,
passed: false,
errors: [],
warnings: [],
rulesTriggered: [],
executionTime: 0,
validatorResults: {
typescript: [],
schematron: [],
vatCategories: [],
codeLists: []
}
};
try {
// Read XML content
const xmlContent = fs.readFileSync(sample.path, 'utf-8');
// Convert XML to EInvoice
const invoice = await this.xmlConverter.convert(xmlContent, sample.format);
// Run TypeScript validators
const businessRules = this.businessRulesValidator.validate(invoice);
result.validatorResults.typescript = businessRules;
const codeLists = this.codeListValidator.validate(invoice);
result.validatorResults.codeLists = codeLists;
const vatCategories = this.vatCategoriesValidator.validate(invoice);
result.validatorResults.vatCategories = vatCategories;
// Try to run Schematron if available
try {
await this.integratedValidator.loadSchematron('EN16931', sample.format);
const report = await this.integratedValidator.validate(invoice, xmlContent);
result.validatorResults.schematron = report.results.filter(r =>
r.source === 'Schematron'
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron not available for ${sample.format}: ${errorMessage}`);
}
// Aggregate results
const allResults = [
...businessRules,
...codeLists,
...vatCategories,
...result.validatorResults.schematron
];
result.errors = allResults.filter(r => r.severity === 'error');
result.warnings = allResults.filter(r => r.severity === 'warning');
result.rulesTriggered = [...new Set(allResults.map(r => r.ruleId))];
result.passed = result.errors.length === 0 === sample.expectedValid;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`Error testing ${sample.name}: ${errorMessage}`);
result.errors.push({
ruleId: 'TEST-ERROR',
source: 'TestHarness',
severity: 'error',
message: `Test execution failed: ${errorMessage}`
});
}
result.executionTime = Date.now() - startTime;
return result;
}
/**
* Run conformance tests on all samples
*/
public async runConformanceTests(): Promise<void> {
console.log('\n🔬 Running conformance tests...\n');
this.results = [];
for (const sample of this.testSamples) {
process.stdout.write(`Testing ${sample.name}... `);
const result = await this.runTestSample(sample);
this.results.push(result);
if (result.passed) {
console.log('✅ PASSED');
} else {
console.log(`❌ FAILED (${result.errors.length} errors)`);
}
}
console.log('\n' + '='.repeat(60));
this.printSummary();
}
/**
* Generate BR coverage matrix
*/
public generateCoverageMatrix(): CoverageReport {
// Define all EN16931 business rules
const allRules = this.getAllEN16931Rules();
const ruleDetails = new Map<string, any>();
// Initialize rule details
for (const rule of allRules) {
ruleDetails.set(rule, {
covered: false,
samplesCovering: [],
errorCount: 0,
warningCount: 0
});
}
// Process test results
for (const result of this.results) {
for (const ruleId of result.rulesTriggered) {
if (ruleDetails.has(ruleId)) {
const detail = ruleDetails.get(ruleId);
detail.covered = true;
detail.samplesCovering.push(result.sampleId);
detail.errorCount += result.errors.filter(e => e.ruleId === ruleId).length;
detail.warningCount += result.warnings.filter(w => w.ruleId === ruleId).length;
}
}
}
// Calculate coverage by category
const categories = {
document: { total: 0, covered: 0 },
calculation: { total: 0, covered: 0 },
vat: { total: 0, covered: 0 },
lineLevel: { total: 0, covered: 0 },
codeLists: { total: 0, covered: 0 }
};
for (const [rule, detail] of ruleDetails) {
const category = this.getRuleCategory(rule);
if (category && categories[category]) {
categories[category].total++;
if (detail.covered) {
categories[category].covered++;
}
}
}
// Find uncovered rules
const uncoveredRules = Array.from(ruleDetails.entries())
.filter(([_, detail]) => !detail.covered)
.map(([rule, _]) => rule);
const coveredCount = Array.from(ruleDetails.values())
.filter(d => d.covered).length;
return {
totalRules: allRules.length,
coveredRules: coveredCount,
coveragePercentage: (coveredCount / allRules.length) * 100,
ruleDetails,
uncoveredRules,
byCategory: categories
};
}
/**
* Print test summary
*/
private printSummary(): void {
const passed = this.results.filter(r => r.passed).length;
const failed = this.results.filter(r => !r.passed).length;
const totalErrors = this.results.reduce((sum, r) => sum + r.errors.length, 0);
const totalWarnings = this.results.reduce((sum, r) => sum + r.warnings.length, 0);
console.log('\n📊 Test Summary:');
console.log(` Total samples: ${this.testSamples.length}`);
console.log(` ✅ Passed: ${passed}`);
console.log(` ❌ Failed: ${failed}`);
console.log(` 🔴 Total errors: ${totalErrors}`);
console.log(` 🟡 Total warnings: ${totalWarnings}`);
// Show failed samples
if (failed > 0) {
console.log('\n❌ Failed samples:');
for (const result of this.results.filter(r => !r.passed)) {
console.log(` - ${result.sampleName} (${result.errors.length} errors)`);
for (const error of result.errors.slice(0, 3)) {
console.log(`${error.ruleId}: ${error.message}`);
}
if (result.errors.length > 3) {
console.log(` ... and ${result.errors.length - 3} more errors`);
}
}
}
}
/**
* Generate HTML coverage report
*/
public async generateHTMLReport(outputPath: string = 'coverage-report.html'): Promise<void> {
const coverage = this.generateCoverageMatrix();
const html = `
<!DOCTYPE html>
<html>
<head>
<title>EN16931 Conformance Test Report</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.summary { background: #f0f0f0; padding: 15px; border-radius: 5px; margin: 20px 0; }
.metric { display: inline-block; margin: 10px 20px 10px 0; }
.metric-value { font-size: 24px; font-weight: bold; color: #007bff; }
.coverage-bar { width: 100%; height: 30px; background: #e0e0e0; border-radius: 5px; overflow: hidden; }
.coverage-fill { height: 100%; background: linear-gradient(90deg, #28a745, #ffc107); }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { padding: 10px; text-align: left; border: 1px solid #ddd; }
th { background: #f8f9fa; font-weight: bold; }
.covered { background: #d4edda; }
.uncovered { background: #f8d7da; }
.category-section { margin: 30px 0; }
.rule-tag { display: inline-block; padding: 2px 8px; margin: 2px; background: #007bff; color: white; border-radius: 3px; font-size: 12px; }
</style>
</head>
<body>
<h1>EN16931 Conformance Test Report</h1>
<div class="summary">
<h2>Overall Coverage</h2>
<div class="metric">
<div class="metric-value">${coverage.coveragePercentage.toFixed(1)}%</div>
<div>Total Coverage</div>
</div>
<div class="metric">
<div class="metric-value">${coverage.coveredRules}</div>
<div>Rules Covered</div>
</div>
<div class="metric">
<div class="metric-value">${coverage.totalRules}</div>
<div>Total Rules</div>
</div>
<div class="coverage-bar">
<div class="coverage-fill" style="width: ${coverage.coveragePercentage}%"></div>
</div>
</div>
<div class="category-section">
<h2>Coverage by Category</h2>
<table>
<tr>
<th>Category</th>
<th>Covered</th>
<th>Total</th>
<th>Percentage</th>
</tr>
${Object.entries(coverage.byCategory).map(([cat, data]) => `
<tr>
<td>${cat.charAt(0).toUpperCase() + cat.slice(1)}</td>
<td>${data.covered}</td>
<td>${data.total}</td>
<td>${data.total > 0 ? ((data.covered / data.total) * 100).toFixed(1) : 0}%</td>
</tr>
`).join('')}
</table>
</div>
<div class="category-section">
<h2>Test Samples</h2>
<table>
<tr>
<th>Sample</th>
<th>Status</th>
<th>Errors</th>
<th>Warnings</th>
<th>Rules Triggered</th>
</tr>
${this.results.map(r => `
<tr class="${r.passed ? 'covered' : 'uncovered'}">
<td>${r.sampleName}</td>
<td>${r.passed ? '✅ PASSED' : '❌ FAILED'}</td>
<td>${r.errors.length}</td>
<td>${r.warnings.length}</td>
<td>${r.rulesTriggered.length}</td>
</tr>
`).join('')}
</table>
</div>
<div class="category-section">
<h2>Uncovered Rules</h2>
${coverage.uncoveredRules.length === 0 ? '<p>All rules covered! 🎉</p>' : `
<p>The following ${coverage.uncoveredRules.length} rules need test coverage:</p>
<div>
${coverage.uncoveredRules.map(rule =>
`<span class="rule-tag">${rule}</span>`
).join('')}
</div>
`}
</div>
<div class="category-section">
<p>Generated: ${new Date().toISOString()}</p>
</div>
</body>
</html>
`;
fs.writeFileSync(outputPath, html);
console.log(`\n📄 HTML report generated: ${outputPath}`);
}
/**
* Get all EN16931 business rules
*/
private getAllEN16931Rules(): string[] {
return [
// Document level rules
'BR-01', 'BR-02', 'BR-03', 'BR-04', 'BR-05', 'BR-06', 'BR-07', 'BR-08', 'BR-09', 'BR-10',
'BR-11', 'BR-12', 'BR-13', 'BR-14', 'BR-15', 'BR-16', 'BR-17', 'BR-18', 'BR-19', 'BR-20',
// Line level rules
'BR-21', 'BR-22', 'BR-23', 'BR-24', 'BR-25', 'BR-26', 'BR-27', 'BR-28', 'BR-29', 'BR-30',
// Allowances and charges
'BR-31', 'BR-32', 'BR-33', 'BR-34', 'BR-35', 'BR-36', 'BR-37', 'BR-38', 'BR-39', 'BR-40',
'BR-41', 'BR-42', 'BR-43', 'BR-44', 'BR-45', 'BR-46', 'BR-47', 'BR-48', 'BR-49', 'BR-50',
'BR-51', 'BR-52', 'BR-53', 'BR-54', 'BR-55', 'BR-56', 'BR-57', 'BR-58', 'BR-59', 'BR-60',
'BR-61', 'BR-62', 'BR-63', 'BR-64', 'BR-65',
// Calculation rules
'BR-CO-01', 'BR-CO-02', 'BR-CO-03', 'BR-CO-04', 'BR-CO-05', 'BR-CO-06', 'BR-CO-07', 'BR-CO-08',
'BR-CO-09', 'BR-CO-10', 'BR-CO-11', 'BR-CO-12', 'BR-CO-13', 'BR-CO-14', 'BR-CO-15', 'BR-CO-16',
'BR-CO-17', 'BR-CO-18', 'BR-CO-19', 'BR-CO-20',
// VAT rules - Standard rate
'BR-S-01', 'BR-S-02', 'BR-S-03', 'BR-S-04', 'BR-S-05', 'BR-S-06', 'BR-S-07', 'BR-S-08',
// VAT rules - Zero rated
'BR-Z-01', 'BR-Z-02', 'BR-Z-03', 'BR-Z-04', 'BR-Z-05', 'BR-Z-06', 'BR-Z-07', 'BR-Z-08',
// VAT rules - Exempt
'BR-E-01', 'BR-E-02', 'BR-E-03', 'BR-E-04', 'BR-E-05', 'BR-E-06', 'BR-E-07', 'BR-E-08',
// VAT rules - Reverse charge
'BR-AE-01', 'BR-AE-02', 'BR-AE-03', 'BR-AE-04', 'BR-AE-05', 'BR-AE-06', 'BR-AE-07', 'BR-AE-08',
// VAT rules - Intra-community
'BR-K-01', 'BR-K-02', 'BR-K-03', 'BR-K-04', 'BR-K-05', 'BR-K-06', 'BR-K-07', 'BR-K-08',
'BR-K-09', 'BR-K-10',
// VAT rules - Export
'BR-G-01', 'BR-G-02', 'BR-G-03', 'BR-G-04', 'BR-G-05', 'BR-G-06', 'BR-G-07', 'BR-G-08',
// VAT rules - Out of scope
'BR-O-01', 'BR-O-02', 'BR-O-03', 'BR-O-04', 'BR-O-05', 'BR-O-06', 'BR-O-07', 'BR-O-08',
// Code list rules
'BR-CL-01', 'BR-CL-02', 'BR-CL-03', 'BR-CL-04', 'BR-CL-05', 'BR-CL-06', 'BR-CL-07', 'BR-CL-08',
'BR-CL-09', 'BR-CL-10', 'BR-CL-11', 'BR-CL-12', 'BR-CL-13', 'BR-CL-14', 'BR-CL-15', 'BR-CL-16',
'BR-CL-17', 'BR-CL-18', 'BR-CL-19', 'BR-CL-20', 'BR-CL-21', 'BR-CL-22', 'BR-CL-23', 'BR-CL-24',
'BR-CL-25', 'BR-CL-26'
];
}
/**
* Get category for a rule
*/
private getRuleCategory(ruleId: string): keyof CoverageReport['byCategory'] | null {
if (ruleId.startsWith('BR-CO-')) return 'calculation';
if (ruleId.match(/^BR-[SZAEKG0]-/)) return 'vat';
if (ruleId.startsWith('BR-CL-')) return 'codeLists';
if (ruleId.match(/^BR-2[0-9]/) || ruleId.match(/^BR-3[0-9]/)) return 'lineLevel';
if (ruleId.match(/^BR-[0-9]/) || ruleId.match(/^BR-1[0-9]/)) return 'document';
return null;
}
/**
* Get description from filename
*/
private getDescriptionFromFilename(filename: string): string {
const descriptions: Record<string, string> = {
'Allowance-example': 'Invoice with document level allowances',
'base-example': 'Basic EN16931 compliant invoice',
'base-negative-inv-correction': 'Negative invoice correction',
'vat-category-E': 'VAT Exempt invoice',
'vat-category-O': 'Out of scope services',
'vat-category-S': 'Standard rated VAT',
'vat-category-Z': 'Zero rated VAT',
'vat-category-AE': 'Reverse charge VAT',
'vat-category-K': 'Intra-community supply',
'vat-category-G': 'Export outside EU'
};
const key = filename.replace('.xml', '');
return descriptions[key] || filename;
}
/**
* Get focus rules from filename
*/
private getFocusRulesFromFilename(filename: string): string[] {
const focusMap: Record<string, string[]> = {
'vat-category-E': ['BR-E-01', 'BR-E-02', 'BR-E-03', 'BR-E-04', 'BR-E-05', 'BR-E-06'],
'vat-category-S': ['BR-S-01', 'BR-S-02', 'BR-S-03', 'BR-S-04', 'BR-S-05'],
'vat-category-Z': ['BR-Z-01', 'BR-Z-02', 'BR-Z-03', 'BR-Z-04', 'BR-Z-05'],
'vat-category-AE': ['BR-AE-01', 'BR-AE-02', 'BR-AE-03', 'BR-AE-04', 'BR-AE-05', 'BR-AE-06'],
'vat-category-K': ['BR-K-01', 'BR-K-02', 'BR-K-03', 'BR-K-04', 'BR-K-05', 'BR-K-06'],
'vat-category-G': ['BR-G-01', 'BR-G-02', 'BR-G-03', 'BR-G-04', 'BR-G-05', 'BR-G-06'],
'vat-category-O': ['BR-O-01', 'BR-O-02', 'BR-O-03', 'BR-O-04', 'BR-O-05', 'BR-O-06']
};
const key = filename.replace('.xml', '');
return focusMap[key] || [];
}
}
/**
* Export convenience function to run conformance tests
*/
export async function runConformanceTests(
samplesDir: string = 'test-samples',
generateReport: boolean = true
): Promise<void> {
const harness = new ConformanceTestHarness();
// Load samples
await harness.loadTestSamples(samplesDir);
// Run tests
await harness.runConformanceTests();
// Generate reports
if (generateReport) {
const coverage = harness.generateCoverageMatrix();
console.log('\n📊 Coverage Report:');
console.log(` Overall: ${coverage.coveragePercentage.toFixed(1)}%`);
console.log(` Rules covered: ${coverage.coveredRules}/${coverage.totalRules}`);
// Show category breakdown
console.log('\n By Category:');
for (const [category, data] of Object.entries(coverage.byCategory)) {
const pct = data.total > 0 ? ((data.covered / data.total) * 100).toFixed(1) : '0';
console.log(` - ${category}: ${data.covered}/${data.total} (${pct}%)`);
}
// Generate HTML report
await harness.generateHTMLReport();
}
}
@@ -0,0 +1,694 @@
import * as plugins from '../../plugins.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import { CurrencyCalculator, areMonetaryValuesEqual } from '../utils/currency.utils.js';
import { DecimalCurrencyCalculator } from '../utils/currency.calculator.decimal.js';
import { Decimal } from '../utils/decimal.js';
import type { ValidationResult, ValidationOptions } from './validation.types.js';
/**
* EN16931 Business Rules Validator
* Implements the full set of EN16931 business rules for invoice validation
*/
export class EN16931BusinessRulesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
private decimalCalculator?: DecimalCurrencyCalculator;
/**
* Validate an invoice against EN16931 business rules
*/
public validate(invoice: EInvoice, options: ValidationOptions = {}): ValidationResult[] {
this.results = [];
// Initialize currency calculators if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
this.decimalCalculator = new DecimalCurrencyCalculator(invoice.currency);
}
// Document level rules (BR-01 to BR-65)
this.validateDocumentRules(invoice);
// Calculation rules (BR-CO-*)
if (options.checkCalculations !== false) {
this.validateCalculationRules(invoice);
}
// VAT rules (BR-S-*, BR-Z-*, BR-E-*, BR-AE-*, BR-IC-*, BR-G-*, BR-O-*)
if (options.checkVAT !== false) {
this.validateVATRules(invoice);
}
// Line level rules (BR-21 to BR-30)
this.validateLineRules(invoice);
// Allowances and charges rules
if (options.checkAllowances !== false) {
this.validateAllowancesCharges(invoice);
}
return this.results;
}
/**
* Validate document level rules (BR-01 to BR-65)
*/
private validateDocumentRules(invoice: EInvoice): void {
// BR-01: An Invoice shall have a Specification identifier (BT-24)
if (!invoice.metadata?.customizationId) {
this.addError('BR-01', 'Invoice must have a Specification identifier (CustomizationID)', 'customizationId');
}
// BR-02: An Invoice shall have an Invoice number (BT-1)
if (!invoice.accountingDocId) {
this.addError('BR-02', 'Invoice must have an Invoice number', 'accountingDocId');
}
// BR-03: An Invoice shall have an Invoice issue date (BT-2)
if (!invoice.date) {
this.addError('BR-03', 'Invoice must have an issue date', 'date');
}
// BR-04: An Invoice shall have an Invoice type code (BT-3)
if (!invoice.accountingDocType) {
this.addError('BR-04', 'Invoice must have a type code', 'accountingDocType');
}
// BR-05: An Invoice shall have an Invoice currency code (BT-5)
if (!invoice.currency) {
this.addError('BR-05', 'Invoice must have a currency code', 'currency');
}
// BR-06: An Invoice shall contain the Seller name (BT-27)
if (!invoice.from?.name) {
this.addError('BR-06', 'Invoice must contain the Seller name', 'from.name');
}
// BR-07: An Invoice shall contain the Buyer name (BT-44)
if (!invoice.to?.name) {
this.addError('BR-07', 'Invoice must contain the Buyer name', 'to.name');
}
// BR-08: An Invoice shall contain the Seller postal address (BG-5)
if (!invoice.from?.address) {
this.addError('BR-08', 'Invoice must contain the Seller postal address', 'from.address');
}
// BR-09: The Seller postal address shall contain a Seller country code (BT-40)
if (!invoice.from?.address?.countryCode) {
this.addError('BR-09', 'Seller postal address must contain a country code', 'from.address.countryCode');
}
// BR-10: An Invoice shall contain the Buyer postal address (BG-8)
if (!invoice.to?.address) {
this.addError('BR-10', 'Invoice must contain the Buyer postal address', 'to.address');
}
// BR-11: The Buyer postal address shall contain a Buyer country code (BT-55)
if (!invoice.to?.address?.countryCode) {
this.addError('BR-11', 'Buyer postal address must contain a country code', 'to.address.countryCode');
}
// BR-16: An Invoice shall have at least one Invoice line (BG-25)
if (!invoice.items || invoice.items.length === 0) {
this.addError('BR-16', 'Invoice must have at least one invoice line', 'items');
}
}
/**
* Validate calculation rules (BR-CO-*)
*/
private validateCalculationRules(invoice: EInvoice): void {
if (!invoice.items || invoice.items.length === 0) return;
// Use decimal calculator for precise calculations
const useDecimal = this.decimalCalculator !== undefined;
// BR-CO-10: Sum of Invoice line net amount = Σ(Invoice line net amount)
const calculatedLineTotal = useDecimal
? this.calculateLineTotalDecimal(invoice.items)
: this.calculateLineTotal(invoice.items);
const declaredLineTotal = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedLineTotal, declaredLineTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedLineTotal as number, declaredLineTotal as number)
: Math.abs((calculatedLineTotal as number) - (declaredLineTotal as number)) < 0.01;
if (!isEqual) {
this.addError(
'BR-CO-10',
`Sum of line net amounts (${useDecimal ? (calculatedLineTotal as Decimal).toFixed(2) : (calculatedLineTotal as number).toFixed(2)}) does not match declared total (${useDecimal ? (declaredLineTotal as Decimal).toFixed(2) : (declaredLineTotal as number).toFixed(2)})`,
'totalNet',
useDecimal ? (declaredLineTotal as Decimal).toNumber() : declaredLineTotal as number,
useDecimal ? (calculatedLineTotal as Decimal).toNumber() : calculatedLineTotal as number
);
}
// BR-CO-11: Sum of allowances on document level
const documentAllowances = useDecimal
? this.calculateDocumentAllowancesDecimal(invoice)
: this.calculateDocumentAllowances(invoice);
// BR-CO-12: Sum of charges on document level
const documentCharges = useDecimal
? this.calculateDocumentChargesDecimal(invoice)
: this.calculateDocumentCharges(invoice);
// BR-CO-13: Invoice total without VAT = Σ(line) - allowances + charges
const expectedTaxExclusive = useDecimal
? (calculatedLineTotal as Decimal).subtract(documentAllowances).add(documentCharges)
: (calculatedLineTotal as number) - (documentAllowances as number) + (documentCharges as number);
const declaredTaxExclusive = useDecimal
? new Decimal(invoice.totalNet || 0)
: invoice.totalNet || 0;
const isTaxExclusiveEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxExclusive, declaredTaxExclusive)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedTaxExclusive as number, declaredTaxExclusive as number)
: Math.abs((expectedTaxExclusive as number) - (declaredTaxExclusive as number)) < 0.01;
if (!isTaxExclusiveEqual) {
this.addError(
'BR-CO-13',
`Tax exclusive amount (${useDecimal ? (declaredTaxExclusive as Decimal).toFixed(2) : (declaredTaxExclusive as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedTaxExclusive as Decimal).toFixed(2) : (expectedTaxExclusive as number).toFixed(2)})`,
'totalNet',
useDecimal ? (declaredTaxExclusive as Decimal).toNumber() : declaredTaxExclusive as number,
useDecimal ? (expectedTaxExclusive as Decimal).toNumber() : expectedTaxExclusive as number
);
}
// BR-CO-14: Invoice total VAT amount = Σ(VAT category tax amount)
const calculatedVAT = useDecimal
? this.calculateTotalVATDecimal(invoice)
: this.calculateTotalVAT(invoice);
const declaredVAT = useDecimal
? new Decimal(invoice.totalVat || 0)
: invoice.totalVat || 0;
const isVATEqual = useDecimal
? this.decimalCalculator!.areEqual(calculatedVAT, declaredVAT)
: this.currencyCalculator
? this.currencyCalculator.areEqual(calculatedVAT as number, declaredVAT as number)
: Math.abs((calculatedVAT as number) - (declaredVAT as number)) < 0.01;
if (!isVATEqual) {
this.addError(
'BR-CO-14',
`Total VAT (${useDecimal ? (declaredVAT as Decimal).toFixed(2) : (declaredVAT as number).toFixed(2)}) does not match calculation (${useDecimal ? (calculatedVAT as Decimal).toFixed(2) : (calculatedVAT as number).toFixed(2)})`,
'totalVat',
useDecimal ? (declaredVAT as Decimal).toNumber() : declaredVAT as number,
useDecimal ? (calculatedVAT as Decimal).toNumber() : calculatedVAT as number
);
}
// BR-CO-15: Invoice total with VAT = Invoice total without VAT + Invoice total VAT
const expectedGrossTotal = useDecimal
? (expectedTaxExclusive as Decimal).add(calculatedVAT)
: (expectedTaxExclusive as number) + (calculatedVAT as number);
const declaredGrossTotal = useDecimal
? new Decimal(invoice.totalGross || 0)
: invoice.totalGross || 0;
const isGrossEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedGrossTotal, declaredGrossTotal)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedGrossTotal as number, declaredGrossTotal as number)
: Math.abs((expectedGrossTotal as number) - (declaredGrossTotal as number)) < 0.01;
if (!isGrossEqual) {
this.addError(
'BR-CO-15',
`Gross total (${useDecimal ? (declaredGrossTotal as Decimal).toFixed(2) : (declaredGrossTotal as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedGrossTotal as Decimal).toFixed(2) : (expectedGrossTotal as number).toFixed(2)})`,
'totalGross',
useDecimal ? (declaredGrossTotal as Decimal).toNumber() : declaredGrossTotal as number,
useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal as number
);
}
// BR-CO-16: Amount due for payment = Invoice total with VAT - Paid amount
const paidAmount = useDecimal
? new Decimal(invoice.metadata?.paidAmount || 0)
: invoice.metadata?.paidAmount || 0;
const expectedDueAmount = useDecimal
? (expectedGrossTotal as Decimal).subtract(paidAmount)
: (expectedGrossTotal as number) - (paidAmount as number);
const declaredDueAmount = useDecimal
? new Decimal(invoice.metadata?.amountDue || (useDecimal ? (expectedGrossTotal as Decimal).toNumber() : expectedGrossTotal))
: invoice.metadata?.amountDue || expectedGrossTotal;
const isDueEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedDueAmount, declaredDueAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(expectedDueAmount as number, declaredDueAmount as number)
: Math.abs((expectedDueAmount as number) - (declaredDueAmount as number)) < 0.01;
if (!isDueEqual) {
this.addError(
'BR-CO-16',
`Amount due (${useDecimal ? (declaredDueAmount as Decimal).toFixed(2) : (declaredDueAmount as number).toFixed(2)}) does not match calculation (${useDecimal ? (expectedDueAmount as Decimal).toFixed(2) : (expectedDueAmount as number).toFixed(2)})`,
'amountDue',
useDecimal ? (declaredDueAmount as Decimal).toNumber() : declaredDueAmount as number,
useDecimal ? (expectedDueAmount as Decimal).toNumber() : expectedDueAmount as number
);
}
}
/**
* Validate VAT rules
*/
private validateVATRules(invoice: EInvoice): void {
const useDecimal = this.decimalCalculator !== undefined;
// Group items by VAT rate
const vatGroups = this.groupItemsByVAT(invoice.items || []);
// BR-S-01: An Invoice that contains an Invoice line where VAT category code is "Standard rated"
// shall contain in the VAT breakdown at least one VAT category code equal to "Standard rated"
const hasStandardRatedLine = invoice.items?.some(item =>
item.vatPercentage && item.vatPercentage > 0
);
if (hasStandardRatedLine) {
const hasStandardRatedBreakdown = invoice.taxBreakdown?.some(breakdown =>
breakdown.taxPercent && breakdown.taxPercent > 0
);
if (!hasStandardRatedBreakdown) {
this.addError(
'BR-S-01',
'Invoice with standard rated lines must have standard rated VAT breakdown',
'taxBreakdown'
);
}
}
// BR-S-02: VAT category taxable amount for standard rated
// BR-S-03: VAT category tax amount for standard rated
vatGroups.forEach((group, rate) => {
if (rate > 0) { // Standard rated
const expectedTaxableAmount = useDecimal
? group.reduce((sum, item) => {
const unitPrice = new Decimal(item.unitNetPrice);
const quantity = new Decimal(item.unitQuantity);
return sum.add(unitPrice.multiply(quantity));
}, Decimal.ZERO)
: group.reduce((sum, item) =>
sum + (item.unitNetPrice * item.unitQuantity), 0
);
const expectedTaxAmount = useDecimal
? this.decimalCalculator!.calculateVAT(expectedTaxableAmount, new Decimal(rate))
: (expectedTaxableAmount as number) * (rate / 100);
// Find corresponding breakdown
const breakdown = invoice.taxBreakdown?.find(b =>
Math.abs((b.taxPercent || 0) - rate) < 0.01
);
if (breakdown) {
const isTaxableEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxableAmount, breakdown.netAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.netAmount, expectedTaxableAmount as number)
: Math.abs(breakdown.netAmount - (expectedTaxableAmount as number)) < 0.01;
if (!isTaxableEqual) {
this.addError(
'BR-S-02',
`VAT taxable amount for ${rate}% incorrect`,
'taxBreakdown.netAmount',
breakdown.netAmount,
useDecimal ? (expectedTaxableAmount as Decimal).toNumber() : expectedTaxableAmount as number
);
}
const isTaxEqual = useDecimal
? this.decimalCalculator!.areEqual(expectedTaxAmount, breakdown.taxAmount)
: this.currencyCalculator
? this.currencyCalculator.areEqual(breakdown.taxAmount, expectedTaxAmount as number)
: Math.abs(breakdown.taxAmount - (expectedTaxAmount as number)) < 0.01;
if (!isTaxEqual) {
this.addError(
'BR-S-03',
`VAT tax amount for ${rate}% incorrect`,
'taxBreakdown.vatAmount',
breakdown.taxAmount,
useDecimal ? (expectedTaxAmount as Decimal).toNumber() : expectedTaxAmount as number
);
}
}
}
});
// BR-Z-01: Zero rated VAT rules
const hasZeroRatedLine = invoice.items?.some(item =>
item.vatPercentage === 0
);
if (hasZeroRatedLine) {
const hasZeroRatedBreakdown = invoice.taxBreakdown?.some(breakdown =>
breakdown.taxPercent === 0
);
if (!hasZeroRatedBreakdown) {
this.addError(
'BR-Z-01',
'Invoice with zero rated lines must have zero rated VAT breakdown',
'taxBreakdown'
);
}
}
}
/**
* Validate line level rules (BR-21 to BR-30)
*/
private validateLineRules(invoice: EInvoice): void {
invoice.items?.forEach((item, index) => {
// BR-21: Each Invoice line shall have an Invoice line identifier
if (!item.position && item.position !== 0) {
this.addError(
'BR-21',
`Invoice line ${index + 1} must have an identifier`,
`items[${index}].id`
);
}
// BR-22: Each Invoice line shall have an Item name
if (!item.name) {
this.addError(
'BR-22',
`Invoice line ${index + 1} must have an item name`,
`items[${index}].name`
);
}
// BR-23: An Invoice line shall have an Invoiced quantity
if (item.unitQuantity === undefined || item.unitQuantity === null) {
this.addError(
'BR-23',
`Invoice line ${index + 1} must have a quantity`,
`items[${index}].quantity`
);
}
// BR-24: An Invoice line shall have an Invoiced quantity unit of measure code
if (!item.unitType) {
this.addError(
'BR-24',
`Invoice line ${index + 1} must have a unit of measure code`,
`items[${index}].unitCode`
);
}
// BR-25: An Invoice line shall have an Invoice line net amount
const lineNetAmount = item.unitNetPrice * item.unitQuantity;
if (isNaN(lineNetAmount)) {
this.addError(
'BR-25',
`Invoice line ${index + 1} must have a valid net amount`,
`items[${index}]`
);
}
// BR-26: Each Invoice line shall have an Invoice line VAT category code
if (item.vatPercentage === undefined) {
this.addError(
'BR-26',
`Invoice line ${index + 1} must have a VAT category code`,
`items[${index}].vatPercentage`
);
}
// BR-27: Invoice line net price shall be present
if (item.unitNetPrice === undefined || item.unitNetPrice === null) {
this.addError(
'BR-27',
`Invoice line ${index + 1} must have a net price`,
`items[${index}].unitPrice`
);
}
// BR-28: Item price base quantity shall be greater than zero
const baseQuantity = 1; // Default to 1 as TAccountingDocItem doesn't have priceBaseQuantity
if (baseQuantity <= 0) {
this.addError(
'BR-28',
`Invoice line ${index + 1} price base quantity must be greater than zero`,
`items[${index}].metadata.priceBaseQuantity`,
baseQuantity,
'> 0'
);
}
});
}
/**
* Validate allowances and charges
*/
private validateAllowancesCharges(invoice: EInvoice): void {
// BR-31: Document level allowance shall have an amount
invoice.metadata?.allowances?.forEach((allowance: any, index: number) => {
if (!allowance.amount && allowance.amount !== 0) {
this.addError(
'BR-31',
`Document allowance ${index + 1} must have an amount`,
`metadata.allowances[${index}].amount`
);
}
// BR-32: Document level allowance shall have VAT category code
if (!allowance.vatCategoryCode) {
this.addError(
'BR-32',
`Document allowance ${index + 1} must have a VAT category code`,
`metadata.allowances[${index}].vatCategoryCode`
);
}
// BR-33: Document level allowance shall have a reason
if (!allowance.reason) {
this.addError(
'BR-33',
`Document allowance ${index + 1} must have a reason`,
`metadata.allowances[${index}].reason`
);
}
});
// BR-36: Document level charge shall have an amount
invoice.metadata?.charges?.forEach((charge: any, index: number) => {
if (!charge.amount && charge.amount !== 0) {
this.addError(
'BR-36',
`Document charge ${index + 1} must have an amount`,
`metadata.charges[${index}].amount`
);
}
// BR-37: Document level charge shall have VAT category code
if (!charge.vatCategoryCode) {
this.addError(
'BR-37',
`Document charge ${index + 1} must have a VAT category code`,
`metadata.charges[${index}].vatCategoryCode`
);
}
// BR-38: Document level charge shall have a reason
if (!charge.reason) {
this.addError(
'BR-38',
`Document charge ${index + 1} must have a reason`,
`metadata.charges[${index}].reason`
);
}
});
}
// Helper methods
private calculateLineTotal(items: TAccountingDocItem[]): number {
return items.reduce((sum, item) => {
const lineTotal = (item.unitNetPrice || 0) * (item.unitQuantity || 0);
const rounded = this.currencyCalculator
? this.currencyCalculator.round(lineTotal)
: lineTotal;
return sum + rounded;
}, 0);
}
/**
* Calculate line total using decimal arithmetic for precision
*/
private calculateLineTotalDecimal(items: TAccountingDocItem[]): Decimal {
let total = Decimal.ZERO;
for (const item of items) {
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineTotal = unitPrice.multiply(quantity);
total = total.add(this.decimalCalculator!.round(lineTotal));
}
return total;
}
/**
* Calculate document allowances using decimal arithmetic
*/
private calculateDocumentAllowancesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.allowances) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const allowance of invoice.metadata.allowances) {
const amount = new Decimal(allowance.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate document charges using decimal arithmetic
*/
private calculateDocumentChargesDecimal(invoice: EInvoice): Decimal {
if (!invoice.metadata?.charges) {
return Decimal.ZERO;
}
let total = Decimal.ZERO;
for (const charge of invoice.metadata.charges) {
const amount = new Decimal(charge.amount || 0);
total = total.add(this.decimalCalculator!.round(amount));
}
return total;
}
/**
* Calculate total VAT using decimal arithmetic
*/
private calculateTotalVATDecimal(invoice: EInvoice): Decimal {
let totalVAT = Decimal.ZERO;
// Group items by VAT rate
const vatGroups = new Map<string, Decimal>();
for (const item of invoice.items || []) {
const vatRate = item.vatPercentage || 0;
const rateKey = vatRate.toString();
const unitPrice = new Decimal(item.unitNetPrice || 0);
const quantity = new Decimal(item.unitQuantity || 0);
const lineNet = unitPrice.multiply(quantity);
if (vatGroups.has(rateKey)) {
vatGroups.set(rateKey, vatGroups.get(rateKey)!.add(lineNet));
} else {
vatGroups.set(rateKey, lineNet);
}
}
// Calculate VAT for each group
for (const [rateKey, baseAmount] of vatGroups) {
const rate = new Decimal(rateKey);
const vat = this.decimalCalculator!.calculateVAT(baseAmount, rate);
totalVAT = totalVAT.add(vat);
}
return totalVAT;
}
private calculateDocumentAllowances(invoice: EInvoice): number {
return invoice.metadata?.allowances?.reduce((sum: number, allowance: any) =>
sum + (allowance.amount || 0), 0
) || 0;
}
private calculateDocumentCharges(invoice: EInvoice): number {
return invoice.metadata?.charges?.reduce((sum: number, charge: any) =>
sum + (charge.amount || 0), 0
) || 0;
}
private calculateTotalVAT(invoice: EInvoice): number {
const vatGroups = this.groupItemsByVAT(invoice.items || []);
let totalVAT = 0;
vatGroups.forEach((items, rate) => {
const taxableAmount = items.reduce((sum, item) => {
const lineNet = item.unitNetPrice * item.unitQuantity;
return sum + (this.currencyCalculator ? this.currencyCalculator.round(lineNet) : lineNet);
}, 0);
const vatAmount = taxableAmount * (rate / 100);
const roundedVAT = this.currencyCalculator
? this.currencyCalculator.round(vatAmount)
: vatAmount;
totalVAT += roundedVAT;
});
return totalVAT;
}
private groupItemsByVAT(items: TAccountingDocItem[]): Map<number, TAccountingDocItem[]> {
const groups = new Map<number, TAccountingDocItem[]>();
items.forEach(item => {
const rate = item.vatPercentage || 0;
if (!groups.has(rate)) {
groups.set(rate, []);
}
groups.get(rate)!.push(item);
});
return groups;
}
private addError(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'error',
message,
field,
value,
expected
});
}
private addWarning(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'warning',
message,
field,
value,
expected
});
}
}
+20 -10
View File
@@ -27,11 +27,11 @@ export class EN16931Validator {
];
/**
* Validates that an invoice object contains all mandatory EN16931 fields
* Collects mandatory EN16931 field errors without throwing.
* @param invoice The invoice object to validate
* @throws Error if mandatory fields are missing
* @returns List of validation error messages
*/
public static validateMandatoryFields(invoice: any): void {
public static collectMandatoryFieldErrors(invoice: any): string[] {
const errors: string[] = [];
// BR-01: Invoice number is mandatory
@@ -49,7 +49,7 @@ export class EN16931Validator {
errors.push('BR-06: Seller name is mandatory');
}
// BR-07: Buyer name is mandatory
// BR-07: Buyer name is mandatory
if (!invoice.to?.name) {
errors.push('BR-07: Buyer name is mandatory');
}
@@ -67,11 +67,10 @@ export class EN16931Validator {
// BR-05: Invoice currency code is mandatory
if (!invoice.currency) {
errors.push('BR-05: Invoice currency code is mandatory');
} else {
// Validate currency format
if (!this.VALID_CURRENCIES.includes(invoice.currency.toUpperCase())) {
errors.push(`Invalid currency code: ${invoice.currency}. Must be a valid ISO 4217 currency code`);
}
} else if (!this.VALID_CURRENCIES.includes(invoice.currency.toUpperCase())) {
errors.push(
`BR-05: Invalid currency code: ${invoice.currency}. Must be a valid ISO 4217 currency code`
);
}
// BR-08: Seller postal address is mandatory
@@ -84,6 +83,17 @@ export class EN16931Validator {
errors.push('BR-10: Buyer postal address (city, postal code, country) is mandatory');
}
return errors;
}
/**
* Validates that an invoice object contains all mandatory EN16931 fields
* @param invoice The invoice object to validate
* @throws Error if mandatory fields are missing
*/
public static validateMandatoryFields(invoice: any): void {
const errors = this.collectMandatoryFieldErrors(invoice);
if (errors.length > 0) {
throw new Error(`EN16931 validation failed:\n${errors.join('\n')}`);
}
@@ -132,4 +142,4 @@ export class EN16931Validator {
throw new Error(`EN16931 XML validation failed:\n${errors.join('\n')}`);
}
}
}
}
+579
View File
@@ -0,0 +1,579 @@
/**
* Factur-X validator for profile-specific compliance
* Implements validation for MINIMUM, BASIC, EN16931, and EXTENDED profiles
*/
import type { ValidationResult } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* Factur-X Profile definitions
*/
export enum FacturXProfile {
MINIMUM = 'MINIMUM',
BASIC = 'BASIC',
BASIC_WL = 'BASIC_WL', // Basic without lines
EN16931 = 'EN16931',
EXTENDED = 'EXTENDED'
}
/**
* Field cardinality requirements per profile
*/
interface ProfileRequirements {
mandatory: string[];
optional: string[];
forbidden?: string[];
}
/**
* Factur-X Validator
* Validates invoices according to Factur-X profile specifications
*/
export class FacturXValidator {
private static instance: FacturXValidator;
/**
* Profile requirements mapping
*/
private profileRequirements: Record<FacturXProfile, ProfileRequirements> = {
[FacturXProfile.MINIMUM]: {
mandatory: [
'accountingDocId', // BT-1: Invoice number
'issueDate', // BT-2: Invoice issue date
'accountingDocType', // BT-3: Invoice type code
'currency', // BT-5: Invoice currency code
'from.name', // BT-27: Seller name
'from.vatNumber', // BT-31: Seller VAT identifier
'to.name', // BT-44: Buyer name
'totalInvoiceAmount', // BT-112: Invoice total amount with VAT
'totalNetAmount', // BT-109: Invoice total amount without VAT
'totalVatAmount', // BT-110: Invoice total VAT amount
],
optional: []
},
[FacturXProfile.BASIC]: {
mandatory: [
// All MINIMUM fields plus:
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'from.name',
'from.vatNumber',
'from.address', // BT-35: Seller postal address
'from.country', // BT-40: Seller country code
'to.name',
'to.address', // BT-50: Buyer postal address
'to.country', // BT-55: Buyer country code
'items', // BG-25: Invoice line items
'items[].name', // BT-153: Item name
'items[].unitQuantity', // BT-129: Invoiced quantity
'items[].unitNetPrice', // BT-146: Item net price
'items[].vatPercentage', // BT-152: Invoiced item VAT rate
'totalInvoiceAmount',
'totalNetAmount',
'totalVatAmount',
'dueDate', // BT-9: Payment due date
],
optional: [
'metadata.buyerReference', // BT-10: Buyer reference
'metadata.purchaseOrderReference', // BT-13: Purchase order reference
'metadata.salesOrderReference', // BT-14: Sales order reference
'metadata.contractReference', // BT-12: Contract reference
'projectReference', // BT-11: Project reference
]
},
[FacturXProfile.BASIC_WL]: {
// Basic without lines - for summary invoices
mandatory: [
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'from.name',
'from.vatNumber',
'from.address',
'from.country',
'to.name',
'to.address',
'to.country',
'totalInvoiceAmount',
'totalNetAmount',
'totalVatAmount',
'dueDate',
// No items required
],
optional: [
'metadata.buyerReference',
'metadata.purchaseOrderReference',
'metadata.contractReference',
]
},
[FacturXProfile.EN16931]: {
// Full EN16931 compliance - all mandatory fields from the standard
mandatory: [
// Document level
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'metadata.buyerReference',
// Seller information
'from.name',
'from.address',
'from.city',
'from.postalCode',
'from.country',
'from.vatNumber',
// Buyer information
'to.name',
'to.address',
'to.city',
'to.postalCode',
'to.country',
// Line items
'items',
'items[].name',
'items[].unitQuantity',
'items[].unitType',
'items[].unitNetPrice',
'items[].vatPercentage',
// Totals
'totalInvoiceAmount',
'totalNetAmount',
'totalVatAmount',
'dueDate',
],
optional: [
// All other EN16931 fields
'metadata.purchaseOrderReference',
'metadata.salesOrderReference',
'metadata.contractReference',
'metadata.deliveryDate',
'metadata.paymentTerms',
'metadata.paymentMeans',
'to.vatNumber',
'to.legalRegistration',
'items[].articleNumber',
'items[].description',
'paymentAccount',
]
},
[FacturXProfile.EXTENDED]: {
// Extended profile allows all fields
mandatory: [
// Same as EN16931 core
'accountingDocId',
'issueDate',
'accountingDocType',
'currency',
'from.name',
'from.vatNumber',
'to.name',
'totalInvoiceAmount',
],
optional: [
// All fields are allowed in EXTENDED profile
]
}
};
/**
* Singleton pattern for validator instance
*/
public static create(): FacturXValidator {
if (!FacturXValidator.instance) {
FacturXValidator.instance = new FacturXValidator();
}
return FacturXValidator.instance;
}
/**
* Main validation entry point for Factur-X
*/
public validateFacturX(invoice: EInvoice, profile?: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
// Detect profile if not provided
const detectedProfile = profile || this.detectProfile(invoice);
// Skip if not a Factur-X invoice
if (!detectedProfile) {
return results;
}
// Validate according to profile
results.push(...this.validateProfileRequirements(invoice, detectedProfile));
results.push(...this.validateProfileSpecificRules(invoice, detectedProfile));
// Add profile-specific business rules
if (detectedProfile === FacturXProfile.MINIMUM) {
results.push(...this.validateMinimumProfile(invoice));
} else if (detectedProfile === FacturXProfile.BASIC || detectedProfile === FacturXProfile.BASIC_WL) {
results.push(...this.validateBasicProfile(invoice, detectedProfile));
} else if (detectedProfile === FacturXProfile.EN16931) {
results.push(...this.validateEN16931Profile(invoice));
} else if (detectedProfile === FacturXProfile.EXTENDED) {
results.push(...this.validateExtendedProfile(invoice));
}
return results;
}
/**
* Detect Factur-X profile from invoice metadata
*/
public detectProfile(invoice: EInvoice): FacturXProfile | null {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const format = invoice.metadata?.format;
// Check if it's a Factur-X invoice
if (!format?.includes('facturx') && !profileId.includes('facturx') &&
!customizationId.includes('facturx') && !profileId.includes('zugferd')) {
return null;
}
// Detect specific profile
const profileLower = profileId.toLowerCase();
const customLower = customizationId.toLowerCase();
if (profileLower.includes('minimum') || customLower.includes('minimum')) {
return FacturXProfile.MINIMUM;
} else if (profileLower.includes('basic_wl') || customLower.includes('basicwl')) {
return FacturXProfile.BASIC_WL;
} else if (profileLower.includes('basic') || customLower.includes('basic')) {
return FacturXProfile.BASIC;
} else if (profileLower.includes('en16931') || customLower.includes('en16931') ||
profileLower.includes('comfort') || customLower.includes('comfort')) {
return FacturXProfile.EN16931;
} else if (profileLower.includes('extended') || customLower.includes('extended')) {
return FacturXProfile.EXTENDED;
}
// Default to BASIC if format is Factur-X but profile unclear
return FacturXProfile.BASIC;
}
/**
* Validate field requirements for a specific profile
*/
private validateProfileRequirements(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
const requirements = this.profileRequirements[profile];
// Check mandatory fields
for (const field of requirements.mandatory) {
const value = this.getFieldValue(invoice, field);
if (value === undefined || value === null || value === '') {
results.push({
ruleId: `FX-${profile}-M01`,
severity: 'error',
message: `Field '${field}' is mandatory for Factur-X ${profile} profile`,
field: field,
source: 'FACTURX'
});
}
}
// Check forbidden fields (if any)
if (requirements.forbidden) {
for (const field of requirements.forbidden) {
const value = this.getFieldValue(invoice, field);
if (value !== undefined && value !== null) {
results.push({
ruleId: `FX-${profile}-F01`,
severity: 'error',
message: `Field '${field}' is not allowed in Factur-X ${profile} profile`,
field: field,
value: value,
source: 'FACTURX'
});
}
}
}
return results;
}
/**
* Get field value from invoice using dot notation
*/
private getFieldValue(invoice: any, fieldPath: string): any {
// Handle special calculated fields
if (fieldPath === 'totalInvoiceAmount') {
return invoice.totalGross || invoice.totalInvoiceAmount;
}
if (fieldPath === 'totalNetAmount') {
return invoice.totalNet || invoice.totalNetAmount;
}
if (fieldPath === 'totalVatAmount') {
return invoice.totalVat || invoice.totalVatAmount;
}
if (fieldPath === 'dueDate') {
// Check for dueInDays which is used in EInvoice
if (invoice.dueInDays !== undefined && invoice.dueInDays !== null) {
return true; // Has payment terms
}
return invoice.dueDate;
}
const parts = fieldPath.split('.');
let value = invoice;
for (const part of parts) {
if (part.includes('[')) {
// Array field like items[]
const fieldName = part.substring(0, part.indexOf('['));
const arrayField = part.substring(part.indexOf('[') + 1, part.indexOf(']'));
if (!value[fieldName] || !Array.isArray(value[fieldName])) {
return undefined;
}
if (arrayField === '') {
// Check if array exists and has items
return value[fieldName].length > 0 ? value[fieldName] : undefined;
} else {
// Check specific field in array items
return value[fieldName].every((item: any) => item[arrayField] !== undefined);
}
} else {
value = value?.[part];
}
}
return value;
}
/**
* Profile-specific validation rules
*/
private validateProfileSpecificRules(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
// Validate according to profile level
switch (profile) {
case FacturXProfile.MINIMUM:
// MINIMUM requires at least gross amounts
// Check both calculated totals and direct properties (for test compatibility)
const totalGross = invoice.totalGross || (invoice as any).totalInvoiceAmount;
if (!totalGross || totalGross <= 0) {
results.push({
ruleId: 'FX-MIN-01',
severity: 'error',
message: 'MINIMUM profile requires positive total invoice amount',
field: 'totalInvoiceAmount',
value: totalGross,
source: 'FACTURX'
});
}
break;
case FacturXProfile.BASIC:
case FacturXProfile.BASIC_WL:
// BASIC requires VAT breakdown
const totalVat = invoice.totalVat;
if (!invoice.metadata?.extensions?.taxDetails && totalVat > 0) {
results.push({
ruleId: 'FX-BAS-01',
severity: 'warning',
message: 'BASIC profile should include VAT breakdown when VAT is present',
field: 'metadata.extensions.taxDetails',
source: 'FACTURX'
});
}
break;
case FacturXProfile.EN16931:
// EN16931 requires full compliance - additional checks handled by EN16931 validator
if (!invoice.metadata?.buyerReference && !invoice.metadata?.extensions?.purchaseOrderReference) {
results.push({
ruleId: 'FX-EN-01',
severity: 'error',
message: 'EN16931 profile requires either buyer reference or purchase order reference',
field: 'metadata.buyerReference',
source: 'FACTURX'
});
}
break;
}
return results;
}
/**
* Validate MINIMUM profile specific rules
*/
private validateMinimumProfile(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// MINIMUM profile allows only essential fields
// Check that complex structures are not present
if (invoice.items && invoice.items.length > 0) {
// Lines are optional but if present must be minimal
invoice.items.forEach((item, index) => {
if ((item as any).allowances || (item as any).charges) {
results.push({
ruleId: 'FX-MIN-02',
severity: 'warning',
message: `Line ${index + 1}: MINIMUM profile should not include line allowances/charges`,
field: `items[${index}]`,
source: 'FACTURX'
});
}
});
}
return results;
}
/**
* Validate BASIC profile specific rules
*/
private validateBasicProfile(invoice: EInvoice, profile: FacturXProfile): ValidationResult[] {
const results: ValidationResult[] = [];
// BASIC requires line items (except BASIC_WL)
// Only check for line items in BASIC profile, not BASIC_WL
if (profile === FacturXProfile.BASIC) {
if (!invoice.items || invoice.items.length === 0) {
results.push({
ruleId: 'FX-BAS-02',
severity: 'error',
message: 'BASIC profile requires at least one invoice line item',
field: 'items',
source: 'FACTURX'
});
}
}
// Payment information should be present
if (!invoice.dueInDays && invoice.dueInDays !== 0) {
results.push({
ruleId: 'FX-BAS-03',
severity: 'warning',
message: 'BASIC profile should include payment terms (due in days)',
field: 'dueInDays',
source: 'FACTURX'
});
}
return results;
}
/**
* Validate EN16931 profile specific rules
*/
private validateEN16931Profile(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// EN16931 requires complete address information
const fromAny = invoice.from as any;
const toAny = invoice.to as any;
if (!fromAny?.city || !fromAny?.postalCode) {
results.push({
ruleId: 'FX-EN-02',
severity: 'error',
message: 'EN16931 profile requires complete seller address including city and postal code',
field: 'from.address',
source: 'FACTURX'
});
}
if (!toAny?.city || !toAny?.postalCode) {
results.push({
ruleId: 'FX-EN-03',
severity: 'error',
message: 'EN16931 profile requires complete buyer address including city and postal code',
field: 'to.address',
source: 'FACTURX'
});
}
// Line items must have unit type
if (invoice.items) {
invoice.items.forEach((item, index) => {
if (!item.unitType) {
results.push({
ruleId: 'FX-EN-04',
severity: 'error',
message: `Line ${index + 1}: EN16931 profile requires unit of measure`,
field: `items[${index}].unitType`,
source: 'FACTURX'
});
}
});
}
return results;
}
/**
* Validate EXTENDED profile specific rules
*/
private validateExtendedProfile(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// EXTENDED profile is most permissive - mainly check for data consistency
if (invoice.metadata?.extensions) {
// Extended profile can include additional structured data
// Validate that extended data is well-formed
const extensions = invoice.metadata.extensions;
if (extensions.attachments && Array.isArray(extensions.attachments)) {
extensions.attachments.forEach((attachment: any, index: number) => {
if (!attachment.filename || !attachment.mimeType) {
results.push({
ruleId: 'FX-EXT-01',
severity: 'warning',
message: `Attachment ${index + 1}: Should include filename and MIME type`,
field: `metadata.extensions.attachments[${index}]`,
source: 'FACTURX'
});
}
});
}
}
return results;
}
/**
* Get profile display name
*/
public getProfileDisplayName(profile: FacturXProfile): string {
const names: Record<FacturXProfile, string> = {
[FacturXProfile.MINIMUM]: 'Factur-X MINIMUM',
[FacturXProfile.BASIC]: 'Factur-X BASIC',
[FacturXProfile.BASIC_WL]: 'Factur-X BASIC WL',
[FacturXProfile.EN16931]: 'Factur-X EN16931',
[FacturXProfile.EXTENDED]: 'Factur-X EXTENDED'
};
return names[profile];
}
/**
* Get profile compliance level (for reporting)
*/
public getProfileComplianceLevel(profile: FacturXProfile): number {
const levels: Record<FacturXProfile, number> = {
[FacturXProfile.MINIMUM]: 1,
[FacturXProfile.BASIC_WL]: 2,
[FacturXProfile.BASIC]: 3,
[FacturXProfile.EN16931]: 4,
[FacturXProfile.EXTENDED]: 5
};
return levels[profile];
}
}
@@ -0,0 +1,407 @@
/**
* Main integrated validator combining all validation capabilities
* Orchestrates TypeScript validators, Schematron, and profile-specific rules
*/
import { IntegratedValidator } from './schematron.integration.js';
import { XRechnungValidator } from './xrechnung.validator.js';
import { PeppolValidator } from './peppol.validator.js';
import { FacturXValidator } from './facturx.validator.js';
import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js';
import { CodeListValidator } from './codelist.validator.js';
import type { ValidationResult, ValidationOptions, ValidationReport } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* Main validator that combines all validation capabilities
*/
export class MainValidator {
private integratedValidator: IntegratedValidator;
private xrechnungValidator: XRechnungValidator;
private peppolValidator: PeppolValidator;
private facturxValidator: FacturXValidator;
private businessRulesValidator: EN16931BusinessRulesValidator;
private codeListValidator: CodeListValidator;
private schematronEnabled: boolean = false;
constructor() {
this.integratedValidator = new IntegratedValidator();
this.xrechnungValidator = XRechnungValidator.create();
this.peppolValidator = PeppolValidator.create();
this.facturxValidator = FacturXValidator.create();
this.businessRulesValidator = new EN16931BusinessRulesValidator();
this.codeListValidator = new CodeListValidator();
}
/**
* Initialize Schematron validation for better coverage
*/
public async initializeSchematron(
profile?: 'EN16931' | 'PEPPOL' | 'XRECHNUNG'
): Promise<void> {
try {
// Check available Schematron files
const available = await this.integratedValidator.getAvailableSchematron();
if (available.length === 0) {
console.warn('No Schematron files available. Run: npm run download-schematron');
return;
}
// Load appropriate Schematron based on profile
const standard = profile || 'EN16931';
const format = 'UBL'; // Default to UBL, can be made configurable
await this.integratedValidator.loadSchematron(
standard === 'XRECHNUNG' ? 'EN16931' : standard, // XRechnung uses EN16931 as base
format
);
this.schematronEnabled = true;
console.log(`Schematron validation enabled for ${standard} ${format}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to initialize Schematron: ${errorMessage}`);
}
}
/**
* Validate an invoice with all available validators
*/
public async validate(
invoice: EInvoice,
xmlContent?: string,
options: ValidationOptions = {}
): Promise<ValidationReport> {
const startTime = Date.now();
const results: ValidationResult[] = [];
// Detect profile from invoice
const profile = this.detectProfile(invoice);
const mergedOptions: ValidationOptions = {
...options,
profile: profile as ValidationOptions['profile']
};
// Run base validators
if (options.checkCodeLists !== false) {
results.push(...this.codeListValidator.validate(invoice));
}
results.push(...this.businessRulesValidator.validate(invoice, mergedOptions));
// Run XRechnung-specific validation if applicable
if (this.isXRechnungInvoice(invoice)) {
const xrResults = this.xrechnungValidator.validateXRechnung(invoice);
results.push(...xrResults);
}
// Run PEPPOL-specific validation if applicable
if (this.isPeppolInvoice(invoice)) {
const peppolResults = this.peppolValidator.validatePeppol(invoice);
results.push(...peppolResults);
}
// Run Factur-X specific validation if applicable
if (this.isFacturXInvoice(invoice)) {
const facturxResults = this.facturxValidator.validateFacturX(invoice);
results.push(...facturxResults);
}
// Run Schematron validation if available and XML is provided
if (this.schematronEnabled && xmlContent) {
try {
const schematronReport = await this.integratedValidator.validate(
invoice,
xmlContent,
mergedOptions
);
// Extract only Schematron-specific results to avoid duplication
const schematronResults = schematronReport.results.filter(
r => r.source === 'SCHEMATRON'
);
results.push(...schematronResults);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron validation error: ${errorMessage}`);
}
}
// Remove duplicates (same rule + same field)
const uniqueResults = this.deduplicateResults(results);
// Calculate statistics
const errorCount = uniqueResults.filter(r => r.severity === 'error').length;
const warningCount = uniqueResults.filter(r => r.severity === 'warning').length;
const infoCount = uniqueResults.filter(r => r.severity === 'info').length;
// Estimate coverage
const totalRules = this.estimateTotalRules(profile);
const rulesChecked = new Set(uniqueResults.map(r => r.ruleId)).size;
const coverage = totalRules > 0 ? (rulesChecked / totalRules) * 100 : 0;
return {
valid: errorCount === 0,
profile: profile || 'EN16931',
timestamp: new Date().toISOString(),
validatorVersion: '2.0.0',
rulesetVersion: '1.3.14',
results: uniqueResults,
errorCount,
warningCount,
infoCount,
rulesChecked,
rulesTotal: totalRules,
coverage,
validationTime: Date.now() - startTime,
documentId: invoice.accountingDocId,
documentType: invoice.accountingDocType,
format: this.detectFormat(xmlContent)
} as ValidationReport & { schematronEnabled: boolean };
}
/**
* Detect profile from invoice metadata
*/
private detectProfile(invoice: EInvoice): string {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
if (profileId.includes('xrechnung') || customizationId.includes('xrechnung')) {
return 'XRECHNUNG_3.0';
}
if (profileId.includes('peppol') || customizationId.includes('peppol') ||
profileId.includes('urn:fdc:peppol.eu')) {
return 'PEPPOL_BIS_3.0';
}
if (profileId.includes('facturx') || customizationId.includes('facturx') ||
profileId.includes('zugferd')) {
// Try to detect specific Factur-X profile
const facturxProfile = this.facturxValidator.detectProfile(invoice);
if (facturxProfile) {
return `FACTURX_${facturxProfile}`;
}
return 'FACTURX_EN16931';
}
return 'EN16931';
}
/**
* Check if invoice is XRechnung
*/
private isXRechnungInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const xrechnungProfiles = [
'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung',
'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung',
'xrechnung'
];
return xrechnungProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Check if invoice is PEPPOL
*/
private isPeppolInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const peppolProfiles = [
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'peppol-bis-3',
'peppol'
];
return peppolProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Check if invoice is Factur-X
*/
private isFacturXInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const format = invoice.metadata?.format;
return format?.includes('facturx') ||
profileId.toLowerCase().includes('facturx') ||
customizationId.toLowerCase().includes('facturx') ||
profileId.toLowerCase().includes('zugferd') ||
customizationId.toLowerCase().includes('zugferd');
}
/**
* Detect format from XML content
*/
private detectFormat(xmlContent?: string): 'UBL' | 'CII' | undefined {
if (!xmlContent) return undefined;
if (xmlContent.includes('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2')) {
return 'UBL';
} else if (xmlContent.includes('urn:un:unece:uncefact:data:standard:CrossIndustryInvoice')) {
return 'CII';
}
return undefined;
}
/**
* Remove duplicate validation results
*/
private deduplicateResults(results: ValidationResult[]): ValidationResult[] {
const seen = new Set<string>();
const unique: ValidationResult[] = [];
for (const result of results) {
const key = `${result.ruleId}|${result.field || ''}|${result.message}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(result);
}
}
return unique;
}
/**
* Estimate total rules for coverage calculation
*/
private estimateTotalRules(profile?: string): number {
const ruleCounts: Record<string, number> = {
EN16931: 150,
'PEPPOL_BIS_3.0': 250,
'XRECHNUNG_3.0': 280,
FACTURX_BASIC: 100,
FACTURX_EN16931: 150
};
return ruleCounts[profile || 'EN16931'] || 150;
}
/**
* Validate with automatic format and profile detection
*/
public async validateAuto(
invoice: EInvoice,
xmlContent?: string
): Promise<ValidationReport> {
// Auto-detect profile
const profile = this.detectProfile(invoice);
// Initialize Schematron if not already done
if (!this.schematronEnabled && xmlContent) {
await this.initializeSchematron(
profile.startsWith('XRECHNUNG') ? 'XRECHNUNG' :
profile.startsWith('PEPPOL') ? 'PEPPOL' : 'EN16931'
);
}
return this.validate(invoice, xmlContent, {
checkCalculations: true,
checkVAT: true,
checkCodeLists: true,
strictMode: profile.includes('XRECHNUNG') // Strict for XRechnung
});
}
/**
* Get validation capabilities
*/
public getCapabilities(): {
schematron: boolean;
xrechnung: boolean;
peppol: boolean;
facturx: boolean;
calculations: boolean;
codeLists: boolean;
} {
return {
schematron: this.schematronEnabled,
xrechnung: true,
peppol: true,
facturx: true,
calculations: true,
codeLists: true
};
}
/**
* Format validation report as text
*/
public formatReport(report: ValidationReport): string {
const lines: string[] = [];
lines.push('=== Validation Report ===');
lines.push(`Profile: ${report.profile}`);
lines.push(`Valid: ${report.valid ? '✅' : '❌'}`);
lines.push(`Timestamp: ${report.timestamp}`);
lines.push('');
if (report.errorCount > 0) {
lines.push(`Errors: ${report.errorCount}`);
report.results
.filter(r => r.severity === 'error')
.forEach(r => {
lines.push(` ❌ [${r.ruleId}] ${r.message}`);
if (r.field) lines.push(` Field: ${r.field}`);
});
lines.push('');
}
if (report.warningCount > 0) {
lines.push(`Warnings: ${report.warningCount}`);
report.results
.filter(r => r.severity === 'warning')
.forEach(r => {
lines.push(` ⚠️ [${r.ruleId}] ${r.message}`);
if (r.field) lines.push(` Field: ${r.field}`);
});
lines.push('');
}
lines.push('Statistics:');
lines.push(` Rules checked: ${report.rulesChecked}/${report.rulesTotal}`);
lines.push(` Coverage: ${report.coverage.toFixed(1)}%`);
lines.push(` Validation time: ${report.validationTime}ms`);
if ((report as any).schematronEnabled) {
lines.push(' Schematron: ✅ Enabled');
}
return lines.join('\n');
}
}
/**
* Create a pre-configured validator instance
*/
export async function createValidator(
options: {
profile?: 'EN16931' | 'PEPPOL' | 'XRECHNUNG';
enableSchematron?: boolean;
} = {}
): Promise<MainValidator> {
const validator = new MainValidator();
if (options.enableSchematron !== false) {
await validator.initializeSchematron(options.profile);
}
return validator;
}
// Export for convenience
export type { ValidationReport, ValidationResult, ValidationOptions } from './validation.types.js';
+589
View File
@@ -0,0 +1,589 @@
/**
* PEPPOL BIS 3.0 validator for compliance with PEPPOL e-invoice specifications
* Implements PEPPOL-specific validation rules on top of EN16931
*/
import type { ValidationResult } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
/**
* PEPPOL BIS 3.0 Validator
* Implements PEPPOL-specific validation rules and constraints
*/
export class PeppolValidator {
private static instance: PeppolValidator;
/**
* Singleton pattern for validator instance
*/
public static create(): PeppolValidator {
if (!PeppolValidator.instance) {
PeppolValidator.instance = new PeppolValidator();
}
return PeppolValidator.instance;
}
/**
* Main validation entry point for PEPPOL
*/
public validatePeppol(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if this is a PEPPOL invoice
if (!this.isPeppolInvoice(invoice)) {
return results; // Not a PEPPOL invoice, skip validation
}
// Run all PEPPOL validations
results.push(...this.validateEndpointId(invoice));
results.push(...this.validateDocumentTypeId(invoice));
results.push(...this.validateProcessId(invoice));
results.push(...this.validatePartyIdentification(invoice));
results.push(...this.validatePeppolBusinessRules(invoice));
results.push(...this.validateSchemeIds(invoice));
results.push(...this.validateTransportProtocol(invoice));
return results;
}
/**
* Check if invoice is PEPPOL
*/
private isPeppolInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
const peppolProfiles = [
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'peppol-bis-3',
'peppol'
];
return peppolProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Validate Endpoint ID format (0088:xxxxxxxxx or other schemes)
* PEPPOL-T001, PEPPOL-T002
*/
private validateEndpointId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check seller endpoint ID
const sellerEndpointId = invoice.metadata?.extensions?.sellerEndpointId ||
invoice.metadata?.extensions?.peppolSellerEndpoint;
if (sellerEndpointId) {
if (!this.isValidEndpointId(sellerEndpointId)) {
results.push({
ruleId: 'PEPPOL-T001',
severity: 'error',
message: 'Invalid seller endpoint ID format. Expected format: scheme:identifier (e.g., 0088:1234567890128)',
field: 'metadata.extensions.sellerEndpointId',
value: sellerEndpointId,
source: 'PEPPOL'
});
}
} else if (this.isPeppolB2G(invoice)) {
// Endpoint ID is mandatory for B2G
results.push({
ruleId: 'PEPPOL-T001',
severity: 'error',
message: 'Seller endpoint ID is mandatory for PEPPOL B2G invoices',
field: 'metadata.extensions.sellerEndpointId',
source: 'PEPPOL'
});
}
// Check buyer endpoint ID
const buyerEndpointId = invoice.metadata?.extensions?.buyerEndpointId ||
invoice.metadata?.extensions?.peppolBuyerEndpoint;
if (buyerEndpointId) {
if (!this.isValidEndpointId(buyerEndpointId)) {
results.push({
ruleId: 'PEPPOL-T002',
severity: 'error',
message: 'Invalid buyer endpoint ID format. Expected format: scheme:identifier (e.g., 0088:1234567890128)',
field: 'metadata.extensions.buyerEndpointId',
value: buyerEndpointId,
source: 'PEPPOL'
});
}
} else if (this.isPeppolB2G(invoice)) {
// Endpoint ID is mandatory for B2G
results.push({
ruleId: 'PEPPOL-T002',
severity: 'error',
message: 'Buyer endpoint ID is mandatory for PEPPOL B2G invoices',
field: 'metadata.extensions.buyerEndpointId',
source: 'PEPPOL'
});
}
return results;
}
/**
* Validate endpoint ID format
*/
private isValidEndpointId(endpointId: string): boolean {
// PEPPOL endpoint ID format: scheme:identifier
// Common schemes: 0088 (GLN), 0192 (Norwegian org), 9906 (IT VAT), etc.
const endpointPattern = /^[0-9]{4}:[A-Za-z0-9\-._]+$/;
// Special validation for GLN (0088)
if (endpointId.startsWith('0088:')) {
const gln = endpointId.substring(5);
// GLN should be 13 digits
if (!/^\d{13}$/.test(gln)) {
return false;
}
// Validate GLN check digit
return this.validateGLNCheckDigit(gln);
}
return endpointPattern.test(endpointId);
}
/**
* Validate GLN check digit using modulo 10
*/
private validateGLNCheckDigit(gln: string): boolean {
if (gln.length !== 13) return false;
let sum = 0;
for (let i = 0; i < 12; i++) {
const digit = parseInt(gln[i], 10);
sum += digit * (i % 2 === 0 ? 1 : 3);
}
const checkDigit = (10 - (sum % 10)) % 10;
return checkDigit === parseInt(gln[12], 10);
}
/**
* Validate Document Type ID
* PEPPOL-T003
*/
private validateDocumentTypeId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const documentTypeId = invoice.metadata?.extensions?.documentTypeId ||
invoice.metadata?.extensions?.peppolDocumentType;
if (!documentTypeId && this.isPeppolB2G(invoice)) {
results.push({
ruleId: 'PEPPOL-T003',
severity: 'error',
message: 'Document type ID is mandatory for PEPPOL invoices',
field: 'metadata.extensions.documentTypeId',
source: 'PEPPOL'
});
} else if (documentTypeId) {
// Validate against known PEPPOL document types
const validDocumentTypes = [
'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1',
'urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2::CreditNote##urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1',
// Add more valid document types as needed
];
if (!validDocumentTypes.some(type => documentTypeId.includes(type))) {
results.push({
ruleId: 'PEPPOL-T003',
severity: 'warning',
message: 'Document type ID may not be a valid PEPPOL document type',
field: 'metadata.extensions.documentTypeId',
value: documentTypeId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate Process ID
* PEPPOL-T004
*/
private validateProcessId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const processId = invoice.metadata?.extensions?.processId ||
invoice.metadata?.extensions?.peppolProcessId;
if (!processId && this.isPeppolB2G(invoice)) {
results.push({
ruleId: 'PEPPOL-T004',
severity: 'error',
message: 'Process ID is mandatory for PEPPOL invoices',
field: 'metadata.extensions.processId',
source: 'PEPPOL'
});
} else if (processId) {
// Validate against known PEPPOL processes
const validProcessIds = [
'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0',
'urn:fdc:peppol.eu:2017:poacc:billing:3.0',
// Legacy process IDs
'urn:www.cenbii.eu:profile:bii05:ver2.0',
'urn:www.cenbii.eu:profile:bii04:ver2.0'
];
if (!validProcessIds.includes(processId)) {
results.push({
ruleId: 'PEPPOL-T004',
severity: 'warning',
message: 'Process ID may not be a valid PEPPOL process',
field: 'metadata.extensions.processId',
value: processId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate Party Identification Schemes
* PEPPOL-T005, PEPPOL-T006
*/
private validatePartyIdentification(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Validate seller party identification
if (invoice.from?.type === 'company') {
const company = invoice.from as any;
const partyId = company.registrationDetails?.peppolPartyId ||
company.registrationDetails?.partyIdentification;
if (partyId && partyId.schemeId) {
if (!this.isValidSchemeId(partyId.schemeId)) {
results.push({
ruleId: 'PEPPOL-T005',
severity: 'warning',
message: 'Seller party identification scheme may not be valid',
field: 'from.registrationDetails.partyIdentification.schemeId',
value: partyId.schemeId,
source: 'PEPPOL'
});
}
}
}
// Validate buyer party identification
const buyerPartyId = invoice.metadata?.extensions?.buyerPartyId;
if (buyerPartyId && buyerPartyId.schemeId) {
if (!this.isValidSchemeId(buyerPartyId.schemeId)) {
results.push({
ruleId: 'PEPPOL-T006',
severity: 'warning',
message: 'Buyer party identification scheme may not be valid',
field: 'metadata.extensions.buyerPartyId.schemeId',
value: buyerPartyId.schemeId,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate scheme IDs against PEPPOL code list
*/
private isValidSchemeId(schemeId: string): boolean {
// PEPPOL Party Identifier Scheme (subset of ISO 6523 ICD list)
const validSchemes = [
'0002', // System Information et Repertoire des Entreprise et des Etablissements (SIRENE)
'0007', // Organisationsnummer (Swedish legal entities)
'0009', // SIRET
'0037', // LY-tunnus (Finnish business ID)
'0060', // DUNS number
'0088', // EAN Location Code (GLN)
'0096', // VIOC (Danish CVR)
'0097', // Danish Ministry of the Interior and Health
'0106', // Netherlands Chamber of Commerce
'0130', // Direktoratet for forvaltning og IKT (DIFI)
'0135', // IT:SIA
'0142', // IT:SECETI
'0184', // Danish CVR
'0190', // Dutch Originator's Identification Number
'0191', // Centre of Registers and Information Systems of the Ministry of Justice (Estonia)
'0192', // Norwegian Legal Entity
'0193', // UBL.BE party identifier
'0195', // Singapore UEN
'0196', // Kennitala (Iceland)
'0198', // ERSTORG
'0199', // Legal Entity Identifier (LEI)
'0200', // Legal entity code (Lithuania)
'0201', // CODICE UNIVOCO UNITÀ ORGANIZZATIVA
'0204', // German Leitweg-ID
'0208', // Belgian enterprise number
'0209', // GS1 identification keys
'0210', // CODICE FISCALE
'0211', // PARTITA IVA
'0212', // Finnish Organization Number
'0213', // Finnish VAT number
'9901', // Danish CVR
'9902', // Danish SE
'9904', // German VAT number
'9905', // German Leitweg ID
'9906', // IT:VAT
'9907', // IT:CF
'9910', // HU:VAT
'9914', // AT:VAT
'9915', // AT:GOV
'9917', // Netherlands OIN
'9918', // IS:KT
'9919', // IS company code
'9920', // ES:VAT
'9922', // AD:VAT
'9923', // AL:VAT
'9924', // BA:VAT
'9925', // BE:VAT
'9926', // BG:VAT
'9927', // CH:VAT
'9928', // CY:VAT
'9929', // CZ:VAT
'9930', // DE:VAT
'9931', // EE:VAT
'9932', // GB:VAT
'9933', // GR:VAT
'9934', // HR:VAT
'9935', // IE:VAT
'9936', // LI:VAT
'9937', // LT:VAT
'9938', // LU:VAT
'9939', // LV:VAT
'9940', // MC:VAT
'9941', // ME:VAT
'9942', // MK:VAT
'9943', // MT:VAT
'9944', // NL:VAT
'9945', // PL:VAT
'9946', // PT:VAT
'9947', // RO:VAT
'9948', // RS:VAT
'9949', // SI:VAT
'9950', // SK:VAT
'9951', // SM:VAT
'9952', // TR:VAT
'9953', // VA:VAT
'9955', // SE:VAT
'9956', // BE:CBE
'9957', // FR:VAT
'9958', // German Leitweg ID
];
return validSchemes.includes(schemeId);
}
/**
* Validate PEPPOL-specific business rules
*/
private validatePeppolBusinessRules(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// PEPPOL-B-01: Invoice must have a buyer reference or purchase order reference
const purchaseOrderRef = invoice.metadata?.extensions?.purchaseOrderReference;
if (!invoice.metadata?.buyerReference && !purchaseOrderRef) {
results.push({
ruleId: 'PEPPOL-B-01',
severity: 'error',
message: 'Invoice must have either a buyer reference (BT-10) or purchase order reference (BT-13)',
field: 'metadata.buyerReference',
source: 'PEPPOL'
});
}
// PEPPOL-B-02: Seller electronic address is mandatory
const sellerEmail = invoice.from?.type === 'company' ?
(invoice.from as any).contact?.email :
(invoice.from as any)?.email;
if (!sellerEmail) {
results.push({
ruleId: 'PEPPOL-B-02',
severity: 'warning',
message: 'Seller electronic address (email) is recommended for PEPPOL invoices',
field: 'from.contact.email',
source: 'PEPPOL'
});
}
// PEPPOL-B-03: Item standard identifier
if (invoice.items && invoice.items.length > 0) {
invoice.items.forEach((item, index) => {
const itemId = (item as any).standardItemIdentification;
if (!itemId) {
results.push({
ruleId: 'PEPPOL-B-03',
severity: 'info',
message: `Item ${index + 1} should have a standard item identification (GTIN, EAN, etc.)`,
field: `items[${index}].standardItemIdentification`,
source: 'PEPPOL'
});
} else if (itemId.schemeId === '0160' && !this.isValidGTIN(itemId.id)) {
// Validate GTIN if scheme is 0160
results.push({
ruleId: 'PEPPOL-B-03',
severity: 'error',
message: `Item ${index + 1} has invalid GTIN`,
field: `items[${index}].standardItemIdentification.id`,
value: itemId.id,
source: 'PEPPOL'
});
}
});
}
// PEPPOL-B-04: Payment means code must be from UNCL4461
const paymentMeansCode = invoice.metadata?.extensions?.paymentMeans?.paymentMeansCode;
if (paymentMeansCode) {
const validPaymentMeans = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '10',
'11', '12', '13', '14', '15', '16', '17', '18', '19', '20',
'21', '22', '23', '24', '25', '26', '27', '28', '29', '30',
'31', '32', '33', '34', '35', '36', '37', '38', '39', '40',
'41', '42', '43', '44', '45', '46', '47', '48', '49', '50',
'51', '52', '53', '54', '55', '56', '57', '58', '59', '60',
'61', '62', '63', '64', '65', '66', '67', '68', '70', '74',
'75', '76', '77', '78', '91', '92', '93', '94', '95', '96', '97', 'ZZZ'
];
if (!validPaymentMeans.includes(paymentMeansCode)) {
results.push({
ruleId: 'PEPPOL-B-04',
severity: 'error',
message: 'Payment means code must be from UNCL4461 code list',
field: 'metadata.extensions.paymentMeans.paymentMeansCode',
value: paymentMeansCode,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate GTIN (Global Trade Item Number)
*/
private isValidGTIN(gtin: string): boolean {
// GTIN can be 8, 12, 13, or 14 digits
if (!/^(\d{8}|\d{12}|\d{13}|\d{14})$/.test(gtin)) {
return false;
}
// Validate check digit
const digits = gtin.split('').map(d => parseInt(d, 10));
const checkDigit = digits[digits.length - 1];
let sum = 0;
for (let i = digits.length - 2; i >= 0; i--) {
const multiplier = ((digits.length - 2 - i) % 2 === 0) ? 3 : 1;
sum += digits[i] * multiplier;
}
const calculatedCheck = (10 - (sum % 10)) % 10;
return calculatedCheck === checkDigit;
}
/**
* Validate scheme IDs used in the invoice
*/
private validateSchemeIds(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check tax scheme ID
const taxSchemeId = invoice.metadata?.extensions?.taxDetails?.[0]?.taxScheme?.id;
if (taxSchemeId && taxSchemeId !== 'VAT') {
results.push({
ruleId: 'PEPPOL-S-01',
severity: 'warning',
message: 'Tax scheme ID should be "VAT" for PEPPOL invoices',
field: 'metadata.extensions.taxDetails[0].taxScheme.id',
value: taxSchemeId,
source: 'PEPPOL'
});
}
// Check currency code is from ISO 4217
if (invoice.currency) {
// This is already validated by CodeListValidator, but we can add PEPPOL-specific check
if (!['EUR', 'USD', 'GBP', 'SEK', 'NOK', 'DKK', 'CHF', 'PLN', 'CZK', 'HUF'].includes(invoice.currency)) {
results.push({
ruleId: 'PEPPOL-S-02',
severity: 'info',
message: `Currency ${invoice.currency} is uncommon in PEPPOL network`,
field: 'currency',
value: invoice.currency,
source: 'PEPPOL'
});
}
}
return results;
}
/**
* Validate transport protocol requirements
*/
private validateTransportProtocol(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if transport protocol is specified
const transportProtocol = invoice.metadata?.extensions?.transportProtocol;
if (transportProtocol) {
const validProtocols = ['AS2', 'AS4'];
if (!validProtocols.includes(transportProtocol)) {
results.push({
ruleId: 'PEPPOL-P-01',
severity: 'warning',
message: 'Transport protocol should be AS2 or AS4 for PEPPOL',
field: 'metadata.extensions.transportProtocol',
value: transportProtocol,
source: 'PEPPOL'
});
}
}
// Check if SMP lookup is possible
const sellerEndpointId = invoice.metadata?.extensions?.sellerEndpointId;
if (sellerEndpointId && !invoice.metadata?.extensions?.smpRegistered) {
results.push({
ruleId: 'PEPPOL-P-02',
severity: 'info',
message: 'Seller endpoint should be registered in PEPPOL SMP for discovery',
field: 'metadata.extensions.smpRegistered',
source: 'PEPPOL'
});
}
return results;
}
/**
* Check if invoice is B2G (Business to Government)
*/
private isPeppolB2G(invoice: EInvoice): boolean {
// Check if buyer has government indicators
const buyerSchemeId = invoice.metadata?.extensions?.buyerPartyId?.schemeId;
const buyerCategory = invoice.metadata?.extensions?.buyerCategory;
// Government scheme IDs often include specific codes
const governmentSchemes = ['0204', '9905', '0197', '0215'];
// Check various indicators for government entity
return buyerCategory === 'government' ||
(buyerSchemeId && governmentSchemes.includes(buyerSchemeId)) ||
invoice.metadata?.extensions?.isB2G === true;
}
}
@@ -0,0 +1,309 @@
import * as plugins from '../../plugins.js';
import * as path from 'path';
import { promises as fs } from 'fs';
/**
* Schematron rule sources
*/
export interface SchematronSource {
name: string;
version: string;
url: string;
description: string;
format: 'UBL' | 'CII' | 'BOTH';
}
/**
* Official Schematron sources for e-invoicing standards
*/
export const SCHEMATRON_SOURCES: Record<string, SchematronSource[]> = {
EN16931: [
{
name: 'EN16931-UBL',
version: '1.3.14',
url: 'https://github.com/ConnectingEurope/eInvoicing-EN16931/raw/master/ubl/schematron/EN16931-UBL-validation.sch',
description: 'Official EN16931 validation rules for UBL format',
format: 'UBL'
},
{
name: 'EN16931-CII',
version: '1.3.14',
url: 'https://github.com/ConnectingEurope/eInvoicing-EN16931/raw/master/cii/schematron/EN16931-CII-validation.sch',
description: 'Official EN16931 validation rules for CII format',
format: 'CII'
},
{
name: 'EN16931-EDIFACT',
version: '1.3.14',
url: 'https://github.com/ConnectingEurope/eInvoicing-EN16931/raw/master/edifact/schematron/EN16931-EDIFACT-validation.sch',
description: 'Official EN16931 validation rules for EDIFACT format',
format: 'CII'
}
],
XRECHNUNG: [
{
name: 'XRechnung-UBL',
version: '3.0.2',
url: 'https://github.com/itplr-kosit/xrechnung-schematron/raw/master/src/validation/schematron/ubl/XRechnung-UBL-validation.sch',
description: 'XRechnung CIUS validation for UBL',
format: 'UBL'
},
{
name: 'XRechnung-CII',
version: '3.0.2',
url: 'https://github.com/itplr-kosit/xrechnung-schematron/raw/master/src/validation/schematron/cii/XRechnung-CII-validation.sch',
description: 'XRechnung CIUS validation for CII',
format: 'CII'
}
],
PEPPOL: [
{
name: 'PEPPOL-EN16931-UBL',
version: '3.0.17',
url: 'https://github.com/OpenPEPPOL/peppol-bis-invoice-3/raw/master/rules/sch/PEPPOL-EN16931-UBL.sch',
description: 'PEPPOL BIS Billing 3.0 validation rules for UBL',
format: 'UBL'
},
{
name: 'PEPPOL-EN16931-CII',
version: '3.0.17',
url: 'https://github.com/OpenPEPPOL/peppol-bis-invoice-3/raw/master/rules/sch/PEPPOL-EN16931-CII.sch',
description: 'PEPPOL BIS Billing 3.0 validation rules for CII',
format: 'CII'
}
]
};
/**
* Schematron downloader and cache manager
*/
export class SchematronDownloader {
private cacheDir: string;
private smartfile: any;
constructor(cacheDir: string = 'assets_downloaded/schematron') {
this.cacheDir = cacheDir;
}
/**
* Initialize the downloader
*/
public async initialize(): Promise<void> {
// Ensure cache directory exists
this.smartfile = await import('@push.rocks/smartfile');
await fs.mkdir(this.cacheDir, { recursive: true });
}
/**
* Download a Schematron file
*/
public async download(source: SchematronSource): Promise<string> {
const fileName = `${source.name}-v${source.version}.sch`;
const filePath = path.join(this.cacheDir, fileName);
// Check if already cached
if (await this.isCached(filePath)) {
console.log(`Using cached Schematron: ${fileName}`);
return filePath;
}
console.log(`Downloading Schematron: ${source.name} v${source.version}`);
try {
// Download the file
const response = await fetch(source.url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const content = await response.text();
// Validate it's actually Schematron
if (!content.includes('schematron') && !content.includes('sch:schema')) {
throw new Error('Downloaded file does not appear to be Schematron');
}
// Save to cache
await fs.writeFile(filePath, content, 'utf-8');
// Also save metadata
const metaPath = filePath.replace('.sch', '.meta.json');
await fs.writeFile(metaPath, JSON.stringify({
source: source.name,
version: source.version,
url: source.url,
format: source.format,
downloadDate: new Date().toISOString()
}, null, 2), 'utf-8');
console.log(`Successfully downloaded: ${fileName}`);
return filePath;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to download ${source.name}: ${errorMessage}`);
}
}
/**
* Download all Schematron files for a standard
*/
public async downloadStandard(
standard: 'EN16931' | 'XRECHNUNG' | 'PEPPOL'
): Promise<string[]> {
const sources = SCHEMATRON_SOURCES[standard];
if (!sources) {
throw new Error(`Unknown standard: ${standard}`);
}
const paths: string[] = [];
for (const source of sources) {
try {
const path = await this.download(source);
paths.push(path);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to download ${source.name}: ${errorMessage}`);
}
}
return paths;
}
/**
* Check if a file is cached
*/
private async isCached(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
// Check if file is not empty
const stats = await fs.stat(filePath);
return stats.size > 0;
} catch {
return false;
}
}
/**
* Get cached Schematron files
*/
public async getCachedFiles(): Promise<Array<{
path: string;
metadata: any;
}>> {
const files: Array<{ path: string; metadata: any }> = [];
try {
const entries = await fs.readdir(this.cacheDir);
for (const entry of entries) {
if (entry.endsWith('.sch')) {
const filePath = path.join(this.cacheDir, entry);
const metaPath = filePath.replace('.sch', '.meta.json');
try {
const metadata = JSON.parse(await fs.readFile(metaPath, 'utf-8'));
files.push({ path: filePath, metadata });
} catch {
// No metadata file
files.push({ path: filePath, metadata: null });
}
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to list cached files: ${errorMessage}`);
}
return files;
}
/**
* Clear cache
*/
public async clearCache(): Promise<void> {
try {
const entries = await fs.readdir(this.cacheDir);
for (const entry of entries) {
if (entry.endsWith('.sch') || entry.endsWith('.meta.json')) {
await fs.unlink(path.join(this.cacheDir, entry));
}
}
console.log('Schematron cache cleared');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to clear cache: ${errorMessage}`);
}
}
/**
* Get the appropriate Schematron for a format
*/
public async getSchematronForFormat(
standard: 'EN16931' | 'XRECHNUNG' | 'PEPPOL',
format: 'UBL' | 'CII'
): Promise<string | null> {
const sources = SCHEMATRON_SOURCES[standard];
if (!sources) return null;
const source = sources.find(s => s.format === format || s.format === 'BOTH');
if (!source) return null;
return await this.download(source);
}
/**
* Update all cached Schematron files
*/
public async updateAll(): Promise<void> {
console.log('Updating all Schematron files...');
for (const standard of ['EN16931', 'XRECHNUNG', 'PEPPOL'] as const) {
await this.downloadStandard(standard);
}
console.log('All Schematron files updated');
}
}
/**
* ISO Schematron skeleton URLs
* These are needed to compile Schematron to XSLT
*/
export const ISO_SCHEMATRON_SKELETONS = {
'iso_dsdl_include.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_dsdl_include.xsl',
'iso_abstract_expand.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_abstract_expand.xsl',
'iso_svrl_for_xslt2.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_svrl_for_xslt2.xsl',
'iso_schematron_skeleton_for_saxon.xsl': 'https://github.com/Schematron/schematron/raw/master/trunk/schematron/code/iso_schematron_skeleton_for_saxon.xsl'
};
/**
* Download ISO Schematron skeleton files
*/
export async function downloadISOSkeletons(targetDir: string = 'assets_downloaded/schematron/iso'): Promise<void> {
await fs.mkdir(targetDir, { recursive: true });
console.log('Downloading ISO Schematron skeleton files...');
for (const [name, url] of Object.entries(ISO_SCHEMATRON_SKELETONS)) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const content = await response.text();
const filePath = path.join(targetDir, name);
await fs.writeFile(filePath, content, 'utf-8');
console.log(`Downloaded: ${name}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to download ${name}: ${errorMessage}`);
}
}
console.log('ISO Schematron skeleton download complete');
}
@@ -0,0 +1,288 @@
/**
* Integration of official Schematron validation with the EInvoice module
*/
import { SchematronValidator, HybridValidator } from './schematron.validator.js';
import { EN16931BusinessRulesValidator } from './en16931.business-rules.validator.js';
import { CodeListValidator } from './codelist.validator.js';
import type { ValidationResult, ValidationOptions, ValidationReport } from './validation.types.js';
import type { EInvoice } from '../../einvoice.js';
import * as path from 'path';
import { promises as fs } from 'fs';
/**
* Integrated validator combining TypeScript and Schematron validation
*/
export class IntegratedValidator {
private hybridValidator: HybridValidator;
private schematronValidator: SchematronValidator;
private businessRulesValidator: EN16931BusinessRulesValidator;
private codeListValidator: CodeListValidator;
private schematronLoaded: boolean = false;
constructor() {
this.schematronValidator = new SchematronValidator();
this.hybridValidator = new HybridValidator(this.schematronValidator);
this.businessRulesValidator = new EN16931BusinessRulesValidator();
this.codeListValidator = new CodeListValidator();
// Add TypeScript validators to hybrid pipeline
this.setupTypeScriptValidators();
}
/**
* Setup TypeScript validators in the hybrid pipeline
*/
private setupTypeScriptValidators(): void {
// Wrap business rules validator
this.hybridValidator.addTSValidator({
validate: (xml: string) => {
// Note: This would need the invoice object, not XML
// In practice, we'd parse the XML to EInvoice first
return [];
}
});
}
/**
* Load Schematron for a specific format and standard
*/
public async loadSchematron(
standard: 'EN16931' | 'PEPPOL' | 'XRECHNUNG',
format: 'UBL' | 'CII'
): Promise<void> {
const schematronPath = await this.getSchematronPath(standard, format);
if (!schematronPath) {
throw new Error(`No Schematron available for ${standard} ${format}`);
}
// Check if file exists
try {
await fs.access(schematronPath);
} catch {
throw new Error(`Schematron file not found: ${schematronPath}. Run 'npm run download-schematron' first.`);
}
await this.schematronValidator.loadSchematron(schematronPath, true);
this.schematronLoaded = true;
}
/**
* Get the path to the appropriate Schematron file
*/
private async getSchematronPath(
standard: 'EN16931' | 'PEPPOL' | 'XRECHNUNG',
format: 'UBL' | 'CII'
): Promise<string | null> {
const basePath = 'assets_downloaded/schematron';
// Map standard and format to file pattern
const patterns: Record<string, Record<string, string>> = {
EN16931: {
UBL: 'EN16931-UBL-*.sch',
CII: 'EN16931-CII-*.sch'
},
PEPPOL: {
UBL: 'PEPPOL-EN16931-UBL-*.sch',
CII: 'PEPPOL-EN16931-CII-*.sch'
},
XRECHNUNG: {
UBL: 'XRechnung-UBL-*.sch',
CII: 'XRechnung-CII-*.sch'
}
};
const pattern = patterns[standard]?.[format];
if (!pattern) return null;
// Find matching files
try {
const files = await fs.readdir(basePath);
const regex = new RegExp(pattern.replace('*', '.*'));
const matches = files.filter(f => regex.test(f));
if (matches.length > 0) {
// Return the most recent version (lexicographically last)
matches.sort();
return path.join(basePath, matches[matches.length - 1]);
}
} catch {
// Directory doesn't exist
}
return null;
}
/**
* Validate an invoice using all available validators
*/
public async validate(
invoice: EInvoice,
xmlContent?: string,
options: ValidationOptions = {}
): Promise<ValidationReport> {
const startTime = Date.now();
const results: ValidationResult[] = [];
// Determine format hint
const formatHint = options.formatHint || this.detectFormat(xmlContent);
// Run TypeScript validators
if (options.checkCodeLists !== false) {
results.push(...this.codeListValidator.validate(invoice));
}
results.push(...this.businessRulesValidator.validate(invoice, options));
// Run Schematron validation if XML is provided and Schematron is loaded
if (xmlContent && this.schematronLoaded) {
try {
const schematronResults = await this.schematronValidator.validate(xmlContent, {
includeWarnings: !options.strictMode,
parameters: {
profile: options.profile
}
});
results.push(...schematronResults);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron validation failed: ${errorMessage}`);
}
}
// Calculate statistics
const errorCount = results.filter(r => r.severity === 'error').length;
const warningCount = results.filter(r => r.severity === 'warning').length;
const infoCount = results.filter(r => r.severity === 'info').length;
// Estimate rule coverage
const totalRules = this.estimateTotalRules(options.profile);
const rulesChecked = new Set(results.map(r => r.ruleId)).size;
return {
valid: errorCount === 0,
profile: options.profile || 'EN16931',
timestamp: new Date().toISOString(),
validatorVersion: '1.0.0',
rulesetVersion: '1.3.14',
results,
errorCount,
warningCount,
infoCount,
rulesChecked,
rulesTotal: totalRules,
coverage: (rulesChecked / totalRules) * 100,
validationTime: Date.now() - startTime,
documentId: invoice.accountingDocId,
documentType: invoice.accountingDocType,
format: formatHint
};
}
/**
* Detect format from XML content
*/
private detectFormat(xmlContent?: string): 'UBL' | 'CII' | undefined {
if (!xmlContent) return undefined;
if (xmlContent.includes('urn:oasis:names:specification:ubl:schema:xsd:Invoice-2')) {
return 'UBL';
} else if (xmlContent.includes('urn:un:unece:uncefact:data:standard:CrossIndustryInvoice')) {
return 'CII';
}
return undefined;
}
/**
* Estimate total number of rules for a profile
*/
private estimateTotalRules(profile?: string): number {
const ruleCounts: Record<string, number> = {
EN16931: 150,
PEPPOL_BIS_3_0: 250,
XRECHNUNG_3_0: 280,
FACTURX_BASIC: 100,
FACTURX_EN16931: 150
};
return ruleCounts[profile || 'EN16931'] || 150;
}
/**
* Validate with automatic format detection
*/
public async validateAuto(
invoice: EInvoice,
xmlContent?: string
): Promise<ValidationReport> {
// Auto-detect format
const format = this.detectFormat(xmlContent);
// Try to load appropriate Schematron
if (format && !this.schematronLoaded) {
try {
await this.loadSchematron('EN16931', format);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Could not load Schematron: ${errorMessage}`);
}
}
return this.validate(invoice, xmlContent, {
formatHint: format,
checkCalculations: true,
checkVAT: true,
checkCodeLists: true
});
}
/**
* Check if Schematron validation is available
*/
public hasSchematron(): boolean {
return this.schematronLoaded;
}
/**
* Get available Schematron files
*/
public async getAvailableSchematron(): Promise<Array<{
standard: string;
format: string;
path: string;
}>> {
const available: Array<{ standard: string; format: string; path: string }> = [];
for (const standard of ['EN16931', 'PEPPOL', 'XRECHNUNG'] as const) {
for (const format of ['UBL', 'CII'] as const) {
const schematronPath = await this.getSchematronPath(standard, format);
if (schematronPath) {
available.push({ standard, format, path: schematronPath });
}
}
}
return available;
}
}
/**
* Create a pre-configured validator for a specific standard
*/
export async function createStandardValidator(
standard: 'EN16931' | 'PEPPOL' | 'XRECHNUNG',
format: 'UBL' | 'CII'
): Promise<IntegratedValidator> {
const validator = new IntegratedValidator();
try {
await validator.loadSchematron(standard, format);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron not available for ${standard} ${format}: ${errorMessage}`);
}
return validator;
}
@@ -0,0 +1,349 @@
import * as plugins from '../../plugins.js';
import type { ValidationResult } from './validation.types.js';
/**
* Schematron validation options
*/
export interface SchematronOptions {
phase?: string; // Schematron phase to activate
parameters?: Record<string, any>; // Parameters to pass to Schematron
includeWarnings?: boolean; // Include warning-level messages
maxErrors?: number; // Maximum errors before stopping
}
/**
* Schematron validation engine using Saxon-JS
* Provides official standards validation through Schematron rules
*/
export class SchematronValidator {
private compiledStylesheet: any;
private schematronRules: string;
private isCompiled: boolean = false;
constructor(schematronRules?: string) {
this.schematronRules = schematronRules || '';
}
/**
* Load Schematron rules from file or string
*/
public async loadSchematron(source: string, isFilePath: boolean = true): Promise<void> {
if (isFilePath) {
this.schematronRules = await plugins.fs.readFile(source, 'utf-8');
} else {
// Use provided string
this.schematronRules = source;
}
// Reset compilation state
this.isCompiled = false;
}
/**
* Compile Schematron to XSLT using ISO Schematron skeleton
*/
private async compileSchematron(): Promise<void> {
if (this.isCompiled) return;
// The Schematron to XSLT transformation requires the ISO Schematron skeleton
// For now, we'll use a simplified approach with direct XSLT generation
// In production, we would use the official ISO Schematron skeleton XSLTs
try {
// Convert Schematron to XSLT
// This is a simplified version - in production we'd use the full ISO skeleton
const xslt = this.generateXSLTFromSchematron(this.schematronRules);
// Compile the XSLT with Saxon-JS
this.compiledStylesheet = await plugins.SaxonJS.compile({
stylesheetText: xslt,
warnings: 'silent'
});
this.isCompiled = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to compile Schematron: ${errorMessage}`);
}
}
/**
* Validate an XML document against loaded Schematron rules
*/
public async validate(
xmlContent: string,
options: SchematronOptions = {}
): Promise<ValidationResult[]> {
if (!this.schematronRules) {
throw new Error('No Schematron rules loaded');
}
// Ensure Schematron is compiled
await this.compileSchematron();
const results: ValidationResult[] = [];
try {
// Transform the XML with the compiled Schematron XSLT
const transformResult = await plugins.SaxonJS.transform({
stylesheetInternal: this.compiledStylesheet,
sourceText: xmlContent,
destination: 'serialized',
stylesheetParams: options.parameters || {}
});
// Parse the SVRL (Schematron Validation Report Language) output
results.push(...this.parseSVRL(transformResult.principalResult));
// Apply options filters
if (!options.includeWarnings) {
return results.filter(r => r.severity !== 'warning');
}
if (options.maxErrors && results.filter(r => r.severity === 'error').length > options.maxErrors) {
return results.slice(0, options.maxErrors);
}
return results;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
results.push({
ruleId: 'SCHEMATRON-ERROR',
source: 'SCHEMATRON',
severity: 'error',
message: `Schematron validation failed: ${errorMessage}`,
btReference: undefined,
bgReference: undefined
});
return results;
}
}
/**
* Parse SVRL output to ValidationResult array
*/
private parseSVRL(svrlXml: string): ValidationResult[] {
const results: ValidationResult[] = [];
// Parse SVRL XML
const parser = new plugins.xmldom.DOMParser();
const doc = parser.parseFromString(svrlXml, 'text/xml');
// Get all failed assertions and successful reports
const failedAsserts = doc.getElementsByTagName('svrl:failed-assert');
const successfulReports = doc.getElementsByTagName('svrl:successful-report');
// Process failed assertions (these are errors)
for (let i = 0; i < failedAsserts.length; i++) {
const assert = failedAsserts[i];
const result = this.extractValidationResult(assert, 'error');
if (result) results.push(result);
}
// Process successful reports (these can be warnings or info)
for (let i = 0; i < successfulReports.length; i++) {
const report = successfulReports[i];
const result = this.extractValidationResult(report, 'warning');
if (result) results.push(result);
}
return results;
}
/**
* Extract ValidationResult from SVRL element
*/
private extractValidationResult(
element: Element,
defaultSeverity: 'error' | 'warning'
): ValidationResult | null {
const text = element.getElementsByTagName('svrl:text')[0]?.textContent || '';
const location = element.getAttribute('location') || undefined;
const test = element.getAttribute('test') || '';
const id = element.getAttribute('id') || element.getAttribute('role') || 'UNKNOWN';
const flag = element.getAttribute('flag') || defaultSeverity;
// Determine severity from flag attribute
let severity: 'error' | 'warning' | 'info' = defaultSeverity;
if (flag.toLowerCase().includes('fatal') || flag.toLowerCase().includes('error')) {
severity = 'error';
} else if (flag.toLowerCase().includes('warning')) {
severity = 'warning';
} else if (flag.toLowerCase().includes('info')) {
severity = 'info';
}
// Extract BT/BG references if present
const btMatch = text.match(/\[BT-(\d+)\]/);
const bgMatch = text.match(/\[BG-(\d+)\]/);
return {
ruleId: id,
source: 'EN16931',
severity,
message: text,
syntaxPath: location,
btReference: btMatch ? `BT-${btMatch[1]}` : undefined,
bgReference: bgMatch ? `BG-${bgMatch[1]}` : undefined,
profile: 'EN16931'
};
}
/**
* Generate simplified XSLT from Schematron
* This is a placeholder - in production, use ISO Schematron skeleton
*/
private generateXSLTFromSchematron(schematron: string): string {
// This is a simplified transformation
// In production, we would use the official ISO Schematron skeleton XSLTs
// (iso_schematron_skeleton.xsl, iso_svrl_for_xslt2.xsl, etc.)
// For now, return a basic XSLT that creates SVRL output
return `<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:svrl="http://purl.oclc.org/dsdl/svrl">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
<svrl:schematron-output>
<!-- This is a placeholder transformation -->
<!-- Real implementation would process Schematron patterns and rules -->
<svrl:active-pattern>
<xsl:attribute name="document">
<xsl:value-of select="base-uri(/)"/>
</xsl:attribute>
</svrl:active-pattern>
</svrl:schematron-output>
</xsl:template>
</xsl:stylesheet>`;
}
/**
* Check if validator has rules loaded
*/
public hasRules(): boolean {
return !!this.schematronRules;
}
/**
* Get list of available phases from Schematron
*/
public async getPhases(): Promise<string[]> {
if (!this.schematronRules) return [];
const parser = new plugins.xmldom.DOMParser();
const doc = parser.parseFromString(this.schematronRules, 'text/xml');
const phases = doc.getElementsByTagName('sch:phase');
const phaseNames: string[] = [];
for (let i = 0; i < phases.length; i++) {
const id = phases[i].getAttribute('id');
if (id) phaseNames.push(id);
}
return phaseNames;
}
/**
* Validate with specific phase activated
*/
public async validateWithPhase(
xmlContent: string,
phase: string,
options: SchematronOptions = {}
): Promise<ValidationResult[]> {
return this.validate(xmlContent, { ...options, phase });
}
}
/**
* Factory function to create validator with standard Schematron packs
*/
export async function createStandardValidator(
standard: 'EN16931' | 'XRECHNUNG' | 'PEPPOL' | 'FACTURX'
): Promise<SchematronValidator> {
const validator = new SchematronValidator();
// Load appropriate Schematron based on standard
// These paths would point to actual Schematron files in production
switch (standard) {
case 'EN16931':
// Would load from ConnectingEurope/eInvoicing-EN16931
await validator.loadSchematron('assets_downloaded/schematron/en16931/EN16931-UBL-validation.sch');
break;
case 'XRECHNUNG':
// Would load from itplr-kosit/xrechnung-schematron
await validator.loadSchematron('assets_downloaded/schematron/xrechnung/XRechnung-UBL-validation.sch');
break;
case 'PEPPOL':
// Would load from OpenPEPPOL/peppol-bis-invoice-3
await validator.loadSchematron('assets_downloaded/schematron/peppol/PEPPOL-EN16931-UBL.sch');
break;
case 'FACTURX':
// Would load from Factur-X specific Schematron
await validator.loadSchematron('assets_downloaded/schematron/facturx/Factur-X-EN16931-validation.sch');
break;
}
return validator;
}
/**
* Hybrid validator that combines TypeScript and Schematron validation
*/
export class HybridValidator {
private schematronValidator: SchematronValidator;
private tsValidators: Array<{ validate: (xml: string) => ValidationResult[] }> = [];
constructor(schematronValidator?: SchematronValidator) {
this.schematronValidator = schematronValidator || new SchematronValidator();
}
/**
* Add a TypeScript validator to the pipeline
*/
public addTSValidator(validator: { validate: (xml: string) => ValidationResult[] }): void {
this.tsValidators.push(validator);
}
/**
* Run all validators and merge results
*/
public async validate(
xmlContent: string,
options: SchematronOptions = {}
): Promise<ValidationResult[]> {
const results: ValidationResult[] = [];
// Run TypeScript validators first (faster, better UX)
for (const validator of this.tsValidators) {
try {
results.push(...validator.validate(xmlContent));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`TS validator failed: ${errorMessage}`);
}
}
// Run Schematron validation if available
if (this.schematronValidator.hasRules()) {
try {
const schematronResults = await this.schematronValidator.validate(xmlContent, options);
results.push(...schematronResults);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Schematron validation failed: ${errorMessage}`);
}
}
// Deduplicate results by ruleId
const seen = new Set<string>();
return results.filter(r => {
if (seen.has(r.ruleId)) return false;
seen.add(r.ruleId);
return true;
});
}
}
+221
View File
@@ -0,0 +1,221 @@
import { Worker } from 'worker_threads';
import * as path from 'path';
import type { ValidationResult } from './validation.types.js';
import type { SchematronOptions } from './schematron.validator.js';
/**
* Worker pool for Schematron validation
* Provides non-blocking validation in worker threads
*/
export class SchematronWorkerPool {
private workers: Worker[] = [];
private availableWorkers: Worker[] = [];
private taskQueue: Array<{
xmlContent: string;
options: SchematronOptions;
resolve: (results: ValidationResult[]) => void;
reject: (error: Error) => void;
}> = [];
private maxWorkers: number;
private schematronRules: string = '';
constructor(maxWorkers: number = 4) {
this.maxWorkers = maxWorkers;
}
/**
* Initialize worker pool
*/
public async initialize(schematronRules: string): Promise<void> {
this.schematronRules = schematronRules;
// Create workers
for (let i = 0; i < this.maxWorkers; i++) {
await this.createWorker();
}
}
/**
* Create a new worker
*/
private async createWorker(): Promise<void> {
const workerPath = path.join(import.meta.url, 'schematron.worker.impl.js');
const worker = new Worker(`
const { parentPort } = require('worker_threads');
const SaxonJS = require('saxon-js');
let compiledStylesheet = null;
parentPort.on('message', async (msg) => {
try {
if (msg.type === 'init') {
// Compile Schematron to XSLT
compiledStylesheet = await SaxonJS.compile({
stylesheetText: msg.xslt,
warnings: 'silent'
});
parentPort.postMessage({ type: 'ready' });
} else if (msg.type === 'validate') {
if (!compiledStylesheet) {
throw new Error('Worker not initialized');
}
// Transform XML with compiled Schematron
const result = await SaxonJS.transform({
stylesheetInternal: compiledStylesheet,
sourceText: msg.xmlContent,
destination: 'serialized',
stylesheetParams: msg.options.parameters || {}
});
parentPort.postMessage({
type: 'result',
svrl: result.principalResult
});
}
} catch (error) {
parentPort.postMessage({
type: 'error',
error: error.message
});
}
});
`, { eval: true });
// Initialize worker with Schematron rules
await new Promise<void>((resolve, reject) => {
worker.once('message', (msg) => {
if (msg.type === 'ready') {
resolve();
} else if (msg.type === 'error') {
reject(new Error(msg.error));
}
});
// Send initialization message
worker.postMessage({
type: 'init',
xslt: this.generateXSLTFromSchematron(this.schematronRules)
});
});
this.workers.push(worker);
this.availableWorkers.push(worker);
}
/**
* Validate XML using worker pool
*/
public async validate(
xmlContent: string,
options: SchematronOptions = {}
): Promise<ValidationResult[]> {
return new Promise((resolve, reject) => {
// Add task to queue
this.taskQueue.push({ xmlContent, options, resolve, reject });
this.processTasks();
});
}
/**
* Process queued validation tasks
*/
private processTasks(): void {
while (this.taskQueue.length > 0 && this.availableWorkers.length > 0) {
const task = this.taskQueue.shift()!;
const worker = this.availableWorkers.shift()!;
// Set up one-time listeners
const messageHandler = (msg: any) => {
if (msg.type === 'result') {
// Parse SVRL and return results
const results = this.parseSVRL(msg.svrl);
task.resolve(results);
// Return worker to pool
this.availableWorkers.push(worker);
worker.removeListener('message', messageHandler);
// Process next task
this.processTasks();
} else if (msg.type === 'error') {
task.reject(new Error(msg.error));
// Return worker to pool
this.availableWorkers.push(worker);
worker.removeListener('message', messageHandler);
// Process next task
this.processTasks();
}
};
worker.on('message', messageHandler);
// Send validation task
worker.postMessage({
type: 'validate',
xmlContent: task.xmlContent,
options: task.options
});
}
}
/**
* Parse SVRL output
*/
private parseSVRL(svrlXml: string): ValidationResult[] {
const results: ValidationResult[] = [];
// This would use the same parsing logic as SchematronValidator
// Simplified for brevity
return results;
}
/**
* Generate XSLT from Schematron (simplified)
*/
private generateXSLTFromSchematron(schematron: string): string {
// Simplified - would use ISO Schematron skeleton in production
return `<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="3.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:svrl="http://purl.oclc.org/dsdl/svrl">
<xsl:output method="xml" indent="yes"/>
<xsl:template match="/">
<svrl:schematron-output>
<svrl:active-pattern document="{base-uri(/)}"/>
</svrl:schematron-output>
</xsl:template>
</xsl:stylesheet>`;
}
/**
* Terminate all workers
*/
public async terminate(): Promise<void> {
await Promise.all(this.workers.map(w => w.terminate()));
this.workers = [];
this.availableWorkers = [];
this.taskQueue = [];
}
/**
* Get pool statistics
*/
public getStats(): {
totalWorkers: number;
availableWorkers: number;
queuedTasks: number;
} {
return {
totalWorkers: this.workers.length,
availableWorkers: this.availableWorkers.length,
queuedTasks: this.taskQueue.length
};
}
}
+274
View File
@@ -0,0 +1,274 @@
/**
* Enhanced validation types for EN16931 compliance
*/
export interface ValidationResult {
// Core identification
ruleId: string; // e.g., "BR-CO-14"
source: string; // e.g., "EN16931", "PEPPOL", "XRECHNUNG"
severity: 'error' | 'warning' | 'info';
message: string;
// Semantic references
btReference?: string; // Business Term reference (e.g., "BT-112")
bgReference?: string; // Business Group reference (e.g., "BG-23")
// Location information
semanticPath?: string; // BT/BG-based path (portable across syntaxes)
syntaxPath?: string; // XPath/JSON Pointer to concrete field
field?: string; // Simple field name
// Values and validation context
value?: any; // Actual value found
expected?: any; // Expected value or pattern
tolerance?: number; // Numeric tolerance applied
// Context
profile?: string; // e.g., "EN16931", "PEPPOL_BIS_3.0", "XRECHNUNG_3.0"
codeList?: {
name: string; // e.g., "ISO4217", "UNCL5305"
version: string; // e.g., "2021"
};
// Remediation
hint?: string; // Machine-friendly hint key
remediation?: string; // Human-readable fix suggestion
}
export interface ValidationOptions {
// Profile and target
profile?: 'EN16931' | 'PEPPOL_BIS_3.0' | 'XRECHNUNG_3.0' | 'FACTURX_BASIC' | 'FACTURX_EN16931';
formatHint?: 'UBL' | 'CII';
// Validation toggles
checkCalculations?: boolean;
checkVAT?: boolean;
checkAllowances?: boolean;
checkCodeLists?: boolean;
checkCardinality?: boolean;
// Tolerances
tolerance?: number; // Default 0.01 for currency
currencyMinorUnits?: Map<string, number>; // Currency-specific decimal places
// Mode
strictMode?: boolean; // Fail on warnings
reportOnly?: boolean; // Non-blocking validation
featureFlags?: string[]; // Enable specific rule sets
}
export interface ValidationReport {
// Summary
valid: boolean;
profile: string;
timestamp: string;
validatorVersion: string;
rulesetVersion: string;
// Results
results: ValidationResult[];
errorCount: number;
warningCount: number;
infoCount: number;
// Coverage
rulesChecked: number;
rulesTotal: number;
coverage: number; // Percentage
// Performance
validationTime: number; // Milliseconds
// Document info
documentId?: string;
documentType?: string;
format?: string;
}
// Code list definitions
export const CodeLists = {
// ISO 4217 Currency codes
ISO4217: {
version: '2021',
codes: new Set([
'EUR', 'USD', 'GBP', 'CHF', 'SEK', 'NOK', 'DKK', 'PLN', 'CZK', 'HUF',
'RON', 'BGN', 'HRK', 'TRY', 'ISK', 'JPY', 'CNY', 'AUD', 'CAD', 'NZD'
])
},
// ISO 3166-1 alpha-2 Country codes
ISO3166: {
version: '2020',
codes: new Set([
'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'CH', 'GB', 'IE', 'PT', 'GR',
'SE', 'NO', 'DK', 'FI', 'PL', 'CZ', 'HU', 'RO', 'BG', 'HR', 'SI', 'SK',
'LT', 'LV', 'EE', 'LU', 'MT', 'CY', 'US', 'CA', 'AU', 'NZ', 'JP', 'CN'
])
},
// UNCL5305 Tax category codes
UNCL5305: {
version: 'D16B',
codes: new Map([
['S', 'Standard rate'],
['Z', 'Zero rated'],
['E', 'Exempt from tax'],
['AE', 'VAT Reverse Charge'],
['K', 'VAT exempt for EEA intra-community supply'],
['G', 'Free export outside EU'],
['O', 'Services outside scope of tax'],
['L', 'Canary Islands general indirect tax'],
['M', 'Tax for production, services and importation in Ceuta and Melilla']
])
},
// UNCL1001 Document type codes
UNCL1001: {
version: 'D16B',
codes: new Map([
['380', 'Commercial invoice'],
['381', 'Credit note'],
['383', 'Debit note'],
['384', 'Corrected invoice'],
['389', 'Self-billed invoice'],
['751', 'Invoice information for accounting purposes']
])
},
// UNCL4461 Payment means codes
UNCL4461: {
version: 'D16B',
codes: new Map([
['1', 'Instrument not defined'],
['10', 'In cash'],
['20', 'Cheque'],
['30', 'Credit transfer'],
['31', 'Debit transfer'],
['42', 'Payment to bank account'],
['48', 'Bank card'],
['49', 'Direct debit'],
['58', 'SEPA credit transfer'],
['59', 'SEPA direct debit']
])
},
// UNECE Rec 20 Unit codes (subset)
UNECERec20: {
version: '2021',
codes: new Map([
['C62', 'One (unit)'],
['DAY', 'Day'],
['HAR', 'Hectare'],
['HUR', 'Hour'],
['KGM', 'Kilogram'],
['KTM', 'Kilometre'],
['KWH', 'Kilowatt hour'],
['LS', 'Lump sum'],
['LTR', 'Litre'],
['MIN', 'Minute'],
['MMT', 'Millimetre'],
['MON', 'Month'],
['MTK', 'Square metre'],
['MTQ', 'Cubic metre'],
['MTR', 'Metre'],
['NAR', 'Number of articles'],
['NPR', 'Number of pairs'],
['P1', 'Percent'],
['SET', 'Set'],
['TNE', 'Tonne (metric ton)'],
['WEE', 'Week']
])
}
};
// Business Term (BT) and Business Group (BG) mappings
export const SemanticModel = {
// Document level BTs
BT1: 'Invoice number',
BT2: 'Invoice issue date',
BT3: 'Invoice type code',
BT5: 'Invoice currency code',
BT6: 'VAT accounting currency code',
BT7: 'Value added tax point date',
BT8: 'Value added tax point date code',
BT9: 'Payment due date',
BT10: 'Buyer reference',
BT11: 'Project reference',
BT12: 'Contract reference',
BT13: 'Purchase order reference',
BT14: 'Sales order reference',
BT15: 'Receiving advice reference',
BT16: 'Despatch advice reference',
BT17: 'Tender or lot reference',
BT18: 'Invoiced object identifier',
BT19: 'Buyer accounting reference',
BT20: 'Payment terms',
BT21: 'Seller note',
BT22: 'Buyer note',
BT23: 'Business process',
BT24: 'Specification identifier',
// Seller BTs (BG-4)
BT27: 'Seller name',
BT28: 'Seller trading name',
BT29: 'Seller identifier',
BT30: 'Seller legal registration identifier',
BT31: 'Seller VAT identifier',
BT32: 'Seller tax registration identifier',
BT33: 'Seller additional legal information',
BT34: 'Seller electronic address',
// Buyer BTs (BG-7)
BT44: 'Buyer name',
BT45: 'Buyer trading name',
BT46: 'Buyer identifier',
BT47: 'Buyer legal registration identifier',
BT48: 'Buyer VAT identifier',
BT49: 'Buyer electronic address',
// Monetary totals (BG-22)
BT106: 'Sum of Invoice line net amount',
BT107: 'Sum of allowances on document level',
BT108: 'Sum of charges on document level',
BT109: 'Invoice total amount without VAT',
BT110: 'Invoice total VAT amount',
BT111: 'Invoice total VAT amount in accounting currency',
BT112: 'Invoice total amount with VAT',
BT113: 'Paid amount',
BT114: 'Rounding amount',
BT115: 'Amount due for payment',
// Business Groups
BG1: 'Invoice note',
BG2: 'Process control',
BG3: 'Preceding Invoice reference',
BG4: 'Seller',
BG5: 'Seller postal address',
BG6: 'Seller contact',
BG7: 'Buyer',
BG8: 'Buyer postal address',
BG9: 'Buyer contact',
BG10: 'Payee',
BG11: 'Seller tax representative',
BG12: 'Seller tax representative postal address',
BG13: 'Delivery information',
BG14: 'Delivery or invoice period',
BG15: 'Deliver to address',
BG16: 'Payment instructions',
BG17: 'Credit transfer',
BG18: 'Payment card information',
BG19: 'Direct debit',
BG20: 'Document level allowances',
BG21: 'Document level charges',
BG22: 'Document totals',
BG23: 'VAT breakdown',
BG24: 'Additional supporting documents',
BG25: 'Invoice line',
BG26: 'Invoice line period',
BG27: 'Invoice line allowances',
BG28: 'Invoice line charges',
BG29: 'Price details',
BG30: 'Line VAT information',
BG31: 'Item information',
BG32: 'Item attributes'
};
@@ -0,0 +1,845 @@
import * as plugins from '../../plugins.js';
import type { TAccountingDocItem } from '@tsclass/tsclass/dist_ts/finance/index.js';
import type { EInvoice } from '../../einvoice.js';
import { CurrencyCalculator } from '../utils/currency.utils.js';
import type { ValidationResult } from './validation.types.js';
/**
* VAT Category codes according to UNCL5305
*/
export enum VATCategory {
S = 'S', // Standard rate
Z = 'Z', // Zero rated
E = 'E', // Exempt from tax
AE = 'AE', // VAT Reverse Charge
K = 'K', // VAT exempt for EEA intra-community supply
G = 'G', // Free export outside EU
O = 'O', // Services outside scope of tax
L = 'L', // Canary Islands general indirect tax
M = 'M' // Tax for production, services and importation in Ceuta and Melilla
}
/**
* Extended VAT information for EN16931
*/
export interface VATBreakdown {
category: VATCategory;
rate: number;
taxableAmount: number;
taxAmount: number;
exemptionReason?: string;
exemptionReasonCode?: string;
}
/**
* Comprehensive VAT Category Rules Validator
* Implements all EN16931 VAT category-specific business rules
*/
export class VATCategoriesValidator {
private results: ValidationResult[] = [];
private currencyCalculator?: CurrencyCalculator;
/**
* Validate VAT categories according to EN16931
*/
public validate(invoice: EInvoice): ValidationResult[] {
this.results = [];
// Initialize currency calculator if currency is available
if (invoice.currency) {
this.currencyCalculator = new CurrencyCalculator(invoice.currency);
}
// Group items by VAT category
const itemsByCategory = this.groupItemsByVATCategory(invoice.items || []);
const breakdownsByCategory = this.groupBreakdownsByCategory(invoice.taxBreakdown || []);
// Validate each VAT category
this.validateStandardRate(itemsByCategory.get('S'), breakdownsByCategory.get('S'), invoice);
this.validateZeroRated(itemsByCategory.get('Z'), breakdownsByCategory.get('Z'), invoice);
this.validateExempt(itemsByCategory.get('E'), breakdownsByCategory.get('E'), invoice);
this.validateReverseCharge(itemsByCategory.get('AE'), breakdownsByCategory.get('AE'), invoice);
this.validateIntraCommunity(itemsByCategory.get('K'), breakdownsByCategory.get('K'), invoice);
this.validateExport(itemsByCategory.get('G'), breakdownsByCategory.get('G'), invoice);
this.validateOutOfScope(itemsByCategory.get('O'), breakdownsByCategory.get('O'), invoice);
// Cross-category validation
this.validateCrossCategoryRules(invoice, itemsByCategory, breakdownsByCategory);
return this.results;
}
/**
* Validate Standard Rate VAT (BR-S-*)
*/
private validateStandardRate(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-S-01: Invoice with standard rated items must have standard rated breakdown
if (!breakdown) {
this.addError('BR-S-01',
'Invoice with standard rated items must have a standard rated VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-S-02: Standard rate VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-S-02',
`Standard rate VAT taxable amount mismatch`,
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-S-03: Standard rate VAT category tax amount
const rate = breakdown.taxPercent || 0;
const expectedTax = this.calculateVATAmount(expectedTaxable, rate);
if (!this.areAmountsEqual(breakdown.taxAmount, expectedTax)) {
this.addError('BR-S-03',
`Standard rate VAT tax amount mismatch`,
'taxBreakdown.taxAmount',
breakdown.taxAmount,
expectedTax
);
}
// BR-S-04: Standard rate VAT category code must be "S"
if (breakdown.categoryCode && breakdown.categoryCode !== 'S') {
this.addError('BR-S-04',
'Standard rate VAT category code must be "S"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'S'
);
}
// BR-S-05: Standard rate VAT rate must be greater than zero
if (rate <= 0) {
this.addError('BR-S-05',
'Standard rate VAT rate must be greater than zero',
'taxBreakdown.taxPercent',
rate,
'> 0'
);
}
// BR-S-08: No exemption reason for standard rate
if (breakdown.exemptionReason) {
this.addError('BR-S-08',
'Standard rate VAT must not have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
}
/**
* Validate Zero Rated VAT (BR-Z-*)
*/
private validateZeroRated(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-Z-01: Invoice with zero rated items must have zero rated breakdown
if (!breakdown) {
this.addError('BR-Z-01',
'Invoice with zero rated items must have a zero rated VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-Z-02: Zero rate VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-Z-02',
'Zero rate VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-Z-03: Zero rate VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-Z-03',
'Zero rate VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-Z-04: Zero rate VAT category code must be "Z"
if (breakdown.categoryCode && breakdown.categoryCode !== 'Z') {
this.addError('BR-Z-04',
'Zero rate VAT category code must be "Z"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'Z'
);
}
// BR-Z-05: Zero rate VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-Z-05',
'Zero rate VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
}
/**
* Validate Exempt from Tax (BR-E-*)
*/
private validateExempt(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-E-01: Invoice with exempt items must have exempt breakdown
if (!breakdown) {
this.addError('BR-E-01',
'Invoice with tax exempt items must have an exempt VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-E-02: Exempt VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-E-02',
'Exempt VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-E-03: Exempt VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-E-03',
'Exempt VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-E-04: Exempt VAT category code must be "E"
if (breakdown.categoryCode && breakdown.categoryCode !== 'E') {
this.addError('BR-E-04',
'Exempt VAT category code must be "E"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'E'
);
}
// BR-E-05: Exempt VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-E-05',
'Exempt VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-E-06: Exempt VAT must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-E-06',
'Exempt VAT must have an exemption reason or exemption reason code',
'taxBreakdown.exemptionReason'
);
}
}
/**
* Validate VAT Reverse Charge (BR-AE-*)
*/
private validateReverseCharge(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-AE-01: Invoice with reverse charge items must have reverse charge breakdown
if (!breakdown) {
this.addError('BR-AE-01',
'Invoice with reverse charge items must have a reverse charge VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-AE-02: Reverse charge VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-AE-02',
'Reverse charge VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-AE-03: Reverse charge VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-AE-03',
'Reverse charge VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-AE-04: Reverse charge VAT category code must be "AE"
if (breakdown.categoryCode && breakdown.categoryCode !== 'AE') {
this.addError('BR-AE-04',
'Reverse charge VAT category code must be "AE"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'AE'
);
}
// BR-AE-05: Reverse charge VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-AE-05',
'Reverse charge VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-AE-06: Reverse charge must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-AE-06',
'Reverse charge VAT must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
// BR-AE-08: Buyer must have VAT identifier for reverse charge
if (!invoice?.metadata?.buyerTaxId) {
this.addError('BR-AE-08',
'Buyer must have a VAT identifier for reverse charge invoices',
'metadata.buyerTaxId'
);
}
}
/**
* Validate Intra-Community Supply (BR-K-*)
*/
private validateIntraCommunity(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-K-01: Invoice with intra-community items must have intra-community breakdown
if (!breakdown) {
this.addError('BR-K-01',
'Invoice with intra-community supply must have corresponding VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-K-02: Intra-community VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-K-02',
'Intra-community VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-K-03: Intra-community VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-K-03',
'Intra-community VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-K-04: Intra-community VAT category code must be "K"
if (breakdown.categoryCode && breakdown.categoryCode !== 'K') {
this.addError('BR-K-04',
'Intra-community VAT category code must be "K"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'K'
);
}
// BR-K-05: Intra-community VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-K-05',
'Intra-community VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-K-06: Must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-K-06',
'Intra-community supply must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
// BR-K-08: Both seller and buyer must have VAT identifiers
if (!invoice?.metadata?.sellerTaxId) {
this.addError('BR-K-08',
'Seller must have a VAT identifier for intra-community supply',
'metadata.sellerTaxId'
);
}
if (!invoice?.metadata?.buyerTaxId) {
this.addError('BR-K-09',
'Buyer must have a VAT identifier for intra-community supply',
'metadata.buyerTaxId'
);
}
// BR-K-10: Must be in different EU member states
if (invoice?.from?.address?.countryCode === invoice?.to?.address?.countryCode) {
this.addWarning('BR-K-10',
'Intra-community supply should be between different EU member states',
'address.countryCode'
);
}
}
/**
* Validate Export Outside EU (BR-G-*)
*/
private validateExport(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-G-01: Invoice with export items must have export breakdown
if (!breakdown) {
this.addError('BR-G-01',
'Invoice with export items must have an export VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-G-02: Export VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-G-02',
'Export VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-G-03: Export VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-G-03',
'Export VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-G-04: Export VAT category code must be "G"
if (breakdown.categoryCode && breakdown.categoryCode !== 'G') {
this.addError('BR-G-04',
'Export VAT category code must be "G"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'G'
);
}
// BR-G-05: Export VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-G-05',
'Export VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-G-06: Must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-G-06',
'Export must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
// BR-G-08: Buyer should be outside EU
const buyerCountry = invoice?.to?.address?.countryCode;
if (buyerCountry && this.isEUCountry(buyerCountry)) {
this.addWarning('BR-G-08',
'Export category should be used for buyers outside EU',
'to.address.countryCode',
buyerCountry,
'non-EU'
);
}
}
/**
* Validate Out of Scope Services (BR-O-*)
*/
private validateOutOfScope(
items?: TAccountingDocItem[],
breakdown?: any,
invoice?: EInvoice
): void {
if (!items || items.length === 0) return;
// BR-O-01: Invoice with out of scope items must have out of scope breakdown
if (!breakdown) {
this.addError('BR-O-01',
'Invoice with out of scope items must have corresponding VAT breakdown',
'taxBreakdown'
);
return;
}
// BR-O-02: Out of scope VAT category taxable amount
const expectedTaxable = this.calculateTaxableAmount(items);
if (!this.areAmountsEqual(breakdown.netAmount, expectedTaxable)) {
this.addError('BR-O-02',
'Out of scope VAT taxable amount mismatch',
'taxBreakdown.netAmount',
breakdown.netAmount,
expectedTaxable
);
}
// BR-O-03: Out of scope VAT tax amount must be zero
if (breakdown.taxAmount !== 0) {
this.addError('BR-O-03',
'Out of scope VAT tax amount must be zero',
'taxBreakdown.taxAmount',
breakdown.taxAmount,
0
);
}
// BR-O-04: Out of scope VAT category code must be "O"
if (breakdown.categoryCode && breakdown.categoryCode !== 'O') {
this.addError('BR-O-04',
'Out of scope VAT category code must be "O"',
'taxBreakdown.categoryCode',
breakdown.categoryCode,
'O'
);
}
// BR-O-05: Out of scope VAT rate must be zero
if (breakdown.taxPercent !== 0) {
this.addError('BR-O-05',
'Out of scope VAT rate must be zero',
'taxBreakdown.taxPercent',
breakdown.taxPercent,
0
);
}
// BR-O-06: Must have exemption reason
if (!breakdown.exemptionReason && !breakdown.exemptionReasonCode) {
this.addError('BR-O-06',
'Out of scope services must have an exemption reason',
'taxBreakdown.exemptionReason'
);
}
}
/**
* Cross-category validation rules
*/
private validateCrossCategoryRules(
invoice: EInvoice,
itemsByCategory: Map<string, TAccountingDocItem[]>,
breakdownsByCategory: Map<string, any>
): void {
// BR-CO-17: VAT category tax amount = Σ(VAT category taxable amount × VAT rate)
breakdownsByCategory.forEach((breakdown, category) => {
if (category === 'S' && breakdown.taxPercent > 0) {
const expectedTax = this.calculateVATAmount(breakdown.netAmount, breakdown.taxPercent);
if (!this.areAmountsEqual(breakdown.taxAmount, expectedTax)) {
this.addError('BR-CO-17',
`VAT tax amount calculation error for category ${category}`,
'taxBreakdown.taxAmount',
breakdown.taxAmount,
expectedTax
);
}
}
});
// BR-CO-18: Invoice with mixed VAT categories
const categoriesUsed = new Set<string>();
itemsByCategory.forEach((items, category) => {
if (items.length > 0) categoriesUsed.add(category);
});
// BR-IC-01: Supply to EU countries without VAT ID should use standard rate
if (categoriesUsed.has('K') && !invoice.metadata?.buyerTaxId) {
this.addError('BR-IC-01',
'Intra-community supply requires buyer VAT identifier',
'metadata.buyerTaxId'
);
}
// BR-IC-02: Reverse charge requires specific conditions
if (categoriesUsed.has('AE')) {
// Check for service codes that qualify for reverse charge
const hasQualifyingServices = invoice.items?.some(item =>
this.isReverseChargeService(item)
);
if (!hasQualifyingServices) {
this.addWarning('BR-IC-02',
'Reverse charge should only be used for qualifying services',
'items'
);
}
}
// BR-CO-19: Sum of VAT breakdown taxable amounts must equal invoice tax exclusive total
let totalTaxable = 0;
breakdownsByCategory.forEach(breakdown => {
totalTaxable += breakdown.netAmount || 0;
});
const declaredTotal = invoice.totalNet || 0;
if (!this.areAmountsEqual(totalTaxable, declaredTotal)) {
this.addError('BR-CO-19',
'Sum of VAT breakdown taxable amounts must equal invoice total without VAT',
'totalNet',
declaredTotal,
totalTaxable
);
}
}
// Helper methods
private groupItemsByVATCategory(items: TAccountingDocItem[]): Map<string, TAccountingDocItem[]> {
const groups = new Map<string, TAccountingDocItem[]>();
items.forEach(item => {
const category = this.determineVATCategory(item);
if (!groups.has(category)) {
groups.set(category, []);
}
groups.get(category)!.push(item);
});
return groups;
}
private groupBreakdownsByCategory(breakdowns: any[]): Map<string, any> {
const groups = new Map<string, any>();
breakdowns.forEach(breakdown => {
const category = breakdown.categoryCode || this.inferCategoryFromRate(breakdown.taxPercent);
groups.set(category, breakdown);
});
return groups;
}
private determineVATCategory(item: TAccountingDocItem): string {
// Determine VAT category from item metadata or rate
const metadata = (item as any).metadata;
if (metadata?.vatCategory) {
return metadata.vatCategory;
}
// Infer from rate
if (item.vatPercentage === undefined || item.vatPercentage === null) {
return 'S'; // Default to standard
} else if (item.vatPercentage > 0) {
return 'S'; // Standard rate
} else if (item.vatPercentage === 0) {
// Could be Z, E, AE, K, G, or O - need more context
if (metadata?.exemptionReason) {
if (metadata.exemptionReason.includes('reverse')) return 'AE';
if (metadata.exemptionReason.includes('intra')) return 'K';
if (metadata.exemptionReason.includes('export')) return 'G';
if (metadata.exemptionReason.includes('scope')) return 'O';
return 'E'; // Default exempt
}
return 'Z'; // Default zero-rated
}
return 'S'; // Default
}
private inferCategoryFromRate(rate?: number): string {
if (!rate || rate === 0) return 'Z';
if (rate > 0) return 'S';
return 'S';
}
private calculateTaxableAmount(items: TAccountingDocItem[]): number {
const total = items.reduce((sum, item) => {
const lineNet = (item.unitNetPrice || 0) * (item.unitQuantity || 0);
return sum + (this.currencyCalculator ? this.currencyCalculator.round(lineNet) : lineNet);
}, 0);
return this.currencyCalculator ? this.currencyCalculator.round(total) : total;
}
private calculateVATAmount(taxableAmount: number, rate: number): number {
const vat = taxableAmount * (rate / 100);
return this.currencyCalculator ? this.currencyCalculator.round(vat) : vat;
}
private areAmountsEqual(value1: number, value2: number): boolean {
if (this.currencyCalculator) {
return this.currencyCalculator.areEqual(value1, value2);
}
return Math.abs(value1 - value2) < 0.01;
}
private isEUCountry(countryCode: string): boolean {
const euCountries = [
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'
];
return euCountries.includes(countryCode);
}
private isReverseChargeService(item: TAccountingDocItem): boolean {
// Check if item qualifies for reverse charge
// This would typically check service codes
const metadata = (item as any).metadata;
if (metadata?.serviceCode) {
// Construction services, telecommunication, etc.
const reverseChargeServices = ['44', '45', '61', '62'];
return reverseChargeServices.some(code =>
metadata.serviceCode.startsWith(code)
);
}
return false;
}
private addError(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'error',
message,
field,
value,
expected,
btReference: this.getBTReference(ruleId),
bgReference: 'BG-23' // VAT breakdown
});
}
private addWarning(
ruleId: string,
message: string,
field?: string,
value?: any,
expected?: any
): void {
this.results.push({
ruleId,
source: 'EN16931',
severity: 'warning',
message,
field,
value,
expected,
btReference: this.getBTReference(ruleId),
bgReference: 'BG-23'
});
}
private getBTReference(ruleId: string): string | undefined {
const btMap: Record<string, string> = {
'BR-S-': 'BT-118', // VAT category rate
'BR-Z-': 'BT-118',
'BR-E-': 'BT-120', // VAT exemption reason
'BR-AE-': 'BT-120',
'BR-K-': 'BT-120',
'BR-G-': 'BT-120',
'BR-O-': 'BT-120',
'BR-CO-17': 'BT-117', // VAT category tax amount
'BR-CO-18': 'BT-118',
'BR-CO-19': 'BT-116' // VAT category taxable amount
};
for (const [prefix, bt] of Object.entries(btMap)) {
if (ruleId.startsWith(prefix)) {
return bt;
}
}
return undefined;
}
}
/**
* Get VAT category name
*/
export function getVATCategoryName(category: VATCategory): string {
const names: Record<VATCategory, string> = {
[VATCategory.S]: 'Standard rate',
[VATCategory.Z]: 'Zero rated',
[VATCategory.E]: 'Exempt from tax',
[VATCategory.AE]: 'VAT Reverse Charge',
[VATCategory.K]: 'VAT exempt for EEA intra-community supply',
[VATCategory.G]: 'Free export outside EU',
[VATCategory.O]: 'Services outside scope of tax',
[VATCategory.L]: 'Canary Islands general indirect tax',
[VATCategory.M]: 'Tax for production, services and importation in Ceuta and Melilla'
};
return names[category] || 'Unknown';
}
@@ -0,0 +1,494 @@
/**
* XRechnung CIUS Validator
* Implements German-specific validation rules for XRechnung 3.0
*
* XRechnung is the German Core Invoice Usage Specification (CIUS) of EN16931
* Required for B2G invoicing in Germany since November 2020
*/
import type { EInvoice } from '../../einvoice.js';
import type { ValidationResult } from './validation.types.js';
/**
* XRechnung-specific validator implementing German CIUS rules
*/
export class XRechnungValidator {
private static readonly LEITWEG_ID_PATTERN = /^[0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}$/;
private static readonly IBAN_PATTERNS: Record<string, { length: number; pattern: RegExp }> = {
DE: { length: 22, pattern: /^DE[0-9]{2}[0-9]{8}[0-9]{10}$/ },
AT: { length: 20, pattern: /^AT[0-9]{2}[0-9]{5}[0-9]{11}$/ },
CH: { length: 21, pattern: /^CH[0-9]{2}[0-9]{5}[0-9A-Z]{12}$/ },
FR: { length: 27, pattern: /^FR[0-9]{2}[0-9]{5}[0-9]{5}[0-9A-Z]{11}[0-9]{2}$/ },
NL: { length: 18, pattern: /^NL[0-9]{2}[A-Z]{4}[0-9]{10}$/ },
BE: { length: 16, pattern: /^BE[0-9]{2}[0-9]{3}[0-9]{7}[0-9]{2}$/ },
IT: { length: 27, pattern: /^IT[0-9]{2}[A-Z][0-9]{5}[0-9]{5}[0-9A-Z]{12}$/ },
ES: { length: 24, pattern: /^ES[0-9]{2}[0-9]{4}[0-9]{4}[0-9]{2}[0-9]{10}$/ }
};
private static readonly BIC_PATTERN = /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/;
// SEPA countries
private static readonly SEPA_COUNTRIES = new Set([
'AD', 'AT', 'BE', 'BG', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI',
'FR', 'GB', 'GI', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LI', 'LT', 'LU',
'LV', 'MC', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', 'SM', 'VA'
]);
/**
* Validate XRechnung-specific requirements
*/
validateXRechnung(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check if this is an XRechnung invoice
if (!this.isXRechnungInvoice(invoice)) {
return results; // Not XRechnung, skip validation
}
// Validate mandatory fields
results.push(...this.validateLeitwegId(invoice));
results.push(...this.validateBuyerReference(invoice));
results.push(...this.validatePaymentDetails(invoice));
results.push(...this.validateSellerContact(invoice));
results.push(...this.validateTaxRegistration(invoice));
return results;
}
/**
* Check if invoice is XRechnung based on profile/customization ID
*/
private isXRechnungInvoice(invoice: EInvoice): boolean {
const profileId = invoice.metadata?.profileId || '';
const customizationId = invoice.metadata?.customizationId || '';
// XRechnung profile identifiers
const xrechnungProfiles = [
'urn:cen.eu:en16931:2017#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0',
'urn:cen.eu:en16931:2017#conformant#urn:xeinkauf.de:kosit:xrechnung_3.0',
'urn:cen.eu:en16931:2017:xrechnung',
'xrechnung'
];
return xrechnungProfiles.some(profile =>
profileId.toLowerCase().includes(profile.toLowerCase()) ||
customizationId.toLowerCase().includes(profile.toLowerCase())
);
}
/**
* Validate Leitweg-ID (routing ID for German public administration)
* Pattern: [0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}
* Rule: XR-DE-01
*/
private validateLeitwegId(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Leitweg-ID is typically in buyer reference (BT-10) for B2G
const buyerReference = invoice.metadata?.buyerReference || invoice.buyerReference || '';
// Check if it looks like a Leitweg-ID
if (buyerReference && this.looksLikeLeitwegId(buyerReference)) {
if (!XRechnungValidator.LEITWEG_ID_PATTERN.test(buyerReference.trim())) {
results.push({
ruleId: 'XR-DE-01',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid Leitweg-ID format: ${buyerReference}. Expected pattern: [0-9]{2,3}-[0-9]{1,12}-[0-9]{2,30}`,
btReference: 'BT-10',
field: 'buyerReference',
value: buyerReference
});
}
}
// For B2G invoices, Leitweg-ID might be mandatory
if (this.isB2GInvoice(invoice) && !buyerReference) {
results.push({
ruleId: 'XR-DE-15',
severity: 'error',
source: 'XRECHNUNG',
message: 'Buyer reference (Leitweg-ID) is mandatory for B2G invoices in Germany',
btReference: 'BT-10',
field: 'buyerReference'
});
}
return results;
}
/**
* Check if string looks like a Leitweg-ID
*/
private looksLikeLeitwegId(value: string): boolean {
// Contains dashes and numbers in the right proportion
return value.includes('-') && /^\d+-\d+-\d+$/.test(value.trim());
}
/**
* Check if this is a B2G invoice
*/
private isB2GInvoice(invoice: EInvoice): boolean {
// Check if buyer is a public entity (simplified check)
const buyerName = invoice.to?.name?.toLowerCase() || '';
const buyerType = invoice.metadata?.extensions?.buyerType?.toLowerCase() || '';
const publicIndicators = [
'bundesamt', 'landesamt', 'stadtverwaltung', 'gemeinde',
'ministerium', 'behörde', 'öffentlich', 'public', 'government'
];
return publicIndicators.some(indicator =>
buyerName.includes(indicator) || buyerType.includes(indicator)
);
}
/**
* Validate mandatory buyer reference (BT-10)
* Rule: XR-DE-15
*/
private validateBuyerReference(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const buyerReference = invoice.metadata?.buyerReference || invoice.buyerReference || '';
// Skip if B2G invoice - already handled in validateLeitwegId
if (this.isB2GInvoice(invoice)) {
return results;
}
if (!buyerReference || buyerReference.trim().length === 0) {
results.push({
ruleId: 'XR-DE-15',
severity: 'error',
source: 'XRECHNUNG',
message: 'Buyer reference (BT-10) is mandatory in XRechnung',
btReference: 'BT-10',
field: 'buyerReference'
});
}
return results;
}
/**
* Validate payment details (IBAN/BIC for SEPA)
* Rules: XR-DE-19, XR-DE-20
*/
private validatePaymentDetails(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Check payment means
const paymentMeans = invoice.metadata?.extensions?.paymentMeans as Array<{
type?: string;
iban?: string;
bic?: string;
accountName?: string;
}> | undefined;
if (!paymentMeans || paymentMeans.length === 0) {
return results; // No payment details to validate
}
for (const payment of paymentMeans) {
// Validate IBAN if present
if (payment.iban) {
const ibanResult = this.validateIBAN(payment.iban);
if (!ibanResult.valid) {
results.push({
ruleId: 'XR-DE-19',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid IBAN: ${ibanResult.message}`,
btReference: 'BT-84',
field: 'iban',
value: payment.iban
});
}
// Check if IBAN country is in SEPA zone
const countryCode = payment.iban.substring(0, 2);
if (!XRechnungValidator.SEPA_COUNTRIES.has(countryCode)) {
results.push({
ruleId: 'XR-DE-19',
severity: 'warning',
source: 'XRECHNUNG',
message: `IBAN country ${countryCode} is not in SEPA zone`,
btReference: 'BT-84',
field: 'iban',
value: payment.iban
});
}
}
// Validate BIC if present
if (payment.bic) {
const bicResult = this.validateBIC(payment.bic);
if (!bicResult.valid) {
results.push({
ruleId: 'XR-DE-20',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid BIC: ${bicResult.message}`,
btReference: 'BT-86',
field: 'bic',
value: payment.bic
});
}
}
// For German domestic payments, BIC is optional if IBAN starts with DE
if (payment.iban?.startsWith('DE') && !payment.bic) {
// This is fine, BIC is optional for domestic German payments
} else if (payment.iban && !payment.iban.startsWith('DE') && !payment.bic) {
results.push({
ruleId: 'XR-DE-20',
severity: 'warning',
source: 'XRECHNUNG',
message: 'BIC is recommended for international SEPA transfers',
btReference: 'BT-86',
field: 'bic'
});
}
}
return results;
}
/**
* Validate IBAN format and checksum
*/
private validateIBAN(iban: string): { valid: boolean; message?: string } {
// Remove spaces and convert to uppercase
const cleanIBAN = iban.replace(/\s/g, '').toUpperCase();
// Check basic format
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleanIBAN)) {
return { valid: false, message: 'Invalid IBAN format' };
}
// Get country code
const countryCode = cleanIBAN.substring(0, 2);
// Check country-specific format
const countryFormat = XRechnungValidator.IBAN_PATTERNS[countryCode];
if (countryFormat) {
if (cleanIBAN.length !== countryFormat.length) {
return {
valid: false,
message: `Invalid IBAN length for ${countryCode}: expected ${countryFormat.length}, got ${cleanIBAN.length}`
};
}
if (!countryFormat.pattern.test(cleanIBAN)) {
return {
valid: false,
message: `Invalid IBAN format for ${countryCode}`
};
}
}
// Validate checksum using mod-97 algorithm
const rearranged = cleanIBAN.substring(4) + cleanIBAN.substring(0, 4);
const numeric = rearranged.replace(/[A-Z]/g, char => (char.charCodeAt(0) - 55).toString());
// Calculate mod 97 for large numbers
let remainder = 0;
for (let i = 0; i < numeric.length; i++) {
remainder = (remainder * 10 + parseInt(numeric[i])) % 97;
}
if (remainder !== 1) {
return { valid: false, message: 'Invalid IBAN checksum' };
}
return { valid: true };
}
/**
* Validate BIC format
*/
private validateBIC(bic: string): { valid: boolean; message?: string } {
const cleanBIC = bic.replace(/\s/g, '').toUpperCase();
if (!XRechnungValidator.BIC_PATTERN.test(cleanBIC)) {
return {
valid: false,
message: 'Invalid BIC format. Expected 8 or 11 alphanumeric characters'
};
}
// Additional validation could check if BIC exists in SWIFT directory
// but that requires external data
return { valid: true };
}
/**
* Validate seller contact details
* Rule: XR-DE-02
*/
private validateSellerContact(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
// Seller contact is mandatory in XRechnung
const sellerContact = invoice.metadata?.extensions?.sellerContact as {
name?: string;
email?: string;
phone?: string;
} | undefined;
if (!sellerContact || (!sellerContact.name && !sellerContact.email && !sellerContact.phone)) {
results.push({
ruleId: 'XR-DE-02',
severity: 'error',
source: 'XRECHNUNG',
message: 'Seller contact information (name, email, or phone) is mandatory in XRechnung',
bgReference: 'BG-6',
field: 'sellerContact'
});
}
// Validate email format if present
if (sellerContact?.email && !this.isValidEmail(sellerContact.email)) {
results.push({
ruleId: 'XR-DE-02',
severity: 'warning',
source: 'XRECHNUNG',
message: `Invalid email format: ${sellerContact.email}`,
btReference: 'BT-43',
field: 'email',
value: sellerContact.email
});
}
// Validate phone format if present (basic validation)
if (sellerContact?.phone && !this.isValidPhone(sellerContact.phone)) {
results.push({
ruleId: 'XR-DE-02',
severity: 'warning',
source: 'XRECHNUNG',
message: `Invalid phone format: ${sellerContact.phone}`,
btReference: 'BT-42',
field: 'phone',
value: sellerContact.phone
});
}
return results;
}
/**
* Validate email format
*/
private isValidEmail(email: string): boolean {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email);
}
/**
* Validate phone format (basic)
*/
private isValidPhone(phone: string): boolean {
// Remove common formatting characters
const cleanPhone = phone.replace(/[\s\-\(\)\.]/g, '');
// Check if it contains only numbers and optional + at start
return /^\+?[0-9]{6,15}$/.test(cleanPhone);
}
/**
* Validate tax registration details
* Rules: XR-DE-03, XR-DE-04
*/
private validateTaxRegistration(invoice: EInvoice): ValidationResult[] {
const results: ValidationResult[] = [];
const sellerVatId = invoice.metadata?.sellerTaxId ||
(invoice.from?.type === 'company' ? (invoice.from as any).registrationDetails?.vatId : undefined) ||
invoice.metadata?.extensions?.sellerVatId;
const sellerTaxId = invoice.metadata?.extensions?.sellerTaxId;
// Either VAT ID or Tax ID must be present
if (!sellerVatId && !sellerTaxId) {
results.push({
ruleId: 'XR-DE-03',
severity: 'error',
source: 'XRECHNUNG',
message: 'Either seller VAT ID (BT-31) or Tax ID (BT-32) must be provided',
btReference: 'BT-31',
field: 'sellerTaxRegistration'
});
}
// Validate German VAT ID format if present
if (sellerVatId && sellerVatId.startsWith('DE')) {
if (!this.isValidGermanVatId(sellerVatId)) {
results.push({
ruleId: 'XR-DE-04',
severity: 'error',
source: 'XRECHNUNG',
message: `Invalid German VAT ID format: ${sellerVatId}`,
btReference: 'BT-31',
field: 'vatId',
value: sellerVatId
});
}
}
// Validate German Tax ID format if present
if (sellerTaxId && this.looksLikeGermanTaxId(sellerTaxId)) {
if (!this.isValidGermanTaxId(sellerTaxId)) {
results.push({
ruleId: 'XR-DE-04',
severity: 'warning',
source: 'XRECHNUNG',
message: `Invalid German Tax ID format: ${sellerTaxId}`,
btReference: 'BT-32',
field: 'taxId',
value: sellerTaxId
});
}
}
return results;
}
/**
* Validate German VAT ID format
*/
private isValidGermanVatId(vatId: string): boolean {
// German VAT ID: DE followed by 9 digits
const germanVatPattern = /^DE[0-9]{9}$/;
return germanVatPattern.test(vatId.replace(/\s/g, ''));
}
/**
* Check if value looks like a German Tax ID
*/
private looksLikeGermanTaxId(value: string): boolean {
const clean = value.replace(/[\s\/\-]/g, '');
return /^[0-9]{10,11}$/.test(clean);
}
/**
* Validate German Tax ID format
*/
private isValidGermanTaxId(taxId: string): boolean {
// German Tax ID: 11 digits with specific checksum algorithm
const clean = taxId.replace(/[\s\/\-]/g, '');
if (!/^[0-9]{11}$/.test(clean)) {
return false;
}
// Simplified validation - full algorithm would require checksum calculation
// At least check that not all digits are the same
const firstDigit = clean[0];
return !clean.split('').every(digit => digit === firstDigit);
}
/**
* Create XRechnung profile validator instance
*/
static create(): XRechnungValidator {
return new XRechnungValidator();
}
}
+2 -1
View File
@@ -144,11 +144,12 @@ export function validateXml(
const validator = ValidatorFactory.createValidator(xml);
return validator.validate(level);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
valid: false,
errors: [{
code: 'VAL-ERROR',
message: `Validation error: ${error.message}`
message: `Validation error: ${errorMessage}`
}],
level
};
+2 -3
View File
@@ -1,5 +1,3 @@
import { business, finance } from '../plugins.js';
/**
* Supported electronic invoice formats
*/
@@ -44,6 +42,7 @@ export interface ValidationError {
export interface ValidationResult {
valid: boolean; // Overall validation result
errors: ValidationError[]; // List of validation errors
warnings?: ValidationError[]; // List of validation warnings (optional)
level: ValidationLevel; // The level that was validated
}
@@ -87,4 +86,4 @@ export type { TCreditNote } from '@tsclass/tsclass/dist_ts/finance/index.js';
export type { TDebitNote } from '@tsclass/tsclass/dist_ts/finance/index.js';
export type { TContact } from '@tsclass/tsclass/dist_ts/business/index.js';
export type { TLetterEnvelope } from '@tsclass/tsclass/dist_ts/business/index.js';
export type { TDocumentEnvelope } from '@tsclass/tsclass/dist_ts/business/index.js';
export type { TDocumentEnvelope } from '@tsclass/tsclass/dist_ts/business/index.js';
+104
View File
@@ -0,0 +1,104 @@
/**
* EN16931-compliant metadata interface for EInvoice
* Contains all additional fields required for full standards compliance
*/
import type { business } from '@tsclass/tsclass';
import type { InvoiceFormat } from './common.js';
/**
* Extended metadata for EN16931 compliance
*/
export interface IEInvoiceMetadata {
// Format identification
format?: InvoiceFormat;
version?: string;
profile?: string;
customizationId?: string;
// EN16931 Business Terms
vatAccountingCurrency?: string; // BT-6
documentTypeCode?: string; // BT-3
paymentMeansCode?: string; // BT-81
paidAmount?: number; // BT-113
amountDue?: number; // BT-115
// Tax identifiers
sellerTaxId?: string; // BT-31
buyerTaxId?: string; // BT-48
buyerReference?: string; // BT-10
profileId?: string; // BT-23
paymentTerms?: string; // BT-20
// Delivery information (BG-13)
deliveryAddress?: {
streetName?: string;
houseNumber?: string;
city?: string;
postalCode?: string;
countryCode?: string; // BT-80
countrySubdivision?: string;
};
// Payment information (BG-16)
paymentAccount?: {
iban?: string; // BT-84
accountName?: string; // BT-85
bankId?: string; // BT-86
};
// Allowances and charges (BG-20, BG-21)
allowances?: Array<{
amount: number; // BT-92
baseAmount?: number; // BT-93
percentage?: number; // BT-94
vatCategoryCode?: string; // BT-95
vatRate?: number; // BT-96
reason?: string; // BT-97
reasonCode?: string; // BT-98
}>;
charges?: Array<{
amount: number; // BT-99
baseAmount?: number; // BT-100
percentage?: number; // BT-101
vatCategoryCode?: string; // BT-102
vatRate?: number; // BT-103
reason?: string; // BT-104
reasonCode?: string; // BT-105
}>;
// Extensions for specific standards
extensions?: Record<string, any>;
}
/**
* Extended item metadata for EN16931 compliance
*/
export interface IItemMetadata {
vatCategoryCode?: string; // BT-151
priceBaseQuantity?: number; // BT-149
exemptionReason?: string; // BT-120 (for exempt categories)
originCountryCode?: string; // BT-159
commodityCode?: string; // BT-158
// Item attributes (BG-32)
attributes?: Array<{
name: string; // BT-160
value: string; // BT-161
}>;
}
/**
* Extended accounting document item with metadata
*/
export interface IExtendedAccountingDocItem {
position: number;
name: string;
articleNumber?: string;
unitType: string;
unitQuantity: number;
unitNetPrice: number;
vatPercentage: number;
metadata?: IItemMetadata;
}
+9 -2
View File
@@ -21,11 +21,14 @@ import {
} from 'pdf-lib';
// XML-related imports
import { DOMParser, XMLSerializer } from 'xmldom';
import { DOMParser, XMLSerializer, xmldom } from './vendor/xmldom.js';
import * as xpath from 'xpath';
// XSLT/Schematron imports
import { SaxonJS } from './vendor/saxonjs.js';
// Compression-related imports
import * as pako from 'pako';
import { pako } from './vendor/pako.js';
// Business model imports
import { business, finance, general } from '@tsclass/tsclass';
@@ -49,8 +52,12 @@ export {
// XML-related exports
DOMParser,
XMLSerializer,
xmldom,
xpath,
// XSLT/Schematron exports
SaxonJS,
// Compression-related exports
pako,
+55
View File
@@ -0,0 +1,55 @@
# @fin.cx/einvoice
Source module for the main `@fin.cx/einvoice` package.
This directory contains the public TypeScript API for loading, validating, converting, and embedding European e-invoice XML.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What this module exports
- `EInvoice`
- `createEInvoice()`
- `validateXml(xml, level?)`
- `FormatDetector`
- `PDFExtractor`, `PDFEmbedder`
- `DecoderFactory`, `EncoderFactory`, `ValidatorFactory`
- `BaseDecoder`, `BaseEncoder`, `BaseValidator`
- `UBLBase*` and `CIIBase*` extension points
- Factur-X and ZUGFeRD format-specific classes
- root types such as `TInvoice`, `ValidationResult`, `ExportFormat`, `IPdf`
## Main workflow
```ts
import { EInvoice, ValidationLevel } from '@fin.cx/einvoice';
const invoice = await EInvoice.fromFile('./invoice.xml');
const validation = await invoice.validate(ValidationLevel.BUSINESS);
const xml = await invoice.exportXml('facturx');
```
## Format support
| Format | Detect | Import | Export | Notes |
| --- | --- | --- | --- | --- |
| `ubl` | Yes | Yes | Yes | Generic UBL flow |
| `xrechnung` | Yes | Yes | Yes | UBL-based profile |
| `cii` | Yes | Yes | Yes | Generic export currently routes through the Factur-X encoder path |
| `facturx` | Yes | Yes | Yes | Main CII generation path |
| `zugferd` | Yes | Yes | Yes | Input supports v1 and v2+ |
| `fatturapa` | Yes | No | No | Detection only at the moment |
## Important implementation notes
- `peppol` is not a root-level XML export target.
- `FatturaPA` is not fully implemented for import/export.
- PDF support means extracting or embedding XML into existing PDFs, not generating invoice PDFs from scratch.
- Validation is useful and extensive, but should not be documented as blanket certification.
## Related directories
- `../readme.md`: full package README for end users
- `../ts_install/readme.md`: install-time resource bootstrap module
+3
View File
@@ -0,0 +1,3 @@
{
"order": 1
}
+32
View File
@@ -0,0 +1,32 @@
declare module 'xmldom' {
export class DOMParser {
parseFromString(source: string, mimeType: string): Document;
}
export class XMLSerializer {
serializeToString(node: Node): string;
}
}
declare module 'pako' {
export function inflate(input: Uint8Array | ArrayBuffer | string, options?: unknown): Uint8Array;
}
declare module 'saxon-js' {
export function compile(options: {
stylesheetText: string;
warnings?: string;
[key: string]: unknown;
}): Promise<unknown>;
export function transform(options: {
stylesheetInternal?: unknown;
sourceText: string;
destination?: string;
stylesheetParams?: Record<string, unknown>;
[key: string]: unknown;
}): Promise<{
principalResult: string;
[key: string]: unknown;
}>;
}
+8
View File
@@ -0,0 +1,8 @@
import * as pakoRuntime from 'pako';
export interface IPakoModule {
inflate(input: Uint8Array | ArrayBuffer | string, options?: unknown): Uint8Array;
[key: string]: unknown;
}
export const pako: IPakoModule = pakoRuntime as unknown as IPakoModule;
+28
View File
@@ -0,0 +1,28 @@
import * as saxonJSRuntime from 'saxon-js';
export interface ISaxonJSCompileOptions {
stylesheetText: string;
warnings?: string;
[key: string]: unknown;
}
export interface ISaxonJSTransformOptions {
stylesheetInternal?: unknown;
sourceText: string;
destination?: string;
stylesheetParams?: Record<string, unknown>;
[key: string]: unknown;
}
export interface ISaxonJSTransformResult {
principalResult: string;
[key: string]: unknown;
}
export interface ISaxonJSModule {
compile(options: ISaxonJSCompileOptions): Promise<unknown>;
transform(options: ISaxonJSTransformOptions): Promise<ISaxonJSTransformResult>;
[key: string]: unknown;
}
export const SaxonJS: ISaxonJSModule = saxonJSRuntime as unknown as ISaxonJSModule;
+27
View File
@@ -0,0 +1,27 @@
import * as xmldomRuntime from 'xmldom';
export interface IDOMParser {
parseFromString(source: string, mimeType: string): Document;
}
export interface IDOMParserConstructor {
new (): IDOMParser;
}
export interface IXMLSerializer {
serializeToString(node: Node): string;
}
export interface IXMLSerializerConstructor {
new (): IXMLSerializer;
}
export interface IXmlDomModule {
DOMParser: IDOMParserConstructor;
XMLSerializer: IXMLSerializerConstructor;
[key: string]: unknown;
}
export const DOMParser: IDOMParserConstructor = xmldomRuntime.DOMParser as unknown as IDOMParserConstructor;
export const XMLSerializer: IXMLSerializerConstructor = xmldomRuntime.XMLSerializer as unknown as IXMLSerializerConstructor;
export const xmldom: IXmlDomModule = xmldomRuntime as unknown as IXmlDomModule;
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/// <reference types="node" />
/**
* Script to download official Schematron files for e-invoice validation
*/
const schematronDownloaderModulePath = '../dist_ts/formats/validation/schematron.downloader.js';
async function createDownloader() {
const { SchematronDownloader } = await import(schematronDownloaderModulePath);
const downloader = new SchematronDownloader('assets_downloaded/schematron');
await downloader.initialize();
return downloader;
}
async function main() {
console.log('📥 Starting Schematron download...\n');
const downloader = await createDownloader();
// Download EN16931 Schematron files
console.log('🔵 Downloading EN16931 Schematron files...');
try {
const en16931Paths = await downloader.downloadStandard('EN16931');
console.log(`✅ Downloaded ${en16931Paths.length} EN16931 files`);
en16931Paths.forEach((p: string) => console.log(` - ${p}`));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`❌ Failed to download EN16931: ${errorMessage}`);
}
console.log('\n🔵 Downloading PEPPOL Schematron files...');
try {
const peppolPaths = await downloader.downloadStandard('PEPPOL');
console.log(`✅ Downloaded ${peppolPaths.length} PEPPOL files`);
peppolPaths.forEach((p: string) => console.log(` - ${p}`));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`❌ Failed to download PEPPOL: ${errorMessage}`);
}
console.log('\n🔵 Downloading XRechnung Schematron files...');
try {
const xrechnungPaths = await downloader.downloadStandard('XRECHNUNG');
console.log(`✅ Downloaded ${xrechnungPaths.length} XRechnung files`);
xrechnungPaths.forEach((p: string) => console.log(` - ${p}`));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`❌ Failed to download XRechnung: ${errorMessage}`);
}
// List cached files
console.log('\n📂 Cached Schematron files:');
const cached = await downloader.getCachedFiles();
cached.forEach((file: { path: string; metadata: any }) => {
if (file.metadata) {
console.log(` - ${file.path}`);
console.log(` Version: ${file.metadata.version}`);
console.log(` Format: ${file.metadata.format}`);
console.log(` Downloaded: ${file.metadata.downloadDate}`);
} else {
console.log(` - ${file.path} (no metadata)`);
}
});
console.log('\n✅ Schematron download complete!');
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
console.error('❌ Script failed:', error);
process.exit(1);
});
}
export default main;
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env node
/// <reference types="node" />
/**
* Download official EN16931 and PEPPOL test samples for conformance testing
*/
import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { fileURLToPath } from 'url';
interface TestSampleSource {
name: string;
description: string;
repository: string;
branch: string;
paths: string[];
targetDir: string;
}
const TEST_SAMPLE_SOURCES: TestSampleSource[] = [
{
name: 'PEPPOL BIS 3.0 Examples',
description: 'Official PEPPOL BIS Billing 3.0 example files',
repository: 'OpenPEPPOL/peppol-bis-invoice-3',
branch: 'master',
paths: [
'rules/examples/Allowance-example.xml',
'rules/examples/base-example.xml',
'rules/examples/base-negative-inv-correction.xml',
'rules/examples/vat-category-E.xml',
'rules/examples/vat-category-O.xml',
'rules/examples/vat-category-S.xml',
'rules/examples/vat-category-Z.xml',
'rules/examples/vat-category-AE.xml',
'rules/examples/vat-category-K.xml',
'rules/examples/vat-category-G.xml'
],
targetDir: 'peppol-bis3'
},
{
name: 'CEN TC434 Test Files',
description: 'European Committee for Standardization test files',
repository: 'ConnectingEurope/eInvoicing-EN16931',
branch: 'master',
paths: [
'ubl/examples/ubl-tc434-example1.xml',
'ubl/examples/ubl-tc434-example2.xml',
'ubl/examples/ubl-tc434-example3.xml',
'ubl/examples/ubl-tc434-example4.xml',
'ubl/examples/ubl-tc434-example5.xml',
'ubl/examples/ubl-tc434-example6.xml',
'ubl/examples/ubl-tc434-example7.xml',
'ubl/examples/ubl-tc434-example8.xml',
'ubl/examples/ubl-tc434-example9.xml',
'cii/examples/cii-tc434-example1.xml',
'cii/examples/cii-tc434-example2.xml',
'cii/examples/cii-tc434-example3.xml',
'cii/examples/cii-tc434-example4.xml',
'cii/examples/cii-tc434-example5.xml',
'cii/examples/cii-tc434-example6.xml',
'cii/examples/cii-tc434-example7.xml',
'cii/examples/cii-tc434-example8.xml',
'cii/examples/cii-tc434-example9.xml'
],
targetDir: 'cen-tc434'
},
{
name: 'PEPPOL Validation Artifacts',
description: 'PEPPOL validation test files',
repository: 'OpenPEPPOL/peppol-bis-invoice-3',
branch: 'master',
paths: [
'rules/unit-UBL/PEPPOL-EN16931-UBL.xml'
],
targetDir: 'peppol-validation'
}
];
/**
* Download a file from GitHub
*/
async function downloadFile(
repo: string,
branch: string,
filePath: string,
targetPath: string
): Promise<void> {
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`;
return new Promise((resolve, reject) => {
https.get(url, (response) => {
if (response.statusCode === 404) {
console.warn(` ⚠️ File not found: ${filePath}`);
resolve();
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: ${response.statusCode}`));
return;
}
const dir = path.dirname(targetPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const file = createWriteStream(targetPath);
response.pipe(file);
file.on('finish', () => {
file.close();
console.log(` ✅ Downloaded: ${path.basename(filePath)}`);
resolve();
});
file.on('error', (err) => {
fs.unlink(targetPath, () => {}); // Delete incomplete file
reject(err);
});
}).on('error', reject);
});
}
/**
* Download test samples from a source
*/
async function downloadTestSamples(source: TestSampleSource): Promise<void> {
console.log(`\n📦 ${source.name}`);
console.log(` ${source.description}`);
console.log(` Repository: ${source.repository}`);
const baseDir = path.join('test-samples', source.targetDir);
for (const filePath of source.paths) {
const fileName = path.basename(filePath);
const targetPath = path.join(baseDir, fileName);
try {
await downloadFile(source.repository, source.branch, filePath, targetPath);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(` ❌ Error downloading ${fileName}: ${errorMessage}`);
}
}
}
/**
* Create metadata file for downloaded samples
*/
function createMetadata(sources: TestSampleSource[]): void {
const metadata = {
downloadDate: new Date().toISOString(),
sources: sources.map(s => ({
name: s.name,
repository: s.repository,
branch: s.branch,
fileCount: s.paths.length
})),
totalFiles: sources.reduce((sum, s) => sum + s.paths.length, 0)
};
const metadataPath = path.join('test-samples', 'metadata.json');
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
console.log('\n📝 Created metadata.json');
}
/**
* Main function
*/
async function main() {
console.log('🚀 Downloading official EN16931 test samples...\n');
// Create base directory
if (!fs.existsSync('test-samples')) {
fs.mkdirSync('test-samples');
}
// Download samples from each source
for (const source of TEST_SAMPLE_SOURCES) {
await downloadTestSamples(source);
}
// Create metadata file
createMetadata(TEST_SAMPLE_SOURCES);
console.log('\n✨ Test sample download complete!');
console.log('📁 Samples saved to: test-samples/');
// Count total files
const totalFiles = TEST_SAMPLE_SOURCES.reduce((sum, s) => sum + s.paths.length, 0);
console.log(`📊 Total files: ${totalFiles}`);
}
// Run if executed directly
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(console.error);
}
export default main;
export { TEST_SAMPLE_SOURCES };
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env tsx
/// <reference types="node" />
/**
* Downloads official XRechnung Schematron validation rules
* from the KoSIT repositories
*/
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
const XRECHNUNG_VERSION = '3.0.2'; // Latest version as of 2025
const VALIDATOR_VERSION = '2025-07-31'; // Next release date
const REPOS = {
schematron: {
url: 'https://github.com/itplr-kosit/xrechnung-schematron/archive/refs/tags/release-3.0.2.zip',
dir: 'xrechnung-schematron'
},
validator: {
url: 'https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/release-2024-07-31/validator-configuration-xrechnung_3.0.1_2024-07-31.zip',
dir: 'xrechnung-validator'
}
};
const ASSETS_DIR = path.join(process.cwd(), 'assets', 'schematron', 'xrechnung');
async function downloadFile(url: string, destination: string): Promise<void> {
console.log(`Downloading ${url}...`);
try {
// Use curl to download the file
execSync(`curl -L -o "${destination}" "${url}"`, { stdio: 'inherit' });
console.log(`Downloaded to ${destination}`);
} catch (error) {
console.error(`Failed to download ${url}:`, error);
throw error;
}
}
async function extractZip(zipFile: string, destination: string): Promise<void> {
console.log(`Extracting ${zipFile}...`);
try {
// Create destination directory if it doesn't exist
fs.mkdirSync(destination, { recursive: true });
// Extract using unzip
execSync(`unzip -o "${zipFile}" -d "${destination}"`, { stdio: 'inherit' });
console.log(`Extracted to ${destination}`);
} catch (error) {
console.error(`Failed to extract ${zipFile}:`, error);
throw error;
}
}
async function downloadXRechnungRules(): Promise<void> {
console.log('Starting XRechnung Schematron rules download...\n');
// Create assets directory
fs.mkdirSync(ASSETS_DIR, { recursive: true });
const tempDir = path.join(ASSETS_DIR, 'temp');
fs.mkdirSync(tempDir, { recursive: true });
// Download and extract Schematron rules
console.log('1. Downloading XRechnung Schematron rules...');
const schematronZip = path.join(tempDir, 'xrechnung-schematron.zip');
await downloadFile(REPOS.schematron.url, schematronZip);
const schematronDir = path.join(ASSETS_DIR, REPOS.schematron.dir);
await extractZip(schematronZip, schematronDir);
// Find the actual Schematron files
const schematronExtractedDir = path.join(schematronDir, `xrechnung-schematron-release-${XRECHNUNG_VERSION}`);
const schematronValidationDir = path.join(schematronExtractedDir, 'validation', 'schematron');
if (fs.existsSync(schematronValidationDir)) {
console.log('\nFound Schematron validation files:');
// List UBL Schematron files
const ublDir = path.join(schematronValidationDir, 'ubl-inv');
if (fs.existsSync(ublDir)) {
const ublFiles = fs.readdirSync(ublDir).filter(f => f.endsWith('.sch') || f.endsWith('.xsl'));
console.log(' UBL Invoice Schematron:', ublFiles.join(', '));
}
// List CII Schematron files
const ciiDir = path.join(schematronValidationDir, 'cii');
if (fs.existsSync(ciiDir)) {
const ciiFiles = fs.readdirSync(ciiDir).filter(f => f.endsWith('.sch') || f.endsWith('.xsl'));
console.log(' CII Schematron:', ciiFiles.join(', '));
}
// Copy to final location
const finalUblDir = path.join(ASSETS_DIR, 'ubl');
const finalCiiDir = path.join(ASSETS_DIR, 'cii');
fs.mkdirSync(finalUblDir, { recursive: true });
fs.mkdirSync(finalCiiDir, { recursive: true });
// Copy UBL files
if (fs.existsSync(ublDir)) {
const ublFiles = fs.readdirSync(ublDir);
for (const file of ublFiles) {
if (file.endsWith('.sch') || file.endsWith('.xsl')) {
fs.copyFileSync(
path.join(ublDir, file),
path.join(finalUblDir, file)
);
}
}
console.log(`\nCopied UBL Schematron files to ${finalUblDir}`);
}
// Copy CII files
if (fs.existsSync(ciiDir)) {
const ciiFiles = fs.readdirSync(ciiDir);
for (const file of ciiFiles) {
if (file.endsWith('.sch') || file.endsWith('.xsl')) {
fs.copyFileSync(
path.join(ciiDir, file),
path.join(finalCiiDir, file)
);
}
}
console.log(`Copied CII Schematron files to ${finalCiiDir}`);
}
}
// Download validator configuration (contains additional rules and scenarios)
console.log('\n2. Downloading XRechnung validator configuration...');
const validatorZip = path.join(tempDir, 'xrechnung-validator.zip');
await downloadFile(REPOS.validator.url, validatorZip);
const validatorDir = path.join(ASSETS_DIR, REPOS.validator.dir);
await extractZip(validatorZip, validatorDir);
// Create metadata file
const metadata = {
version: XRECHNUNG_VERSION,
validatorVersion: VALIDATOR_VERSION,
downloadDate: new Date().toISOString(),
sources: {
schematron: REPOS.schematron.url,
validator: REPOS.validator.url
},
files: {
ubl: fs.existsSync(path.join(ASSETS_DIR, 'ubl'))
? fs.readdirSync(path.join(ASSETS_DIR, 'ubl')).filter(f => f.endsWith('.sch'))
: [],
cii: fs.existsSync(path.join(ASSETS_DIR, 'cii'))
? fs.readdirSync(path.join(ASSETS_DIR, 'cii')).filter(f => f.endsWith('.sch'))
: []
}
};
fs.writeFileSync(
path.join(ASSETS_DIR, 'metadata.json'),
JSON.stringify(metadata, null, 2)
);
// Clean up temp directory
console.log('\n3. Cleaning up...');
fs.rmSync(tempDir, { recursive: true, force: true });
console.log('\n✅ XRechnung Schematron rules downloaded successfully!');
console.log(`📁 Files are located in: ${ASSETS_DIR}`);
console.log('\nNext steps:');
console.log('1. Run Saxon-JS to compile .sch files to SEF format');
console.log('2. Integrate with SchematronValidator');
console.log('3. Add XRechnung-specific TypeScript validators');
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
downloadXRechnungRules().catch(error => {
console.error('Failed to download XRechnung rules:', error);
process.exit(1);
});
}
export default downloadXRechnungRules;
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env node
/// <reference types="node" />
/**
* Post-install script to download required validation resources
* This script is automatically run after npm/pnpm install
* All users need validation capabilities, so this is mandatory
*/
import * as path from 'path';
import * as fs from 'fs';
import * as crypto from 'crypto';
const schematronDownloaderModulePath = '../dist_ts/formats/validation/schematron.downloader.js';
async function createDownloader() {
const { SchematronDownloader } = await import(schematronDownloaderModulePath);
const downloader = new SchematronDownloader('assets_downloaded/schematron');
await downloader.initialize();
return downloader;
}
// Version for cache invalidation
const RESOURCES_VERSION = '1.0.0';
/**
* Check if we're in a proper npm install context
*/
function isValidInstallContext(): boolean {
// Skip if we're in a git install or similar
if (process.env.npm_lifecycle_event !== 'postinstall') {
return false;
}
// Skip in CI if explicitly disabled
if (process.env.CI && process.env.EINVOICE_SKIP_RESOURCES) {
console.log('⏭️ Skipping resource download (EINVOICE_SKIP_RESOURCES set)');
return false;
}
return true;
}
/**
* Create a checksum for a file
*/
function getFileChecksum(filePath: string): string | null {
try {
if (!fs.existsSync(filePath)) return null;
const content = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(content).digest('hex');
} catch {
return null;
}
}
/**
* Check if resources are already downloaded and valid
*/
function checkExistingResources(): boolean {
const versionFile = path.join('assets_downloaded', 'schematron', '.version');
try {
if (!fs.existsSync(versionFile)) return false;
const version = fs.readFileSync(versionFile, 'utf-8').trim();
if (version !== RESOURCES_VERSION) {
console.log('📦 Resource version mismatch, re-downloading...');
return false;
}
// Check if key files exist
const keyFiles = [
'assets_downloaded/schematron/EN16931-UBL-v1.3.14.sch',
'assets_downloaded/schematron/EN16931-CII-v1.3.14.sch',
'assets_downloaded/schematron/PEPPOL-EN16931-UBL-v3.0.17.sch'
];
for (const file of keyFiles) {
if (!fs.existsSync(file)) {
console.log(`📦 Missing ${file}, re-downloading resources...`);
return false;
}
}
return true;
} catch {
return false;
}
}
/**
* Save version file after successful download
*/
function saveVersionFile(): void {
const versionFile = path.join('assets_downloaded', 'schematron', '.version');
try {
fs.mkdirSync(path.dirname(versionFile), { recursive: true });
fs.writeFileSync(versionFile, RESOURCES_VERSION);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn('⚠️ Could not save version file:', errorMessage);
}
}
async function downloadSchematron() {
console.log('📥 Downloading Schematron validation files...\n');
const downloader = await createDownloader();
let successCount = 0;
let failCount = 0;
// Download EN16931 Schematron files
console.log('🔵 Downloading EN16931 Schematron files...');
try {
const en16931Files = await downloader.downloadStandard('EN16931');
console.log(`✅ Downloaded ${en16931Files.length} EN16931 files`);
successCount += en16931Files.length;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`⚠️ Failed to download EN16931: ${errorMessage}`);
failCount++;
}
// Download PEPPOL Schematron files
console.log('\n🔵 Downloading PEPPOL Schematron files...');
try {
const peppolFiles = await downloader.downloadStandard('PEPPOL');
console.log(`✅ Downloaded ${peppolFiles.length} PEPPOL files`);
successCount += peppolFiles.length;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`⚠️ Failed to download PEPPOL: ${errorMessage}`);
failCount++;
}
// Download XRechnung Schematron files
console.log('\n🔵 Downloading XRechnung Schematron files...');
try {
const xrechnungFiles = await downloader.downloadStandard('XRECHNUNG');
console.log(`✅ Downloaded ${xrechnungFiles.length} XRechnung files`);
successCount += xrechnungFiles.length;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`⚠️ Failed to download XRechnung: ${errorMessage}`);
failCount++;
}
// Report results
if (successCount > 0) {
saveVersionFile();
console.log(`\n✅ Successfully downloaded ${successCount} validation files`);
}
if (failCount > 0) {
console.log(`⚠️ Failed to download ${failCount} resource sets`);
console.log(' Some validation features may be limited');
}
return { successCount, failCount };
}
async function main() {
// Check if we should run
if (!isValidInstallContext()) {
return;
}
console.log('='.repeat(60));
console.log('🚀 @fin.cx/einvoice - Validation Resources Setup');
console.log('='.repeat(60));
console.log();
try {
// Check if resources already exist and are current
if (checkExistingResources()) {
console.log('✅ Validation resources already installed and up-to-date');
console.log();
return;
}
// Check if we're in the right directory
const packageJsonPath = path.join(process.cwd(), 'package.json');
if (!fs.existsSync(packageJsonPath)) {
console.error('❌ Error: package.json not found');
console.error(' Installation context issue - skipping resource download');
return;
}
// Check if dist_ts exists (module should be built)
const distPath = path.join(process.cwd(), 'dist_ts');
if (!fs.existsSync(distPath)) {
console.log('⚠️ Module not yet built - skipping resource download');
console.log(' Resources will be downloaded on first use');
return;
}
// Check network connectivity (simple DNS check)
try {
await import('dns').then(dns =>
new Promise((resolve, reject) => {
dns.lookup('github.com', (err) => {
if (err) reject(err);
else resolve(true);
});
})
);
} catch {
console.log('⚠️ No network connectivity detected');
console.log(' Validation resources will be downloaded on first use');
console.log(' when network is available');
return;
}
// Download resources with retry logic
let attempts = 0;
const maxAttempts = 3;
let lastError;
while (attempts < maxAttempts) {
attempts++;
if (attempts > 1) {
console.log(`\n🔄 Retry attempt ${attempts}/${maxAttempts}...`);
}
try {
const { successCount, failCount } = await downloadSchematron();
if (successCount > 0) {
console.log();
console.log('='.repeat(60));
console.log('✅ Validation resources installed successfully!');
console.log('='.repeat(60));
console.log();
return;
}
if (failCount > 0 && attempts < maxAttempts) {
console.log(`\n⚠️ Some downloads failed, retrying...`);
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s before retry
continue;
}
break;
} catch (error) {
lastError = error;
if (attempts < maxAttempts) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(`\n⚠️ Download failed: ${errorMessage}`);
console.log(' Retrying...');
await new Promise(resolve => setTimeout(resolve, 3000)); // Wait 3s before retry
}
}
}
// If we get here, downloads failed after retries
console.error();
console.error('⚠️ Could not download all validation resources');
console.error(' The library will work but validation features may be limited');
console.error(' Resources will be attempted again on first use');
console.error();
if (lastError) {
const errorMessage = lastError instanceof Error ? lastError.message : String(lastError);
console.error(' Last error:', errorMessage);
}
} catch (error) {
// Catch-all for unexpected errors
const errorMessage = error instanceof Error ? error.message : String(error);
console.error();
console.error('⚠️ Unexpected error during resource setup:', errorMessage);
console.error(' This won\'t affect library installation');
console.error(' Resources will be downloaded on first use');
console.error();
}
}
// Only run if this is the main module
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('⚠️ Resource setup error:', errorMessage);
// Never fail the install
process.exit(0);
});
}
export default main;
+45
View File
@@ -0,0 +1,45 @@
# ts_install 🧰
Install-time bootstrap module for `@fin.cx/einvoice`.
This directory contains the scripts that download optional validation resources and external test data used by the main library.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## What it does
- runs the package `postinstall` bootstrap
- checks whether downloaded Schematron resources are already present and current
- skips cleanly when offline or when install context is not valid
- downloads validation resources into `assets_downloaded/schematron`
- exposes manual scripts for maintainers and advanced users
## Runtime behavior
- it only runs automatically when `npm_lifecycle_event === 'postinstall'`
- it never fails package installation on download/setup problems
- it skips when `dist_ts/` is missing
- it skips in CI when `EINVOICE_SKIP_RESOURCES` is set
- it keeps a `.version` marker to avoid unnecessary re-downloads
## Manual commands
```bash
pnpm download-schematron
pnpm download-test-samples
```
## Output locations
- `assets_downloaded/schematron/`: downloaded validation rules
- `test-samples/`: downloaded external sample corpus for conformance tests
## Who needs this?
- library consumers who want richer validation resources available locally
- maintainers working on Schematron validation or conformance testing
- CI or packaging flows that want explicit control over resource setup
For normal basic XML/PDF parsing and conversion, the main package API remains the primary entrypoint.
+3
View File
@@ -0,0 +1,3 @@
{
"order": 1
}
+2 -3
View File
@@ -1,12 +1,11 @@
{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true
"verbatimModuleSyntax": true,
"types": ["node"]
},
"exclude": [
"dist_*/**/*.d.ts"