From 73b46f7857d6159df4c18e07a3db8d6458dad19e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 12 Aug 2025 12:37:01 +0000 Subject: [PATCH] feat(invoice): add e-invoice support with XRechnung/ZUGFeRD and advanced export features --- changelog.md | 21 ++ package.json | 15 +- pnpm-lock.yaml | 469 ++++++++++++++++++++++++ readme.md | 223 +++++++++++- ts/index.ts | 6 + ts/plugins.ts | 23 +- ts/skr.api.ts | 485 +++++++++++++++++++++++++ ts/skr.export.accounts.ts | 154 ++++++++ ts/skr.export.balances.ts | 270 ++++++++++++++ ts/skr.export.ledger.ts | 249 +++++++++++++ ts/skr.export.pdf.ts | 601 +++++++++++++++++++++++++++++++ ts/skr.export.ts | 443 +++++++++++++++++++++++ ts/skr.invoice.adapter.ts | 581 ++++++++++++++++++++++++++++++ ts/skr.invoice.booking.ts | 738 ++++++++++++++++++++++++++++++++++++++ ts/skr.invoice.entity.ts | 351 ++++++++++++++++++ ts/skr.invoice.mapper.ts | 486 +++++++++++++++++++++++++ ts/skr.invoice.storage.ts | 710 ++++++++++++++++++++++++++++++++++++ ts/skr.security.ts | 405 +++++++++++++++++++++ ts/skr.types.ts | 1 + 19 files changed, 6211 insertions(+), 20 deletions(-) create mode 100644 ts/skr.export.accounts.ts create mode 100644 ts/skr.export.balances.ts create mode 100644 ts/skr.export.ledger.ts create mode 100644 ts/skr.export.pdf.ts create mode 100644 ts/skr.export.ts create mode 100644 ts/skr.invoice.adapter.ts create mode 100644 ts/skr.invoice.booking.ts create mode 100644 ts/skr.invoice.entity.ts create mode 100644 ts/skr.invoice.mapper.ts create mode 100644 ts/skr.invoice.storage.ts create mode 100644 ts/skr.security.ts diff --git a/changelog.md b/changelog.md index 581d50a..fd2708e 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,27 @@ 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/), 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 diff --git a/package.json b/package.json index 4b0fc68..8893e05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fin.cx/skr", - "version": "1.1.0", + "version": "1.2.0", "description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping", "main": "dist_ts/index.js", "typings": "dist_ts/index.d.ts", @@ -25,16 +25,25 @@ "license": "MIT", "packageManager": "pnpm@10.11.0", "dependencies": { + "@e-invoice-eu/core": "^2.1.9", + "@fin.cx/einvoice": "5.1.4", "@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/smartpath": "^5.0.18", + "@push.rocks/smartpdf": "^3.1.5", "@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": { "@git.zone/tsbuild": "^2.6.4", "@git.zone/tsrun": "^1.3.3", "@git.zone/tstest": "^2.3.2", - "@push.rocks/qenv": "^6.1.0" + "@push.rocks/qenv": "^6.1.0", + "@types/node-forge": "^1.3.11" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fac3404..c9dff21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,42 @@ importers: .: 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': specifier: ^5.15.1 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': specifier: ^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': specifier: ^4.1.1 version: 4.1.1 '@push.rocks/smartunique': specifier: ^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: '@git.zone/tsbuild': specifier: ^2.6.4 @@ -33,6 +57,9 @@ importers: '@push.rocks/qenv': specifier: ^6.1.0 version: 6.1.0 + '@types/node-forge': + specifier: ^1.3.11 + version: 1.3.13 packages: @@ -51,6 +78,9 @@ packages: '@api.global/typedsocket@3.0.1': resolution: {integrity: sha512-xojiAVNXtHoxkpBo8U2HHJG8FrVXXuLvDNndSHXwx4C9VslUwDn5zSCI+PdBl8iAg+ZuBmKjqkpZZ9sL6DC5yQ==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -226,6 +256,9 @@ packages: resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} engines: {node: '>=6.9.0'} + '@cantoo/pdf-lib@2.4.2': + resolution: {integrity: sha512-ZqMiY8XEyM6Rc3WjpsQnrZYwCdyf/Emg2J3RbmSxoIKN1Kpa/93uIaO9cx/14dJoC6vkcAtMhrYsO7YLB8i8Lg==} + '@cloudflare/workers-types@4.20250809.0': resolution: {integrity: sha512-MAG8S0aL+/WT52/XaqXCb6qc7XyfuYqmhwnpyLsx+RUkMZJxkhpJqaOny3Wa4IlWQfpgtQXktogmEoxxCpUmfA==} @@ -236,6 +269,34 @@ packages: '@configvault.io/interfaces@1.0.17': 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': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} @@ -248,6 +309,14 @@ packages: '@design.estate/dees-element@2.1.2': 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': resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} @@ -413,6 +482,12 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-eeNW5hnXHU9lPzTaMbtdYDkb6cpFFC8fF5849BiwLO4N1Ga9Q5Om/6w5SZyJQcct8rHjcTgOOWdlxhjeKCr6NQ==} hasBin: true @@ -449,6 +524,18 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} 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': resolution: {integrity: sha512-dOOXgzqaDoHu5qqMEPLKEgLz5CeIA7q8+1W62mCvFVCOqeC71UoTGJ4u1xUSOpIl2J1x2pqrNULkFteUeZW3/A==} engines: {node: '>= 8.0.0'} @@ -1430,6 +1517,10 @@ packages: '@ungap/structured-clone@1.3.0': 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: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1446,6 +1537,9 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-256-colors@1.1.0: resolution: {integrity: sha1-kQ3lDvzHwJ49gvL4er1rcAwYgYo=} engines: {node: '>=0.10.0'} @@ -1496,6 +1590,9 @@ packages: asynckit@0.4.0: resolution: {integrity: sha1-x57Zf380y48robyXkLzDZkdLS3k=} + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} @@ -1586,6 +1683,9 @@ packages: buffer-json@2.0.0: resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==} + buffer-reverse@1.0.1: + resolution: {integrity: sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==} + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1703,6 +1803,10 @@ packages: color@3.2.1: 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: resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} @@ -1795,14 +1899,25 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + crypto-random-string@5.0.0: resolution: {integrity: sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==} engines: {node: '>=14.16'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -1835,6 +1950,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.2.0: resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} @@ -1961,6 +2079,10 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2074,6 +2196,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-uri@3.0.6: + resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fast-xml-parser@3.21.1: resolution: {integrity: sha512-FTFVjYoBOZTJekiUsawGsSYV9QL0A+zDYCRj7y34IO6Jg+2IMYEtQa+bbictpdpV8dHxXywqU7C0gRDEOFtBFg==} hasBin: true @@ -2302,6 +2427,13 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} 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: resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} engines: {node: '>=6'} @@ -2425,6 +2557,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -2493,18 +2628,39 @@ packages: jsbn@1.1.0: 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: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + jsonfile@4.0.0: resolution: {integrity: sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=} jsonfile@6.1.0: 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: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -2713,6 +2869,10 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merkletreejs@0.4.1: + resolution: {integrity: sha512-W2VSHeGTdAnWtedee+pgGn7SHvncMdINnMeHAaXrfarSaMNLff/pm7RCr/QXYxN6XzJFgJZY+28ejO0lAosW4A==} + engines: {node: '>= 7.6.0'} + methods@1.1.2: resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=} engines: {node: '>= 0.6'} @@ -2959,6 +3119,9 @@ packages: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-html-better-parser@1.5.3: + resolution: {integrity: sha512-rvnbT4FUS+pIQPAs3bBpzeuWdgdjne0LsgrEINdsMfAvjAKHTEGVhknMEqBriGuVRWM8iRL1LKhRhZ9RB6gPVA==} + normalize-newline@4.1.0: resolution: {integrity: sha512-ff4jKqMI8Xl50/4Mms/9jPobzAV/UK+kXG2XJ/7AqOmxIx8mqfqTIHYxuAnEgJ2AQeBbLnlbmZ5+38Y9A0w/YA==} engines: {node: '>=12'} @@ -2967,6 +3130,9 @@ packages: resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==} engines: {node: '>=14.16'} + nwsapi@2.2.21: + resolution: {integrity: sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==} + object-assign@4.1.1: resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} engines: {node: '>=0.10.0'} @@ -3052,6 +3218,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + param-case@2.1.1: resolution: {integrity: sha1-35T9jPZTHs915r75oIWPvHK+Ikc=} @@ -3075,6 +3244,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3292,6 +3464,10 @@ packages: resolution: {integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I=} 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: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -3318,6 +3494,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + rss-parser@3.13.0: resolution: {integrity: sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==} @@ -3349,6 +3528,13 @@ packages: sax@1.4.1: 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: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3582,6 +3768,20 @@ packages: tiny-worker@2.3.0: 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: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -3590,6 +3790,10 @@ packages: resolution: {integrity: sha512-MD9MjpVNhVyH4fyd5rKphjvt/1qj+PtQUz65aFqAZA6XniWAuSFRjLk3e2VALEFlh9OwBpXUN7rfeqSnT/Fmkw==} engines: {node: '>=14.16'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@3.0.0: resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} engines: {node: '>=12'} @@ -3602,6 +3806,10 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + treeify@1.1.0: + resolution: {integrity: sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==} + engines: {node: '>=0.6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3730,14 +3938,26 @@ packages: vfile@6.0.3: 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: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@11.0.0: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} engines: {node: '>=12'} @@ -3803,6 +4023,10 @@ packages: resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} hasBin: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.5.0: resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} engines: {node: '>=4.0.0'} @@ -3815,10 +4039,21 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} 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: resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} engines: {node: '>=0.4.0'} + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3938,6 +4173,14 @@ snapshots: - utf-8-validate - 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': dependencies: '@aws-crypto/util': 5.2.0 @@ -4497,6 +4740,16 @@ snapshots: dependencies: 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': {} '@colors/colors@1.6.0': {} @@ -4505,6 +4758,26 @@ snapshots: dependencies: '@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': dependencies: colorspace: 1.1.4 @@ -4556,6 +4829,18 @@ snapshots: - supports-color - 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': dependencies: '@emnapi/wasi-threads': 1.0.4 @@ -4650,6 +4935,27 @@ snapshots: '@esbuild/win32-x64@0.25.8': 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': dependencies: '@git.zone/tspublish': 1.10.1 @@ -4769,6 +5075,14 @@ snapshots: wrap-ansi: 8.1.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': dependencies: debug: 4.4.1 @@ -6343,6 +6657,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@xmldom/xmldom@0.9.8': {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -6359,6 +6675,13 @@ snapshots: dependencies: 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-regex@5.0.1: {} @@ -6397,6 +6720,14 @@ snapshots: 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: {} bail@2.0.2: {} @@ -6499,6 +6830,8 @@ snapshots: buffer-json@2.0.0: {} + buffer-reverse@1.0.1: {} + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -6624,6 +6957,11 @@ snapshots: color-convert: 1.9.3 color-string: 1.9.1 + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + colorspace@1.1.4: dependencies: color: 3.2.1 @@ -6701,12 +7039,24 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + crypto-random-string@5.0.0: dependencies: 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-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + date-fns@4.1.0: {} dayjs@1.11.13: {} @@ -6723,6 +7073,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.2.0: dependencies: character-entities: 2.0.2 @@ -6852,6 +7204,8 @@ snapshots: entities@4.5.0: {} + entities@6.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.2: @@ -7026,6 +7380,8 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fast-uri@3.0.6: {} + fast-xml-parser@3.21.1: dependencies: strnum: 1.1.2 @@ -7328,6 +7684,12 @@ snapshots: 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: dependencies: camel-case: 3.0.0 @@ -7456,6 +7818,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -7512,10 +7876,41 @@ snapshots: 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-parse-even-better-errors@2.3.1: {} + json-schema-traverse@1.0.0: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -7526,6 +7921,12 @@ snapshots: optionalDependencies: 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: dependencies: tsscmp: 1.0.6 @@ -7825,6 +8226,12 @@ snapshots: 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: {} micromark-core-commonmark@2.0.3: @@ -8170,12 +8577,18 @@ snapshots: node-forge@1.3.1: {} + node-html-better-parser@1.5.3: + dependencies: + html-entities: 2.6.0 + normalize-newline@4.1.0: dependencies: replace-buffer: 1.2.1 normalize-url@8.0.2: {} + nwsapi@2.2.21: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8260,6 +8673,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + param-case@2.1.1: dependencies: no-case: 2.3.2 @@ -8281,6 +8696,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} passthrough-counter@1.0.0: {} @@ -8547,6 +8966,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -8593,6 +9014,8 @@ snapshots: transitivePeerDependencies: - supports-color + rrweb-cssom@0.8.0: {} + rss-parser@3.13.0: dependencies: entities: 2.2.0 @@ -8634,6 +9057,16 @@ snapshots: 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@7.7.2: {} @@ -8962,6 +9395,18 @@ snapshots: dependencies: 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: {} token-types@6.0.4: @@ -8969,6 +9414,10 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@3.0.0: dependencies: punycode: 2.3.1 @@ -8979,6 +9428,8 @@ snapshots: tree-kill@1.2.2: {} + treeify@1.1.0: {} + trim-lines@3.0.1: {} triple-beam@1.4.1: {} @@ -9095,10 +9546,20 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.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@4.0.0: {} + whatwg-url@11.0.0: dependencies: tr46: 3.0.0 @@ -9159,6 +9620,8 @@ snapshots: dependencies: sax: 1.4.1 + xml-name-validator@5.0.0: {} + xml2js@0.5.0: dependencies: sax: 1.4.1 @@ -9173,8 +9636,14 @@ snapshots: xmlbuilder@11.0.1: {} + xmlchars@2.2.0: {} + + xmldom@0.6.0: {} + xmlhttprequest-ssl@2.1.2: {} + xpath@0.0.34: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/readme.md b/readme.md index daf7b51..d30578f 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,7 @@ # @fin.cx/skr 📊 > **Enterprise-grade German accounting standards implementation for SKR03 and SKR04** -> Rock-solid 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? @@ -17,6 +17,9 @@ Building compliant German accounting software? You've come to the right place! T - **🔄 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 @@ -76,6 +79,47 @@ const journalEntry = await api.postJournalEntry({ }); ``` +### 🧾 E-Invoice Integration + +```typescript +// 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 @@ -109,6 +153,71 @@ const cashFlow = await api.generateCashFlowStatement({ }); ``` +### 📑 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 @@ -316,6 +425,50 @@ try { } ``` +### 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 @@ -346,7 +499,12 @@ import type { IPaginationParams, IAccountBalance, ICashFlowStatement, - IGeneralLedger + IGeneralLedger, + IInvoice, + IInvoiceLine, + IInvoiceParty, + IBookingRules, + IValidationResult } from '@fin.cx/skr'; // All operations are fully typed @@ -413,7 +571,22 @@ async function performJahresabschluss() { ] }); - // 2. Generate financial statements + // 2. Generate comprehensive annual closing package + const jahresabschluss = await api.exportJahresabschluss({ + year: 2024, + includeReports: ['balance_sheet', 'income_statement', 'cash_flow', 'trial_balance'], + format: 'pdf', + language: 'de', + signatureRequired: true, + companyInfo: { + name: 'Mustermann GmbH', + address: 'Hauptstraße 1, 10115 Berlin', + taxNumber: 'DE123456789', + registrationNumber: 'HRB 12345' + } + }); + + // 3. Generate individual reports for analysis const incomeStatement = await api.generateIncomeStatement({ dateFrom: new Date('2024-01-01'), dateTo: new Date('2024-12-31') @@ -423,34 +596,37 @@ async function performJahresabschluss() { date: new Date('2024-12-31') }); - const trialBalance = await api.generateTrialBalance({ - dateFrom: new Date('2024-01-01'), - dateTo: new Date('2024-12-31') - }); - const cashFlow = await api.generateCashFlowStatement({ dateFrom: new Date('2024-01-01'), dateTo: new Date('2024-12-31') }); - // 3. Export for tax advisor + // 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') }); - // 4. Close the period + // 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 ==='); - console.log(`Umsatz: €${incomeStatement.totalRevenue}`); - console.log(`Aufwendungen: €${incomeStatement.totalExpenses}`); - console.log(`Jahresergebnis: €${incomeStatement.netIncome}`); - console.log(`Bilanzsumme: €${balanceSheet.assets.totalAssets}`); - console.log(`Cash Flow: €${cashFlow.netCashFlow}`); + 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'); await api.close(); @@ -472,6 +648,9 @@ performJahresabschluss().catch(console.error); | **`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 @@ -489,6 +668,13 @@ performJahresabschluss().catch(console.error); | `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 | @@ -505,6 +691,9 @@ performJahresabschluss().catch(console.error); - **📚 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 @@ -525,6 +714,8 @@ 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 diff --git a/ts/index.ts b/ts/index.ts index d644d80..5ab89ce 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -8,3 +8,9 @@ export * from './skr.classes.reports.js'; export * from './skr.api.js'; export * from './skr03.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'; diff --git a/ts/plugins.ts b/ts/plugins.ts index dca7087..c8fc801 100644 --- a/ts/plugins.ts +++ b/ts/plugins.ts @@ -3,5 +3,26 @@ import * as smartdata from '@push.rocks/smartdata'; import * as smartunique from '@push.rocks/smartunique'; import * as smarttime from '@push.rocks/smarttime'; 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 +}; diff --git a/ts/skr.api.ts b/ts/skr.api.ts index 4e7be7f..6836cba 100644 --- a/ts/skr.api.ts +++ b/ts/skr.api.ts @@ -1,10 +1,28 @@ import * as plugins from './plugins.js'; +import * as path from 'path'; import { ChartOfAccounts } from './skr.classes.chartofaccounts.js'; import { Ledger } from './skr.classes.ledger.js'; import { Reports } from './skr.classes.reports.js'; import { Account } from './skr.classes.account.js'; import { Transaction } from './skr.classes.transaction.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 { IDatabaseConfig, TSKRType, @@ -17,6 +35,7 @@ import type { ITrialBalanceReport, IIncomeStatement, IBalanceSheet, + IAccountBalance, } from './skr.types.js'; /** @@ -29,6 +48,9 @@ export class SkrApi { private logger: plugins.smartlog.Smartlog; private initialized: boolean = false; private currentSKRType: TSKRType | null = null; + private invoiceAdapter: InvoiceAdapter | null = null; + private invoiceStorage: InvoiceStorage | null = null; + private invoiceBookingEngine: InvoiceBookingEngine | null = null; constructor(private config: IDatabaseConfig) { this.chartOfAccounts = new ChartOfAccounts(config); @@ -62,6 +84,13 @@ export class SkrApi { this.currentSKRType = skrType; this.ledger = new Ledger(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.logger.log('info', 'SKR API initialized successfully'); @@ -350,6 +379,262 @@ export class SkrApi { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 ========== /** @@ -532,4 +817,204 @@ export class SkrApi { 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 { + 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, + options?: IBookingOptions + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + 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); + } } diff --git a/ts/skr.export.accounts.ts b/ts/skr.export.accounts.ts new file mode 100644 index 0000000..1df26e6 --- /dev/null +++ b/ts/skr.export.accounts.ts @@ -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 { + 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 { + 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 = []; + } +} \ No newline at end of file diff --git a/ts/skr.export.balances.ts b/ts/skr.export.balances.ts new file mode 100644 index 0000000..d8e28d6 --- /dev/null +++ b/ts/skr.export.balances.ts @@ -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 { + 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 { + 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 { + 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 { + 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 = []; + } +} \ No newline at end of file diff --git a/ts/skr.export.ledger.ts b/ts/skr.export.ledger.ts new file mode 100644 index 0000000..93a0a75 --- /dev/null +++ b/ts/skr.export.ledger.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/ts/skr.export.pdf.ts b/ts/skr.export.pdf.ts new file mode 100644 index 0000000..44c8f9d --- /dev/null +++ b/ts/skr.export.pdf.ts @@ -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 { + this.pdfInstance = new plugins.smartpdf.SmartPdf(); + await this.pdfInstance.start(); + } + + /** + * Generates the trial balance PDF report + */ + public async generateTrialBalancePdf(report: ITrialBalanceReport): Promise { + 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 { + 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 { + 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 { + 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 => ` + + ${entry.accountNumber} + ${entry.accountName} + ${this.formatGermanNumber(0)} + ${this.formatGermanNumber(entry.debitBalance)} + ${this.formatGermanNumber(entry.creditBalance)} + ${this.formatGermanNumber(entry.netBalance)} + + `).join(''); + + return ` + + + + + + + + ${this.generateHeader('Summen- und Saldenliste')} + + + + + + + + + + + + + + ${tableRows} + + + + + + + + + +
KontoBezeichnungAnfangssaldoSollHabenSaldo
Summe${this.formatGermanNumber(report.totalDebits)}${this.formatGermanNumber(report.totalCredits)}${this.formatGermanNumber(report.totalDebits - report.totalCredits)}
+ + ${this.generateFooter()} + + + `; + } + + /** + * Generates HTML for income statement report + */ + private generateIncomeStatementHtml(report: IIncomeStatement): string { + const revenueRows = (report.revenue || []).map(entry => ` + + ${entry.accountNumber} + ${entry.accountName} + ${this.formatGermanNumber(entry.amount)} + + `).join(''); + + const expenseRows = (report.expenses || []).map(entry => ` + + ${entry.accountNumber} + ${entry.accountName} + ${this.formatGermanNumber(entry.amount)} + + `).join(''); + + return ` + + + + + + + + ${this.generateHeader('Gewinn- und Verlustrechnung')} + +

