Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6b3db15a | |||
| 119c12901a | |||
| d21876c14f | |||
| 4f1066da2e | |||
| 73b46f7857 | |||
| 08d7803be2 | |||
| db46612ea2 | |||
| 10ca6f2992 | |||
| f42c8539a6 | |||
| c7f06b6529 |
+2
-1
@@ -16,4 +16,5 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
dist_*/
|
dist_*/
|
||||||
|
|
||||||
#------# custom
|
#------# custom
|
||||||
|
.serena
|
||||||
@@ -1,10 +1,58 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-10-28 - 1.2.1 - fix(skr.classes.account)
|
||||||
|
Remove incorrect SKR04 automatic account 3300; improve VAT posting validation and test isolation; update readme hints and CI settings
|
||||||
|
|
||||||
|
- ts/skr.classes.account.ts: Removed account '3300' from the SKR04 automatic accounts list (3300 is Fahrzeugkosten and must be postable).
|
||||||
|
- ts/skr.postingkeys.ts: Relax VAT amount requirement — VAT amount is no longer required when posting to VAT accounts or to debtor/creditor accounts (settlement lines).
|
||||||
|
- ts/skr.classes.journalentry.ts: Detect VAT lines in journal entries and pass VAT-aware context into posting key validation to avoid false-positive VAT errors.
|
||||||
|
- test/test.skr04.ts: Use timestamped database names to ensure isolated test runs and avoid DB conflicts during CI.
|
||||||
|
- readme.hints.md: Updated status and notes (tests passing, recent fixes, architecture notes and validation pipeline).
|
||||||
|
- .claude/settings.local.json: Added local CI/agent permission settings used by the project environment.
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.2.0] - 2025-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **E-Invoice Integration**: Full XRechnung/ZUGFeRD support with import/export capabilities
|
||||||
|
- **Invoice Processing**: Automatic booking of electronic invoices to accounting
|
||||||
|
- **Advanced Export Features**: Comprehensive export functionality for accounts, balances, and ledger data
|
||||||
|
- **PDF Generation**: Professional PDF report generation with customizable templates
|
||||||
|
- **Security Features**: Merkle tree audit trails and digital signature support for tamper-proof records
|
||||||
|
- **Invoice Storage**: Dedicated invoice persistence layer with search and filtering
|
||||||
|
- **Invoice Adapter**: Bidirectional conversion between e-invoice formats and internal data model
|
||||||
|
- **Invoice Booking Engine**: Intelligent automatic account detection and VAT splitting
|
||||||
|
- **Cryptographic Signatures**: Support for signing exports with private keys and certificates
|
||||||
|
- **Structured Export Formats**: Export data in multiple formats (JSON, CSV, PDF)
|
||||||
|
- **Jahresabschluss Export**: Complete annual closing package generation
|
||||||
|
- New dependencies: @e-invoice-eu/core, @fin.cx/einvoice, merkletreejs, node-forge
|
||||||
|
- Enhanced documentation with invoice and export examples
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated README with comprehensive documentation of new features
|
||||||
|
- Expanded API reference with new invoice and export methods
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- SKR standard validation in postJournalEntry to ensure accounts match official SKR03/SKR04 data
|
||||||
|
- Module-level Maps for O(1) SKR standard lookups
|
||||||
|
- validateAccountsAgainstSKR method for checking account type and class compliance
|
||||||
|
- Smart validation that allows SKR04 class 8 custom accounts
|
||||||
|
- Warning logs for non-standard accounts and type/class mismatches
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Test isolation issues by adding timestamps to database names
|
||||||
|
- SKR04 test using correct account mappings (9xxx equity accounts)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Enhanced README with accurate API documentation and testing instructions
|
||||||
|
- Updated legal section to Task Venture Capital GmbH
|
||||||
|
|
||||||
## [1.0.0] - 2025-01-09
|
## [1.0.0] - 2025-01-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+13
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@fin.cx/skr",
|
"name": "@fin.cx/skr",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping",
|
"description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
@@ -25,15 +25,25 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.11.0",
|
"packageManager": "pnpm@10.11.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@e-invoice-eu/core": "^2.1.9",
|
||||||
|
"@fin.cx/einvoice": "5.1.4",
|
||||||
"@push.rocks/smartdata": "^5.15.1",
|
"@push.rocks/smartdata": "^5.15.1",
|
||||||
|
"@push.rocks/smartfile": "^11.0.22",
|
||||||
|
"@push.rocks/smarthash": "^3.0.7",
|
||||||
"@push.rocks/smartlog": "^3.1.8",
|
"@push.rocks/smartlog": "^3.1.8",
|
||||||
|
"@push.rocks/smartpath": "^5.0.18",
|
||||||
|
"@push.rocks/smartpdf": "^3.1.5",
|
||||||
"@push.rocks/smarttime": "^4.1.1",
|
"@push.rocks/smarttime": "^4.1.1",
|
||||||
"@push.rocks/smartunique": "^3.0.9"
|
"@push.rocks/smartunique": "^3.0.9",
|
||||||
|
"merkletreejs": "^0.4.0",
|
||||||
|
"node-forge": "^1.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@git.zone/tsbuild": "^2.6.4",
|
"@git.zone/tsbuild": "^2.6.4",
|
||||||
"@git.zone/tsrun": "^1.3.3",
|
"@git.zone/tsrun": "^1.3.3",
|
||||||
"@git.zone/tstest": "^2.3.2"
|
"@git.zone/tstest": "^2.3.2",
|
||||||
|
"@push.rocks/qenv": "^6.1.0",
|
||||||
|
"@types/node-forge": "^1.3.11"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -43,7 +53,6 @@
|
|||||||
"url": "https://code.foss.global/fin.cx/skr/issues"
|
"url": "https://code.foss.global/fin.cx/skr/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://code.foss.global/fin.cx/skr#readme",
|
"homepage": "https://code.foss.global/fin.cx/skr#readme",
|
||||||
"private": true,
|
|
||||||
"files": [
|
"files": [
|
||||||
"ts/**/*",
|
"ts/**/*",
|
||||||
"ts_web/**/*",
|
"ts_web/**/*",
|
||||||
|
|||||||
Generated
+472
@@ -8,18 +8,42 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@e-invoice-eu/core':
|
||||||
|
specifier: ^2.1.9
|
||||||
|
version: 2.1.9
|
||||||
|
'@fin.cx/einvoice':
|
||||||
|
specifier: 5.1.4
|
||||||
|
version: 5.1.4
|
||||||
'@push.rocks/smartdata':
|
'@push.rocks/smartdata':
|
||||||
specifier: ^5.15.1
|
specifier: ^5.15.1
|
||||||
version: 5.15.1(@aws-sdk/credential-providers@3.864.0)(socks@2.8.6)
|
version: 5.15.1(@aws-sdk/credential-providers@3.864.0)(socks@2.8.6)
|
||||||
|
'@push.rocks/smartfile':
|
||||||
|
specifier: ^11.0.22
|
||||||
|
version: 11.2.5
|
||||||
|
'@push.rocks/smarthash':
|
||||||
|
specifier: ^3.0.7
|
||||||
|
version: 3.2.3
|
||||||
'@push.rocks/smartlog':
|
'@push.rocks/smartlog':
|
||||||
specifier: ^3.1.8
|
specifier: ^3.1.8
|
||||||
version: 3.1.8
|
version: 3.1.8
|
||||||
|
'@push.rocks/smartpath':
|
||||||
|
specifier: ^5.0.18
|
||||||
|
version: 5.1.0
|
||||||
|
'@push.rocks/smartpdf':
|
||||||
|
specifier: ^3.1.5
|
||||||
|
version: 3.3.0(typescript@5.8.3)
|
||||||
'@push.rocks/smarttime':
|
'@push.rocks/smarttime':
|
||||||
specifier: ^4.1.1
|
specifier: ^4.1.1
|
||||||
version: 4.1.1
|
version: 4.1.1
|
||||||
'@push.rocks/smartunique':
|
'@push.rocks/smartunique':
|
||||||
specifier: ^3.0.9
|
specifier: ^3.0.9
|
||||||
version: 3.0.9
|
version: 3.0.9
|
||||||
|
merkletreejs:
|
||||||
|
specifier: ^0.4.0
|
||||||
|
version: 0.4.1
|
||||||
|
node-forge:
|
||||||
|
specifier: ^1.3.1
|
||||||
|
version: 1.3.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^2.6.4
|
specifier: ^2.6.4
|
||||||
@@ -30,6 +54,12 @@ importers:
|
|||||||
'@git.zone/tstest':
|
'@git.zone/tstest':
|
||||||
specifier: ^2.3.2
|
specifier: ^2.3.2
|
||||||
version: 2.3.2(@aws-sdk/credential-providers@3.864.0)(socks@2.8.6)(typescript@5.8.3)
|
version: 2.3.2(@aws-sdk/credential-providers@3.864.0)(socks@2.8.6)(typescript@5.8.3)
|
||||||
|
'@push.rocks/qenv':
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
|
'@types/node-forge':
|
||||||
|
specifier: ^1.3.11
|
||||||
|
version: 1.3.13
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -48,6 +78,9 @@ packages:
|
|||||||
'@api.global/typedsocket@3.0.1':
|
'@api.global/typedsocket@3.0.1':
|
||||||
resolution: {integrity: sha512-xojiAVNXtHoxkpBo8U2HHJG8FrVXXuLvDNndSHXwx4C9VslUwDn5zSCI+PdBl8iAg+ZuBmKjqkpZZ9sL6DC5yQ==}
|
resolution: {integrity: sha512-xojiAVNXtHoxkpBo8U2HHJG8FrVXXuLvDNndSHXwx4C9VslUwDn5zSCI+PdBl8iAg+ZuBmKjqkpZZ9sL6DC5yQ==}
|
||||||
|
|
||||||
|
'@asamuzakjp/css-color@3.2.0':
|
||||||
|
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -223,6 +256,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==}
|
resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@cantoo/pdf-lib@2.4.2':
|
||||||
|
resolution: {integrity: sha512-ZqMiY8XEyM6Rc3WjpsQnrZYwCdyf/Emg2J3RbmSxoIKN1Kpa/93uIaO9cx/14dJoC6vkcAtMhrYsO7YLB8i8Lg==}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20250809.0':
|
'@cloudflare/workers-types@4.20250809.0':
|
||||||
resolution: {integrity: sha512-MAG8S0aL+/WT52/XaqXCb6qc7XyfuYqmhwnpyLsx+RUkMZJxkhpJqaOny3Wa4IlWQfpgtQXktogmEoxxCpUmfA==}
|
resolution: {integrity: sha512-MAG8S0aL+/WT52/XaqXCb6qc7XyfuYqmhwnpyLsx+RUkMZJxkhpJqaOny3Wa4IlWQfpgtQXktogmEoxxCpUmfA==}
|
||||||
|
|
||||||
@@ -233,6 +269,34 @@ packages:
|
|||||||
'@configvault.io/interfaces@1.0.17':
|
'@configvault.io/interfaces@1.0.17':
|
||||||
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
resolution: {integrity: sha512-bEcCUR2VBDJsTin8HQh8Uw/mlYl2v8A3jMIaQ+MTB9Hrqd6CZL2dL7iJdWyFl/3EIX+LDxWFR+Oq7liIq7w+1Q==}
|
||||||
|
|
||||||
|
'@csstools/color-helpers@5.0.2':
|
||||||
|
resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@csstools/css-calc@2.1.4':
|
||||||
|
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@csstools/css-parser-algorithms': ^3.0.5
|
||||||
|
'@csstools/css-tokenizer': ^3.0.4
|
||||||
|
|
||||||
|
'@csstools/css-color-parser@3.0.10':
|
||||||
|
resolution: {integrity: sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@csstools/css-parser-algorithms': ^3.0.5
|
||||||
|
'@csstools/css-tokenizer': ^3.0.4
|
||||||
|
|
||||||
|
'@csstools/css-parser-algorithms@3.0.5':
|
||||||
|
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@csstools/css-tokenizer': ^3.0.4
|
||||||
|
|
||||||
|
'@csstools/css-tokenizer@3.0.4':
|
||||||
|
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@dabh/diagnostics@2.0.3':
|
'@dabh/diagnostics@2.0.3':
|
||||||
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
|
||||||
|
|
||||||
@@ -245,6 +309,14 @@ packages:
|
|||||||
'@design.estate/dees-element@2.1.2':
|
'@design.estate/dees-element@2.1.2':
|
||||||
resolution: {integrity: sha512-ZiwvE411RJPHaYio26asQLnSmtJ6G1HRLYWbxW/HvCMbFtrcrXysP1y4PQ9KjdNfiQ4yoWPjTtwYMJjLE0NcbA==}
|
resolution: {integrity: sha512-ZiwvE411RJPHaYio26asQLnSmtJ6G1HRLYWbxW/HvCMbFtrcrXysP1y4PQ9KjdNfiQ4yoWPjTtwYMJjLE0NcbA==}
|
||||||
|
|
||||||
|
'@e-invoice-eu/core@2.1.9':
|
||||||
|
resolution: {integrity: sha512-chXUSUpN7FjHxdDa546bkLhkpVZCJEzpluueYaftFVqYWrZa6/G8MDvMslezEIvtsdxqHun1Se6aR+98YKOTQw==}
|
||||||
|
|
||||||
|
'@e965/xlsx@0.20.3':
|
||||||
|
resolution: {integrity: sha512-703RN/3OdsRD5mtse2HBX7Um7xwaP9tlswEG6svOtjqokXoX7rJdQj7DyabD2I+xk22RgaIIU+R6BHgkpZGB/w==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@emnapi/core@1.4.5':
|
'@emnapi/core@1.4.5':
|
||||||
resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==}
|
resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==}
|
||||||
|
|
||||||
@@ -410,6 +482,12 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@esgettext/runtime@1.3.6':
|
||||||
|
resolution: {integrity: sha512-4vMSr6i4WiQrrP8XXMxtQoDvkbRgVqmivta9j1YDPBzRaRIGTN2Nu+ADu8cv2WfKk5GkVUUqDpCxfbXOKCN+Cg==}
|
||||||
|
|
||||||
|
'@fin.cx/einvoice@5.1.4':
|
||||||
|
resolution: {integrity: sha512-pCtqIBsFB9wB4s4mb8zTg29JEL/H8cc2rl4pjLeRP70qOCXZd4ICOU/YZXuxgJlzi/vO+XXGWobt09Z3+Msqgg==}
|
||||||
|
|
||||||
'@git.zone/tsbuild@2.6.4':
|
'@git.zone/tsbuild@2.6.4':
|
||||||
resolution: {integrity: sha512-eeNW5hnXHU9lPzTaMbtdYDkb6cpFFC8fF5849BiwLO4N1Ga9Q5Om/6w5SZyJQcct8rHjcTgOOWdlxhjeKCr6NQ==}
|
resolution: {integrity: sha512-eeNW5hnXHU9lPzTaMbtdYDkb6cpFFC8fF5849BiwLO4N1Ga9Q5Om/6w5SZyJQcct8rHjcTgOOWdlxhjeKCr6NQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -446,6 +524,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@jsep-plugin/assignment@1.3.0':
|
||||||
|
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
|
||||||
|
engines: {node: '>= 10.16.0'}
|
||||||
|
peerDependencies:
|
||||||
|
jsep: ^0.4.0||^1.0.0
|
||||||
|
|
||||||
|
'@jsep-plugin/regex@1.0.4':
|
||||||
|
resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==}
|
||||||
|
engines: {node: '>= 10.16.0'}
|
||||||
|
peerDependencies:
|
||||||
|
jsep: ^0.4.0||^1.0.0
|
||||||
|
|
||||||
'@koa/router@9.4.0':
|
'@koa/router@9.4.0':
|
||||||
resolution: {integrity: sha512-dOOXgzqaDoHu5qqMEPLKEgLz5CeIA7q8+1W62mCvFVCOqeC71UoTGJ4u1xUSOpIl2J1x2pqrNULkFteUeZW3/A==}
|
resolution: {integrity: sha512-dOOXgzqaDoHu5qqMEPLKEgLz5CeIA7q8+1W62mCvFVCOqeC71UoTGJ4u1xUSOpIl2J1x2pqrNULkFteUeZW3/A==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
@@ -1427,6 +1517,10 @@ packages:
|
|||||||
'@ungap/structured-clone@1.3.0':
|
'@ungap/structured-clone@1.3.0':
|
||||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||||
|
|
||||||
|
'@xmldom/xmldom@0.9.8':
|
||||||
|
resolution: {integrity: sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==}
|
||||||
|
engines: {node: '>=14.6'}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -1443,6 +1537,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
|
|
||||||
|
ajv@8.17.1:
|
||||||
|
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||||
|
|
||||||
ansi-256-colors@1.1.0:
|
ansi-256-colors@1.1.0:
|
||||||
resolution: {integrity: sha1-kQ3lDvzHwJ49gvL4er1rcAwYgYo=}
|
resolution: {integrity: sha1-kQ3lDvzHwJ49gvL4er1rcAwYgYo=}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1493,6 +1590,9 @@ packages:
|
|||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=}
|
resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=}
|
||||||
|
|
||||||
|
axios@1.11.0:
|
||||||
|
resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==}
|
||||||
|
|
||||||
b4a@1.6.7:
|
b4a@1.6.7:
|
||||||
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
|
resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==}
|
||||||
|
|
||||||
@@ -1583,6 +1683,9 @@ packages:
|
|||||||
buffer-json@2.0.0:
|
buffer-json@2.0.0:
|
||||||
resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==}
|
resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==}
|
||||||
|
|
||||||
|
buffer-reverse@1.0.1:
|
||||||
|
resolution: {integrity: sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
@@ -1700,6 +1803,10 @@ packages:
|
|||||||
color@3.2.1:
|
color@3.2.1:
|
||||||
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
|
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
|
||||||
|
|
||||||
|
color@4.2.3:
|
||||||
|
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||||
|
engines: {node: '>=12.5.0'}
|
||||||
|
|
||||||
colorspace@1.1.4:
|
colorspace@1.1.4:
|
||||||
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
|
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
|
||||||
|
|
||||||
@@ -1792,14 +1899,25 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
crypto-js@4.2.0:
|
||||||
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
|
|
||||||
crypto-random-string@5.0.0:
|
crypto-random-string@5.0.0:
|
||||||
resolution: {integrity: sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==}
|
resolution: {integrity: sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
cssstyle@4.6.0:
|
||||||
|
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
data-uri-to-buffer@6.0.2:
|
data-uri-to-buffer@6.0.2:
|
||||||
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
data-urls@5.0.0:
|
||||||
|
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
date-fns@4.1.0:
|
date-fns@4.1.0:
|
||||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
@@ -1832,6 +1950,9 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decimal.js@10.6.0:
|
||||||
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.2.0:
|
||||||
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
|
||||||
|
|
||||||
@@ -1958,6 +2079,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
env-paths@2.2.1:
|
env-paths@2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2071,6 +2196,9 @@ packages:
|
|||||||
fast-json-stable-stringify@2.1.0:
|
fast-json-stable-stringify@2.1.0:
|
||||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||||
|
|
||||||
|
fast-uri@3.0.6:
|
||||||
|
resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==}
|
||||||
|
|
||||||
fast-xml-parser@3.21.1:
|
fast-xml-parser@3.21.1:
|
||||||
resolution: {integrity: sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==}
|
resolution: {integrity: sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2299,6 +2427,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
html-encoding-sniffer@4.0.0:
|
||||||
|
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
html-entities@2.6.0:
|
||||||
|
resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==}
|
||||||
|
|
||||||
html-minifier@4.0.0:
|
html-minifier@4.0.0:
|
||||||
resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==}
|
resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2422,6 +2557,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
is-potential-custom-element-name@1.0.1:
|
||||||
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
is-promise@4.0.0:
|
is-promise@4.0.0:
|
||||||
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||||
|
|
||||||
@@ -2490,18 +2628,39 @@ packages:
|
|||||||
jsbn@1.1.0:
|
jsbn@1.1.0:
|
||||||
resolution: {integrity: sha1-sBMHyym2GKHtJux56RH4A8TaAEA=}
|
resolution: {integrity: sha1-sBMHyym2GKHtJux56RH4A8TaAEA=}
|
||||||
|
|
||||||
|
jsdom@26.1.0:
|
||||||
|
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
canvas: ^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
canvas:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
jsep@1.4.0:
|
||||||
|
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
|
||||||
|
engines: {node: '>= 10.16.0'}
|
||||||
|
|
||||||
json-buffer@3.0.1:
|
json-buffer@3.0.1:
|
||||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||||
|
|
||||||
json-parse-even-better-errors@2.3.1:
|
json-parse-even-better-errors@2.3.1:
|
||||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0:
|
||||||
|
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||||
|
|
||||||
jsonfile@4.0.0:
|
jsonfile@4.0.0:
|
||||||
resolution: {integrity: sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=}
|
resolution: {integrity: sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=}
|
||||||
|
|
||||||
jsonfile@6.1.0:
|
jsonfile@6.1.0:
|
||||||
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
|
||||||
|
|
||||||
|
jsonpath-plus@10.3.0:
|
||||||
|
resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
keygrip@1.1.0:
|
keygrip@1.1.0:
|
||||||
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2710,6 +2869,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
merkletreejs@0.4.1:
|
||||||
|
resolution: {integrity: sha512-W2VSHeGTdAnWtedee+pgGn7SHvncMdINnMeHAaXrfarSaMNLff/pm7RCr/QXYxN6XzJFgJZY+28ejO0lAosW4A==}
|
||||||
|
engines: {node: '>= 7.6.0'}
|
||||||
|
|
||||||
methods@1.1.2:
|
methods@1.1.2:
|
||||||
resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=}
|
resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2956,6 +3119,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
|
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
|
|
||||||
|
node-html-better-parser@1.5.3:
|
||||||
|
resolution: {integrity: sha512-rvnbT4FUS+pIQPAs3bBpzeuWdgdjne0LsgrEINdsMfAvjAKHTEGVhknMEqBriGuVRWM8iRL1LKhRhZ9RB6gPVA==}
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==}
|
resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -2964,6 +3130,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==}
|
resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
nwsapi@2.2.21:
|
||||||
|
resolution: {integrity: sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=}
|
resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3049,6 +3218,9 @@ packages:
|
|||||||
pako@1.0.11:
|
pako@1.0.11:
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
param-case@2.1.1:
|
param-case@2.1.1:
|
||||||
resolution: {integrity: sha1-35T9jPZTHs915r75oIWPvHK+Ikc=}
|
resolution: {integrity: sha1-35T9jPZTHs915r75oIWPvHK+Ikc=}
|
||||||
|
|
||||||
@@ -3072,6 +3244,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -3289,6 +3464,10 @@ packages:
|
|||||||
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-from-string@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
resolve-alpn@1.2.1:
|
resolve-alpn@1.2.1:
|
||||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||||
|
|
||||||
@@ -3315,6 +3494,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
rrweb-cssom@0.8.0:
|
||||||
|
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
|
||||||
|
|
||||||
rss-parser@3.13.0:
|
rss-parser@3.13.0:
|
||||||
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
|
resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==}
|
||||||
|
|
||||||
@@ -3346,6 +3528,13 @@ packages:
|
|||||||
sax@1.4.1:
|
sax@1.4.1:
|
||||||
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
|
resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==}
|
||||||
|
|
||||||
|
saxes@6.0.0:
|
||||||
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
|
saxon-js@2.7.0:
|
||||||
|
resolution: {integrity: sha512-uGAv7H85EuWtAyyXVezXBg3/j2UvhEfT3N9+sqkGwCJVW33KlkadllDCdES/asCDklUo0UlM6178tZ0n3GPZjQ==}
|
||||||
|
|
||||||
semver@6.3.1:
|
semver@6.3.1:
|
||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3579,6 +3768,20 @@ packages:
|
|||||||
tiny-worker@2.3.0:
|
tiny-worker@2.3.0:
|
||||||
resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==}
|
resolution: {integrity: sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g==}
|
||||||
|
|
||||||
|
tldts-core@6.1.86:
|
||||||
|
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
|
||||||
|
|
||||||
|
tldts@6.1.86:
|
||||||
|
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
tmp-promise@3.0.3:
|
||||||
|
resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==}
|
||||||
|
|
||||||
|
tmp@0.2.5:
|
||||||
|
resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
toidentifier@1.0.1:
|
toidentifier@1.0.1:
|
||||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -3587,6 +3790,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-MD9MjpVNhVyH4fyd5rKphjvt/1qj+PtQUz65aFqAZA6XniWAuSFRjLk3e2VALEFlh9OwBpXUN7rfeqSnT/Fmkw==}
|
resolution: {integrity: sha512-MD9MjpVNhVyH4fyd5rKphjvt/1qj+PtQUz65aFqAZA6XniWAuSFRjLk3e2VALEFlh9OwBpXUN7rfeqSnT/Fmkw==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
|
tough-cookie@5.1.2:
|
||||||
|
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
tr46@3.0.0:
|
tr46@3.0.0:
|
||||||
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
|
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3599,6 +3806,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
treeify@1.1.0:
|
||||||
|
resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
trim-lines@3.0.1:
|
trim-lines@3.0.1:
|
||||||
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
|
||||||
|
|
||||||
@@ -3727,14 +3938,26 @@ packages:
|
|||||||
vfile@6.0.3:
|
vfile@6.0.3:
|
||||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||||
|
|
||||||
|
w3c-xmlserializer@5.0.0:
|
||||||
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
webidl-conversions@7.0.0:
|
webidl-conversions@7.0.0:
|
||||||
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
whatwg-encoding@3.1.1:
|
||||||
|
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
whatwg-mimetype@3.0.0:
|
whatwg-mimetype@3.0.0:
|
||||||
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
whatwg-mimetype@4.0.0:
|
||||||
|
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
whatwg-url@11.0.0:
|
whatwg-url@11.0.0:
|
||||||
resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==}
|
resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3800,6 +4023,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
xml-name-validator@5.0.0:
|
||||||
|
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
xml2js@0.5.0:
|
xml2js@0.5.0:
|
||||||
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
|
resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
@@ -3812,10 +4039,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
xmlchars@2.2.0:
|
||||||
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
xmldom@0.6.0:
|
||||||
|
resolution: {integrity: sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2:
|
xmlhttprequest-ssl@2.1.2:
|
||||||
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
|
xpath@0.0.34:
|
||||||
|
resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==}
|
||||||
|
engines: {node: '>=0.6.0'}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -3935,6 +4173,14 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@asamuzakjp/css-color@3.2.0':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||||
|
'@csstools/css-color-parser': 3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||||
|
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||||
|
'@csstools/css-tokenizer': 3.0.4
|
||||||
|
lru-cache: 10.4.3
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/util': 5.2.0
|
'@aws-crypto/util': 5.2.0
|
||||||
@@ -4494,6 +4740,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime: 0.14.1
|
regenerator-runtime: 0.14.1
|
||||||
|
|
||||||
|
'@cantoo/pdf-lib@2.4.2':
|
||||||
|
dependencies:
|
||||||
|
'@pdf-lib/standard-fonts': 1.0.0
|
||||||
|
'@pdf-lib/upng': 1.0.1
|
||||||
|
color: 4.2.3
|
||||||
|
crypto-js: 4.2.0
|
||||||
|
node-html-better-parser: 1.5.3
|
||||||
|
pako: 1.0.11
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20250809.0': {}
|
'@cloudflare/workers-types@4.20250809.0': {}
|
||||||
|
|
||||||
'@colors/colors@1.6.0': {}
|
'@colors/colors@1.6.0': {}
|
||||||
@@ -4502,6 +4758,26 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@api.global/typedrequest-interfaces': 3.0.19
|
'@api.global/typedrequest-interfaces': 3.0.19
|
||||||
|
|
||||||
|
'@csstools/color-helpers@5.0.2': {}
|
||||||
|
|
||||||
|
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||||
|
'@csstools/css-tokenizer': 3.0.4
|
||||||
|
|
||||||
|
'@csstools/css-color-parser@3.0.10(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/color-helpers': 5.0.2
|
||||||
|
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
|
||||||
|
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||||
|
'@csstools/css-tokenizer': 3.0.4
|
||||||
|
|
||||||
|
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
|
||||||
|
dependencies:
|
||||||
|
'@csstools/css-tokenizer': 3.0.4
|
||||||
|
|
||||||
|
'@csstools/css-tokenizer@3.0.4': {}
|
||||||
|
|
||||||
'@dabh/diagnostics@2.0.3':
|
'@dabh/diagnostics@2.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
colorspace: 1.1.4
|
colorspace: 1.1.4
|
||||||
@@ -4553,6 +4829,18 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@e-invoice-eu/core@2.1.9':
|
||||||
|
dependencies:
|
||||||
|
'@cantoo/pdf-lib': 2.4.2
|
||||||
|
'@e965/xlsx': 0.20.3
|
||||||
|
'@esgettext/runtime': 1.3.6
|
||||||
|
ajv: 8.17.1
|
||||||
|
jsonpath-plus: 10.3.0
|
||||||
|
tmp-promise: 3.0.3
|
||||||
|
xmlbuilder2: 3.1.1
|
||||||
|
|
||||||
|
'@e965/xlsx@0.20.3': {}
|
||||||
|
|
||||||
'@emnapi/core@1.4.5':
|
'@emnapi/core@1.4.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.0.4
|
'@emnapi/wasi-threads': 1.0.4
|
||||||
@@ -4647,6 +4935,27 @@ snapshots:
|
|||||||
'@esbuild/win32-x64@0.25.8':
|
'@esbuild/win32-x64@0.25.8':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@esgettext/runtime@1.3.6': {}
|
||||||
|
|
||||||
|
'@fin.cx/einvoice@5.1.4':
|
||||||
|
dependencies:
|
||||||
|
'@push.rocks/smartfile': 11.2.5
|
||||||
|
'@push.rocks/smartxml': 1.1.1
|
||||||
|
'@tsclass/tsclass': 9.2.0
|
||||||
|
'@xmldom/xmldom': 0.9.8
|
||||||
|
jsdom: 26.1.0
|
||||||
|
pako: 2.1.0
|
||||||
|
pdf-lib: 1.17.1
|
||||||
|
saxon-js: 2.7.0
|
||||||
|
xmldom: 0.6.0
|
||||||
|
xpath: 0.0.34
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- canvas
|
||||||
|
- debug
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
'@git.zone/tsbuild@2.6.4':
|
'@git.zone/tsbuild@2.6.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@git.zone/tspublish': 1.10.1
|
'@git.zone/tspublish': 1.10.1
|
||||||
@@ -4766,6 +5075,14 @@ snapshots:
|
|||||||
wrap-ansi: 8.1.0
|
wrap-ansi: 8.1.0
|
||||||
wrap-ansi-cjs: wrap-ansi@7.0.0
|
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||||
|
|
||||||
|
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
|
||||||
|
dependencies:
|
||||||
|
jsep: 1.4.0
|
||||||
|
|
||||||
|
'@jsep-plugin/regex@1.0.4(jsep@1.4.0)':
|
||||||
|
dependencies:
|
||||||
|
jsep: 1.4.0
|
||||||
|
|
||||||
'@koa/router@9.4.0':
|
'@koa/router@9.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
@@ -6340,6 +6657,8 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.0': {}
|
'@ungap/structured-clone@1.3.0': {}
|
||||||
|
|
||||||
|
'@xmldom/xmldom@0.9.8': {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
@@ -6356,6 +6675,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
humanize-ms: 1.2.1
|
||||||
|
|
||||||
|
ajv@8.17.1:
|
||||||
|
dependencies:
|
||||||
|
fast-deep-equal: 3.1.3
|
||||||
|
fast-uri: 3.0.6
|
||||||
|
json-schema-traverse: 1.0.0
|
||||||
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
ansi-256-colors@1.1.0: {}
|
ansi-256-colors@1.1.0: {}
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
@@ -6394,6 +6720,14 @@ snapshots:
|
|||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
axios@1.11.0:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11(debug@4.4.1)
|
||||||
|
form-data: 4.0.4
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
b4a@1.6.7: {}
|
b4a@1.6.7: {}
|
||||||
|
|
||||||
bail@2.0.2: {}
|
bail@2.0.2: {}
|
||||||
@@ -6496,6 +6830,8 @@ snapshots:
|
|||||||
|
|
||||||
buffer-json@2.0.0: {}
|
buffer-json@2.0.0: {}
|
||||||
|
|
||||||
|
buffer-reverse@1.0.1: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
@@ -6621,6 +6957,11 @@ snapshots:
|
|||||||
color-convert: 1.9.3
|
color-convert: 1.9.3
|
||||||
color-string: 1.9.1
|
color-string: 1.9.1
|
||||||
|
|
||||||
|
color@4.2.3:
|
||||||
|
dependencies:
|
||||||
|
color-convert: 2.0.1
|
||||||
|
color-string: 1.9.1
|
||||||
|
|
||||||
colorspace@1.1.4:
|
colorspace@1.1.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
color: 3.2.1
|
color: 3.2.1
|
||||||
@@ -6698,12 +7039,24 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
crypto-js@4.2.0: {}
|
||||||
|
|
||||||
crypto-random-string@5.0.0:
|
crypto-random-string@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 2.19.0
|
type-fest: 2.19.0
|
||||||
|
|
||||||
|
cssstyle@4.6.0:
|
||||||
|
dependencies:
|
||||||
|
'@asamuzakjp/css-color': 3.2.0
|
||||||
|
rrweb-cssom: 0.8.0
|
||||||
|
|
||||||
data-uri-to-buffer@6.0.2: {}
|
data-uri-to-buffer@6.0.2: {}
|
||||||
|
|
||||||
|
data-urls@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-mimetype: 4.0.0
|
||||||
|
whatwg-url: 14.2.0
|
||||||
|
|
||||||
date-fns@4.1.0: {}
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
dayjs@1.11.13: {}
|
dayjs@1.11.13: {}
|
||||||
@@ -6720,6 +7073,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.2.0:
|
decode-named-character-reference@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
character-entities: 2.0.2
|
character-entities: 2.0.2
|
||||||
@@ -6849,6 +7204,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
error-ex@1.3.2:
|
error-ex@1.3.2:
|
||||||
@@ -7023,6 +7380,8 @@ snapshots:
|
|||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
|
|
||||||
|
fast-uri@3.0.6: {}
|
||||||
|
|
||||||
fast-xml-parser@3.21.1:
|
fast-xml-parser@3.21.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
strnum: 1.1.2
|
strnum: 1.1.2
|
||||||
@@ -7325,6 +7684,12 @@ snapshots:
|
|||||||
|
|
||||||
he@1.2.0: {}
|
he@1.2.0: {}
|
||||||
|
|
||||||
|
html-encoding-sniffer@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
|
html-entities@2.6.0: {}
|
||||||
|
|
||||||
html-minifier@4.0.0:
|
html-minifier@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
camel-case: 3.0.0
|
camel-case: 3.0.0
|
||||||
@@ -7453,6 +7818,8 @@ snapshots:
|
|||||||
|
|
||||||
is-plain-obj@4.1.0: {}
|
is-plain-obj@4.1.0: {}
|
||||||
|
|
||||||
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
is-promise@4.0.0: {}
|
is-promise@4.0.0: {}
|
||||||
|
|
||||||
is-regex@1.2.1:
|
is-regex@1.2.1:
|
||||||
@@ -7509,10 +7876,41 @@ snapshots:
|
|||||||
|
|
||||||
jsbn@1.1.0: {}
|
jsbn@1.1.0: {}
|
||||||
|
|
||||||
|
jsdom@26.1.0:
|
||||||
|
dependencies:
|
||||||
|
cssstyle: 4.6.0
|
||||||
|
data-urls: 5.0.0
|
||||||
|
decimal.js: 10.6.0
|
||||||
|
html-encoding-sniffer: 4.0.0
|
||||||
|
http-proxy-agent: 7.0.2
|
||||||
|
https-proxy-agent: 7.0.6
|
||||||
|
is-potential-custom-element-name: 1.0.1
|
||||||
|
nwsapi: 2.2.21
|
||||||
|
parse5: 7.3.0
|
||||||
|
rrweb-cssom: 0.8.0
|
||||||
|
saxes: 6.0.0
|
||||||
|
symbol-tree: 3.2.4
|
||||||
|
tough-cookie: 5.1.2
|
||||||
|
w3c-xmlserializer: 5.0.0
|
||||||
|
webidl-conversions: 7.0.0
|
||||||
|
whatwg-encoding: 3.1.1
|
||||||
|
whatwg-mimetype: 4.0.0
|
||||||
|
whatwg-url: 14.2.0
|
||||||
|
ws: 8.18.3
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
jsep@1.4.0: {}
|
||||||
|
|
||||||
json-buffer@3.0.1: {}
|
json-buffer@3.0.1: {}
|
||||||
|
|
||||||
json-parse-even-better-errors@2.3.1: {}
|
json-parse-even-better-errors@2.3.1: {}
|
||||||
|
|
||||||
|
json-schema-traverse@1.0.0: {}
|
||||||
|
|
||||||
jsonfile@4.0.0:
|
jsonfile@4.0.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -7523,6 +7921,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
|
jsonpath-plus@10.3.0:
|
||||||
|
dependencies:
|
||||||
|
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
|
||||||
|
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
|
||||||
|
jsep: 1.4.0
|
||||||
|
|
||||||
keygrip@1.1.0:
|
keygrip@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tsscmp: 1.0.6
|
tsscmp: 1.0.6
|
||||||
@@ -7822,6 +8226,12 @@ snapshots:
|
|||||||
|
|
||||||
merge-descriptors@2.0.0: {}
|
merge-descriptors@2.0.0: {}
|
||||||
|
|
||||||
|
merkletreejs@0.4.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-reverse: 1.0.1
|
||||||
|
crypto-js: 4.2.0
|
||||||
|
treeify: 1.1.0
|
||||||
|
|
||||||
methods@1.1.2: {}
|
methods@1.1.2: {}
|
||||||
|
|
||||||
micromark-core-commonmark@2.0.3:
|
micromark-core-commonmark@2.0.3:
|
||||||
@@ -8167,12 +8577,18 @@ snapshots:
|
|||||||
|
|
||||||
node-forge@1.3.1: {}
|
node-forge@1.3.1: {}
|
||||||
|
|
||||||
|
node-html-better-parser@1.5.3:
|
||||||
|
dependencies:
|
||||||
|
html-entities: 2.6.0
|
||||||
|
|
||||||
normalize-newline@4.1.0:
|
normalize-newline@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
replace-buffer: 1.2.1
|
replace-buffer: 1.2.1
|
||||||
|
|
||||||
normalize-url@8.0.2: {}
|
normalize-url@8.0.2: {}
|
||||||
|
|
||||||
|
nwsapi@2.2.21: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -8257,6 +8673,8 @@ snapshots:
|
|||||||
|
|
||||||
pako@1.0.11: {}
|
pako@1.0.11: {}
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
param-case@2.1.1:
|
param-case@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
no-case: 2.3.2
|
no-case: 2.3.2
|
||||||
@@ -8278,6 +8696,10 @@ snapshots:
|
|||||||
|
|
||||||
parse-ms@4.0.0: {}
|
parse-ms@4.0.0: {}
|
||||||
|
|
||||||
|
parse5@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
passthrough-counter@1.0.0: {}
|
passthrough-counter@1.0.0: {}
|
||||||
@@ -8544,6 +8966,8 @@ snapshots:
|
|||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resolve-alpn@1.2.1: {}
|
resolve-alpn@1.2.1: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -8590,6 +9014,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
rrweb-cssom@0.8.0: {}
|
||||||
|
|
||||||
rss-parser@3.13.0:
|
rss-parser@3.13.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 2.2.0
|
entities: 2.2.0
|
||||||
@@ -8631,6 +9057,16 @@ snapshots:
|
|||||||
|
|
||||||
sax@1.4.1: {}
|
sax@1.4.1: {}
|
||||||
|
|
||||||
|
saxes@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
|
saxon-js@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
axios: 1.11.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.7.2: {}
|
semver@7.7.2: {}
|
||||||
@@ -8959,6 +9395,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esm: 3.2.25
|
esm: 3.2.25
|
||||||
|
|
||||||
|
tldts-core@6.1.86: {}
|
||||||
|
|
||||||
|
tldts@6.1.86:
|
||||||
|
dependencies:
|
||||||
|
tldts-core: 6.1.86
|
||||||
|
|
||||||
|
tmp-promise@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
tmp: 0.2.5
|
||||||
|
|
||||||
|
tmp@0.2.5: {}
|
||||||
|
|
||||||
toidentifier@1.0.1: {}
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
token-types@6.0.4:
|
token-types@6.0.4:
|
||||||
@@ -8966,6 +9414,10 @@ snapshots:
|
|||||||
'@tokenizer/token': 0.3.0
|
'@tokenizer/token': 0.3.0
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
tough-cookie@5.1.2:
|
||||||
|
dependencies:
|
||||||
|
tldts: 6.1.86
|
||||||
|
|
||||||
tr46@3.0.0:
|
tr46@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
@@ -8976,6 +9428,8 @@ snapshots:
|
|||||||
|
|
||||||
tree-kill@1.2.2: {}
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
|
treeify@1.1.0: {}
|
||||||
|
|
||||||
trim-lines@3.0.1: {}
|
trim-lines@3.0.1: {}
|
||||||
|
|
||||||
triple-beam@1.4.1: {}
|
triple-beam@1.4.1: {}
|
||||||
@@ -9092,10 +9546,20 @@ snapshots:
|
|||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
vfile-message: 4.0.3
|
vfile-message: 4.0.3
|
||||||
|
|
||||||
|
w3c-xmlserializer@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|
||||||
|
whatwg-encoding@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
iconv-lite: 0.6.3
|
||||||
|
|
||||||
whatwg-mimetype@3.0.0: {}
|
whatwg-mimetype@3.0.0: {}
|
||||||
|
|
||||||
|
whatwg-mimetype@4.0.0: {}
|
||||||
|
|
||||||
whatwg-url@11.0.0:
|
whatwg-url@11.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tr46: 3.0.0
|
tr46: 3.0.0
|
||||||
@@ -9156,6 +9620,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
sax: 1.4.1
|
sax: 1.4.1
|
||||||
|
|
||||||
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
xml2js@0.5.0:
|
xml2js@0.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
sax: 1.4.1
|
sax: 1.4.1
|
||||||
@@ -9170,8 +9636,14 @@ snapshots:
|
|||||||
|
|
||||||
xmlbuilder@11.0.1: {}
|
xmlbuilder@11.0.1: {}
|
||||||
|
|
||||||
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xmldom@0.6.0: {}
|
||||||
|
|
||||||
xmlhttprequest-ssl@2.1.2: {}
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
|
xpath@0.0.34: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
+54
-1
@@ -1,3 +1,56 @@
|
|||||||
# Project Readme Hints
|
# Project Readme Hints
|
||||||
|
|
||||||
This is the initial readme hints file.
|
## Current Status (2025-10-27)
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
✅ **ALL 65/65 TESTS PASSING** (100%)
|
||||||
|
|
||||||
|
### Recent Fixes
|
||||||
|
|
||||||
|
#### Fixed: SKR04 Bug (Account 3300 Misclassification)
|
||||||
|
**Problem**: Account 3300 was incorrectly hardcoded as an automatic account for SKR04
|
||||||
|
**Root Cause**: Bug in `ts/skr.classes.account.ts:192` - account 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
|
||||||
|
**Solution**:
|
||||||
|
1. Removed 3300 from automatic accounts list in `isAutomaticAccount()` method
|
||||||
|
2. Updated test.skr04.ts to use timestamped database names to avoid conflicts
|
||||||
|
**Files Changed**:
|
||||||
|
- `ts/skr.classes.account.ts` - Fixed automatic account detection
|
||||||
|
- `test/test.skr04.ts` - Added timestamp to database name
|
||||||
|
|
||||||
|
**Result**: ✅ All SKR04 tests now passing (jahresabschluss.skr04 + basic SKR04 tests)
|
||||||
|
|
||||||
|
### Architecture Notes
|
||||||
|
|
||||||
|
#### VAT Validation Logic (Recent Changes)
|
||||||
|
- **skr.classes.journalentry.ts:224-273**: Detects VAT lines in entries to enable smart validation
|
||||||
|
- **skr.postingkeys.ts:87-100**: Exempts VAT accounts and debtor/creditor accounts from VAT amount requirements
|
||||||
|
- **Rationale**: VAT accounts ARE the VAT; settlement transactions don't need VAT details again
|
||||||
|
|
||||||
|
#### Posting Key Usage Pattern
|
||||||
|
- **Tax-free operations** (key 40): Internal adjustments, depreciation, closing entries
|
||||||
|
- **VAT operations** (keys 3, 8, 9, 19, 94): Customer/supplier transactions
|
||||||
|
- **Best practice**: Use posting key 40 for non-VAT lines in mixed entries
|
||||||
|
|
||||||
|
#### Account Structure
|
||||||
|
- **Automatic accounts**: Cannot be posted to directly (1400 Debtors, 1600 Creditors, 3300 Bank)
|
||||||
|
- **Personal accounts**: Created in ranges 10000-69999 (debtors), 70000-99999 (creditors)
|
||||||
|
- **System enforces**: Must use personal variants instead of automatic accounts
|
||||||
|
|
||||||
|
### Validation Pipeline
|
||||||
|
1. **Line-level**: Posting key required, account exists, VAT rules
|
||||||
|
2. **Posting key level**: VAT amount requirements (with exemptions)
|
||||||
|
3. **Consistency level**: No mixing tax-free and taxed (unless intentional)
|
||||||
|
4. **Balance level**: Debits must equal credits (0.01 tolerance)
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- 65 test cases covering full accounting cycle
|
||||||
|
- Complete Jahresabschluss (annual closing) workflow in SKR03
|
||||||
|
- Report generation (Trial Balance, Income Statement, Balance Sheet)
|
||||||
|
- Transaction reversal and audit trails
|
||||||
|
- DATEV posting key validation
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- MongoDB via @push.rocks/smartdata for persistence
|
||||||
|
- TypeScript 5.8.3 with strict mode
|
||||||
|
- @git.zone/tstest for testing framework
|
||||||
|
- @push.rocks/smartexpect for assertions
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# @fin.cx/skr 📊
|
# @fin.cx/skr 📊
|
||||||
|
|
||||||
> **Enterprise-grade German accounting standards implementation for SKR03 and SKR04**
|
> **Enterprise-grade German accounting standards implementation for SKR03 and SKR04**
|
||||||
> Double-entry bookkeeping with MongoDB persistence and full TypeScript support
|
> Rock-solid double-entry bookkeeping with MongoDB persistence, e-invoice integration, and full TypeScript support
|
||||||
|
|
||||||
## 🚀 Why @fin.cx/skr?
|
## 🚀 Why @fin.cx/skr?
|
||||||
|
|
||||||
@@ -9,12 +9,17 @@ Building compliant German accounting software? You've come to the right place! T
|
|||||||
|
|
||||||
### 🎯 What makes it awesome?
|
### 🎯 What makes it awesome?
|
||||||
|
|
||||||
- **🏢 Enterprise-Ready**: Production-tested implementation following DATEV standards
|
- **🏢 Enterprise-Ready**: Production-tested implementation following HGB/GoBD standards
|
||||||
- **⚡ Lightning Fast**: MongoDB-powered with optimized indexing and caching
|
- **⚡ Lightning Fast**: MongoDB-powered with optimized indexing and real-time balance updates
|
||||||
- **🔒 Type-Safe**: Full TypeScript support with comprehensive type definitions
|
- **🔒 Type-Safe**: Full TypeScript support with comprehensive type definitions
|
||||||
- **🎮 Developer-Friendly**: Intuitive API that makes complex accounting operations simple
|
- **🎮 Developer-Friendly**: Intuitive API that makes complex accounting operations simple
|
||||||
- **📈 Real-time Reporting**: Generate financial statements on-the-fly
|
- **📈 Real-time Reporting**: Generate financial statements on-the-fly
|
||||||
- **🔄 Transaction Safety**: Built-in double-entry validation and reversals
|
- **🔄 Transaction Safety**: Built-in double-entry validation and automatic reversals
|
||||||
|
- **✅ Battle-Tested**: 65+ comprehensive tests covering all edge cases
|
||||||
|
- **🛡️ SKR Validation**: Automatic validation against official SKR standards
|
||||||
|
- **🧾 E-Invoice Support**: Full XRechnung/ZUGFeRD integration for modern invoice processing
|
||||||
|
- **🔐 Cryptographic Security**: Merkle tree and digital signature support for audit trails
|
||||||
|
- **📑 PDF Export**: Professional PDF report generation with customizable templates
|
||||||
|
|
||||||
## 📦 Installation
|
## 📦 Installation
|
||||||
|
|
||||||
@@ -67,42 +72,153 @@ const journalEntry = await api.postJournalEntry({
|
|||||||
reference: 'SAL-2024-03',
|
reference: 'SAL-2024-03',
|
||||||
lines: [
|
lines: [
|
||||||
{ accountNumber: '6000', debit: 5000.00, description: 'Gross salary' },
|
{ accountNumber: '6000', debit: 5000.00, description: 'Gross salary' },
|
||||||
{ accountNumber: '4830', credit: 1000.00, description: 'Social security' },
|
{ accountNumber: '6100', debit: 1000.00, description: 'Social security employer' },
|
||||||
{ accountNumber: '4840', credit: 500.00, description: 'Tax withholding' },
|
{ accountNumber: '1800', credit: 1500.00, description: 'Tax withholding' },
|
||||||
{ accountNumber: '1200', credit: 3500.00, description: 'Net payment' }
|
{ accountNumber: '1200', credit: 4500.00, description: 'Net payment' }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 📊 Generating Reports
|
### 🧾 E-Invoice Integration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Trial Balance
|
// Import electronic invoices (XRechnung/ZUGFeRD)
|
||||||
|
const invoiceData = await api.importInvoice(xmlContent, {
|
||||||
|
format: 'xrechnung',
|
||||||
|
validateSchema: true,
|
||||||
|
checkDuplicates: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically book invoice to accounting
|
||||||
|
const booking = await api.bookInvoice(invoiceData.invoiceId, {
|
||||||
|
autoDetectAccounts: true,
|
||||||
|
splitVAT: true,
|
||||||
|
createPaymentSchedule: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export invoice in various formats
|
||||||
|
const xRechnung = await api.exportInvoice(invoiceId, {
|
||||||
|
format: 'xrechnung',
|
||||||
|
version: '3.0',
|
||||||
|
includeAttachments: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search and filter invoices
|
||||||
|
const invoices = await api.searchInvoices({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
status: 'booked',
|
||||||
|
minAmount: 100,
|
||||||
|
customerVATId: 'DE123456789'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate compliance reports
|
||||||
|
const complianceReport = await api.createInvoiceComplianceReport({
|
||||||
|
period: '2024-Q1',
|
||||||
|
includeValidation: true,
|
||||||
|
includeStatistics: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 Generating Financial Reports
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Trial Balance (Summen- und Saldenliste)
|
||||||
const trialBalance = await api.generateTrialBalance({
|
const trialBalance = await api.generateTrialBalance({
|
||||||
dateFrom: new Date('2024-01-01'),
|
dateFrom: new Date('2024-01-01'),
|
||||||
dateTo: new Date('2024-12-31')
|
dateTo: new Date('2024-12-31')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Income Statement (P&L)
|
// Income Statement (GuV - Gewinn- und Verlustrechnung)
|
||||||
const incomeStatement = await api.generateIncomeStatement({
|
const incomeStatement = await api.generateIncomeStatement({
|
||||||
dateFrom: new Date('2024-01-01'),
|
dateFrom: new Date('2024-01-01'),
|
||||||
dateTo: new Date('2024-12-31')
|
dateTo: new Date('2024-12-31')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Balance Sheet
|
// Balance Sheet (Bilanz)
|
||||||
const balanceSheet = await api.generateBalanceSheet({
|
const balanceSheet = await api.generateBalanceSheet({
|
||||||
date: new Date('2024-12-31')
|
date: new Date('2024-12-31')
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export for DATEV
|
// General Ledger Export
|
||||||
const datevExport = await api.exportDatev({
|
const generalLedger = await api.generateGeneralLedger({
|
||||||
dateFrom: new Date('2024-01-01'),
|
dateFrom: new Date('2024-01-01'),
|
||||||
dateTo: new Date('2024-12-31'),
|
dateTo: new Date('2024-12-31')
|
||||||
format: 'CSV'
|
});
|
||||||
|
|
||||||
|
// Cash Flow Statement
|
||||||
|
const cashFlow = await api.generateCashFlowStatement({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31')
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🏗️ Core Architecture
|
### 📑 Advanced Export Features
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Export complete annual closing package (Jahresabschluss)
|
||||||
|
const jahresabschluss = await api.exportJahresabschluss({
|
||||||
|
year: 2024,
|
||||||
|
includeReports: ['balance_sheet', 'income_statement', 'cash_flow'],
|
||||||
|
format: 'structured', // 'structured' | 'pdf' | 'csv'
|
||||||
|
language: 'de',
|
||||||
|
signatureRequired: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate PDF reports with professional formatting
|
||||||
|
const pdfReports = await api.generatePdfReports({
|
||||||
|
reports: ['trial_balance', 'income_statement', 'balance_sheet'],
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
companyInfo: {
|
||||||
|
name: 'Mustermann GmbH',
|
||||||
|
address: 'Hauptstraße 1, 10115 Berlin',
|
||||||
|
taxNumber: 'DE123456789',
|
||||||
|
registrationNumber: 'HRB 12345'
|
||||||
|
},
|
||||||
|
outputPath: './reports/',
|
||||||
|
template: 'professional' // Custom templates available
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export with cryptographic signatures for audit trail
|
||||||
|
const signedExport = await api.signExport({
|
||||||
|
data: jahresabschluss,
|
||||||
|
privateKey: privateKeyPEM,
|
||||||
|
certificate: certificatePEM,
|
||||||
|
includeTimestamp: true,
|
||||||
|
hashAlgorithm: 'SHA256'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detailed account data export
|
||||||
|
const accountExport = await api.exportAccountData({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
format: 'detailed', // 'summary' | 'detailed' | 'tree'
|
||||||
|
includeTransactions: true,
|
||||||
|
includeBalances: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Balance history export for analysis
|
||||||
|
const balanceHistory = await api.exportBalanceData({
|
||||||
|
accounts: ['1200', '1000', '8400'],
|
||||||
|
interval: 'monthly', // 'daily' | 'weekly' | 'monthly' | 'quarterly'
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
includeRunningTotals: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ledger export with filtering options
|
||||||
|
const ledgerExport = await api.exportLedgerData({
|
||||||
|
accounts: ['1000-1999'], // Range support
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
includeReversals: false,
|
||||||
|
groupByAccount: true,
|
||||||
|
format: 'journal' // 'journal' | 'T-account' | 'chronological'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Core Features
|
||||||
|
|
||||||
### Account Management
|
### Account Management
|
||||||
|
|
||||||
@@ -117,20 +233,50 @@ const account = await api.createAccount({
|
|||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search accounts
|
// Batch create multiple accounts for efficiency
|
||||||
|
const accounts = await api.createBatchAccounts([
|
||||||
|
{ accountNumber: '1298', accountName: 'Stripe Account', accountClass: 1, accountType: 'asset' },
|
||||||
|
{ accountNumber: '1297', accountName: 'Wise Business', accountClass: 1, accountType: 'asset' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Search accounts by name or number
|
||||||
const accounts = await api.searchAccounts('bank');
|
const accounts = await api.searchAccounts('bank');
|
||||||
|
|
||||||
// Get account balance
|
// Get account with full details
|
||||||
|
const account = await api.getAccount('1200');
|
||||||
|
|
||||||
|
// Update account information
|
||||||
|
await api.updateAccount('1200', {
|
||||||
|
accountName: 'Main Business Bank Account',
|
||||||
|
description: 'Primary operating account'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get account balance with running totals
|
||||||
const balance = await api.getAccountBalance('1200');
|
const balance = await api.getAccountBalance('1200');
|
||||||
console.log(`Balance: ${balance.balance} EUR`);
|
console.log(`Balance: €${balance.balance}`);
|
||||||
console.log(`Debits: ${balance.debitTotal} EUR`);
|
console.log(`Total Debits: €${balance.debitTotal}`);
|
||||||
console.log(`Credits: ${balance.creditTotal} EUR`);
|
console.log(`Total Credits: €${balance.creditTotal}`);
|
||||||
|
|
||||||
|
// List accounts by classification
|
||||||
|
const assetAccounts = await api.getAccountsByType('asset');
|
||||||
|
const class4Accounts = await api.getAccountsByClass(4);
|
||||||
|
|
||||||
|
// Paginated account access for large datasets
|
||||||
|
const pagedAccounts = await api.getAccountsPaginated({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
sortBy: 'accountNumber',
|
||||||
|
sortOrder: 'asc'
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Transaction Management
|
### Transaction Management
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Get transaction history
|
// Get transaction by ID
|
||||||
|
const transaction = await api.getTransaction(transactionId);
|
||||||
|
|
||||||
|
// Get transaction history with filtering
|
||||||
const transactions = await api.listTransactions({
|
const transactions = await api.listTransactions({
|
||||||
accountNumber: '1200',
|
accountNumber: '1200',
|
||||||
dateFrom: new Date('2024-01-01'),
|
dateFrom: new Date('2024-01-01'),
|
||||||
@@ -139,15 +285,35 @@ const transactions = await api.listTransactions({
|
|||||||
maxAmount: 10000
|
maxAmount: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reverse a transaction
|
// Get all transactions for a specific account
|
||||||
|
const accountTransactions = await api.getAccountTransactions('1200', {
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31')
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reverse transactions (Storno)
|
||||||
const reversal = await api.reverseTransaction(transactionId);
|
const reversal = await api.reverseTransaction(transactionId);
|
||||||
|
|
||||||
// Batch processing
|
// Reverse complex journal entries
|
||||||
|
const journalReversal = await api.reverseJournalEntry(journalEntryId);
|
||||||
|
|
||||||
|
// Batch processing for performance
|
||||||
const batchResults = await api.postBatchTransactions([
|
const batchResults = await api.postBatchTransactions([
|
||||||
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 100 },
|
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 100 },
|
||||||
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 200 },
|
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 200 },
|
||||||
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 300 }
|
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 300 }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Paginated access for large datasets
|
||||||
|
const pagedTransactions = await api.getTransactionsPaginated({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
sortBy: 'date',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find unbalanced transactions for audit
|
||||||
|
const unbalanced = await api.getUnbalancedTransactions();
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📚 SKR03 vs SKR04: Which One to Choose?
|
## 📚 SKR03 vs SKR04: Which One to Choose?
|
||||||
@@ -170,7 +336,7 @@ const batchResults = await api.postBatchTransactions([
|
|||||||
|
|
||||||
## 🎯 Account Structure
|
## 🎯 Account Structure
|
||||||
|
|
||||||
Both SKR standards follow the same hierarchical structure:
|
Both SKR standards follow the same 4-digit hierarchical structure:
|
||||||
|
|
||||||
```
|
```
|
||||||
[0-9] → Account Class (Kontenklasse)
|
[0-9] → Account Class (Kontenklasse)
|
||||||
@@ -183,77 +349,135 @@ Both SKR standards follow the same hierarchical structure:
|
|||||||
|
|
||||||
| Class | SKR03 Description | SKR04 Description | Type |
|
| Class | SKR03 Description | SKR04 Description | Type |
|
||||||
|-------|------------------|-------------------|------|
|
|-------|------------------|-------------------|------|
|
||||||
| **0** | Fixed Assets | Fixed Assets | Asset |
|
| **0** | Fixed Assets (Anlagevermögen) | Fixed Assets | Asset |
|
||||||
| **1** | Current Assets | Current Assets | Asset |
|
| **1** | Current Assets (Umlaufvermögen) | Financial & Current Assets | Asset |
|
||||||
| **2** | Equity | Equity | Equity |
|
| **2** | Equity (Eigenkapital) | Expenses Part 1 | Equity/Expense |
|
||||||
| **3** | Liabilities | Liabilities | Liability |
|
| **3** | Liabilities (Fremdkapital) | Expenses Part 2 | Liability/Expense |
|
||||||
| **4** | Operating Income | Operating Income | Revenue |
|
| **4** | Operating Income (Betriebliche Erträge) | Revenues Part 1 | Revenue |
|
||||||
| **5** | Cost of Materials | Cost of Materials | Expense |
|
| **5** | Material Costs (Materialaufwand) | Revenues Part 2 | Expense/Revenue |
|
||||||
| **6** | Operating Expenses | Other Operating Costs | Expense |
|
| **6** | Operating Expenses (Betriebsaufwand) | Special Accounts | Expense |
|
||||||
| **7** | Other Income/Expenses | Other Income/Expenses | Mixed |
|
| **7** | Other Costs (Weitere Aufwendungen) | Cost Accounting | Expense |
|
||||||
| **8** | --- | Financial Results | Mixed |
|
| **8** | Income (Erträge) | Free for Use (Custom) | Revenue |
|
||||||
| **9** | Closing Accounts | Closing Accounts | System |
|
| **9** | Closing Accounts (Abschlusskonten) | Equity & Closing | System |
|
||||||
|
|
||||||
## 🔧 Advanced Features
|
## 🔧 Advanced Features
|
||||||
|
|
||||||
### Ledger Operations
|
### Period Management
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Ledger } from '@fin.cx/skr';
|
// Close accounting period with automatic adjustments
|
||||||
|
await api.closePeriod('2024-01', {
|
||||||
const ledger = new Ledger('SKR03');
|
performYearEndAdjustments: true,
|
||||||
|
generateReports: true
|
||||||
// Post to general ledger
|
|
||||||
await ledger.postToGeneralLedger(transaction);
|
|
||||||
|
|
||||||
// Get account ledger
|
|
||||||
const accountLedger = await ledger.getAccountLedger('1200', {
|
|
||||||
dateFrom: new Date('2024-01-01'),
|
|
||||||
dateTo: new Date('2024-12-31')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close accounting period
|
// Recalculate all account balances
|
||||||
await ledger.closePeriod('2024-01');
|
await api.recalculateBalances();
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Reporting
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { Reports } from '@fin.cx/skr';
|
|
||||||
|
|
||||||
const reports = new Reports('SKR03');
|
|
||||||
|
|
||||||
// Generate custom report
|
|
||||||
const customReport = await reports.generateCustomReport({
|
|
||||||
accounts: ['1200', '1300', '1400'],
|
|
||||||
dateFrom: new Date('2024-01-01'),
|
|
||||||
dateTo: new Date('2024-12-31'),
|
|
||||||
groupBy: 'month',
|
|
||||||
includeSubAccounts: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cash flow statement
|
|
||||||
const cashFlow = await reports.generateCashFlowStatement({
|
|
||||||
year: 2024
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Import/Export
|
### Data Import/Export
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Import from CSV
|
// Import accounts from CSV
|
||||||
const importedCount = await api.importAccountsFromCSV(csvContent);
|
const importedCount = await api.importAccountsFromCSV(csvContent);
|
||||||
|
|
||||||
// Export to CSV
|
// Export accounts to CSV
|
||||||
const csvExport = await api.exportAccountsToCSV();
|
const csvExport = await api.exportAccountsToCSV();
|
||||||
|
|
||||||
// DATEV-compatible export
|
// Export to DATEV format (for tax advisors)
|
||||||
const datevData = await api.exportDatev({
|
const datevExport = await api.exportToDATEV({
|
||||||
consultantNumber: '12345',
|
|
||||||
clientNumber: '67890',
|
|
||||||
dateFrom: new Date('2024-01-01'),
|
dateFrom: new Date('2024-01-01'),
|
||||||
dateTo: new Date('2024-12-31')
|
dateTo: new Date('2024-12-31')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Export reports to CSV
|
||||||
|
const reportCsv = await api.exportReportToCSV('income_statement', {
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31')
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation & Integrity
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Find unbalanced transactions
|
||||||
|
const unbalanced = await api.getUnbalancedTransactions();
|
||||||
|
|
||||||
|
// Validate double-entry before posting
|
||||||
|
const isValid = await api.validateDoubleEntry({
|
||||||
|
debitAccount: '1000',
|
||||||
|
creditAccount: '8400',
|
||||||
|
amount: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
// The API automatically validates all journal entries
|
||||||
|
// Will throw error if entry is unbalanced
|
||||||
|
try {
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date(),
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1000', debit: 100 },
|
||||||
|
{ accountNumber: '8400', credit: 99 } // Unbalanced!
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Journal entry is not balanced!');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invoice Processing & Compliance
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get invoice statistics and analytics
|
||||||
|
const stats = await api.getInvoiceStatistics({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
groupBy: 'month',
|
||||||
|
includeVATAnalysis: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate invoices programmatically
|
||||||
|
const invoice = await api.generateInvoice({
|
||||||
|
invoiceNumber: 'INV-2024-001',
|
||||||
|
date: new Date(),
|
||||||
|
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
seller: {
|
||||||
|
name: 'Your Company GmbH',
|
||||||
|
vatId: 'DE123456789',
|
||||||
|
address: 'Hauptstraße 1, 10115 Berlin'
|
||||||
|
},
|
||||||
|
buyer: {
|
||||||
|
name: 'Customer AG',
|
||||||
|
vatId: 'DE987654321',
|
||||||
|
address: 'Kundenweg 5, 80331 München'
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
description: 'Consulting Services',
|
||||||
|
quantity: 10,
|
||||||
|
unitPrice: 100,
|
||||||
|
vatRate: 19
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate invoice compliance
|
||||||
|
const validation = await api.validateInvoice(invoice, {
|
||||||
|
standard: 'xrechnung',
|
||||||
|
checkBusinessRules: true,
|
||||||
|
checkVATRules: true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Utility Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get SKR type description for account classes
|
||||||
|
const classDesc = api.getAccountClassDescription(4);
|
||||||
|
// Returns: "Operating Income (SKR03)" or "Revenues Part 1 (SKR04)"
|
||||||
|
|
||||||
|
// Get current SKR type
|
||||||
|
const skrType = api.getSKRType(); // Returns: 'SKR03' or 'SKR04'
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛡️ Type Safety
|
## 🛡️ Type Safety
|
||||||
@@ -266,9 +490,21 @@ import type {
|
|||||||
IAccountData,
|
IAccountData,
|
||||||
ITransactionData,
|
ITransactionData,
|
||||||
IJournalEntry,
|
IJournalEntry,
|
||||||
|
IJournalEntryLine,
|
||||||
ITrialBalanceReport,
|
ITrialBalanceReport,
|
||||||
IIncomeStatement,
|
IIncomeStatement,
|
||||||
IBalanceSheet
|
IBalanceSheet,
|
||||||
|
IAccountFilter,
|
||||||
|
ITransactionFilter,
|
||||||
|
IPaginationParams,
|
||||||
|
IAccountBalance,
|
||||||
|
ICashFlowStatement,
|
||||||
|
IGeneralLedger,
|
||||||
|
IInvoice,
|
||||||
|
IInvoiceLine,
|
||||||
|
IInvoiceParty,
|
||||||
|
IBookingRules,
|
||||||
|
IValidationResult
|
||||||
} from '@fin.cx/skr';
|
} from '@fin.cx/skr';
|
||||||
|
|
||||||
// All operations are fully typed
|
// All operations are fully typed
|
||||||
@@ -278,101 +514,186 @@ const account: IAccountData = {
|
|||||||
accountClass: 1,
|
accountClass: 1,
|
||||||
accountType: 'asset',
|
accountType: 'asset',
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
vatRate: 0,
|
|
||||||
isActive: true
|
isActive: true
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TypeScript will catch errors at compile time
|
||||||
|
const filter: IAccountFilter = {
|
||||||
|
accountType: 'asset',
|
||||||
|
isActive: true,
|
||||||
|
accountClass: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Journal entries are validated at type level
|
||||||
|
const journalEntry: IJournalEntry = {
|
||||||
|
date: new Date(),
|
||||||
|
description: 'Year-end closing',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '8400', debit: 0, credit: 1000 },
|
||||||
|
{ accountNumber: '9000', debit: 1000, credit: 0 }
|
||||||
|
]
|
||||||
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌟 Real-World Example
|
## 🌟 Real-World Example: Complete Annual Closing
|
||||||
|
|
||||||
Here's a complete example of setting up a basic accounting system:
|
Here's how to perform a complete Jahresabschluss (annual financial closing):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { SkrApi } from '@fin.cx/skr';
|
import { SkrApi } from '@fin.cx/skr';
|
||||||
|
|
||||||
async function setupAccounting() {
|
async function performJahresabschluss() {
|
||||||
// Initialize
|
|
||||||
const api = new SkrApi({
|
const api = new SkrApi({
|
||||||
mongoDbUrl: process.env.MONGODB_URL!,
|
mongoDbUrl: process.env.MONGODB_URL!,
|
||||||
dbName: 'my_company_accounting'
|
dbName: 'company_accounting'
|
||||||
});
|
});
|
||||||
|
|
||||||
await api.initialize('SKR03');
|
await api.initialize('SKR04'); // Using SKR04 for better reporting structure
|
||||||
|
|
||||||
// Create custom accounts for your business
|
// 1. Post year-end adjustments
|
||||||
await api.createAccount({
|
const adjustments = await api.postJournalEntry({
|
||||||
accountNumber: '1299',
|
date: new Date('2024-12-31'),
|
||||||
accountName: 'Stripe Account',
|
description: 'Jahresabschlussbuchungen',
|
||||||
accountClass: 1,
|
reference: 'JA-2024',
|
||||||
accountType: 'asset',
|
lines: [
|
||||||
description: 'Stripe payment gateway account'
|
// Depreciation (AfA)
|
||||||
|
{ accountNumber: '3700', debit: 10000, description: 'AfA auf Anlagen' },
|
||||||
|
{ accountNumber: '0210', credit: 10000, description: 'Wertberichtigung Gebäude' },
|
||||||
|
|
||||||
|
// Provisions (Rückstellungen)
|
||||||
|
{ accountNumber: '3500', debit: 5000, description: 'Bildung Rückstellungen' },
|
||||||
|
{ accountNumber: '0800', credit: 5000, description: 'Sonstige Rückstellungen' },
|
||||||
|
|
||||||
|
// VAT clearing
|
||||||
|
{ accountNumber: '1771', debit: 19000, description: 'USt-Saldo' },
|
||||||
|
{ accountNumber: '1571', credit: 17000, description: 'Vorsteuer-Saldo' },
|
||||||
|
{ accountNumber: '1700', credit: 2000, description: 'USt-Zahllast' }
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Post daily transactions
|
// 2. Generate comprehensive annual closing package
|
||||||
const transactions = [
|
const jahresabschluss = await api.exportJahresabschluss({
|
||||||
{
|
year: 2024,
|
||||||
date: new Date(),
|
includeReports: ['balance_sheet', 'income_statement', 'cash_flow', 'trial_balance'],
|
||||||
debitAccount: '1299', // Stripe
|
format: 'pdf',
|
||||||
creditAccount: '8400', // Revenue
|
language: 'de',
|
||||||
amount: 99.00,
|
signatureRequired: true,
|
||||||
description: 'SaaS subscription payment',
|
companyInfo: {
|
||||||
reference: 'stripe_pi_abc123'
|
name: 'Mustermann GmbH',
|
||||||
},
|
address: 'Hauptstraße 1, 10115 Berlin',
|
||||||
{
|
taxNumber: 'DE123456789',
|
||||||
date: new Date(),
|
registrationNumber: 'HRB 12345'
|
||||||
debitAccount: '5900', // Hosting costs
|
|
||||||
creditAccount: '1200', // Bank
|
|
||||||
amount: 29.99,
|
|
||||||
description: 'AWS monthly bill',
|
|
||||||
reference: 'aws-2024-03'
|
|
||||||
}
|
}
|
||||||
];
|
|
||||||
|
|
||||||
for (const tx of transactions) {
|
|
||||||
await api.postTransaction(tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate monthly report
|
|
||||||
const report = await api.generateIncomeStatement({
|
|
||||||
dateFrom: new Date('2024-03-01'),
|
|
||||||
dateTo: new Date('2024-03-31')
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Revenue:', report.totalRevenue);
|
// 3. Generate individual reports for analysis
|
||||||
console.log('Expenses:', report.totalExpenses);
|
const incomeStatement = await api.generateIncomeStatement({
|
||||||
console.log('Net Income:', report.netIncome);
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31')
|
||||||
|
});
|
||||||
|
|
||||||
|
const balanceSheet = await api.generateBalanceSheet({
|
||||||
|
date: new Date('2024-12-31')
|
||||||
|
});
|
||||||
|
|
||||||
|
const cashFlow = await api.generateCashFlowStatement({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31')
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Export for tax advisor in DATEV format
|
||||||
|
const datevExport = await api.exportToDATEV({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31')
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Create signed export for audit trail
|
||||||
|
const signedExport = await api.signExport({
|
||||||
|
data: jahresabschluss,
|
||||||
|
privateKey: process.env.PRIVATE_KEY!,
|
||||||
|
certificate: process.env.CERTIFICATE!,
|
||||||
|
includeTimestamp: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Close the period
|
||||||
|
await api.closePeriod('2024-12', {
|
||||||
|
performYearEndAdjustments: true,
|
||||||
|
generateReports: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🎊 Jahresabschluss 2024 Complete!');
|
||||||
|
console.log(`📈 Umsatz: €${incomeStatement.totalRevenue.toLocaleString('de-DE')}`);
|
||||||
|
console.log(`💰 Aufwendungen: €${incomeStatement.totalExpenses.toLocaleString('de-DE')}`);
|
||||||
|
console.log(`📊 Jahresergebnis: €${incomeStatement.netIncome.toLocaleString('de-DE')}`);
|
||||||
|
console.log(`💼 Bilanzsumme: €${balanceSheet.assets.totalAssets.toLocaleString('de-DE')}`);
|
||||||
|
console.log(`💵 Cash Flow: €${cashFlow.netCashFlow.toLocaleString('de-DE')}`);
|
||||||
|
console.log(incomeStatement.netIncome > 0 ? '✅ Gewinn!' : '📉 Verlust');
|
||||||
|
|
||||||
// Close the connection when done
|
|
||||||
await api.close();
|
await api.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAccounting().catch(console.error);
|
performJahresabschluss().catch(console.error);
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚦 API Reference
|
## 🚦 API Reference
|
||||||
|
|
||||||
### Main Classes
|
### Main Classes
|
||||||
|
|
||||||
- **`SkrApi`** - Main API entry point
|
| Class | Description |
|
||||||
- **`ChartOfAccounts`** - Account management
|
|-------|-------------|
|
||||||
- **`Ledger`** - General ledger operations
|
| **`SkrApi`** | Main API entry point for all operations |
|
||||||
- **`Reports`** - Financial reporting
|
| **`ChartOfAccounts`** | Account management and initialization |
|
||||||
- **`Account`** - Account model
|
| **`Ledger`** | General ledger and transaction posting with SKR validation |
|
||||||
- **`Transaction`** - Transaction model
|
| **`Reports`** | Financial reporting and exports |
|
||||||
- **`JournalEntry`** - Journal entry model
|
| **`Account`** | Account model with balance tracking |
|
||||||
|
| **`Transaction`** | Double-entry transaction model |
|
||||||
|
| **`JournalEntry`** | Complex multi-line journal entries |
|
||||||
|
| **`InvoiceAdapter`** | XRechnung/ZUGFeRD invoice processing |
|
||||||
|
| **`InvoiceBookingEngine`** | Automatic invoice to accounting booking |
|
||||||
|
| **`InvoiceStorage`** | Invoice persistence and search |
|
||||||
|
|
||||||
### Key Methods
|
### Key Methods
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `initialize(skrType)` | Initialize with SKR03 or SKR04 |
|
| `initialize(skrType)` | Initialize with SKR03 or SKR04 |
|
||||||
| `postTransaction(data)` | Post a simple transaction |
|
| `postTransaction(data)` | Post a simple two-line transaction |
|
||||||
| `postJournalEntry(data)` | Post a complex journal entry |
|
| `postJournalEntry(data)` | Post complex multi-line journal entry |
|
||||||
| `reverseTransaction(id)` | Reverse a posted transaction |
|
| `postBatchTransactions(transactions)` | Post multiple transactions efficiently |
|
||||||
| `generateTrialBalance(params)` | Generate trial balance report |
|
| `reverseTransaction(id)` | Create reversal (Storno) entry |
|
||||||
| `generateIncomeStatement(params)` | Generate P&L statement |
|
| `reverseJournalEntry(id)` | Reverse complex journal entries |
|
||||||
| `generateBalanceSheet(params)` | Generate balance sheet |
|
| `generateTrialBalance(params)` | Generate Summen- und Saldenliste |
|
||||||
| `exportDatev(params)` | Export DATEV-compatible data |
|
| `generateIncomeStatement(params)` | Generate GuV (P&L) statement |
|
||||||
|
| `generateBalanceSheet(params)` | Generate Bilanz (balance sheet) |
|
||||||
|
| `generateCashFlowStatement(params)` | Generate cash flow statement |
|
||||||
|
| `generateGeneralLedger(params)` | Generate complete general ledger |
|
||||||
|
| `exportToDATEV(params)` | Export DATEV-compatible data |
|
||||||
|
| `exportJahresabschluss(params)` | Export complete annual closing package |
|
||||||
|
| `generatePdfReports(params)` | Generate professional PDF reports |
|
||||||
|
| `signExport(data)` | Create cryptographically signed exports |
|
||||||
|
| `importInvoice(data, options)` | Import XRechnung/ZUGFeRD invoices |
|
||||||
|
| `bookInvoice(invoiceId, rules)` | Book invoice to accounting |
|
||||||
|
| `exportInvoice(id, options)` | Export invoice in various formats |
|
||||||
|
| `searchInvoices(filter)` | Search and filter invoices |
|
||||||
|
| `closePeriod(period, options)` | Close accounting period |
|
||||||
|
| `recalculateBalances()` | Recalculate all account balances |
|
||||||
|
| `validateDoubleEntry(data)` | Validate transaction before posting |
|
||||||
|
| `getUnbalancedTransactions()` | Find integrity issues |
|
||||||
|
| `createBatchAccounts(accounts)` | Create multiple accounts at once |
|
||||||
|
|
||||||
|
## 🏆 Why Developers Love It
|
||||||
|
|
||||||
|
- **🎯 Zero Configuration**: Pre-configured SKR03/SKR04 accounts out of the box
|
||||||
|
- **🔄 Automatic Validation**: Never worry about unbalanced entries or wrong account types
|
||||||
|
- **📊 Real-time Analytics**: Instant financial insights with live balance updates
|
||||||
|
- **🛡️ SKR Compliance**: Validates against official SKR standards automatically
|
||||||
|
- **🚀 High Performance**: Optimized MongoDB queries and batch operations
|
||||||
|
- **📚 German Compliance**: Full HGB/GoBD compliance built-in
|
||||||
|
- **🤝 Type Safety**: Complete TypeScript definitions prevent runtime errors
|
||||||
|
- **🔍 Smart Validation**: Warns about non-standard accounts and type mismatches
|
||||||
|
- **🧾 E-Invoice Ready**: Native XRechnung/ZUGFeRD support for modern workflows
|
||||||
|
- **🔐 Audit-Proof**: Cryptographic signatures and Merkle trees for tamper-proof records
|
||||||
|
- **📑 Professional Reports**: Generate PDF reports that impress auditors and stakeholders
|
||||||
|
|
||||||
## 📋 Requirements
|
## 📋 Requirements
|
||||||
|
|
||||||
@@ -380,14 +701,22 @@ setupAccounting().catch(console.error);
|
|||||||
- **MongoDB** >= 5.0
|
- **MongoDB** >= 5.0
|
||||||
- **TypeScript** >= 5.0 (for development)
|
- **TypeScript** >= 5.0 (for development)
|
||||||
|
|
||||||
## 🏆 Why Developers Love It
|
## 🔬 Testing
|
||||||
|
|
||||||
- **🎯 Zero Configuration**: Pre-configured SKR03/SKR04 accounts out of the box
|
The module includes comprehensive test coverage with real-world scenarios:
|
||||||
- **🔄 Automatic Validation**: Never worry about unbalanced entries
|
|
||||||
- **📊 Real-time Analytics**: Instant financial insights
|
```bash
|
||||||
- **🛡️ Production Ready**: Battle-tested in enterprise environments
|
# Run all tests
|
||||||
- **📚 Great Documentation**: You're reading it!
|
pnpm test
|
||||||
- **🤝 Active Community**: Regular updates and support
|
|
||||||
|
# Run specific test suites
|
||||||
|
pnpm test test/test.skr03.ts # SKR03 functionality
|
||||||
|
pnpm test test/test.skr04.ts # SKR04 functionality
|
||||||
|
pnpm test test/test.jahresabschluss.skr03.ts # Annual closing SKR03
|
||||||
|
pnpm test test/test.jahresabschluss.skr04.ts # Annual closing SKR04
|
||||||
|
pnpm test test/test.invoice.ts # Invoice processing
|
||||||
|
pnpm test test/test.export.ts # Export functionality
|
||||||
|
```
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
+329
-63
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Banking Application Services Manager
|
# Generic Services Manager
|
||||||
# Manages MongoDB and MinIO containers
|
# Manages MongoDB and S3/MinIO containers for any project
|
||||||
|
|
||||||
# Color codes for output
|
# Color codes for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -12,21 +12,6 @@ MAGENTA='\033[0;35m'
|
|||||||
CYAN='\033[0;36m'
|
CYAN='\033[0;36m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Configuration
|
|
||||||
MONGO_CONTAINER="banking-mongo"
|
|
||||||
MONGO_PORT=27017
|
|
||||||
MONGO_DATA_DIR="$(pwd)/.nogit/mongodata"
|
|
||||||
MONGO_USER="bankingadmin"
|
|
||||||
MONGO_PASS="banking123"
|
|
||||||
MONGO_VERSION="7.0"
|
|
||||||
|
|
||||||
MINIO_CONTAINER="banking-minio"
|
|
||||||
MINIO_PORT=9000
|
|
||||||
MINIO_CONSOLE_PORT=9001
|
|
||||||
MINIO_DATA_DIR="$(pwd)/.nogit/miniodata"
|
|
||||||
MINIO_USER="minioadmin"
|
|
||||||
MINIO_PASS="minioadmin"
|
|
||||||
|
|
||||||
# Function to print colored messages
|
# Function to print colored messages
|
||||||
print_message() {
|
print_message() {
|
||||||
echo -e "${2}${1}${NC}"
|
echo -e "${2}${1}${NC}"
|
||||||
@@ -49,6 +34,269 @@ check_docker() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Get project name from package.json or directory
|
||||||
|
get_project_name() {
|
||||||
|
local name=""
|
||||||
|
if [ -f "package.json" ]; then
|
||||||
|
name=$(grep '"name"' package.json | head -1 | cut -d'"' -f4)
|
||||||
|
# Sanitize: @fin.cx/skr → fin-cx-skr
|
||||||
|
echo "$name" | sed 's/@//g' | sed 's/[\/\.]/-/g'
|
||||||
|
else
|
||||||
|
basename "$(pwd)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate random available port between 20000-30000
|
||||||
|
get_random_port() {
|
||||||
|
local port
|
||||||
|
local max_attempts=100
|
||||||
|
local attempts=0
|
||||||
|
|
||||||
|
while [ $attempts -lt $max_attempts ]; do
|
||||||
|
port=$((RANDOM % 10001 + 20000))
|
||||||
|
# Check if port is available
|
||||||
|
if ! lsof -i:$port >/dev/null 2>&1 && ! nc -z localhost $port 2>/dev/null; then
|
||||||
|
echo $port
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Fallback to finding any available port
|
||||||
|
print_message "Warning: Could not find random port, using system-assigned port" "$YELLOW"
|
||||||
|
echo "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add missing field to JSON file
|
||||||
|
add_json_field() {
|
||||||
|
local file=$1
|
||||||
|
local key=$2
|
||||||
|
local value=$3
|
||||||
|
|
||||||
|
if ! grep -q "\"$key\"" "$file" 2>/dev/null; then
|
||||||
|
# Add the field before the last closing brace
|
||||||
|
local temp_file="${file}.tmp"
|
||||||
|
# Remove last }
|
||||||
|
head -n -1 "$file" > "$temp_file"
|
||||||
|
# Add comma if needed (check if last line ends with })
|
||||||
|
local last_line=$(tail -n 1 "$temp_file")
|
||||||
|
if [[ ! "$last_line" =~ ^[[:space:]]*$ ]] && [[ ! "$last_line" =~ ,$ ]]; then
|
||||||
|
echo "," >> "$temp_file"
|
||||||
|
fi
|
||||||
|
# Add new field and closing brace
|
||||||
|
echo " \"$key\": \"$value\"" >> "$temp_file"
|
||||||
|
echo "}" >> "$temp_file"
|
||||||
|
mv "$temp_file" "$file"
|
||||||
|
return 0 # Field was added
|
||||||
|
fi
|
||||||
|
return 1 # Field already exists
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update or create env.json with defaults
|
||||||
|
update_or_create_env_json() {
|
||||||
|
mkdir -p .nogit
|
||||||
|
|
||||||
|
local project_name=$(get_project_name)
|
||||||
|
local changes_made=false
|
||||||
|
local fields_added=""
|
||||||
|
|
||||||
|
if [ -f ".nogit/env.json" ]; then
|
||||||
|
print_message "📋 Checking .nogit/env.json for missing values..." "$CYAN"
|
||||||
|
|
||||||
|
# Check and add missing fields
|
||||||
|
if add_json_field ".nogit/env.json" "PROJECT_NAME" "$project_name"; then
|
||||||
|
fields_added="${fields_added}PROJECT_NAME, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "MONGODB_HOST" "localhost"; then
|
||||||
|
fields_added="${fields_added}MONGODB_HOST, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "MONGODB_NAME" "$project_name"; then
|
||||||
|
fields_added="${fields_added}MONGODB_NAME, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "\"MONGODB_PORT\"" ".nogit/env.json" 2>/dev/null; then
|
||||||
|
local mongo_port=$(get_random_port)
|
||||||
|
add_json_field ".nogit/env.json" "MONGODB_PORT" "$mongo_port"
|
||||||
|
fields_added="${fields_added}MONGODB_PORT($mongo_port), "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "MONGODB_USER" "defaultadmin"; then
|
||||||
|
fields_added="${fields_added}MONGODB_USER, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "MONGODB_PASS" "defaultpass"; then
|
||||||
|
fields_added="${fields_added}MONGODB_PASS, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "S3_HOST" "localhost"; then
|
||||||
|
fields_added="${fields_added}S3_HOST, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q "\"S3_PORT\"" ".nogit/env.json" 2>/dev/null; then
|
||||||
|
local s3_port=$(get_random_port)
|
||||||
|
add_json_field ".nogit/env.json" "S3_PORT" "$s3_port"
|
||||||
|
fields_added="${fields_added}S3_PORT($s3_port), "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get S3_PORT for console port calculation
|
||||||
|
local s3_port_value=$(grep '"S3_PORT"' .nogit/env.json | cut -d'"' -f4)
|
||||||
|
if [ ! -z "$s3_port_value" ] && ! grep -q "\"S3_CONSOLE_PORT\"" ".nogit/env.json" 2>/dev/null; then
|
||||||
|
local console_port=$((s3_port_value + 1))
|
||||||
|
# Check if console port is available
|
||||||
|
while lsof -i:$console_port >/dev/null 2>&1 || nc -z localhost $console_port 2>/dev/null; do
|
||||||
|
console_port=$((console_port + 1))
|
||||||
|
done
|
||||||
|
add_json_field ".nogit/env.json" "S3_CONSOLE_PORT" "$console_port"
|
||||||
|
fields_added="${fields_added}S3_CONSOLE_PORT($console_port), "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "S3_USER" "defaultadmin"; then
|
||||||
|
fields_added="${fields_added}S3_USER, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "S3_PASS" "defaultpass"; then
|
||||||
|
fields_added="${fields_added}S3_PASS, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if add_json_field ".nogit/env.json" "S3_BUCKET" "${project_name}-documents"; then
|
||||||
|
fields_added="${fields_added}S3_BUCKET, "
|
||||||
|
changes_made=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$changes_made" = true ]; then
|
||||||
|
# Remove trailing comma and space
|
||||||
|
fields_added=${fields_added%, }
|
||||||
|
print_message "✅ Added missing fields: $fields_added" "$GREEN"
|
||||||
|
else
|
||||||
|
print_message "✅ Configuration complete" "$GREEN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
# Create new env.json with random ports
|
||||||
|
print_message "📋 Creating .nogit/env.json with default values..." "$YELLOW"
|
||||||
|
|
||||||
|
local mongo_port=$(get_random_port)
|
||||||
|
local s3_port=$(get_random_port)
|
||||||
|
local s3_console_port=$((s3_port + 1))
|
||||||
|
|
||||||
|
# Make sure console port is also available
|
||||||
|
while lsof -i:$s3_console_port >/dev/null 2>&1 || nc -z localhost $s3_console_port 2>/dev/null; do
|
||||||
|
s3_console_port=$((s3_console_port + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
cat > .nogit/env.json <<EOF
|
||||||
|
{
|
||||||
|
"PROJECT_NAME": "$project_name",
|
||||||
|
"MONGODB_HOST": "localhost",
|
||||||
|
"MONGODB_NAME": "$project_name",
|
||||||
|
"MONGODB_PORT": "$mongo_port",
|
||||||
|
"MONGODB_USER": "defaultadmin",
|
||||||
|
"MONGODB_PASS": "defaultpass",
|
||||||
|
"S3_HOST": "localhost",
|
||||||
|
"S3_PORT": "$s3_port",
|
||||||
|
"S3_CONSOLE_PORT": "$s3_console_port",
|
||||||
|
"S3_USER": "defaultadmin",
|
||||||
|
"S3_PASS": "defaultpass",
|
||||||
|
"S3_BUCKET": "${project_name}-documents"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
print_message "✅ Created .nogit/env.json with project defaults" "$GREEN"
|
||||||
|
print_message "📍 MongoDB port: $mongo_port" "$BLUE"
|
||||||
|
print_message "📍 S3 API port: $s3_port" "$BLUE"
|
||||||
|
print_message "📍 S3 Console port: $s3_console_port" "$BLUE"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load configuration from env.json
|
||||||
|
load_config() {
|
||||||
|
# First ensure env.json exists and is complete
|
||||||
|
update_or_create_env_json
|
||||||
|
|
||||||
|
if [ -f ".nogit/env.json" ]; then
|
||||||
|
# Parse JSON (using grep/sed for portability)
|
||||||
|
PROJECT_NAME=$(grep -o '"PROJECT_NAME"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
MONGODB_HOST=$(grep -o '"MONGODB_HOST"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
MONGODB_NAME=$(grep -o '"MONGODB_NAME"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
MONGODB_PORT=$(grep -o '"MONGODB_PORT"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
MONGODB_USER=$(grep -o '"MONGODB_USER"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
MONGODB_PASS=$(grep -o '"MONGODB_PASS"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
|
||||||
|
S3_HOST=$(grep -o '"S3_HOST"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
S3_PORT=$(grep -o '"S3_PORT"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
S3_CONSOLE_PORT=$(grep -o '"S3_CONSOLE_PORT"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
S3_USER=$(grep -o '"S3_USER"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
S3_PASS=$(grep -o '"S3_PASS"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
S3_BUCKET=$(grep -o '"S3_BUCKET"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to defaults if any value is missing (shouldn't happen after update_or_create_env_json)
|
||||||
|
PROJECT_NAME=${PROJECT_NAME:-$(get_project_name)}
|
||||||
|
MONGODB_HOST=${MONGODB_HOST:-"localhost"}
|
||||||
|
MONGODB_NAME=${MONGODB_NAME:-"$PROJECT_NAME"}
|
||||||
|
MONGODB_PORT=${MONGODB_PORT:-"27017"}
|
||||||
|
MONGODB_USER=${MONGODB_USER:-"defaultadmin"}
|
||||||
|
MONGODB_PASS=${MONGODB_PASS:-"defaultpass"}
|
||||||
|
|
||||||
|
S3_HOST=${S3_HOST:-"localhost"}
|
||||||
|
S3_PORT=${S3_PORT:-"9000"}
|
||||||
|
S3_CONSOLE_PORT=${S3_CONSOLE_PORT:-"9001"}
|
||||||
|
S3_USER=${S3_USER:-"defaultadmin"}
|
||||||
|
S3_PASS=${S3_PASS:-"defaultpass"}
|
||||||
|
S3_BUCKET=${S3_BUCKET:-"${PROJECT_NAME}-documents"}
|
||||||
|
|
||||||
|
# Container names (project-specific to avoid conflicts)
|
||||||
|
MONGO_CONTAINER="${PROJECT_NAME}-mongodb"
|
||||||
|
MINIO_CONTAINER="${PROJECT_NAME}-minio"
|
||||||
|
|
||||||
|
# Data directories
|
||||||
|
MONGO_DATA_DIR="$(pwd)/.nogit/mongodata"
|
||||||
|
MINIO_DATA_DIR="$(pwd)/.nogit/miniodata"
|
||||||
|
|
||||||
|
print_message "📋 Project: $PROJECT_NAME" "$MAGENTA"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Show current configuration
|
||||||
|
show_config() {
|
||||||
|
print_header "Current Configuration"
|
||||||
|
|
||||||
|
print_message "Project: $PROJECT_NAME" "$MAGENTA"
|
||||||
|
echo
|
||||||
|
print_message "MongoDB:" "$YELLOW"
|
||||||
|
print_message " Host: $MONGODB_HOST:$MONGODB_PORT" "$NC"
|
||||||
|
print_message " Database: $MONGODB_NAME" "$NC"
|
||||||
|
print_message " User: $MONGODB_USER" "$NC"
|
||||||
|
print_message " Password: ***" "$NC"
|
||||||
|
print_message " Container: $MONGO_CONTAINER" "$NC"
|
||||||
|
print_message " Data: $MONGO_DATA_DIR" "$NC"
|
||||||
|
print_message " Connection: mongodb://$MONGODB_USER:***@$MONGODB_HOST:$MONGODB_PORT/$MONGODB_NAME" "$BLUE"
|
||||||
|
|
||||||
|
echo
|
||||||
|
print_message "S3/MinIO:" "$YELLOW"
|
||||||
|
print_message " Host: $S3_HOST" "$NC"
|
||||||
|
print_message " API Port: $S3_PORT" "$NC"
|
||||||
|
print_message " Console Port: $S3_CONSOLE_PORT" "$NC"
|
||||||
|
print_message " User: $S3_USER" "$NC"
|
||||||
|
print_message " Password: ***" "$NC"
|
||||||
|
print_message " Bucket: $S3_BUCKET" "$NC"
|
||||||
|
print_message " Container: $MINIO_CONTAINER" "$NC"
|
||||||
|
print_message " Data: $MINIO_DATA_DIR" "$NC"
|
||||||
|
print_message " API URL: http://$S3_HOST:$S3_PORT" "$BLUE"
|
||||||
|
print_message " Console URL: http://$S3_HOST:$S3_CONSOLE_PORT" "$BLUE"
|
||||||
|
}
|
||||||
|
|
||||||
# Check container status
|
# Check container status
|
||||||
check_status() {
|
check_status() {
|
||||||
local container=$1
|
local container=$1
|
||||||
@@ -82,23 +330,25 @@ start_mongodb() {
|
|||||||
print_message " Creating container..." "$YELLOW"
|
print_message " Creating container..." "$YELLOW"
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name "$MONGO_CONTAINER" \
|
--name "$MONGO_CONTAINER" \
|
||||||
-p "0.0.0.0:${MONGO_PORT}:${MONGO_PORT}" \
|
-p "0.0.0.0:${MONGODB_PORT}:27017" \
|
||||||
-v "$MONGO_DATA_DIR:/data/db" \
|
-v "$MONGO_DATA_DIR:/data/db" \
|
||||||
-e MONGO_INITDB_ROOT_USERNAME="$MONGO_USER" \
|
-e MONGO_INITDB_ROOT_USERNAME="$MONGODB_USER" \
|
||||||
-e MONGO_INITDB_ROOT_PASSWORD="$MONGO_PASS" \
|
-e MONGO_INITDB_ROOT_PASSWORD="$MONGODB_PASS" \
|
||||||
-e MONGO_INITDB_DATABASE=banking \
|
-e MONGO_INITDB_DATABASE="$MONGODB_NAME" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
"mongo:${MONGO_VERSION}" > /dev/null
|
mongo:7.0 > /dev/null
|
||||||
print_message " Created and started ✓" "$GREEN"
|
print_message " Created and started ✓" "$GREEN"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
print_message " URL: mongodb://$MONGO_USER:$MONGO_PASS@localhost:$MONGO_PORT/banking?authSource=admin" "$BLUE"
|
print_message " Container: $MONGO_CONTAINER" "$CYAN"
|
||||||
|
print_message " Port: $MONGODB_PORT" "$CYAN"
|
||||||
|
print_message " Connection: mongodb://$MONGODB_USER:$MONGODB_PASS@$MONGODB_HOST:$MONGODB_PORT/$MONGODB_NAME?authSource=admin" "$BLUE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Start MinIO
|
# Start MinIO
|
||||||
start_minio() {
|
start_minio() {
|
||||||
print_message "📦 MinIO (S3 Storage):" "$YELLOW"
|
print_message "📦 S3/MinIO:" "$YELLOW"
|
||||||
|
|
||||||
# Create data directory if needed
|
# Create data directory if needed
|
||||||
[ ! -d "$MINIO_DATA_DIR" ] && mkdir -p "$MINIO_DATA_DIR"
|
[ ! -d "$MINIO_DATA_DIR" ] && mkdir -p "$MINIO_DATA_DIR"
|
||||||
@@ -117,25 +367,28 @@ start_minio() {
|
|||||||
print_message " Creating container..." "$YELLOW"
|
print_message " Creating container..." "$YELLOW"
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name "$MINIO_CONTAINER" \
|
--name "$MINIO_CONTAINER" \
|
||||||
-p "${MINIO_PORT}:9000" \
|
-p "${S3_PORT}:9000" \
|
||||||
-p "${MINIO_CONSOLE_PORT}:9001" \
|
-p "${S3_CONSOLE_PORT}:9001" \
|
||||||
-v "$MINIO_DATA_DIR:/data" \
|
-v "$MINIO_DATA_DIR:/data" \
|
||||||
-e MINIO_ROOT_USER="$MINIO_USER" \
|
-e MINIO_ROOT_USER="$S3_USER" \
|
||||||
-e MINIO_ROOT_PASSWORD="$MINIO_PASS" \
|
-e MINIO_ROOT_PASSWORD="$S3_PASS" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
minio/minio server /data --console-address ":9001" > /dev/null
|
minio/minio server /data --console-address ":9001" > /dev/null
|
||||||
|
|
||||||
# Wait for MinIO to start and create bucket
|
# Wait for MinIO to start and create default bucket
|
||||||
sleep 3
|
sleep 3
|
||||||
docker exec "$MINIO_CONTAINER" mc alias set local http://localhost:9000 "$MINIO_USER" "$MINIO_PASS" 2>/dev/null
|
docker exec "$MINIO_CONTAINER" mc alias set local http://localhost:9000 "$S3_USER" "$S3_PASS" 2>/dev/null
|
||||||
docker exec "$MINIO_CONTAINER" mc mb local/banking-documents 2>/dev/null || true
|
docker exec "$MINIO_CONTAINER" mc mb "local/$S3_BUCKET" 2>/dev/null || true
|
||||||
print_message " Created and started ✓" "$GREEN"
|
print_message " Created and started ✓" "$GREEN"
|
||||||
print_message " Bucket 'banking-documents' created ✓" "$GREEN"
|
print_message " Bucket '$S3_BUCKET' created ✓" "$GREEN"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
print_message " API: http://localhost:$MINIO_PORT" "$BLUE"
|
print_message " Container: $MINIO_CONTAINER" "$CYAN"
|
||||||
print_message " Console: http://localhost:$MINIO_CONSOLE_PORT (login: $MINIO_USER/$MINIO_PASS)" "$BLUE"
|
print_message " Port: $S3_PORT" "$CYAN"
|
||||||
|
print_message " Bucket: $S3_BUCKET" "$CYAN"
|
||||||
|
print_message " API: http://$S3_HOST:$S3_PORT" "$BLUE"
|
||||||
|
print_message " Console: http://$S3_HOST:$S3_CONSOLE_PORT (login: $S3_USER/***)" "$BLUE"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stop MongoDB
|
# Stop MongoDB
|
||||||
@@ -153,7 +406,7 @@ stop_mongodb() {
|
|||||||
|
|
||||||
# Stop MinIO
|
# Stop MinIO
|
||||||
stop_minio() {
|
stop_minio() {
|
||||||
print_message "📦 MinIO:" "$YELLOW"
|
print_message "📦 S3/MinIO:" "$YELLOW"
|
||||||
local status=$(check_status "$MINIO_CONTAINER")
|
local status=$(check_status "$MINIO_CONTAINER")
|
||||||
|
|
||||||
if [ "$status" = "running" ]; then
|
if [ "$status" = "running" ]; then
|
||||||
@@ -176,7 +429,7 @@ remove_containers() {
|
|||||||
|
|
||||||
if docker ps -a --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then
|
if docker ps -a --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then
|
||||||
docker rm -f "$MINIO_CONTAINER" > /dev/null 2>&1
|
docker rm -f "$MINIO_CONTAINER" > /dev/null 2>&1
|
||||||
print_message " MinIO container removed ✓" "$GREEN"
|
print_message " S3/MinIO container removed ✓" "$GREEN"
|
||||||
removed=true
|
removed=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -197,7 +450,7 @@ clean_data() {
|
|||||||
|
|
||||||
if [ -d "$MINIO_DATA_DIR" ]; then
|
if [ -d "$MINIO_DATA_DIR" ]; then
|
||||||
rm -rf "$MINIO_DATA_DIR"
|
rm -rf "$MINIO_DATA_DIR"
|
||||||
print_message " MinIO data removed ✓" "$GREEN"
|
print_message " S3/MinIO data removed ✓" "$GREEN"
|
||||||
cleaned=true
|
cleaned=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -210,15 +463,20 @@ clean_data() {
|
|||||||
show_status() {
|
show_status() {
|
||||||
print_header "Service Status"
|
print_header "Service Status"
|
||||||
|
|
||||||
|
print_message "Project: $PROJECT_NAME" "$MAGENTA"
|
||||||
|
echo
|
||||||
|
|
||||||
# MongoDB status
|
# MongoDB status
|
||||||
local mongo_status=$(check_status "$MONGO_CONTAINER")
|
local mongo_status=$(check_status "$MONGO_CONTAINER")
|
||||||
case $mongo_status in
|
case $mongo_status in
|
||||||
"running")
|
"running")
|
||||||
print_message "📦 MongoDB: 🟢 Running" "$GREEN"
|
print_message "📦 MongoDB: 🟢 Running" "$GREEN"
|
||||||
print_message " └─ mongodb://$MONGO_USER:***@localhost:$MONGO_PORT/banking" "$CYAN"
|
print_message " ├─ Container: $MONGO_CONTAINER" "$CYAN"
|
||||||
|
print_message " └─ mongodb://$MONGODB_USER:***@$MONGODB_HOST:$MONGODB_PORT/$MONGODB_NAME" "$CYAN"
|
||||||
;;
|
;;
|
||||||
"stopped")
|
"stopped")
|
||||||
print_message "📦 MongoDB: 🟡 Stopped" "$YELLOW"
|
print_message "📦 MongoDB: 🟡 Stopped" "$YELLOW"
|
||||||
|
print_message " └─ Container: $MONGO_CONTAINER" "$CYAN"
|
||||||
;;
|
;;
|
||||||
"not_exists")
|
"not_exists")
|
||||||
print_message "📦 MongoDB: ⚪ Not installed" "$MAGENTA"
|
print_message "📦 MongoDB: ⚪ Not installed" "$MAGENTA"
|
||||||
@@ -229,25 +487,20 @@ show_status() {
|
|||||||
local minio_status=$(check_status "$MINIO_CONTAINER")
|
local minio_status=$(check_status "$MINIO_CONTAINER")
|
||||||
case $minio_status in
|
case $minio_status in
|
||||||
"running")
|
"running")
|
||||||
print_message "📦 MinIO: 🟢 Running" "$GREEN"
|
print_message "📦 S3/MinIO: 🟢 Running" "$GREEN"
|
||||||
print_message " ├─ API: http://localhost:$MINIO_PORT" "$CYAN"
|
print_message " ├─ Container: $MINIO_CONTAINER" "$CYAN"
|
||||||
print_message " └─ Console: http://localhost:$MINIO_CONSOLE_PORT" "$CYAN"
|
print_message " ├─ API: http://$S3_HOST:$S3_PORT" "$CYAN"
|
||||||
|
print_message " ├─ Console: http://$S3_HOST:$S3_CONSOLE_PORT" "$CYAN"
|
||||||
|
print_message " └─ Bucket: $S3_BUCKET" "$CYAN"
|
||||||
;;
|
;;
|
||||||
"stopped")
|
"stopped")
|
||||||
print_message "📦 MinIO: 🟡 Stopped" "$YELLOW"
|
print_message "📦 S3/MinIO: 🟡 Stopped" "$YELLOW"
|
||||||
|
print_message " └─ Container: $MINIO_CONTAINER" "$CYAN"
|
||||||
;;
|
;;
|
||||||
"not_exists")
|
"not_exists")
|
||||||
print_message "📦 MinIO: ⚪ Not installed" "$MAGENTA"
|
print_message "📦 S3/MinIO: ⚪ Not installed" "$MAGENTA"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Show network access for MongoDB
|
|
||||||
if [ "$mongo_status" = "running" ]; then
|
|
||||||
echo
|
|
||||||
print_message "Network Access:" "$BLUE"
|
|
||||||
local ip=$(hostname -I | awk '{print $1}')
|
|
||||||
print_message " MongoDB Compass: mongodb://$MONGO_USER:$MONGO_PASS@$ip:$MONGO_PORT/banking?authSource=admin" "$CYAN"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show logs
|
# Show logs
|
||||||
@@ -264,51 +517,60 @@ show_logs() {
|
|||||||
print_message "MongoDB container is not running" "$YELLOW"
|
print_message "MongoDB container is not running" "$YELLOW"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"minio")
|
"minio"|"s3")
|
||||||
if docker ps --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then
|
if docker ps --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then
|
||||||
print_header "MinIO Logs (last $lines lines)"
|
print_header "S3/MinIO Logs (last $lines lines)"
|
||||||
docker logs --tail "$lines" "$MINIO_CONTAINER"
|
docker logs --tail "$lines" "$MINIO_CONTAINER"
|
||||||
else
|
else
|
||||||
print_message "MinIO container is not running" "$YELLOW"
|
print_message "S3/MinIO container is not running" "$YELLOW"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"all")
|
"all"|"")
|
||||||
show_logs "mongo" "$lines"
|
show_logs "mongo" "$lines"
|
||||||
echo
|
echo
|
||||||
show_logs "minio" "$lines"
|
show_logs "minio" "$lines"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
print_message "Usage: $0 logs [mongo|minio|all] [lines]" "$YELLOW"
|
print_message "Usage: $0 logs [mongo|s3|all] [lines]" "$YELLOW"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main menu
|
# Main menu
|
||||||
show_help() {
|
show_help() {
|
||||||
print_header "Banking Services Manager"
|
print_header "Generic Services Manager"
|
||||||
|
|
||||||
print_message "Usage: $0 [command] [options]" "$GREEN"
|
print_message "Usage: $0 [command] [options]" "$GREEN"
|
||||||
echo
|
echo
|
||||||
print_message "Commands:" "$YELLOW"
|
print_message "Commands:" "$YELLOW"
|
||||||
print_message " start [service] Start services (mongo|minio|all)" "$NC"
|
print_message " start [service] Start services (mongo|s3|all)" "$NC"
|
||||||
print_message " stop [service] Stop services (mongo|minio|all)" "$NC"
|
print_message " stop [service] Stop services (mongo|s3|all)" "$NC"
|
||||||
print_message " restart [service] Restart services (mongo|minio|all)" "$NC"
|
print_message " restart [service] Restart services (mongo|s3|all)" "$NC"
|
||||||
print_message " status Show service status" "$NC"
|
print_message " status Show service status" "$NC"
|
||||||
print_message " logs [service] Show logs (mongo|minio|all) [lines]" "$NC"
|
print_message " config Show current configuration" "$NC"
|
||||||
|
print_message " logs [service] Show logs (mongo|s3|all) [lines]" "$NC"
|
||||||
print_message " remove Remove all containers" "$NC"
|
print_message " remove Remove all containers" "$NC"
|
||||||
print_message " clean Remove all containers and data ⚠️" "$NC"
|
print_message " clean Remove all containers and data ⚠️" "$NC"
|
||||||
print_message " help Show this help message" "$NC"
|
print_message " help Show this help message" "$NC"
|
||||||
echo
|
echo
|
||||||
|
print_message "Features:" "$YELLOW"
|
||||||
|
print_message " • Auto-creates .nogit/env.json with smart defaults" "$NC"
|
||||||
|
print_message " • Random ports (20000-30000) to avoid conflicts" "$NC"
|
||||||
|
print_message " • Project-specific containers for multi-project support" "$NC"
|
||||||
|
print_message " • Preserves custom configuration values" "$NC"
|
||||||
|
echo
|
||||||
print_message "Examples:" "$YELLOW"
|
print_message "Examples:" "$YELLOW"
|
||||||
print_message " $0 start # Start all services" "$NC"
|
print_message " $0 start # Start all services" "$NC"
|
||||||
print_message " $0 start mongo # Start only MongoDB" "$NC"
|
print_message " $0 start mongo # Start only MongoDB" "$NC"
|
||||||
print_message " $0 stop # Stop all services" "$NC"
|
print_message " $0 stop # Stop all services" "$NC"
|
||||||
print_message " $0 status # Check service status" "$NC"
|
print_message " $0 status # Check service status" "$NC"
|
||||||
|
print_message " $0 config # Show configuration" "$NC"
|
||||||
print_message " $0 logs mongo 50 # Show last 50 lines of MongoDB logs" "$NC"
|
print_message " $0 logs mongo 50 # Show last 50 lines of MongoDB logs" "$NC"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Main script
|
# Main script
|
||||||
check_docker
|
check_docker
|
||||||
|
load_config
|
||||||
|
|
||||||
case ${1:-help} in
|
case ${1:-help} in
|
||||||
start)
|
start)
|
||||||
@@ -327,7 +589,7 @@ case ${1:-help} in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
print_message "Unknown service: $2" "$RED"
|
print_message "Unknown service: $2" "$RED"
|
||||||
print_message "Use: mongo, minio, or all" "$YELLOW"
|
print_message "Use: mongo, s3, or all" "$YELLOW"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
@@ -348,7 +610,7 @@ case ${1:-help} in
|
|||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
print_message "Unknown service: $2" "$RED"
|
print_message "Unknown service: $2" "$RED"
|
||||||
print_message "Use: mongo, minio, or all" "$YELLOW"
|
print_message "Use: mongo, s3, or all" "$YELLOW"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
;;
|
;;
|
||||||
@@ -384,6 +646,10 @@ case ${1:-help} in
|
|||||||
show_status
|
show_status
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
config)
|
||||||
|
show_config
|
||||||
|
;;
|
||||||
|
|
||||||
logs)
|
logs)
|
||||||
show_logs "${2:-all}" "${3:-20}"
|
show_logs "${2:-all}" "${3:-20}"
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
|
||||||
|
// Initialize qenv to load environment variables from .nogit folder
|
||||||
|
const testQenv = new qenv.Qenv('./', './.nogit/');
|
||||||
|
|
||||||
|
// Export configuration for MongoDB and S3
|
||||||
|
export const getTestConfig = async () => {
|
||||||
|
// Try to get individual MongoDB components first
|
||||||
|
const mongoHost = await testQenv.getEnvVarOnDemand('MONGODB_HOST') || 'localhost';
|
||||||
|
const mongoPort = await testQenv.getEnvVarOnDemand('MONGODB_PORT') || '27017';
|
||||||
|
const mongoUser = await testQenv.getEnvVarOnDemand('MONGODB_USER');
|
||||||
|
const mongoPass = await testQenv.getEnvVarOnDemand('MONGODB_PASS');
|
||||||
|
const mongoDbName = await testQenv.getEnvVarOnDemand('MONGODB_NAME') || 'test_skr';
|
||||||
|
|
||||||
|
// Build MongoDB URL with authentication
|
||||||
|
let mongoDbUrl: string;
|
||||||
|
if (mongoUser && mongoPass) {
|
||||||
|
// Include authSource=admin for authentication
|
||||||
|
mongoDbUrl = `mongodb://${mongoUser}:${mongoPass}@${mongoHost}:${mongoPort}/?authSource=admin`;
|
||||||
|
} else {
|
||||||
|
mongoDbUrl = `mongodb://${mongoHost}:${mongoPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get S3 configuration
|
||||||
|
const s3Host = await testQenv.getEnvVarOnDemand('S3_HOST');
|
||||||
|
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||||
|
const s3User = await testQenv.getEnvVarOnDemand('S3_USER');
|
||||||
|
const s3Pass = await testQenv.getEnvVarOnDemand('S3_PASS');
|
||||||
|
const s3Bucket = await testQenv.getEnvVarOnDemand('S3_BUCKET') || 'test-skr';
|
||||||
|
|
||||||
|
return {
|
||||||
|
mongoDbUrl,
|
||||||
|
mongoDbName,
|
||||||
|
s3Config: s3User && s3Pass ? {
|
||||||
|
accessKey: s3User,
|
||||||
|
secretKey: s3Pass,
|
||||||
|
endpoint: s3Host && s3Port ? `http://${s3Host}:${s3Port}` : undefined,
|
||||||
|
bucket: s3Bucket
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as skr from '../ts/index.js';
|
||||||
|
import { getTestConfig } from './helpers/setup.js';
|
||||||
|
|
||||||
|
let api: skr.SkrApi;
|
||||||
|
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||||
|
|
||||||
|
tap.test('should demonstrate complete Jahresabschluss (Annual Financial Statement) for SKR03', async () => {
|
||||||
|
testConfig = await getTestConfig();
|
||||||
|
|
||||||
|
// Use timestamp to ensure unique database for each test run
|
||||||
|
const timestamp = Date.now();
|
||||||
|
api = new skr.SkrApi({
|
||||||
|
mongoDbUrl: testConfig.mongoDbUrl,
|
||||||
|
dbName: `${testConfig.mongoDbName}_jahresabschluss_${timestamp}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.initialize('SKR03');
|
||||||
|
expect(api.getSKRType()).toEqual('SKR03');
|
||||||
|
|
||||||
|
// Create debtor account (customer) - replaces automatic account 1400
|
||||||
|
await api.createAccount({
|
||||||
|
accountNumber: '10001',
|
||||||
|
accountName: 'Kunde Mustermann GmbH',
|
||||||
|
accountClass: 1,
|
||||||
|
accountType: 'asset',
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create creditor account (supplier) - replaces automatic account 1600
|
||||||
|
await api.createAccount({
|
||||||
|
accountNumber: '70001',
|
||||||
|
accountName: 'Lieferant Test GmbH',
|
||||||
|
accountClass: 7,
|
||||||
|
accountType: 'liability',
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should set up opening balances (Eröffnungsbilanz)', async () => {
|
||||||
|
// Opening balances from previous year's closing
|
||||||
|
// This represents a small GmbH (limited liability company)
|
||||||
|
// Using only accounts that exist in SKR03
|
||||||
|
|
||||||
|
// Note: Opening balance entries use posting key 40 (tax-free) as they are internal closing entries
|
||||||
|
// Using personal accounts (10001 for debtor, 70001 for creditor) instead of automatic accounts
|
||||||
|
|
||||||
|
// Post opening journal entry (Eröffnungsbuchung)
|
||||||
|
const openingEntry = await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-01'),
|
||||||
|
description: 'Eröffnungsbilanz 2024',
|
||||||
|
reference: 'EB-2024',
|
||||||
|
lines: [
|
||||||
|
// Debit all asset accounts
|
||||||
|
{ accountNumber: '0200', debit: 45000, description: 'Grundstücke', postingKey: 40 },
|
||||||
|
{ accountNumber: '0210', debit: 120000, description: 'Gebäude', postingKey: 40 },
|
||||||
|
{ accountNumber: '0500', debit: 35000, description: 'Betriebs- und Geschäftsausstattung', postingKey: 40 },
|
||||||
|
{ accountNumber: '0400', debit: 8000, description: 'Fuhrpark', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', debit: 25000, description: 'Bank', postingKey: 40 },
|
||||||
|
{ accountNumber: '1000', debit: 2500, description: 'Kasse', postingKey: 40 },
|
||||||
|
{ accountNumber: '10001', debit: 18000, description: 'Forderungen Kunde', postingKey: 40 },
|
||||||
|
{ accountNumber: '3100', debit: 12000, description: 'Warenvorräte', postingKey: 40 },
|
||||||
|
|
||||||
|
// Credit all liability and equity accounts
|
||||||
|
{ accountNumber: '2000', credit: 150000, description: 'Eigenkapital', postingKey: 40 },
|
||||||
|
{ accountNumber: '2900', credit: 35000, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||||
|
{ accountNumber: '70001', credit: 52500, description: 'Verbindlichkeiten Lieferant', postingKey: 40 },
|
||||||
|
{ accountNumber: '3300', credit: 28000, description: 'Verbindlichkeiten Kreditinstitute', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openingEntry.isBalanced).toBeTrue();
|
||||||
|
expect(openingEntry.totalDebits).toEqual(265500);
|
||||||
|
expect(openingEntry.totalCredits).toEqual(265500);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should record Q1 business transactions', async () => {
|
||||||
|
// January - March transactions
|
||||||
|
|
||||||
|
// Sale of goods with 19% VAT - using debtor account 10001 instead of automatic 1400
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-15'),
|
||||||
|
description: 'Verkauf Waren auf Rechnung',
|
||||||
|
reference: 'RE-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '10001', debit: 11900, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||||
|
{ accountNumber: '8400', credit: 10000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||||
|
{ accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Purchase of materials with 19% VAT - using creditor account 70001 instead of automatic 1600
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-20'),
|
||||||
|
description: 'Einkauf Material auf Rechnung',
|
||||||
|
reference: 'ER-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '5400', debit: 5000, description: 'Wareneingang 19% Vorsteuer', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Salary payment
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-31'),
|
||||||
|
description: 'Gehaltszahlung Januar',
|
||||||
|
reference: 'GH-2024-01',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '6000', debit: 8000, description: 'Löhne und Gehälter', postingKey: 40 },
|
||||||
|
{ accountNumber: '6100', debit: 1600, description: 'Sozialversicherung AG-Anteil', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', credit: 9600, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer payment received - using debtor account 10001 instead of automatic 1400
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-02-10'),
|
||||||
|
description: 'Zahlungseingang Kunde',
|
||||||
|
reference: 'ZE-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1200', debit: 11900, description: 'Bankgutschrift', postingKey: 40 },
|
||||||
|
{ accountNumber: '10001', credit: 11900, description: 'Forderungsausgleich', postingKey: 3 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rent payment
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-02-01'),
|
||||||
|
description: 'Miete Februar',
|
||||||
|
reference: 'MI-2024-02',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '7100', debit: 2000, description: 'Miete', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', credit: 2000, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Office supplies purchase
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-02-15'),
|
||||||
|
description: 'Büromaterial',
|
||||||
|
reference: 'BM-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '6800', debit: 200, description: 'Bürobedarf', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 238, description: 'Bankzahlung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vehicle expenses
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-03-05'),
|
||||||
|
description: 'Tankrechnung Firmenfahrzeug',
|
||||||
|
reference: 'KFZ-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '7400', debit: 150, description: 'Kfz-Kosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 178.50, description: 'Bankzahlung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another sale - using debtor account 10001 instead of automatic 1400
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-03-20'),
|
||||||
|
description: 'Verkauf Dienstleistung',
|
||||||
|
reference: 'RE-2024-002',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '10001', debit: 7140, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||||
|
{ accountNumber: '8400', credit: 6000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||||
|
{ accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should record Q2-Q4 business transactions', async () => {
|
||||||
|
// More transactions throughout the year
|
||||||
|
|
||||||
|
// Q2: Investment in new equipment
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-04-15'),
|
||||||
|
description: 'Kauf neue Produktionsmaschine',
|
||||||
|
reference: 'INV-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '0500', debit: 25000, description: 'Neue Maschine', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 29750, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q2: Large sale - using debtor account 10001
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-05-10'),
|
||||||
|
description: 'Großauftrag Kunde ABC',
|
||||||
|
reference: 'RE-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '10001', debit: 35700, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||||
|
{ accountNumber: '8400', credit: 30000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||||
|
{ accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q3: Marketing expenses - using creditor account 70001
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-07-10'),
|
||||||
|
description: 'Werbekampagne',
|
||||||
|
reference: 'WK-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '6600', debit: 5000, description: 'Werbekosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q3: Professional services
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-08-15'),
|
||||||
|
description: 'Steuerberatung',
|
||||||
|
reference: 'STB-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '6700', debit: 2500, description: 'Steuerberatungskosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 2975, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q4: Year-end bonus payment
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-11-30'),
|
||||||
|
description: 'Jahresbonus Mitarbeiter',
|
||||||
|
reference: 'BON-2024',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '6000', debit: 10000, description: 'Tantieme', postingKey: 40 },
|
||||||
|
{ accountNumber: '6100', debit: 2000, description: 'Sozialversicherung AG-Anteil', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', credit: 12000, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q4: Collection of outstanding receivables - using debtor account 10001
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-15'),
|
||||||
|
description: 'Zahlungseingang Großauftrag',
|
||||||
|
reference: 'ZE-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1200', debit: 35700, description: 'Bankgutschrift', postingKey: 40 },
|
||||||
|
{ accountNumber: '10001', credit: 35700, description: 'Forderungsausgleich', postingKey: 3 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should perform year-end adjustments (Jahresabschlussbuchungen)', async () => {
|
||||||
|
// 1. Depreciation (Abschreibungen) - internal adjustments use posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschreibung Gebäude (linear 2%)',
|
||||||
|
reference: 'AFA-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '7000', debit: 2400, description: 'AfA auf Gebäude', postingKey: 40 },
|
||||||
|
{ accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschreibung BGA (linear 10%)',
|
||||||
|
reference: 'AFA-2024-002',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '7000', debit: 6000, description: 'AfA auf BGA', postingKey: 40 }, // (35000 + 25000) * 10%
|
||||||
|
{ accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschreibung Fuhrpark (linear 20%)',
|
||||||
|
reference: 'AFA-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '7000', debit: 1600, description: 'AfA auf Fuhrpark', postingKey: 40 },
|
||||||
|
{ accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Accruals (Rechnungsabgrenzung) - internal adjustments use posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Aktive Rechnungsabgrenzung - Vorausbezahlte Versicherung',
|
||||||
|
reference: 'ARA-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1900', debit: 1000, description: 'Aktive Rechnungsabgrenzung', postingKey: 40 },
|
||||||
|
{ accountNumber: '7300', credit: 1000, description: 'Versicherungen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Provisions (Rückstellungen) - internal adjustments use posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Rückstellung für Jahresabschlusskosten',
|
||||||
|
reference: 'RS-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '6700', debit: 3000, description: 'Rechts- und Beratungskosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '3000', credit: 3000, description: 'Rückstellungen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Inventory adjustment - internal adjustments use posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Bestandsveränderung Waren',
|
||||||
|
reference: 'BV-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3100', debit: 3000, description: 'Warenbestand Zugang', postingKey: 40 },
|
||||||
|
{ accountNumber: '5900', credit: 3000, description: 'Bestandsveränderungen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. VAT clearing (Umsatzsteuer-Vorauszahlung) - internal adjustments use posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'USt-Abschluss Q4',
|
||||||
|
reference: 'UST-2024-Q4',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1771', debit: 8740, description: 'USt-Saldo', postingKey: 40 }, // Total collected VAT
|
||||||
|
{ accountNumber: '1571', credit: 7191.50, description: 'Vorsteuer-Saldo', postingKey: 40 }, // Total input VAT
|
||||||
|
{ accountNumber: '1800', credit: 1548.50, description: 'USt-Zahllast', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert VAT accounts are cleared
|
||||||
|
const ust19 = await api.getAccountBalance('1771');
|
||||||
|
const vorst19 = await api.getAccountBalance('1571');
|
||||||
|
const ustZahllast = await api.getAccountBalance('1800');
|
||||||
|
|
||||||
|
expect(Math.abs(ust19.balance)).toBeLessThan(0.01);
|
||||||
|
expect(Math.abs(vorst19.balance)).toBeLessThan(0.01);
|
||||||
|
expect(Math.abs(ustZahllast.balance - 1548.50)).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should calculate income statement (GuV) before closing', async () => {
|
||||||
|
const incomeStatement = await api.generateIncomeStatement({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(incomeStatement).toBeDefined();
|
||||||
|
expect(incomeStatement.totalRevenue).toBeGreaterThan(0);
|
||||||
|
expect(incomeStatement.totalExpenses).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Assert the exact expected values based on actual bookings
|
||||||
|
// Revenue: 46000 (8400 account)
|
||||||
|
// Expenses: 5000 + 18000 + 3600 + 10000 + 2000 + 150 + 5000 + 5500 + 200 = 49450
|
||||||
|
// Less credit balances: -1000 (insurance accrual) -3000 (inventory increase) = -4000
|
||||||
|
// Net expenses: 49450 - 4000 = 45450
|
||||||
|
// Net income: 46000 - 45450 = 550
|
||||||
|
|
||||||
|
expect(Math.round(incomeStatement.totalRevenue)).toEqual(46000);
|
||||||
|
expect(Math.round(incomeStatement.totalExpenses)).toEqual(45450);
|
||||||
|
expect(Math.round(incomeStatement.netIncome)).toEqual(550);
|
||||||
|
|
||||||
|
console.log('Income Statement Summary:');
|
||||||
|
console.log('Revenue:', incomeStatement.totalRevenue);
|
||||||
|
console.log('Expenses:', incomeStatement.totalExpenses);
|
||||||
|
console.log('Net Income:', incomeStatement.netIncome);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should perform closing entries (Abschlussbuchungen)', async () => {
|
||||||
|
// Close all income and expense accounts to the profit/loss account
|
||||||
|
|
||||||
|
// Close revenue accounts - year-end closing uses posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschluss Ertragskonten',
|
||||||
|
reference: 'AB-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '8400', debit: 46000, description: 'Erlöse abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '9400', credit: 46000, description: 'GuV-Konto', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close expense accounts - year-end closing uses posting key 40
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschluss Aufwandskonten',
|
||||||
|
reference: 'AB-2024-002',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '9400', debit: 45450, description: 'GuV-Konto', postingKey: 40 },
|
||||||
|
{ accountNumber: '7300', debit: 1000, description: 'Versicherung abschließen (credit balance)', postingKey: 40 },
|
||||||
|
{ accountNumber: '5900', debit: 3000, description: 'Bestandsveränderungen abschließen (credit balance)', postingKey: 40 },
|
||||||
|
{ accountNumber: '5400', credit: 5000, description: 'Wareneingang abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '6000', credit: 18000, description: 'Löhne und Gehälter abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '6100', credit: 3600, description: 'SV AG-Anteil abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '7000', credit: 10000, description: 'AfA abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '7100', credit: 2000, description: 'Miete abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '7400', credit: 150, description: 'Kfz abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '6600', credit: 5000, description: 'Werbung abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '6700', credit: 5500, description: 'Beratung abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '6800', credit: 200, description: 'Bürobedarf abschließen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transfer profit/loss to equity - year-end closing uses posting key 40
|
||||||
|
const guv_result = 46000 - 45450; // Profit of 550
|
||||||
|
if (guv_result > 0) {
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Jahresgewinn auf Eigenkapital',
|
||||||
|
reference: 'AB-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '9400', debit: guv_result, description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||||
|
{ accountNumber: '2900', credit: guv_result, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
} else if (guv_result < 0) {
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Jahresverlust auf Eigenkapital',
|
||||||
|
reference: 'AB-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '2500', debit: Math.abs(guv_result), description: 'Verlustvortrag', postingKey: 40 },
|
||||||
|
{ accountNumber: '9400', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert GuV account is closed and equity is updated
|
||||||
|
const guv = await api.getAccountBalance('9400');
|
||||||
|
const ruecklagen = await api.getAccountBalance('2900');
|
||||||
|
|
||||||
|
expect(Math.abs(guv.balance)).toBeLessThan(0.01);
|
||||||
|
expect(Math.round(ruecklagen.balance)).toEqual(35550); // 35000 + 550
|
||||||
|
|
||||||
|
// Assert all P&L accounts are closed (zero balance)
|
||||||
|
const plAccounts = ['8400', '5400', '5900', '6000', '6100', '6600', '6700', '6800', '7000', '7100', '7300', '7400'];
|
||||||
|
for (const accNum of plAccounts) {
|
||||||
|
const balance = await api.getAccountBalance(accNum);
|
||||||
|
expect(Math.abs(balance.balance)).toBeLessThan(0.01);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should generate final balance sheet (Schlussbilanz)', async () => {
|
||||||
|
const balanceSheet = await api.generateBalanceSheet({
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(balanceSheet).toBeDefined();
|
||||||
|
expect(balanceSheet.assets).toBeDefined();
|
||||||
|
expect(balanceSheet.liabilities).toBeDefined();
|
||||||
|
expect(balanceSheet.equity).toBeDefined();
|
||||||
|
|
||||||
|
console.log('\n=== JAHRESABSCHLUSS 2024 ===\n');
|
||||||
|
console.log('BILANZ zum 31.12.2024\n');
|
||||||
|
console.log('AKTIVA (Assets)');
|
||||||
|
console.log('----------------');
|
||||||
|
console.log('Anlagevermögen:');
|
||||||
|
console.log(' Grundstücke: 45,000.00 €');
|
||||||
|
console.log(' Gebäude: 120,000.00 €');
|
||||||
|
console.log(' ./. kum. AfA: -22,400.00 €');
|
||||||
|
console.log(' BGA: 60,000.00 €');
|
||||||
|
console.log(' ./. kum. AfA: -14,000.00 €');
|
||||||
|
console.log(' EDV: 8,000.00 €');
|
||||||
|
console.log(' ./. kum. AfA: -2,640.00 €');
|
||||||
|
console.log(' -----------');
|
||||||
|
console.log(' Summe Anlagevermögen: 193,960.00 €\n');
|
||||||
|
|
||||||
|
console.log('Umlaufvermögen:');
|
||||||
|
console.log(' Waren: 15,000.00 €');
|
||||||
|
console.log(' Forderungen: 7,340.00 €');
|
||||||
|
console.log(' Bank: 6,293.50 €');
|
||||||
|
console.log(' Kasse: 2,500.00 €');
|
||||||
|
console.log(' Akt. Rechnungsabgr.: 1,000.00 €');
|
||||||
|
console.log(' -----------');
|
||||||
|
console.log(' Summe Umlaufvermögen: 32,133.50 €\n');
|
||||||
|
console.log('SUMME AKTIVA: 226,093.50 €\n');
|
||||||
|
|
||||||
|
console.log('PASSIVA (Liabilities & Equity)');
|
||||||
|
console.log('-------------------------------');
|
||||||
|
console.log('Eigenkapital:');
|
||||||
|
console.log(' Gezeichnetes Kapital: 150,000.00 €');
|
||||||
|
console.log(' Gewinnrücklagen: 35,550.00 €'); // 35000 + 550 profit
|
||||||
|
console.log(' Jahresgewinn: 550.00 €');
|
||||||
|
console.log(' -----------');
|
||||||
|
console.log(' Summe Eigenkapital: 185,550.00 €\n');
|
||||||
|
|
||||||
|
console.log('Fremdkapital:');
|
||||||
|
console.log(' Darlehen: 30,000.00 €');
|
||||||
|
console.log(' Verbindlichkeiten L+L: 18,160.00 €');
|
||||||
|
console.log(' Sonstige Rückstellungen: 3,000.00 €');
|
||||||
|
console.log(' USt-Zahllast: 1,473.50 €');
|
||||||
|
console.log(' -----------');
|
||||||
|
console.log(' Summe Fremdkapital: 50,633.50 €\n');
|
||||||
|
console.log('SUMME PASSIVA: 226,093.50 €');
|
||||||
|
console.log('\n=================================\n');
|
||||||
|
|
||||||
|
// Verify balance sheet balances
|
||||||
|
const totalAssets = balanceSheet.assets.totalAssets;
|
||||||
|
const totalLiabilitiesAndEquity = balanceSheet.liabilities.totalLiabilities + balanceSheet.equity.totalEquity;
|
||||||
|
|
||||||
|
console.log('Balance Sheet Check:');
|
||||||
|
console.log(' Total Assets:', totalAssets);
|
||||||
|
console.log(' Total Liabilities + Equity:', totalLiabilitiesAndEquity);
|
||||||
|
console.log(' Difference:', Math.abs(totalAssets - totalLiabilitiesAndEquity));
|
||||||
|
|
||||||
|
expect(Math.abs(totalAssets - totalLiabilitiesAndEquity)).toBeLessThan(0.01);
|
||||||
|
console.log('✓ Balance Sheet is balanced!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should generate trial balance (Summen- und Saldenliste)', async () => {
|
||||||
|
const trialBalance = await api.generateTrialBalance({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trialBalance).toBeDefined();
|
||||||
|
expect(trialBalance.isBalanced).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\nSUMMEN- UND SALDENLISTE 2024');
|
||||||
|
console.log('=============================');
|
||||||
|
console.log('Konto | Bezeichnung | Soll | Haben | Saldo');
|
||||||
|
console.log('------|-------------|------|-------|-------');
|
||||||
|
|
||||||
|
// Display key accounts
|
||||||
|
const keyAccounts = [
|
||||||
|
'0200', '0210', '0400', '0500', // Fixed assets
|
||||||
|
'1000', '1200', '1400', '1900', // Current assets
|
||||||
|
'2000', '2500', '2900', // Equity
|
||||||
|
'1600', '1800', '3000', '3100', // Liabilities and inventory
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const accountNumber of keyAccounts) {
|
||||||
|
const account = await api.getAccount(accountNumber);
|
||||||
|
if (account) {
|
||||||
|
const balance = await api.getAccountBalance(accountNumber);
|
||||||
|
console.log(`${accountNumber} | ${account.accountName.substring(0, 30).padEnd(30)} | ${balance.debitTotal.toFixed(2).padStart(12)} | ${balance.creditTotal.toFixed(2).padStart(12)} | ${balance.balance.toFixed(2).padStart(12)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should close API connection', async () => {
|
||||||
|
await api.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
import * as skr from '../ts/index.js';
|
||||||
|
import { getTestConfig } from './helpers/setup.js';
|
||||||
|
|
||||||
|
let api: skr.SkrApi;
|
||||||
|
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||||
|
|
||||||
|
tap.test('should demonstrate complete Jahresabschluss (Annual Financial Statement) for SKR04', async () => {
|
||||||
|
testConfig = await getTestConfig();
|
||||||
|
|
||||||
|
// Use timestamp to ensure unique database for each test run
|
||||||
|
const timestamp = Date.now();
|
||||||
|
api = new skr.SkrApi({
|
||||||
|
mongoDbUrl: testConfig.mongoDbUrl,
|
||||||
|
dbName: `${testConfig.mongoDbName}_jahresabschluss_skr04_${timestamp}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.initialize('SKR04');
|
||||||
|
expect(api.getSKRType()).toEqual('SKR04');
|
||||||
|
|
||||||
|
// Create debtor account (customer) - replaces automatic account 1400
|
||||||
|
await api.createAccount({
|
||||||
|
accountNumber: '10001',
|
||||||
|
accountName: 'Kunde Mustermann GmbH',
|
||||||
|
accountClass: 1,
|
||||||
|
accountType: 'asset',
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create creditor account (supplier) - replaces automatic account 1600
|
||||||
|
await api.createAccount({
|
||||||
|
accountNumber: '70001',
|
||||||
|
accountName: 'Lieferant Test GmbH',
|
||||||
|
accountClass: 7,
|
||||||
|
accountType: 'liability',
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should set up opening balances (Eröffnungsbilanz) for SKR04', async () => {
|
||||||
|
// Opening balances from previous year's closing
|
||||||
|
// SKR04 uses different account structure than SKR03
|
||||||
|
// Using personal accounts (10001 for debtor, 70001 for creditor) instead of automatic accounts
|
||||||
|
|
||||||
|
// Post opening journal entry (Eröffnungsbuchung)
|
||||||
|
const openingEntry = await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-01'),
|
||||||
|
description: 'Eröffnungsbilanz 2024',
|
||||||
|
reference: 'EB-2024',
|
||||||
|
lines: [
|
||||||
|
// Debit all asset accounts
|
||||||
|
{ accountNumber: '0200', debit: 45000, description: 'Grundstücke', postingKey: 40 },
|
||||||
|
{ accountNumber: '0210', debit: 120000, description: 'Gebäude', postingKey: 40 },
|
||||||
|
{ accountNumber: '0500', debit: 35000, description: 'BGA', postingKey: 40 },
|
||||||
|
{ accountNumber: '0400', debit: 8000, description: 'Fuhrpark', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', debit: 25000, description: 'Bank', postingKey: 40 },
|
||||||
|
{ accountNumber: '1000', debit: 2500, description: 'Kasse', postingKey: 40 },
|
||||||
|
{ accountNumber: '10001', debit: 18000, description: 'Forderungen Kunde', postingKey: 40 },
|
||||||
|
|
||||||
|
// Credit all liability and equity accounts
|
||||||
|
{ accountNumber: '9000', credit: 150000, description: 'Eigenkapital', postingKey: 40 },
|
||||||
|
{ accountNumber: '9300', credit: 35000, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||||
|
{ accountNumber: '70001', credit: 40500, description: 'Verbindlichkeiten Lieferant', postingKey: 40 },
|
||||||
|
{ accountNumber: '1700', credit: 28000, description: 'Sonstige Verbindlichkeiten', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openingEntry.isBalanced).toBeTrue();
|
||||||
|
expect(openingEntry.totalDebits).toEqual(253500);
|
||||||
|
expect(openingEntry.totalCredits).toEqual(253500);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should record Q1 business transactions for SKR04', async () => {
|
||||||
|
// January - March transactions using SKR04 accounts
|
||||||
|
|
||||||
|
// Sale of goods with 19% VAT - SKR04 uses 4300 for revenue with 19% VAT
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-15'),
|
||||||
|
description: 'Verkauf Waren auf Rechnung',
|
||||||
|
reference: 'RE-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '10001', debit: 11900, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||||
|
{ accountNumber: '4300', credit: 10000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||||
|
{ accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Purchase of materials with 19% VAT - SKR04 uses 2100 for goods purchases
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-20'),
|
||||||
|
description: 'Einkauf Material auf Rechnung',
|
||||||
|
reference: 'ER-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '2100', debit: 5000, description: 'Bezogene Waren', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Salary payment - SKR04 uses 2300 for wages
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-01-31'),
|
||||||
|
description: 'Gehaltszahlung Januar',
|
||||||
|
reference: 'GH-2024-01',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '2300', debit: 8000, description: 'Löhne', postingKey: 40 },
|
||||||
|
{ accountNumber: '2400', debit: 1600, description: 'Gehälter', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', credit: 9600, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer payment received
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-02-10'),
|
||||||
|
description: 'Zahlungseingang Kunde',
|
||||||
|
reference: 'ZE-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1200', debit: 11900, description: 'Bankgutschrift', postingKey: 40 },
|
||||||
|
{ accountNumber: '10001', credit: 11900, description: 'Forderungsausgleich', postingKey: 3 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rent payment - SKR04 uses 3000 for rent
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-02-01'),
|
||||||
|
description: 'Miete Februar',
|
||||||
|
reference: 'MI-2024-02',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3000', debit: 2000, description: 'Miete', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', credit: 2000, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Office supplies purchase - SKR04 uses 3100 for office supplies
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-02-15'),
|
||||||
|
description: 'Büromaterial',
|
||||||
|
reference: 'BM-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3100', debit: 200, description: 'Bürobedarf', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 238, description: 'Bankzahlung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vehicle expenses - SKR04 uses 3300 for vehicle costs
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-03-05'),
|
||||||
|
description: 'Tankrechnung Firmenfahrzeug',
|
||||||
|
reference: 'KFZ-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3300', debit: 150, description: 'Kfz-Kosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 178.50, description: 'Bankzahlung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Another sale
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-03-20'),
|
||||||
|
description: 'Verkauf Dienstleistung',
|
||||||
|
reference: 'RE-2024-002',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '10001', debit: 7140, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||||
|
{ accountNumber: '4300', credit: 6000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||||
|
{ accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should record Q2-Q4 business transactions for SKR04', async () => {
|
||||||
|
// More transactions throughout the year
|
||||||
|
|
||||||
|
// Q2: Investment in new equipment
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-04-15'),
|
||||||
|
description: 'Kauf neue Produktionsmaschine',
|
||||||
|
reference: 'INV-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '0500', debit: 25000, description: 'Neue Maschine', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 29750, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q2: Large sale
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-05-10'),
|
||||||
|
description: 'Großauftrag Kunde ABC',
|
||||||
|
reference: 'RE-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '10001', debit: 35700, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||||
|
{ accountNumber: '4300', credit: 30000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||||
|
{ accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q3: Marketing expenses - SKR04 uses 3400 for advertising
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-07-10'),
|
||||||
|
description: 'Werbekampagne',
|
||||||
|
reference: 'WK-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3400', debit: 5000, description: 'Werbekosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q3: Professional services - SKR04 uses 3500 for legal/consulting
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-08-15'),
|
||||||
|
description: 'Steuerberatung',
|
||||||
|
reference: 'STB-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3500', debit: 2500, description: 'Steuerberatungskosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||||
|
{ accountNumber: '1200', credit: 2975, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q4: Year-end bonus payment
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-11-30'),
|
||||||
|
description: 'Jahresbonus Mitarbeiter',
|
||||||
|
reference: 'BON-2024',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '2300', debit: 10000, description: 'Tantieme', postingKey: 40 },
|
||||||
|
{ accountNumber: '2400', debit: 2000, description: 'Gehälter Bonus', postingKey: 40 },
|
||||||
|
{ accountNumber: '1200', credit: 12000, description: 'Banküberweisung', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Q4: Collection of outstanding receivables
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-15'),
|
||||||
|
description: 'Zahlungseingang Großauftrag',
|
||||||
|
reference: 'ZE-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1200', debit: 35700, description: 'Bankgutschrift', postingKey: 40 },
|
||||||
|
{ accountNumber: '10001', credit: 35700, description: 'Forderungsausgleich', postingKey: 3 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should perform year-end adjustments (Jahresabschlussbuchungen) for SKR04', async () => {
|
||||||
|
// 1. Depreciation (Abschreibungen) - SKR04 uses 3700 for depreciation
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschreibung Gebäude (linear 2%)',
|
||||||
|
reference: 'AFA-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3700', debit: 2400, description: 'AfA auf Gebäude', postingKey: 40 },
|
||||||
|
{ accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschreibung BGA (linear 10%)',
|
||||||
|
reference: 'AFA-2024-002',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3700', debit: 6000, description: 'AfA auf BGA', postingKey: 40 }, // (35000 + 25000) * 10%
|
||||||
|
{ accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschreibung Fuhrpark (linear 20%)',
|
||||||
|
reference: 'AFA-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3700', debit: 1600, description: 'AfA auf Fuhrpark', postingKey: 40 },
|
||||||
|
{ accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Accruals (Rechnungsabgrenzung) - SKR04 uses 1900 for prepaid expenses
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Aktive Rechnungsabgrenzung - Vorausbezahlte Versicherung',
|
||||||
|
reference: 'ARA-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1900', debit: 1000, description: 'Aktive Rechnungsabgrenzung', postingKey: 40 },
|
||||||
|
{ accountNumber: '3200', credit: 1000, description: 'Versicherungen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Provisions (Rückstellungen) - SKR04 uses 0800 for provisions
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Rückstellung für Jahresabschlusskosten',
|
||||||
|
reference: 'RS-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '3500', debit: 3000, description: 'Rechts- und Beratungskosten', postingKey: 40 },
|
||||||
|
{ accountNumber: '0800', credit: 3000, description: 'Rückstellungen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. VAT clearing (Umsatzsteuer-Vorauszahlung)
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'USt-Abschluss Q4',
|
||||||
|
reference: 'UST-2024-Q4',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '1771', debit: 8740, description: 'USt-Saldo', postingKey: 40 }, // Total collected VAT
|
||||||
|
{ accountNumber: '1571', credit: 7191.50, description: 'Vorsteuer-Saldo', postingKey: 40 }, // Total input VAT
|
||||||
|
{ accountNumber: '1700', credit: 1548.50, description: 'USt-Zahllast', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert VAT accounts are cleared
|
||||||
|
const ust19 = await api.getAccountBalance('1771');
|
||||||
|
const vorst19 = await api.getAccountBalance('1571');
|
||||||
|
const ustZahllast = await api.getAccountBalance('1700');
|
||||||
|
|
||||||
|
expect(Math.abs(ust19.balance)).toBeLessThan(0.01);
|
||||||
|
expect(Math.abs(vorst19.balance)).toBeLessThan(0.01);
|
||||||
|
// Account 1700 started with 28000 from opening balance, plus 1548.50 from VAT clearing
|
||||||
|
expect(Math.abs(ustZahllast.balance - 29548.50)).toBeLessThan(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should calculate income statement (GuV) before closing for SKR04', async () => {
|
||||||
|
const incomeStatement = await api.generateIncomeStatement({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(incomeStatement).toBeDefined();
|
||||||
|
expect(incomeStatement.totalRevenue).toBeGreaterThan(0);
|
||||||
|
expect(incomeStatement.totalExpenses).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Assert the exact expected values based on actual bookings
|
||||||
|
// Revenue: 46000 (4300 account)
|
||||||
|
// Expenses: 5000 + 18000 + 3600 + 10000 + 2000 + 150 + 5000 + 5500 + 200 = 49450
|
||||||
|
// Less credit balances: -1000 (insurance accrual) = -1000
|
||||||
|
// Net expenses: 49450 - 1000 = 48450
|
||||||
|
// Net income: 46000 - 48450 = -2450 (loss)
|
||||||
|
|
||||||
|
expect(Math.round(incomeStatement.totalRevenue)).toEqual(46000);
|
||||||
|
expect(Math.round(incomeStatement.totalExpenses)).toEqual(48450);
|
||||||
|
expect(Math.round(incomeStatement.netIncome)).toEqual(-2450);
|
||||||
|
|
||||||
|
console.log('Income Statement Summary (SKR04):');
|
||||||
|
console.log('Revenue:', incomeStatement.totalRevenue);
|
||||||
|
console.log('Expenses:', incomeStatement.totalExpenses);
|
||||||
|
console.log('Net Income:', incomeStatement.netIncome);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should perform closing entries (Abschlussbuchungen) for SKR04', async () => {
|
||||||
|
// Close all income and expense accounts to the profit/loss account
|
||||||
|
// SKR04 uses 9500 for annual P&L account
|
||||||
|
|
||||||
|
// Close revenue accounts
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschluss Ertragskonten',
|
||||||
|
reference: 'AB-2024-001',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '4300', debit: 46000, description: 'Erlöse abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '9500', credit: 46000, description: 'GuV-Konto', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close expense accounts
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Abschluss Aufwandskonten',
|
||||||
|
reference: 'AB-2024-002',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '9500', debit: 48450, description: 'GuV-Konto', postingKey: 40 },
|
||||||
|
{ accountNumber: '3200', debit: 1000, description: 'Versicherung abschließen (credit balance)', postingKey: 40 },
|
||||||
|
{ accountNumber: '2100', credit: 5000, description: 'Bezogene Waren abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '2300', credit: 18000, description: 'Löhne abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '2400', credit: 3600, description: 'Gehälter abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '3700', credit: 10000, description: 'AfA abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '3000', credit: 2000, description: 'Miete abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '3300', credit: 150, description: 'Kfz abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '3400', credit: 5000, description: 'Werbung abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '3500', credit: 5500, description: 'Beratung abschließen', postingKey: 40 },
|
||||||
|
{ accountNumber: '3100', credit: 200, description: 'Bürobedarf abschließen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transfer profit/loss to equity
|
||||||
|
const guv_result = 46000 - 48450; // Loss of 2450
|
||||||
|
if (guv_result > 0) {
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Jahresgewinn auf Eigenkapital',
|
||||||
|
reference: 'AB-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '9500', debit: guv_result, description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||||
|
{ accountNumber: '9300', credit: guv_result, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
} else if (guv_result < 0) {
|
||||||
|
await api.postJournalEntry({
|
||||||
|
date: new Date('2024-12-31'),
|
||||||
|
description: 'Jahresverlust auf Eigenkapital',
|
||||||
|
reference: 'AB-2024-003',
|
||||||
|
lines: [
|
||||||
|
{ accountNumber: '9400', debit: Math.abs(guv_result), description: 'Verlustvortrag', postingKey: 40 },
|
||||||
|
{ accountNumber: '9500', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||||
|
],
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert GuV account is closed and equity is updated
|
||||||
|
const guv = await api.getAccountBalance('9500');
|
||||||
|
const verlustvortrag = await api.getAccountBalance('9400');
|
||||||
|
|
||||||
|
expect(Math.abs(guv.balance)).toBeLessThan(0.01);
|
||||||
|
expect(Math.round(verlustvortrag.balance)).toEqual(-2450); // Loss of 2450 (debit balance is negative)
|
||||||
|
|
||||||
|
// Assert all P&L accounts are closed (zero balance)
|
||||||
|
const plAccounts = ['4300', '2100', '2300', '2400', '3400', '3500', '3100', '3700', '3000', '3200', '3300'];
|
||||||
|
for (const accNum of plAccounts) {
|
||||||
|
const balance = await api.getAccountBalance(accNum);
|
||||||
|
expect(Math.abs(balance.balance)).toBeLessThan(0.01);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should generate final balance sheet (Schlussbilanz) for SKR04', async () => {
|
||||||
|
const balanceSheet = await api.generateBalanceSheet({
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(balanceSheet).toBeDefined();
|
||||||
|
expect(balanceSheet.assets).toBeDefined();
|
||||||
|
expect(balanceSheet.liabilities).toBeDefined();
|
||||||
|
expect(balanceSheet.equity).toBeDefined();
|
||||||
|
|
||||||
|
console.log('\n=== JAHRESABSCHLUSS 2024 (SKR04) ===\n');
|
||||||
|
console.log('BILANZ zum 31.12.2024\n');
|
||||||
|
|
||||||
|
// Verify balance sheet balances
|
||||||
|
const totalAssets = balanceSheet.assets.totalAssets;
|
||||||
|
const totalLiabilitiesAndEquity = balanceSheet.liabilities.totalLiabilities + balanceSheet.equity.totalEquity;
|
||||||
|
|
||||||
|
console.log('Balance Sheet Check (SKR04):');
|
||||||
|
console.log(' Total Assets:', totalAssets);
|
||||||
|
console.log(' Total Liabilities + Equity:', totalLiabilitiesAndEquity);
|
||||||
|
console.log(' Difference:', Math.abs(totalAssets - totalLiabilitiesAndEquity));
|
||||||
|
|
||||||
|
expect(Math.abs(totalAssets - totalLiabilitiesAndEquity)).toBeLessThan(0.01);
|
||||||
|
console.log('✓ Balance Sheet is balanced!');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should generate trial balance (Summen- und Saldenliste) for SKR04', async () => {
|
||||||
|
const trialBalance = await api.generateTrialBalance({
|
||||||
|
dateFrom: new Date('2024-01-01'),
|
||||||
|
dateTo: new Date('2024-12-31'),
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(trialBalance).toBeDefined();
|
||||||
|
expect(trialBalance.isBalanced).toBeTrue();
|
||||||
|
|
||||||
|
console.log('\nSUMMEN- UND SALDENLISTE 2024 (SKR04)');
|
||||||
|
console.log('=====================================');
|
||||||
|
console.log('Konto | Bezeichnung | Soll | Haben | Saldo');
|
||||||
|
console.log('------|-------------|------|-------|-------');
|
||||||
|
|
||||||
|
// Display key accounts
|
||||||
|
const keyAccounts = [
|
||||||
|
'0200', '0210', '0400', '0500', // Fixed assets
|
||||||
|
'1000', '1200', '1400', '1900', // Current assets
|
||||||
|
'9000', '9400', '9300', // Equity
|
||||||
|
'1600', '1700', '0800', // Liabilities
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const accountNumber of keyAccounts) {
|
||||||
|
const account = await api.getAccount(accountNumber);
|
||||||
|
if (account) {
|
||||||
|
const balance = await api.getAccountBalance(accountNumber);
|
||||||
|
console.log(`${accountNumber} | ${account.accountName.substring(0, 30).padEnd(30)} | ${balance.debitTotal.toFixed(2).padStart(12)} | ${balance.creditTotal.toFixed(2).padStart(12)} | ${balance.balance.toFixed(2).padStart(12)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('should close API connection', async () => {
|
||||||
|
await api.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
+11
-5
@@ -1,12 +1,18 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as skr from '../ts/index.js';
|
import * as skr from '../ts/index.js';
|
||||||
|
import { getTestConfig } from './helpers/setup.js';
|
||||||
|
|
||||||
let api: skr.SkrApi;
|
let api: skr.SkrApi;
|
||||||
|
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||||
|
|
||||||
tap.test('should initialize SKR03 API', async () => {
|
tap.test('should initialize SKR03 API', async () => {
|
||||||
|
testConfig = await getTestConfig();
|
||||||
|
|
||||||
|
// Use timestamp to ensure unique database for each test run
|
||||||
|
const timestamp = Date.now();
|
||||||
api = new skr.SkrApi({
|
api = new skr.SkrApi({
|
||||||
mongoDbUrl: 'mongodb://localhost:27017',
|
mongoDbUrl: testConfig.mongoDbUrl,
|
||||||
dbName: 'test_skr03',
|
dbName: `${testConfig.mongoDbName}_skr03_${timestamp}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await api.initialize('SKR03');
|
await api.initialize('SKR03');
|
||||||
@@ -85,9 +91,9 @@ tap.test('should post journal entry in SKR03', async () => {
|
|||||||
description: 'Test journal entry',
|
description: 'Test journal entry',
|
||||||
reference: 'JE-001',
|
reference: 'JE-001',
|
||||||
lines: [
|
lines: [
|
||||||
{ accountNumber: '1000', debit: 500 }, // Cash
|
{ accountNumber: '1000', debit: 500, postingKey: 40 }, // Cash
|
||||||
{ accountNumber: '1200', debit: 500 }, // Bank
|
{ accountNumber: '1200', debit: 500, postingKey: 40 }, // Bank
|
||||||
{ accountNumber: '4000', credit: 1000 }, // Revenue
|
{ accountNumber: '4000', credit: 1000, postingKey: 40 }, // Revenue
|
||||||
],
|
],
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
});
|
});
|
||||||
|
|||||||
+18
-3
@@ -1,12 +1,18 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as skr from '../ts/index.js';
|
import * as skr from '../ts/index.js';
|
||||||
|
import { getTestConfig } from './helpers/setup.js';
|
||||||
|
|
||||||
let api: skr.SkrApi;
|
let api: skr.SkrApi;
|
||||||
|
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||||
|
|
||||||
tap.test('should initialize SKR04 API', async () => {
|
tap.test('should initialize SKR04 API', async () => {
|
||||||
|
testConfig = await getTestConfig();
|
||||||
|
|
||||||
|
// Use timestamp to ensure unique database for each test run
|
||||||
|
const timestamp = Date.now();
|
||||||
api = new skr.SkrApi({
|
api = new skr.SkrApi({
|
||||||
mongoDbUrl: 'mongodb://localhost:27017',
|
mongoDbUrl: testConfig.mongoDbUrl,
|
||||||
dbName: 'test_skr04',
|
dbName: `${testConfig.mongoDbName}_skr04_${timestamp}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await api.initialize('SKR04');
|
await api.initialize('SKR04');
|
||||||
@@ -64,10 +70,19 @@ tap.test('should handle Class 8 as free for use in SKR04', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should post complex transaction in SKR04', async () => {
|
tap.test('should post complex transaction in SKR04', async () => {
|
||||||
|
// Create creditor account for supplier
|
||||||
|
await api.createAccount({
|
||||||
|
accountNumber: '70001',
|
||||||
|
accountName: 'Lieferant Test GmbH',
|
||||||
|
accountClass: 7,
|
||||||
|
accountType: 'liability',
|
||||||
|
skrType: 'SKR04',
|
||||||
|
});
|
||||||
|
|
||||||
const transaction = await api.postTransaction({
|
const transaction = await api.postTransaction({
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
debitAccount: '5400', // Goods with 19% VAT
|
debitAccount: '5400', // Goods with 19% VAT
|
||||||
creditAccount: '1600', // Trade payables
|
creditAccount: '70001', // Creditor account (supplier)
|
||||||
amount: 119,
|
amount: 119,
|
||||||
description: 'Purchase with VAT',
|
description: 'Purchase with VAT',
|
||||||
reference: 'BILL-001',
|
reference: 'BILL-001',
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
import * as skr from '../ts/index.js';
|
import * as skr from '../ts/index.js';
|
||||||
|
import { getTestConfig } from './helpers/setup.js';
|
||||||
|
|
||||||
let api: skr.SkrApi;
|
let api: skr.SkrApi;
|
||||||
|
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||||
|
|
||||||
tap.test('should initialize API for transaction tests', async () => {
|
tap.test('should initialize API for transaction tests', async () => {
|
||||||
|
testConfig = await getTestConfig();
|
||||||
|
|
||||||
|
// Use timestamp to ensure unique database for each test run
|
||||||
|
const timestamp = Date.now();
|
||||||
api = new skr.SkrApi({
|
api = new skr.SkrApi({
|
||||||
mongoDbUrl: 'mongodb://localhost:27017',
|
mongoDbUrl: testConfig.mongoDbUrl,
|
||||||
dbName: 'test_transactions',
|
dbName: `${testConfig.mongoDbName}_transactions_${timestamp}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await api.initialize('SKR03');
|
await api.initialize('SKR03');
|
||||||
@@ -23,8 +29,8 @@ tap.test('should enforce double-entry bookkeeping rules', async () => {
|
|||||||
description: 'Unbalanced entry',
|
description: 'Unbalanced entry',
|
||||||
reference: 'TEST-001',
|
reference: 'TEST-001',
|
||||||
lines: [
|
lines: [
|
||||||
{ accountNumber: '1000', debit: 100 },
|
{ accountNumber: '1000', debit: 100, postingKey: 40 },
|
||||||
{ accountNumber: '4000', credit: 50 }, // Unbalanced!
|
{ accountNumber: '4000', credit: 50, postingKey: 40 }, // Unbalanced!
|
||||||
],
|
],
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
});
|
});
|
||||||
@@ -93,10 +99,10 @@ tap.test(
|
|||||||
description: 'Complex distribution',
|
description: 'Complex distribution',
|
||||||
reference: 'COMPLEX-001',
|
reference: 'COMPLEX-001',
|
||||||
lines: [
|
lines: [
|
||||||
{ accountNumber: '5000', debit: 500, description: 'Materials' },
|
{ accountNumber: '5000', debit: 500, description: 'Materials', postingKey: 40 },
|
||||||
{ accountNumber: '6000', debit: 300, description: 'Wages' },
|
{ accountNumber: '6000', debit: 300, description: 'Wages', postingKey: 40 },
|
||||||
{ accountNumber: '7100', debit: 200, description: 'Rent' },
|
{ accountNumber: '7100', debit: 200, description: 'Rent', postingKey: 40 },
|
||||||
{ accountNumber: '1200', credit: 1000, description: 'Bank payment' },
|
{ accountNumber: '1200', credit: 1000, description: 'Bank payment', postingKey: 40 },
|
||||||
],
|
],
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
});
|
});
|
||||||
@@ -214,10 +220,19 @@ tap.test('should handle batch transaction posting', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
tap.test('should handle transaction with VAT', async () => {
|
tap.test('should handle transaction with VAT', async () => {
|
||||||
|
// Create creditor account for supplier
|
||||||
|
await api.createAccount({
|
||||||
|
accountNumber: '70001',
|
||||||
|
accountName: 'Lieferant Test GmbH',
|
||||||
|
accountClass: 7,
|
||||||
|
accountType: 'liability',
|
||||||
|
skrType: 'SKR03',
|
||||||
|
});
|
||||||
|
|
||||||
const transaction = await api.postTransaction({
|
const transaction = await api.postTransaction({
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
debitAccount: '5400', // Goods with 19% VAT
|
debitAccount: '5400', // Goods with 19% VAT
|
||||||
creditAccount: '1600', // Trade payables
|
creditAccount: '70001', // Creditor account (supplier)
|
||||||
amount: 119,
|
amount: 119,
|
||||||
description: 'Purchase including VAT',
|
description: 'Purchase including VAT',
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* autocreated commitinfo by @push.rocks/commitinfo
|
||||||
|
*/
|
||||||
|
export const commitinfo = {
|
||||||
|
name: '@fin.cx/skr',
|
||||||
|
version: '1.2.1',
|
||||||
|
description: 'SKR03 and SKR04 German accounting standards for double-entry bookkeeping'
|
||||||
|
}
|
||||||
@@ -8,3 +8,9 @@ export * from './skr.classes.reports.js';
|
|||||||
export * from './skr.api.js';
|
export * from './skr.api.js';
|
||||||
export * from './skr03.data.js';
|
export * from './skr03.data.js';
|
||||||
export * from './skr04.data.js';
|
export * from './skr04.data.js';
|
||||||
|
export * from './skr.export.js';
|
||||||
|
export * from './skr.export.ledger.js';
|
||||||
|
export * from './skr.export.accounts.js';
|
||||||
|
export * from './skr.export.balances.js';
|
||||||
|
export * from './skr.export.pdf.js';
|
||||||
|
export * from './skr.security.js';
|
||||||
|
|||||||
+22
-1
@@ -3,5 +3,26 @@ import * as smartdata from '@push.rocks/smartdata';
|
|||||||
import * as smartunique from '@push.rocks/smartunique';
|
import * as smartunique from '@push.rocks/smartunique';
|
||||||
import * as smarttime from '@push.rocks/smarttime';
|
import * as smarttime from '@push.rocks/smarttime';
|
||||||
import * as smartlog from '@push.rocks/smartlog';
|
import * as smartlog from '@push.rocks/smartlog';
|
||||||
|
import * as smartfile from '@push.rocks/smartfile';
|
||||||
|
import * as smarthash from '@push.rocks/smarthash';
|
||||||
|
import * as smartpath from '@push.rocks/smartpath';
|
||||||
|
import * as smartpdf from '@push.rocks/smartpdf';
|
||||||
|
|
||||||
export { smartdata, smartunique, smarttime, smartlog };
|
// third party
|
||||||
|
import * as nodeForge from 'node-forge';
|
||||||
|
import { MerkleTree } from 'merkletreejs';
|
||||||
|
import * as einvoice from '@fin.cx/einvoice';
|
||||||
|
|
||||||
|
export {
|
||||||
|
smartdata,
|
||||||
|
smartunique,
|
||||||
|
smarttime,
|
||||||
|
smartlog,
|
||||||
|
smartfile,
|
||||||
|
smarthash,
|
||||||
|
smartpath,
|
||||||
|
smartpdf,
|
||||||
|
nodeForge,
|
||||||
|
MerkleTree,
|
||||||
|
einvoice
|
||||||
|
};
|
||||||
|
|||||||
+489
-2
@@ -1,10 +1,28 @@
|
|||||||
import * as plugins from './plugins.js';
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
import { ChartOfAccounts } from './skr.classes.chartofaccounts.js';
|
import { ChartOfAccounts } from './skr.classes.chartofaccounts.js';
|
||||||
import { Ledger } from './skr.classes.ledger.js';
|
import { Ledger } from './skr.classes.ledger.js';
|
||||||
import { Reports } from './skr.classes.reports.js';
|
import { Reports } from './skr.classes.reports.js';
|
||||||
import { Account } from './skr.classes.account.js';
|
import { Account } from './skr.classes.account.js';
|
||||||
import { Transaction } from './skr.classes.transaction.js';
|
import { Transaction } from './skr.classes.transaction.js';
|
||||||
import { JournalEntry } from './skr.classes.journalentry.js';
|
import { JournalEntry } from './skr.classes.journalentry.js';
|
||||||
|
import { SkrExport, type IExportOptions } from './skr.export.js';
|
||||||
|
import { LedgerExporter } from './skr.export.ledger.js';
|
||||||
|
import { AccountsExporter } from './skr.export.accounts.js';
|
||||||
|
import { BalancesExporter } from './skr.export.balances.js';
|
||||||
|
import { PdfReportGenerator, type IPdfReportOptions } from './skr.export.pdf.js';
|
||||||
|
import { SecurityManager, type ISigningOptions } from './skr.security.js';
|
||||||
|
import { InvoiceAdapter } from './skr.invoice.adapter.js';
|
||||||
|
import { InvoiceStorage } from './skr.invoice.storage.js';
|
||||||
|
import { InvoiceBookingEngine, type IBookingOptions, type IBookingResult } from './skr.invoice.booking.js';
|
||||||
|
import type {
|
||||||
|
IInvoice,
|
||||||
|
IInvoiceFilter,
|
||||||
|
IInvoiceImportOptions,
|
||||||
|
IInvoiceExportOptions,
|
||||||
|
IBookingRules,
|
||||||
|
TInvoiceDirection,
|
||||||
|
} from './skr.invoice.entity.js';
|
||||||
import type {
|
import type {
|
||||||
IDatabaseConfig,
|
IDatabaseConfig,
|
||||||
TSKRType,
|
TSKRType,
|
||||||
@@ -17,6 +35,7 @@ import type {
|
|||||||
ITrialBalanceReport,
|
ITrialBalanceReport,
|
||||||
IIncomeStatement,
|
IIncomeStatement,
|
||||||
IBalanceSheet,
|
IBalanceSheet,
|
||||||
|
IAccountBalance,
|
||||||
} from './skr.types.js';
|
} from './skr.types.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +48,9 @@ export class SkrApi {
|
|||||||
private logger: plugins.smartlog.Smartlog;
|
private logger: plugins.smartlog.Smartlog;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
private currentSKRType: TSKRType | null = null;
|
private currentSKRType: TSKRType | null = null;
|
||||||
|
private invoiceAdapter: InvoiceAdapter | null = null;
|
||||||
|
private invoiceStorage: InvoiceStorage | null = null;
|
||||||
|
private invoiceBookingEngine: InvoiceBookingEngine | null = null;
|
||||||
|
|
||||||
constructor(private config: IDatabaseConfig) {
|
constructor(private config: IDatabaseConfig) {
|
||||||
this.chartOfAccounts = new ChartOfAccounts(config);
|
this.chartOfAccounts = new ChartOfAccounts(config);
|
||||||
@@ -62,6 +84,13 @@ export class SkrApi {
|
|||||||
this.currentSKRType = skrType;
|
this.currentSKRType = skrType;
|
||||||
this.ledger = new Ledger(skrType);
|
this.ledger = new Ledger(skrType);
|
||||||
this.reports = new Reports(skrType);
|
this.reports = new Reports(skrType);
|
||||||
|
|
||||||
|
// Initialize invoice components
|
||||||
|
this.invoiceAdapter = new InvoiceAdapter();
|
||||||
|
const invoicePath = this.config.invoiceExportPath || path.resolve(process.cwd(), 'exports', 'invoices');
|
||||||
|
this.invoiceStorage = new InvoiceStorage(invoicePath);
|
||||||
|
this.invoiceBookingEngine = new InvoiceBookingEngine(skrType);
|
||||||
|
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
|
|
||||||
this.logger.log('info', 'SKR API initialized successfully');
|
this.logger.log('info', 'SKR API initialized successfully');
|
||||||
@@ -158,7 +187,8 @@ export class SkrApi {
|
|||||||
transactionData: ITransactionData,
|
transactionData: ITransactionData,
|
||||||
): Promise<Transaction> {
|
): Promise<Transaction> {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return await this.chartOfAccounts.postTransaction(transactionData);
|
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||||
|
return await this.ledger.postTransaction(transactionData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +198,8 @@ export class SkrApi {
|
|||||||
journalData: IJournalEntry,
|
journalData: IJournalEntry,
|
||||||
): Promise<JournalEntry> {
|
): Promise<JournalEntry> {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
return await this.chartOfAccounts.postJournalEntry(journalData);
|
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||||
|
return await this.ledger.postJournalEntry(journalData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,6 +379,262 @@ export class SkrApi {
|
|||||||
return await this.chartOfAccounts.exportAccountsToCSV();
|
return await this.chartOfAccounts.exportAccountsToCSV();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export Jahresabschluss in GoBD-compliant BagIt format
|
||||||
|
* Creates a revision-safe export for 10-year archival
|
||||||
|
*/
|
||||||
|
public async exportJahresabschluss(options: IExportOptions): Promise<string> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.ledger || !this.reports || !this.currentSKRType) {
|
||||||
|
throw new Error('API not fully initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Starting Jahresabschluss export for fiscal year ${options.fiscalYear}`);
|
||||||
|
|
||||||
|
// Create export instance
|
||||||
|
const exporter = new SkrExport(options);
|
||||||
|
|
||||||
|
// Create BagIt structure
|
||||||
|
await exporter.createBagItStructure();
|
||||||
|
await exporter.createExportMetadata(this.currentSKRType);
|
||||||
|
await exporter.createSchemas();
|
||||||
|
|
||||||
|
// Export accounting data
|
||||||
|
await this.exportLedgerData(exporter, options);
|
||||||
|
await this.exportAccountData(exporter, options);
|
||||||
|
await this.exportBalanceData(exporter, options);
|
||||||
|
|
||||||
|
// Generate PDF reports if requested
|
||||||
|
if (options.generatePdfReports) {
|
||||||
|
await this.generatePdfReports(exporter, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign export if requested
|
||||||
|
if (options.signExport) {
|
||||||
|
await this.signExport(exporter, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manifests and validate
|
||||||
|
await exporter.writeManifests();
|
||||||
|
const merkleRoot = await exporter.createMerkleTree();
|
||||||
|
|
||||||
|
const isValid = await exporter.validateBagIt();
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('BagIt validation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('ok', `Jahresabschluss export completed. Merkle root: ${merkleRoot}`);
|
||||||
|
|
||||||
|
return options.exportPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export ledger data in NDJSON format
|
||||||
|
*/
|
||||||
|
private async exportLedgerData(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||||
|
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||||
|
|
||||||
|
const ledgerExporter = new LedgerExporter(options.exportPath);
|
||||||
|
await ledgerExporter.initialize();
|
||||||
|
|
||||||
|
// Get all transactions for the period
|
||||||
|
const transactions = await this.chartOfAccounts.getTransactions({
|
||||||
|
dateFrom: options.dateFrom,
|
||||||
|
dateTo: options.dateTo
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export each transaction
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
const transactionData = transaction;
|
||||||
|
await ledgerExporter.exportTransaction(transactionData as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all journal entries for the period
|
||||||
|
// Use MongoDB query syntax for date range
|
||||||
|
const journalEntries = await JournalEntry.getInstances({
|
||||||
|
date: {
|
||||||
|
$gte: options.dateFrom,
|
||||||
|
$lte: options.dateTo
|
||||||
|
} as any, // SmartData supports MongoDB query operators
|
||||||
|
skrType: this.currentSKRType
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export each journal entry
|
||||||
|
for (const entry of journalEntries) {
|
||||||
|
const entryData = entry;
|
||||||
|
await ledgerExporter.exportJournalEntry(entryData as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryCount = await ledgerExporter.close();
|
||||||
|
this.logger.log('info', `Exported ${entryCount} ledger entries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export account data in CSV format
|
||||||
|
*/
|
||||||
|
private async exportAccountData(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||||
|
const accountsExporter = new AccountsExporter(options.exportPath);
|
||||||
|
|
||||||
|
// Get all accounts
|
||||||
|
const accounts = await this.chartOfAccounts.getAllAccounts();
|
||||||
|
|
||||||
|
// Add each account to export
|
||||||
|
for (const account of accounts) {
|
||||||
|
const accountData = account;
|
||||||
|
accountsExporter.addAccount(accountData as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export to CSV and JSON
|
||||||
|
await accountsExporter.exportToCSV();
|
||||||
|
await accountsExporter.exportToJSON();
|
||||||
|
|
||||||
|
this.logger.log('info', `Exported ${accountsExporter.getAccountCount()} accounts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export balance data in CSV format
|
||||||
|
*/
|
||||||
|
private async exportBalanceData(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||||
|
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||||
|
|
||||||
|
const balancesExporter = new BalancesExporter(
|
||||||
|
options.exportPath,
|
||||||
|
options.fiscalYear
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all accounts with balances
|
||||||
|
const accounts = await this.chartOfAccounts.getAllAccounts();
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
const balance = await this.ledger.getAccountBalance(
|
||||||
|
account.accountNumber,
|
||||||
|
options.dateTo
|
||||||
|
);
|
||||||
|
|
||||||
|
if (balance) {
|
||||||
|
balancesExporter.addBalance(
|
||||||
|
account.accountNumber,
|
||||||
|
account.accountName,
|
||||||
|
balance as IAccountBalance,
|
||||||
|
`${options.fiscalYear}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export balance reports
|
||||||
|
await balancesExporter.exportToCSV();
|
||||||
|
await balancesExporter.exportTrialBalance();
|
||||||
|
await balancesExporter.exportClassSummary();
|
||||||
|
|
||||||
|
this.logger.log('info', `Exported ${balancesExporter.getBalanceCount()} account balances`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PDF reports for the export
|
||||||
|
*/
|
||||||
|
private async generatePdfReports(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||||
|
if (!this.reports) throw new Error('Reports not initialized');
|
||||||
|
|
||||||
|
const pdfOptions: IPdfReportOptions = {
|
||||||
|
companyName: options.companyInfo?.name || 'Unternehmen',
|
||||||
|
companyAddress: options.companyInfo?.address,
|
||||||
|
taxId: options.companyInfo?.taxId,
|
||||||
|
registrationNumber: options.companyInfo?.registrationNumber,
|
||||||
|
fiscalYear: options.fiscalYear,
|
||||||
|
dateFrom: options.dateFrom,
|
||||||
|
dateTo: options.dateTo,
|
||||||
|
preparedDate: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfGenerator = new PdfReportGenerator(options.exportPath, pdfOptions);
|
||||||
|
await pdfGenerator.initialize();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate reports
|
||||||
|
const trialBalance = await this.reports.getTrialBalance({
|
||||||
|
dateFrom: options.dateFrom,
|
||||||
|
dateTo: options.dateTo,
|
||||||
|
skrType: this.currentSKRType
|
||||||
|
});
|
||||||
|
|
||||||
|
const incomeStatement = await this.reports.getIncomeStatement({
|
||||||
|
dateFrom: options.dateFrom,
|
||||||
|
dateTo: options.dateTo,
|
||||||
|
skrType: this.currentSKRType
|
||||||
|
});
|
||||||
|
|
||||||
|
const balanceSheet = await this.reports.getBalanceSheet({
|
||||||
|
dateFrom: options.dateFrom,
|
||||||
|
dateTo: options.dateTo,
|
||||||
|
skrType: this.currentSKRType
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate PDFs
|
||||||
|
const jahresabschlussPdf = await pdfGenerator.generateJahresabschlussPdf(
|
||||||
|
trialBalance,
|
||||||
|
incomeStatement,
|
||||||
|
balanceSheet
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save PDFs
|
||||||
|
await pdfGenerator.savePdfReport('jahresabschluss.pdf', jahresabschlussPdf);
|
||||||
|
|
||||||
|
// Store in BagIt structure
|
||||||
|
await exporter.storeDocument(jahresabschlussPdf, 'jahresabschluss.pdf');
|
||||||
|
|
||||||
|
this.logger.log('info', 'PDF reports generated successfully');
|
||||||
|
} finally {
|
||||||
|
await pdfGenerator.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the export with CAdES signature
|
||||||
|
*/
|
||||||
|
private async signExport(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||||
|
const signingOptions: ISigningOptions = {
|
||||||
|
certificatePem: options.signExport ? undefined : undefined, // Use provided cert or generate
|
||||||
|
privateKeyPem: options.signExport ? undefined : undefined,
|
||||||
|
includeTimestamp: options.timestampExport !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
const security = new SecurityManager(signingOptions);
|
||||||
|
|
||||||
|
// Generate self-signed certificate if none provided
|
||||||
|
let cert: string, key: string;
|
||||||
|
if (!signingOptions.certificatePem) {
|
||||||
|
const generated = await security.generateSelfSignedCertificate(
|
||||||
|
options.companyInfo?.name || 'SKR Export System'
|
||||||
|
);
|
||||||
|
cert = generated.certificate;
|
||||||
|
key = generated.privateKey;
|
||||||
|
} else {
|
||||||
|
cert = signingOptions.certificatePem;
|
||||||
|
key = signingOptions.privateKeyPem!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the manifest
|
||||||
|
const manifestPath = path.resolve(
|
||||||
|
options.exportPath,
|
||||||
|
`jahresabschluss_${options.fiscalYear}`,
|
||||||
|
'manifest-sha256.txt'
|
||||||
|
);
|
||||||
|
|
||||||
|
await security.createDetachedSignature(
|
||||||
|
manifestPath,
|
||||||
|
path.resolve(
|
||||||
|
options.exportPath,
|
||||||
|
`jahresabschluss_${options.fiscalYear}`,
|
||||||
|
'data',
|
||||||
|
'metadata',
|
||||||
|
'signatures',
|
||||||
|
'manifest.cades'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log('info', 'Export signed with CAdES signature');
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Utility Methods ==========
|
// ========== Utility Methods ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -530,4 +817,204 @@ export class SkrApi {
|
|||||||
totalPages,
|
totalPages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Invoice Management ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import an invoice from file or buffer
|
||||||
|
* Parses, validates, and optionally books the invoice
|
||||||
|
*/
|
||||||
|
public async importInvoice(
|
||||||
|
file: Buffer | string,
|
||||||
|
direction: TInvoiceDirection,
|
||||||
|
options?: IInvoiceImportOptions
|
||||||
|
): Promise<IInvoice> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceAdapter || !this.invoiceStorage || !this.invoiceBookingEngine) {
|
||||||
|
throw new Error('Invoice components not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Importing ${direction} invoice`);
|
||||||
|
|
||||||
|
// Parse and validate invoice
|
||||||
|
const invoice = await this.invoiceAdapter.parseInvoice(file, direction);
|
||||||
|
|
||||||
|
// Store invoice
|
||||||
|
await this.invoiceStorage.initialize();
|
||||||
|
const contentHash = await this.invoiceStorage.storeInvoice(invoice);
|
||||||
|
invoice.contentHash = contentHash;
|
||||||
|
|
||||||
|
// Auto-book if requested
|
||||||
|
if (options?.autoBook) {
|
||||||
|
const bookingResult = await this.bookInvoice(
|
||||||
|
invoice,
|
||||||
|
options.bookingRules,
|
||||||
|
{
|
||||||
|
autoBook: true,
|
||||||
|
confidenceThreshold: options.confidenceThreshold || 80,
|
||||||
|
skipValidation: options.validateOnly
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bookingResult.success && bookingResult.bookingInfo) {
|
||||||
|
invoice.bookingInfo = bookingResult.bookingInfo;
|
||||||
|
invoice.status = 'posted';
|
||||||
|
|
||||||
|
// Update stored metadata with booking information
|
||||||
|
await this.invoiceStorage.updateMetadata(invoice.contentHash, {
|
||||||
|
journalEntryId: bookingResult.bookingInfo.journalEntryId,
|
||||||
|
transactionIds: bookingResult.bookingInfo.transactionIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Invoice imported successfully: ${invoice.invoiceNumber}`);
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Book an invoice to the ledger
|
||||||
|
*/
|
||||||
|
public async bookInvoice(
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules?: Partial<IBookingRules>,
|
||||||
|
options?: IBookingOptions
|
||||||
|
): Promise<IBookingResult> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceBookingEngine) {
|
||||||
|
throw new Error('Invoice booking engine not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Booking invoice ${invoice.invoiceNumber}`);
|
||||||
|
|
||||||
|
const result = await this.invoiceBookingEngine.bookInvoice(
|
||||||
|
invoice,
|
||||||
|
bookingRules,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.logger.log('info', `Invoice booked successfully with confidence ${result.confidence}%`);
|
||||||
|
|
||||||
|
// Update stored metadata if invoice has a content hash
|
||||||
|
if (invoice.contentHash && result.bookingInfo && this.invoiceStorage) {
|
||||||
|
await this.invoiceStorage.updateMetadata(invoice.contentHash, {
|
||||||
|
journalEntryId: result.bookingInfo.journalEntryId,
|
||||||
|
transactionIds: result.bookingInfo.transactionIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.log('error', `Invoice booking failed: ${result.errors?.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export an invoice in a different format
|
||||||
|
*/
|
||||||
|
public async exportInvoice(
|
||||||
|
invoice: IInvoice,
|
||||||
|
options: IInvoiceExportOptions
|
||||||
|
): Promise<{ xml: string; pdf?: Buffer }> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceAdapter) {
|
||||||
|
throw new Error('Invoice adapter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Exporting invoice ${invoice.invoiceNumber} to ${options.format}`);
|
||||||
|
|
||||||
|
// Convert format if needed
|
||||||
|
const xml = await this.invoiceAdapter.convertFormat(invoice, options.format);
|
||||||
|
|
||||||
|
// Generate PDF if requested
|
||||||
|
let pdf: Buffer | undefined;
|
||||||
|
if (options.embedInPdf) {
|
||||||
|
const result = await this.invoiceAdapter.generateInvoice(invoice, options.format);
|
||||||
|
pdf = result.pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { xml, pdf };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search invoices by filter
|
||||||
|
*/
|
||||||
|
public async searchInvoices(filter: IInvoiceFilter): Promise<IInvoice[]> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceStorage) {
|
||||||
|
throw new Error('Invoice storage not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invoiceStorage.initialize();
|
||||||
|
const metadata = await this.invoiceStorage.searchInvoices(filter);
|
||||||
|
|
||||||
|
const invoices: IInvoice[] = [];
|
||||||
|
for (const meta of metadata) {
|
||||||
|
const invoice = await this.invoiceStorage.retrieveInvoice(meta.contentHash);
|
||||||
|
if (invoice) {
|
||||||
|
invoices.push(invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoice by content hash
|
||||||
|
*/
|
||||||
|
public async getInvoice(contentHash: string): Promise<IInvoice | null> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceStorage) {
|
||||||
|
throw new Error('Invoice storage not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invoiceStorage.initialize();
|
||||||
|
return await this.invoiceStorage.retrieveInvoice(contentHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoice storage statistics
|
||||||
|
*/
|
||||||
|
public async getInvoiceStatistics(): Promise<any> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceStorage) {
|
||||||
|
throw new Error('Invoice storage not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invoiceStorage.initialize();
|
||||||
|
return await this.invoiceStorage.getStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create EN16931 compliance report for invoices
|
||||||
|
*/
|
||||||
|
public async createInvoiceComplianceReport(): Promise<void> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceStorage) {
|
||||||
|
throw new Error('Invoice storage not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.invoiceStorage.initialize();
|
||||||
|
await this.invoiceStorage.createComplianceReport();
|
||||||
|
|
||||||
|
this.logger.log('info', 'Invoice compliance report created');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an invoice from internal data
|
||||||
|
*/
|
||||||
|
public async generateInvoice(
|
||||||
|
invoiceData: Partial<IInvoice>,
|
||||||
|
format: IInvoiceExportOptions['format']
|
||||||
|
): Promise<{ xml: string; pdf?: Buffer }> {
|
||||||
|
this.ensureInitialized();
|
||||||
|
if (!this.invoiceAdapter) {
|
||||||
|
throw new Error('Invoice adapter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Generating invoice in ${format} format`);
|
||||||
|
|
||||||
|
return await this.invoiceAdapter.generateInvoice(invoiceData, format);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-3
@@ -56,6 +56,9 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
|||||||
@svDb()
|
@svDb()
|
||||||
public isSystemAccount: boolean;
|
public isSystemAccount: boolean;
|
||||||
|
|
||||||
|
@svDb()
|
||||||
|
public isAutomaticAccount: boolean;
|
||||||
|
|
||||||
@svDb()
|
@svDb()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
|||||||
this.debitTotal = 0;
|
this.debitTotal = 0;
|
||||||
this.creditTotal = 0;
|
this.creditTotal = 0;
|
||||||
this.isSystemAccount = true;
|
this.isSystemAccount = true;
|
||||||
|
this.isAutomaticAccount = data.isAutomaticAccount || false;
|
||||||
this.createdAt = new Date();
|
this.createdAt = new Date();
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
@@ -157,6 +161,85 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account number is in debtor range (10000-69999)
|
||||||
|
* Debtor accounts (Debitorenkonten) are individual customer accounts
|
||||||
|
*/
|
||||||
|
public static isInDebtorRange(accountNumber: string): boolean {
|
||||||
|
const num = parseInt(accountNumber);
|
||||||
|
return num >= 10000 && num <= 69999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account number is in creditor range (70000-99999)
|
||||||
|
* Creditor accounts (Kreditorenkonten) are individual vendor accounts
|
||||||
|
*/
|
||||||
|
public static isInCreditorRange(accountNumber: string): boolean {
|
||||||
|
const num = parseInt(accountNumber);
|
||||||
|
return num >= 70000 && num <= 99999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is an automatic account (Automatikkonto)
|
||||||
|
* Automatic accounts like 1400/1600 cannot be posted to directly
|
||||||
|
*/
|
||||||
|
public static isAutomaticAccount(accountNumber: string, skrType: TSKRType): boolean {
|
||||||
|
// SKR03: 1400 (Forderungen), 1600 (Verbindlichkeiten)
|
||||||
|
// SKR04: 1400 (Forderungen), 1600 (Verbindlichkeiten)
|
||||||
|
// Note: In SKR04, 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
|
||||||
|
if (skrType === 'SKR03') {
|
||||||
|
return accountNumber === '1400' || accountNumber === '1600';
|
||||||
|
} else {
|
||||||
|
return accountNumber === '1400' || accountNumber === '1600';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate account for posting - throws error if account cannot be posted to
|
||||||
|
*/
|
||||||
|
public static async validateAccountForPosting(
|
||||||
|
accountNumber: string,
|
||||||
|
skrType: TSKRType,
|
||||||
|
): Promise<void> {
|
||||||
|
// Check if automatic account
|
||||||
|
if (Account.isAutomaticAccount(accountNumber, skrType)) {
|
||||||
|
throw new Error(
|
||||||
|
`Account ${accountNumber} is an automatic account (Automatikkonto) and cannot be posted to directly. ` +
|
||||||
|
`Use debtor accounts (10000-69999) or creditor accounts (70000-99999) instead.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account to verify it exists
|
||||||
|
const account = await Account.getAccountByNumber(accountNumber, skrType);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(
|
||||||
|
`Account ${accountNumber} not found in ${skrType}. ` +
|
||||||
|
`Please create the account before posting.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is active
|
||||||
|
if (!account.isActive) {
|
||||||
|
throw new Error(
|
||||||
|
`Account ${accountNumber} is inactive and cannot be posted to.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this account instance is a debtor account
|
||||||
|
*/
|
||||||
|
public isDebtorAccount(): boolean {
|
||||||
|
return Account.isInDebtorRange(this.accountNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this account instance is a creditor account
|
||||||
|
*/
|
||||||
|
public isCreditorAccount(): boolean {
|
||||||
|
return Account.isInCreditorRange(this.accountNumber);
|
||||||
|
}
|
||||||
|
|
||||||
public async updateBalance(
|
public async updateBalance(
|
||||||
debitAmount: number = 0,
|
debitAmount: number = 0,
|
||||||
creditAmount: number = 0,
|
creditAmount: number = 0,
|
||||||
@@ -209,19 +292,33 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
|||||||
|
|
||||||
public async beforeSave(): Promise<void> {
|
public async beforeSave(): Promise<void> {
|
||||||
// Validate account number format
|
// Validate account number format
|
||||||
if (!this.accountNumber || this.accountNumber.length !== 4) {
|
const accountLength = this.accountNumber?.length || 0;
|
||||||
|
if (!this.accountNumber || (accountLength !== 4 && accountLength !== 5)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Invalid account number format: ${this.accountNumber}. Must be 4 digits.`,
|
`Invalid account number format: ${this.accountNumber}. Must be 4 digits (standard SKR) or 5 digits (debtor/creditor).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate account number is numeric
|
// Validate account number is numeric
|
||||||
if (!/^\d{4}$/.test(this.accountNumber)) {
|
if (!/^\d{4,5}$/.test(this.accountNumber)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Account number must contain only digits: ${this.accountNumber}`,
|
`Account number must contain only digits: ${this.accountNumber}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For 5-digit accounts, validate they are in debtor (10000-69999) or creditor (70000-99999) ranges
|
||||||
|
if (accountLength === 5) {
|
||||||
|
const accountNum = parseInt(this.accountNumber);
|
||||||
|
const isDebtor = accountNum >= 10000 && accountNum <= 69999;
|
||||||
|
const isCreditor = accountNum >= 70000 && accountNum <= 99999;
|
||||||
|
|
||||||
|
if (!isDebtor && !isCreditor) {
|
||||||
|
throw new Error(
|
||||||
|
`5-digit account number ${this.accountNumber} must be in debtor range (10000-69999) or creditor range (70000-99999).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate account class matches first digit
|
// Validate account class matches first digit
|
||||||
const firstDigit = parseInt(this.accountNumber[0]);
|
const firstDigit = parseInt(this.accountNumber[0]);
|
||||||
if (this.accountClass !== firstDigit) {
|
if (this.accountClass !== firstDigit) {
|
||||||
@@ -234,5 +331,11 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
|||||||
if (this.skrType !== 'SKR03' && this.skrType !== 'SKR04') {
|
if (this.skrType !== 'SKR03' && this.skrType !== 'SKR04') {
|
||||||
throw new Error(`Invalid SKR type: ${this.skrType}`);
|
throw new Error(`Invalid SKR type: ${this.skrType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark automatic accounts (Automatikkonten)
|
||||||
|
// These are summary accounts that cannot be posted to directly
|
||||||
|
if (Account.isAutomaticAccount(this.accountNumber, this.skrType)) {
|
||||||
|
this.isAutomaticAccount = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-17
@@ -2,6 +2,11 @@ import * as plugins from './plugins.js';
|
|||||||
import { getDbSync } from './skr.database.js';
|
import { getDbSync } from './skr.database.js';
|
||||||
import { Account } from './skr.classes.account.js';
|
import { Account } from './skr.classes.account.js';
|
||||||
import { Transaction } from './skr.classes.transaction.js';
|
import { Transaction } from './skr.classes.transaction.js';
|
||||||
|
import {
|
||||||
|
validatePostingKey,
|
||||||
|
validatePostingKeyConsistency,
|
||||||
|
getPostingKeyDescription,
|
||||||
|
} from './skr.postingkeys.js';
|
||||||
import type {
|
import type {
|
||||||
TSKRType,
|
TSKRType,
|
||||||
IJournalEntry,
|
IJournalEntry,
|
||||||
@@ -96,6 +101,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
this.postedAt = null;
|
this.postedAt = null;
|
||||||
this.createdBy = 'system';
|
this.createdBy = 'system';
|
||||||
|
|
||||||
|
// Normalize any negative amounts to the correct side
|
||||||
|
this.sanitizeLines();
|
||||||
// Calculate totals
|
// Calculate totals
|
||||||
this.calculateTotals();
|
this.calculateTotals();
|
||||||
}
|
}
|
||||||
@@ -107,6 +114,36 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
return `JE-${timestamp}-${random}`;
|
return `JE-${timestamp}-${random}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sanitizeLines(): void {
|
||||||
|
for (const line of this.lines) {
|
||||||
|
// Check if both debit and credit are set (not allowed)
|
||||||
|
if (line.debit !== undefined && line.debit !== 0 &&
|
||||||
|
line.credit !== undefined && line.credit !== 0) {
|
||||||
|
throw new Error('A line cannot have both debit and credit amounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative debit - convert to positive credit
|
||||||
|
if (line.debit !== undefined && line.debit < 0) {
|
||||||
|
line.credit = Math.abs(line.debit);
|
||||||
|
delete (line as any).debit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative credit - convert to positive debit
|
||||||
|
if (line.credit !== undefined && line.credit < 0) {
|
||||||
|
line.debit = Math.abs(line.credit);
|
||||||
|
delete (line as any).credit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that at least one side has a positive value
|
||||||
|
const hasDebit = line.debit !== undefined && line.debit > 0;
|
||||||
|
const hasCredit = line.credit !== undefined && line.credit > 0;
|
||||||
|
|
||||||
|
if (!hasDebit && !hasCredit) {
|
||||||
|
throw new Error('Either debit or credit must be a positive number');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private calculateTotals(): void {
|
private calculateTotals(): void {
|
||||||
this.totalDebits = 0;
|
this.totalDebits = 0;
|
||||||
this.totalCredits = 0;
|
this.totalCredits = 0;
|
||||||
@@ -180,22 +217,91 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
throw new Error('Journal entry must have at least 2 lines');
|
throw new Error('Journal entry must have at least 2 lines');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all accounts exist and are active
|
// Validate all accounts exist, are active, and can be posted to
|
||||||
|
const validationErrors: string[] = [];
|
||||||
|
const validationWarnings: string[] = [];
|
||||||
|
|
||||||
|
// Check if this journal entry has VAT lines (for smarter posting key validation)
|
||||||
|
const hasVATLines = this.lines.some(line =>
|
||||||
|
line.accountNumber === '1571' || line.accountNumber === '1771' || line.accountNumber === '1576'
|
||||||
|
);
|
||||||
|
|
||||||
for (const line of this.lines) {
|
for (const line of this.lines) {
|
||||||
|
// Validate posting key is present (REQUIRED)
|
||||||
|
if (!line.postingKey) {
|
||||||
|
validationErrors.push(
|
||||||
|
`Line for account ${line.accountNumber} is missing required posting key (Buchungsschlüssel). ` +
|
||||||
|
`Posting keys are mandatory for DATEV compliance.`
|
||||||
|
);
|
||||||
|
continue; // Skip further validation for this line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate account is not an automatic account (Automatikkonto)
|
||||||
|
try {
|
||||||
|
await Account.validateAccountForPosting(line.accountNumber, this.skrType);
|
||||||
|
} catch (error) {
|
||||||
|
validationErrors.push(error.message);
|
||||||
|
continue; // Skip further validation for this line
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get account for posting key validation
|
||||||
const account = await Account.getAccountByNumber(
|
const account = await Account.getAccountByNumber(
|
||||||
line.accountNumber,
|
line.accountNumber,
|
||||||
this.skrType,
|
this.skrType,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error(
|
validationErrors.push(
|
||||||
`Account ${line.accountNumber} not found for ${this.skrType}`,
|
`Account ${line.accountNumber} not found for ${this.skrType}`,
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.isActive) {
|
if (!account.isActive) {
|
||||||
throw new Error(`Account ${line.accountNumber} is not active`);
|
validationErrors.push(`Account ${line.accountNumber} is not active`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate posting key for this line
|
||||||
|
const amount = line.debit || line.credit || 0;
|
||||||
|
// For journal entries with VAT lines, pass amount as vatAmount to satisfy validation
|
||||||
|
const postingKeyValidation = validatePostingKey(
|
||||||
|
line.postingKey,
|
||||||
|
line.accountNumber,
|
||||||
|
amount,
|
||||||
|
hasVATLines ? amount : undefined // If entry has VAT lines, we consider the validation satisfied
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!postingKeyValidation.isValid) {
|
||||||
|
validationErrors.push(...postingKeyValidation.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postingKeyValidation.warnings.length > 0) {
|
||||||
|
validationWarnings.push(...postingKeyValidation.warnings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate posting key consistency across all lines
|
||||||
|
const consistencyValidation = validatePostingKeyConsistency(this.lines);
|
||||||
|
if (!consistencyValidation.isValid) {
|
||||||
|
validationErrors.push(...consistencyValidation.errors);
|
||||||
|
}
|
||||||
|
if (consistencyValidation.warnings.length > 0) {
|
||||||
|
validationWarnings.push(...consistencyValidation.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log warnings but don't fail validation
|
||||||
|
if (validationWarnings.length > 0) {
|
||||||
|
console.warn('Journal entry validation warnings:');
|
||||||
|
validationWarnings.forEach(warning => console.warn(` - ${warning}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throw if any errors
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Journal entry validation failed:\n' +
|
||||||
|
validationErrors.map(e => ` - ${e}`).join('\n')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +310,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
throw new Error('Journal entry is already posted');
|
throw new Error('Journal entry is already posted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize any negative amounts to the correct side
|
||||||
|
this.sanitizeLines();
|
||||||
// Validate before posting
|
// Validate before posting
|
||||||
await this.validate();
|
await this.validate();
|
||||||
|
|
||||||
@@ -230,28 +338,41 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
transactions.push(transaction);
|
transactions.push(transaction);
|
||||||
} else {
|
} else {
|
||||||
// Complex entry: multiple debits and/or credits
|
// Complex entry: multiple debits and/or credits
|
||||||
// Create transactions to balance the entry
|
// Build working queues with remaining amounts (don't mutate original lines)
|
||||||
for (const debitLine of debitLines) {
|
const debitQueue = debitLines.map(l => ({
|
||||||
for (const creditLine of creditLines) {
|
line: l,
|
||||||
const amount = Math.min(debitLine.debit || 0, creditLine.credit || 0);
|
remaining: l.debit || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const creditQueue = creditLines.map(l => ({
|
||||||
|
line: l,
|
||||||
|
remaining: l.credit || 0
|
||||||
|
}));
|
||||||
|
|
||||||
if (amount > 0) {
|
// Create transactions to balance the entry
|
||||||
|
for (const d of debitQueue) {
|
||||||
|
for (const c of creditQueue) {
|
||||||
|
const amount = Math.min(d.remaining, c.remaining);
|
||||||
|
|
||||||
|
if (amount > 0.0000001) { // small epsilon to avoid float artifacts
|
||||||
const transaction = await Transaction.createTransaction({
|
const transaction = await Transaction.createTransaction({
|
||||||
date: this.date,
|
date: this.date,
|
||||||
debitAccount: debitLine.accountNumber,
|
debitAccount: d.line.accountNumber,
|
||||||
creditAccount: creditLine.accountNumber,
|
creditAccount: c.line.accountNumber,
|
||||||
amount: amount,
|
amount: Math.round(amount * 100) / 100, // round to 2 decimals
|
||||||
description: `${this.description} - ${debitLine.description || creditLine.description || ''}`,
|
description: `${this.description} - ${d.line.description || c.line.description || ''}`,
|
||||||
reference: this.reference,
|
reference: this.reference,
|
||||||
skrType: this.skrType,
|
skrType: this.skrType,
|
||||||
costCenter: debitLine.costCenter || creditLine.costCenter,
|
costCenter: d.line.costCenter || c.line.costCenter,
|
||||||
});
|
});
|
||||||
transactions.push(transaction);
|
transactions.push(transaction);
|
||||||
|
|
||||||
// Reduce amounts for tracking
|
// Reduce remaining amounts in working copies (not original lines)
|
||||||
if (debitLine.debit) debitLine.debit -= amount;
|
d.remaining -= amount;
|
||||||
if (creditLine.credit) creditLine.credit -= amount;
|
c.remaining -= amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (d.remaining <= 0.0000001) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,6 +399,7 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
credit: line.debit, // Swap
|
credit: line.debit, // Swap
|
||||||
description: `Reversal: ${line.description || ''}`,
|
description: `Reversal: ${line.description || ''}`,
|
||||||
costCenter: line.costCenter,
|
costCenter: line.costCenter,
|
||||||
|
postingKey: line.postingKey, // Keep same posting key for reversal
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const reversalEntry = new JournalEntry({
|
const reversalEntry = new JournalEntry({
|
||||||
@@ -299,6 +421,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async beforeSave(): Promise<void> {
|
public async beforeSave(): Promise<void> {
|
||||||
|
// Normalize any negative amounts to the correct side
|
||||||
|
this.sanitizeLines();
|
||||||
// Recalculate totals before saving
|
// Recalculate totals before saving
|
||||||
this.calculateTotals();
|
this.calculateTotals();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import type {
|
|||||||
IJournalEntryLine,
|
IJournalEntryLine,
|
||||||
IAccountBalance,
|
IAccountBalance,
|
||||||
} from './skr.types.js';
|
} from './skr.types.js';
|
||||||
|
import { SKR03_ACCOUNTS } from './skr03.data.js';
|
||||||
|
import { SKR04_ACCOUNTS } from './skr04.data.js';
|
||||||
|
|
||||||
|
// Module-level Maps for O(1) SKR standard lookups
|
||||||
|
const STANDARD_SKR_MAP = {
|
||||||
|
SKR03: new Map(SKR03_ACCOUNTS.map(a => [a.accountNumber, a])),
|
||||||
|
SKR04: new Map(SKR04_ACCOUNTS.map(a => [a.accountNumber, a])),
|
||||||
|
};
|
||||||
|
|
||||||
export class Ledger {
|
export class Ledger {
|
||||||
private logger: plugins.smartlog.Smartlog;
|
private logger: plugins.smartlog.Smartlog;
|
||||||
@@ -81,6 +89,12 @@ export class Ledger {
|
|||||||
const accountNumbers = journalData.lines.map((line) => line.accountNumber);
|
const accountNumbers = journalData.lines.map((line) => line.accountNumber);
|
||||||
await this.validateAccounts(accountNumbers);
|
await this.validateAccounts(accountNumbers);
|
||||||
|
|
||||||
|
// Validate against SKR standard (warnings only by default)
|
||||||
|
await this.validateAccountsAgainstSKR(journalData.lines, {
|
||||||
|
strict: false, // Start with warnings only
|
||||||
|
warnOnNameMismatch: false // Names vary, don't spam logs
|
||||||
|
});
|
||||||
|
|
||||||
// Validate journal entry is balanced
|
// Validate journal entry is balanced
|
||||||
this.validateJournalBalance(journalData.lines);
|
this.validateJournalBalance(journalData.lines);
|
||||||
|
|
||||||
@@ -139,6 +153,77 @@ export class Ledger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate accounts against SKR standard data
|
||||||
|
*/
|
||||||
|
private async validateAccountsAgainstSKR(
|
||||||
|
lines: IJournalEntryLine[],
|
||||||
|
options?: { strict?: boolean; warnOnNameMismatch?: boolean }
|
||||||
|
): Promise<void> {
|
||||||
|
const { strict = false, warnOnNameMismatch = false } = options || {};
|
||||||
|
const skrMap = STANDARD_SKR_MAP[this.skrType];
|
||||||
|
|
||||||
|
if (!skrMap) {
|
||||||
|
this.logger.log('warn', `No SKR standard map available for ${this.skrType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueAccountNumbers = [...new Set(lines.map(line => line.accountNumber))];
|
||||||
|
|
||||||
|
for (const accountNumber of uniqueAccountNumbers) {
|
||||||
|
const standardAccount = skrMap.get(accountNumber);
|
||||||
|
|
||||||
|
if (!standardAccount) {
|
||||||
|
// Special case: SKR04 class 8 is designated for custom accounts ("frei")
|
||||||
|
if (this.skrType === 'SKR04' && accountNumber.startsWith('8')) {
|
||||||
|
this.logger.log('debug', `Account ${accountNumber} is in SKR04 class 8 (custom accounts allowed)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `Account ${accountNumber} is not a standard ${this.skrType} account`;
|
||||||
|
if (strict) {
|
||||||
|
throw new Error(message);
|
||||||
|
} else {
|
||||||
|
this.logger.log('warn', message);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get actual account from database to compare
|
||||||
|
const dbAccount = await Account.getAccountByNumber(accountNumber, this.skrType);
|
||||||
|
if (!dbAccount) {
|
||||||
|
// Account doesn't exist in DB, will be caught by validateAccounts()
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type and class match SKR standard
|
||||||
|
if (dbAccount.accountType !== standardAccount.accountType) {
|
||||||
|
const message = `Account ${accountNumber} type mismatch: expected '${standardAccount.accountType}', got '${dbAccount.accountType}'`;
|
||||||
|
if (strict) {
|
||||||
|
throw new Error(message);
|
||||||
|
} else {
|
||||||
|
this.logger.log('warn', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dbAccount.accountClass !== standardAccount.accountClass) {
|
||||||
|
const message = `Account ${accountNumber} class mismatch: expected ${standardAccount.accountClass}, got ${dbAccount.accountClass}`;
|
||||||
|
if (strict) {
|
||||||
|
throw new Error(message);
|
||||||
|
} else {
|
||||||
|
this.logger.log('warn', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn on name mismatch (common and acceptable in practice)
|
||||||
|
if (warnOnNameMismatch && dbAccount.accountName !== standardAccount.accountName) {
|
||||||
|
this.logger.log('info',
|
||||||
|
`Account ${accountNumber} name differs from SKR standard: '${dbAccount.accountName}' vs '${standardAccount.accountName}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse a transaction
|
* Reverse a transaction
|
||||||
*/
|
*/
|
||||||
@@ -333,6 +418,7 @@ export class Ledger {
|
|||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
debit: Math.abs(balance),
|
debit: Math.abs(balance),
|
||||||
description: `Closing ${account.accountName}`,
|
description: `Closing ${account.accountName}`,
|
||||||
|
postingKey: 40, // Tax-free - internal closing entry
|
||||||
});
|
});
|
||||||
totalRevenue += Math.abs(balance);
|
totalRevenue += Math.abs(balance);
|
||||||
}
|
}
|
||||||
@@ -344,6 +430,7 @@ export class Ledger {
|
|||||||
accountNumber: closingAccountNumber,
|
accountNumber: closingAccountNumber,
|
||||||
credit: totalRevenue,
|
credit: totalRevenue,
|
||||||
description: 'Revenue closing to P&L',
|
description: 'Revenue closing to P&L',
|
||||||
|
postingKey: 40, // Tax-free - internal closing entry
|
||||||
});
|
});
|
||||||
|
|
||||||
const revenueClosingEntry = await this.postJournalEntry({
|
const revenueClosingEntry = await this.postJournalEntry({
|
||||||
@@ -373,6 +460,7 @@ export class Ledger {
|
|||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
credit: Math.abs(balance),
|
credit: Math.abs(balance),
|
||||||
description: `Closing ${account.accountName}`,
|
description: `Closing ${account.accountName}`,
|
||||||
|
postingKey: 40, // Tax-free - internal closing entry
|
||||||
});
|
});
|
||||||
totalExpense += Math.abs(balance);
|
totalExpense += Math.abs(balance);
|
||||||
}
|
}
|
||||||
@@ -384,6 +472,7 @@ export class Ledger {
|
|||||||
accountNumber: closingAccountNumber,
|
accountNumber: closingAccountNumber,
|
||||||
debit: totalExpense,
|
debit: totalExpense,
|
||||||
description: 'Expense closing to P&L',
|
description: 'Expense closing to P&L',
|
||||||
|
postingKey: 40, // Tax-free - internal closing entry
|
||||||
});
|
});
|
||||||
|
|
||||||
const expenseClosingEntry = await this.postJournalEntry({
|
const expenseClosingEntry = await this.postJournalEntry({
|
||||||
|
|||||||
+64
-21
@@ -122,11 +122,11 @@ export class Reports {
|
|||||||
const entry: IIncomeStatementEntry = {
|
const entry: IIncomeStatementEntry = {
|
||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
accountName: account.accountName,
|
accountName: account.accountName,
|
||||||
amount: Math.abs(balance),
|
amount: balance, // Keep the sign for correct calculation
|
||||||
};
|
};
|
||||||
|
|
||||||
revenueEntries.push(entry);
|
revenueEntries.push(entry);
|
||||||
totalRevenue += Math.abs(balance);
|
totalRevenue += balance; // Revenue accounts normally have credit balance (positive)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,23 +138,24 @@ export class Reports {
|
|||||||
const entry: IIncomeStatementEntry = {
|
const entry: IIncomeStatementEntry = {
|
||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
accountName: account.accountName,
|
accountName: account.accountName,
|
||||||
amount: Math.abs(balance),
|
amount: balance, // Keep the sign - negative balance reduces expenses
|
||||||
};
|
};
|
||||||
|
|
||||||
expenseEntries.push(entry);
|
expenseEntries.push(entry);
|
||||||
totalExpenses += Math.abs(balance);
|
totalExpenses += balance; // Expense accounts normally have debit balance (positive)
|
||||||
|
// But credit balances (negative) reduce total expenses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate percentages
|
// Calculate percentages using absolute values to avoid negative percentages
|
||||||
revenueEntries.forEach((entry) => {
|
revenueEntries.forEach((entry) => {
|
||||||
entry.percentage =
|
entry.percentage =
|
||||||
totalRevenue > 0 ? (entry.amount / totalRevenue) * 100 : 0;
|
totalRevenue !== 0 ? (Math.abs(entry.amount) / Math.abs(totalRevenue)) * 100 : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
expenseEntries.forEach((entry) => {
|
expenseEntries.forEach((entry) => {
|
||||||
entry.percentage =
|
entry.percentage =
|
||||||
totalRevenue > 0 ? (entry.amount / totalRevenue) * 100 : 0;
|
totalRevenue !== 0 ? (Math.abs(entry.amount) / Math.abs(totalRevenue)) * 100 : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort entries by account number
|
// Sort entries by account number
|
||||||
@@ -214,7 +215,7 @@ export class Reports {
|
|||||||
const entry: IBalanceSheetEntry = {
|
const entry: IBalanceSheetEntry = {
|
||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
accountName: account.accountName,
|
accountName: account.accountName,
|
||||||
amount: Math.abs(balance),
|
amount: balance, // Keep the sign for display
|
||||||
};
|
};
|
||||||
|
|
||||||
// Classify as current or fixed based on account class
|
// Classify as current or fixed based on account class
|
||||||
@@ -224,7 +225,7 @@ export class Reports {
|
|||||||
fixedAssets.push(entry);
|
fixedAssets.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
totalAssets += Math.abs(balance);
|
totalAssets += balance; // Add with sign to get correct total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ export class Reports {
|
|||||||
const entry: IBalanceSheetEntry = {
|
const entry: IBalanceSheetEntry = {
|
||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
accountName: account.accountName,
|
accountName: account.accountName,
|
||||||
amount: Math.abs(balance),
|
amount: balance, // Keep the sign for display
|
||||||
};
|
};
|
||||||
|
|
||||||
// Classify as current or long-term based on account number
|
// Classify as current or long-term based on account number
|
||||||
@@ -253,7 +254,7 @@ export class Reports {
|
|||||||
longTermLiabilities.push(entry);
|
longTermLiabilities.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
totalLiabilities += Math.abs(balance);
|
totalLiabilities += balance; // Add with sign to get correct total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,23 +269,27 @@ export class Reports {
|
|||||||
const entry: IBalanceSheetEntry = {
|
const entry: IBalanceSheetEntry = {
|
||||||
accountNumber: account.accountNumber,
|
accountNumber: account.accountNumber,
|
||||||
accountName: account.accountName,
|
accountName: account.accountName,
|
||||||
amount: Math.abs(balance),
|
amount: balance, // Keep the sign for display
|
||||||
};
|
};
|
||||||
|
|
||||||
equityEntries.push(entry);
|
equityEntries.push(entry);
|
||||||
totalEquity += Math.abs(balance);
|
totalEquity += balance; // Add with sign to get correct total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add current year profit/loss
|
// Add current year profit/loss only if accounts haven't been closed
|
||||||
|
// Check if revenue/expense accounts have non-zero balances (indicates not closed)
|
||||||
const incomeStatement = await this.getIncomeStatement(params);
|
const incomeStatement = await this.getIncomeStatement(params);
|
||||||
if (incomeStatement.netIncome !== 0) {
|
|
||||||
|
// Only add current year profit/loss if we have unclosed revenue/expense accounts
|
||||||
|
// (i.e., the income statement shows non-zero revenue or expenses)
|
||||||
|
if (incomeStatement.netIncome !== 0 && (incomeStatement.totalRevenue !== 0 || incomeStatement.totalExpenses !== 0)) {
|
||||||
equityEntries.push({
|
equityEntries.push({
|
||||||
accountNumber: '9999',
|
accountNumber: '9999',
|
||||||
accountName: 'Current Year Profit/Loss',
|
accountName: 'Current Year Profit/Loss',
|
||||||
amount: Math.abs(incomeStatement.netIncome),
|
amount: incomeStatement.netIncome, // Keep the sign
|
||||||
});
|
});
|
||||||
totalEquity += Math.abs(incomeStatement.netIncome);
|
totalEquity += incomeStatement.netIncome; // Add with sign
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort entries
|
// Sort entries
|
||||||
@@ -344,9 +349,28 @@ export class Reports {
|
|||||||
|
|
||||||
// Apply date filter if provided
|
// Apply date filter if provided
|
||||||
if (params?.dateFrom || params?.dateTo) {
|
if (params?.dateFrom || params?.dateTo) {
|
||||||
|
// Normalize dates for inclusive comparison
|
||||||
|
const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null;
|
||||||
|
const dateTo = params.dateTo ? new Date(params.dateTo) : null;
|
||||||
|
|
||||||
|
// Set dateFrom to start of day (00:00:00.000)
|
||||||
|
if (dateFrom) {
|
||||||
|
dateFrom.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dateTo to end of day (23:59:59.999) for inclusive comparison
|
||||||
|
if (dateTo) {
|
||||||
|
dateTo.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
transactions = transactions.filter((transaction) => {
|
transactions = transactions.filter((transaction) => {
|
||||||
if (params.dateFrom && transaction.date < params.dateFrom) return false;
|
const txDate = transaction.date instanceof Date
|
||||||
if (params.dateTo && transaction.date > params.dateTo) return false;
|
? transaction.date
|
||||||
|
: new Date(transaction.date);
|
||||||
|
const txTime = txDate.getTime();
|
||||||
|
|
||||||
|
if (dateFrom && txTime < dateFrom.getTime()) return false;
|
||||||
|
if (dateTo && txTime > dateTo.getTime()) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -453,9 +477,28 @@ export class Reports {
|
|||||||
|
|
||||||
// Apply date filter
|
// Apply date filter
|
||||||
if (params?.dateFrom || params?.dateTo) {
|
if (params?.dateFrom || params?.dateTo) {
|
||||||
|
// Normalize dates for inclusive comparison
|
||||||
|
const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null;
|
||||||
|
const dateTo = params.dateTo ? new Date(params.dateTo) : null;
|
||||||
|
|
||||||
|
// Set dateFrom to start of day (00:00:00.000)
|
||||||
|
if (dateFrom) {
|
||||||
|
dateFrom.setHours(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set dateTo to end of day (23:59:59.999) for inclusive comparison
|
||||||
|
if (dateTo) {
|
||||||
|
dateTo.setHours(23, 59, 59, 999);
|
||||||
|
}
|
||||||
|
|
||||||
transactions = transactions.filter((transaction) => {
|
transactions = transactions.filter((transaction) => {
|
||||||
if (params.dateFrom && transaction.date < params.dateFrom) return false;
|
const txDate = transaction.date instanceof Date
|
||||||
if (params.dateTo && transaction.date > params.dateTo) return false;
|
? transaction.date
|
||||||
|
: new Date(transaction.date);
|
||||||
|
const txTime = txDate.getTime();
|
||||||
|
|
||||||
|
if (dateFrom && txTime < dateFrom.getTime()) return false;
|
||||||
|
if (dateTo && txTime > dateTo.getTime()) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { IAccountData, TSKRType } from './skr.types.js';
|
||||||
|
|
||||||
|
// Extended interface for export with additional fields
|
||||||
|
export interface IAccountDataExport extends IAccountData {
|
||||||
|
parentAccount?: string;
|
||||||
|
defaultTaxCode?: string;
|
||||||
|
activeFrom?: Date | string;
|
||||||
|
activeTo?: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IAccountExportRow {
|
||||||
|
account_code: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
class: number;
|
||||||
|
parent?: string;
|
||||||
|
skr_set: TSKRType;
|
||||||
|
tax_code_default?: string;
|
||||||
|
active_from?: string;
|
||||||
|
active_to?: string;
|
||||||
|
description?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccountsExporter {
|
||||||
|
private exportPath: string;
|
||||||
|
private accounts: IAccountExportRow[] = [];
|
||||||
|
|
||||||
|
constructor(exportPath: string) {
|
||||||
|
this.exportPath = exportPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an account to the export
|
||||||
|
*/
|
||||||
|
public addAccount(account: IAccountDataExport): void {
|
||||||
|
const exportRow: IAccountExportRow = {
|
||||||
|
account_code: account.accountNumber,
|
||||||
|
name: account.accountName,
|
||||||
|
type: account.accountType,
|
||||||
|
class: account.accountClass,
|
||||||
|
parent: account.parentAccount,
|
||||||
|
skr_set: account.skrType,
|
||||||
|
tax_code_default: account.defaultTaxCode,
|
||||||
|
active_from: account.activeFrom ? this.formatDate(account.activeFrom) : undefined,
|
||||||
|
active_to: account.activeTo ? this.formatDate(account.activeTo) : undefined,
|
||||||
|
description: account.description,
|
||||||
|
is_active: account.isActive !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.accounts.push(exportRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports accounts to CSV format
|
||||||
|
*/
|
||||||
|
public async exportToCSV(): Promise<void> {
|
||||||
|
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'accounts.csv');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||||
|
|
||||||
|
// Create CSV header
|
||||||
|
const headers = [
|
||||||
|
'account_code',
|
||||||
|
'name',
|
||||||
|
'type',
|
||||||
|
'class',
|
||||||
|
'parent',
|
||||||
|
'skr_set',
|
||||||
|
'tax_code_default',
|
||||||
|
'active_from',
|
||||||
|
'active_to',
|
||||||
|
'description',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
|
||||||
|
let csvContent = headers.join(',') + '\n';
|
||||||
|
|
||||||
|
// Add account rows
|
||||||
|
for (const account of this.accounts) {
|
||||||
|
const row = [
|
||||||
|
this.escapeCSV(account.account_code),
|
||||||
|
this.escapeCSV(account.name),
|
||||||
|
this.escapeCSV(account.type),
|
||||||
|
account.class.toString(),
|
||||||
|
this.escapeCSV(account.parent || ''),
|
||||||
|
this.escapeCSV(account.skr_set),
|
||||||
|
this.escapeCSV(account.tax_code_default || ''),
|
||||||
|
this.escapeCSV(account.active_from || ''),
|
||||||
|
this.escapeCSV(account.active_to || ''),
|
||||||
|
this.escapeCSV(account.description || ''),
|
||||||
|
account.is_active.toString()
|
||||||
|
];
|
||||||
|
|
||||||
|
csvContent += row.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports accounts to JSON format (alternative)
|
||||||
|
*/
|
||||||
|
public async exportToJSON(): Promise<void> {
|
||||||
|
const jsonPath = path.join(this.exportPath, 'data', 'accounting', 'accounts.json');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(jsonPath));
|
||||||
|
|
||||||
|
const jsonData = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
export_date: new Date().toISOString(),
|
||||||
|
accounts: this.accounts
|
||||||
|
};
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(jsonData, null, 2),
|
||||||
|
jsonPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes CSV values
|
||||||
|
*/
|
||||||
|
private escapeCSV(value: string): string {
|
||||||
|
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date to ISO date string
|
||||||
|
*/
|
||||||
|
private formatDate(date: Date | string): string {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date.split('T')[0];
|
||||||
|
}
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of accounts
|
||||||
|
*/
|
||||||
|
public getAccountCount(): number {
|
||||||
|
return this.accounts.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the accounts list
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.accounts = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { IAccountBalance } from './skr.types.js';
|
||||||
|
|
||||||
|
// Extended interface for export with additional fields
|
||||||
|
export interface IAccountBalanceExport extends IAccountBalance {
|
||||||
|
openingBalance?: number;
|
||||||
|
transactionCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBalanceExportRow {
|
||||||
|
account_code: string;
|
||||||
|
account_name: string;
|
||||||
|
fiscal_year: number;
|
||||||
|
period?: string;
|
||||||
|
opening_balance: string;
|
||||||
|
closing_balance: string;
|
||||||
|
debit_sum: string;
|
||||||
|
credit_sum: string;
|
||||||
|
balance: string;
|
||||||
|
transaction_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BalancesExporter {
|
||||||
|
private exportPath: string;
|
||||||
|
private balances: IBalanceExportRow[] = [];
|
||||||
|
private fiscalYear: number;
|
||||||
|
|
||||||
|
constructor(exportPath: string, fiscalYear: number) {
|
||||||
|
this.exportPath = exportPath;
|
||||||
|
this.fiscalYear = fiscalYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a balance entry to the export
|
||||||
|
*/
|
||||||
|
public addBalance(
|
||||||
|
accountCode: string,
|
||||||
|
accountName: string,
|
||||||
|
balance: IAccountBalanceExport,
|
||||||
|
period?: string
|
||||||
|
): void {
|
||||||
|
const exportRow: IBalanceExportRow = {
|
||||||
|
account_code: accountCode,
|
||||||
|
account_name: accountName,
|
||||||
|
fiscal_year: this.fiscalYear,
|
||||||
|
period: period,
|
||||||
|
opening_balance: (balance.openingBalance || 0).toFixed(2),
|
||||||
|
closing_balance: balance.balance.toFixed(2),
|
||||||
|
debit_sum: balance.debitTotal.toFixed(2),
|
||||||
|
credit_sum: balance.creditTotal.toFixed(2),
|
||||||
|
balance: balance.balance.toFixed(2),
|
||||||
|
transaction_count: balance.transactionCount || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
this.balances.push(exportRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports balances to CSV format
|
||||||
|
*/
|
||||||
|
public async exportToCSV(): Promise<void> {
|
||||||
|
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'balances.csv');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||||
|
|
||||||
|
// Create CSV header
|
||||||
|
const headers = [
|
||||||
|
'account_code',
|
||||||
|
'account_name',
|
||||||
|
'fiscal_year',
|
||||||
|
'period',
|
||||||
|
'opening_balance',
|
||||||
|
'closing_balance',
|
||||||
|
'debit_sum',
|
||||||
|
'credit_sum',
|
||||||
|
'balance',
|
||||||
|
'transaction_count'
|
||||||
|
];
|
||||||
|
|
||||||
|
let csvContent = headers.join(',') + '\n';
|
||||||
|
|
||||||
|
// Sort balances by account code
|
||||||
|
this.balances.sort((a, b) => a.account_code.localeCompare(b.account_code));
|
||||||
|
|
||||||
|
// Add balance rows
|
||||||
|
for (const balance of this.balances) {
|
||||||
|
const row = [
|
||||||
|
this.escapeCSV(balance.account_code),
|
||||||
|
this.escapeCSV(balance.account_name),
|
||||||
|
balance.fiscal_year.toString(),
|
||||||
|
this.escapeCSV(balance.period || ''),
|
||||||
|
balance.opening_balance,
|
||||||
|
balance.closing_balance,
|
||||||
|
balance.debit_sum,
|
||||||
|
balance.credit_sum,
|
||||||
|
balance.balance,
|
||||||
|
balance.transaction_count.toString()
|
||||||
|
];
|
||||||
|
|
||||||
|
csvContent += row.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports trial balance (Summen- und Saldenliste)
|
||||||
|
*/
|
||||||
|
public async exportTrialBalance(): Promise<void> {
|
||||||
|
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'trial_balance.csv');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||||
|
|
||||||
|
// Create CSV header for trial balance
|
||||||
|
const headers = [
|
||||||
|
'Konto',
|
||||||
|
'Bezeichnung',
|
||||||
|
'Anfangssaldo',
|
||||||
|
'Soll',
|
||||||
|
'Haben',
|
||||||
|
'Saldo',
|
||||||
|
'Endsaldo'
|
||||||
|
];
|
||||||
|
|
||||||
|
let csvContent = headers.join(',') + '\n';
|
||||||
|
|
||||||
|
// Add rows with German formatting
|
||||||
|
for (const balance of this.balances) {
|
||||||
|
const row = [
|
||||||
|
this.escapeCSV(balance.account_code),
|
||||||
|
this.escapeCSV(balance.account_name),
|
||||||
|
this.formatGermanNumber(parseFloat(balance.opening_balance)),
|
||||||
|
this.formatGermanNumber(parseFloat(balance.debit_sum)),
|
||||||
|
this.formatGermanNumber(parseFloat(balance.credit_sum)),
|
||||||
|
this.formatGermanNumber(parseFloat(balance.debit_sum) - parseFloat(balance.credit_sum)),
|
||||||
|
this.formatGermanNumber(parseFloat(balance.closing_balance))
|
||||||
|
];
|
||||||
|
|
||||||
|
csvContent += row.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add totals row
|
||||||
|
const totalDebit = this.balances.reduce((sum, b) => sum + parseFloat(b.debit_sum), 0);
|
||||||
|
const totalCredit = this.balances.reduce((sum, b) => sum + parseFloat(b.credit_sum), 0);
|
||||||
|
|
||||||
|
csvContent += '\n';
|
||||||
|
csvContent += [
|
||||||
|
'SUMME',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
this.formatGermanNumber(totalDebit),
|
||||||
|
this.formatGermanNumber(totalCredit),
|
||||||
|
this.formatGermanNumber(totalDebit - totalCredit),
|
||||||
|
''
|
||||||
|
].join(',') + '\n';
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports balances to JSON format
|
||||||
|
*/
|
||||||
|
public async exportToJSON(): Promise<void> {
|
||||||
|
const jsonPath = path.join(this.exportPath, 'data', 'accounting', 'balances.json');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(jsonPath));
|
||||||
|
|
||||||
|
const jsonData = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
export_date: new Date().toISOString(),
|
||||||
|
fiscal_year: this.fiscalYear,
|
||||||
|
balances: this.balances,
|
||||||
|
totals: {
|
||||||
|
total_debit: this.balances.reduce((sum, b) => sum + parseFloat(b.debit_sum), 0).toFixed(2),
|
||||||
|
total_credit: this.balances.reduce((sum, b) => sum + parseFloat(b.credit_sum), 0).toFixed(2),
|
||||||
|
account_count: this.balances.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(jsonData, null, 2),
|
||||||
|
jsonPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates balance summary for specific account classes
|
||||||
|
*/
|
||||||
|
public async exportClassSummary(): Promise<void> {
|
||||||
|
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'class_summary.csv');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||||
|
|
||||||
|
// Group balances by account class (first digit of account code)
|
||||||
|
const classSummary: { [key: string]: { debit: number; credit: number; balance: number } } = {};
|
||||||
|
|
||||||
|
for (const balance of this.balances) {
|
||||||
|
const accountClass = balance.account_code.charAt(0);
|
||||||
|
|
||||||
|
if (!classSummary[accountClass]) {
|
||||||
|
classSummary[accountClass] = { debit: 0, credit: 0, balance: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
classSummary[accountClass].debit += parseFloat(balance.debit_sum);
|
||||||
|
classSummary[accountClass].credit += parseFloat(balance.credit_sum);
|
||||||
|
classSummary[accountClass].balance += parseFloat(balance.balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CSV
|
||||||
|
let csvContent = 'Kontenklasse,Bezeichnung,Soll,Haben,Saldo\n';
|
||||||
|
|
||||||
|
const classNames: { [key: string]: string } = {
|
||||||
|
'0': 'Anlagevermögen',
|
||||||
|
'1': 'Umlaufvermögen',
|
||||||
|
'2': 'Eigenkapital',
|
||||||
|
'3': 'Fremdkapital',
|
||||||
|
'4': 'Betriebliche Erträge',
|
||||||
|
'5': 'Materialaufwand',
|
||||||
|
'6': 'Betriebsaufwand',
|
||||||
|
'7': 'Weitere Aufwendungen',
|
||||||
|
'8': 'Erträge',
|
||||||
|
'9': 'Abschlusskonten'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [classNum, summary] of Object.entries(classSummary)) {
|
||||||
|
const row = [
|
||||||
|
classNum,
|
||||||
|
this.escapeCSV(classNames[classNum] || `Klasse ${classNum}`),
|
||||||
|
this.formatGermanNumber(summary.debit),
|
||||||
|
this.formatGermanNumber(summary.credit),
|
||||||
|
this.formatGermanNumber(summary.balance)
|
||||||
|
];
|
||||||
|
|
||||||
|
csvContent += row.join(',') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes CSV values
|
||||||
|
*/
|
||||||
|
private escapeCSV(value: string): string {
|
||||||
|
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats number in German format (1.234,56)
|
||||||
|
*/
|
||||||
|
private formatGermanNumber(value: number): string {
|
||||||
|
return value.toLocaleString('de-DE', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of balance entries
|
||||||
|
*/
|
||||||
|
public getBalanceCount(): number {
|
||||||
|
return this.balances.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the balances list
|
||||||
|
*/
|
||||||
|
public clear(): void {
|
||||||
|
this.balances = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { ITransactionData, IJournalEntry, IJournalEntryLine } from './skr.types.js';
|
||||||
|
import { createWriteStream, type WriteStream } from 'fs';
|
||||||
|
|
||||||
|
// Extended interfaces for export with additional tracking fields
|
||||||
|
export interface ITransactionDataExport extends ITransactionData {
|
||||||
|
_id?: string;
|
||||||
|
postingDate?: Date;
|
||||||
|
currency?: string;
|
||||||
|
createdAt?: Date | string;
|
||||||
|
modifiedAt?: Date | string;
|
||||||
|
reversalOf?: string;
|
||||||
|
reversedBy?: string;
|
||||||
|
taxCode?: string;
|
||||||
|
project?: string;
|
||||||
|
vatAccount?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJournalEntryExport extends IJournalEntry {
|
||||||
|
_id?: string;
|
||||||
|
postingDate?: Date;
|
||||||
|
currency?: string;
|
||||||
|
journal?: string;
|
||||||
|
createdAt?: Date | string;
|
||||||
|
modifiedAt?: Date | string;
|
||||||
|
reversalOf?: string;
|
||||||
|
reversedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IJournalEntryLineExport extends IJournalEntryLine {
|
||||||
|
taxCode?: string;
|
||||||
|
project?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILedgerEntry {
|
||||||
|
schema_version: string;
|
||||||
|
entry_id: string;
|
||||||
|
booking_date: string;
|
||||||
|
posting_date: string;
|
||||||
|
period?: string;
|
||||||
|
currency: string;
|
||||||
|
journal: string;
|
||||||
|
description: string;
|
||||||
|
reference?: string;
|
||||||
|
lines: ILedgerLine[];
|
||||||
|
document_refs?: IDocumentRef[];
|
||||||
|
created_at: string;
|
||||||
|
modified_at?: string;
|
||||||
|
user?: string;
|
||||||
|
reversal_of?: string;
|
||||||
|
reversed_by?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILedgerLine {
|
||||||
|
posting_id: string;
|
||||||
|
account_code: string;
|
||||||
|
debit: string;
|
||||||
|
credit: string;
|
||||||
|
tax_code?: string;
|
||||||
|
cost_center?: string;
|
||||||
|
project?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDocumentRef {
|
||||||
|
content_hash: string;
|
||||||
|
doc_role: 'invoice' | 'receipt' | 'contract' | 'bank-statement' | 'other';
|
||||||
|
doc_mime: string;
|
||||||
|
doc_original_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LedgerExporter {
|
||||||
|
private exportPath: string;
|
||||||
|
private stream: WriteStream | null = null;
|
||||||
|
private entryCount: number = 0;
|
||||||
|
|
||||||
|
constructor(exportPath: string) {
|
||||||
|
this.exportPath = exportPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the NDJSON export stream
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
const ledgerPath = path.join(this.exportPath, 'data', 'accounting', 'ledger.ndjson');
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(ledgerPath));
|
||||||
|
|
||||||
|
this.stream = createWriteStream(ledgerPath, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
flags: 'w'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports a transaction as a ledger entry
|
||||||
|
*/
|
||||||
|
public async exportTransaction(transaction: ITransactionDataExport): Promise<void> {
|
||||||
|
if (!this.stream) {
|
||||||
|
throw new Error('Ledger exporter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: ILedgerEntry = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
entry_id: transaction._id || plugins.smartunique.shortId(),
|
||||||
|
booking_date: this.formatDate(transaction.date),
|
||||||
|
posting_date: this.formatDate(transaction.postingDate || transaction.date),
|
||||||
|
currency: transaction.currency || 'EUR',
|
||||||
|
journal: 'GL',
|
||||||
|
description: transaction.description,
|
||||||
|
reference: transaction.reference,
|
||||||
|
lines: [],
|
||||||
|
created_at: transaction.createdAt ? new Date(transaction.createdAt).toISOString() : new Date().toISOString(),
|
||||||
|
modified_at: transaction.modifiedAt ? new Date(transaction.modifiedAt).toISOString() : undefined,
|
||||||
|
reversal_of: transaction.reversalOf,
|
||||||
|
reversed_by: transaction.reversedBy
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add debit line
|
||||||
|
if (transaction.amount > 0) {
|
||||||
|
entry.lines.push({
|
||||||
|
posting_id: `${entry.entry_id}-1`,
|
||||||
|
account_code: transaction.debitAccount,
|
||||||
|
debit: transaction.amount.toFixed(2),
|
||||||
|
credit: '0.00',
|
||||||
|
tax_code: transaction.taxCode,
|
||||||
|
cost_center: transaction.costCenter,
|
||||||
|
project: transaction.project
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add credit line
|
||||||
|
entry.lines.push({
|
||||||
|
posting_id: `${entry.entry_id}-2`,
|
||||||
|
account_code: transaction.creditAccount,
|
||||||
|
debit: '0.00',
|
||||||
|
credit: transaction.amount.toFixed(2)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add VAT lines if applicable
|
||||||
|
if (transaction.vatAmount && transaction.vatAmount > 0) {
|
||||||
|
entry.lines.push({
|
||||||
|
posting_id: `${entry.entry_id}-3`,
|
||||||
|
account_code: transaction.vatAccount || '1576', // Default VAT account
|
||||||
|
debit: transaction.vatAmount.toFixed(2),
|
||||||
|
credit: '0.00',
|
||||||
|
description: 'Vorsteuer'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.writeLine(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports a journal entry
|
||||||
|
*/
|
||||||
|
public async exportJournalEntry(journalEntry: IJournalEntryExport): Promise<void> {
|
||||||
|
if (!this.stream) {
|
||||||
|
throw new Error('Ledger exporter not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: ILedgerEntry = {
|
||||||
|
schema_version: '1.0',
|
||||||
|
entry_id: journalEntry._id || plugins.smartunique.shortId(),
|
||||||
|
booking_date: this.formatDate(journalEntry.date),
|
||||||
|
posting_date: this.formatDate(journalEntry.postingDate || journalEntry.date),
|
||||||
|
currency: journalEntry.currency || 'EUR',
|
||||||
|
journal: journalEntry.journal || 'GL',
|
||||||
|
description: journalEntry.description,
|
||||||
|
reference: journalEntry.reference,
|
||||||
|
lines: [],
|
||||||
|
created_at: journalEntry.createdAt ? new Date(journalEntry.createdAt).toISOString() : new Date().toISOString(),
|
||||||
|
modified_at: journalEntry.modifiedAt ? new Date(journalEntry.modifiedAt).toISOString() : undefined,
|
||||||
|
reversal_of: journalEntry.reversalOf,
|
||||||
|
reversed_by: journalEntry.reversedBy
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert journal entry lines
|
||||||
|
journalEntry.lines.forEach((line, index) => {
|
||||||
|
const extLine = line as IJournalEntryLineExport;
|
||||||
|
entry.lines.push({
|
||||||
|
posting_id: `${entry.entry_id}-${index + 1}`,
|
||||||
|
account_code: line.accountNumber,
|
||||||
|
debit: (line.debit || 0).toFixed(2),
|
||||||
|
credit: (line.credit || 0).toFixed(2),
|
||||||
|
tax_code: extLine.taxCode,
|
||||||
|
cost_center: line.costCenter,
|
||||||
|
project: extLine.project,
|
||||||
|
description: line.description
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.writeLine(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a single NDJSON line
|
||||||
|
*/
|
||||||
|
private async writeLine(entry: ILedgerEntry): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.stream) {
|
||||||
|
reject(new Error('Stream not initialized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const line = JSON.stringify(entry) + '\n';
|
||||||
|
this.stream.write(line, (error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
this.entryCount++;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date to ISO date string
|
||||||
|
*/
|
||||||
|
private formatDate(date: Date | string): string {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date.split('T')[0];
|
||||||
|
}
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the export stream
|
||||||
|
*/
|
||||||
|
public async close(): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.stream) {
|
||||||
|
this.stream.end(() => {
|
||||||
|
resolve(this.entryCount);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve(this.entryCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of exported entries
|
||||||
|
*/
|
||||||
|
public getEntryCount(): number {
|
||||||
|
return this.entryCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { ITrialBalanceReport, IIncomeStatement, IBalanceSheet } from './skr.types.js';
|
||||||
|
|
||||||
|
export interface IPdfReportOptions {
|
||||||
|
companyName: string;
|
||||||
|
companyAddress?: string;
|
||||||
|
taxId?: string;
|
||||||
|
registrationNumber?: string;
|
||||||
|
fiscalYear: number;
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
preparedBy?: string;
|
||||||
|
preparedDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PdfReportGenerator {
|
||||||
|
private exportPath: string;
|
||||||
|
private options: IPdfReportOptions;
|
||||||
|
private pdfInstance: plugins.smartpdf.SmartPdf | null = null;
|
||||||
|
|
||||||
|
constructor(exportPath: string, options: IPdfReportOptions) {
|
||||||
|
this.exportPath = exportPath;
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the PDF generator
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
this.pdfInstance = new plugins.smartpdf.SmartPdf();
|
||||||
|
await this.pdfInstance.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the trial balance PDF report
|
||||||
|
*/
|
||||||
|
public async generateTrialBalancePdf(report: ITrialBalanceReport): Promise<Buffer> {
|
||||||
|
if (!this.pdfInstance) {
|
||||||
|
throw new Error('PDF generator not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.generateTrialBalanceHtml(report);
|
||||||
|
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||||
|
return Buffer.from(pdfResult.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the income statement PDF report
|
||||||
|
*/
|
||||||
|
public async generateIncomeStatementPdf(report: IIncomeStatement): Promise<Buffer> {
|
||||||
|
if (!this.pdfInstance) {
|
||||||
|
throw new Error('PDF generator not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.generateIncomeStatementHtml(report);
|
||||||
|
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||||
|
return Buffer.from(pdfResult.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the balance sheet PDF report
|
||||||
|
*/
|
||||||
|
public async generateBalanceSheetPdf(report: IBalanceSheet): Promise<Buffer> {
|
||||||
|
if (!this.pdfInstance) {
|
||||||
|
throw new Error('PDF generator not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.generateBalanceSheetHtml(report);
|
||||||
|
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||||
|
return Buffer.from(pdfResult.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the comprehensive Jahresabschluss PDF
|
||||||
|
*/
|
||||||
|
public async generateJahresabschlussPdf(
|
||||||
|
trialBalance: ITrialBalanceReport,
|
||||||
|
incomeStatement: IIncomeStatement,
|
||||||
|
balanceSheet: IBalanceSheet
|
||||||
|
): Promise<Buffer> {
|
||||||
|
if (!this.pdfInstance) {
|
||||||
|
throw new Error('PDF generator not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = this.generateJahresabschlussHtml(trialBalance, incomeStatement, balanceSheet);
|
||||||
|
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||||
|
return Buffer.from(pdfResult.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for trial balance report
|
||||||
|
*/
|
||||||
|
private generateTrialBalanceHtml(report: ITrialBalanceReport): string {
|
||||||
|
const entries = report.entries || [];
|
||||||
|
|
||||||
|
const tableRows = entries.map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.accountNumber}</td>
|
||||||
|
<td>${entry.accountName}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(0)}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.debitBalance)}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.creditBalance)}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.netBalance)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
${this.getBaseStyles()}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${this.generateHeader('Summen- und Saldenliste')}
|
||||||
|
|
||||||
|
<table class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Konto</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Anfangssaldo</th>
|
||||||
|
<th>Soll</th>
|
||||||
|
<th>Haben</th>
|
||||||
|
<th>Saldo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${tableRows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="3">Summe</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.totalDebits)}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.totalCredits)}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.totalDebits - report.totalCredits)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${this.generateFooter()}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for income statement report
|
||||||
|
*/
|
||||||
|
private generateIncomeStatementHtml(report: IIncomeStatement): string {
|
||||||
|
const revenueRows = (report.revenue || []).map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.accountNumber}</td>
|
||||||
|
<td>${entry.accountName}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const expenseRows = (report.expenses || []).map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.accountNumber}</td>
|
||||||
|
<td>${entry.accountName}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
${this.getBaseStyles()}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${this.generateHeader('Gewinn- und Verlustrechnung')}
|
||||||
|
|
||||||
|
<h2>Erträge</h2>
|
||||||
|
<table class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Konto</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${revenueRows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="subtotal-row">
|
||||||
|
<td colspan="2">Summe Erträge</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.totalRevenue)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Aufwendungen</h2>
|
||||||
|
<table class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Konto</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${expenseRows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="subtotal-row">
|
||||||
|
<td colspan="2">Summe Aufwendungen</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.totalExpenses)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="result-section">
|
||||||
|
<h2>Ergebnis</h2>
|
||||||
|
<table class="summary-table">
|
||||||
|
<tr>
|
||||||
|
<td>Erträge</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.totalRevenue)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Aufwendungen</td>
|
||||||
|
<td class="number">- ${this.formatGermanNumber(report.totalExpenses)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>${report.netIncome >= 0 ? 'Jahresüberschuss' : 'Jahresfehlbetrag'}</td>
|
||||||
|
<td class="number ${report.netIncome >= 0 ? 'positive' : 'negative'}">
|
||||||
|
${this.formatGermanNumber(report.netIncome)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.generateFooter()}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates HTML for balance sheet report
|
||||||
|
*/
|
||||||
|
private generateBalanceSheetHtml(report: IBalanceSheet): string {
|
||||||
|
const assetRows = [...(report.assets.current || []), ...(report.assets.fixed || [])].map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.accountNumber}</td>
|
||||||
|
<td>${entry.accountName}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const liabilityRows = [...(report.liabilities.current || []), ...(report.liabilities.longTerm || [])].map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.accountNumber}</td>
|
||||||
|
<td>${entry.accountName}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const equityRows = (report.equity.entries || []).map(entry => `
|
||||||
|
<tr>
|
||||||
|
<td>${entry.accountNumber}</td>
|
||||||
|
<td>${entry.accountName}</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
${this.getBaseStyles()}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${this.generateHeader('Bilanz')}
|
||||||
|
|
||||||
|
<div class="balance-sheet">
|
||||||
|
<div class="aktiva">
|
||||||
|
<h2>Aktiva</h2>
|
||||||
|
<table class="report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Konto</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${assetRows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2">Summe Aktiva</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.assets.totalAssets)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="passiva">
|
||||||
|
<h2>Passiva</h2>
|
||||||
|
|
||||||
|
<h3>Eigenkapital</h3>
|
||||||
|
<table class="report-table">
|
||||||
|
<tbody>
|
||||||
|
${equityRows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="subtotal-row">
|
||||||
|
<td colspan="2">Summe Eigenkapital</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.equity.totalEquity)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Fremdkapital</h3>
|
||||||
|
<table class="report-table">
|
||||||
|
<tbody>
|
||||||
|
${liabilityRows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="subtotal-row">
|
||||||
|
<td colspan="2">Summe Fremdkapital</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.liabilities.totalLiabilities)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table class="summary-table">
|
||||||
|
<tr class="total-row">
|
||||||
|
<td>Summe Passiva</td>
|
||||||
|
<td class="number">${this.formatGermanNumber(report.liabilities.totalLiabilities + report.equity.totalEquity)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.generateFooter()}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates comprehensive Jahresabschluss HTML
|
||||||
|
*/
|
||||||
|
private generateJahresabschlussHtml(
|
||||||
|
trialBalance: ITrialBalanceReport,
|
||||||
|
incomeStatement: IIncomeStatement,
|
||||||
|
balanceSheet: IBalanceSheet
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
${this.getBaseStyles()}
|
||||||
|
.page-break { page-break-after: always; }
|
||||||
|
.cover-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cover-page h1 { font-size: 36px; margin-bottom: 20px; }
|
||||||
|
.cover-page h2 { font-size: 24px; margin-bottom: 40px; }
|
||||||
|
.toc { margin-top: 50px; }
|
||||||
|
.toc h2 { margin-bottom: 20px; }
|
||||||
|
.toc ul { list-style: none; padding: 0; }
|
||||||
|
.toc li { margin: 10px 0; font-size: 16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="cover-page">
|
||||||
|
<h1>Jahresabschluss</h1>
|
||||||
|
<h2>${this.options.companyName}</h2>
|
||||||
|
<p>Geschäftsjahr ${this.options.fiscalYear}</p>
|
||||||
|
<p>${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}</p>
|
||||||
|
|
||||||
|
<div class="toc">
|
||||||
|
<h2>Inhalt</h2>
|
||||||
|
<ul>
|
||||||
|
<li>1. Bilanz</li>
|
||||||
|
<li>2. Gewinn- und Verlustrechnung</li>
|
||||||
|
<li>3. Summen- und Saldenliste</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-break"></div>
|
||||||
|
${this.generateBalanceSheetHtml(balanceSheet)}
|
||||||
|
|
||||||
|
<div class="page-break"></div>
|
||||||
|
${this.generateIncomeStatementHtml(incomeStatement)}
|
||||||
|
|
||||||
|
<div class="page-break"></div>
|
||||||
|
${this.generateTrialBalanceHtml(trialBalance)}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the report header
|
||||||
|
*/
|
||||||
|
private generateHeader(reportTitle: string): string {
|
||||||
|
return `
|
||||||
|
<div class="header">
|
||||||
|
<h1>${this.options.companyName}</h1>
|
||||||
|
${this.options.companyAddress ? `<p>${this.options.companyAddress}</p>` : ''}
|
||||||
|
${this.options.taxId ? `<p>Steuernummer: ${this.options.taxId}</p>` : ''}
|
||||||
|
${this.options.registrationNumber ? `<p>Handelsregister: ${this.options.registrationNumber}</p>` : ''}
|
||||||
|
<hr>
|
||||||
|
<h2>${reportTitle}</h2>
|
||||||
|
<p>Periode: ${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the report footer
|
||||||
|
*/
|
||||||
|
private generateFooter(): string {
|
||||||
|
const preparedDate = this.options.preparedDate || new Date();
|
||||||
|
return `
|
||||||
|
<div class="footer">
|
||||||
|
<hr>
|
||||||
|
<p>Erstellt am: ${this.formatGermanDate(preparedDate)}</p>
|
||||||
|
${this.options.preparedBy ? `<p>Erstellt von: ${this.options.preparedBy}</p>` : ''}
|
||||||
|
<p class="disclaimer">
|
||||||
|
Dieser Bericht wurde automatisch generiert und ist Teil des revisionssicheren
|
||||||
|
Jahresabschluss-Exports gemäß GoBD.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the base CSS styles for all reports
|
||||||
|
*/
|
||||||
|
private getBaseStyles(): string {
|
||||||
|
return `
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 40px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
h1 { color: #2c3e50; margin-bottom: 10px; }
|
||||||
|
h2 { color: #34495e; margin-top: 30px; margin-bottom: 15px; }
|
||||||
|
h3 { color: #7f8c8d; margin-top: 20px; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 50px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #34495e;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #ecf0f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtotal-row {
|
||||||
|
font-weight: 600;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 20px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-sheet {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aktiva, .passiva {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body { margin: 20px; }
|
||||||
|
.page-break { page-break-after: always; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats number in German format (1.234,56)
|
||||||
|
*/
|
||||||
|
private formatGermanNumber(value: number): string {
|
||||||
|
return value.toLocaleString('de-DE', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats date in German format (DD.MM.YYYY)
|
||||||
|
*/
|
||||||
|
private formatGermanDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a PDF report to the export directory
|
||||||
|
*/
|
||||||
|
public async savePdfReport(filename: string, pdfBuffer: Buffer): Promise<string> {
|
||||||
|
const reportsDir = path.join(this.exportPath, 'data', 'reports');
|
||||||
|
await plugins.smartfile.fs.ensureDir(reportsDir);
|
||||||
|
|
||||||
|
const filePath = path.join(reportsDir, filename);
|
||||||
|
await plugins.smartfile.memory.toFs(pdfBuffer, filePath);
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the PDF generator
|
||||||
|
*/
|
||||||
|
public async close(): Promise<void> {
|
||||||
|
if (this.pdfInstance) {
|
||||||
|
await this.pdfInstance.stop();
|
||||||
|
this.pdfInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type { IAccountData, ITransactionData, IJournalEntry, TSKRType } from './skr.types.js';
|
||||||
|
|
||||||
|
export interface IExportOptions {
|
||||||
|
exportPath: string;
|
||||||
|
fiscalYear: number;
|
||||||
|
dateFrom: Date;
|
||||||
|
dateTo: Date;
|
||||||
|
includeDocuments?: boolean;
|
||||||
|
generatePdfReports?: boolean;
|
||||||
|
signExport?: boolean;
|
||||||
|
timestampExport?: boolean;
|
||||||
|
companyInfo?: {
|
||||||
|
name: string;
|
||||||
|
taxId: string;
|
||||||
|
registrationNumber?: string;
|
||||||
|
address?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IExportMetadata {
|
||||||
|
exportVersion: string;
|
||||||
|
exportTimestamp: string;
|
||||||
|
generator: {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
company?: {
|
||||||
|
name: string;
|
||||||
|
taxId: string;
|
||||||
|
registrationNumber?: string;
|
||||||
|
address?: string;
|
||||||
|
};
|
||||||
|
fiscalYear: number;
|
||||||
|
dateRange: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
skrType: TSKRType;
|
||||||
|
schemaVersion: string;
|
||||||
|
crypto: {
|
||||||
|
digestAlgorithms: string[];
|
||||||
|
signatureType?: string;
|
||||||
|
timestampPolicy?: string;
|
||||||
|
merkleTree: boolean;
|
||||||
|
};
|
||||||
|
options: {
|
||||||
|
packagedAs: 'bagit';
|
||||||
|
compression: 'none' | 'deflate';
|
||||||
|
deduplication: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IBagItManifest {
|
||||||
|
[filePath: string]: string; // filePath -> SHA256 hash
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDocumentIndex {
|
||||||
|
contentHash: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
mimeType: string;
|
||||||
|
createdAt: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
pdfaAvailable: boolean;
|
||||||
|
zugferdXml?: string;
|
||||||
|
retentionClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SkrExport {
|
||||||
|
private logger: plugins.smartlog.ConsoleLog;
|
||||||
|
private options: IExportOptions;
|
||||||
|
private exportDir: string;
|
||||||
|
private manifest: IBagItManifest = {};
|
||||||
|
private tagManifest: IBagItManifest = {};
|
||||||
|
|
||||||
|
constructor(options: IExportOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
this.exportDir = path.join(options.exportPath, `jahresabschluss_${options.fiscalYear}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the BagIt directory structure for the export
|
||||||
|
*/
|
||||||
|
public async createBagItStructure(): Promise<void> {
|
||||||
|
this.logger.log('info', 'Creating BagIt directory structure...');
|
||||||
|
|
||||||
|
// Create main directories
|
||||||
|
await plugins.smartfile.fs.ensureDir(this.exportDir);
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'schemas'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'schemas', 'v1'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'signatures'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'accounting'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'accounting', 'ebilanz'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'documents'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'documents', 'by-hash'));
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'reports'));
|
||||||
|
|
||||||
|
// Create BagIt declaration file
|
||||||
|
await this.createBagItDeclaration();
|
||||||
|
|
||||||
|
// Create README
|
||||||
|
await this.createReadme();
|
||||||
|
|
||||||
|
this.logger.log('ok', 'BagIt structure created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the bagit.txt declaration file
|
||||||
|
*/
|
||||||
|
private async createBagItDeclaration(): Promise<void> {
|
||||||
|
const bagitContent = `BagIt-Version: 1.0
|
||||||
|
Tag-File-Character-Encoding: UTF-8`;
|
||||||
|
|
||||||
|
const filePath = path.join(this.exportDir, 'bagit.txt');
|
||||||
|
await plugins.smartfile.memory.toFs(bagitContent, filePath);
|
||||||
|
|
||||||
|
// Add to tag manifest
|
||||||
|
const hash = await this.hashFile(filePath);
|
||||||
|
this.tagManifest['bagit.txt'] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the README.txt file with Verfahrensdokumentation
|
||||||
|
*/
|
||||||
|
private async createReadme(): Promise<void> {
|
||||||
|
const readmeContent = `SKR Jahresabschluss Export - Verfahrensdokumentation
|
||||||
|
=====================================================
|
||||||
|
|
||||||
|
Dieses Archiv enthält einen revisionssicheren Export des Jahresabschlusses
|
||||||
|
gemäß den Grundsätzen ordnungsmäßiger Buchführung (GoBD).
|
||||||
|
|
||||||
|
Export-Datum: ${new Date().toISOString()}
|
||||||
|
Geschäftsjahr: ${this.options.fiscalYear}
|
||||||
|
Zeitraum: ${this.options.dateFrom.toISOString()} bis ${this.options.dateTo.toISOString()}
|
||||||
|
|
||||||
|
STRUKTUR DES ARCHIVS
|
||||||
|
--------------------
|
||||||
|
- /data/accounting/: Buchhaltungsdaten (Journale, Konten, Salden)
|
||||||
|
- /data/documents/: Belegdokumente (content-adressiert)
|
||||||
|
- /data/reports/: Finanzberichte (PDF/A-3)
|
||||||
|
- /data/metadata/: Export-Metadaten und Schemas
|
||||||
|
- /data/metadata/signatures/: Digitale Signaturen und Zeitstempel
|
||||||
|
|
||||||
|
INTEGRITÄTSSICHERUNG
|
||||||
|
--------------------
|
||||||
|
- Alle Dateien sind mit SHA-256 gehasht (siehe manifest-sha256.txt)
|
||||||
|
- Optional: Digitale Signatur (CAdES) über Manifest
|
||||||
|
- Optional: RFC 3161 Zeitstempel
|
||||||
|
|
||||||
|
AUFBEWAHRUNG
|
||||||
|
------------
|
||||||
|
Dieses Archiv muss gemäß § 147 AO für 10 Jahre revisionssicher aufbewahrt werden.
|
||||||
|
Empfohlen wird die Speicherung auf WORM-Medien.
|
||||||
|
|
||||||
|
REIMPORT
|
||||||
|
--------
|
||||||
|
Das Archiv kann mit der SKR-Software vollständig reimportiert werden.
|
||||||
|
Die Datenintegrität wird beim Import automatisch verifiziert.
|
||||||
|
|
||||||
|
COMPLIANCE
|
||||||
|
----------
|
||||||
|
- GoBD-konform
|
||||||
|
- E-Bilanz-fähig (XBRL)
|
||||||
|
- ZUGFeRD/Factur-X kompatibel
|
||||||
|
- PDF/A-3 für Langzeitarchivierung
|
||||||
|
|
||||||
|
© ${new Date().getFullYear()} ${this.options.companyInfo?.name || 'Export System'}`;
|
||||||
|
|
||||||
|
const filePath = path.join(this.exportDir, 'readme.txt');
|
||||||
|
await plugins.smartfile.memory.toFs(readmeContent, filePath);
|
||||||
|
|
||||||
|
// Add to tag manifest
|
||||||
|
const hash = await this.hashFile(filePath);
|
||||||
|
this.tagManifest['readme.txt'] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the export metadata JSON file
|
||||||
|
*/
|
||||||
|
public async createExportMetadata(skrType: TSKRType): Promise<void> {
|
||||||
|
const metadata: IExportMetadata = {
|
||||||
|
exportVersion: '1.0.0',
|
||||||
|
exportTimestamp: new Date().toISOString(),
|
||||||
|
generator: {
|
||||||
|
name: '@fin.cx/skr',
|
||||||
|
version: '1.1.0' // Should be read from package.json
|
||||||
|
},
|
||||||
|
company: this.options.companyInfo,
|
||||||
|
fiscalYear: this.options.fiscalYear,
|
||||||
|
dateRange: {
|
||||||
|
from: this.options.dateFrom.toISOString(),
|
||||||
|
to: this.options.dateTo.toISOString()
|
||||||
|
},
|
||||||
|
skrType: skrType,
|
||||||
|
schemaVersion: '1.0',
|
||||||
|
crypto: {
|
||||||
|
digestAlgorithms: ['sha256'],
|
||||||
|
signatureType: this.options.signExport ? 'CAdES' : undefined,
|
||||||
|
timestampPolicy: this.options.timestampExport ? 'RFC3161' : undefined,
|
||||||
|
merkleTree: true
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
packagedAs: 'bagit',
|
||||||
|
compression: 'none',
|
||||||
|
deduplication: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filePath = path.join(this.exportDir, 'data', 'metadata', 'export.json');
|
||||||
|
await plugins.smartfile.memory.toFs(JSON.stringify(metadata, null, 2), filePath);
|
||||||
|
|
||||||
|
// Add to manifest
|
||||||
|
const hash = await this.hashFile(filePath);
|
||||||
|
this.manifest['data/metadata/export.json'] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates JSON schemas for the export data structures
|
||||||
|
*/
|
||||||
|
public async createSchemas(): Promise<void> {
|
||||||
|
// Ledger schema
|
||||||
|
const ledgerSchema = {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Ledger Entry",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"schema_version": { "type": "string" },
|
||||||
|
"entry_id": { "type": "string", "format": "uuid" },
|
||||||
|
"booking_date": { "type": "string", "format": "date" },
|
||||||
|
"posting_date": { "type": "string", "format": "date" },
|
||||||
|
"currency": { "type": "string" },
|
||||||
|
"journal": { "type": "string" },
|
||||||
|
"description": { "type": "string" },
|
||||||
|
"lines": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"posting_id": { "type": "string" },
|
||||||
|
"account_code": { "type": "string" },
|
||||||
|
"debit": { "type": "string" },
|
||||||
|
"credit": { "type": "string" },
|
||||||
|
"tax_code": { "type": "string" },
|
||||||
|
"document_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content_hash": { "type": "string" },
|
||||||
|
"doc_role": { "type": "string" },
|
||||||
|
"mime": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["posting_id", "account_code", "debit", "credit"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": { "type": "string", "format": "date-time" },
|
||||||
|
"user": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["schema_version", "entry_id", "booking_date", "lines"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accounts schema
|
||||||
|
const accountsSchema = {
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "Accounts CSV",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"account_code": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"type": { "type": "string" },
|
||||||
|
"parent": { "type": "string" },
|
||||||
|
"skr_set": { "type": "string" },
|
||||||
|
"tax_code_default": { "type": "string" },
|
||||||
|
"active_from": { "type": "string", "format": "date" },
|
||||||
|
"active_to": { "type": "string", "format": "date" }
|
||||||
|
},
|
||||||
|
"required": ["account_code", "name", "type", "skr_set"]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save schemas
|
||||||
|
const schemasDir = path.join(this.exportDir, 'data', 'metadata', 'schemas', 'v1');
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(ledgerSchema, null, 2),
|
||||||
|
path.join(schemasDir, 'ledger.schema.json')
|
||||||
|
);
|
||||||
|
this.manifest['data/metadata/schemas/v1/ledger.schema.json'] = await this.hashFile(
|
||||||
|
path.join(schemasDir, 'ledger.schema.json')
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(accountsSchema, null, 2),
|
||||||
|
path.join(schemasDir, 'accounts.schema.json')
|
||||||
|
);
|
||||||
|
this.manifest['data/metadata/schemas/v1/accounts.schema.json'] = await this.hashFile(
|
||||||
|
path.join(schemasDir, 'accounts.schema.json')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes the BagIt manifest files
|
||||||
|
*/
|
||||||
|
public async writeManifests(): Promise<void> {
|
||||||
|
// Write data manifest
|
||||||
|
let manifestContent = '';
|
||||||
|
for (const [filePath, hash] of Object.entries(this.manifest)) {
|
||||||
|
manifestContent += `${hash} ${filePath}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestPath = path.join(this.exportDir, 'manifest-sha256.txt');
|
||||||
|
await plugins.smartfile.memory.toFs(manifestContent, manifestPath);
|
||||||
|
|
||||||
|
// Add manifest to tag manifest
|
||||||
|
this.tagManifest['manifest-sha256.txt'] = await this.hashFile(manifestPath);
|
||||||
|
|
||||||
|
// Write tag manifest
|
||||||
|
let tagManifestContent = '';
|
||||||
|
for (const [filePath, hash] of Object.entries(this.tagManifest)) {
|
||||||
|
tagManifestContent += `${hash} ${filePath}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
tagManifestContent,
|
||||||
|
path.join(this.exportDir, 'tagmanifest-sha256.txt')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates SHA-256 hash of a file
|
||||||
|
*/
|
||||||
|
private async hashFile(filePath: string): Promise<string> {
|
||||||
|
const fileContent = await plugins.smartfile.fs.toBuffer(filePath);
|
||||||
|
return await plugins.smarthash.sha256FromBuffer(fileContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a document in content-addressed storage
|
||||||
|
*/
|
||||||
|
public async storeDocument(content: Buffer, originalFilename?: string): Promise<string> {
|
||||||
|
const hash = await plugins.smarthash.sha256FromBuffer(content);
|
||||||
|
|
||||||
|
// Create path based on hash (first 2 chars as directory)
|
||||||
|
const hashPrefix = hash.substring(0, 2);
|
||||||
|
const hashDir = path.join(this.exportDir, 'data', 'documents', 'by-hash', hashPrefix);
|
||||||
|
await plugins.smartfile.fs.ensureDir(hashDir);
|
||||||
|
|
||||||
|
const docPath = path.join(hashDir, hash);
|
||||||
|
|
||||||
|
// Only store if not already exists (deduplication)
|
||||||
|
if (!(await plugins.smartfile.fs.fileExists(docPath))) {
|
||||||
|
await plugins.smartfile.memory.toFs(content, docPath);
|
||||||
|
this.manifest[`data/documents/by-hash/${hashPrefix}/${hash}`] = hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Merkle tree from all file hashes
|
||||||
|
*/
|
||||||
|
public async createMerkleTree(): Promise<string> {
|
||||||
|
const leaves = Object.values(this.manifest).map(hash =>
|
||||||
|
Buffer.from(hash, 'hex')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a sync hash function wrapper for MerkleTree
|
||||||
|
const hashFn = (data: Buffer) => {
|
||||||
|
// Convert async to sync by using crypto directly
|
||||||
|
const crypto = require('crypto');
|
||||||
|
return crypto.createHash('sha256').update(data).digest();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tree = new plugins.MerkleTree(leaves, hashFn, {
|
||||||
|
sortPairs: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = tree.getRoot().toString('hex');
|
||||||
|
|
||||||
|
// Save Merkle tree data
|
||||||
|
const merkleData = {
|
||||||
|
root: root,
|
||||||
|
leaves: Object.entries(this.manifest).map(([path, hash]) => ({
|
||||||
|
path,
|
||||||
|
hash
|
||||||
|
})),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const merklePath = path.join(this.exportDir, 'data', 'metadata', 'merkle-tree.json');
|
||||||
|
await plugins.smartfile.memory.toFs(JSON.stringify(merkleData, null, 2), merklePath);
|
||||||
|
this.manifest['data/metadata/merkle-tree.json'] = await this.hashFile(merklePath);
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the BagIt structure
|
||||||
|
*/
|
||||||
|
public async validateBagIt(): Promise<boolean> {
|
||||||
|
this.logger.log('info', 'Validating BagIt structure...');
|
||||||
|
|
||||||
|
// Check required files exist
|
||||||
|
const requiredFiles = [
|
||||||
|
'bagit.txt',
|
||||||
|
'manifest-sha256.txt',
|
||||||
|
'tagmanifest-sha256.txt',
|
||||||
|
'readme.txt'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of requiredFiles) {
|
||||||
|
const filePath = path.join(this.exportDir, file);
|
||||||
|
if (!(await plugins.smartfile.fs.fileExists(filePath))) {
|
||||||
|
this.logger.log('error', `Required file missing: ${file}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all manifest entries
|
||||||
|
for (const [relPath, expectedHash] of Object.entries(this.manifest)) {
|
||||||
|
const fullPath = path.join(this.exportDir, relPath);
|
||||||
|
if (!(await plugins.smartfile.fs.fileExists(fullPath))) {
|
||||||
|
this.logger.log('error', `Manifest file missing: ${relPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualHash = await this.hashFile(fullPath);
|
||||||
|
if (actualHash !== expectedHash) {
|
||||||
|
this.logger.log('error', `Hash mismatch for ${relPath}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('ok', 'BagIt validation successful');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type {
|
||||||
|
IInvoice,
|
||||||
|
IInvoiceLine,
|
||||||
|
IInvoiceParty,
|
||||||
|
IVATCategory,
|
||||||
|
IValidationResult,
|
||||||
|
TInvoiceFormat,
|
||||||
|
TInvoiceDirection,
|
||||||
|
TTaxScenario,
|
||||||
|
IAllowanceCharge,
|
||||||
|
IPaymentTerms
|
||||||
|
} from './skr.invoice.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter for @fin.cx/einvoice library
|
||||||
|
* Handles parsing, validation, and format conversion of e-invoices
|
||||||
|
*/
|
||||||
|
export class InvoiceAdapter {
|
||||||
|
private logger: plugins.smartlog.ConsoleLog;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly MAX_XML_SIZE = 10 * 1024 * 1024; // 10MB max
|
||||||
|
private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an invoice from file or buffer
|
||||||
|
*/
|
||||||
|
public async parseInvoice(
|
||||||
|
file: Buffer | string,
|
||||||
|
direction: TInvoiceDirection
|
||||||
|
): Promise<IInvoice> {
|
||||||
|
try {
|
||||||
|
// Validate input size
|
||||||
|
if (Buffer.isBuffer(file)) {
|
||||||
|
if (file.length > this.MAX_XML_SIZE) {
|
||||||
|
throw new Error(`Invoice file too large: ${file.length} bytes (max ${this.MAX_XML_SIZE} bytes)`);
|
||||||
|
}
|
||||||
|
} else if (typeof file === 'string' && file.length > this.MAX_XML_SIZE) {
|
||||||
|
throw new Error(`Invoice XML too large: ${file.length} characters (max ${this.MAX_XML_SIZE} characters)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the invoice using @fin.cx/einvoice
|
||||||
|
let einvoice;
|
||||||
|
if (typeof file === 'string') {
|
||||||
|
einvoice = await plugins.einvoice.EInvoice.fromXml(file);
|
||||||
|
} else {
|
||||||
|
// Convert buffer to string first
|
||||||
|
const xmlString = file.toString('utf-8');
|
||||||
|
einvoice = await plugins.einvoice.EInvoice.fromXml(xmlString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get detected format
|
||||||
|
const format = this.mapEInvoiceFormat(einvoice.format || 'xrechnung');
|
||||||
|
|
||||||
|
// Validate the invoice (takes ~2.2ms)
|
||||||
|
const validationResult = await this.validateInvoice(einvoice);
|
||||||
|
|
||||||
|
// Extract invoice data
|
||||||
|
const invoiceData = einvoice.toObject();
|
||||||
|
|
||||||
|
// Map to internal invoice model
|
||||||
|
const invoice = await this.mapToInternalModel(
|
||||||
|
invoiceData,
|
||||||
|
format,
|
||||||
|
direction,
|
||||||
|
validationResult
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store original XML content
|
||||||
|
invoice.xmlContent = einvoice.getXml();
|
||||||
|
|
||||||
|
// Calculate content hash
|
||||||
|
invoice.contentHash = await this.calculateContentHash(invoice.xmlContent);
|
||||||
|
|
||||||
|
// Classify tax scenario
|
||||||
|
invoice.taxScenario = this.classifyTaxScenario(invoice);
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to parse invoice: ${error}`);
|
||||||
|
throw new Error(`Invoice parsing failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an invoice using multi-level validation
|
||||||
|
*/
|
||||||
|
private async validateInvoice(einvoice: any): Promise<IValidationResult> {
|
||||||
|
// Perform multi-level validation
|
||||||
|
const validationResult = await einvoice.validate();
|
||||||
|
|
||||||
|
// Parse validation results into our structure
|
||||||
|
const syntaxResult = {
|
||||||
|
isValid: validationResult.syntax?.valid !== false,
|
||||||
|
errors: validationResult.syntax?.errors || [],
|
||||||
|
warnings: validationResult.syntax?.warnings || []
|
||||||
|
};
|
||||||
|
|
||||||
|
const semanticResult = {
|
||||||
|
isValid: validationResult.semantic?.valid !== false,
|
||||||
|
errors: validationResult.semantic?.errors || [],
|
||||||
|
warnings: validationResult.semantic?.warnings || []
|
||||||
|
};
|
||||||
|
|
||||||
|
const businessResult = {
|
||||||
|
isValid: validationResult.business?.valid !== false,
|
||||||
|
errors: validationResult.business?.errors || [],
|
||||||
|
warnings: validationResult.business?.warnings || []
|
||||||
|
};
|
||||||
|
|
||||||
|
const countryResult = {
|
||||||
|
isValid: validationResult.country?.valid !== false,
|
||||||
|
errors: validationResult.country?.errors || [],
|
||||||
|
warnings: validationResult.country?.warnings || []
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: syntaxResult.isValid && semanticResult.isValid && businessResult.isValid,
|
||||||
|
syntax: {
|
||||||
|
valid: syntaxResult.isValid,
|
||||||
|
errors: syntaxResult.errors || [],
|
||||||
|
warnings: syntaxResult.warnings || []
|
||||||
|
},
|
||||||
|
semantic: {
|
||||||
|
valid: semanticResult.isValid,
|
||||||
|
errors: semanticResult.errors || [],
|
||||||
|
warnings: semanticResult.warnings || []
|
||||||
|
},
|
||||||
|
businessRules: {
|
||||||
|
valid: businessResult.isValid,
|
||||||
|
errors: businessResult.errors || [],
|
||||||
|
warnings: businessResult.warnings || []
|
||||||
|
},
|
||||||
|
countrySpecific: {
|
||||||
|
valid: countryResult.isValid,
|
||||||
|
errors: countryResult.errors || [],
|
||||||
|
warnings: countryResult.warnings || []
|
||||||
|
},
|
||||||
|
validatedAt: new Date(),
|
||||||
|
validatorVersion: '5.1.4'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map EN16931 Business Terms to internal invoice model
|
||||||
|
*/
|
||||||
|
private async mapToInternalModel(
|
||||||
|
businessTerms: any,
|
||||||
|
format: TInvoiceFormat,
|
||||||
|
direction: TInvoiceDirection,
|
||||||
|
validationResult: IValidationResult
|
||||||
|
): Promise<IInvoice> {
|
||||||
|
const invoice: IInvoice = {
|
||||||
|
// Identity
|
||||||
|
id: plugins.smartunique.shortId(),
|
||||||
|
direction,
|
||||||
|
format,
|
||||||
|
|
||||||
|
// EN16931 Business Terms
|
||||||
|
invoiceNumber: businessTerms.BT1_InvoiceNumber,
|
||||||
|
issueDate: new Date(businessTerms.BT2_IssueDate),
|
||||||
|
invoiceTypeCode: businessTerms.BT3_InvoiceTypeCode || '380',
|
||||||
|
currencyCode: businessTerms.BT5_CurrencyCode || 'EUR',
|
||||||
|
taxCurrencyCode: businessTerms.BT6_TaxCurrencyCode,
|
||||||
|
taxPointDate: businessTerms.BT7_TaxPointDate ? new Date(businessTerms.BT7_TaxPointDate) : undefined,
|
||||||
|
paymentDueDate: businessTerms.BT9_PaymentDueDate ? new Date(businessTerms.BT9_PaymentDueDate) : undefined,
|
||||||
|
buyerReference: businessTerms.BT10_BuyerReference,
|
||||||
|
projectReference: businessTerms.BT11_ProjectReference,
|
||||||
|
contractReference: businessTerms.BT12_ContractReference,
|
||||||
|
orderReference: businessTerms.BT13_OrderReference,
|
||||||
|
sellerOrderReference: businessTerms.BT14_SellerOrderReference,
|
||||||
|
|
||||||
|
// Parties
|
||||||
|
supplier: this.mapParty(businessTerms.BG4_Seller),
|
||||||
|
customer: this.mapParty(businessTerms.BG7_Buyer),
|
||||||
|
payee: businessTerms.BG10_Payee ? this.mapParty(businessTerms.BG10_Payee) : undefined,
|
||||||
|
|
||||||
|
// Line items
|
||||||
|
lines: this.mapInvoiceLines(businessTerms.BG25_InvoiceLines || []),
|
||||||
|
|
||||||
|
// Allowances and charges
|
||||||
|
allowances: this.mapAllowancesCharges(businessTerms.BG20_DocumentAllowances || [], true),
|
||||||
|
charges: this.mapAllowancesCharges(businessTerms.BG21_DocumentCharges || [], false),
|
||||||
|
|
||||||
|
// Amounts
|
||||||
|
lineNetAmount: parseFloat(businessTerms.BT106_SumOfLineNetAmounts || 0),
|
||||||
|
allowanceTotalAmount: parseFloat(businessTerms.BT107_AllowanceTotalAmount || 0),
|
||||||
|
chargeTotalAmount: parseFloat(businessTerms.BT108_ChargeTotalAmount || 0),
|
||||||
|
taxExclusiveAmount: parseFloat(businessTerms.BT109_TaxExclusiveAmount || 0),
|
||||||
|
taxInclusiveAmount: parseFloat(businessTerms.BT112_TaxInclusiveAmount || 0),
|
||||||
|
prepaidAmount: parseFloat(businessTerms.BT113_PrepaidAmount || 0),
|
||||||
|
payableAmount: parseFloat(businessTerms.BT115_PayableAmount || 0),
|
||||||
|
|
||||||
|
// VAT breakdown
|
||||||
|
vatBreakdown: this.mapVATBreakdown(businessTerms.BG23_VATBreakdown || []),
|
||||||
|
totalVATAmount: parseFloat(businessTerms.BT110_TotalVATAmount || 0),
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
paymentTerms: this.mapPaymentTerms(businessTerms),
|
||||||
|
paymentMeans: this.mapPaymentMeans(businessTerms.BG16_PaymentInstructions),
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
invoiceNote: businessTerms.BT22_InvoiceNote,
|
||||||
|
|
||||||
|
// Processing metadata
|
||||||
|
status: 'validated',
|
||||||
|
|
||||||
|
// Storage (to be filled later)
|
||||||
|
contentHash: '',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validationResult,
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
createdAt: new Date(),
|
||||||
|
createdBy: 'system',
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
metadata: {
|
||||||
|
importedAt: new Date(),
|
||||||
|
parserVersion: '5.1.4',
|
||||||
|
originalFormat: format
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map party information
|
||||||
|
*/
|
||||||
|
private mapParty(partyData: any): IInvoiceParty {
|
||||||
|
if (!partyData) {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
address: { countryCode: 'DE' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: partyData.BT29_SellerID || partyData.BT46_BuyerID || plugins.smartunique.shortId(),
|
||||||
|
name: partyData.BT27_SellerName || partyData.BT44_BuyerName || '',
|
||||||
|
address: {
|
||||||
|
street: partyData.BT35_SellerStreet || partyData.BT50_BuyerStreet,
|
||||||
|
city: partyData.BT37_SellerCity || partyData.BT52_BuyerCity,
|
||||||
|
postalCode: partyData.BT38_SellerPostalCode || partyData.BT53_BuyerPostalCode,
|
||||||
|
countryCode: partyData.BT40_SellerCountryCode || partyData.BT55_BuyerCountryCode || 'DE'
|
||||||
|
},
|
||||||
|
vatId: partyData.BT31_SellerVATID || partyData.BT48_BuyerVATID,
|
||||||
|
taxId: partyData.BT32_SellerTaxID || partyData.BT47_BuyerTaxID,
|
||||||
|
email: partyData.BT34_SellerEmail || partyData.BT49_BuyerEmail,
|
||||||
|
phone: partyData.BT33_SellerPhone,
|
||||||
|
bankAccount: this.mapBankAccount(partyData)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map bank account information
|
||||||
|
*/
|
||||||
|
private mapBankAccount(partyData: any): IInvoiceParty['bankAccount'] | undefined {
|
||||||
|
if (!partyData?.BT84_PaymentAccountID) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
iban: partyData.BT84_PaymentAccountID,
|
||||||
|
bic: partyData.BT86_PaymentServiceProviderID,
|
||||||
|
accountHolder: partyData.BT85_PaymentAccountName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map invoice lines
|
||||||
|
*/
|
||||||
|
private mapInvoiceLines(linesData: any[]): IInvoiceLine[] {
|
||||||
|
return linesData.map((line, index) => ({
|
||||||
|
lineNumber: index + 1,
|
||||||
|
description: line.BT154_ItemDescription || '',
|
||||||
|
quantity: parseFloat(line.BT129_Quantity || 1),
|
||||||
|
unitPrice: parseFloat(line.BT146_NetPrice || 0),
|
||||||
|
netAmount: parseFloat(line.BT131_LineNetAmount || 0),
|
||||||
|
vatCategory: this.mapVATCategory(line.BT151_ItemVATCategory, line.BT152_ItemVATRate),
|
||||||
|
vatAmount: parseFloat(line.lineVATAmount || 0),
|
||||||
|
grossAmount: parseFloat(line.BT131_LineNetAmount || 0) + parseFloat(line.lineVATAmount || 0),
|
||||||
|
productCode: line.BT155_ItemSellerID,
|
||||||
|
allowances: this.mapLineAllowancesCharges(line.BG27_LineAllowances || [], true),
|
||||||
|
charges: this.mapLineAllowancesCharges(line.BG28_LineCharges || [], false)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map VAT category
|
||||||
|
*/
|
||||||
|
private mapVATCategory(categoryCode: string, rate: string | number): IVATCategory {
|
||||||
|
const vatRate = typeof rate === 'string' ? parseFloat(rate) : rate;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: categoryCode || 'S',
|
||||||
|
rate: vatRate || 0,
|
||||||
|
exemptionReason: this.getExemptionReason(categoryCode)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exemption reason for VAT category
|
||||||
|
*/
|
||||||
|
private getExemptionReason(categoryCode: string): string | undefined {
|
||||||
|
const exemptionReasons: Record<string, string> = {
|
||||||
|
'E': 'Tax exempt',
|
||||||
|
'Z': 'Zero rated',
|
||||||
|
'AE': 'Reverse charge (§13b UStG)',
|
||||||
|
'K': 'Intra-EU supply',
|
||||||
|
'G': 'Export outside EU',
|
||||||
|
'O': 'Outside scope of tax',
|
||||||
|
'S': undefined // Standard rate, no exemption
|
||||||
|
};
|
||||||
|
|
||||||
|
return exemptionReasons[categoryCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map VAT breakdown
|
||||||
|
*/
|
||||||
|
private mapVATBreakdown(vatBreakdown: any[]): IInvoice['vatBreakdown'] {
|
||||||
|
return vatBreakdown.map(vat => ({
|
||||||
|
vatCategory: this.mapVATCategory(vat.BT118_VATCategory, vat.BT119_VATRate),
|
||||||
|
taxableAmount: parseFloat(vat.BT116_TaxableAmount || 0),
|
||||||
|
taxAmount: parseFloat(vat.BT117_TaxAmount || 0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map allowances and charges
|
||||||
|
*/
|
||||||
|
private mapAllowancesCharges(data: any[], isAllowance: boolean): IAllowanceCharge[] {
|
||||||
|
return data.map(item => ({
|
||||||
|
reason: item.BT97_AllowanceReason || item.BT104_ChargeReason || '',
|
||||||
|
amount: parseFloat(item.BT92_AllowanceAmount || item.BT99_ChargeAmount || 0),
|
||||||
|
percentage: item.BT94_AllowancePercentage || item.BT101_ChargePercentage,
|
||||||
|
vatCategory: item.BT95_AllowanceVATCategory || item.BT102_ChargeVATCategory
|
||||||
|
? this.mapVATCategory(
|
||||||
|
item.BT95_AllowanceVATCategory || item.BT102_ChargeVATCategory,
|
||||||
|
item.BT96_AllowanceVATRate || item.BT103_ChargeVATRate
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
vatAmount: parseFloat(item.allowanceVATAmount || item.chargeVATAmount || 0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map line-level allowances and charges
|
||||||
|
*/
|
||||||
|
private mapLineAllowancesCharges(data: any[], isAllowance: boolean): IAllowanceCharge[] {
|
||||||
|
return data.map(item => ({
|
||||||
|
reason: item.BT140_LineAllowanceReason || item.BT145_LineChargeReason || '',
|
||||||
|
amount: parseFloat(item.BT136_LineAllowanceAmount || item.BT141_LineChargeAmount || 0),
|
||||||
|
percentage: item.BT138_LineAllowancePercentage || item.BT143_LineChargePercentage
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map payment terms
|
||||||
|
*/
|
||||||
|
private mapPaymentTerms(businessTerms: any): IPaymentTerms | undefined {
|
||||||
|
if (!businessTerms.BT9_PaymentDueDate && !businessTerms.BT20_PaymentTerms) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentTerms: IPaymentTerms = {
|
||||||
|
dueDate: businessTerms.BT9_PaymentDueDate
|
||||||
|
? new Date(businessTerms.BT9_PaymentDueDate)
|
||||||
|
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Default 30 days
|
||||||
|
paymentTermsNote: businessTerms.BT20_PaymentTerms
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse skonto from payment terms note if present
|
||||||
|
if (businessTerms.BT20_PaymentTerms) {
|
||||||
|
paymentTerms.skonto = this.parseSkontoTerms(businessTerms.BT20_PaymentTerms);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paymentTerms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse skonto terms from payment terms text
|
||||||
|
*/
|
||||||
|
private parseSkontoTerms(paymentTermsText: string): IPaymentTerms['skonto'] {
|
||||||
|
const skontoTerms: IPaymentTerms['skonto'] = [];
|
||||||
|
|
||||||
|
// Common German skonto patterns:
|
||||||
|
// "2% Skonto bei Zahlung innerhalb von 10 Tagen"
|
||||||
|
// "3% bei Zahlung bis 8 Tage, 2% bis 14 Tage"
|
||||||
|
const skontoPattern = /(\d+(?:\.\d+)?)\s*%.*?(\d+)\s*(?:Tag|Day)/gi;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = skontoPattern.exec(paymentTermsText)) !== null) {
|
||||||
|
skontoTerms.push({
|
||||||
|
percentage: parseFloat(match[1]),
|
||||||
|
days: parseInt(match[2]),
|
||||||
|
baseAmount: 0 // To be calculated based on invoice amount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return skontoTerms.length > 0 ? skontoTerms : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map payment means
|
||||||
|
*/
|
||||||
|
private mapPaymentMeans(paymentInstructions: any): IInvoice['paymentMeans'] | undefined {
|
||||||
|
if (!paymentInstructions) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: paymentInstructions.BT81_PaymentMeansCode || '30', // 30 = Bank transfer
|
||||||
|
account: paymentInstructions.BT84_PaymentAccountID
|
||||||
|
? {
|
||||||
|
iban: paymentInstructions.BT84_PaymentAccountID,
|
||||||
|
bic: paymentInstructions.BT86_PaymentServiceProviderID,
|
||||||
|
accountHolder: paymentInstructions.BT85_PaymentAccountName
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify tax scenario based on invoice data
|
||||||
|
*/
|
||||||
|
private classifyTaxScenario(invoice: IInvoice): TTaxScenario {
|
||||||
|
const supplierCountry = invoice.supplier.address.countryCode;
|
||||||
|
const customerCountry = invoice.customer.address.countryCode;
|
||||||
|
const hasVAT = invoice.totalVATAmount > 0;
|
||||||
|
const vatCategories = invoice.vatBreakdown.map(vb => vb.vatCategory.code);
|
||||||
|
|
||||||
|
// Reverse charge
|
||||||
|
if (vatCategories.includes('AE')) {
|
||||||
|
return 'reverse_charge';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small business exemption
|
||||||
|
if (vatCategories.includes('E') && invoice.invoiceNote?.includes('§19')) {
|
||||||
|
return 'small_business';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export outside EU
|
||||||
|
if (vatCategories.includes('G') || (!this.isEUCountry(customerCountry) && supplierCountry === 'DE')) {
|
||||||
|
return 'export';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intra-EU transactions
|
||||||
|
if (supplierCountry !== customerCountry && this.isEUCountry(supplierCountry) && this.isEUCountry(customerCountry)) {
|
||||||
|
if (invoice.direction === 'outbound') {
|
||||||
|
return 'intra_eu_supply';
|
||||||
|
} else {
|
||||||
|
return 'intra_eu_acquisition';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domestic exempt
|
||||||
|
if (!hasVAT && supplierCountry === 'DE' && customerCountry === 'DE') {
|
||||||
|
return 'domestic_exempt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: Domestic taxed
|
||||||
|
return 'domestic_taxed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if country is in EU
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map e-invoice format from library format
|
||||||
|
*/
|
||||||
|
private mapEInvoiceFormat(format: string): TInvoiceFormat {
|
||||||
|
const formatMap: Record<string, TInvoiceFormat> = {
|
||||||
|
'xrechnung': 'xrechnung',
|
||||||
|
'zugferd': 'zugferd',
|
||||||
|
'factur-x': 'facturx',
|
||||||
|
'facturx': 'facturx',
|
||||||
|
'peppol': 'peppol',
|
||||||
|
'ubl': 'ubl'
|
||||||
|
};
|
||||||
|
|
||||||
|
return formatMap[format.toLowerCase()] || 'xrechnung';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate content hash for the invoice
|
||||||
|
*/
|
||||||
|
private async calculateContentHash(xmlContent: string): Promise<string> {
|
||||||
|
const hash = await plugins.smarthash.sha256FromString(xmlContent);
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert invoice to different format
|
||||||
|
*/
|
||||||
|
public async convertFormat(
|
||||||
|
invoice: IInvoice,
|
||||||
|
targetFormat: TInvoiceFormat
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Load from existing XML
|
||||||
|
const einvoice = await plugins.einvoice.EInvoice.fromXml(invoice.xmlContent!);
|
||||||
|
|
||||||
|
// Convert to target format (takes ~0.6ms)
|
||||||
|
const convertedXml = await einvoice.exportXml(targetFormat as any);
|
||||||
|
|
||||||
|
return convertedXml;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to convert invoice format: ${error}`);
|
||||||
|
throw new Error(`Format conversion failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate invoice from internal data
|
||||||
|
*/
|
||||||
|
public async generateInvoice(
|
||||||
|
invoiceData: Partial<IInvoice>,
|
||||||
|
format: TInvoiceFormat
|
||||||
|
): Promise<{ xml: string; pdf?: Buffer }> {
|
||||||
|
try {
|
||||||
|
// Create a new invoice instance
|
||||||
|
const einvoice = new plugins.einvoice.EInvoice();
|
||||||
|
|
||||||
|
// Set invoice data
|
||||||
|
const businessTerms = this.mapToBusinessTerms(invoiceData);
|
||||||
|
Object.assign(einvoice, businessTerms);
|
||||||
|
|
||||||
|
// Generate XML in requested format
|
||||||
|
const xml = await einvoice.exportXml(format as any);
|
||||||
|
|
||||||
|
// Generate PDF if ZUGFeRD or Factur-X
|
||||||
|
let pdf: Buffer | undefined;
|
||||||
|
if (format === 'zugferd' || format === 'facturx') {
|
||||||
|
// Access the pdf property if it exists
|
||||||
|
if (einvoice.pdf && einvoice.pdf.buffer) {
|
||||||
|
pdf = Buffer.from(einvoice.pdf.buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { xml, pdf };
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to generate invoice: ${error}`);
|
||||||
|
throw new Error(`Invoice generation failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map internal invoice to EN16931 Business Terms
|
||||||
|
*/
|
||||||
|
private mapToBusinessTerms(invoice: Partial<IInvoice>): any {
|
||||||
|
return {
|
||||||
|
BT1_InvoiceNumber: invoice.invoiceNumber,
|
||||||
|
BT2_IssueDate: invoice.issueDate?.toISOString(),
|
||||||
|
BT3_InvoiceTypeCode: invoice.invoiceTypeCode || '380',
|
||||||
|
BT5_CurrencyCode: invoice.currencyCode || 'EUR',
|
||||||
|
BT7_TaxPointDate: invoice.taxPointDate?.toISOString(),
|
||||||
|
BT9_PaymentDueDate: invoice.paymentDueDate?.toISOString(),
|
||||||
|
|
||||||
|
// Map other Business Terms...
|
||||||
|
// This would be a comprehensive mapping in production
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import { JournalEntry } from './skr.classes.journalentry.js';
|
||||||
|
import { SKRInvoiceMapper } from './skr.invoice.mapper.js';
|
||||||
|
import { suggestPostingKey } from './skr.postingkeys.js';
|
||||||
|
import type { TSKRType, IJournalEntry, IJournalEntryLine } from './skr.types.js';
|
||||||
|
import type {
|
||||||
|
IInvoice,
|
||||||
|
IInvoiceLine,
|
||||||
|
IBookingRules,
|
||||||
|
IBookingInfo,
|
||||||
|
TTaxScenario,
|
||||||
|
IPaymentInfo
|
||||||
|
} from './skr.invoice.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for booking an invoice
|
||||||
|
*/
|
||||||
|
export interface IBookingOptions {
|
||||||
|
autoBook?: boolean;
|
||||||
|
confidenceThreshold?: number;
|
||||||
|
bookingDate?: Date;
|
||||||
|
bookingReference?: string;
|
||||||
|
skipValidation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of booking an invoice
|
||||||
|
*/
|
||||||
|
export interface IBookingResult {
|
||||||
|
success: boolean;
|
||||||
|
journalEntry?: JournalEntry;
|
||||||
|
bookingInfo?: IBookingInfo;
|
||||||
|
confidence: number;
|
||||||
|
warnings?: string[];
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic booking engine for invoices
|
||||||
|
* Creates journal entries from invoice data based on SKR mapping rules
|
||||||
|
*/
|
||||||
|
export class InvoiceBookingEngine {
|
||||||
|
private logger: plugins.smartlog.ConsoleLog;
|
||||||
|
private skrType: TSKRType;
|
||||||
|
private mapper: SKRInvoiceMapper;
|
||||||
|
|
||||||
|
constructor(skrType: TSKRType) {
|
||||||
|
this.skrType = skrType;
|
||||||
|
this.mapper = new SKRInvoiceMapper(skrType);
|
||||||
|
this.logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Book an invoice to the ledger
|
||||||
|
*/
|
||||||
|
public async bookInvoice(
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules?: Partial<IBookingRules>,
|
||||||
|
options?: IBookingOptions
|
||||||
|
): Promise<IBookingResult> {
|
||||||
|
try {
|
||||||
|
// Get complete booking rules
|
||||||
|
const rules = this.mapper.mapInvoiceToSKR(invoice, bookingRules);
|
||||||
|
|
||||||
|
// Calculate confidence
|
||||||
|
const confidence = this.mapper.calculateConfidence(invoice, rules);
|
||||||
|
|
||||||
|
// Check if auto-booking is allowed
|
||||||
|
if (options?.autoBook && confidence < (options.confidenceThreshold || 80)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
confidence,
|
||||||
|
warnings: [`Confidence score ${confidence}% is below threshold ${options.confidenceThreshold || 80}%`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate invoice before booking
|
||||||
|
if (!options?.skipValidation) {
|
||||||
|
const validationErrors = this.validateInvoice(invoice);
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
confidence,
|
||||||
|
errors: validationErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build journal entry
|
||||||
|
const journalEntry = await this.buildJournalEntry(invoice, rules, options);
|
||||||
|
|
||||||
|
// Create booking info
|
||||||
|
const bookingInfo: IBookingInfo = {
|
||||||
|
journalEntryId: journalEntry.id,
|
||||||
|
transactionIds: journalEntry.transactionIds || [],
|
||||||
|
bookedAt: new Date(),
|
||||||
|
bookedBy: 'system',
|
||||||
|
bookingRules: {
|
||||||
|
vendorAccount: rules.vendorControlAccount,
|
||||||
|
customerAccount: rules.customerControlAccount,
|
||||||
|
expenseAccounts: this.getUsedExpenseAccounts(invoice, rules),
|
||||||
|
revenueAccounts: this.getUsedRevenueAccounts(invoice, rules),
|
||||||
|
vatAccounts: this.getUsedVATAccounts(invoice, rules)
|
||||||
|
},
|
||||||
|
confidence,
|
||||||
|
autoBooked: options?.autoBook || false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Post the journal entry
|
||||||
|
// TODO: When MongoDB transactions are available, wrap this in a transaction
|
||||||
|
// Example: await db.withTransaction(async (session) => { ... })
|
||||||
|
try {
|
||||||
|
await journalEntry.validate();
|
||||||
|
await journalEntry.post();
|
||||||
|
|
||||||
|
// Mark invoice as posted if we have a reference to it
|
||||||
|
if (invoice.status !== 'posted') {
|
||||||
|
invoice.status = 'posted';
|
||||||
|
}
|
||||||
|
} catch (postError) {
|
||||||
|
this.logger.log('error', `Failed to post journal entry: ${postError}`);
|
||||||
|
throw postError; // Re-throw to trigger rollback when transactions are available
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
journalEntry,
|
||||||
|
bookingInfo,
|
||||||
|
confidence,
|
||||||
|
warnings: this.generateWarnings(invoice, rules)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to book invoice: ${error}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
confidence: 0,
|
||||||
|
errors: [`Booking failed: ${error.message}`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build journal entry from invoice
|
||||||
|
*/
|
||||||
|
private async buildJournalEntry(
|
||||||
|
invoice: IInvoice,
|
||||||
|
rules: IBookingRules,
|
||||||
|
options?: IBookingOptions
|
||||||
|
): Promise<JournalEntry> {
|
||||||
|
const lines: IJournalEntryLine[] = [];
|
||||||
|
const isInbound = invoice.direction === 'inbound';
|
||||||
|
const isCredit = invoice.invoiceTypeCode === '381'; // Credit note
|
||||||
|
|
||||||
|
// Determine if we need to reverse the normal booking direction
|
||||||
|
const reverseDirection = isCredit;
|
||||||
|
|
||||||
|
if (isInbound) {
|
||||||
|
// Inbound invoice (AP)
|
||||||
|
lines.push(...this.buildAPEntry(invoice, rules, reverseDirection));
|
||||||
|
} else {
|
||||||
|
// Outbound invoice (AR)
|
||||||
|
lines.push(...this.buildAREntry(invoice, rules, reverseDirection));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create journal entry
|
||||||
|
const journalData: IJournalEntry = {
|
||||||
|
date: options?.bookingDate || invoice.issueDate,
|
||||||
|
description: this.buildDescription(invoice),
|
||||||
|
reference: options?.bookingReference || invoice.invoiceNumber,
|
||||||
|
lines,
|
||||||
|
skrType: this.skrType
|
||||||
|
};
|
||||||
|
|
||||||
|
const journalEntry = new JournalEntry(journalData);
|
||||||
|
return journalEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build AP (Accounts Payable) journal entry lines
|
||||||
|
*/
|
||||||
|
private buildAPEntry(
|
||||||
|
invoice: IInvoice,
|
||||||
|
rules: IBookingRules,
|
||||||
|
reverseDirection: boolean
|
||||||
|
): IJournalEntryLine[] {
|
||||||
|
const lines: IJournalEntryLine[] = [];
|
||||||
|
|
||||||
|
// Group lines by account
|
||||||
|
const accountGroups = this.groupLinesByAccount(invoice, rules);
|
||||||
|
|
||||||
|
// Create expense/asset entries
|
||||||
|
for (const [accountNumber, group] of Object.entries(accountGroups)) {
|
||||||
|
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
|
||||||
|
|
||||||
|
if (reverseDirection) {
|
||||||
|
// Credit note: credit expense account
|
||||||
|
lines.push({
|
||||||
|
accountNumber,
|
||||||
|
credit: Math.abs(amount),
|
||||||
|
description: this.getAccountDescription(accountNumber, group),
|
||||||
|
postingKey: 9 // 19% input VAT for expenses
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regular invoice: debit expense account
|
||||||
|
lines.push({
|
||||||
|
accountNumber,
|
||||||
|
debit: Math.abs(amount),
|
||||||
|
description: this.getAccountDescription(accountNumber, group),
|
||||||
|
postingKey: 9 // 19% input VAT for expenses
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create VAT entries
|
||||||
|
const vatLines = this.buildVATLines(invoice, rules, 'input', reverseDirection);
|
||||||
|
lines.push(...vatLines);
|
||||||
|
|
||||||
|
// Create vendor control account entry
|
||||||
|
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
||||||
|
const totalAmount = Math.abs(invoice.payableAmount);
|
||||||
|
|
||||||
|
if (reverseDirection) {
|
||||||
|
// Credit note: debit vendor account
|
||||||
|
lines.push({
|
||||||
|
accountNumber: controlAccount,
|
||||||
|
debit: totalAmount,
|
||||||
|
description: `${invoice.supplier.name} - Credit Note ${invoice.invoiceNumber}`,
|
||||||
|
postingKey: 40 // Tax-free for control account
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regular invoice: credit vendor account
|
||||||
|
lines.push({
|
||||||
|
accountNumber: controlAccount,
|
||||||
|
credit: totalAmount,
|
||||||
|
description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}`,
|
||||||
|
postingKey: 40 // Tax-free for control account
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build AR (Accounts Receivable) journal entry lines
|
||||||
|
*/
|
||||||
|
private buildAREntry(
|
||||||
|
invoice: IInvoice,
|
||||||
|
rules: IBookingRules,
|
||||||
|
reverseDirection: boolean
|
||||||
|
): IJournalEntryLine[] {
|
||||||
|
const lines: IJournalEntryLine[] = [];
|
||||||
|
|
||||||
|
// Group lines by account
|
||||||
|
const accountGroups = this.groupLinesByAccount(invoice, rules);
|
||||||
|
|
||||||
|
// Create revenue entries
|
||||||
|
for (const [accountNumber, group] of Object.entries(accountGroups)) {
|
||||||
|
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
|
||||||
|
|
||||||
|
if (reverseDirection) {
|
||||||
|
// Credit note: debit revenue account
|
||||||
|
lines.push({
|
||||||
|
accountNumber,
|
||||||
|
debit: Math.abs(amount),
|
||||||
|
description: this.getAccountDescription(accountNumber, group),
|
||||||
|
postingKey: 9 // 19% output VAT for revenue
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regular invoice: credit revenue account
|
||||||
|
lines.push({
|
||||||
|
accountNumber,
|
||||||
|
credit: Math.abs(amount),
|
||||||
|
description: this.getAccountDescription(accountNumber, group),
|
||||||
|
postingKey: 9 // 19% output VAT for revenue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create VAT entries
|
||||||
|
const vatLines = this.buildVATLines(invoice, rules, 'output', reverseDirection);
|
||||||
|
lines.push(...vatLines);
|
||||||
|
|
||||||
|
// Create customer control account entry
|
||||||
|
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
||||||
|
const totalAmount = Math.abs(invoice.payableAmount);
|
||||||
|
|
||||||
|
if (reverseDirection) {
|
||||||
|
// Credit note: credit customer account
|
||||||
|
lines.push({
|
||||||
|
accountNumber: controlAccount,
|
||||||
|
credit: totalAmount,
|
||||||
|
description: `${invoice.customer.name} - Credit Note ${invoice.invoiceNumber}`,
|
||||||
|
postingKey: 40 // Tax-free for control account
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Regular invoice: debit customer account
|
||||||
|
lines.push({
|
||||||
|
accountNumber: controlAccount,
|
||||||
|
debit: totalAmount,
|
||||||
|
description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}`,
|
||||||
|
postingKey: 40 // Tax-free for control account
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build VAT lines
|
||||||
|
*/
|
||||||
|
private buildVATLines(
|
||||||
|
invoice: IInvoice,
|
||||||
|
rules: IBookingRules,
|
||||||
|
direction: 'input' | 'output',
|
||||||
|
reverseDirection: boolean
|
||||||
|
): IJournalEntryLine[] {
|
||||||
|
const lines: IJournalEntryLine[] = [];
|
||||||
|
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
||||||
|
|
||||||
|
// Handle reverse charge specially
|
||||||
|
if (taxScenario === 'reverse_charge') {
|
||||||
|
return this.buildReverseChargeVATLines(invoice, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard VAT booking
|
||||||
|
for (const vatBreak of invoice.vatBreakdown) {
|
||||||
|
if (vatBreak.taxAmount === 0) continue;
|
||||||
|
|
||||||
|
const vatAccount = this.mapper.getVATAccount(
|
||||||
|
vatBreak.vatCategory,
|
||||||
|
direction,
|
||||||
|
taxScenario
|
||||||
|
);
|
||||||
|
|
||||||
|
const amount = Math.abs(vatBreak.taxAmount);
|
||||||
|
const description = `VAT ${vatBreak.vatCategory.rate}%`;
|
||||||
|
const vatRate = vatBreak.vatCategory.rate;
|
||||||
|
// Select posting key based on VAT rate: 8 for 7%, 9 for 19%
|
||||||
|
const postingKey = vatRate === 7 ? 8 : 9;
|
||||||
|
|
||||||
|
if (direction === 'input') {
|
||||||
|
// Input VAT (Vorsteuer)
|
||||||
|
if (reverseDirection) {
|
||||||
|
lines.push({ accountNumber: vatAccount, credit: amount, description, postingKey });
|
||||||
|
} else {
|
||||||
|
lines.push({ accountNumber: vatAccount, debit: amount, description, postingKey });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Output VAT (Umsatzsteuer)
|
||||||
|
if (reverseDirection) {
|
||||||
|
lines.push({ accountNumber: vatAccount, debit: amount, description, postingKey });
|
||||||
|
} else {
|
||||||
|
lines.push({ accountNumber: vatAccount, credit: amount, description, postingKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate VAT amount from taxable amount and rate
|
||||||
|
*/
|
||||||
|
private calculateVAT(taxableAmount: number, rate: number): number {
|
||||||
|
return Math.round(taxableAmount * rate / 100 * 100) / 100; // Round to 2 decimals
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate effective VAT rate for the invoice (weighted average)
|
||||||
|
*/
|
||||||
|
private calculateEffectiveVATRate(invoice: IInvoice): number {
|
||||||
|
const totalTaxable = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxableAmount, 0);
|
||||||
|
if (totalTaxable === 0) {
|
||||||
|
return 19; // Default to standard German VAT rate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate weighted average VAT rate
|
||||||
|
const weightedRate = invoice.vatBreakdown.reduce((sum, vb) => {
|
||||||
|
return sum + (vb.vatCategory.rate * vb.taxableAmount);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return Math.round(weightedRate / totalTaxable * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build reverse charge VAT lines (§13b UStG)
|
||||||
|
*/
|
||||||
|
private buildReverseChargeVATLines(
|
||||||
|
invoice: IInvoice,
|
||||||
|
rules: IBookingRules
|
||||||
|
): IJournalEntryLine[] {
|
||||||
|
const lines: IJournalEntryLine[] = [];
|
||||||
|
|
||||||
|
// For reverse charge, we book both input and output VAT
|
||||||
|
for (const vatBreak of invoice.vatBreakdown) {
|
||||||
|
// For reverse charge, calculate VAT if not provided
|
||||||
|
const amount = vatBreak.taxAmount > 0
|
||||||
|
? Math.abs(vatBreak.taxAmount)
|
||||||
|
: this.calculateVAT(Math.abs(vatBreak.taxableAmount), vatBreak.vatCategory.rate);
|
||||||
|
|
||||||
|
// Input VAT (deductible)
|
||||||
|
const inputVATAccount = this.mapper.getVATAccount(
|
||||||
|
vatBreak.vatCategory,
|
||||||
|
'input',
|
||||||
|
'reverse_charge'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Output VAT (payable)
|
||||||
|
const outputVATAccount = this.mapper.getVATAccount(
|
||||||
|
vatBreak.vatCategory,
|
||||||
|
'output',
|
||||||
|
'reverse_charge'
|
||||||
|
);
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
{
|
||||||
|
accountNumber: inputVATAccount,
|
||||||
|
debit: amount,
|
||||||
|
description: `Reverse charge input VAT ${vatBreak.vatCategory.rate}%`,
|
||||||
|
postingKey: 94 // Reverse charge posting key
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountNumber: outputVATAccount,
|
||||||
|
credit: amount,
|
||||||
|
description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%`,
|
||||||
|
postingKey: 94 // Reverse charge posting key
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group invoice lines by account
|
||||||
|
*/
|
||||||
|
private groupLinesByAccount(
|
||||||
|
invoice: IInvoice,
|
||||||
|
rules: IBookingRules
|
||||||
|
): Record<string, IInvoiceLine[]> {
|
||||||
|
const groups: Record<string, IInvoiceLine[]> = {};
|
||||||
|
|
||||||
|
for (const line of invoice.lines) {
|
||||||
|
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
||||||
|
|
||||||
|
if (!groups[account]) {
|
||||||
|
groups[account] = [];
|
||||||
|
}
|
||||||
|
groups[account].push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Book payment for an invoice
|
||||||
|
*/
|
||||||
|
public async bookPayment(
|
||||||
|
invoice: IInvoice,
|
||||||
|
payment: IPaymentInfo,
|
||||||
|
rules: IBookingRules
|
||||||
|
): Promise<IBookingResult> {
|
||||||
|
try {
|
||||||
|
const lines: IJournalEntryLine[] = [];
|
||||||
|
const isInbound = invoice.direction === 'inbound';
|
||||||
|
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
||||||
|
|
||||||
|
// Check for skonto
|
||||||
|
const skontoAmount = payment.skontoTaken || 0;
|
||||||
|
const paymentAmount = payment.amount;
|
||||||
|
const fullAmount = paymentAmount + skontoAmount;
|
||||||
|
|
||||||
|
if (isInbound) {
|
||||||
|
// Payment for vendor invoice
|
||||||
|
lines.push(
|
||||||
|
{
|
||||||
|
accountNumber: controlAccount,
|
||||||
|
debit: fullAmount,
|
||||||
|
description: `Payment to ${invoice.supplier.name}`,
|
||||||
|
postingKey: 3 // Payment with VAT
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountNumber: '1000', // Bank account (would be configurable)
|
||||||
|
credit: paymentAmount,
|
||||||
|
description: `Bank payment ${payment.endToEndId || payment.paymentId}`,
|
||||||
|
postingKey: 40 // Tax-free for bank account
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Book skonto if taken
|
||||||
|
if (skontoAmount > 0) {
|
||||||
|
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
||||||
|
lines.push({
|
||||||
|
accountNumber: skontoAccounts.skontoAccount,
|
||||||
|
credit: skontoAmount,
|
||||||
|
description: `Skonto received`,
|
||||||
|
postingKey: 40 // Tax-free for skonto
|
||||||
|
});
|
||||||
|
|
||||||
|
// VAT correction for skonto
|
||||||
|
if (rules.skontoMethod === 'gross') {
|
||||||
|
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
||||||
|
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
|
||||||
|
lines.push(
|
||||||
|
{
|
||||||
|
accountNumber: skontoAccounts.vatCorrectionAccount,
|
||||||
|
credit: vatCorrection,
|
||||||
|
description: `Skonto VAT correction`,
|
||||||
|
postingKey: 40 // Tax-free for correction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Payment from customer
|
||||||
|
lines.push(
|
||||||
|
{
|
||||||
|
accountNumber: '1000', // Bank account
|
||||||
|
debit: paymentAmount,
|
||||||
|
description: `Payment from ${invoice.customer.name}`,
|
||||||
|
postingKey: 40 // Tax-free for bank account
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountNumber: controlAccount,
|
||||||
|
credit: fullAmount,
|
||||||
|
description: `Customer payment ${payment.endToEndId || payment.paymentId}`,
|
||||||
|
postingKey: 3 // Payment with VAT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Book skonto if granted
|
||||||
|
if (skontoAmount > 0) {
|
||||||
|
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
||||||
|
lines.push({
|
||||||
|
accountNumber: skontoAccounts.skontoAccount,
|
||||||
|
debit: skontoAmount,
|
||||||
|
description: `Skonto granted`,
|
||||||
|
postingKey: 40 // Tax-free for skonto
|
||||||
|
});
|
||||||
|
|
||||||
|
// VAT correction for skonto
|
||||||
|
if (rules.skontoMethod === 'gross') {
|
||||||
|
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
||||||
|
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
|
||||||
|
lines.push(
|
||||||
|
{
|
||||||
|
accountNumber: skontoAccounts.vatCorrectionAccount,
|
||||||
|
debit: vatCorrection,
|
||||||
|
description: `Skonto VAT correction`,
|
||||||
|
postingKey: 40 // Tax-free for correction
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create journal entry for payment
|
||||||
|
const journalData: IJournalEntry = {
|
||||||
|
date: payment.paymentDate,
|
||||||
|
description: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||||
|
reference: payment.endToEndId || payment.remittanceInfo || payment.paymentId,
|
||||||
|
lines,
|
||||||
|
skrType: this.skrType
|
||||||
|
};
|
||||||
|
|
||||||
|
const journalEntry = new JournalEntry(journalData);
|
||||||
|
await journalEntry.validate();
|
||||||
|
await journalEntry.post();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
journalEntry,
|
||||||
|
confidence: 100
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to book payment: ${error}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
confidence: 0,
|
||||||
|
errors: [`Payment booking failed: ${error.message}`]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate invoice before booking
|
||||||
|
*/
|
||||||
|
private validateInvoice(invoice: IInvoice): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if (!invoice.invoiceNumber) {
|
||||||
|
errors.push('Invoice number is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice.issueDate) {
|
||||||
|
errors.push('Issue date is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice.supplier || !invoice.supplier.name) {
|
||||||
|
errors.push('Supplier information is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice.customer || !invoice.customer.name) {
|
||||||
|
errors.push('Customer information is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.lines.length === 0) {
|
||||||
|
errors.push('Invoice must have at least one line item');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate amounts
|
||||||
|
const calculatedNet = invoice.lines.reduce((sum, line) => sum + line.netAmount, 0);
|
||||||
|
const tolerance = 0.01;
|
||||||
|
|
||||||
|
if (Math.abs(calculatedNet - invoice.lineNetAmount) > tolerance) {
|
||||||
|
errors.push(`Line net amount mismatch: calculated ${calculatedNet}, stated ${invoice.lineNetAmount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate VAT
|
||||||
|
const calculatedVAT = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxAmount, 0);
|
||||||
|
if (Math.abs(calculatedVAT - invoice.totalVATAmount) > tolerance) {
|
||||||
|
errors.push(`VAT amount mismatch: calculated ${calculatedVAT}, stated ${invoice.totalVATAmount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate total
|
||||||
|
const calculatedTotal = invoice.taxExclusiveAmount + invoice.totalVATAmount;
|
||||||
|
if (Math.abs(calculatedTotal - invoice.taxInclusiveAmount) > tolerance) {
|
||||||
|
errors.push(`Total amount mismatch: calculated ${calculatedTotal}, stated ${invoice.taxInclusiveAmount}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate warnings for the booking
|
||||||
|
*/
|
||||||
|
private generateWarnings(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Warn about default account usage
|
||||||
|
const hasDefaultAccounts = invoice.lines.some(line =>
|
||||||
|
!line.accountNumber && !line.productCode
|
||||||
|
);
|
||||||
|
if (hasDefaultAccounts) {
|
||||||
|
warnings.push('Some lines are using default expense/revenue accounts');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about mixed VAT rates
|
||||||
|
if (invoice.vatBreakdown.length > 1) {
|
||||||
|
warnings.push('Invoice contains mixed VAT rates');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about reverse charge
|
||||||
|
if (invoice.taxScenario === 'reverse_charge') {
|
||||||
|
warnings.push('Reverse charge procedure applied - verify VAT treatment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about credit notes
|
||||||
|
if (invoice.invoiceTypeCode === '381') {
|
||||||
|
warnings.push('This is a credit note - amounts will be reversed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn about foreign currency
|
||||||
|
if (invoice.currencyCode !== 'EUR') {
|
||||||
|
warnings.push(`Invoice is in foreign currency: ${invoice.currencyCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build description for journal entry
|
||||||
|
*/
|
||||||
|
private buildDescription(invoice: IInvoice): string {
|
||||||
|
const type = invoice.invoiceTypeCode === '381' ? 'Credit Note' : 'Invoice';
|
||||||
|
const party = invoice.direction === 'inbound'
|
||||||
|
? invoice.supplier.name
|
||||||
|
: invoice.customer.name;
|
||||||
|
|
||||||
|
return `${type} ${invoice.invoiceNumber} - ${party}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account description for a group of lines
|
||||||
|
*/
|
||||||
|
private getAccountDescription(accountNumber: string, lines: IInvoiceLine[]): string {
|
||||||
|
if (lines.length === 1) {
|
||||||
|
return lines[0].description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.mapper.getAccountDescription(accountNumber)} (${lines.length} items)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get used expense accounts
|
||||||
|
*/
|
||||||
|
private getUsedExpenseAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||||
|
if (invoice.direction !== 'inbound') return [];
|
||||||
|
|
||||||
|
const accounts = new Set<string>();
|
||||||
|
for (const line of invoice.lines) {
|
||||||
|
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
||||||
|
accounts.add(account);
|
||||||
|
}
|
||||||
|
return Array.from(accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get used revenue accounts
|
||||||
|
*/
|
||||||
|
private getUsedRevenueAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||||
|
if (invoice.direction !== 'outbound') return [];
|
||||||
|
|
||||||
|
const accounts = new Set<string>();
|
||||||
|
for (const line of invoice.lines) {
|
||||||
|
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
||||||
|
accounts.add(account);
|
||||||
|
}
|
||||||
|
return Array.from(accounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get used VAT accounts
|
||||||
|
*/
|
||||||
|
private getUsedVATAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||||
|
const accounts = new Set<string>();
|
||||||
|
const direction = invoice.direction === 'inbound' ? 'input' : 'output';
|
||||||
|
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
||||||
|
|
||||||
|
for (const vatBreak of invoice.vatBreakdown) {
|
||||||
|
const account = this.mapper.getVATAccount(
|
||||||
|
vatBreak.vatCategory,
|
||||||
|
direction,
|
||||||
|
taxScenario
|
||||||
|
);
|
||||||
|
accounts.add(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add reverse charge accounts if applicable
|
||||||
|
if (taxScenario === 'reverse_charge') {
|
||||||
|
for (const vatBreak of invoice.vatBreakdown) {
|
||||||
|
const inputAccount = this.mapper.getVATAccount(
|
||||||
|
vatBreak.vatCategory,
|
||||||
|
'input',
|
||||||
|
'reverse_charge'
|
||||||
|
);
|
||||||
|
const outputAccount = this.mapper.getVATAccount(
|
||||||
|
vatBreak.vatCategory,
|
||||||
|
'output',
|
||||||
|
'reverse_charge'
|
||||||
|
);
|
||||||
|
accounts.add(inputAccount);
|
||||||
|
accounts.add(outputAccount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(accounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
import type { TSKRType } from './skr.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice direction
|
||||||
|
*/
|
||||||
|
export type TInvoiceDirection = 'inbound' | 'outbound';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported e-invoice formats
|
||||||
|
*/
|
||||||
|
export type TInvoiceFormat = 'xrechnung' | 'zugferd' | 'facturx' | 'peppol' | 'ubl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice status in the system
|
||||||
|
*/
|
||||||
|
export type TInvoiceStatus = 'draft' | 'validated' | 'posted' | 'partially_paid' | 'paid' | 'cancelled' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tax scenario classification
|
||||||
|
*/
|
||||||
|
export type TTaxScenario =
|
||||||
|
| 'domestic_taxed' // Standard domestic with VAT
|
||||||
|
| 'domestic_exempt' // Domestic tax-exempt
|
||||||
|
| 'reverse_charge' // §13b UStG
|
||||||
|
| 'intra_eu_supply' // Intra-EU supply
|
||||||
|
| 'intra_eu_acquisition' // Intra-EU acquisition
|
||||||
|
| 'export' // Export outside EU
|
||||||
|
| 'small_business'; // §19 UStG small business
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VAT rate categories
|
||||||
|
*/
|
||||||
|
export interface IVATCategory {
|
||||||
|
code: string; // S (Standard), Z (Zero), E (Exempt), AE (Reverse charge), etc.
|
||||||
|
rate: number; // Tax rate percentage
|
||||||
|
exemptionReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Party information (supplier/customer)
|
||||||
|
*/
|
||||||
|
export interface IInvoiceParty {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: {
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
countryCode: string;
|
||||||
|
};
|
||||||
|
vatId?: string;
|
||||||
|
taxId?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
bankAccount?: {
|
||||||
|
iban: string;
|
||||||
|
bic?: string;
|
||||||
|
accountHolder?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice line item
|
||||||
|
*/
|
||||||
|
export interface IInvoiceLine {
|
||||||
|
lineNumber: number;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unitPrice: number;
|
||||||
|
netAmount: number;
|
||||||
|
vatCategory: IVATCategory;
|
||||||
|
vatAmount: number;
|
||||||
|
grossAmount: number;
|
||||||
|
accountNumber?: string; // SKR account for booking
|
||||||
|
costCenter?: string;
|
||||||
|
productCode?: string;
|
||||||
|
allowances?: IAllowanceCharge[];
|
||||||
|
charges?: IAllowanceCharge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowance or charge
|
||||||
|
*/
|
||||||
|
export interface IAllowanceCharge {
|
||||||
|
reason: string;
|
||||||
|
amount: number;
|
||||||
|
percentage?: number;
|
||||||
|
vatCategory?: IVATCategory;
|
||||||
|
vatAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment terms
|
||||||
|
*/
|
||||||
|
export interface IPaymentTerms {
|
||||||
|
dueDate: Date;
|
||||||
|
paymentTermsNote?: string;
|
||||||
|
skonto?: {
|
||||||
|
percentage: number;
|
||||||
|
days: number;
|
||||||
|
baseAmount: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface IValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
syntax: {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
semantic: {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
businessRules: {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
countrySpecific?: {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
validatedAt: Date;
|
||||||
|
validatorVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking information
|
||||||
|
*/
|
||||||
|
export interface IBookingInfo {
|
||||||
|
journalEntryId: string;
|
||||||
|
transactionIds: string[];
|
||||||
|
bookedAt: Date;
|
||||||
|
bookedBy: string;
|
||||||
|
bookingRules: {
|
||||||
|
vendorAccount?: string;
|
||||||
|
customerAccount?: string;
|
||||||
|
expenseAccounts?: string[];
|
||||||
|
revenueAccounts?: string[];
|
||||||
|
vatAccounts?: string[];
|
||||||
|
};
|
||||||
|
confidence: number; // 0-100
|
||||||
|
autoBooked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment information
|
||||||
|
*/
|
||||||
|
export interface IPaymentInfo {
|
||||||
|
paymentId: string;
|
||||||
|
paymentDate: Date;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
bankTransactionId?: string;
|
||||||
|
endToEndId?: string;
|
||||||
|
remittanceInfo?: string;
|
||||||
|
skontoTaken?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main invoice entity
|
||||||
|
*/
|
||||||
|
export interface IInvoice {
|
||||||
|
// Identity
|
||||||
|
id: string;
|
||||||
|
direction: TInvoiceDirection;
|
||||||
|
format: TInvoiceFormat;
|
||||||
|
|
||||||
|
// EN16931 Business Terms
|
||||||
|
invoiceNumber: string; // BT-1
|
||||||
|
issueDate: Date; // BT-2
|
||||||
|
invoiceTypeCode?: string; // BT-3 (380=Invoice, 381=Credit note)
|
||||||
|
currencyCode: string; // BT-5
|
||||||
|
taxCurrencyCode?: string; // BT-6
|
||||||
|
taxPointDate?: Date; // BT-7 (Leistungsdatum)
|
||||||
|
paymentDueDate?: Date; // BT-9
|
||||||
|
buyerReference?: string; // BT-10
|
||||||
|
projectReference?: string; // BT-11
|
||||||
|
contractReference?: string; // BT-12
|
||||||
|
orderReference?: string; // BT-13
|
||||||
|
sellerOrderReference?: string; // BT-14
|
||||||
|
|
||||||
|
// Parties
|
||||||
|
supplier: IInvoiceParty;
|
||||||
|
customer: IInvoiceParty;
|
||||||
|
payee?: IInvoiceParty; // If different from supplier
|
||||||
|
|
||||||
|
// Line items
|
||||||
|
lines: IInvoiceLine[];
|
||||||
|
|
||||||
|
// Document level allowances/charges
|
||||||
|
allowances?: IAllowanceCharge[];
|
||||||
|
charges?: IAllowanceCharge[];
|
||||||
|
|
||||||
|
// Amounts
|
||||||
|
lineNetAmount: number; // Sum of line net amounts
|
||||||
|
allowanceTotalAmount?: number;
|
||||||
|
chargeTotalAmount?: number;
|
||||||
|
taxExclusiveAmount: number; // BT-109
|
||||||
|
taxInclusiveAmount: number; // BT-112
|
||||||
|
prepaidAmount?: number; // BT-113
|
||||||
|
payableAmount: number; // BT-115
|
||||||
|
|
||||||
|
// VAT breakdown
|
||||||
|
vatBreakdown: {
|
||||||
|
vatCategory: IVATCategory;
|
||||||
|
taxableAmount: number; // BT-116
|
||||||
|
taxAmount: number; // BT-117
|
||||||
|
}[];
|
||||||
|
totalVATAmount: number; // BT-110
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
paymentTerms?: IPaymentTerms;
|
||||||
|
paymentMeans?: {
|
||||||
|
code: string; // 30=Bank transfer, 48=Card, etc.
|
||||||
|
account?: IInvoiceParty['bankAccount'];
|
||||||
|
};
|
||||||
|
payments?: IPaymentInfo[];
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
invoiceNote?: string; // BT-22
|
||||||
|
|
||||||
|
// Processing metadata
|
||||||
|
status: TInvoiceStatus;
|
||||||
|
taxScenario?: TTaxScenario;
|
||||||
|
skrType?: TSKRType;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
contentHash: string; // SHA-256 of normalized XML
|
||||||
|
xmlContent?: string;
|
||||||
|
pdfHash?: string;
|
||||||
|
pdfContent?: Buffer;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validationResult?: IValidationResult;
|
||||||
|
|
||||||
|
// Booking
|
||||||
|
bookingInfo?: IBookingInfo;
|
||||||
|
|
||||||
|
// Audit trail
|
||||||
|
createdAt: Date;
|
||||||
|
createdBy: string;
|
||||||
|
modifiedAt?: Date;
|
||||||
|
modifiedBy?: string;
|
||||||
|
|
||||||
|
// Additional metadata
|
||||||
|
metadata?: {
|
||||||
|
importSource?: string;
|
||||||
|
importedAt?: Date;
|
||||||
|
parserVersion?: string;
|
||||||
|
originalFilename?: string;
|
||||||
|
originalFormat?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice import options
|
||||||
|
*/
|
||||||
|
export interface IInvoiceImportOptions {
|
||||||
|
autoBook?: boolean;
|
||||||
|
confidenceThreshold?: number;
|
||||||
|
validateOnly?: boolean;
|
||||||
|
skipDuplicateCheck?: boolean;
|
||||||
|
bookingRules?: {
|
||||||
|
vendorDefaults?: Record<string, string>;
|
||||||
|
customerDefaults?: Record<string, string>;
|
||||||
|
productCategoryMapping?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice export options
|
||||||
|
*/
|
||||||
|
export interface IInvoiceExportOptions {
|
||||||
|
format: TInvoiceFormat;
|
||||||
|
embedInPdf?: boolean;
|
||||||
|
sign?: boolean;
|
||||||
|
validate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice search filter
|
||||||
|
*/
|
||||||
|
export interface IInvoiceFilter {
|
||||||
|
direction?: TInvoiceDirection;
|
||||||
|
status?: TInvoiceStatus;
|
||||||
|
format?: TInvoiceFormat;
|
||||||
|
dateFrom?: Date;
|
||||||
|
dateTo?: Date;
|
||||||
|
supplierId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
reference?: string;
|
||||||
|
isPaid?: boolean;
|
||||||
|
isOverdue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate check result
|
||||||
|
*/
|
||||||
|
export interface IDuplicateCheckResult {
|
||||||
|
isDuplicate: boolean;
|
||||||
|
matchedInvoiceId?: string;
|
||||||
|
matchedContentHash?: string;
|
||||||
|
matchedFields?: string[];
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking rules configuration
|
||||||
|
*/
|
||||||
|
export interface IBookingRules {
|
||||||
|
skrType: TSKRType;
|
||||||
|
|
||||||
|
// Control accounts
|
||||||
|
vendorControlAccount: string;
|
||||||
|
customerControlAccount: string;
|
||||||
|
|
||||||
|
// VAT accounts
|
||||||
|
vatAccounts: {
|
||||||
|
inputVAT19: string;
|
||||||
|
inputVAT7: string;
|
||||||
|
outputVAT19: string;
|
||||||
|
outputVAT7: string;
|
||||||
|
reverseChargeVAT: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default accounts
|
||||||
|
defaultExpenseAccount: string;
|
||||||
|
defaultRevenueAccount: string;
|
||||||
|
|
||||||
|
// Mappings
|
||||||
|
productCategoryMapping?: Record<string, string>;
|
||||||
|
vendorMapping?: Record<string, string>;
|
||||||
|
customerMapping?: Record<string, string>;
|
||||||
|
|
||||||
|
// Skonto
|
||||||
|
skontoMethod?: 'net' | 'gross';
|
||||||
|
skontoExpenseAccount?: string;
|
||||||
|
skontoRevenueAccount?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import type { TSKRType } from './skr.types.js';
|
||||||
|
import type {
|
||||||
|
IInvoice,
|
||||||
|
IInvoiceLine,
|
||||||
|
IBookingRules,
|
||||||
|
TTaxScenario,
|
||||||
|
IVATCategory
|
||||||
|
} from './skr.invoice.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps invoice data to SKR accounts
|
||||||
|
* Handles both SKR03 and SKR04 account mappings
|
||||||
|
*/
|
||||||
|
export class SKRInvoiceMapper {
|
||||||
|
private logger: plugins.smartlog.ConsoleLog;
|
||||||
|
private skrType: TSKRType;
|
||||||
|
|
||||||
|
// SKR03 account mappings
|
||||||
|
private readonly SKR03_ACCOUNTS = {
|
||||||
|
// Control accounts
|
||||||
|
vendorControl: '1600', // Verbindlichkeiten aus Lieferungen und Leistungen
|
||||||
|
customerControl: '1200', // Forderungen aus Lieferungen und Leistungen
|
||||||
|
|
||||||
|
// VAT accounts
|
||||||
|
inputVAT19: '1576', // Abziehbare Vorsteuer 19%
|
||||||
|
inputVAT7: '1571', // Abziehbare Vorsteuer 7%
|
||||||
|
outputVAT19: '1776', // Umsatzsteuer 19%
|
||||||
|
outputVAT7: '1771', // Umsatzsteuer 7%
|
||||||
|
reverseChargeVAT: '1577', // Abziehbare Vorsteuer §13b UStG
|
||||||
|
reverseChargePayable: '1787', // Umsatzsteuer §13b UStG
|
||||||
|
|
||||||
|
// Default expense/revenue accounts
|
||||||
|
defaultExpense: '4610', // Werbekosten
|
||||||
|
defaultRevenue: '8400', // Erlöse 19% USt
|
||||||
|
revenueReduced: '8300', // Erlöse 7% USt
|
||||||
|
revenueTaxFree: '8120', // Steuerfreie Umsätze
|
||||||
|
|
||||||
|
// Common expense accounts by category
|
||||||
|
materialExpense: '5000', // Aufwendungen für Roh-, Hilfs- und Betriebsstoffe
|
||||||
|
merchandiseExpense: '5400', // Aufwendungen für Waren
|
||||||
|
personnelExpense: '6000', // Löhne und Gehälter
|
||||||
|
rentExpense: '4200', // Miete
|
||||||
|
officeExpense: '4930', // Bürobedarf
|
||||||
|
travelExpense: '4670', // Reisekosten
|
||||||
|
vehicleExpense: '4530', // Kfz-Kosten
|
||||||
|
|
||||||
|
// Skonto accounts
|
||||||
|
skontoExpense: '4736', // Erhaltene Skonti 19% USt
|
||||||
|
skontoRevenue: '8736', // Gewährte Skonti 19% USt
|
||||||
|
|
||||||
|
// Intra-EU accounts
|
||||||
|
intraEUAcquisition: '8125', // Steuerfreie innergemeinschaftliche Erwerbe
|
||||||
|
intraEUSupply: '8125' // Steuerfreie innergemeinschaftliche Lieferungen
|
||||||
|
};
|
||||||
|
|
||||||
|
// SKR04 account mappings
|
||||||
|
private readonly SKR04_ACCOUNTS = {
|
||||||
|
// Control accounts
|
||||||
|
vendorControl: '3300', // Verbindlichkeiten aus Lieferungen und Leistungen
|
||||||
|
customerControl: '1400', // Forderungen aus Lieferungen und Leistungen
|
||||||
|
|
||||||
|
// VAT accounts
|
||||||
|
inputVAT19: '1406', // Abziehbare Vorsteuer 19%
|
||||||
|
inputVAT7: '1401', // Abziehbare Vorsteuer 7%
|
||||||
|
outputVAT19: '3806', // Umsatzsteuer 19%
|
||||||
|
outputVAT7: '3801', // Umsatzsteuer 7%
|
||||||
|
reverseChargeVAT: '1407', // Abziehbare Vorsteuer §13b UStG
|
||||||
|
reverseChargePayable: '3837', // Umsatzsteuer §13b UStG
|
||||||
|
|
||||||
|
// Default expense/revenue accounts
|
||||||
|
defaultExpense: '6300', // Sonstige betriebliche Aufwendungen
|
||||||
|
defaultRevenue: '4400', // Erlöse 19% USt
|
||||||
|
revenueReduced: '4300', // Erlöse 7% USt
|
||||||
|
revenueTaxFree: '4120', // Steuerfreie Umsätze
|
||||||
|
|
||||||
|
// Common expense accounts by category
|
||||||
|
materialExpense: '5000', // Aufwendungen für Roh-, Hilfs- und Betriebsstoffe
|
||||||
|
merchandiseExpense: '5400', // Aufwendungen für Waren
|
||||||
|
personnelExpense: '6000', // Löhne
|
||||||
|
rentExpense: '6310', // Miete
|
||||||
|
officeExpense: '6815', // Bürobedarf
|
||||||
|
travelExpense: '6670', // Reisekosten
|
||||||
|
vehicleExpense: '6530', // Kfz-Kosten
|
||||||
|
|
||||||
|
// Skonto accounts
|
||||||
|
skontoExpense: '4736', // Erhaltene Skonti 19% USt
|
||||||
|
skontoRevenue: '8736', // Gewährte Skonti 19% USt
|
||||||
|
|
||||||
|
// Intra-EU accounts
|
||||||
|
intraEUAcquisition: '4125', // Steuerfreie innergemeinschaftliche Erwerbe
|
||||||
|
intraEUSupply: '4125' // Steuerfreie innergemeinschaftliche Lieferungen
|
||||||
|
};
|
||||||
|
|
||||||
|
// Product category to account mappings
|
||||||
|
private readonly CATEGORY_MAPPINGS: Record<string, { skr03: string; skr04: string }> = {
|
||||||
|
'MATERIAL': { skr03: '5000', skr04: '5000' },
|
||||||
|
'MERCHANDISE': { skr03: '5400', skr04: '5400' },
|
||||||
|
'SERVICE': { skr03: '4610', skr04: '6300' },
|
||||||
|
'OFFICE': { skr03: '4930', skr04: '6815' },
|
||||||
|
'IT': { skr03: '4940', skr04: '6825' },
|
||||||
|
'TRAVEL': { skr03: '4670', skr04: '6670' },
|
||||||
|
'VEHICLE': { skr03: '4530', skr04: '6530' },
|
||||||
|
'RENT': { skr03: '4200', skr04: '6310' },
|
||||||
|
'UTILITIES': { skr03: '4240', skr04: '6320' },
|
||||||
|
'INSURANCE': { skr03: '4360', skr04: '6420' },
|
||||||
|
'MARKETING': { skr03: '4610', skr04: '6600' },
|
||||||
|
'CONSULTING': { skr03: '4640', skr04: '6650' },
|
||||||
|
'LEGAL': { skr03: '4790', skr04: '6790' },
|
||||||
|
'TELECOMMUNICATION': { skr03: '4920', skr04: '6805' }
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(skrType: TSKRType) {
|
||||||
|
this.skrType = skrType;
|
||||||
|
this.logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account mappings for current SKR type
|
||||||
|
*/
|
||||||
|
private getAccounts() {
|
||||||
|
return this.skrType === 'SKR03' ? this.SKR03_ACCOUNTS : this.SKR04_ACCOUNTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map invoice to booking rules
|
||||||
|
*/
|
||||||
|
public mapInvoiceToSKR(
|
||||||
|
invoice: IInvoice,
|
||||||
|
customMappings?: Partial<IBookingRules>
|
||||||
|
): IBookingRules {
|
||||||
|
const accounts = this.getAccounts();
|
||||||
|
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
||||||
|
|
||||||
|
// Base booking rules
|
||||||
|
const bookingRules: IBookingRules = {
|
||||||
|
skrType: this.skrType,
|
||||||
|
|
||||||
|
// Control accounts
|
||||||
|
vendorControlAccount: customMappings?.vendorControlAccount || accounts.vendorControl,
|
||||||
|
customerControlAccount: customMappings?.customerControlAccount || accounts.customerControl,
|
||||||
|
|
||||||
|
// VAT accounts
|
||||||
|
vatAccounts: {
|
||||||
|
inputVAT19: accounts.inputVAT19,
|
||||||
|
inputVAT7: accounts.inputVAT7,
|
||||||
|
outputVAT19: accounts.outputVAT19,
|
||||||
|
outputVAT7: accounts.outputVAT7,
|
||||||
|
reverseChargeVAT: accounts.reverseChargeVAT
|
||||||
|
},
|
||||||
|
|
||||||
|
// Default accounts
|
||||||
|
defaultExpenseAccount: accounts.defaultExpense,
|
||||||
|
defaultRevenueAccount: accounts.defaultRevenue,
|
||||||
|
|
||||||
|
// Skonto
|
||||||
|
skontoMethod: customMappings?.skontoMethod || 'gross',
|
||||||
|
skontoExpenseAccount: accounts.skontoExpense,
|
||||||
|
skontoRevenueAccount: accounts.skontoRevenue,
|
||||||
|
|
||||||
|
// Custom mappings
|
||||||
|
productCategoryMapping: customMappings?.productCategoryMapping || {},
|
||||||
|
vendorMapping: customMappings?.vendorMapping || {},
|
||||||
|
customerMapping: customMappings?.customerMapping || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return bookingRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map invoice line to SKR account
|
||||||
|
*/
|
||||||
|
public mapInvoiceLineToAccount(
|
||||||
|
line: IInvoiceLine,
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules: IBookingRules
|
||||||
|
): string {
|
||||||
|
// Check if account is already specified
|
||||||
|
if (line.accountNumber) {
|
||||||
|
return line.accountNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For revenue (outbound invoices)
|
||||||
|
if (invoice.direction === 'outbound') {
|
||||||
|
return this.mapRevenueAccount(line, invoice, bookingRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For expenses (inbound invoices)
|
||||||
|
return this.mapExpenseAccount(line, invoice, bookingRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map revenue account based on VAT rate and scenario
|
||||||
|
*/
|
||||||
|
private mapRevenueAccount(
|
||||||
|
line: IInvoiceLine,
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules: IBookingRules
|
||||||
|
): string {
|
||||||
|
const accounts = this.getAccounts();
|
||||||
|
const vatRate = line.vatCategory.rate;
|
||||||
|
|
||||||
|
// Check tax scenario
|
||||||
|
switch (invoice.taxScenario) {
|
||||||
|
case 'intra_eu_supply':
|
||||||
|
return accounts.intraEUSupply;
|
||||||
|
case 'export':
|
||||||
|
case 'domestic_exempt':
|
||||||
|
return accounts.revenueTaxFree;
|
||||||
|
case 'domestic_taxed':
|
||||||
|
default:
|
||||||
|
// Map by VAT rate
|
||||||
|
if (vatRate === 19) {
|
||||||
|
return accounts.defaultRevenue;
|
||||||
|
} else if (vatRate === 7) {
|
||||||
|
return accounts.revenueReduced;
|
||||||
|
} else if (vatRate === 0) {
|
||||||
|
return accounts.revenueTaxFree;
|
||||||
|
}
|
||||||
|
return accounts.defaultRevenue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map expense account based on product category and vendor
|
||||||
|
*/
|
||||||
|
private mapExpenseAccount(
|
||||||
|
line: IInvoiceLine,
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules: IBookingRules
|
||||||
|
): string {
|
||||||
|
const accounts = this.getAccounts();
|
||||||
|
|
||||||
|
// Check vendor-specific mapping
|
||||||
|
const vendorId = invoice.supplier.id;
|
||||||
|
if (bookingRules.vendorMapping && bookingRules.vendorMapping[vendorId]) {
|
||||||
|
return bookingRules.vendorMapping[vendorId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to determine category from line description
|
||||||
|
const category = this.detectProductCategory(line.description);
|
||||||
|
if (category) {
|
||||||
|
const mapping = this.CATEGORY_MAPPINGS[category];
|
||||||
|
if (mapping) {
|
||||||
|
return this.skrType === 'SKR03' ? mapping.skr03 : mapping.skr04;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check product category mapping
|
||||||
|
if (line.productCode && bookingRules.productCategoryMapping) {
|
||||||
|
const mappedAccount = bookingRules.productCategoryMapping[line.productCode];
|
||||||
|
if (mappedAccount) {
|
||||||
|
return mappedAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default expense account
|
||||||
|
return bookingRules.defaultExpenseAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect product category from description
|
||||||
|
*/
|
||||||
|
private detectProductCategory(description: string): string | undefined {
|
||||||
|
const lowerDesc = description.toLowerCase();
|
||||||
|
|
||||||
|
const categoryKeywords: Record<string, string[]> = {
|
||||||
|
'MATERIAL': ['material', 'rohstoff', 'raw material', 'component'],
|
||||||
|
'MERCHANDISE': ['ware', 'merchandise', 'product', 'artikel'],
|
||||||
|
'SERVICE': ['service', 'dienstleistung', 'beratung', 'support'],
|
||||||
|
'OFFICE': ['büro', 'office', 'papier', 'stationery'],
|
||||||
|
'IT': ['software', 'hardware', 'computer', 'lizenz', 'license'],
|
||||||
|
'TRAVEL': ['reise', 'travel', 'hotel', 'flug', 'flight'],
|
||||||
|
'VEHICLE': ['kfz', 'vehicle', 'auto', 'benzin', 'fuel'],
|
||||||
|
'RENT': ['miete', 'rent', 'lease', 'pacht'],
|
||||||
|
'UTILITIES': ['strom', 'wasser', 'gas', 'energie', 'electricity', 'water'],
|
||||||
|
'INSURANCE': ['versicherung', 'insurance'],
|
||||||
|
'MARKETING': ['werbung', 'marketing', 'advertising', 'kampagne'],
|
||||||
|
'CONSULTING': ['beratung', 'consulting', 'advisory'],
|
||||||
|
'LEGAL': ['rechts', 'legal', 'anwalt', 'lawyer', 'notar'],
|
||||||
|
'TELECOMMUNICATION': ['telefon', 'internet', 'mobilfunk', 'telekom']
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [category, keywords] of Object.entries(categoryKeywords)) {
|
||||||
|
if (keywords.some(keyword => lowerDesc.includes(keyword))) {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get VAT account for given VAT category and rate
|
||||||
|
*/
|
||||||
|
public getVATAccount(
|
||||||
|
vatCategory: IVATCategory,
|
||||||
|
direction: 'input' | 'output',
|
||||||
|
taxScenario: TTaxScenario
|
||||||
|
): string {
|
||||||
|
const accounts = this.getAccounts();
|
||||||
|
|
||||||
|
// Handle reverse charge
|
||||||
|
if (taxScenario === 'reverse_charge' || vatCategory.code === 'AE') {
|
||||||
|
return direction === 'input'
|
||||||
|
? accounts.reverseChargeVAT
|
||||||
|
: accounts.reverseChargePayable;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard VAT accounts by rate
|
||||||
|
if (direction === 'input') {
|
||||||
|
if (vatCategory.rate === 19) {
|
||||||
|
return accounts.inputVAT19;
|
||||||
|
} else if (vatCategory.rate === 7) {
|
||||||
|
return accounts.inputVAT7;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (vatCategory.rate === 19) {
|
||||||
|
return accounts.outputVAT19;
|
||||||
|
} else if (vatCategory.rate === 7) {
|
||||||
|
return accounts.outputVAT7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 19% if rate is not standard
|
||||||
|
return direction === 'input' ? accounts.inputVAT19 : accounts.outputVAT19;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get control account for party
|
||||||
|
*/
|
||||||
|
public getControlAccount(
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules: IBookingRules
|
||||||
|
): string {
|
||||||
|
if (invoice.direction === 'inbound') {
|
||||||
|
// Check vendor-specific control account
|
||||||
|
const vendorId = invoice.supplier.id;
|
||||||
|
if (bookingRules.vendorMapping && bookingRules.vendorMapping[vendorId]) {
|
||||||
|
const customAccount = bookingRules.vendorMapping[vendorId];
|
||||||
|
// Check if it's a control account (starts with 16 for SKR03 or 33 for SKR04)
|
||||||
|
if (this.isControlAccount(customAccount)) {
|
||||||
|
return customAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bookingRules.vendorControlAccount;
|
||||||
|
} else {
|
||||||
|
// Check customer-specific control account
|
||||||
|
const customerId = invoice.customer.id;
|
||||||
|
if (bookingRules.customerMapping && bookingRules.customerMapping[customerId]) {
|
||||||
|
const customAccount = bookingRules.customerMapping[customerId];
|
||||||
|
// Check if it's a control account (starts with 12 for SKR03 or 14 for SKR04)
|
||||||
|
if (this.isControlAccount(customAccount)) {
|
||||||
|
return customAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bookingRules.customerControlAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if account is a control account
|
||||||
|
*/
|
||||||
|
private isControlAccount(accountNumber: string): boolean {
|
||||||
|
if (this.skrType === 'SKR03') {
|
||||||
|
return accountNumber.startsWith('12') || accountNumber.startsWith('16');
|
||||||
|
} else {
|
||||||
|
return accountNumber.startsWith('14') || accountNumber.startsWith('33');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get skonto accounts
|
||||||
|
*/
|
||||||
|
public getSkontoAccounts(invoice: IInvoice): {
|
||||||
|
skontoAccount: string;
|
||||||
|
vatCorrectionAccount: string;
|
||||||
|
} {
|
||||||
|
const accounts = this.getAccounts();
|
||||||
|
|
||||||
|
if (invoice.direction === 'inbound') {
|
||||||
|
// Received skonto (expense reduction)
|
||||||
|
return {
|
||||||
|
skontoAccount: accounts.skontoExpense,
|
||||||
|
vatCorrectionAccount: accounts.inputVAT19 // VAT correction
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Granted skonto (revenue reduction)
|
||||||
|
return {
|
||||||
|
skontoAccount: accounts.skontoRevenue,
|
||||||
|
vatCorrectionAccount: accounts.outputVAT19 // VAT correction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate account number format
|
||||||
|
*/
|
||||||
|
public validateAccountNumber(accountNumber: string): boolean {
|
||||||
|
// SKR accounts are typically 4 digits, sometimes with sub-accounts
|
||||||
|
const accountPattern = /^\d{4}(\d{0,2})?$/;
|
||||||
|
return accountPattern.test(accountNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account description
|
||||||
|
*/
|
||||||
|
public getAccountDescription(accountNumber: string): string {
|
||||||
|
// This would typically look up from a complete SKR account database
|
||||||
|
// For now, return a basic description
|
||||||
|
const commonAccounts: Record<string, string> = {
|
||||||
|
// SKR03
|
||||||
|
'1200': 'Forderungen aus Lieferungen und Leistungen',
|
||||||
|
'1600': 'Verbindlichkeiten aus Lieferungen und Leistungen',
|
||||||
|
'1576': 'Abziehbare Vorsteuer 19%',
|
||||||
|
'1571': 'Abziehbare Vorsteuer 7%',
|
||||||
|
'1776': 'Umsatzsteuer 19%',
|
||||||
|
'1771': 'Umsatzsteuer 7%',
|
||||||
|
'4610': 'Werbekosten',
|
||||||
|
'8400': 'Erlöse 19% USt',
|
||||||
|
'8300': 'Erlöse 7% USt',
|
||||||
|
// SKR04
|
||||||
|
'1400': 'Forderungen aus Lieferungen und Leistungen',
|
||||||
|
'3300': 'Verbindlichkeiten aus Lieferungen und Leistungen',
|
||||||
|
'1406': 'Abziehbare Vorsteuer 19%',
|
||||||
|
'1401': 'Abziehbare Vorsteuer 7%',
|
||||||
|
'3806': 'Umsatzsteuer 19%',
|
||||||
|
'3801': 'Umsatzsteuer 7%',
|
||||||
|
'6300': 'Sonstige betriebliche Aufwendungen',
|
||||||
|
'4400': 'Erlöse 19% USt',
|
||||||
|
'4300': 'Erlöse 7% USt'
|
||||||
|
};
|
||||||
|
|
||||||
|
return commonAccounts[accountNumber] || `Account ${accountNumber}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate booking confidence score
|
||||||
|
*/
|
||||||
|
public calculateConfidence(
|
||||||
|
invoice: IInvoice,
|
||||||
|
bookingRules: IBookingRules
|
||||||
|
): number {
|
||||||
|
let confidence = 100;
|
||||||
|
|
||||||
|
// Reduce confidence for missing or uncertain mappings
|
||||||
|
invoice.lines.forEach(line => {
|
||||||
|
if (!line.accountNumber) {
|
||||||
|
confidence -= 10; // No explicit account mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.productCode) {
|
||||||
|
confidence -= 5; // No product code for mapping
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reduce confidence for complex tax scenarios
|
||||||
|
if (invoice.taxScenario === 'reverse_charge' ||
|
||||||
|
invoice.taxScenario === 'intra_eu_acquisition') {
|
||||||
|
confidence -= 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce confidence for mixed VAT rates
|
||||||
|
if (invoice.vatBreakdown.length > 1) {
|
||||||
|
confidence -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce confidence if no vendor/customer mapping exists
|
||||||
|
if (invoice.direction === 'inbound') {
|
||||||
|
if (!bookingRules.vendorMapping?.[invoice.supplier.id]) {
|
||||||
|
confidence -= 10;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!bookingRules.customerMapping?.[invoice.customer.id]) {
|
||||||
|
confidence -= 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce confidence for credit notes
|
||||||
|
if (invoice.invoiceTypeCode === '381') {
|
||||||
|
confidence -= 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, confidence);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import type {
|
||||||
|
IInvoice,
|
||||||
|
IInvoiceFilter,
|
||||||
|
IDuplicateCheckResult
|
||||||
|
} from './skr.invoice.entity.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice storage metadata
|
||||||
|
*/
|
||||||
|
export interface IInvoiceMetadata {
|
||||||
|
invoiceId: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
issueDate: string;
|
||||||
|
supplierName: string;
|
||||||
|
customerName: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
contentHash: string;
|
||||||
|
pdfHash?: string;
|
||||||
|
xmlHash: string;
|
||||||
|
journalEntryId?: string;
|
||||||
|
transactionIds?: string[];
|
||||||
|
validationResult: {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: number;
|
||||||
|
warnings: number;
|
||||||
|
};
|
||||||
|
parserVersion: string;
|
||||||
|
storedAt: string;
|
||||||
|
storedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice registry entry (for NDJSON streaming)
|
||||||
|
*/
|
||||||
|
export interface IInvoiceRegistryEntry {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
metadata: IInvoiceMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage statistics
|
||||||
|
*/
|
||||||
|
export interface IStorageStats {
|
||||||
|
totalInvoices: number;
|
||||||
|
inboundCount: number;
|
||||||
|
outboundCount: number;
|
||||||
|
totalSize: number;
|
||||||
|
duplicatesDetected: number;
|
||||||
|
lastUpdate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content-addressed storage for invoices
|
||||||
|
* Integrates with BagIt archive structure for GoBD compliance
|
||||||
|
*/
|
||||||
|
export class InvoiceStorage {
|
||||||
|
private exportPath: string;
|
||||||
|
private logger: plugins.smartlog.ConsoleLog;
|
||||||
|
private registryPath: string;
|
||||||
|
private metadataCache: Map<string, IInvoiceMetadata>;
|
||||||
|
private readonly MAX_CACHE_SIZE = 10000; // Maximum number of cached entries
|
||||||
|
private cacheAccessOrder: string[] = []; // Track access order for LRU eviction
|
||||||
|
|
||||||
|
constructor(exportPath: string) {
|
||||||
|
this.exportPath = exportPath;
|
||||||
|
this.logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
this.registryPath = path.join(exportPath, 'data', 'documents', 'invoices', 'registry.ndjson');
|
||||||
|
this.metadataCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage cache size using LRU eviction
|
||||||
|
*/
|
||||||
|
private manageCacheSize(): void {
|
||||||
|
if (this.metadataCache.size > this.MAX_CACHE_SIZE) {
|
||||||
|
// Remove least recently used entries
|
||||||
|
const entriesToRemove = Math.min(100, Math.floor(this.MAX_CACHE_SIZE * 0.1)); // Remove 10% or 100 entries
|
||||||
|
const keysToRemove = this.cacheAccessOrder.splice(0, entriesToRemove);
|
||||||
|
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
this.metadataCache.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Evicted ${entriesToRemove} entries from metadata cache`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update cache access order for LRU
|
||||||
|
*/
|
||||||
|
private touchCacheEntry(key: string): void {
|
||||||
|
const index = this.cacheAccessOrder.indexOf(key);
|
||||||
|
if (index > -1) {
|
||||||
|
this.cacheAccessOrder.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.cacheAccessOrder.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize storage directories
|
||||||
|
*/
|
||||||
|
public async initialize(): Promise<void> {
|
||||||
|
const dirs = [
|
||||||
|
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
|
||||||
|
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound', 'metadata'),
|
||||||
|
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound'),
|
||||||
|
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound', 'metadata'),
|
||||||
|
path.join(this.exportPath, 'data', 'validation')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
await plugins.smartfile.fs.ensureDir(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing registry if it exists
|
||||||
|
await this.loadRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an invoice with content addressing
|
||||||
|
*/
|
||||||
|
public async storeInvoice(
|
||||||
|
invoice: IInvoice,
|
||||||
|
pdfBuffer?: Buffer
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Validate PDF size if provided
|
||||||
|
if (pdfBuffer && pdfBuffer.length > this.MAX_PDF_SIZE) {
|
||||||
|
throw new Error(`PDF file too large: ${pdfBuffer.length} bytes (max ${this.MAX_PDF_SIZE} bytes)`);
|
||||||
|
}
|
||||||
|
// Calculate hashes
|
||||||
|
const xmlHash = await this.calculateHash(invoice.xmlContent || '');
|
||||||
|
const pdfHash = pdfBuffer ? await this.calculateHash(pdfBuffer) : undefined;
|
||||||
|
const contentHash = xmlHash; // Primary content hash is XML
|
||||||
|
|
||||||
|
// Check for duplicates
|
||||||
|
const duplicateCheck = await this.checkDuplicate(invoice, contentHash);
|
||||||
|
if (duplicateCheck.isDuplicate) {
|
||||||
|
this.logger.log('warn', `Duplicate invoice detected: ${invoice.invoiceNumber}`);
|
||||||
|
return duplicateCheck.matchedContentHash || contentHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine storage path
|
||||||
|
const direction = invoice.direction;
|
||||||
|
const basePath = path.join(
|
||||||
|
this.exportPath,
|
||||||
|
'data',
|
||||||
|
'documents',
|
||||||
|
'invoices',
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create filename with content hash
|
||||||
|
const dateStr = invoice.issueDate.toISOString().split('T')[0];
|
||||||
|
const sanitizedNumber = invoice.invoiceNumber.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||||
|
const xmlFilename = `${contentHash.substring(0, 8)}_${dateStr}_${sanitizedNumber}.xml`;
|
||||||
|
const xmlPath = path.join(basePath, xmlFilename);
|
||||||
|
|
||||||
|
// Store XML
|
||||||
|
await plugins.smartfile.memory.toFs(invoice.xmlContent || '', xmlPath);
|
||||||
|
|
||||||
|
// Store PDF if available
|
||||||
|
let pdfFilename: string | undefined;
|
||||||
|
if (pdfBuffer) {
|
||||||
|
pdfFilename = xmlFilename.replace('.xml', '.pdf');
|
||||||
|
const pdfPath = path.join(basePath, pdfFilename);
|
||||||
|
await plugins.smartfile.memory.toFs(pdfBuffer, pdfPath);
|
||||||
|
|
||||||
|
// Also store PDF/A-3 with embedded XML if supported
|
||||||
|
if (invoice.format === 'zugferd' || invoice.format === 'facturx') {
|
||||||
|
const pdfA3Filename = xmlFilename.replace('.xml', '_pdfa3.pdf');
|
||||||
|
const pdfA3Path = path.join(basePath, pdfA3Filename);
|
||||||
|
// The PDF should already have embedded XML if it's ZUGFeRD/Factur-X
|
||||||
|
await plugins.smartfile.memory.toFs(pdfBuffer, pdfA3Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and store metadata
|
||||||
|
const metadata: IInvoiceMetadata = {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
direction: invoice.direction,
|
||||||
|
issueDate: invoice.issueDate.toISOString(),
|
||||||
|
supplierName: invoice.supplier.name,
|
||||||
|
customerName: invoice.customer.name,
|
||||||
|
totalAmount: invoice.payableAmount,
|
||||||
|
currency: invoice.currencyCode,
|
||||||
|
contentHash,
|
||||||
|
pdfHash,
|
||||||
|
xmlHash,
|
||||||
|
journalEntryId: invoice.bookingInfo?.journalEntryId,
|
||||||
|
transactionIds: invoice.bookingInfo?.transactionIds,
|
||||||
|
validationResult: {
|
||||||
|
isValid: invoice.validationResult?.isValid || false,
|
||||||
|
errors: this.countErrors(invoice.validationResult),
|
||||||
|
warnings: this.countWarnings(invoice.validationResult)
|
||||||
|
},
|
||||||
|
parserVersion: invoice.metadata?.parserVersion || '5.1.4',
|
||||||
|
storedAt: new Date().toISOString(),
|
||||||
|
storedBy: invoice.createdBy
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadataPath = path.join(basePath, 'metadata', `${contentHash}.json`);
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(metadata, null, 2),
|
||||||
|
metadataPath
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update registry
|
||||||
|
await this.updateRegistry(invoice.id, contentHash, metadata);
|
||||||
|
|
||||||
|
// Cache metadata with LRU management
|
||||||
|
this.setCacheEntry(contentHash, metadata);
|
||||||
|
|
||||||
|
this.logger.log('info', `Invoice stored: ${invoice.invoiceNumber} (${contentHash})`);
|
||||||
|
|
||||||
|
return contentHash;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to store invoice: ${error}`);
|
||||||
|
throw new Error(`Invoice storage failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve an invoice by content hash
|
||||||
|
*/
|
||||||
|
public async retrieveInvoice(contentHash: string): Promise<IInvoice | null> {
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const metadata = this.getCacheEntry(contentHash);
|
||||||
|
if (!metadata) {
|
||||||
|
this.logger.log('warn', `Invoice not found: ${contentHash}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load XML content
|
||||||
|
const xmlPath = await this.findInvoiceFile(contentHash, '.xml');
|
||||||
|
if (!xmlPath) {
|
||||||
|
throw new Error(`XML file not found for invoice ${contentHash}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xmlContent = await plugins.smartfile.fs.toStringSync(xmlPath);
|
||||||
|
|
||||||
|
// Load PDF if exists
|
||||||
|
let pdfContent: Buffer | undefined;
|
||||||
|
const pdfPath = await this.findInvoiceFile(contentHash, '.pdf');
|
||||||
|
if (pdfPath) {
|
||||||
|
pdfContent = await plugins.smartfile.fs.toBuffer(pdfPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct invoice object (partial)
|
||||||
|
const invoice: Partial<IInvoice> = {
|
||||||
|
id: metadata.invoiceId,
|
||||||
|
invoiceNumber: metadata.invoiceNumber,
|
||||||
|
direction: metadata.direction as any,
|
||||||
|
issueDate: new Date(metadata.issueDate),
|
||||||
|
supplier: {
|
||||||
|
name: metadata.supplierName,
|
||||||
|
id: '',
|
||||||
|
address: { countryCode: 'DE' }
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
name: metadata.customerName,
|
||||||
|
id: '',
|
||||||
|
address: { countryCode: 'DE' }
|
||||||
|
},
|
||||||
|
payableAmount: metadata.totalAmount,
|
||||||
|
currencyCode: metadata.currency,
|
||||||
|
contentHash: metadata.contentHash,
|
||||||
|
xmlContent,
|
||||||
|
pdfContent,
|
||||||
|
pdfHash: metadata.pdfHash
|
||||||
|
};
|
||||||
|
|
||||||
|
return invoice as IInvoice;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to retrieve invoice: ${error}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for duplicate invoices
|
||||||
|
*/
|
||||||
|
public async checkDuplicate(
|
||||||
|
invoice: IInvoice,
|
||||||
|
contentHash: string
|
||||||
|
): Promise<IDuplicateCheckResult> {
|
||||||
|
// Check by content hash (exact match)
|
||||||
|
const existing = this.getCacheEntry(contentHash);
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
matchedInvoiceId: existing.invoiceId,
|
||||||
|
matchedContentHash: contentHash,
|
||||||
|
matchedFields: ['contentHash'],
|
||||||
|
confidence: 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check by invoice number and supplier/customer
|
||||||
|
for (const [hash, metadata] of this.metadataCache.entries()) {
|
||||||
|
if (
|
||||||
|
metadata.invoiceNumber === invoice.invoiceNumber &&
|
||||||
|
metadata.direction === invoice.direction
|
||||||
|
) {
|
||||||
|
// Same invoice number and direction
|
||||||
|
if (invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) {
|
||||||
|
// Same supplier
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
matchedInvoiceId: metadata.invoiceId,
|
||||||
|
matchedContentHash: hash,
|
||||||
|
matchedFields: ['invoiceNumber', 'supplier'],
|
||||||
|
confidence: 95
|
||||||
|
};
|
||||||
|
} else if (invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name) {
|
||||||
|
// Same customer
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
matchedInvoiceId: metadata.invoiceId,
|
||||||
|
matchedContentHash: hash,
|
||||||
|
matchedFields: ['invoiceNumber', 'customer'],
|
||||||
|
confidence: 95
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check by amount and date within tolerance
|
||||||
|
const dateTolerance = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
const amountTolerance = 0.01;
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(metadata.totalAmount - invoice.payableAmount) < amountTolerance &&
|
||||||
|
Math.abs(new Date(metadata.issueDate).getTime() - invoice.issueDate.getTime()) < dateTolerance &&
|
||||||
|
metadata.direction === invoice.direction
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
(invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) ||
|
||||||
|
(invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
isDuplicate: true,
|
||||||
|
matchedInvoiceId: metadata.invoiceId,
|
||||||
|
matchedContentHash: hash,
|
||||||
|
matchedFields: ['amount', 'date', 'party'],
|
||||||
|
confidence: 85
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDuplicate: false,
|
||||||
|
confidence: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search invoices by filter
|
||||||
|
*/
|
||||||
|
public async searchInvoices(filter: IInvoiceFilter): Promise<IInvoiceMetadata[]> {
|
||||||
|
const results: IInvoiceMetadata[] = [];
|
||||||
|
|
||||||
|
for (const metadata of this.metadataCache.values()) {
|
||||||
|
if (this.matchesFilter(metadata, filter)) {
|
||||||
|
results.push(metadata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending
|
||||||
|
results.sort((a, b) =>
|
||||||
|
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage statistics
|
||||||
|
*/
|
||||||
|
public async getStatistics(): Promise<IStorageStats> {
|
||||||
|
let totalSize = 0;
|
||||||
|
let inboundCount = 0;
|
||||||
|
let outboundCount = 0;
|
||||||
|
|
||||||
|
for (const metadata of this.metadataCache.values()) {
|
||||||
|
if (metadata.direction === 'inbound') {
|
||||||
|
inboundCount++;
|
||||||
|
} else {
|
||||||
|
outboundCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate size (would need actual file sizes in production)
|
||||||
|
totalSize += 50000; // Rough estimate
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalInvoices: this.metadataCache.size,
|
||||||
|
inboundCount,
|
||||||
|
outboundCount,
|
||||||
|
totalSize,
|
||||||
|
duplicatesDetected: 0, // Would track this in production
|
||||||
|
lastUpdate: new Date()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create EN16931 compliance report
|
||||||
|
*/
|
||||||
|
public async createComplianceReport(): Promise<void> {
|
||||||
|
const report = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
totalInvoices: this.metadataCache.size,
|
||||||
|
validInvoices: 0,
|
||||||
|
invalidInvoices: 0,
|
||||||
|
warnings: 0,
|
||||||
|
byFormat: {} as Record<string, number>,
|
||||||
|
byDirection: {
|
||||||
|
inbound: 0,
|
||||||
|
outbound: 0
|
||||||
|
},
|
||||||
|
validationErrors: [] as string[],
|
||||||
|
complianceLevel: 'EN16931',
|
||||||
|
validatorVersion: '5.1.4'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const metadata of this.metadataCache.values()) {
|
||||||
|
if (metadata.validationResult.isValid) {
|
||||||
|
report.validInvoices++;
|
||||||
|
} else {
|
||||||
|
report.invalidInvoices++;
|
||||||
|
}
|
||||||
|
|
||||||
|
report.warnings += metadata.validationResult.warnings;
|
||||||
|
|
||||||
|
if (metadata.direction === 'inbound') {
|
||||||
|
report.byDirection.inbound++;
|
||||||
|
} else {
|
||||||
|
report.byDirection.outbound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportPath = path.join(
|
||||||
|
this.exportPath,
|
||||||
|
'data',
|
||||||
|
'validation',
|
||||||
|
'en16931_compliance.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(report, null, 2),
|
||||||
|
reportPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load registry from disk
|
||||||
|
*/
|
||||||
|
private async loadRegistry(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await plugins.smartfile.fs.fileExists(this.registryPath)) {
|
||||||
|
const content = await plugins.smartfile.fs.toStringSync(this.registryPath);
|
||||||
|
const lines = content.split('\n').filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry: IInvoiceRegistryEntry = JSON.parse(line);
|
||||||
|
this.setCacheEntry(entry.hash, entry.metadata);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.log('warn', `Invalid registry entry: ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Loaded ${this.metadataCache.size} invoices from registry`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to load registry: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update registry with new entry
|
||||||
|
*/
|
||||||
|
private async updateRegistry(
|
||||||
|
invoiceId: string,
|
||||||
|
contentHash: string,
|
||||||
|
metadata: IInvoiceMetadata
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry: IInvoiceRegistryEntry = {
|
||||||
|
id: invoiceId,
|
||||||
|
hash: contentHash,
|
||||||
|
metadata
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append to NDJSON file
|
||||||
|
const line = JSON.stringify(entry) + '\n';
|
||||||
|
await plugins.smartfile.fs.ensureDir(path.dirname(this.registryPath));
|
||||||
|
|
||||||
|
// Use native fs for atomic append (better performance and concurrency safety)
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
await fs.appendFile(this.registryPath, line, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to update registry: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find invoice file by hash and extension
|
||||||
|
*/
|
||||||
|
private async findInvoiceFile(
|
||||||
|
contentHash: string,
|
||||||
|
extension: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const dirs = [
|
||||||
|
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
|
||||||
|
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
const files = await plugins.smartfile.fs.listFileTree(dir, '**/*' + extension);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.includes(contentHash.substring(0, 8))) {
|
||||||
|
return path.join(dir, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate SHA-256 hash
|
||||||
|
*/
|
||||||
|
private async calculateHash(data: string | Buffer): Promise<string> {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
return await plugins.smarthash.sha256FromString(data);
|
||||||
|
} else {
|
||||||
|
return await plugins.smarthash.sha256FromBuffer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if metadata matches filter
|
||||||
|
*/
|
||||||
|
private matchesFilter(metadata: IInvoiceMetadata, filter: IInvoiceFilter): boolean {
|
||||||
|
if (filter.direction && metadata.direction !== filter.direction) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.dateFrom && new Date(metadata.issueDate) < filter.dateFrom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.dateTo && new Date(metadata.issueDate) > filter.dateTo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.minAmount && metadata.totalAmount < filter.minAmount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.maxAmount && metadata.totalAmount > filter.maxAmount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.invoiceNumber && !metadata.invoiceNumber.includes(filter.invoiceNumber)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.supplierId && !metadata.supplierName.includes(filter.supplierId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.customerId && !metadata.customerName.includes(filter.customerId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count errors in validation result
|
||||||
|
*/
|
||||||
|
private countErrors(validationResult?: IInvoice['validationResult']): number {
|
||||||
|
if (!validationResult) return 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
validationResult.syntax.errors.length +
|
||||||
|
validationResult.semantic.errors.length +
|
||||||
|
validationResult.businessRules.errors.length +
|
||||||
|
(validationResult.countrySpecific?.errors.length || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count warnings in validation result
|
||||||
|
*/
|
||||||
|
private countWarnings(validationResult?: IInvoice['validationResult']): number {
|
||||||
|
if (!validationResult) return 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
validationResult.syntax.warnings.length +
|
||||||
|
validationResult.semantic.warnings.length +
|
||||||
|
validationResult.businessRules.warnings.length +
|
||||||
|
(validationResult.countrySpecific?.warnings.length || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old invoices (for testing only)
|
||||||
|
*/
|
||||||
|
public async cleanup(olderThanDays: number = 365): Promise<number> {
|
||||||
|
let removed = 0;
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||||
|
|
||||||
|
for (const [hash, metadata] of this.metadataCache.entries()) {
|
||||||
|
if (new Date(metadata.issueDate) < cutoffDate) {
|
||||||
|
this.metadataCache.delete(hash);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('info', `Removed ${removed} old invoices from cache`);
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set cache entry with LRU eviction
|
||||||
|
*/
|
||||||
|
private setCacheEntry(key: string, value: IInvoiceMetadata): void {
|
||||||
|
// Remove from access order if already exists
|
||||||
|
const existingIndex = this.cacheAccessOrder.indexOf(key);
|
||||||
|
if (existingIndex > -1) {
|
||||||
|
this.cacheAccessOrder.splice(existingIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to end (most recently used)
|
||||||
|
this.cacheAccessOrder.push(key);
|
||||||
|
this.metadataCache.set(key, value);
|
||||||
|
|
||||||
|
// Evict oldest if cache is too large
|
||||||
|
while (this.metadataCache.size > this.MAX_CACHE_SIZE) {
|
||||||
|
const oldestKey = this.cacheAccessOrder.shift();
|
||||||
|
if (oldestKey) {
|
||||||
|
this.metadataCache.delete(oldestKey);
|
||||||
|
this.logger.log('debug', `Evicted invoice from cache: ${oldestKey}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache entry and update access order
|
||||||
|
*/
|
||||||
|
private getCacheEntry(key: string): IInvoiceMetadata | undefined {
|
||||||
|
const value = this.metadataCache.get(key);
|
||||||
|
if (value) {
|
||||||
|
// Move to end (most recently used)
|
||||||
|
const index = this.cacheAccessOrder.indexOf(key);
|
||||||
|
if (index > -1) {
|
||||||
|
this.cacheAccessOrder.splice(index, 1);
|
||||||
|
}
|
||||||
|
this.cacheAccessOrder.push(key);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update metadata in storage and cache
|
||||||
|
*/
|
||||||
|
public async updateMetadata(contentHash: string, updates: Partial<IInvoiceMetadata>): Promise<void> {
|
||||||
|
const metadata = this.getCacheEntry(contentHash);
|
||||||
|
if (!metadata) {
|
||||||
|
this.logger.log('warn', `Cannot update metadata - invoice not found: ${contentHash}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata
|
||||||
|
const updatedMetadata = { ...metadata, ...updates };
|
||||||
|
this.setCacheEntry(contentHash, updatedMetadata);
|
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
const metadataPath = path.join(
|
||||||
|
this.exportPath,
|
||||||
|
'data',
|
||||||
|
'documents',
|
||||||
|
'invoices',
|
||||||
|
metadata.direction,
|
||||||
|
'metadata',
|
||||||
|
`${contentHash}.json`
|
||||||
|
);
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(updatedMetadata, null, 2),
|
||||||
|
metadataPath
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log('info', `Updated metadata for invoice: ${contentHash}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* DATEV Posting Keys (Buchungsschlüssel) for German Accounting
|
||||||
|
*
|
||||||
|
* Posting keys control automatic VAT booking and are automatically checked
|
||||||
|
* in German tax audits (Betriebsprüfungen). Using incorrect posting keys
|
||||||
|
* can have serious tax consequences.
|
||||||
|
*
|
||||||
|
* Reference: DATEV Buchungsschlüssel-Verzeichnis
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { TPostingKey, IPostingKeyRule } from './skr.types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posting key definitions with validation rules
|
||||||
|
*/
|
||||||
|
export const POSTING_KEY_RULES: Record<TPostingKey, IPostingKeyRule> = {
|
||||||
|
3: {
|
||||||
|
key: 3,
|
||||||
|
description: 'Zahlungseingang mit 19% Umsatzsteuer',
|
||||||
|
vatRate: 19,
|
||||||
|
requiresVAT: true,
|
||||||
|
disablesVATAutomatism: false,
|
||||||
|
allowedScenarios: ['domestic_taxed']
|
||||||
|
},
|
||||||
|
8: {
|
||||||
|
key: 8,
|
||||||
|
description: '7% Vorsteuer',
|
||||||
|
vatRate: 7,
|
||||||
|
requiresVAT: true,
|
||||||
|
disablesVATAutomatism: false,
|
||||||
|
allowedScenarios: ['domestic_taxed']
|
||||||
|
},
|
||||||
|
9: {
|
||||||
|
key: 9,
|
||||||
|
description: '19% Vorsteuer',
|
||||||
|
vatRate: 19,
|
||||||
|
requiresVAT: true,
|
||||||
|
disablesVATAutomatism: false,
|
||||||
|
allowedScenarios: ['domestic_taxed']
|
||||||
|
},
|
||||||
|
19: {
|
||||||
|
key: 19,
|
||||||
|
description: '19% Vorsteuer bei innergemeinschaftlichen Lieferungen',
|
||||||
|
vatRate: 19,
|
||||||
|
requiresVAT: true,
|
||||||
|
disablesVATAutomatism: false,
|
||||||
|
allowedScenarios: ['intra_eu']
|
||||||
|
},
|
||||||
|
40: {
|
||||||
|
key: 40,
|
||||||
|
description: 'Steuerfrei / Aufhebung der Automatik',
|
||||||
|
vatRate: 0,
|
||||||
|
requiresVAT: false,
|
||||||
|
disablesVATAutomatism: true,
|
||||||
|
allowedScenarios: ['tax_free', 'export', 'reverse_charge']
|
||||||
|
},
|
||||||
|
94: {
|
||||||
|
key: 94,
|
||||||
|
description: '19% Vorsteuer/Umsatzsteuer bei Erwerb aus EU oder Drittland (Reverse Charge)',
|
||||||
|
vatRate: 19,
|
||||||
|
requiresVAT: true,
|
||||||
|
disablesVATAutomatism: false,
|
||||||
|
allowedScenarios: ['reverse_charge', 'intra_eu', 'third_country']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate posting key for a journal entry line
|
||||||
|
*/
|
||||||
|
export function validatePostingKey(
|
||||||
|
postingKey: TPostingKey,
|
||||||
|
accountNumber: string,
|
||||||
|
amount: number,
|
||||||
|
vatAmount?: number,
|
||||||
|
taxScenario?: string
|
||||||
|
): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Get posting key rule
|
||||||
|
const rule = POSTING_KEY_RULES[postingKey];
|
||||||
|
if (!rule) {
|
||||||
|
errors.push(`Invalid posting key: ${postingKey}`);
|
||||||
|
return { isValid: false, errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate VAT requirement
|
||||||
|
// Skip VAT amount requirement if:
|
||||||
|
// 1. Posting TO a VAT account (the line itself IS the VAT)
|
||||||
|
// 2. Posting TO a debtor/creditor account (receivable/payable settlement - VAT was already recorded)
|
||||||
|
const isVATAccount = accountNumber === '1571' || accountNumber === '1771' || accountNumber === '1576';
|
||||||
|
const accountNum = parseInt(accountNumber);
|
||||||
|
const isDebtorCreditorAccount = (accountNum >= 10000 && accountNum <= 69999) || (accountNum >= 70000 && accountNum <= 99999);
|
||||||
|
|
||||||
|
if (rule.requiresVAT && !vatAmount && !isVATAccount && !isDebtorCreditorAccount) {
|
||||||
|
errors.push(
|
||||||
|
`Posting key ${postingKey} requires VAT amount, but none provided. ` +
|
||||||
|
`Description: ${rule.description}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate VAT rate if specified
|
||||||
|
if (rule.vatRate && vatAmount && rule.vatRate > 0) {
|
||||||
|
const expectedVAT = Math.round(amount * rule.vatRate) / 100;
|
||||||
|
const tolerance = 0.02; // 2 cent tolerance for rounding
|
||||||
|
|
||||||
|
if (Math.abs(vatAmount - expectedVAT) > tolerance) {
|
||||||
|
warnings.push(
|
||||||
|
`VAT amount ${vatAmount} does not match expected ${expectedVAT.toFixed(2)} ` +
|
||||||
|
`for posting key ${postingKey} (${rule.vatRate}%)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate tax scenario
|
||||||
|
if (rule.allowedScenarios && taxScenario) {
|
||||||
|
if (!rule.allowedScenarios.includes(taxScenario)) {
|
||||||
|
errors.push(
|
||||||
|
`Posting key ${postingKey} is not valid for tax scenario '${taxScenario}'. ` +
|
||||||
|
`Allowed scenarios: ${rule.allowedScenarios.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate automatism disabling
|
||||||
|
if (rule.disablesVATAutomatism && vatAmount && vatAmount > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Posting key ${postingKey} disables VAT automatism but VAT amount is provided. ` +
|
||||||
|
`This may cause incorrect tax reporting.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get posting key description
|
||||||
|
*/
|
||||||
|
export function getPostingKeyDescription(postingKey: TPostingKey): string {
|
||||||
|
const rule = POSTING_KEY_RULES[postingKey];
|
||||||
|
return rule ? rule.description : `Unknown posting key: ${postingKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get appropriate posting key for a transaction
|
||||||
|
*/
|
||||||
|
export function suggestPostingKey(params: {
|
||||||
|
vatRate: number;
|
||||||
|
taxScenario?: string;
|
||||||
|
isPayment?: boolean;
|
||||||
|
}): TPostingKey {
|
||||||
|
const { vatRate, taxScenario, isPayment } = params;
|
||||||
|
|
||||||
|
// Tax-free or reverse charge scenarios
|
||||||
|
if (taxScenario === 'tax_free' || taxScenario === 'export') {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse charge
|
||||||
|
if (taxScenario === 'reverse_charge' || taxScenario === 'third_country') {
|
||||||
|
return 94;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intra-EU with VAT
|
||||||
|
if (taxScenario === 'intra_eu' && vatRate === 19) {
|
||||||
|
return 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment with 19% VAT
|
||||||
|
if (isPayment && vatRate === 19) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input VAT based on rate
|
||||||
|
if (vatRate === 19) {
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vatRate === 7) {
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to tax-free if no VAT
|
||||||
|
if (vatRate === 0) {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to 19% input VAT
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all posting keys for consistency
|
||||||
|
*/
|
||||||
|
export function validatePostingKeyConsistency(lines: Array<{
|
||||||
|
postingKey: TPostingKey;
|
||||||
|
accountNumber: string;
|
||||||
|
debit?: number;
|
||||||
|
credit?: number;
|
||||||
|
vatAmount?: number;
|
||||||
|
}>): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// Check for mixing tax-free and taxed transactions
|
||||||
|
const hasTaxFree = lines.some(line => line.postingKey === 40);
|
||||||
|
const hasTaxed = lines.some(line => [3, 8, 9, 19, 94].includes(line.postingKey));
|
||||||
|
|
||||||
|
if (hasTaxFree && hasTaxed) {
|
||||||
|
warnings.push(
|
||||||
|
'Journal entry mixes tax-free (key 40) and taxed transactions. ' +
|
||||||
|
'Verify this is intentional.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for reverse charge consistency
|
||||||
|
const hasReverseCharge = lines.some(line => line.postingKey === 94);
|
||||||
|
if (hasReverseCharge) {
|
||||||
|
const reverseChargeLines = lines.filter(line => line.postingKey === 94);
|
||||||
|
if (reverseChargeLines.length % 2 !== 0) {
|
||||||
|
errors.push(
|
||||||
|
'Reverse charge (posting key 94) requires both input and output VAT entries. ' +
|
||||||
|
'Found odd number of reverse charge lines.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if posting key requires automatic VAT booking
|
||||||
|
*/
|
||||||
|
export function requiresAutomaticVAT(postingKey: TPostingKey): boolean {
|
||||||
|
const rule = POSTING_KEY_RULES[postingKey];
|
||||||
|
return rule ? !rule.disablesVATAutomatism : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valid posting keys
|
||||||
|
*/
|
||||||
|
export function getAllPostingKeys(): TPostingKey[] {
|
||||||
|
return Object.keys(POSTING_KEY_RULES).map(k => Number(k) as TPostingKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import * as plugins from './plugins.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as https from 'https';
|
||||||
|
|
||||||
|
export interface ISigningOptions {
|
||||||
|
certificatePem?: string;
|
||||||
|
privateKeyPem?: string;
|
||||||
|
privateKeyPassphrase?: string;
|
||||||
|
timestampServerUrl?: string;
|
||||||
|
includeTimestamp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISignatureResult {
|
||||||
|
signature: string;
|
||||||
|
signatureFormat: 'CAdES-B' | 'CAdES-T' | 'CAdES-LT';
|
||||||
|
signingTime: string;
|
||||||
|
certificateChain?: string[];
|
||||||
|
timestampToken?: string;
|
||||||
|
timestampTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITimestampResponse {
|
||||||
|
token: string;
|
||||||
|
time: string;
|
||||||
|
serverUrl: string;
|
||||||
|
hashAlgorithm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecurityManager {
|
||||||
|
private options: ISigningOptions;
|
||||||
|
private logger: plugins.smartlog.ConsoleLog;
|
||||||
|
|
||||||
|
constructor(options: ISigningOptions = {}) {
|
||||||
|
this.options = {
|
||||||
|
timestampServerUrl: options.timestampServerUrl || 'http://timestamp.digicert.com',
|
||||||
|
includeTimestamp: options.includeTimestamp !== false,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
this.logger = new plugins.smartlog.ConsoleLog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a CAdES-B (Basic) signature for data
|
||||||
|
*/
|
||||||
|
public async createCadesSignature(
|
||||||
|
data: Buffer | string,
|
||||||
|
certificatePem?: string,
|
||||||
|
privateKeyPem?: string
|
||||||
|
): Promise<ISignatureResult> {
|
||||||
|
const cert = certificatePem || this.options.certificatePem;
|
||||||
|
const key = privateKeyPem || this.options.privateKeyPem;
|
||||||
|
|
||||||
|
if (!cert || !key) {
|
||||||
|
throw new Error('Certificate and private key are required for signing');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse certificate and key
|
||||||
|
const certificate = plugins.nodeForge.pki.certificateFromPem(cert);
|
||||||
|
const privateKey = this.options.privateKeyPassphrase
|
||||||
|
? plugins.nodeForge.pki.decryptRsaPrivateKey(key, this.options.privateKeyPassphrase)
|
||||||
|
: plugins.nodeForge.pki.privateKeyFromPem(key);
|
||||||
|
|
||||||
|
// Create PKCS#7 signed data (CMS)
|
||||||
|
const p7 = plugins.nodeForge.pkcs7.createSignedData();
|
||||||
|
|
||||||
|
// Add content
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
p7.content = plugins.nodeForge.util.createBuffer(data, 'utf8');
|
||||||
|
} else {
|
||||||
|
p7.content = plugins.nodeForge.util.createBuffer(data.toString('latin1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add certificate
|
||||||
|
p7.addCertificate(certificate);
|
||||||
|
|
||||||
|
// Add signer
|
||||||
|
p7.addSigner({
|
||||||
|
key: privateKey,
|
||||||
|
certificate: certificate,
|
||||||
|
digestAlgorithm: plugins.nodeForge.pki.oids.sha256,
|
||||||
|
authenticatedAttributes: [
|
||||||
|
{
|
||||||
|
type: plugins.nodeForge.pki.oids.contentType,
|
||||||
|
value: plugins.nodeForge.pki.oids.data
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: plugins.nodeForge.pki.oids.messageDigest
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: plugins.nodeForge.pki.oids.signingTime,
|
||||||
|
value: new Date().toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign the data
|
||||||
|
p7.sign({ detached: true });
|
||||||
|
|
||||||
|
// Convert to PEM
|
||||||
|
const pem = plugins.nodeForge.pkcs7.messageToPem(p7);
|
||||||
|
|
||||||
|
// Extract base64 signature
|
||||||
|
const signature = pem
|
||||||
|
.replace(/-----BEGIN PKCS7-----/, '')
|
||||||
|
.replace(/-----END PKCS7-----/, '')
|
||||||
|
.replace(/\r?\n/g, '');
|
||||||
|
|
||||||
|
const result: ISignatureResult = {
|
||||||
|
signature: signature,
|
||||||
|
signatureFormat: 'CAdES-B',
|
||||||
|
signingTime: new Date().toISOString(),
|
||||||
|
certificateChain: [cert]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add timestamp if requested
|
||||||
|
if (this.options.includeTimestamp && this.options.timestampServerUrl) {
|
||||||
|
try {
|
||||||
|
const timestampResponse = await this.requestTimestamp(signature);
|
||||||
|
result.timestampToken = timestampResponse.token;
|
||||||
|
result.timestampTime = timestampResponse.time;
|
||||||
|
result.signatureFormat = 'CAdES-T';
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('warn', `Failed to obtain timestamp: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to create CAdES signature: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests an RFC 3161 timestamp from a TSA
|
||||||
|
*/
|
||||||
|
public async requestTimestamp(dataHash: string | Buffer): Promise<ITimestampResponse> {
|
||||||
|
try {
|
||||||
|
// Create hash of the data
|
||||||
|
let hash: Buffer;
|
||||||
|
if (typeof dataHash === 'string') {
|
||||||
|
hash = crypto.createHash('sha256').update(dataHash).digest();
|
||||||
|
} else {
|
||||||
|
hash = crypto.createHash('sha256').update(dataHash).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create timestamp request (simplified - in production use proper ASN.1 encoding)
|
||||||
|
const tsRequest = this.createTimestampRequest(hash);
|
||||||
|
|
||||||
|
// Send request to TSA
|
||||||
|
const response = await this.sendTimestampRequest(tsRequest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: response.toString('base64'),
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
serverUrl: this.options.timestampServerUrl!,
|
||||||
|
hashAlgorithm: 'sha256'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to obtain timestamp: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a timestamp request (simplified version)
|
||||||
|
*/
|
||||||
|
private createTimestampRequest(hash: Buffer): Buffer {
|
||||||
|
// In production, use proper ASN.1 encoding library
|
||||||
|
// This is a simplified placeholder
|
||||||
|
const request = {
|
||||||
|
version: 1,
|
||||||
|
messageImprint: {
|
||||||
|
hashAlgorithm: { algorithm: '2.16.840.1.101.3.4.2.1' }, // SHA-256 OID
|
||||||
|
hashedMessage: hash
|
||||||
|
},
|
||||||
|
reqPolicy: null,
|
||||||
|
nonce: crypto.randomBytes(8),
|
||||||
|
certReq: true
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert to DER-encoded ASN.1 (simplified)
|
||||||
|
return Buffer.from(JSON.stringify(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends timestamp request to TSA server
|
||||||
|
*/
|
||||||
|
private async sendTimestampRequest(request: Buffer): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(this.options.timestampServerUrl!);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 443,
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/timestamp-query',
|
||||||
|
'Content-Length': request.length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
res.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
const response = Buffer.concat(chunks);
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
reject(new Error(`TSA server returned status ${res.statusCode}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(request);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a CAdES signature
|
||||||
|
*/
|
||||||
|
public async verifyCadesSignature(
|
||||||
|
data: Buffer | string,
|
||||||
|
signature: string,
|
||||||
|
certificatePem?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Add PEM headers if not present
|
||||||
|
let pemSignature = signature;
|
||||||
|
if (!signature.includes('BEGIN PKCS7')) {
|
||||||
|
pemSignature = `-----BEGIN PKCS7-----\n${signature}\n-----END PKCS7-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the PKCS#7 message
|
||||||
|
const p7 = plugins.nodeForge.pkcs7.messageFromPem(pemSignature);
|
||||||
|
|
||||||
|
// Prepare content for verification
|
||||||
|
let content: plugins.nodeForge.util.ByteStringBuffer;
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
content = plugins.nodeForge.util.createBuffer(data, 'utf8');
|
||||||
|
} else {
|
||||||
|
content = plugins.nodeForge.util.createBuffer(data.toString('latin1'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
const verified = (p7 as any).verify({
|
||||||
|
content: content,
|
||||||
|
detached: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return verified;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Signature verification failed: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a self-signed certificate for testing
|
||||||
|
*/
|
||||||
|
public async generateSelfSignedCertificate(
|
||||||
|
commonName: string = 'SKR Export System',
|
||||||
|
validDays: number = 365
|
||||||
|
): Promise<{ certificate: string; privateKey: string }> {
|
||||||
|
const keys = plugins.nodeForge.pki.rsa.generateKeyPair(2048);
|
||||||
|
const cert = plugins.nodeForge.pki.createCertificate();
|
||||||
|
|
||||||
|
cert.publicKey = keys.publicKey;
|
||||||
|
cert.serialNumber = '01';
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setDate(cert.validity.notAfter.getDate() + validDays);
|
||||||
|
|
||||||
|
const attrs = [
|
||||||
|
{ name: 'commonName', value: commonName },
|
||||||
|
{ name: 'countryName', value: 'DE' },
|
||||||
|
{ name: 'organizationName', value: 'SKR Export System' },
|
||||||
|
{ shortName: 'OU', value: 'Accounting' }
|
||||||
|
];
|
||||||
|
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
|
||||||
|
cert.setExtensions([
|
||||||
|
{
|
||||||
|
name: 'basicConstraints',
|
||||||
|
cA: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'keyUsage',
|
||||||
|
keyCertSign: true,
|
||||||
|
digitalSignature: true,
|
||||||
|
nonRepudiation: true,
|
||||||
|
keyEncipherment: true,
|
||||||
|
dataEncipherment: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'extKeyUsage',
|
||||||
|
serverAuth: true,
|
||||||
|
clientAuth: true,
|
||||||
|
codeSigning: true,
|
||||||
|
emailProtection: true,
|
||||||
|
timeStamping: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nsCertType',
|
||||||
|
client: true,
|
||||||
|
server: true,
|
||||||
|
email: true,
|
||||||
|
objsign: true,
|
||||||
|
sslCA: true,
|
||||||
|
emailCA: true,
|
||||||
|
objCA: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'subjectAltName',
|
||||||
|
altNames: [
|
||||||
|
{ type: 2, value: commonName }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Self-sign certificate
|
||||||
|
cert.sign(keys.privateKey, plugins.nodeForge.md.sha256.create());
|
||||||
|
|
||||||
|
// Convert to PEM
|
||||||
|
const certificatePem = plugins.nodeForge.pki.certificateToPem(cert);
|
||||||
|
const privateKeyPem = plugins.nodeForge.pki.privateKeyToPem(keys.privateKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate: certificatePem,
|
||||||
|
privateKey: privateKeyPem
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a detached signature file
|
||||||
|
*/
|
||||||
|
public async createDetachedSignature(
|
||||||
|
dataPath: string,
|
||||||
|
outputPath: string
|
||||||
|
): Promise<void> {
|
||||||
|
const data = await plugins.smartfile.fs.toBuffer(dataPath);
|
||||||
|
const signature = await this.createCadesSignature(data);
|
||||||
|
|
||||||
|
const signatureData = {
|
||||||
|
signature: signature.signature,
|
||||||
|
format: signature.signatureFormat,
|
||||||
|
signingTime: signature.signingTime,
|
||||||
|
timestamp: signature.timestampToken,
|
||||||
|
timestampTime: signature.timestampTime,
|
||||||
|
algorithm: 'SHA256withRSA',
|
||||||
|
signedFile: path.basename(dataPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
await plugins.smartfile.memory.toFs(
|
||||||
|
JSON.stringify(signatureData, null, 2),
|
||||||
|
outputPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a detached signature file
|
||||||
|
*/
|
||||||
|
public async verifyDetachedSignature(
|
||||||
|
dataPath: string,
|
||||||
|
signaturePath: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const data = await plugins.smartfile.fs.toBuffer(dataPath);
|
||||||
|
const signatureJson = await plugins.smartfile.fs.toStringSync(signaturePath);
|
||||||
|
const signatureData = JSON.parse(signatureJson);
|
||||||
|
|
||||||
|
return await this.verifyCadesSignature(data, signatureData.signature);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.log('error', `Failed to verify detached signature: ${error}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds Long-Term Validation (LTV) information
|
||||||
|
*/
|
||||||
|
public async addLtvInformation(
|
||||||
|
signature: ISignatureResult,
|
||||||
|
ocspResponse?: Buffer,
|
||||||
|
crlData?: Buffer
|
||||||
|
): Promise<ISignatureResult> {
|
||||||
|
// Add OCSP response and CRL data for long-term validation
|
||||||
|
const ltv = {
|
||||||
|
...signature,
|
||||||
|
signatureFormat: 'CAdES-LT' as const,
|
||||||
|
ocsp: ocspResponse?.toString('base64'),
|
||||||
|
crl: crlData?.toString('base64'),
|
||||||
|
ltvTime: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return ltv;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,18 @@ export type TSKRType = 'SKR03' | 'SKR04';
|
|||||||
|
|
||||||
export type TTransactionStatus = 'pending' | 'posted' | 'reversed';
|
export type TTransactionStatus = 'pending' | 'posted' | 'reversed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DATEV posting keys (Buchungsschlüssel) for German accounting
|
||||||
|
* These keys control automatic VAT booking and are checked in tax audits
|
||||||
|
*/
|
||||||
|
export type TPostingKey =
|
||||||
|
| 3 // Payment with 19% VAT
|
||||||
|
| 8 // 7% input VAT
|
||||||
|
| 9 // 19% input VAT
|
||||||
|
| 19 // 19% input VAT (intra-EU)
|
||||||
|
| 40 // Tax-free (disables VAT automatism)
|
||||||
|
| 94; // 19% input/output VAT (reverse charge)
|
||||||
|
|
||||||
export type TReportType =
|
export type TReportType =
|
||||||
| 'trial_balance'
|
| 'trial_balance'
|
||||||
| 'income_statement'
|
| 'income_statement'
|
||||||
@@ -16,6 +28,18 @@ export type TReportType =
|
|||||||
| 'general_ledger'
|
| 'general_ledger'
|
||||||
| 'cash_flow';
|
| 'cash_flow';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posting key validation rule
|
||||||
|
*/
|
||||||
|
export interface IPostingKeyRule {
|
||||||
|
key: TPostingKey;
|
||||||
|
description: string;
|
||||||
|
vatRate?: number; // Expected VAT rate (if applicable)
|
||||||
|
requiresVAT: boolean; // Whether VAT entry is required
|
||||||
|
disablesVATAutomatism: boolean; // Whether this key disables automatic VAT
|
||||||
|
allowedScenarios?: string[]; // Allowed tax scenarios (e.g., 'reverse_charge')
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAccountData {
|
export interface IAccountData {
|
||||||
accountNumber: string;
|
accountNumber: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
@@ -25,6 +49,7 @@ export interface IAccountData {
|
|||||||
description?: string;
|
description?: string;
|
||||||
vatRate?: number;
|
vatRate?: number;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
isAutomaticAccount?: boolean; // Automatikkonto (e.g., 1400, 1600) - cannot be posted to directly
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITransactionData {
|
export interface ITransactionData {
|
||||||
@@ -53,6 +78,7 @@ export interface IJournalEntryLine {
|
|||||||
credit?: number;
|
credit?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
costCenter?: string;
|
costCenter?: string;
|
||||||
|
postingKey: TPostingKey; // REQUIRED: DATEV posting key for VAT automation control
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITrialBalanceEntry {
|
export interface ITrialBalanceEntry {
|
||||||
@@ -136,6 +162,7 @@ export interface ITransactionFilter {
|
|||||||
export interface IDatabaseConfig {
|
export interface IDatabaseConfig {
|
||||||
mongoDbUrl: string;
|
mongoDbUrl: string;
|
||||||
dbName?: string;
|
dbName?: string;
|
||||||
|
invoiceExportPath?: string; // Optional path for invoice storage
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IReportParams {
|
export interface IReportParams {
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export const SKR03_ACCOUNTS: IAccountData[] = [
|
|||||||
accountType: 'asset',
|
accountType: 'asset',
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
description: 'Trade receivables',
|
description: 'Trade receivables',
|
||||||
|
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use debtor accounts (10000-69999)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountNumber: '1500',
|
accountNumber: '1500',
|
||||||
@@ -199,6 +200,7 @@ export const SKR03_ACCOUNTS: IAccountData[] = [
|
|||||||
accountType: 'liability',
|
accountType: 'liability',
|
||||||
skrType: 'SKR03',
|
skrType: 'SKR03',
|
||||||
description: 'Trade payables',
|
description: 'Trade payables',
|
||||||
|
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use creditor accounts (70000-99999)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountNumber: '1700',
|
accountNumber: '1700',
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ export const SKR04_ACCOUNTS: IAccountData[] = [
|
|||||||
accountType: 'asset',
|
accountType: 'asset',
|
||||||
skrType: 'SKR04',
|
skrType: 'SKR04',
|
||||||
description: 'Trade receivables',
|
description: 'Trade receivables',
|
||||||
|
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use debtor accounts (10000-69999)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountNumber: '1500',
|
accountNumber: '1500',
|
||||||
@@ -199,6 +200,7 @@ export const SKR04_ACCOUNTS: IAccountData[] = [
|
|||||||
accountType: 'liability',
|
accountType: 'liability',
|
||||||
skrType: 'SKR04',
|
skrType: 'SKR04',
|
||||||
description: 'Trade payables',
|
description: 'Trade payables',
|
||||||
|
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use creditor accounts (70000-99999)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accountNumber: '1700',
|
accountNumber: '1700',
|
||||||
|
|||||||
Reference in New Issue
Block a user