Erträge

+ + + + + + + + + + ${revenueRows} + + + + + + + +
KontoBezeichnungBetrag
Summe Erträge${this.formatGermanNumber(report.totalRevenue)}
+ +

Aufwendungen

+ + + + + + + + + + ${expenseRows} + + + + + + + +
KontoBezeichnungBetrag
Summe Aufwendungen${this.formatGermanNumber(report.totalExpenses)}
+ +
+

Ergebnis

+ + + + + + + + + + + + + +
Erträge${this.formatGermanNumber(report.totalRevenue)}
Aufwendungen- ${this.formatGermanNumber(report.totalExpenses)}
${report.netIncome >= 0 ? 'Jahresüberschuss' : 'Jahresfehlbetrag'} + ${this.formatGermanNumber(report.netIncome)} +
+
+ + ${this.generateFooter()} + + + `; + } + + /** + * Generates HTML for balance sheet report + */ + private generateBalanceSheetHtml(report: IBalanceSheet): string { + const assetRows = [...(report.assets.current || []), ...(report.assets.fixed || [])].map(entry => ` + + ${entry.accountNumber} + ${entry.accountName} + ${this.formatGermanNumber(entry.amount)} + + `).join(''); + + const liabilityRows = [...(report.liabilities.current || []), ...(report.liabilities.longTerm || [])].map(entry => ` + + ${entry.accountNumber} + ${entry.accountName} + ${this.formatGermanNumber(entry.amount)} + + `).join(''); + + const equityRows = (report.equity.entries || []).map(entry => ` + + ${entry.accountNumber} + ${entry.accountName} + ${this.formatGermanNumber(entry.amount)} + + `).join(''); + + return ` + + + + + + + + ${this.generateHeader('Bilanz')} + +
+
+

Aktiva

+ + + + + + + + + + ${assetRows} + + + + + + + +
KontoBezeichnungBetrag
Summe Aktiva${this.formatGermanNumber(report.assets.totalAssets)}
+
+ +
+

Passiva

+ +

Eigenkapital

+ + + ${equityRows} + + + + + + + +
Summe Eigenkapital${this.formatGermanNumber(report.equity.totalEquity)}
+ +

Fremdkapital

+ + + ${liabilityRows} + + + + + + + +
Summe Fremdkapital${this.formatGermanNumber(report.liabilities.totalLiabilities)}
+ + + + + + +
Summe Passiva${this.formatGermanNumber(report.liabilities.totalLiabilities + report.equity.totalEquity)}
+
+
+ + ${this.generateFooter()} + + + `; + } + + /** + * Generates comprehensive Jahresabschluss HTML + */ + private generateJahresabschlussHtml( + trialBalance: ITrialBalanceReport, + incomeStatement: IIncomeStatement, + balanceSheet: IBalanceSheet + ): string { + return ` + + + + + + + +
+

Jahresabschluss

+

${this.options.companyName}

+

Geschäftsjahr ${this.options.fiscalYear}

+

${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}

+ +
+

Inhalt

+
    +
  • 1. Bilanz
  • +
  • 2. Gewinn- und Verlustrechnung
  • +
  • 3. Summen- und Saldenliste
  • +
+
+
+ +
+ ${this.generateBalanceSheetHtml(balanceSheet)} + +
+ ${this.generateIncomeStatementHtml(incomeStatement)} + +
+ ${this.generateTrialBalanceHtml(trialBalance)} + + + `; + } + + /** + * Generates the report header + */ + private generateHeader(reportTitle: string): string { + return ` +
+

${this.options.companyName}

+ ${this.options.companyAddress ? `

${this.options.companyAddress}

` : ''} + ${this.options.taxId ? `

Steuernummer: ${this.options.taxId}

` : ''} + ${this.options.registrationNumber ? `

Handelsregister: ${this.options.registrationNumber}

` : ''} +
+

${reportTitle}

+

Periode: ${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}

+
+ `; + } + + /** + * Generates the report footer + */ + private generateFooter(): string { + const preparedDate = this.options.preparedDate || new Date(); + return ` + + `; + } + + /** + * 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 { + 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 { + if (this.pdfInstance) { + await this.pdfInstance.stop(); + this.pdfInstance = null; + } + } +} \ No newline at end of file diff --git a/ts/skr.export.ts b/ts/skr.export.ts new file mode 100644 index 0000000..d6dea1b --- /dev/null +++ b/ts/skr.export.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/ts/skr.invoice.adapter.ts b/ts/skr.invoice.adapter.ts new file mode 100644 index 0000000..06b5bc7 --- /dev/null +++ b/ts/skr.invoice.adapter.ts @@ -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 { + 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 { + // 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 { + 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 = { + '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 = { + '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 { + const hash = await plugins.smarthash.sha256FromString(xmlContent); + return hash; + } + + /** + * Convert invoice to different format + */ + public async convertFormat( + invoice: IInvoice, + targetFormat: TInvoiceFormat + ): Promise { + 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, + 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): 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 + }; + } +} \ No newline at end of file diff --git a/ts/skr.invoice.booking.ts b/ts/skr.invoice.booking.ts new file mode 100644 index 0000000..96ae676 --- /dev/null +++ b/ts/skr.invoice.booking.ts @@ -0,0 +1,738 @@ +import * as plugins from './plugins.js'; +import { JournalEntry } from './skr.classes.journalentry.js'; +import { SKRInvoiceMapper } from './skr.invoice.mapper.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, + options?: IBookingOptions + ): Promise { + 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 { + 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) + }); + } else { + // Regular invoice: debit expense account + lines.push({ + accountNumber, + debit: Math.abs(amount), + description: this.getAccountDescription(accountNumber, group) + }); + } + } + + // 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}` + }); + } else { + // Regular invoice: credit vendor account + lines.push({ + accountNumber: controlAccount, + credit: totalAmount, + description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}` + }); + } + + 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) + }); + } else { + // Regular invoice: credit revenue account + lines.push({ + accountNumber, + credit: Math.abs(amount), + description: this.getAccountDescription(accountNumber, group) + }); + } + } + + // 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}` + }); + } else { + // Regular invoice: debit customer account + lines.push({ + accountNumber: controlAccount, + debit: totalAmount, + description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}` + }); + } + + 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}%`; + + if (direction === 'input') { + // Input VAT (Vorsteuer) + if (reverseDirection) { + lines.push({ accountNumber: vatAccount, credit: amount, description }); + } else { + lines.push({ accountNumber: vatAccount, debit: amount, description }); + } + } else { + // Output VAT (Umsatzsteuer) + if (reverseDirection) { + lines.push({ accountNumber: vatAccount, debit: amount, description }); + } else { + lines.push({ accountNumber: vatAccount, credit: amount, description }); + } + } + } + + 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}%` + }, + { + accountNumber: outputVATAccount, + credit: amount, + description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%` + } + ); + } + + return lines; + } + + /** + * Group invoice lines by account + */ + private groupLinesByAccount( + invoice: IInvoice, + rules: IBookingRules + ): Record { + const groups: Record = {}; + + 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 { + 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}` + }, + { + accountNumber: '1000', // Bank account (would be configurable) + credit: paymentAmount, + description: `Bank payment ${payment.endToEndId || payment.paymentId}` + } + ); + + // Book skonto if taken + if (skontoAmount > 0) { + const skontoAccounts = this.mapper.getSkontoAccounts(invoice); + lines.push({ + accountNumber: skontoAccounts.skontoAccount, + credit: skontoAmount, + description: `Skonto received` + }); + + // 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` + } + ); + } + } + } else { + // Payment from customer + lines.push( + { + accountNumber: '1000', // Bank account + debit: paymentAmount, + description: `Payment from ${invoice.customer.name}` + }, + { + accountNumber: controlAccount, + credit: fullAmount, + description: `Customer payment ${payment.endToEndId || payment.paymentId}` + } + ); + + // Book skonto if granted + if (skontoAmount > 0) { + const skontoAccounts = this.mapper.getSkontoAccounts(invoice); + lines.push({ + accountNumber: skontoAccounts.skontoAccount, + debit: skontoAmount, + description: `Skonto granted` + }); + + // 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` + } + ); + } + } + } + + // 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(); + 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(); + 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(); + 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); + } +} \ No newline at end of file diff --git a/ts/skr.invoice.entity.ts b/ts/skr.invoice.entity.ts new file mode 100644 index 0000000..40e480f --- /dev/null +++ b/ts/skr.invoice.entity.ts @@ -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; + customerDefaults?: Record; + productCategoryMapping?: Record; + }; +} + +/** + * 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; + vendorMapping?: Record; + customerMapping?: Record; + + // Skonto + skontoMethod?: 'net' | 'gross'; + skontoExpenseAccount?: string; + skontoRevenueAccount?: string; +} \ No newline at end of file diff --git a/ts/skr.invoice.mapper.ts b/ts/skr.invoice.mapper.ts new file mode 100644 index 0000000..8cd6d60 --- /dev/null +++ b/ts/skr.invoice.mapper.ts @@ -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 = { + '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 { + 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 = { + '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 = { + // 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); + } +} \ No newline at end of file diff --git a/ts/skr.invoice.storage.ts b/ts/skr.invoice.storage.ts new file mode 100644 index 0000000..6acccc7 --- /dev/null +++ b/ts/skr.invoice.storage.ts @@ -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; + 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 { + 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 { + 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 { + 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 = { + 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 { + // 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 { + 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 { + 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 { + const report = { + timestamp: new Date().toISOString(), + totalInvoices: this.metadataCache.size, + validInvoices: 0, + invalidInvoices: 0, + warnings: 0, + byFormat: {} as Record, + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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}`); + } +} \ No newline at end of file diff --git a/ts/skr.security.ts b/ts/skr.security.ts new file mode 100644 index 0000000..d612451 --- /dev/null +++ b/ts/skr.security.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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; + } +} \ No newline at end of file diff --git a/ts/skr.types.ts b/ts/skr.types.ts index 46a0033..ef02eab 100644 --- a/ts/skr.types.ts +++ b/ts/skr.types.ts @@ -136,6 +136,7 @@ export interface ITransactionFilter { export interface IDatabaseConfig { mongoDbUrl: string; dbName?: string; + invoiceExportPath?: string; // Optional path for invoice storage } export interface IReportParams {