Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dc566a709 | |||
| 0c20005db2 | |||
| b9df310aaf | |||
| 40ffc2b355 | |||
| cb6b3db15a | |||
| 119c12901a | |||
| d21876c14f | |||
| 4f1066da2e | |||
| 73b46f7857 | |||
| 08d7803be2 | |||
| db46612ea2 | |||
| 10ca6f2992 | |||
| f42c8539a6 | |||
| c7f06b6529 |
@@ -17,3 +17,4 @@ dist/
|
||||
dist_*/
|
||||
|
||||
#------# custom
|
||||
.serena
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"npmci": {
|
||||
"npmGlobalTools": [],
|
||||
"npmAccessLevel": "public"
|
||||
},
|
||||
"gitzone": {
|
||||
"@git.zone/cli": {
|
||||
"projectType": "npm",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -12,6 +8,16 @@
|
||||
"description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping",
|
||||
"npmPackagename": "@fin.cx/skr",
|
||||
"license": "MIT"
|
||||
},
|
||||
"release": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
},
|
||||
"@ship.zone/szci": {
|
||||
"npmGlobalTools": []
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["/npmextra.json"],
|
||||
"fileMatch": ["/.smartconfig.json"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,10 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-16 - 1.3.0 - feat(deps)
|
||||
bump @fin.cx/einvoice to 5.2.0
|
||||
|
||||
- updates the @fin.cx/einvoice dependency from 5.1.4 to 5.2.0
|
||||
|
||||
## 2026-04-16 - 1.2.2 - fix(exports)
|
||||
stabilize published types and compatibility with updated dependencies
|
||||
|
||||
- replace wildcard package exports with explicit runtime and type exports in the public entrypoint
|
||||
- add a strict consumer type-check fixture and test script to verify published declaration files
|
||||
- adapt filesystem, PDF, e-invoice, and signing integrations to updated dependency APIs
|
||||
- harden error handling and initialization checks across API, chart of accounts, invoice, and journal workflows
|
||||
|
||||
## 2025-10-28 - 1.2.1 - fix(skr.classes.account)
|
||||
Remove incorrect SKR04 automatic account 3300; improve VAT posting validation and test isolation; update readme hints and CI settings
|
||||
|
||||
- ts/skr.classes.account.ts: Removed account '3300' from the SKR04 automatic accounts list (3300 is Fahrzeugkosten and must be postable).
|
||||
- ts/skr.postingkeys.ts: Relax VAT amount requirement — VAT amount is no longer required when posting to VAT accounts or to debtor/creditor accounts (settlement lines).
|
||||
- ts/skr.classes.journalentry.ts: Detect VAT lines in journal entries and pass VAT-aware context into posting key validation to avoid false-positive VAT errors.
|
||||
- test/test.skr04.ts: Use timestamped database names to ensure isolated test runs and avoid DB conflicts during CI.
|
||||
- readme.hints.md: Updated status and notes (tests passing, recent fixes, architecture notes and validation pipeline).
|
||||
- .claude/settings.local.json: Added local CI/agent permission settings used by the project environment.
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
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
|
||||
- SKR standard validation in postJournalEntry to ensure accounts match official SKR03/SKR04 data
|
||||
- Module-level Maps for O(1) SKR standard lookups
|
||||
- validateAccountsAgainstSKR method for checking account type and class compliance
|
||||
- Smart validation that allows SKR04 class 8 custom accounts
|
||||
- Warning logs for non-standard accounts and type/class mismatches
|
||||
|
||||
### Fixed
|
||||
- Test isolation issues by adding timestamps to database names
|
||||
- SKR04 test using correct account mappings (9xxx equity accounts)
|
||||
|
||||
### Changed
|
||||
- Enhanced README with accurate API documentation and testing instructions
|
||||
- Updated legal section to Task Venture Capital GmbH
|
||||
|
||||
## [1.0.0] - 2025-01-09
|
||||
|
||||
### Added
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Task Venture Capital GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+25
-12
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "@fin.cx/skr",
|
||||
"version": "1.0.0",
|
||||
"version": "1.3.0",
|
||||
"description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "tstest test/ --verbose --logfile --timeout=60",
|
||||
"test": "tstest test/ --verbose --logfile --timeout=60 && pnpm run test:published-types",
|
||||
"test:published-types": "pnpm build && pnpm exec tsc --pretty false -p test/fixtures/strict-consumer/tsconfig.json",
|
||||
"build": "tsbuild --web --node",
|
||||
"buildDocs": "tsdoc"
|
||||
},
|
||||
@@ -25,15 +26,27 @@
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.11.0",
|
||||
"dependencies": {
|
||||
"@push.rocks/smartdata": "^5.15.1",
|
||||
"@push.rocks/smartlog": "^3.1.8",
|
||||
"@push.rocks/smarttime": "^4.1.1",
|
||||
"@push.rocks/smartunique": "^3.0.9"
|
||||
"@e-invoice-eu/core": "^3.1.0",
|
||||
"@fin.cx/einvoice": "5.2.0",
|
||||
"@push.rocks/smartdata": "^7.1.7",
|
||||
"@push.rocks/smartfile": "^13.1.2",
|
||||
"@push.rocks/smartfs": "^1.5.0",
|
||||
"@push.rocks/smarthash": "^3.2.6",
|
||||
"@push.rocks/smartlog": "^3.2.2",
|
||||
"@push.rocks/smartpath": "^6.0.0",
|
||||
"@push.rocks/smartpdf": "^4.2.0",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"@push.rocks/smartunique": "^3.0.9",
|
||||
"merkletreejs": "^0.6.0",
|
||||
"node-forge": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^2.6.4",
|
||||
"@git.zone/tsrun": "^1.3.3",
|
||||
"@git.zone/tstest": "^2.3.2"
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.2",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@push.rocks/qenv": "^6.1.3",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node-forge": "^1.3.14"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -43,7 +56,6 @@
|
||||
"url": "https://code.foss.global/fin.cx/skr/issues"
|
||||
},
|
||||
"homepage": "https://code.foss.global/fin.cx/skr#readme",
|
||||
"private": true,
|
||||
"files": [
|
||||
"ts/**/*",
|
||||
"ts_web/**/*",
|
||||
@@ -53,8 +65,9 @@
|
||||
"dist_ts_web/**/*",
|
||||
"assets/**/*",
|
||||
"cli.js",
|
||||
"npmextra.json",
|
||||
"readme.md"
|
||||
".smartconfig.json",
|
||||
"readme.md",
|
||||
"license.md"
|
||||
],
|
||||
"pnpm": {
|
||||
"overrides": {}
|
||||
|
||||
Generated
+4277
-4455
File diff suppressed because it is too large
Load Diff
+54
-1
@@ -1,3 +1,56 @@
|
||||
# Project Readme Hints
|
||||
|
||||
This is the initial readme hints file.
|
||||
## Current Status (2025-10-27)
|
||||
|
||||
### Test Results
|
||||
✅ **ALL 65/65 TESTS PASSING** (100%)
|
||||
|
||||
### Recent Fixes
|
||||
|
||||
#### Fixed: SKR04 Bug (Account 3300 Misclassification)
|
||||
**Problem**: Account 3300 was incorrectly hardcoded as an automatic account for SKR04
|
||||
**Root Cause**: Bug in `ts/skr.classes.account.ts:192` - account 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
|
||||
**Solution**:
|
||||
1. Removed 3300 from automatic accounts list in `isAutomaticAccount()` method
|
||||
2. Updated test.skr04.ts to use timestamped database names to avoid conflicts
|
||||
**Files Changed**:
|
||||
- `ts/skr.classes.account.ts` - Fixed automatic account detection
|
||||
- `test/test.skr04.ts` - Added timestamp to database name
|
||||
|
||||
**Result**: ✅ All SKR04 tests now passing (jahresabschluss.skr04 + basic SKR04 tests)
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
#### VAT Validation Logic (Recent Changes)
|
||||
- **skr.classes.journalentry.ts:224-273**: Detects VAT lines in entries to enable smart validation
|
||||
- **skr.postingkeys.ts:87-100**: Exempts VAT accounts and debtor/creditor accounts from VAT amount requirements
|
||||
- **Rationale**: VAT accounts ARE the VAT; settlement transactions don't need VAT details again
|
||||
|
||||
#### Posting Key Usage Pattern
|
||||
- **Tax-free operations** (key 40): Internal adjustments, depreciation, closing entries
|
||||
- **VAT operations** (keys 3, 8, 9, 19, 94): Customer/supplier transactions
|
||||
- **Best practice**: Use posting key 40 for non-VAT lines in mixed entries
|
||||
|
||||
#### Account Structure
|
||||
- **Automatic accounts**: Cannot be posted to directly (1400 Debtors, 1600 Creditors, 3300 Bank)
|
||||
- **Personal accounts**: Created in ranges 10000-69999 (debtors), 70000-99999 (creditors)
|
||||
- **System enforces**: Must use personal variants instead of automatic accounts
|
||||
|
||||
### Validation Pipeline
|
||||
1. **Line-level**: Posting key required, account exists, VAT rules
|
||||
2. **Posting key level**: VAT amount requirements (with exemptions)
|
||||
3. **Consistency level**: No mixing tax-free and taxed (unless intentional)
|
||||
4. **Balance level**: Debits must equal credits (0.01 tolerance)
|
||||
|
||||
### Test Coverage
|
||||
- 65 test cases covering full accounting cycle
|
||||
- Complete Jahresabschluss (annual closing) workflow in SKR03
|
||||
- Report generation (Trial Balance, Income Statement, Balance Sheet)
|
||||
- Transaction reversal and audit trails
|
||||
- DATEV posting key validation
|
||||
|
||||
### Dependencies
|
||||
- MongoDB via @push.rocks/smartdata for persistence
|
||||
- TypeScript 5.8.3 with strict mode
|
||||
- @git.zone/tstest for testing framework
|
||||
- @push.rocks/smartexpect for assertions
|
||||
|
||||
@@ -1,409 +1,321 @@
|
||||
# @fin.cx/skr 📊
|
||||
# @fin.cx/skr
|
||||
|
||||
> **Enterprise-grade German accounting standards implementation for SKR03 and SKR04**
|
||||
> Double-entry bookkeeping with MongoDB persistence and full TypeScript support
|
||||
`@fin.cx/skr` is a TypeScript library for German double-entry bookkeeping with built-in SKR03 and SKR04 chart initialization, MongoDB-backed persistence, reporting, DATEV export, GoBD-oriented Jahresabschluss export, and e-invoice workflows.
|
||||
|
||||
## 🚀 Why @fin.cx/skr?
|
||||
It is built for developers who need a programmable accounting core instead of a pile of CSV glue code: initialize a chart of accounts, post validated transactions and journal entries, generate reports, and archive year-end data in a structured export format.
|
||||
|
||||
Building compliant German accounting software? You've come to the right place! This module provides a **complete, type-safe implementation** of the German standard charts of accounts (Standardkontenrahmen) SKR03 and SKR04, the backbone of professional accounting in Germany.
|
||||
## Issue Reporting and Security
|
||||
|
||||
### 🎯 What makes it awesome?
|
||||
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
||||
|
||||
- **🏢 Enterprise-Ready**: Production-tested implementation following DATEV standards
|
||||
- **⚡ Lightning Fast**: MongoDB-powered with optimized indexing and caching
|
||||
- **🔒 Type-Safe**: Full TypeScript support with comprehensive type definitions
|
||||
- **🎮 Developer-Friendly**: Intuitive API that makes complex accounting operations simple
|
||||
- **📈 Real-time Reporting**: Generate financial statements on-the-fly
|
||||
- **🔄 Transaction Safety**: Built-in double-entry validation and reversals
|
||||
## What This Library Does
|
||||
|
||||
## 📦 Installation
|
||||
- Initializes SKR03 or SKR04 account sets in MongoDB
|
||||
- Enforces double-entry bookkeeping rules for transactions and journal entries
|
||||
- Supports DATEV posting keys and VAT-aware journal lines
|
||||
- Prevents direct posting to automatic accounts that require personal accounts
|
||||
- Generates trial balance, income statement, balance sheet, general ledger, and cash flow reports
|
||||
- Exports accounting data as CSV, DATEV, and GoBD-style Jahresabschluss packages
|
||||
- Imports, stores, searches, books, and exports EN16931-style e-invoices
|
||||
- Adds signing and timestamp helpers for audit-oriented export workflows
|
||||
|
||||
## Why It Is Useful
|
||||
|
||||
- You get a real accounting domain model, not just account lists
|
||||
- SKR03 and SKR04 are both supported behind one API
|
||||
- Tests cover initialization, posting, reversals, reports, pagination, DATEV export, and full year-end flows
|
||||
- The package exports the lower-level classes too, so you can stay high-level with `SkrApi` or build around the primitives
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+ with ESM support
|
||||
- A reachable MongoDB instance
|
||||
- `pnpm`
|
||||
|
||||
The test setup reads MongoDB connection details from `.nogit/` via `@push.rocks/qenv`, but the runtime API only needs a `mongoDbUrl` and an optional `dbName`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Using npm
|
||||
npm install @fin.cx/skr
|
||||
|
||||
# Using pnpm (recommended)
|
||||
pnpm add @fin.cx/skr
|
||||
|
||||
# Using yarn
|
||||
yarn add @fin.cx/skr
|
||||
```
|
||||
|
||||
## 🎓 Quick Start
|
||||
## Quick Start
|
||||
|
||||
### Basic Setup
|
||||
|
||||
```typescript
|
||||
```ts
|
||||
import { SkrApi } from '@fin.cx/skr';
|
||||
|
||||
// Initialize the API
|
||||
const api = new SkrApi({
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
dbName: 'accounting' // optional, defaults to 'skr_accounting'
|
||||
dbName: 'accounting_demo',
|
||||
});
|
||||
|
||||
// Choose your SKR standard (SKR03 or SKR04)
|
||||
await api.initialize('SKR03');
|
||||
```
|
||||
|
||||
### 💰 Posting Transactions
|
||||
|
||||
```typescript
|
||||
// Simple transaction posting
|
||||
const transaction = await api.postTransaction({
|
||||
await api.postTransaction({
|
||||
date: new Date(),
|
||||
debitAccount: '1200', // Bank account
|
||||
creditAccount: '8400', // Revenue account
|
||||
amount: 1190.00,
|
||||
description: 'Invoice #2024-001 payment received',
|
||||
reference: 'INV-2024-001',
|
||||
vatAmount: 190.00
|
||||
debitAccount: '1200',
|
||||
creditAccount: '4000',
|
||||
amount: 1000,
|
||||
description: 'Test sale',
|
||||
reference: 'INV-001',
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Complex journal entry with multiple lines
|
||||
const journalEntry = await api.postJournalEntry({
|
||||
const trialBalance = await api.generateTrialBalance();
|
||||
|
||||
console.log(trialBalance.isBalanced);
|
||||
|
||||
await api.close();
|
||||
```
|
||||
|
||||
## SKR03 vs SKR04
|
||||
|
||||
`initialize('SKR03')` loads the process-oriented chart.
|
||||
|
||||
- Class 4: operating income
|
||||
- Class 5: material costs
|
||||
- Class 6: personnel costs
|
||||
- Class 7: other operating expenses
|
||||
|
||||
`initialize('SKR04')` loads the financial-statement-oriented chart.
|
||||
|
||||
- Class 2 and 3: expenses
|
||||
- Class 4 and 5: revenues
|
||||
- Class 8: reserved as `frei` for custom use
|
||||
|
||||
The test suite exercises both variants and includes full Jahresabschluss scenarios for each.
|
||||
|
||||
## Posting Model
|
||||
|
||||
Simple postings use `postTransaction()`.
|
||||
|
||||
```ts
|
||||
await api.postTransaction({
|
||||
date: new Date(),
|
||||
description: 'Monthly salary payments',
|
||||
reference: 'SAL-2024-03',
|
||||
debitAccount: '5400',
|
||||
creditAccount: '70001',
|
||||
amount: 119,
|
||||
description: 'Purchase including VAT',
|
||||
skrType: 'SKR03',
|
||||
vatAmount: 19,
|
||||
reference: 'VAT-001',
|
||||
});
|
||||
```
|
||||
|
||||
Complex bookings use `postJournalEntry()` with explicit DATEV posting keys.
|
||||
|
||||
```ts
|
||||
await api.postJournalEntry({
|
||||
date: new Date(),
|
||||
description: 'Complex distribution',
|
||||
reference: 'COMPLEX-001',
|
||||
lines: [
|
||||
{ accountNumber: '6000', debit: 5000.00, description: 'Gross salary' },
|
||||
{ accountNumber: '4830', credit: 1000.00, description: 'Social security' },
|
||||
{ accountNumber: '4840', credit: 500.00, description: 'Tax withholding' },
|
||||
{ accountNumber: '1200', credit: 3500.00, description: 'Net payment' }
|
||||
]
|
||||
{ accountNumber: '5000', debit: 500, description: 'Materials', postingKey: 40 },
|
||||
{ accountNumber: '6000', debit: 300, description: 'Wages', postingKey: 40 },
|
||||
{ accountNumber: '7100', debit: 200, description: 'Rent', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 1000, description: 'Bank payment', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
```
|
||||
|
||||
### 📊 Generating Reports
|
||||
Important behavior from the code and tests:
|
||||
|
||||
```typescript
|
||||
// Trial Balance
|
||||
const trialBalance = await api.generateTrialBalance({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31')
|
||||
});
|
||||
- debit and credit totals must balance
|
||||
- debit and credit account cannot be the same in a simple transaction
|
||||
- inactive accounts cannot be posted to
|
||||
- automatic accounts such as debtor or creditor control accounts are meant to be replaced by personal accounts for direct postings
|
||||
|
||||
// Income Statement (P&L)
|
||||
const incomeStatement = await api.generateIncomeStatement({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31')
|
||||
});
|
||||
## Common Workflows
|
||||
|
||||
// Balance Sheet
|
||||
const balanceSheet = await api.generateBalanceSheet({
|
||||
date: new Date('2024-12-31')
|
||||
});
|
||||
Create custom accounts:
|
||||
|
||||
// Export for DATEV
|
||||
const datevExport = await api.exportDatev({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
format: 'CSV'
|
||||
```ts
|
||||
await api.createAccount({
|
||||
accountNumber: '4999',
|
||||
accountName: 'Custom Revenue Account',
|
||||
accountClass: 4,
|
||||
accountType: 'revenue',
|
||||
description: 'Test custom account',
|
||||
});
|
||||
```
|
||||
|
||||
## 🏗️ Core Architecture
|
||||
Batch operations:
|
||||
|
||||
### Account Management
|
||||
|
||||
```typescript
|
||||
// Create custom accounts
|
||||
const account = await api.createAccount({
|
||||
accountNumber: '1299',
|
||||
accountName: 'PayPal Business',
|
||||
accountClass: 1,
|
||||
accountType: 'asset',
|
||||
description: 'PayPal business account for online payments',
|
||||
isActive: true
|
||||
});
|
||||
|
||||
// Search accounts
|
||||
const accounts = await api.searchAccounts('bank');
|
||||
|
||||
// Get account balance
|
||||
const balance = await api.getAccountBalance('1200');
|
||||
console.log(`Balance: ${balance.balance} EUR`);
|
||||
console.log(`Debits: ${balance.debitTotal} EUR`);
|
||||
console.log(`Credits: ${balance.creditTotal} EUR`);
|
||||
```
|
||||
|
||||
### Transaction Management
|
||||
|
||||
```typescript
|
||||
// Get transaction history
|
||||
const transactions = await api.listTransactions({
|
||||
accountNumber: '1200',
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
minAmount: 100,
|
||||
maxAmount: 10000
|
||||
});
|
||||
|
||||
// Reverse a transaction
|
||||
const reversal = await api.reverseTransaction(transactionId);
|
||||
|
||||
// Batch processing
|
||||
const batchResults = await api.postBatchTransactions([
|
||||
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 100 },
|
||||
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 200 },
|
||||
{ date: new Date(), debitAccount: '1200', creditAccount: '8400', amount: 300 }
|
||||
```ts
|
||||
await api.createBatchAccounts([
|
||||
{
|
||||
accountNumber: '10001',
|
||||
accountName: 'Kunde Mustermann GmbH',
|
||||
accountClass: 1,
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR03',
|
||||
},
|
||||
{
|
||||
accountNumber: '70001',
|
||||
accountName: 'Lieferant Test GmbH',
|
||||
accountClass: 7,
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR03',
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## 📚 SKR03 vs SKR04: Which One to Choose?
|
||||
Pagination:
|
||||
|
||||
### SKR03 - Process Structure Principle (Prozessgliederungsprinzip)
|
||||
**Best for:** 🛍️ Trading companies, 💼 Service providers, 🏪 Retail businesses
|
||||
|
||||
- Accounts organized by **business process flow**
|
||||
- Easier mapping to operational workflows
|
||||
- Natural progression from purchasing → inventory → sales
|
||||
- Popular with small to medium enterprises
|
||||
|
||||
### SKR04 - Financial Classification Principle (Abschlussgliederungsprinzip)
|
||||
**Best for:** 🏭 Manufacturing companies, 🏗️ Large corporations, 📈 Public companies
|
||||
|
||||
- Accounts organized by **financial statement structure**
|
||||
- Direct mapping to balance sheet and P&L positions
|
||||
- Simplified financial reporting and analysis
|
||||
- Preferred by auditors and financial institutions
|
||||
|
||||
## 🎯 Account Structure
|
||||
|
||||
Both SKR standards follow the same hierarchical structure:
|
||||
|
||||
```
|
||||
[0-9] → Account Class (Kontenklasse)
|
||||
[0-9] → Account Group (Kontengruppe)
|
||||
[0-9] → Account Subgroup (Kontenuntergruppe)
|
||||
[0-9] → Individual Account (Einzelkonto)
|
||||
```ts
|
||||
const page1 = await api.getAccountsPaginated(1, 10);
|
||||
console.log(page1.total, page1.totalPages, page1.data.length);
|
||||
```
|
||||
|
||||
### Account Classes Overview
|
||||
Reversals and validation:
|
||||
|
||||
| Class | SKR03 Description | SKR04 Description | Type |
|
||||
|-------|------------------|-------------------|------|
|
||||
| **0** | Fixed Assets | Fixed Assets | Asset |
|
||||
| **1** | Current Assets | Current Assets | Asset |
|
||||
| **2** | Equity | Equity | Equity |
|
||||
| **3** | Liabilities | Liabilities | Liability |
|
||||
| **4** | Operating Income | Operating Income | Revenue |
|
||||
| **5** | Cost of Materials | Cost of Materials | Expense |
|
||||
| **6** | Operating Expenses | Other Operating Costs | Expense |
|
||||
| **7** | Other Income/Expenses | Other Income/Expenses | Mixed |
|
||||
| **8** | --- | Financial Results | Mixed |
|
||||
| **9** | Closing Accounts | Closing Accounts | System |
|
||||
|
||||
## 🔧 Advanced Features
|
||||
|
||||
### Ledger Operations
|
||||
|
||||
```typescript
|
||||
import { Ledger } from '@fin.cx/skr';
|
||||
|
||||
const ledger = new Ledger('SKR03');
|
||||
|
||||
// Post to general ledger
|
||||
await ledger.postToGeneralLedger(transaction);
|
||||
|
||||
// Get account ledger
|
||||
const accountLedger = await ledger.getAccountLedger('1200', {
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31')
|
||||
});
|
||||
|
||||
// Close accounting period
|
||||
await ledger.closePeriod('2024-01');
|
||||
```ts
|
||||
const ok = api.validateDoubleEntry(100, 100);
|
||||
const reversed = await api.reverseTransaction(transactionId);
|
||||
```
|
||||
|
||||
### Custom Reporting
|
||||
## Reports And Exports
|
||||
|
||||
```typescript
|
||||
import { Reports } from '@fin.cx/skr';
|
||||
Available reporting methods on `SkrApi`:
|
||||
|
||||
const reports = new Reports('SKR03');
|
||||
- `generateTrialBalance()`
|
||||
- `generateIncomeStatement()`
|
||||
- `generateBalanceSheet()`
|
||||
- `generateGeneralLedger()`
|
||||
- `generateCashFlowStatement()`
|
||||
- `exportReportToCSV()`
|
||||
- `exportToDATEV()`
|
||||
|
||||
// Generate custom report
|
||||
const customReport = await reports.generateCustomReport({
|
||||
accounts: ['1200', '1300', '1400'],
|
||||
Year-end archival export:
|
||||
|
||||
```ts
|
||||
const exportPath = await api.exportJahresabschluss({
|
||||
exportPath: './exports',
|
||||
fiscalYear: 2024,
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
groupBy: 'month',
|
||||
includeSubAccounts: true
|
||||
includeDocuments: true,
|
||||
generatePdfReports: true,
|
||||
signExport: false,
|
||||
timestampExport: false,
|
||||
companyInfo: {
|
||||
name: 'Example GmbH',
|
||||
taxId: 'DE123456789',
|
||||
registrationNumber: 'HRB 12345',
|
||||
address: 'Example Street 1, 28195 Bremen',
|
||||
},
|
||||
});
|
||||
|
||||
// Cash flow statement
|
||||
const cashFlow = await reports.generateCashFlowStatement({
|
||||
year: 2024
|
||||
console.log(exportPath);
|
||||
```
|
||||
|
||||
The export code creates a BagIt-style folder structure with metadata, accounting data, report output, document storage, and manifest hashes.
|
||||
|
||||
## E-Invoice Workflows
|
||||
|
||||
The package includes invoice types and API helpers for importing, storing, booking, searching, exporting, and generating e-invoices.
|
||||
|
||||
Supported invoice directions:
|
||||
|
||||
- `inbound`
|
||||
- `outbound`
|
||||
|
||||
Supported formats in the invoice model:
|
||||
|
||||
- `xrechnung`
|
||||
- `zugferd`
|
||||
- `facturx`
|
||||
- `peppol`
|
||||
- `ubl`
|
||||
|
||||
Example import and booking flow:
|
||||
|
||||
```ts
|
||||
const invoice = await api.importInvoice('./fixtures/invoice.xml', 'inbound', {
|
||||
autoBook: true,
|
||||
confidenceThreshold: 80,
|
||||
});
|
||||
|
||||
const hits = await api.searchInvoices({
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
});
|
||||
|
||||
const exported = await api.exportInvoice(invoice, {
|
||||
format: 'xrechnung',
|
||||
embedInPdf: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Data Import/Export
|
||||
The API also exposes:
|
||||
|
||||
```typescript
|
||||
// Import from CSV
|
||||
const importedCount = await api.importAccountsFromCSV(csvContent);
|
||||
- `bookInvoice()`
|
||||
- `getInvoice()`
|
||||
- `getInvoiceStatistics()`
|
||||
- `createInvoiceComplianceReport()`
|
||||
- `generateInvoice()`
|
||||
|
||||
// Export to CSV
|
||||
const csvExport = await api.exportAccountsToCSV();
|
||||
## Public Exports
|
||||
|
||||
// DATEV-compatible export
|
||||
const datevData = await api.exportDatev({
|
||||
consultantNumber: '12345',
|
||||
clientNumber: '67890',
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31')
|
||||
});
|
||||
Top-level exports include:
|
||||
|
||||
- `SkrApi`
|
||||
- `Account`
|
||||
- `Transaction`
|
||||
- `JournalEntry`
|
||||
- `ChartOfAccounts`
|
||||
- `Ledger`
|
||||
- `Reports`
|
||||
- `SkrExport`
|
||||
- `LedgerExporter`
|
||||
- `AccountsExporter`
|
||||
- `BalancesExporter`
|
||||
- `PdfReportGenerator`
|
||||
- `SecurityManager`
|
||||
- `SKR03_ACCOUNTS`, `SKR04_ACCOUNTS`
|
||||
|
||||
This makes the package usable as both an application-facing API and a toolkit for custom accounting workflows.
|
||||
|
||||
## Development
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 🛡️ Type Safety
|
||||
Test:
|
||||
|
||||
Full TypeScript support with comprehensive type definitions:
|
||||
|
||||
```typescript
|
||||
import type {
|
||||
TSKRType,
|
||||
IAccountData,
|
||||
ITransactionData,
|
||||
IJournalEntry,
|
||||
ITrialBalanceReport,
|
||||
IIncomeStatement,
|
||||
IBalanceSheet
|
||||
} from '@fin.cx/skr';
|
||||
|
||||
// All operations are fully typed
|
||||
const account: IAccountData = {
|
||||
accountNumber: '1200',
|
||||
accountName: 'Bank Account',
|
||||
accountClass: 1,
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR03',
|
||||
vatRate: 0,
|
||||
isActive: true
|
||||
};
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 🌟 Real-World Example
|
||||
Current project checks include:
|
||||
|
||||
Here's a complete example of setting up a basic accounting system:
|
||||
|
||||
```typescript
|
||||
import { SkrApi } from '@fin.cx/skr';
|
||||
|
||||
async function setupAccounting() {
|
||||
// Initialize
|
||||
const api = new SkrApi({
|
||||
mongoDbUrl: process.env.MONGODB_URL!,
|
||||
dbName: 'my_company_accounting'
|
||||
});
|
||||
|
||||
await api.initialize('SKR03');
|
||||
|
||||
// Create custom accounts for your business
|
||||
await api.createAccount({
|
||||
accountNumber: '1299',
|
||||
accountName: 'Stripe Account',
|
||||
accountClass: 1,
|
||||
accountType: 'asset',
|
||||
description: 'Stripe payment gateway account'
|
||||
});
|
||||
|
||||
// Post daily transactions
|
||||
const transactions = [
|
||||
{
|
||||
date: new Date(),
|
||||
debitAccount: '1299', // Stripe
|
||||
creditAccount: '8400', // Revenue
|
||||
amount: 99.00,
|
||||
description: 'SaaS subscription payment',
|
||||
reference: 'stripe_pi_abc123'
|
||||
},
|
||||
{
|
||||
date: new Date(),
|
||||
debitAccount: '5900', // Hosting costs
|
||||
creditAccount: '1200', // Bank
|
||||
amount: 29.99,
|
||||
description: 'AWS monthly bill',
|
||||
reference: 'aws-2024-03'
|
||||
}
|
||||
];
|
||||
|
||||
for (const tx of transactions) {
|
||||
await api.postTransaction(tx);
|
||||
}
|
||||
|
||||
// Generate monthly report
|
||||
const report = await api.generateIncomeStatement({
|
||||
dateFrom: new Date('2024-03-01'),
|
||||
dateTo: new Date('2024-03-31')
|
||||
});
|
||||
|
||||
console.log('Revenue:', report.totalRevenue);
|
||||
console.log('Expenses:', report.totalExpenses);
|
||||
console.log('Net Income:', report.netIncome);
|
||||
|
||||
// Close the connection when done
|
||||
await api.close();
|
||||
}
|
||||
|
||||
setupAccounting().catch(console.error);
|
||||
```
|
||||
|
||||
## 🚦 API Reference
|
||||
|
||||
### Main Classes
|
||||
|
||||
- **`SkrApi`** - Main API entry point
|
||||
- **`ChartOfAccounts`** - Account management
|
||||
- **`Ledger`** - General ledger operations
|
||||
- **`Reports`** - Financial reporting
|
||||
- **`Account`** - Account model
|
||||
- **`Transaction`** - Transaction model
|
||||
- **`JournalEntry`** - Journal entry model
|
||||
|
||||
### Key Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `initialize(skrType)` | Initialize with SKR03 or SKR04 |
|
||||
| `postTransaction(data)` | Post a simple transaction |
|
||||
| `postJournalEntry(data)` | Post a complex journal entry |
|
||||
| `reverseTransaction(id)` | Reverse a posted transaction |
|
||||
| `generateTrialBalance(params)` | Generate trial balance report |
|
||||
| `generateIncomeStatement(params)` | Generate P&L statement |
|
||||
| `generateBalanceSheet(params)` | Generate balance sheet |
|
||||
| `exportDatev(params)` | Export DATEV-compatible data |
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
- **Node.js** >= 18.0.0
|
||||
- **MongoDB** >= 5.0
|
||||
- **TypeScript** >= 5.0 (for development)
|
||||
|
||||
## 🏆 Why Developers Love It
|
||||
|
||||
- **🎯 Zero Configuration**: Pre-configured SKR03/SKR04 accounts out of the box
|
||||
- **🔄 Automatic Validation**: Never worry about unbalanced entries
|
||||
- **📊 Real-time Analytics**: Instant financial insights
|
||||
- **🛡️ Production Ready**: Battle-tested in enterprise environments
|
||||
- **📚 Great Documentation**: You're reading it!
|
||||
- **🤝 Active Community**: Regular updates and support
|
||||
- runtime tests for SKR03 and SKR04 flows
|
||||
- transaction and journal validation
|
||||
- report generation
|
||||
- DATEV export
|
||||
- published type consumption through `test/fixtures/strict-consumer`
|
||||
|
||||
## License and Legal Information
|
||||
|
||||
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
|
||||
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license.md) file.
|
||||
|
||||
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
||||
|
||||
### Trademarks
|
||||
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
|
||||
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
||||
|
||||
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
||||
|
||||
### Company Information
|
||||
|
||||
Task Venture Capital GmbH
|
||||
Registered at District court Bremen HRB 35230 HB, Germany
|
||||
Registered at District Court Bremen HRB 35230 HB, Germany
|
||||
|
||||
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
|
||||
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
||||
|
||||
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|
||||
+1
-1
@@ -18,7 +18,7 @@ TypeScript module implementing SKR03 and SKR04 German accounting standards for d
|
||||
- [ ] @push.rocks/smartdata
|
||||
- [ ] @git.zone/tstest (dev dependency)
|
||||
- [ ] Create tsconfig.json based on @push.rocks/smarthash pattern
|
||||
- [ ] Create npmextra.json for additional configuration
|
||||
- [ ] Create .smartconfig.json for additional configuration
|
||||
- [ ] Create .gitignore file
|
||||
- [ ] Create directory structure
|
||||
- [ ] ts/ directory for source code
|
||||
|
||||
+329
-63
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Banking Application Services Manager
|
||||
# Manages MongoDB and MinIO containers
|
||||
# Generic Services Manager
|
||||
# Manages MongoDB and S3/MinIO containers for any project
|
||||
|
||||
# Color codes for output
|
||||
RED='\033[0;31m'
|
||||
@@ -12,21 +12,6 @@ MAGENTA='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
MONGO_CONTAINER="banking-mongo"
|
||||
MONGO_PORT=27017
|
||||
MONGO_DATA_DIR="$(pwd)/.nogit/mongodata"
|
||||
MONGO_USER="bankingadmin"
|
||||
MONGO_PASS="banking123"
|
||||
MONGO_VERSION="7.0"
|
||||
|
||||
MINIO_CONTAINER="banking-minio"
|
||||
MINIO_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
MINIO_DATA_DIR="$(pwd)/.nogit/miniodata"
|
||||
MINIO_USER="minioadmin"
|
||||
MINIO_PASS="minioadmin"
|
||||
|
||||
# Function to print colored messages
|
||||
print_message() {
|
||||
echo -e "${2}${1}${NC}"
|
||||
@@ -49,6 +34,269 @@ check_docker() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Get project name from package.json or directory
|
||||
get_project_name() {
|
||||
local name=""
|
||||
if [ -f "package.json" ]; then
|
||||
name=$(grep '"name"' package.json | head -1 | cut -d'"' -f4)
|
||||
# Sanitize: @fin.cx/skr → fin-cx-skr
|
||||
echo "$name" | sed 's/@//g' | sed 's/[\/\.]/-/g'
|
||||
else
|
||||
basename "$(pwd)"
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate random available port between 20000-30000
|
||||
get_random_port() {
|
||||
local port
|
||||
local max_attempts=100
|
||||
local attempts=0
|
||||
|
||||
while [ $attempts -lt $max_attempts ]; do
|
||||
port=$((RANDOM % 10001 + 20000))
|
||||
# Check if port is available
|
||||
if ! lsof -i:$port >/dev/null 2>&1 && ! nc -z localhost $port 2>/dev/null; then
|
||||
echo $port
|
||||
return 0
|
||||
fi
|
||||
attempts=$((attempts + 1))
|
||||
done
|
||||
|
||||
# Fallback to finding any available port
|
||||
print_message "Warning: Could not find random port, using system-assigned port" "$YELLOW"
|
||||
echo "0"
|
||||
}
|
||||
|
||||
# Add missing field to JSON file
|
||||
add_json_field() {
|
||||
local file=$1
|
||||
local key=$2
|
||||
local value=$3
|
||||
|
||||
if ! grep -q "\"$key\"" "$file" 2>/dev/null; then
|
||||
# Add the field before the last closing brace
|
||||
local temp_file="${file}.tmp"
|
||||
# Remove last }
|
||||
head -n -1 "$file" > "$temp_file"
|
||||
# Add comma if needed (check if last line ends with })
|
||||
local last_line=$(tail -n 1 "$temp_file")
|
||||
if [[ ! "$last_line" =~ ^[[:space:]]*$ ]] && [[ ! "$last_line" =~ ,$ ]]; then
|
||||
echo "," >> "$temp_file"
|
||||
fi
|
||||
# Add new field and closing brace
|
||||
echo " \"$key\": \"$value\"" >> "$temp_file"
|
||||
echo "}" >> "$temp_file"
|
||||
mv "$temp_file" "$file"
|
||||
return 0 # Field was added
|
||||
fi
|
||||
return 1 # Field already exists
|
||||
}
|
||||
|
||||
# Update or create env.json with defaults
|
||||
update_or_create_env_json() {
|
||||
mkdir -p .nogit
|
||||
|
||||
local project_name=$(get_project_name)
|
||||
local changes_made=false
|
||||
local fields_added=""
|
||||
|
||||
if [ -f ".nogit/env.json" ]; then
|
||||
print_message "📋 Checking .nogit/env.json for missing values..." "$CYAN"
|
||||
|
||||
# Check and add missing fields
|
||||
if add_json_field ".nogit/env.json" "PROJECT_NAME" "$project_name"; then
|
||||
fields_added="${fields_added}PROJECT_NAME, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "MONGODB_HOST" "localhost"; then
|
||||
fields_added="${fields_added}MONGODB_HOST, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "MONGODB_NAME" "$project_name"; then
|
||||
fields_added="${fields_added}MONGODB_NAME, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if ! grep -q "\"MONGODB_PORT\"" ".nogit/env.json" 2>/dev/null; then
|
||||
local mongo_port=$(get_random_port)
|
||||
add_json_field ".nogit/env.json" "MONGODB_PORT" "$mongo_port"
|
||||
fields_added="${fields_added}MONGODB_PORT($mongo_port), "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "MONGODB_USER" "defaultadmin"; then
|
||||
fields_added="${fields_added}MONGODB_USER, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "MONGODB_PASS" "defaultpass"; then
|
||||
fields_added="${fields_added}MONGODB_PASS, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "S3_HOST" "localhost"; then
|
||||
fields_added="${fields_added}S3_HOST, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if ! grep -q "\"S3_PORT\"" ".nogit/env.json" 2>/dev/null; then
|
||||
local s3_port=$(get_random_port)
|
||||
add_json_field ".nogit/env.json" "S3_PORT" "$s3_port"
|
||||
fields_added="${fields_added}S3_PORT($s3_port), "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
# Get S3_PORT for console port calculation
|
||||
local s3_port_value=$(grep '"S3_PORT"' .nogit/env.json | cut -d'"' -f4)
|
||||
if [ ! -z "$s3_port_value" ] && ! grep -q "\"S3_CONSOLE_PORT\"" ".nogit/env.json" 2>/dev/null; then
|
||||
local console_port=$((s3_port_value + 1))
|
||||
# Check if console port is available
|
||||
while lsof -i:$console_port >/dev/null 2>&1 || nc -z localhost $console_port 2>/dev/null; do
|
||||
console_port=$((console_port + 1))
|
||||
done
|
||||
add_json_field ".nogit/env.json" "S3_CONSOLE_PORT" "$console_port"
|
||||
fields_added="${fields_added}S3_CONSOLE_PORT($console_port), "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "S3_USER" "defaultadmin"; then
|
||||
fields_added="${fields_added}S3_USER, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "S3_PASS" "defaultpass"; then
|
||||
fields_added="${fields_added}S3_PASS, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if add_json_field ".nogit/env.json" "S3_BUCKET" "${project_name}-documents"; then
|
||||
fields_added="${fields_added}S3_BUCKET, "
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if [ "$changes_made" = true ]; then
|
||||
# Remove trailing comma and space
|
||||
fields_added=${fields_added%, }
|
||||
print_message "✅ Added missing fields: $fields_added" "$GREEN"
|
||||
else
|
||||
print_message "✅ Configuration complete" "$GREEN"
|
||||
fi
|
||||
|
||||
else
|
||||
# Create new env.json with random ports
|
||||
print_message "📋 Creating .nogit/env.json with default values..." "$YELLOW"
|
||||
|
||||
local mongo_port=$(get_random_port)
|
||||
local s3_port=$(get_random_port)
|
||||
local s3_console_port=$((s3_port + 1))
|
||||
|
||||
# Make sure console port is also available
|
||||
while lsof -i:$s3_console_port >/dev/null 2>&1 || nc -z localhost $s3_console_port 2>/dev/null; do
|
||||
s3_console_port=$((s3_console_port + 1))
|
||||
done
|
||||
|
||||
cat > .nogit/env.json <<EOF
|
||||
{
|
||||
"PROJECT_NAME": "$project_name",
|
||||
"MONGODB_HOST": "localhost",
|
||||
"MONGODB_NAME": "$project_name",
|
||||
"MONGODB_PORT": "$mongo_port",
|
||||
"MONGODB_USER": "defaultadmin",
|
||||
"MONGODB_PASS": "defaultpass",
|
||||
"S3_HOST": "localhost",
|
||||
"S3_PORT": "$s3_port",
|
||||
"S3_CONSOLE_PORT": "$s3_console_port",
|
||||
"S3_USER": "defaultadmin",
|
||||
"S3_PASS": "defaultpass",
|
||||
"S3_BUCKET": "${project_name}-documents"
|
||||
}
|
||||
EOF
|
||||
print_message "✅ Created .nogit/env.json with project defaults" "$GREEN"
|
||||
print_message "📍 MongoDB port: $mongo_port" "$BLUE"
|
||||
print_message "📍 S3 API port: $s3_port" "$BLUE"
|
||||
print_message "📍 S3 Console port: $s3_console_port" "$BLUE"
|
||||
fi
|
||||
}
|
||||
|
||||
# Load configuration from env.json
|
||||
load_config() {
|
||||
# First ensure env.json exists and is complete
|
||||
update_or_create_env_json
|
||||
|
||||
if [ -f ".nogit/env.json" ]; then
|
||||
# Parse JSON (using grep/sed for portability)
|
||||
PROJECT_NAME=$(grep -o '"PROJECT_NAME"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
MONGODB_HOST=$(grep -o '"MONGODB_HOST"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
MONGODB_NAME=$(grep -o '"MONGODB_NAME"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
MONGODB_PORT=$(grep -o '"MONGODB_PORT"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
MONGODB_USER=$(grep -o '"MONGODB_USER"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
MONGODB_PASS=$(grep -o '"MONGODB_PASS"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
|
||||
S3_HOST=$(grep -o '"S3_HOST"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
S3_PORT=$(grep -o '"S3_PORT"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
S3_CONSOLE_PORT=$(grep -o '"S3_CONSOLE_PORT"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
S3_USER=$(grep -o '"S3_USER"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
S3_PASS=$(grep -o '"S3_PASS"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
S3_BUCKET=$(grep -o '"S3_BUCKET"[[:space:]]*:[[:space:]]*"[^"]*"' .nogit/env.json 2>/dev/null | cut -d'"' -f4)
|
||||
fi
|
||||
|
||||
# Fallback to defaults if any value is missing (shouldn't happen after update_or_create_env_json)
|
||||
PROJECT_NAME=${PROJECT_NAME:-$(get_project_name)}
|
||||
MONGODB_HOST=${MONGODB_HOST:-"localhost"}
|
||||
MONGODB_NAME=${MONGODB_NAME:-"$PROJECT_NAME"}
|
||||
MONGODB_PORT=${MONGODB_PORT:-"27017"}
|
||||
MONGODB_USER=${MONGODB_USER:-"defaultadmin"}
|
||||
MONGODB_PASS=${MONGODB_PASS:-"defaultpass"}
|
||||
|
||||
S3_HOST=${S3_HOST:-"localhost"}
|
||||
S3_PORT=${S3_PORT:-"9000"}
|
||||
S3_CONSOLE_PORT=${S3_CONSOLE_PORT:-"9001"}
|
||||
S3_USER=${S3_USER:-"defaultadmin"}
|
||||
S3_PASS=${S3_PASS:-"defaultpass"}
|
||||
S3_BUCKET=${S3_BUCKET:-"${PROJECT_NAME}-documents"}
|
||||
|
||||
# Container names (project-specific to avoid conflicts)
|
||||
MONGO_CONTAINER="${PROJECT_NAME}-mongodb"
|
||||
MINIO_CONTAINER="${PROJECT_NAME}-minio"
|
||||
|
||||
# Data directories
|
||||
MONGO_DATA_DIR="$(pwd)/.nogit/mongodata"
|
||||
MINIO_DATA_DIR="$(pwd)/.nogit/miniodata"
|
||||
|
||||
print_message "📋 Project: $PROJECT_NAME" "$MAGENTA"
|
||||
}
|
||||
|
||||
# Show current configuration
|
||||
show_config() {
|
||||
print_header "Current Configuration"
|
||||
|
||||
print_message "Project: $PROJECT_NAME" "$MAGENTA"
|
||||
echo
|
||||
print_message "MongoDB:" "$YELLOW"
|
||||
print_message " Host: $MONGODB_HOST:$MONGODB_PORT" "$NC"
|
||||
print_message " Database: $MONGODB_NAME" "$NC"
|
||||
print_message " User: $MONGODB_USER" "$NC"
|
||||
print_message " Password: ***" "$NC"
|
||||
print_message " Container: $MONGO_CONTAINER" "$NC"
|
||||
print_message " Data: $MONGO_DATA_DIR" "$NC"
|
||||
print_message " Connection: mongodb://$MONGODB_USER:***@$MONGODB_HOST:$MONGODB_PORT/$MONGODB_NAME" "$BLUE"
|
||||
|
||||
echo
|
||||
print_message "S3/MinIO:" "$YELLOW"
|
||||
print_message " Host: $S3_HOST" "$NC"
|
||||
print_message " API Port: $S3_PORT" "$NC"
|
||||
print_message " Console Port: $S3_CONSOLE_PORT" "$NC"
|
||||
print_message " User: $S3_USER" "$NC"
|
||||
print_message " Password: ***" "$NC"
|
||||
print_message " Bucket: $S3_BUCKET" "$NC"
|
||||
print_message " Container: $MINIO_CONTAINER" "$NC"
|
||||
print_message " Data: $MINIO_DATA_DIR" "$NC"
|
||||
print_message " API URL: http://$S3_HOST:$S3_PORT" "$BLUE"
|
||||
print_message " Console URL: http://$S3_HOST:$S3_CONSOLE_PORT" "$BLUE"
|
||||
}
|
||||
|
||||
# Check container status
|
||||
check_status() {
|
||||
local container=$1
|
||||
@@ -82,23 +330,25 @@ start_mongodb() {
|
||||
print_message " Creating container..." "$YELLOW"
|
||||
docker run -d \
|
||||
--name "$MONGO_CONTAINER" \
|
||||
-p "0.0.0.0:${MONGO_PORT}:${MONGO_PORT}" \
|
||||
-p "0.0.0.0:${MONGODB_PORT}:27017" \
|
||||
-v "$MONGO_DATA_DIR:/data/db" \
|
||||
-e MONGO_INITDB_ROOT_USERNAME="$MONGO_USER" \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD="$MONGO_PASS" \
|
||||
-e MONGO_INITDB_DATABASE=banking \
|
||||
-e MONGO_INITDB_ROOT_USERNAME="$MONGODB_USER" \
|
||||
-e MONGO_INITDB_ROOT_PASSWORD="$MONGODB_PASS" \
|
||||
-e MONGO_INITDB_DATABASE="$MONGODB_NAME" \
|
||||
--restart unless-stopped \
|
||||
"mongo:${MONGO_VERSION}" > /dev/null
|
||||
mongo:7.0 > /dev/null
|
||||
print_message " Created and started ✓" "$GREEN"
|
||||
;;
|
||||
esac
|
||||
|
||||
print_message " URL: mongodb://$MONGO_USER:$MONGO_PASS@localhost:$MONGO_PORT/banking?authSource=admin" "$BLUE"
|
||||
print_message " Container: $MONGO_CONTAINER" "$CYAN"
|
||||
print_message " Port: $MONGODB_PORT" "$CYAN"
|
||||
print_message " Connection: mongodb://$MONGODB_USER:$MONGODB_PASS@$MONGODB_HOST:$MONGODB_PORT/$MONGODB_NAME?authSource=admin" "$BLUE"
|
||||
}
|
||||
|
||||
# Start MinIO
|
||||
start_minio() {
|
||||
print_message "📦 MinIO (S3 Storage):" "$YELLOW"
|
||||
print_message "📦 S3/MinIO:" "$YELLOW"
|
||||
|
||||
# Create data directory if needed
|
||||
[ ! -d "$MINIO_DATA_DIR" ] && mkdir -p "$MINIO_DATA_DIR"
|
||||
@@ -117,25 +367,28 @@ start_minio() {
|
||||
print_message " Creating container..." "$YELLOW"
|
||||
docker run -d \
|
||||
--name "$MINIO_CONTAINER" \
|
||||
-p "${MINIO_PORT}:9000" \
|
||||
-p "${MINIO_CONSOLE_PORT}:9001" \
|
||||
-p "${S3_PORT}:9000" \
|
||||
-p "${S3_CONSOLE_PORT}:9001" \
|
||||
-v "$MINIO_DATA_DIR:/data" \
|
||||
-e MINIO_ROOT_USER="$MINIO_USER" \
|
||||
-e MINIO_ROOT_PASSWORD="$MINIO_PASS" \
|
||||
-e MINIO_ROOT_USER="$S3_USER" \
|
||||
-e MINIO_ROOT_PASSWORD="$S3_PASS" \
|
||||
--restart unless-stopped \
|
||||
minio/minio server /data --console-address ":9001" > /dev/null
|
||||
|
||||
# Wait for MinIO to start and create bucket
|
||||
# Wait for MinIO to start and create default bucket
|
||||
sleep 3
|
||||
docker exec "$MINIO_CONTAINER" mc alias set local http://localhost:9000 "$MINIO_USER" "$MINIO_PASS" 2>/dev/null
|
||||
docker exec "$MINIO_CONTAINER" mc mb local/banking-documents 2>/dev/null || true
|
||||
docker exec "$MINIO_CONTAINER" mc alias set local http://localhost:9000 "$S3_USER" "$S3_PASS" 2>/dev/null
|
||||
docker exec "$MINIO_CONTAINER" mc mb "local/$S3_BUCKET" 2>/dev/null || true
|
||||
print_message " Created and started ✓" "$GREEN"
|
||||
print_message " Bucket 'banking-documents' created ✓" "$GREEN"
|
||||
print_message " Bucket '$S3_BUCKET' created ✓" "$GREEN"
|
||||
;;
|
||||
esac
|
||||
|
||||
print_message " API: http://localhost:$MINIO_PORT" "$BLUE"
|
||||
print_message " Console: http://localhost:$MINIO_CONSOLE_PORT (login: $MINIO_USER/$MINIO_PASS)" "$BLUE"
|
||||
print_message " Container: $MINIO_CONTAINER" "$CYAN"
|
||||
print_message " Port: $S3_PORT" "$CYAN"
|
||||
print_message " Bucket: $S3_BUCKET" "$CYAN"
|
||||
print_message " API: http://$S3_HOST:$S3_PORT" "$BLUE"
|
||||
print_message " Console: http://$S3_HOST:$S3_CONSOLE_PORT (login: $S3_USER/***)" "$BLUE"
|
||||
}
|
||||
|
||||
# Stop MongoDB
|
||||
@@ -153,7 +406,7 @@ stop_mongodb() {
|
||||
|
||||
# Stop MinIO
|
||||
stop_minio() {
|
||||
print_message "📦 MinIO:" "$YELLOW"
|
||||
print_message "📦 S3/MinIO:" "$YELLOW"
|
||||
local status=$(check_status "$MINIO_CONTAINER")
|
||||
|
||||
if [ "$status" = "running" ]; then
|
||||
@@ -176,7 +429,7 @@ remove_containers() {
|
||||
|
||||
if docker ps -a --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then
|
||||
docker rm -f "$MINIO_CONTAINER" > /dev/null 2>&1
|
||||
print_message " MinIO container removed ✓" "$GREEN"
|
||||
print_message " S3/MinIO container removed ✓" "$GREEN"
|
||||
removed=true
|
||||
fi
|
||||
|
||||
@@ -197,7 +450,7 @@ clean_data() {
|
||||
|
||||
if [ -d "$MINIO_DATA_DIR" ]; then
|
||||
rm -rf "$MINIO_DATA_DIR"
|
||||
print_message " MinIO data removed ✓" "$GREEN"
|
||||
print_message " S3/MinIO data removed ✓" "$GREEN"
|
||||
cleaned=true
|
||||
fi
|
||||
|
||||
@@ -210,15 +463,20 @@ clean_data() {
|
||||
show_status() {
|
||||
print_header "Service Status"
|
||||
|
||||
print_message "Project: $PROJECT_NAME" "$MAGENTA"
|
||||
echo
|
||||
|
||||
# MongoDB status
|
||||
local mongo_status=$(check_status "$MONGO_CONTAINER")
|
||||
case $mongo_status in
|
||||
"running")
|
||||
print_message "📦 MongoDB: 🟢 Running" "$GREEN"
|
||||
print_message " └─ mongodb://$MONGO_USER:***@localhost:$MONGO_PORT/banking" "$CYAN"
|
||||
print_message " ├─ Container: $MONGO_CONTAINER" "$CYAN"
|
||||
print_message " └─ mongodb://$MONGODB_USER:***@$MONGODB_HOST:$MONGODB_PORT/$MONGODB_NAME" "$CYAN"
|
||||
;;
|
||||
"stopped")
|
||||
print_message "📦 MongoDB: 🟡 Stopped" "$YELLOW"
|
||||
print_message " └─ Container: $MONGO_CONTAINER" "$CYAN"
|
||||
;;
|
||||
"not_exists")
|
||||
print_message "📦 MongoDB: ⚪ Not installed" "$MAGENTA"
|
||||
@@ -229,25 +487,20 @@ show_status() {
|
||||
local minio_status=$(check_status "$MINIO_CONTAINER")
|
||||
case $minio_status in
|
||||
"running")
|
||||
print_message "📦 MinIO: 🟢 Running" "$GREEN"
|
||||
print_message " ├─ API: http://localhost:$MINIO_PORT" "$CYAN"
|
||||
print_message " └─ Console: http://localhost:$MINIO_CONSOLE_PORT" "$CYAN"
|
||||
print_message "📦 S3/MinIO: 🟢 Running" "$GREEN"
|
||||
print_message " ├─ Container: $MINIO_CONTAINER" "$CYAN"
|
||||
print_message " ├─ API: http://$S3_HOST:$S3_PORT" "$CYAN"
|
||||
print_message " ├─ Console: http://$S3_HOST:$S3_CONSOLE_PORT" "$CYAN"
|
||||
print_message " └─ Bucket: $S3_BUCKET" "$CYAN"
|
||||
;;
|
||||
"stopped")
|
||||
print_message "📦 MinIO: 🟡 Stopped" "$YELLOW"
|
||||
print_message "📦 S3/MinIO: 🟡 Stopped" "$YELLOW"
|
||||
print_message " └─ Container: $MINIO_CONTAINER" "$CYAN"
|
||||
;;
|
||||
"not_exists")
|
||||
print_message "📦 MinIO: ⚪ Not installed" "$MAGENTA"
|
||||
print_message "📦 S3/MinIO: ⚪ Not installed" "$MAGENTA"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Show network access for MongoDB
|
||||
if [ "$mongo_status" = "running" ]; then
|
||||
echo
|
||||
print_message "Network Access:" "$BLUE"
|
||||
local ip=$(hostname -I | awk '{print $1}')
|
||||
print_message " MongoDB Compass: mongodb://$MONGO_USER:$MONGO_PASS@$ip:$MONGO_PORT/banking?authSource=admin" "$CYAN"
|
||||
fi
|
||||
}
|
||||
|
||||
# Show logs
|
||||
@@ -264,51 +517,60 @@ show_logs() {
|
||||
print_message "MongoDB container is not running" "$YELLOW"
|
||||
fi
|
||||
;;
|
||||
"minio")
|
||||
"minio"|"s3")
|
||||
if docker ps --format '{{.Names}}' | grep -q "^${MINIO_CONTAINER}$"; then
|
||||
print_header "MinIO Logs (last $lines lines)"
|
||||
print_header "S3/MinIO Logs (last $lines lines)"
|
||||
docker logs --tail "$lines" "$MINIO_CONTAINER"
|
||||
else
|
||||
print_message "MinIO container is not running" "$YELLOW"
|
||||
print_message "S3/MinIO container is not running" "$YELLOW"
|
||||
fi
|
||||
;;
|
||||
"all")
|
||||
"all"|"")
|
||||
show_logs "mongo" "$lines"
|
||||
echo
|
||||
show_logs "minio" "$lines"
|
||||
;;
|
||||
*)
|
||||
print_message "Usage: $0 logs [mongo|minio|all] [lines]" "$YELLOW"
|
||||
print_message "Usage: $0 logs [mongo|s3|all] [lines]" "$YELLOW"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main menu
|
||||
show_help() {
|
||||
print_header "Banking Services Manager"
|
||||
print_header "Generic Services Manager"
|
||||
|
||||
print_message "Usage: $0 [command] [options]" "$GREEN"
|
||||
echo
|
||||
print_message "Commands:" "$YELLOW"
|
||||
print_message " start [service] Start services (mongo|minio|all)" "$NC"
|
||||
print_message " stop [service] Stop services (mongo|minio|all)" "$NC"
|
||||
print_message " restart [service] Restart services (mongo|minio|all)" "$NC"
|
||||
print_message " start [service] Start services (mongo|s3|all)" "$NC"
|
||||
print_message " stop [service] Stop services (mongo|s3|all)" "$NC"
|
||||
print_message " restart [service] Restart services (mongo|s3|all)" "$NC"
|
||||
print_message " status Show service status" "$NC"
|
||||
print_message " logs [service] Show logs (mongo|minio|all) [lines]" "$NC"
|
||||
print_message " config Show current configuration" "$NC"
|
||||
print_message " logs [service] Show logs (mongo|s3|all) [lines]" "$NC"
|
||||
print_message " remove Remove all containers" "$NC"
|
||||
print_message " clean Remove all containers and data ⚠️" "$NC"
|
||||
print_message " help Show this help message" "$NC"
|
||||
echo
|
||||
print_message "Features:" "$YELLOW"
|
||||
print_message " • Auto-creates .nogit/env.json with smart defaults" "$NC"
|
||||
print_message " • Random ports (20000-30000) to avoid conflicts" "$NC"
|
||||
print_message " • Project-specific containers for multi-project support" "$NC"
|
||||
print_message " • Preserves custom configuration values" "$NC"
|
||||
echo
|
||||
print_message "Examples:" "$YELLOW"
|
||||
print_message " $0 start # Start all services" "$NC"
|
||||
print_message " $0 start mongo # Start only MongoDB" "$NC"
|
||||
print_message " $0 stop # Stop all services" "$NC"
|
||||
print_message " $0 status # Check service status" "$NC"
|
||||
print_message " $0 config # Show configuration" "$NC"
|
||||
print_message " $0 logs mongo 50 # Show last 50 lines of MongoDB logs" "$NC"
|
||||
}
|
||||
|
||||
# Main script
|
||||
check_docker
|
||||
load_config
|
||||
|
||||
case ${1:-help} in
|
||||
start)
|
||||
@@ -327,7 +589,7 @@ case ${1:-help} in
|
||||
;;
|
||||
*)
|
||||
print_message "Unknown service: $2" "$RED"
|
||||
print_message "Use: mongo, minio, or all" "$YELLOW"
|
||||
print_message "Use: mongo, s3, or all" "$YELLOW"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
@@ -348,7 +610,7 @@ case ${1:-help} in
|
||||
;;
|
||||
*)
|
||||
print_message "Unknown service: $2" "$RED"
|
||||
print_message "Use: mongo, minio, or all" "$YELLOW"
|
||||
print_message "Use: mongo, s3, or all" "$YELLOW"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
@@ -384,6 +646,10 @@ case ${1:-help} in
|
||||
show_status
|
||||
;;
|
||||
|
||||
config)
|
||||
show_config
|
||||
;;
|
||||
|
||||
logs)
|
||||
show_logs "${2:-all}" "${3:-20}"
|
||||
;;
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import type { TPostingKey, TSKRType } from '@fin.cx/skr';
|
||||
|
||||
const skrType: TSKRType = 'SKR03';
|
||||
const postingKey: TPostingKey = 40;
|
||||
|
||||
void skrType;
|
||||
void postingKey;
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": false,
|
||||
"noEmit": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fin.cx/skr": ["../../../dist_ts/index.d.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["./index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
|
||||
// Initialize qenv to load environment variables from .nogit folder
|
||||
const testQenv = new qenv.Qenv('./', './.nogit/');
|
||||
|
||||
// Export configuration for MongoDB and S3
|
||||
export const getTestConfig = async () => {
|
||||
// Try to get individual MongoDB components first
|
||||
const mongoHost = await testQenv.getEnvVarOnDemand('MONGODB_HOST') || 'localhost';
|
||||
const mongoPort = await testQenv.getEnvVarOnDemand('MONGODB_PORT') || '27017';
|
||||
const mongoUser = await testQenv.getEnvVarOnDemand('MONGODB_USER');
|
||||
const mongoPass = await testQenv.getEnvVarOnDemand('MONGODB_PASS');
|
||||
const mongoDbName = await testQenv.getEnvVarOnDemand('MONGODB_NAME') || 'test_skr';
|
||||
|
||||
// Build MongoDB URL with authentication
|
||||
let mongoDbUrl: string;
|
||||
if (mongoUser && mongoPass) {
|
||||
// Include authSource=admin for authentication
|
||||
mongoDbUrl = `mongodb://${mongoUser}:${mongoPass}@${mongoHost}:${mongoPort}/?authSource=admin`;
|
||||
} else {
|
||||
mongoDbUrl = `mongodb://${mongoHost}:${mongoPort}`;
|
||||
}
|
||||
|
||||
// Get S3 configuration
|
||||
const s3Host = await testQenv.getEnvVarOnDemand('S3_HOST');
|
||||
const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT');
|
||||
const s3User = await testQenv.getEnvVarOnDemand('S3_USER');
|
||||
const s3Pass = await testQenv.getEnvVarOnDemand('S3_PASS');
|
||||
const s3Bucket = await testQenv.getEnvVarOnDemand('S3_BUCKET') || 'test-skr';
|
||||
|
||||
return {
|
||||
mongoDbUrl,
|
||||
mongoDbName,
|
||||
s3Config: s3User && s3Pass ? {
|
||||
accessKey: s3User,
|
||||
secretKey: s3Pass,
|
||||
endpoint: s3Host && s3Port ? `http://${s3Host}:${s3Port}` : undefined,
|
||||
bucket: s3Bucket
|
||||
} : null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,569 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as skr from '../ts/index.js';
|
||||
import { getTestConfig } from './helpers/setup.js';
|
||||
|
||||
let api: skr.SkrApi;
|
||||
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||
|
||||
tap.test('should demonstrate complete Jahresabschluss (Annual Financial Statement) for SKR03', async () => {
|
||||
testConfig = await getTestConfig();
|
||||
|
||||
// Use timestamp to ensure unique database for each test run
|
||||
const timestamp = Date.now();
|
||||
api = new skr.SkrApi({
|
||||
mongoDbUrl: testConfig.mongoDbUrl,
|
||||
dbName: `${testConfig.mongoDbName}_jahresabschluss_${timestamp}`,
|
||||
});
|
||||
|
||||
await api.initialize('SKR03');
|
||||
expect(api.getSKRType()).toEqual('SKR03');
|
||||
|
||||
// Create debtor account (customer) - replaces automatic account 1400
|
||||
await api.createAccount({
|
||||
accountNumber: '10001',
|
||||
accountName: 'Kunde Mustermann GmbH',
|
||||
accountClass: 1,
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Create creditor account (supplier) - replaces automatic account 1600
|
||||
await api.createAccount({
|
||||
accountNumber: '70001',
|
||||
accountName: 'Lieferant Test GmbH',
|
||||
accountClass: 7,
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should set up opening balances (Eröffnungsbilanz)', async () => {
|
||||
// Opening balances from previous year's closing
|
||||
// This represents a small GmbH (limited liability company)
|
||||
// Using only accounts that exist in SKR03
|
||||
|
||||
// Note: Opening balance entries use posting key 40 (tax-free) as they are internal closing entries
|
||||
// Using personal accounts (10001 for debtor, 70001 for creditor) instead of automatic accounts
|
||||
|
||||
// Post opening journal entry (Eröffnungsbuchung)
|
||||
const openingEntry = await api.postJournalEntry({
|
||||
date: new Date('2024-01-01'),
|
||||
description: 'Eröffnungsbilanz 2024',
|
||||
reference: 'EB-2024',
|
||||
lines: [
|
||||
// Debit all asset accounts
|
||||
{ accountNumber: '0200', debit: 45000, description: 'Grundstücke', postingKey: 40 },
|
||||
{ accountNumber: '0210', debit: 120000, description: 'Gebäude', postingKey: 40 },
|
||||
{ accountNumber: '0500', debit: 35000, description: 'Betriebs- und Geschäftsausstattung', postingKey: 40 },
|
||||
{ accountNumber: '0400', debit: 8000, description: 'Fuhrpark', postingKey: 40 },
|
||||
{ accountNumber: '1200', debit: 25000, description: 'Bank', postingKey: 40 },
|
||||
{ accountNumber: '1000', debit: 2500, description: 'Kasse', postingKey: 40 },
|
||||
{ accountNumber: '10001', debit: 18000, description: 'Forderungen Kunde', postingKey: 40 },
|
||||
{ accountNumber: '3100', debit: 12000, description: 'Warenvorräte', postingKey: 40 },
|
||||
|
||||
// Credit all liability and equity accounts
|
||||
{ accountNumber: '2000', credit: 150000, description: 'Eigenkapital', postingKey: 40 },
|
||||
{ accountNumber: '2900', credit: 35000, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||
{ accountNumber: '70001', credit: 52500, description: 'Verbindlichkeiten Lieferant', postingKey: 40 },
|
||||
{ accountNumber: '3300', credit: 28000, description: 'Verbindlichkeiten Kreditinstitute', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
expect(openingEntry.isBalanced).toBeTrue();
|
||||
expect(openingEntry.totalDebits).toEqual(265500);
|
||||
expect(openingEntry.totalCredits).toEqual(265500);
|
||||
});
|
||||
|
||||
tap.test('should record Q1 business transactions', async () => {
|
||||
// January - March transactions
|
||||
|
||||
// Sale of goods with 19% VAT - using debtor account 10001 instead of automatic 1400
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-01-15'),
|
||||
description: 'Verkauf Waren auf Rechnung',
|
||||
reference: 'RE-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '10001', debit: 11900, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||
{ accountNumber: '8400', credit: 10000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||
{ accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Purchase of materials with 19% VAT - using creditor account 70001 instead of automatic 1600
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-01-20'),
|
||||
description: 'Einkauf Material auf Rechnung',
|
||||
reference: 'ER-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '5400', debit: 5000, description: 'Wareneingang 19% Vorsteuer', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Salary payment
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-01-31'),
|
||||
description: 'Gehaltszahlung Januar',
|
||||
reference: 'GH-2024-01',
|
||||
lines: [
|
||||
{ accountNumber: '6000', debit: 8000, description: 'Löhne und Gehälter', postingKey: 40 },
|
||||
{ accountNumber: '6100', debit: 1600, description: 'Sozialversicherung AG-Anteil', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 9600, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Customer payment received - using debtor account 10001 instead of automatic 1400
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-02-10'),
|
||||
description: 'Zahlungseingang Kunde',
|
||||
reference: 'ZE-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '1200', debit: 11900, description: 'Bankgutschrift', postingKey: 40 },
|
||||
{ accountNumber: '10001', credit: 11900, description: 'Forderungsausgleich', postingKey: 3 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Rent payment
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-02-01'),
|
||||
description: 'Miete Februar',
|
||||
reference: 'MI-2024-02',
|
||||
lines: [
|
||||
{ accountNumber: '7100', debit: 2000, description: 'Miete', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 2000, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Office supplies purchase
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-02-15'),
|
||||
description: 'Büromaterial',
|
||||
reference: 'BM-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '6800', debit: 200, description: 'Bürobedarf', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 238, description: 'Bankzahlung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Vehicle expenses
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-03-05'),
|
||||
description: 'Tankrechnung Firmenfahrzeug',
|
||||
reference: 'KFZ-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '7400', debit: 150, description: 'Kfz-Kosten', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 178.50, description: 'Bankzahlung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Another sale - using debtor account 10001 instead of automatic 1400
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-03-20'),
|
||||
description: 'Verkauf Dienstleistung',
|
||||
reference: 'RE-2024-002',
|
||||
lines: [
|
||||
{ accountNumber: '10001', debit: 7140, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||
{ accountNumber: '8400', credit: 6000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||
{ accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should record Q2-Q4 business transactions', async () => {
|
||||
// More transactions throughout the year
|
||||
|
||||
// Q2: Investment in new equipment
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-04-15'),
|
||||
description: 'Kauf neue Produktionsmaschine',
|
||||
reference: 'INV-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '0500', debit: 25000, description: 'Neue Maschine', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 29750, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Q2: Large sale - using debtor account 10001
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-05-10'),
|
||||
description: 'Großauftrag Kunde ABC',
|
||||
reference: 'RE-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '10001', debit: 35700, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||
{ accountNumber: '8400', credit: 30000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||
{ accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Q3: Marketing expenses - using creditor account 70001
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-07-10'),
|
||||
description: 'Werbekampagne',
|
||||
reference: 'WK-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '6600', debit: 5000, description: 'Werbekosten', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Q3: Professional services
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-08-15'),
|
||||
description: 'Steuerberatung',
|
||||
reference: 'STB-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '6700', debit: 2500, description: 'Steuerberatungskosten', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 2975, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Q4: Year-end bonus payment
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-11-30'),
|
||||
description: 'Jahresbonus Mitarbeiter',
|
||||
reference: 'BON-2024',
|
||||
lines: [
|
||||
{ accountNumber: '6000', debit: 10000, description: 'Tantieme', postingKey: 40 },
|
||||
{ accountNumber: '6100', debit: 2000, description: 'Sozialversicherung AG-Anteil', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 12000, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Q4: Collection of outstanding receivables - using debtor account 10001
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-15'),
|
||||
description: 'Zahlungseingang Großauftrag',
|
||||
reference: 'ZE-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '1200', debit: 35700, description: 'Bankgutschrift', postingKey: 40 },
|
||||
{ accountNumber: '10001', credit: 35700, description: 'Forderungsausgleich', postingKey: 3 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should perform year-end adjustments (Jahresabschlussbuchungen)', async () => {
|
||||
// 1. Depreciation (Abschreibungen) - internal adjustments use posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschreibung Gebäude (linear 2%)',
|
||||
reference: 'AFA-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '7000', debit: 2400, description: 'AfA auf Gebäude', postingKey: 40 },
|
||||
{ accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschreibung BGA (linear 10%)',
|
||||
reference: 'AFA-2024-002',
|
||||
lines: [
|
||||
{ accountNumber: '7000', debit: 6000, description: 'AfA auf BGA', postingKey: 40 }, // (35000 + 25000) * 10%
|
||||
{ accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschreibung Fuhrpark (linear 20%)',
|
||||
reference: 'AFA-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '7000', debit: 1600, description: 'AfA auf Fuhrpark', postingKey: 40 },
|
||||
{ accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// 2. Accruals (Rechnungsabgrenzung) - internal adjustments use posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Aktive Rechnungsabgrenzung - Vorausbezahlte Versicherung',
|
||||
reference: 'ARA-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '1900', debit: 1000, description: 'Aktive Rechnungsabgrenzung', postingKey: 40 },
|
||||
{ accountNumber: '7300', credit: 1000, description: 'Versicherungen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// 3. Provisions (Rückstellungen) - internal adjustments use posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Rückstellung für Jahresabschlusskosten',
|
||||
reference: 'RS-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '6700', debit: 3000, description: 'Rechts- und Beratungskosten', postingKey: 40 },
|
||||
{ accountNumber: '3000', credit: 3000, description: 'Rückstellungen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// 4. Inventory adjustment - internal adjustments use posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Bestandsveränderung Waren',
|
||||
reference: 'BV-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3100', debit: 3000, description: 'Warenbestand Zugang', postingKey: 40 },
|
||||
{ accountNumber: '5900', credit: 3000, description: 'Bestandsveränderungen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// 5. VAT clearing (Umsatzsteuer-Vorauszahlung) - internal adjustments use posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'USt-Abschluss Q4',
|
||||
reference: 'UST-2024-Q4',
|
||||
lines: [
|
||||
{ accountNumber: '1771', debit: 8740, description: 'USt-Saldo', postingKey: 40 }, // Total collected VAT
|
||||
{ accountNumber: '1571', credit: 7191.50, description: 'Vorsteuer-Saldo', postingKey: 40 }, // Total input VAT
|
||||
{ accountNumber: '1800', credit: 1548.50, description: 'USt-Zahllast', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Assert VAT accounts are cleared
|
||||
const ust19 = await api.getAccountBalance('1771');
|
||||
const vorst19 = await api.getAccountBalance('1571');
|
||||
const ustZahllast = await api.getAccountBalance('1800');
|
||||
|
||||
expect(Math.abs(ust19.balance)).toBeLessThan(0.01);
|
||||
expect(Math.abs(vorst19.balance)).toBeLessThan(0.01);
|
||||
expect(Math.abs(ustZahllast.balance - 1548.50)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
tap.test('should calculate income statement (GuV) before closing', async () => {
|
||||
const incomeStatement = await api.generateIncomeStatement({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
expect(incomeStatement).toBeDefined();
|
||||
expect(incomeStatement.totalRevenue).toBeGreaterThan(0);
|
||||
expect(incomeStatement.totalExpenses).toBeGreaterThan(0);
|
||||
|
||||
// Assert the exact expected values based on actual bookings
|
||||
// Revenue: 46000 (8400 account)
|
||||
// Expenses: 5000 + 18000 + 3600 + 10000 + 2000 + 150 + 5000 + 5500 + 200 = 49450
|
||||
// Less credit balances: -1000 (insurance accrual) -3000 (inventory increase) = -4000
|
||||
// Net expenses: 49450 - 4000 = 45450
|
||||
// Net income: 46000 - 45450 = 550
|
||||
|
||||
expect(Math.round(incomeStatement.totalRevenue)).toEqual(46000);
|
||||
expect(Math.round(incomeStatement.totalExpenses)).toEqual(45450);
|
||||
expect(Math.round(incomeStatement.netIncome)).toEqual(550);
|
||||
|
||||
console.log('Income Statement Summary:');
|
||||
console.log('Revenue:', incomeStatement.totalRevenue);
|
||||
console.log('Expenses:', incomeStatement.totalExpenses);
|
||||
console.log('Net Income:', incomeStatement.netIncome);
|
||||
});
|
||||
|
||||
tap.test('should perform closing entries (Abschlussbuchungen)', async () => {
|
||||
// Close all income and expense accounts to the profit/loss account
|
||||
|
||||
// Close revenue accounts - year-end closing uses posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschluss Ertragskonten',
|
||||
reference: 'AB-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '8400', debit: 46000, description: 'Erlöse abschließen', postingKey: 40 },
|
||||
{ accountNumber: '9400', credit: 46000, description: 'GuV-Konto', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Close expense accounts - year-end closing uses posting key 40
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschluss Aufwandskonten',
|
||||
reference: 'AB-2024-002',
|
||||
lines: [
|
||||
{ accountNumber: '9400', debit: 45450, description: 'GuV-Konto', postingKey: 40 },
|
||||
{ accountNumber: '7300', debit: 1000, description: 'Versicherung abschließen (credit balance)', postingKey: 40 },
|
||||
{ accountNumber: '5900', debit: 3000, description: 'Bestandsveränderungen abschließen (credit balance)', postingKey: 40 },
|
||||
{ accountNumber: '5400', credit: 5000, description: 'Wareneingang abschließen', postingKey: 40 },
|
||||
{ accountNumber: '6000', credit: 18000, description: 'Löhne und Gehälter abschließen', postingKey: 40 },
|
||||
{ accountNumber: '6100', credit: 3600, description: 'SV AG-Anteil abschließen', postingKey: 40 },
|
||||
{ accountNumber: '7000', credit: 10000, description: 'AfA abschließen', postingKey: 40 },
|
||||
{ accountNumber: '7100', credit: 2000, description: 'Miete abschließen', postingKey: 40 },
|
||||
{ accountNumber: '7400', credit: 150, description: 'Kfz abschließen', postingKey: 40 },
|
||||
{ accountNumber: '6600', credit: 5000, description: 'Werbung abschließen', postingKey: 40 },
|
||||
{ accountNumber: '6700', credit: 5500, description: 'Beratung abschließen', postingKey: 40 },
|
||||
{ accountNumber: '6800', credit: 200, description: 'Bürobedarf abschließen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
// Transfer profit/loss to equity - year-end closing uses posting key 40
|
||||
const guv_result = 46000 - 45450; // Profit of 550
|
||||
if (guv_result > 0) {
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Jahresgewinn auf Eigenkapital',
|
||||
reference: 'AB-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '9400', debit: guv_result, description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||
{ accountNumber: '2900', credit: guv_result, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
} else if (guv_result < 0) {
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Jahresverlust auf Eigenkapital',
|
||||
reference: 'AB-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '2500', debit: Math.abs(guv_result), description: 'Verlustvortrag', postingKey: 40 },
|
||||
{ accountNumber: '9400', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
}
|
||||
|
||||
// Assert GuV account is closed and equity is updated
|
||||
const guv = await api.getAccountBalance('9400');
|
||||
const ruecklagen = await api.getAccountBalance('2900');
|
||||
|
||||
expect(Math.abs(guv.balance)).toBeLessThan(0.01);
|
||||
expect(Math.round(ruecklagen.balance)).toEqual(35550); // 35000 + 550
|
||||
|
||||
// Assert all P&L accounts are closed (zero balance)
|
||||
const plAccounts = ['8400', '5400', '5900', '6000', '6100', '6600', '6700', '6800', '7000', '7100', '7300', '7400'];
|
||||
for (const accNum of plAccounts) {
|
||||
const balance = await api.getAccountBalance(accNum);
|
||||
expect(Math.abs(balance.balance)).toBeLessThan(0.01);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should generate final balance sheet (Schlussbilanz)', async () => {
|
||||
const balanceSheet = await api.generateBalanceSheet({
|
||||
dateTo: new Date('2024-12-31'),
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
expect(balanceSheet).toBeDefined();
|
||||
expect(balanceSheet.assets).toBeDefined();
|
||||
expect(balanceSheet.liabilities).toBeDefined();
|
||||
expect(balanceSheet.equity).toBeDefined();
|
||||
|
||||
console.log('\n=== JAHRESABSCHLUSS 2024 ===\n');
|
||||
console.log('BILANZ zum 31.12.2024\n');
|
||||
console.log('AKTIVA (Assets)');
|
||||
console.log('----------------');
|
||||
console.log('Anlagevermögen:');
|
||||
console.log(' Grundstücke: 45,000.00 €');
|
||||
console.log(' Gebäude: 120,000.00 €');
|
||||
console.log(' ./. kum. AfA: -22,400.00 €');
|
||||
console.log(' BGA: 60,000.00 €');
|
||||
console.log(' ./. kum. AfA: -14,000.00 €');
|
||||
console.log(' EDV: 8,000.00 €');
|
||||
console.log(' ./. kum. AfA: -2,640.00 €');
|
||||
console.log(' -----------');
|
||||
console.log(' Summe Anlagevermögen: 193,960.00 €\n');
|
||||
|
||||
console.log('Umlaufvermögen:');
|
||||
console.log(' Waren: 15,000.00 €');
|
||||
console.log(' Forderungen: 7,340.00 €');
|
||||
console.log(' Bank: 6,293.50 €');
|
||||
console.log(' Kasse: 2,500.00 €');
|
||||
console.log(' Akt. Rechnungsabgr.: 1,000.00 €');
|
||||
console.log(' -----------');
|
||||
console.log(' Summe Umlaufvermögen: 32,133.50 €\n');
|
||||
console.log('SUMME AKTIVA: 226,093.50 €\n');
|
||||
|
||||
console.log('PASSIVA (Liabilities & Equity)');
|
||||
console.log('-------------------------------');
|
||||
console.log('Eigenkapital:');
|
||||
console.log(' Gezeichnetes Kapital: 150,000.00 €');
|
||||
console.log(' Gewinnrücklagen: 35,550.00 €'); // 35000 + 550 profit
|
||||
console.log(' Jahresgewinn: 550.00 €');
|
||||
console.log(' -----------');
|
||||
console.log(' Summe Eigenkapital: 185,550.00 €\n');
|
||||
|
||||
console.log('Fremdkapital:');
|
||||
console.log(' Darlehen: 30,000.00 €');
|
||||
console.log(' Verbindlichkeiten L+L: 18,160.00 €');
|
||||
console.log(' Sonstige Rückstellungen: 3,000.00 €');
|
||||
console.log(' USt-Zahllast: 1,473.50 €');
|
||||
console.log(' -----------');
|
||||
console.log(' Summe Fremdkapital: 50,633.50 €\n');
|
||||
console.log('SUMME PASSIVA: 226,093.50 €');
|
||||
console.log('\n=================================\n');
|
||||
|
||||
// Verify balance sheet balances
|
||||
const totalAssets = balanceSheet.assets.totalAssets;
|
||||
const totalLiabilitiesAndEquity = balanceSheet.liabilities.totalLiabilities + balanceSheet.equity.totalEquity;
|
||||
|
||||
console.log('Balance Sheet Check:');
|
||||
console.log(' Total Assets:', totalAssets);
|
||||
console.log(' Total Liabilities + Equity:', totalLiabilitiesAndEquity);
|
||||
console.log(' Difference:', Math.abs(totalAssets - totalLiabilitiesAndEquity));
|
||||
|
||||
expect(Math.abs(totalAssets - totalLiabilitiesAndEquity)).toBeLessThan(0.01);
|
||||
console.log('✓ Balance Sheet is balanced!');
|
||||
});
|
||||
|
||||
tap.test('should generate trial balance (Summen- und Saldenliste)', async () => {
|
||||
const trialBalance = await api.generateTrialBalance({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
expect(trialBalance).toBeDefined();
|
||||
expect(trialBalance.isBalanced).toBeTrue();
|
||||
|
||||
console.log('\nSUMMEN- UND SALDENLISTE 2024');
|
||||
console.log('=============================');
|
||||
console.log('Konto | Bezeichnung | Soll | Haben | Saldo');
|
||||
console.log('------|-------------|------|-------|-------');
|
||||
|
||||
// Display key accounts
|
||||
const keyAccounts = [
|
||||
'0200', '0210', '0400', '0500', // Fixed assets
|
||||
'1000', '1200', '1400', '1900', // Current assets
|
||||
'2000', '2500', '2900', // Equity
|
||||
'1600', '1800', '3000', '3100', // Liabilities and inventory
|
||||
];
|
||||
|
||||
for (const accountNumber of keyAccounts) {
|
||||
const account = await api.getAccount(accountNumber);
|
||||
if (account) {
|
||||
const balance = await api.getAccountBalance(accountNumber);
|
||||
console.log(`${accountNumber} | ${account.accountName.substring(0, 30).padEnd(30)} | ${balance.debitTotal.toFixed(2).padStart(12)} | ${balance.creditTotal.toFixed(2).padStart(12)} | ${balance.balance.toFixed(2).padStart(12)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should close API connection', async () => {
|
||||
await api.close();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,513 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as skr from '../ts/index.js';
|
||||
import { getTestConfig } from './helpers/setup.js';
|
||||
|
||||
let api: skr.SkrApi;
|
||||
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||
|
||||
tap.test('should demonstrate complete Jahresabschluss (Annual Financial Statement) for SKR04', async () => {
|
||||
testConfig = await getTestConfig();
|
||||
|
||||
// Use timestamp to ensure unique database for each test run
|
||||
const timestamp = Date.now();
|
||||
api = new skr.SkrApi({
|
||||
mongoDbUrl: testConfig.mongoDbUrl,
|
||||
dbName: `${testConfig.mongoDbName}_jahresabschluss_skr04_${timestamp}`,
|
||||
});
|
||||
|
||||
await api.initialize('SKR04');
|
||||
expect(api.getSKRType()).toEqual('SKR04');
|
||||
|
||||
// Create debtor account (customer) - replaces automatic account 1400
|
||||
await api.createAccount({
|
||||
accountNumber: '10001',
|
||||
accountName: 'Kunde Mustermann GmbH',
|
||||
accountClass: 1,
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Create creditor account (supplier) - replaces automatic account 1600
|
||||
await api.createAccount({
|
||||
accountNumber: '70001',
|
||||
accountName: 'Lieferant Test GmbH',
|
||||
accountClass: 7,
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should set up opening balances (Eröffnungsbilanz) for SKR04', async () => {
|
||||
// Opening balances from previous year's closing
|
||||
// SKR04 uses different account structure than SKR03
|
||||
// Using personal accounts (10001 for debtor, 70001 for creditor) instead of automatic accounts
|
||||
|
||||
// Post opening journal entry (Eröffnungsbuchung)
|
||||
const openingEntry = await api.postJournalEntry({
|
||||
date: new Date('2024-01-01'),
|
||||
description: 'Eröffnungsbilanz 2024',
|
||||
reference: 'EB-2024',
|
||||
lines: [
|
||||
// Debit all asset accounts
|
||||
{ accountNumber: '0200', debit: 45000, description: 'Grundstücke', postingKey: 40 },
|
||||
{ accountNumber: '0210', debit: 120000, description: 'Gebäude', postingKey: 40 },
|
||||
{ accountNumber: '0500', debit: 35000, description: 'BGA', postingKey: 40 },
|
||||
{ accountNumber: '0400', debit: 8000, description: 'Fuhrpark', postingKey: 40 },
|
||||
{ accountNumber: '1200', debit: 25000, description: 'Bank', postingKey: 40 },
|
||||
{ accountNumber: '1000', debit: 2500, description: 'Kasse', postingKey: 40 },
|
||||
{ accountNumber: '10001', debit: 18000, description: 'Forderungen Kunde', postingKey: 40 },
|
||||
|
||||
// Credit all liability and equity accounts
|
||||
{ accountNumber: '9000', credit: 150000, description: 'Eigenkapital', postingKey: 40 },
|
||||
{ accountNumber: '9300', credit: 35000, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||
{ accountNumber: '70001', credit: 40500, description: 'Verbindlichkeiten Lieferant', postingKey: 40 },
|
||||
{ accountNumber: '1700', credit: 28000, description: 'Sonstige Verbindlichkeiten', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
expect(openingEntry.isBalanced).toBeTrue();
|
||||
expect(openingEntry.totalDebits).toEqual(253500);
|
||||
expect(openingEntry.totalCredits).toEqual(253500);
|
||||
});
|
||||
|
||||
tap.test('should record Q1 business transactions for SKR04', async () => {
|
||||
// January - March transactions using SKR04 accounts
|
||||
|
||||
// Sale of goods with 19% VAT - SKR04 uses 4300 for revenue with 19% VAT
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-01-15'),
|
||||
description: 'Verkauf Waren auf Rechnung',
|
||||
reference: 'RE-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '10001', debit: 11900, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||
{ accountNumber: '4300', credit: 10000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||
{ accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Purchase of materials with 19% VAT - SKR04 uses 2100 for goods purchases
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-01-20'),
|
||||
description: 'Einkauf Material auf Rechnung',
|
||||
reference: 'ER-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '2100', debit: 5000, description: 'Bezogene Waren', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Salary payment - SKR04 uses 2300 for wages
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-01-31'),
|
||||
description: 'Gehaltszahlung Januar',
|
||||
reference: 'GH-2024-01',
|
||||
lines: [
|
||||
{ accountNumber: '2300', debit: 8000, description: 'Löhne', postingKey: 40 },
|
||||
{ accountNumber: '2400', debit: 1600, description: 'Gehälter', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 9600, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Customer payment received
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-02-10'),
|
||||
description: 'Zahlungseingang Kunde',
|
||||
reference: 'ZE-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '1200', debit: 11900, description: 'Bankgutschrift', postingKey: 40 },
|
||||
{ accountNumber: '10001', credit: 11900, description: 'Forderungsausgleich', postingKey: 3 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Rent payment - SKR04 uses 3000 for rent
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-02-01'),
|
||||
description: 'Miete Februar',
|
||||
reference: 'MI-2024-02',
|
||||
lines: [
|
||||
{ accountNumber: '3000', debit: 2000, description: 'Miete', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 2000, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Office supplies purchase - SKR04 uses 3100 for office supplies
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-02-15'),
|
||||
description: 'Büromaterial',
|
||||
reference: 'BM-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3100', debit: 200, description: 'Bürobedarf', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 238, description: 'Bankzahlung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Vehicle expenses - SKR04 uses 3300 for vehicle costs
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-03-05'),
|
||||
description: 'Tankrechnung Firmenfahrzeug',
|
||||
reference: 'KFZ-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3300', debit: 150, description: 'Kfz-Kosten', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 178.50, description: 'Bankzahlung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Another sale
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-03-20'),
|
||||
description: 'Verkauf Dienstleistung',
|
||||
reference: 'RE-2024-002',
|
||||
lines: [
|
||||
{ accountNumber: '10001', debit: 7140, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||
{ accountNumber: '4300', credit: 6000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||
{ accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should record Q2-Q4 business transactions for SKR04', async () => {
|
||||
// More transactions throughout the year
|
||||
|
||||
// Q2: Investment in new equipment
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-04-15'),
|
||||
description: 'Kauf neue Produktionsmaschine',
|
||||
reference: 'INV-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '0500', debit: 25000, description: 'Neue Maschine', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 29750, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Q2: Large sale
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-05-10'),
|
||||
description: 'Großauftrag Kunde ABC',
|
||||
reference: 'RE-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '10001', debit: 35700, description: 'Forderungen inkl. USt', postingKey: 9 },
|
||||
{ accountNumber: '4300', credit: 30000, description: 'Erlöse 19% USt', postingKey: 40 },
|
||||
{ accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Q3: Marketing expenses - SKR04 uses 3400 for advertising
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-07-10'),
|
||||
description: 'Werbekampagne',
|
||||
reference: 'WK-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3400', debit: 5000, description: 'Werbekosten', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '70001', credit: 5950, description: 'Verbindlichkeiten', postingKey: 9 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Q3: Professional services - SKR04 uses 3500 for legal/consulting
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-08-15'),
|
||||
description: 'Steuerberatung',
|
||||
reference: 'STB-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3500', debit: 2500, description: 'Steuerberatungskosten', postingKey: 40 },
|
||||
{ accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%', postingKey: 9 },
|
||||
{ accountNumber: '1200', credit: 2975, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Q4: Year-end bonus payment
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-11-30'),
|
||||
description: 'Jahresbonus Mitarbeiter',
|
||||
reference: 'BON-2024',
|
||||
lines: [
|
||||
{ accountNumber: '2300', debit: 10000, description: 'Tantieme', postingKey: 40 },
|
||||
{ accountNumber: '2400', debit: 2000, description: 'Gehälter Bonus', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 12000, description: 'Banküberweisung', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Q4: Collection of outstanding receivables
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-15'),
|
||||
description: 'Zahlungseingang Großauftrag',
|
||||
reference: 'ZE-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '1200', debit: 35700, description: 'Bankgutschrift', postingKey: 40 },
|
||||
{ accountNumber: '10001', credit: 35700, description: 'Forderungsausgleich', postingKey: 3 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('should perform year-end adjustments (Jahresabschlussbuchungen) for SKR04', async () => {
|
||||
// 1. Depreciation (Abschreibungen) - SKR04 uses 3700 for depreciation
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschreibung Gebäude (linear 2%)',
|
||||
reference: 'AFA-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3700', debit: 2400, description: 'AfA auf Gebäude', postingKey: 40 },
|
||||
{ accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschreibung BGA (linear 10%)',
|
||||
reference: 'AFA-2024-002',
|
||||
lines: [
|
||||
{ accountNumber: '3700', debit: 6000, description: 'AfA auf BGA', postingKey: 40 }, // (35000 + 25000) * 10%
|
||||
{ accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschreibung Fuhrpark (linear 20%)',
|
||||
reference: 'AFA-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '3700', debit: 1600, description: 'AfA auf Fuhrpark', postingKey: 40 },
|
||||
{ accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// 2. Accruals (Rechnungsabgrenzung) - SKR04 uses 1900 for prepaid expenses
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Aktive Rechnungsabgrenzung - Vorausbezahlte Versicherung',
|
||||
reference: 'ARA-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '1900', debit: 1000, description: 'Aktive Rechnungsabgrenzung', postingKey: 40 },
|
||||
{ accountNumber: '3200', credit: 1000, description: 'Versicherungen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// 3. Provisions (Rückstellungen) - SKR04 uses 0800 for provisions
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Rückstellung für Jahresabschlusskosten',
|
||||
reference: 'RS-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '3500', debit: 3000, description: 'Rechts- und Beratungskosten', postingKey: 40 },
|
||||
{ accountNumber: '0800', credit: 3000, description: 'Rückstellungen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// 4. VAT clearing (Umsatzsteuer-Vorauszahlung)
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'USt-Abschluss Q4',
|
||||
reference: 'UST-2024-Q4',
|
||||
lines: [
|
||||
{ accountNumber: '1771', debit: 8740, description: 'USt-Saldo', postingKey: 40 }, // Total collected VAT
|
||||
{ accountNumber: '1571', credit: 7191.50, description: 'Vorsteuer-Saldo', postingKey: 40 }, // Total input VAT
|
||||
{ accountNumber: '1700', credit: 1548.50, description: 'USt-Zahllast', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Assert VAT accounts are cleared
|
||||
const ust19 = await api.getAccountBalance('1771');
|
||||
const vorst19 = await api.getAccountBalance('1571');
|
||||
const ustZahllast = await api.getAccountBalance('1700');
|
||||
|
||||
expect(Math.abs(ust19.balance)).toBeLessThan(0.01);
|
||||
expect(Math.abs(vorst19.balance)).toBeLessThan(0.01);
|
||||
// Account 1700 started with 28000 from opening balance, plus 1548.50 from VAT clearing
|
||||
expect(Math.abs(ustZahllast.balance - 29548.50)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
tap.test('should calculate income statement (GuV) before closing for SKR04', async () => {
|
||||
const incomeStatement = await api.generateIncomeStatement({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
expect(incomeStatement).toBeDefined();
|
||||
expect(incomeStatement.totalRevenue).toBeGreaterThan(0);
|
||||
expect(incomeStatement.totalExpenses).toBeGreaterThan(0);
|
||||
|
||||
// Assert the exact expected values based on actual bookings
|
||||
// Revenue: 46000 (4300 account)
|
||||
// Expenses: 5000 + 18000 + 3600 + 10000 + 2000 + 150 + 5000 + 5500 + 200 = 49450
|
||||
// Less credit balances: -1000 (insurance accrual) = -1000
|
||||
// Net expenses: 49450 - 1000 = 48450
|
||||
// Net income: 46000 - 48450 = -2450 (loss)
|
||||
|
||||
expect(Math.round(incomeStatement.totalRevenue)).toEqual(46000);
|
||||
expect(Math.round(incomeStatement.totalExpenses)).toEqual(48450);
|
||||
expect(Math.round(incomeStatement.netIncome)).toEqual(-2450);
|
||||
|
||||
console.log('Income Statement Summary (SKR04):');
|
||||
console.log('Revenue:', incomeStatement.totalRevenue);
|
||||
console.log('Expenses:', incomeStatement.totalExpenses);
|
||||
console.log('Net Income:', incomeStatement.netIncome);
|
||||
});
|
||||
|
||||
tap.test('should perform closing entries (Abschlussbuchungen) for SKR04', async () => {
|
||||
// Close all income and expense accounts to the profit/loss account
|
||||
// SKR04 uses 9500 for annual P&L account
|
||||
|
||||
// Close revenue accounts
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschluss Ertragskonten',
|
||||
reference: 'AB-2024-001',
|
||||
lines: [
|
||||
{ accountNumber: '4300', debit: 46000, description: 'Erlöse abschließen', postingKey: 40 },
|
||||
{ accountNumber: '9500', credit: 46000, description: 'GuV-Konto', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Close expense accounts
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Abschluss Aufwandskonten',
|
||||
reference: 'AB-2024-002',
|
||||
lines: [
|
||||
{ accountNumber: '9500', debit: 48450, description: 'GuV-Konto', postingKey: 40 },
|
||||
{ accountNumber: '3200', debit: 1000, description: 'Versicherung abschließen (credit balance)', postingKey: 40 },
|
||||
{ accountNumber: '2100', credit: 5000, description: 'Bezogene Waren abschließen', postingKey: 40 },
|
||||
{ accountNumber: '2300', credit: 18000, description: 'Löhne abschließen', postingKey: 40 },
|
||||
{ accountNumber: '2400', credit: 3600, description: 'Gehälter abschließen', postingKey: 40 },
|
||||
{ accountNumber: '3700', credit: 10000, description: 'AfA abschließen', postingKey: 40 },
|
||||
{ accountNumber: '3000', credit: 2000, description: 'Miete abschließen', postingKey: 40 },
|
||||
{ accountNumber: '3300', credit: 150, description: 'Kfz abschließen', postingKey: 40 },
|
||||
{ accountNumber: '3400', credit: 5000, description: 'Werbung abschließen', postingKey: 40 },
|
||||
{ accountNumber: '3500', credit: 5500, description: 'Beratung abschließen', postingKey: 40 },
|
||||
{ accountNumber: '3100', credit: 200, description: 'Bürobedarf abschließen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
// Transfer profit/loss to equity
|
||||
const guv_result = 46000 - 48450; // Loss of 2450
|
||||
if (guv_result > 0) {
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Jahresgewinn auf Eigenkapital',
|
||||
reference: 'AB-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '9500', debit: guv_result, description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||
{ accountNumber: '9300', credit: guv_result, description: 'Gewinnrücklagen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
} else if (guv_result < 0) {
|
||||
await api.postJournalEntry({
|
||||
date: new Date('2024-12-31'),
|
||||
description: 'Jahresverlust auf Eigenkapital',
|
||||
reference: 'AB-2024-003',
|
||||
lines: [
|
||||
{ accountNumber: '9400', debit: Math.abs(guv_result), description: 'Verlustvortrag', postingKey: 40 },
|
||||
{ accountNumber: '9500', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
}
|
||||
|
||||
// Assert GuV account is closed and equity is updated
|
||||
const guv = await api.getAccountBalance('9500');
|
||||
const verlustvortrag = await api.getAccountBalance('9400');
|
||||
|
||||
expect(Math.abs(guv.balance)).toBeLessThan(0.01);
|
||||
expect(Math.round(verlustvortrag.balance)).toEqual(-2450); // Loss of 2450 (debit balance is negative)
|
||||
|
||||
// Assert all P&L accounts are closed (zero balance)
|
||||
const plAccounts = ['4300', '2100', '2300', '2400', '3400', '3500', '3100', '3700', '3000', '3200', '3300'];
|
||||
for (const accNum of plAccounts) {
|
||||
const balance = await api.getAccountBalance(accNum);
|
||||
expect(Math.abs(balance.balance)).toBeLessThan(0.01);
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should generate final balance sheet (Schlussbilanz) for SKR04', async () => {
|
||||
const balanceSheet = await api.generateBalanceSheet({
|
||||
dateTo: new Date('2024-12-31'),
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
expect(balanceSheet).toBeDefined();
|
||||
expect(balanceSheet.assets).toBeDefined();
|
||||
expect(balanceSheet.liabilities).toBeDefined();
|
||||
expect(balanceSheet.equity).toBeDefined();
|
||||
|
||||
console.log('\n=== JAHRESABSCHLUSS 2024 (SKR04) ===\n');
|
||||
console.log('BILANZ zum 31.12.2024\n');
|
||||
|
||||
// Verify balance sheet balances
|
||||
const totalAssets = balanceSheet.assets.totalAssets;
|
||||
const totalLiabilitiesAndEquity = balanceSheet.liabilities.totalLiabilities + balanceSheet.equity.totalEquity;
|
||||
|
||||
console.log('Balance Sheet Check (SKR04):');
|
||||
console.log(' Total Assets:', totalAssets);
|
||||
console.log(' Total Liabilities + Equity:', totalLiabilitiesAndEquity);
|
||||
console.log(' Difference:', Math.abs(totalAssets - totalLiabilitiesAndEquity));
|
||||
|
||||
expect(Math.abs(totalAssets - totalLiabilitiesAndEquity)).toBeLessThan(0.01);
|
||||
console.log('✓ Balance Sheet is balanced!');
|
||||
});
|
||||
|
||||
tap.test('should generate trial balance (Summen- und Saldenliste) for SKR04', async () => {
|
||||
const trialBalance = await api.generateTrialBalance({
|
||||
dateFrom: new Date('2024-01-01'),
|
||||
dateTo: new Date('2024-12-31'),
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
expect(trialBalance).toBeDefined();
|
||||
expect(trialBalance.isBalanced).toBeTrue();
|
||||
|
||||
console.log('\nSUMMEN- UND SALDENLISTE 2024 (SKR04)');
|
||||
console.log('=====================================');
|
||||
console.log('Konto | Bezeichnung | Soll | Haben | Saldo');
|
||||
console.log('------|-------------|------|-------|-------');
|
||||
|
||||
// Display key accounts
|
||||
const keyAccounts = [
|
||||
'0200', '0210', '0400', '0500', // Fixed assets
|
||||
'1000', '1200', '1400', '1900', // Current assets
|
||||
'9000', '9400', '9300', // Equity
|
||||
'1600', '1700', '0800', // Liabilities
|
||||
];
|
||||
|
||||
for (const accountNumber of keyAccounts) {
|
||||
const account = await api.getAccount(accountNumber);
|
||||
if (account) {
|
||||
const balance = await api.getAccountBalance(accountNumber);
|
||||
console.log(`${accountNumber} | ${account.accountName.substring(0, 30).padEnd(30)} | ${balance.debitTotal.toFixed(2).padStart(12)} | ${balance.creditTotal.toFixed(2).padStart(12)} | ${balance.balance.toFixed(2).padStart(12)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('should close API connection', async () => {
|
||||
await api.close();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
+11
-5
@@ -1,12 +1,18 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as skr from '../ts/index.js';
|
||||
import { getTestConfig } from './helpers/setup.js';
|
||||
|
||||
let api: skr.SkrApi;
|
||||
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||
|
||||
tap.test('should initialize SKR03 API', async () => {
|
||||
testConfig = await getTestConfig();
|
||||
|
||||
// Use timestamp to ensure unique database for each test run
|
||||
const timestamp = Date.now();
|
||||
api = new skr.SkrApi({
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
dbName: 'test_skr03',
|
||||
mongoDbUrl: testConfig.mongoDbUrl,
|
||||
dbName: `${testConfig.mongoDbName}_skr03_${timestamp}`,
|
||||
});
|
||||
|
||||
await api.initialize('SKR03');
|
||||
@@ -85,9 +91,9 @@ tap.test('should post journal entry in SKR03', async () => {
|
||||
description: 'Test journal entry',
|
||||
reference: 'JE-001',
|
||||
lines: [
|
||||
{ accountNumber: '1000', debit: 500 }, // Cash
|
||||
{ accountNumber: '1200', debit: 500 }, // Bank
|
||||
{ accountNumber: '4000', credit: 1000 }, // Revenue
|
||||
{ accountNumber: '1000', debit: 500, postingKey: 40 }, // Cash
|
||||
{ accountNumber: '1200', debit: 500, postingKey: 40 }, // Bank
|
||||
{ accountNumber: '4000', credit: 1000, postingKey: 40 }, // Revenue
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
+18
-3
@@ -1,12 +1,18 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as skr from '../ts/index.js';
|
||||
import { getTestConfig } from './helpers/setup.js';
|
||||
|
||||
let api: skr.SkrApi;
|
||||
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||
|
||||
tap.test('should initialize SKR04 API', async () => {
|
||||
testConfig = await getTestConfig();
|
||||
|
||||
// Use timestamp to ensure unique database for each test run
|
||||
const timestamp = Date.now();
|
||||
api = new skr.SkrApi({
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
dbName: 'test_skr04',
|
||||
mongoDbUrl: testConfig.mongoDbUrl,
|
||||
dbName: `${testConfig.mongoDbName}_skr04_${timestamp}`,
|
||||
});
|
||||
|
||||
await api.initialize('SKR04');
|
||||
@@ -64,10 +70,19 @@ tap.test('should handle Class 8 as free for use in SKR04', async () => {
|
||||
});
|
||||
|
||||
tap.test('should post complex transaction in SKR04', async () => {
|
||||
// Create creditor account for supplier
|
||||
await api.createAccount({
|
||||
accountNumber: '70001',
|
||||
accountName: 'Lieferant Test GmbH',
|
||||
accountClass: 7,
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR04',
|
||||
});
|
||||
|
||||
const transaction = await api.postTransaction({
|
||||
date: new Date(),
|
||||
debitAccount: '5400', // Goods with 19% VAT
|
||||
creditAccount: '1600', // Trade payables
|
||||
creditAccount: '70001', // Creditor account (supplier)
|
||||
amount: 119,
|
||||
description: 'Purchase with VAT',
|
||||
reference: 'BILL-001',
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as skr from '../ts/index.js';
|
||||
import { getTestConfig } from './helpers/setup.js';
|
||||
|
||||
let api: skr.SkrApi;
|
||||
let testConfig: Awaited<ReturnType<typeof getTestConfig>>;
|
||||
|
||||
tap.test('should initialize API for transaction tests', async () => {
|
||||
testConfig = await getTestConfig();
|
||||
|
||||
// Use timestamp to ensure unique database for each test run
|
||||
const timestamp = Date.now();
|
||||
api = new skr.SkrApi({
|
||||
mongoDbUrl: 'mongodb://localhost:27017',
|
||||
dbName: 'test_transactions',
|
||||
mongoDbUrl: testConfig.mongoDbUrl,
|
||||
dbName: `${testConfig.mongoDbName}_transactions_${timestamp}`,
|
||||
});
|
||||
|
||||
await api.initialize('SKR03');
|
||||
@@ -23,8 +29,8 @@ tap.test('should enforce double-entry bookkeeping rules', async () => {
|
||||
description: 'Unbalanced entry',
|
||||
reference: 'TEST-001',
|
||||
lines: [
|
||||
{ accountNumber: '1000', debit: 100 },
|
||||
{ accountNumber: '4000', credit: 50 }, // Unbalanced!
|
||||
{ accountNumber: '1000', debit: 100, postingKey: 40 },
|
||||
{ accountNumber: '4000', credit: 50, postingKey: 40 }, // Unbalanced!
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
@@ -93,10 +99,10 @@ tap.test(
|
||||
description: 'Complex distribution',
|
||||
reference: 'COMPLEX-001',
|
||||
lines: [
|
||||
{ accountNumber: '5000', debit: 500, description: 'Materials' },
|
||||
{ accountNumber: '6000', debit: 300, description: 'Wages' },
|
||||
{ accountNumber: '7100', debit: 200, description: 'Rent' },
|
||||
{ accountNumber: '1200', credit: 1000, description: 'Bank payment' },
|
||||
{ accountNumber: '5000', debit: 500, description: 'Materials', postingKey: 40 },
|
||||
{ accountNumber: '6000', debit: 300, description: 'Wages', postingKey: 40 },
|
||||
{ accountNumber: '7100', debit: 200, description: 'Rent', postingKey: 40 },
|
||||
{ accountNumber: '1200', credit: 1000, description: 'Bank payment', postingKey: 40 },
|
||||
],
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
@@ -214,10 +220,19 @@ tap.test('should handle batch transaction posting', async () => {
|
||||
});
|
||||
|
||||
tap.test('should handle transaction with VAT', async () => {
|
||||
// Create creditor account for supplier
|
||||
await api.createAccount({
|
||||
accountNumber: '70001',
|
||||
accountName: 'Lieferant Test GmbH',
|
||||
accountClass: 7,
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR03',
|
||||
});
|
||||
|
||||
const transaction = await api.postTransaction({
|
||||
date: new Date(),
|
||||
debitAccount: '5400', // Goods with 19% VAT
|
||||
creditAccount: '1600', // Trade payables
|
||||
creditAccount: '70001', // Creditor account (supplier)
|
||||
amount: 119,
|
||||
description: 'Purchase including VAT',
|
||||
skrType: 'SKR03',
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@fin.cx/skr',
|
||||
version: '1.3.0',
|
||||
description: 'SKR03 and SKR04 German accounting standards for double-entry bookkeeping'
|
||||
}
|
||||
+43
-9
@@ -1,10 +1,44 @@
|
||||
export * from './skr.types.js';
|
||||
export * from './skr.classes.account.js';
|
||||
export * from './skr.classes.transaction.js';
|
||||
export * from './skr.classes.journalentry.js';
|
||||
export * from './skr.classes.chartofaccounts.js';
|
||||
export * from './skr.classes.ledger.js';
|
||||
export * from './skr.classes.reports.js';
|
||||
export * from './skr.api.js';
|
||||
export * from './skr03.data.js';
|
||||
export * from './skr04.data.js';
|
||||
export { Account } from './skr.classes.account.js';
|
||||
export { Transaction } from './skr.classes.transaction.js';
|
||||
export { JournalEntry } from './skr.classes.journalentry.js';
|
||||
export { ChartOfAccounts } from './skr.classes.chartofaccounts.js';
|
||||
export { Ledger } from './skr.classes.ledger.js';
|
||||
export { Reports } from './skr.classes.reports.js';
|
||||
export { SkrApi } from './skr.api.js';
|
||||
export { SKR03_ACCOUNTS, SKR03_ACCOUNT_CLASSES } from './skr03.data.js';
|
||||
export { SKR04_ACCOUNTS, SKR04_ACCOUNT_CLASSES } from './skr04.data.js';
|
||||
export { SkrExport } from './skr.export.js';
|
||||
export type {
|
||||
IExportOptions,
|
||||
IExportMetadata,
|
||||
IBagItManifest,
|
||||
IDocumentIndex,
|
||||
} from './skr.export.js';
|
||||
export { LedgerExporter } from './skr.export.ledger.js';
|
||||
export type {
|
||||
ITransactionDataExport,
|
||||
IJournalEntryExport,
|
||||
IJournalEntryLineExport,
|
||||
ILedgerEntry,
|
||||
ILedgerLine,
|
||||
IDocumentRef,
|
||||
} from './skr.export.ledger.js';
|
||||
export { AccountsExporter } from './skr.export.accounts.js';
|
||||
export type {
|
||||
IAccountDataExport,
|
||||
IAccountExportRow,
|
||||
} from './skr.export.accounts.js';
|
||||
export { BalancesExporter } from './skr.export.balances.js';
|
||||
export type {
|
||||
IAccountBalanceExport,
|
||||
IBalanceExportRow,
|
||||
} from './skr.export.balances.js';
|
||||
export { PdfReportGenerator } from './skr.export.pdf.js';
|
||||
export type { IPdfReportOptions } from './skr.export.pdf.js';
|
||||
export { SecurityManager } from './skr.security.js';
|
||||
export type {
|
||||
ISigningOptions,
|
||||
ISignatureResult,
|
||||
ITimestampResponse,
|
||||
} from './skr.security.js';
|
||||
|
||||
+59
-1
@@ -3,5 +3,63 @@ 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 smartfsModule from '@push.rocks/smartfs';
|
||||
import * as smarthash from '@push.rocks/smarthash';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
import * as path from 'path';
|
||||
|
||||
export { smartdata, smartunique, smarttime, smartlog };
|
||||
// third party
|
||||
import { MerkleTree } from 'merkletreejs';
|
||||
|
||||
const smartfs = new smartfsModule.SmartFs(
|
||||
new smartfsModule.SmartFsProviderNode(),
|
||||
);
|
||||
|
||||
const smartfile = {
|
||||
fs: {
|
||||
ensureDir: async (dirPath: string): Promise<void> => {
|
||||
await smartfs.directory(dirPath).create();
|
||||
},
|
||||
toBuffer: async (filePath: string): Promise<Buffer> => {
|
||||
return (await smartfs.file(filePath).read()) as Buffer;
|
||||
},
|
||||
toStringSync: async (filePath: string): Promise<string> => {
|
||||
return (await smartfs.file(filePath).encoding('utf8').read()) as string;
|
||||
},
|
||||
fileExists: async (filePath: string): Promise<boolean> => {
|
||||
return await smartfs.file(filePath).exists();
|
||||
},
|
||||
listFileTree: async (dirPath: string, pattern: string): Promise<string[]> => {
|
||||
const suffix = pattern.replace(/^\*\*\/\*/, '');
|
||||
try {
|
||||
const entries = await smartfs.directory(dirPath).recursive().list();
|
||||
return entries
|
||||
.filter((entry) => entry.isFile && entry.path.endsWith(suffix))
|
||||
.map((entry) => path.relative(dirPath, entry.path));
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
toFs: async (content: string | Buffer, filePath: string): Promise<void> => {
|
||||
await smartfs.directory(path.dirname(filePath)).create();
|
||||
await smartfs.file(filePath).write(content);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export {
|
||||
smartdata,
|
||||
smartunique,
|
||||
smarttime,
|
||||
smartlog,
|
||||
smartfs,
|
||||
smartfile,
|
||||
smarthash,
|
||||
smartpath,
|
||||
MerkleTree,
|
||||
};
|
||||
|
||||
+495
-4
@@ -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');
|
||||
@@ -158,7 +187,8 @@ export class SkrApi {
|
||||
transactionData: ITransactionData,
|
||||
): Promise<Transaction> {
|
||||
this.ensureInitialized();
|
||||
return await this.chartOfAccounts.postTransaction(transactionData);
|
||||
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||
return await this.ledger.postTransaction(transactionData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +198,8 @@ export class SkrApi {
|
||||
journalData: IJournalEntry,
|
||||
): Promise<JournalEntry> {
|
||||
this.ensureInitialized();
|
||||
return await this.chartOfAccounts.postJournalEntry(journalData);
|
||||
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||
return await this.ledger.postJournalEntry(journalData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -348,6 +379,266 @@ 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<string> {
|
||||
this.ensureInitialized();
|
||||
if (!this.ledger || !this.reports || !this.currentSKRType) {
|
||||
throw new Error('API not fully initialized');
|
||||
}
|
||||
|
||||
this.logger.log('info', `Starting Jahresabschluss export for fiscal year ${options.fiscalYear}`);
|
||||
|
||||
// Create export instance
|
||||
const exporter = new SkrExport(options);
|
||||
|
||||
// Create BagIt structure
|
||||
await exporter.createBagItStructure();
|
||||
await exporter.createExportMetadata(this.currentSKRType);
|
||||
await exporter.createSchemas();
|
||||
|
||||
// Export accounting data
|
||||
await this.exportLedgerData(exporter, options);
|
||||
await this.exportAccountData(exporter, options);
|
||||
await this.exportBalanceData(exporter, options);
|
||||
|
||||
// Generate PDF reports if requested
|
||||
if (options.generatePdfReports) {
|
||||
await this.generatePdfReports(exporter, options);
|
||||
}
|
||||
|
||||
// Sign export if requested
|
||||
if (options.signExport) {
|
||||
await this.signExport(exporter, options);
|
||||
}
|
||||
|
||||
// Create manifests and validate
|
||||
await exporter.writeManifests();
|
||||
const merkleRoot = await exporter.createMerkleTree();
|
||||
|
||||
const isValid = await exporter.validateBagIt();
|
||||
if (!isValid) {
|
||||
throw new Error('BagIt validation failed');
|
||||
}
|
||||
|
||||
this.logger.log('ok', `Jahresabschluss export completed. Merkle root: ${merkleRoot}`);
|
||||
|
||||
return options.exportPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export ledger data in NDJSON format
|
||||
*/
|
||||
private async exportLedgerData(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||
|
||||
const ledgerExporter = new LedgerExporter(options.exportPath);
|
||||
await ledgerExporter.initialize();
|
||||
|
||||
// Get all transactions for the period
|
||||
const transactions = await this.chartOfAccounts.getTransactions({
|
||||
dateFrom: options.dateFrom,
|
||||
dateTo: options.dateTo
|
||||
});
|
||||
|
||||
// Export each transaction
|
||||
for (const transaction of transactions) {
|
||||
const transactionData = transaction;
|
||||
await ledgerExporter.exportTransaction(transactionData as any);
|
||||
}
|
||||
|
||||
// Get all journal entries for the period
|
||||
// Use MongoDB query syntax for date range
|
||||
const journalEntries = await JournalEntry.getInstances({
|
||||
date: {
|
||||
$gte: options.dateFrom,
|
||||
$lte: options.dateTo
|
||||
} as any, // SmartData supports MongoDB query operators
|
||||
skrType: this.currentSKRType
|
||||
});
|
||||
|
||||
// Export each journal entry
|
||||
for (const entry of journalEntries) {
|
||||
const entryData = entry;
|
||||
await ledgerExporter.exportJournalEntry(entryData as any);
|
||||
}
|
||||
|
||||
const entryCount = await ledgerExporter.close();
|
||||
this.logger.log('info', `Exported ${entryCount} ledger entries`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export account data in CSV format
|
||||
*/
|
||||
private async exportAccountData(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||
const accountsExporter = new AccountsExporter(options.exportPath);
|
||||
|
||||
// Get all accounts
|
||||
const accounts = await this.chartOfAccounts.getAllAccounts();
|
||||
|
||||
// Add each account to export
|
||||
for (const account of accounts) {
|
||||
const accountData = account;
|
||||
accountsExporter.addAccount(accountData as any);
|
||||
}
|
||||
|
||||
// Export to CSV and JSON
|
||||
await accountsExporter.exportToCSV();
|
||||
await accountsExporter.exportToJSON();
|
||||
|
||||
this.logger.log('info', `Exported ${accountsExporter.getAccountCount()} accounts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export balance data in CSV format
|
||||
*/
|
||||
private async exportBalanceData(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||
if (!this.ledger) throw new Error('Ledger not initialized');
|
||||
|
||||
const balancesExporter = new BalancesExporter(
|
||||
options.exportPath,
|
||||
options.fiscalYear
|
||||
);
|
||||
|
||||
// Get all accounts with balances
|
||||
const accounts = await this.chartOfAccounts.getAllAccounts();
|
||||
|
||||
for (const account of accounts) {
|
||||
const balance = await this.ledger.getAccountBalance(
|
||||
account.accountNumber,
|
||||
options.dateTo
|
||||
);
|
||||
|
||||
if (balance) {
|
||||
balancesExporter.addBalance(
|
||||
account.accountNumber,
|
||||
account.accountName,
|
||||
balance as IAccountBalance,
|
||||
`${options.fiscalYear}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export balance reports
|
||||
await balancesExporter.exportToCSV();
|
||||
await balancesExporter.exportTrialBalance();
|
||||
await balancesExporter.exportClassSummary();
|
||||
|
||||
this.logger.log('info', `Exported ${balancesExporter.getBalanceCount()} account balances`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF reports for the export
|
||||
*/
|
||||
private async generatePdfReports(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||
if (!this.reports) throw new Error('Reports not initialized');
|
||||
const skrType = this.currentSKRType;
|
||||
if (!skrType) {
|
||||
throw new Error('API not initialized. Call initialize() first.');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const incomeStatement = await this.reports.getIncomeStatement({
|
||||
dateFrom: options.dateFrom,
|
||||
dateTo: options.dateTo,
|
||||
skrType,
|
||||
});
|
||||
|
||||
const balanceSheet = await this.reports.getBalanceSheet({
|
||||
dateFrom: options.dateFrom,
|
||||
dateTo: options.dateTo,
|
||||
skrType,
|
||||
});
|
||||
|
||||
// Generate PDFs
|
||||
const jahresabschlussPdf = await pdfGenerator.generateJahresabschlussPdf(
|
||||
trialBalance,
|
||||
incomeStatement,
|
||||
balanceSheet
|
||||
);
|
||||
|
||||
// Save PDFs
|
||||
await pdfGenerator.savePdfReport('jahresabschluss.pdf', jahresabschlussPdf);
|
||||
|
||||
// Store in BagIt structure
|
||||
await exporter.storeDocument(jahresabschlussPdf, 'jahresabschluss.pdf');
|
||||
|
||||
this.logger.log('info', 'PDF reports generated successfully');
|
||||
} finally {
|
||||
await pdfGenerator.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign the export with CAdES signature
|
||||
*/
|
||||
private async signExport(exporter: SkrExport, options: IExportOptions): Promise<void> {
|
||||
const signingOptions: ISigningOptions = {
|
||||
certificatePem: options.signExport ? undefined : undefined, // Use provided cert or generate
|
||||
privateKeyPem: options.signExport ? undefined : undefined,
|
||||
includeTimestamp: options.timestampExport !== false
|
||||
};
|
||||
|
||||
const security = new SecurityManager(signingOptions);
|
||||
|
||||
// Generate self-signed certificate if none provided
|
||||
let cert: string, key: string;
|
||||
if (!signingOptions.certificatePem) {
|
||||
const generated = await security.generateSelfSignedCertificate(
|
||||
options.companyInfo?.name || 'SKR Export System'
|
||||
);
|
||||
cert = generated.certificate;
|
||||
key = generated.privateKey;
|
||||
} else {
|
||||
cert = signingOptions.certificatePem;
|
||||
key = signingOptions.privateKeyPem!;
|
||||
}
|
||||
|
||||
// Sign the manifest
|
||||
const manifestPath = path.resolve(
|
||||
options.exportPath,
|
||||
`jahresabschluss_${options.fiscalYear}`,
|
||||
'manifest-sha256.txt'
|
||||
);
|
||||
|
||||
await security.createDetachedSignature(
|
||||
manifestPath,
|
||||
path.resolve(
|
||||
options.exportPath,
|
||||
`jahresabschluss_${options.fiscalYear}`,
|
||||
'data',
|
||||
'metadata',
|
||||
'signatures',
|
||||
'manifest.cades'
|
||||
)
|
||||
);
|
||||
|
||||
this.logger.log('info', 'Export signed with CAdES signature');
|
||||
}
|
||||
|
||||
// ========== Utility Methods ==========
|
||||
|
||||
/**
|
||||
@@ -415,7 +706,7 @@ export class SkrApi {
|
||||
const transaction = await this.postTransaction(transactions[i]);
|
||||
results.push(transaction);
|
||||
} catch (error) {
|
||||
errors.push({ index: i, error: error.message });
|
||||
errors.push({ index: i, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +739,7 @@ export class SkrApi {
|
||||
const account = await this.createAccount(accounts[i]);
|
||||
results.push(account);
|
||||
} catch (error) {
|
||||
errors.push({ index: i, error: error.message });
|
||||
errors.push({ index: i, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,4 +821,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<IInvoice> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceAdapter || !this.invoiceStorage || !this.invoiceBookingEngine) {
|
||||
throw new Error('Invoice components not initialized');
|
||||
}
|
||||
|
||||
this.logger.log('info', `Importing ${direction} invoice`);
|
||||
|
||||
// Parse and validate invoice
|
||||
const invoice = await this.invoiceAdapter.parseInvoice(file, direction);
|
||||
|
||||
// Store invoice
|
||||
await this.invoiceStorage.initialize();
|
||||
const contentHash = await this.invoiceStorage.storeInvoice(invoice);
|
||||
invoice.contentHash = contentHash;
|
||||
|
||||
// Auto-book if requested
|
||||
if (options?.autoBook) {
|
||||
const bookingResult = await this.bookInvoice(
|
||||
invoice,
|
||||
options.bookingRules,
|
||||
{
|
||||
autoBook: true,
|
||||
confidenceThreshold: options.confidenceThreshold || 80,
|
||||
skipValidation: options.validateOnly
|
||||
}
|
||||
);
|
||||
|
||||
if (bookingResult.success && bookingResult.bookingInfo) {
|
||||
invoice.bookingInfo = bookingResult.bookingInfo;
|
||||
invoice.status = 'posted';
|
||||
|
||||
// Update stored metadata with booking information
|
||||
await this.invoiceStorage.updateMetadata(invoice.contentHash, {
|
||||
journalEntryId: bookingResult.bookingInfo.journalEntryId,
|
||||
transactionIds: bookingResult.bookingInfo.transactionIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('info', `Invoice imported successfully: ${invoice.invoiceNumber}`);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Book an invoice to the ledger
|
||||
*/
|
||||
public async bookInvoice(
|
||||
invoice: IInvoice,
|
||||
bookingRules?: Partial<IBookingRules>,
|
||||
options?: IBookingOptions
|
||||
): Promise<IBookingResult> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceBookingEngine) {
|
||||
throw new Error('Invoice booking engine not initialized');
|
||||
}
|
||||
|
||||
this.logger.log('info', `Booking invoice ${invoice.invoiceNumber}`);
|
||||
|
||||
const result = await this.invoiceBookingEngine.bookInvoice(
|
||||
invoice,
|
||||
bookingRules,
|
||||
options
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
this.logger.log('info', `Invoice booked successfully with confidence ${result.confidence}%`);
|
||||
|
||||
// Update stored metadata if invoice has a content hash
|
||||
if (invoice.contentHash && result.bookingInfo && this.invoiceStorage) {
|
||||
await this.invoiceStorage.updateMetadata(invoice.contentHash, {
|
||||
journalEntryId: result.bookingInfo.journalEntryId,
|
||||
transactionIds: result.bookingInfo.transactionIds
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.logger.log('error', `Invoice booking failed: ${result.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an invoice in a different format
|
||||
*/
|
||||
public async exportInvoice(
|
||||
invoice: IInvoice,
|
||||
options: IInvoiceExportOptions
|
||||
): Promise<{ xml: string; pdf?: Buffer }> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceAdapter) {
|
||||
throw new Error('Invoice adapter not initialized');
|
||||
}
|
||||
|
||||
this.logger.log('info', `Exporting invoice ${invoice.invoiceNumber} to ${options.format}`);
|
||||
|
||||
// Convert format if needed
|
||||
const xml = await this.invoiceAdapter.convertFormat(invoice, options.format);
|
||||
|
||||
// Generate PDF if requested
|
||||
let pdf: Buffer | undefined;
|
||||
if (options.embedInPdf) {
|
||||
const result = await this.invoiceAdapter.generateInvoice(invoice, options.format);
|
||||
pdf = result.pdf;
|
||||
}
|
||||
|
||||
return { xml, pdf };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search invoices by filter
|
||||
*/
|
||||
public async searchInvoices(filter: IInvoiceFilter): Promise<IInvoice[]> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceStorage) {
|
||||
throw new Error('Invoice storage not initialized');
|
||||
}
|
||||
|
||||
await this.invoiceStorage.initialize();
|
||||
const metadata = await this.invoiceStorage.searchInvoices(filter);
|
||||
|
||||
const invoices: IInvoice[] = [];
|
||||
for (const meta of metadata) {
|
||||
const invoice = await this.invoiceStorage.retrieveInvoice(meta.contentHash);
|
||||
if (invoice) {
|
||||
invoices.push(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
return invoices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice by content hash
|
||||
*/
|
||||
public async getInvoice(contentHash: string): Promise<IInvoice | null> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceStorage) {
|
||||
throw new Error('Invoice storage not initialized');
|
||||
}
|
||||
|
||||
await this.invoiceStorage.initialize();
|
||||
return await this.invoiceStorage.retrieveInvoice(contentHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoice storage statistics
|
||||
*/
|
||||
public async getInvoiceStatistics(): Promise<any> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceStorage) {
|
||||
throw new Error('Invoice storage not initialized');
|
||||
}
|
||||
|
||||
await this.invoiceStorage.initialize();
|
||||
return await this.invoiceStorage.getStatistics();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create EN16931 compliance report for invoices
|
||||
*/
|
||||
public async createInvoiceComplianceReport(): Promise<void> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceStorage) {
|
||||
throw new Error('Invoice storage not initialized');
|
||||
}
|
||||
|
||||
await this.invoiceStorage.initialize();
|
||||
await this.invoiceStorage.createComplianceReport();
|
||||
|
||||
this.logger.log('info', 'Invoice compliance report created');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an invoice from internal data
|
||||
*/
|
||||
public async generateInvoice(
|
||||
invoiceData: Partial<IInvoice>,
|
||||
format: IInvoiceExportOptions['format']
|
||||
): Promise<{ xml: string; pdf?: Buffer }> {
|
||||
this.ensureInitialized();
|
||||
if (!this.invoiceAdapter) {
|
||||
throw new Error('Invoice adapter not initialized');
|
||||
}
|
||||
|
||||
this.logger.log('info', `Generating invoice in ${format} format`);
|
||||
|
||||
return await this.invoiceAdapter.generateInvoice(invoiceData, format);
|
||||
}
|
||||
}
|
||||
|
||||
+144
-23
@@ -2,65 +2,86 @@ import * as plugins from './plugins.js';
|
||||
import { getDb, getDbSync } from './skr.database.js';
|
||||
import type { TAccountType, TSKRType, IAccountData } from './skr.types.js';
|
||||
|
||||
const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata;
|
||||
declare abstract class SmartDataDbDocBase {
|
||||
public save(): Promise<void>;
|
||||
public delete(): Promise<void>;
|
||||
public static getInstance<T>(
|
||||
this: new (...args: any[]) => T,
|
||||
query: Record<string, any>,
|
||||
): Promise<T | null>;
|
||||
public static getInstances<T>(
|
||||
this: new (...args: any[]) => T,
|
||||
query: Record<string, any>,
|
||||
): Promise<T[]>;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Collection(() => getDbSync())
|
||||
export class Account extends SmartDataDbDoc<Account, Account> {
|
||||
const SmartDataDbDoc = plugins.smartdata.SmartDataDbDoc as unknown as typeof SmartDataDbDocBase;
|
||||
const Collection = plugins.smartdata.Collection as any;
|
||||
const svDb = plugins.smartdata.svDb as any;
|
||||
const unI = plugins.smartdata.unI as any;
|
||||
const index = plugins.smartdata.index as any;
|
||||
const searchable = plugins.smartdata.searchable as any;
|
||||
|
||||
@Collection(() => getDbSync())
|
||||
export class Account extends SmartDataDbDoc {
|
||||
@unI()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public accountNumber: string;
|
||||
public accountNumber!: string;
|
||||
|
||||
@svDb()
|
||||
@searchable()
|
||||
public accountName: string;
|
||||
public accountName!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public accountClass: number;
|
||||
public accountClass!: number;
|
||||
|
||||
@svDb()
|
||||
public accountGroup: number;
|
||||
public accountGroup!: number;
|
||||
|
||||
@svDb()
|
||||
public accountSubgroup: number;
|
||||
public accountSubgroup!: number;
|
||||
|
||||
@svDb()
|
||||
public accountType: TAccountType;
|
||||
public accountType!: TAccountType;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public skrType: TSKRType;
|
||||
public skrType!: TSKRType;
|
||||
|
||||
@svDb()
|
||||
@searchable()
|
||||
public description: string;
|
||||
public description!: string;
|
||||
|
||||
@svDb()
|
||||
public vatRate: number;
|
||||
public vatRate!: number;
|
||||
|
||||
@svDb()
|
||||
public balance: number;
|
||||
public balance!: number;
|
||||
|
||||
@svDb()
|
||||
public debitTotal: number;
|
||||
public debitTotal!: number;
|
||||
|
||||
@svDb()
|
||||
public creditTotal: number;
|
||||
public creditTotal!: number;
|
||||
|
||||
@svDb()
|
||||
public isActive: boolean;
|
||||
public isActive!: boolean;
|
||||
|
||||
@svDb()
|
||||
public isSystemAccount: boolean;
|
||||
public isSystemAccount!: boolean;
|
||||
|
||||
@svDb()
|
||||
public createdAt: Date;
|
||||
public isAutomaticAccount!: boolean;
|
||||
|
||||
@svDb()
|
||||
public updatedAt: Date;
|
||||
public createdAt!: Date;
|
||||
|
||||
@svDb()
|
||||
public updatedAt!: Date;
|
||||
|
||||
constructor(data?: Partial<IAccountData>) {
|
||||
super();
|
||||
@@ -90,6 +111,7 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
||||
this.debitTotal = 0;
|
||||
this.creditTotal = 0;
|
||||
this.isSystemAccount = true;
|
||||
this.isAutomaticAccount = data.isAutomaticAccount || false;
|
||||
this.createdAt = new Date();
|
||||
this.updatedAt = new Date();
|
||||
}
|
||||
@@ -157,6 +179,85 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account number is in debtor range (10000-69999)
|
||||
* Debtor accounts (Debitorenkonten) are individual customer accounts
|
||||
*/
|
||||
public static isInDebtorRange(accountNumber: string): boolean {
|
||||
const num = parseInt(accountNumber);
|
||||
return num >= 10000 && num <= 69999;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account number is in creditor range (70000-99999)
|
||||
* Creditor accounts (Kreditorenkonten) are individual vendor accounts
|
||||
*/
|
||||
public static isInCreditorRange(accountNumber: string): boolean {
|
||||
const num = parseInt(accountNumber);
|
||||
return num >= 70000 && num <= 99999;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is an automatic account (Automatikkonto)
|
||||
* Automatic accounts like 1400/1600 cannot be posted to directly
|
||||
*/
|
||||
public static isAutomaticAccount(accountNumber: string, skrType: TSKRType): boolean {
|
||||
// SKR03: 1400 (Forderungen), 1600 (Verbindlichkeiten)
|
||||
// SKR04: 1400 (Forderungen), 1600 (Verbindlichkeiten)
|
||||
// Note: In SKR04, 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
|
||||
if (skrType === 'SKR03') {
|
||||
return accountNumber === '1400' || accountNumber === '1600';
|
||||
} else {
|
||||
return accountNumber === '1400' || accountNumber === '1600';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account for posting - throws error if account cannot be posted to
|
||||
*/
|
||||
public static async validateAccountForPosting(
|
||||
accountNumber: string,
|
||||
skrType: TSKRType,
|
||||
): Promise<void> {
|
||||
// Check if automatic account
|
||||
if (Account.isAutomaticAccount(accountNumber, skrType)) {
|
||||
throw new Error(
|
||||
`Account ${accountNumber} is an automatic account (Automatikkonto) and cannot be posted to directly. ` +
|
||||
`Use debtor accounts (10000-69999) or creditor accounts (70000-99999) instead.`
|
||||
);
|
||||
}
|
||||
|
||||
// Get account to verify it exists
|
||||
const account = await Account.getAccountByNumber(accountNumber, skrType);
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
`Account ${accountNumber} not found in ${skrType}. ` +
|
||||
`Please create the account before posting.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if account is active
|
||||
if (!account.isActive) {
|
||||
throw new Error(
|
||||
`Account ${accountNumber} is inactive and cannot be posted to.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this account instance is a debtor account
|
||||
*/
|
||||
public isDebtorAccount(): boolean {
|
||||
return Account.isInDebtorRange(this.accountNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this account instance is a creditor account
|
||||
*/
|
||||
public isCreditorAccount(): boolean {
|
||||
return Account.isInCreditorRange(this.accountNumber);
|
||||
}
|
||||
|
||||
public async updateBalance(
|
||||
debitAmount: number = 0,
|
||||
creditAmount: number = 0,
|
||||
@@ -209,19 +310,33 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
||||
|
||||
public async beforeSave(): Promise<void> {
|
||||
// Validate account number format
|
||||
if (!this.accountNumber || this.accountNumber.length !== 4) {
|
||||
const accountLength = this.accountNumber?.length || 0;
|
||||
if (!this.accountNumber || (accountLength !== 4 && accountLength !== 5)) {
|
||||
throw new Error(
|
||||
`Invalid account number format: ${this.accountNumber}. Must be 4 digits.`,
|
||||
`Invalid account number format: ${this.accountNumber}. Must be 4 digits (standard SKR) or 5 digits (debtor/creditor).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate account number is numeric
|
||||
if (!/^\d{4}$/.test(this.accountNumber)) {
|
||||
if (!/^\d{4,5}$/.test(this.accountNumber)) {
|
||||
throw new Error(
|
||||
`Account number must contain only digits: ${this.accountNumber}`,
|
||||
);
|
||||
}
|
||||
|
||||
// For 5-digit accounts, validate they are in debtor (10000-69999) or creditor (70000-99999) ranges
|
||||
if (accountLength === 5) {
|
||||
const accountNum = parseInt(this.accountNumber);
|
||||
const isDebtor = accountNum >= 10000 && accountNum <= 69999;
|
||||
const isCreditor = accountNum >= 70000 && accountNum <= 99999;
|
||||
|
||||
if (!isDebtor && !isCreditor) {
|
||||
throw new Error(
|
||||
`5-digit account number ${this.accountNumber} must be in debtor range (10000-69999) or creditor range (70000-99999).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate account class matches first digit
|
||||
const firstDigit = parseInt(this.accountNumber[0]);
|
||||
if (this.accountClass !== firstDigit) {
|
||||
@@ -234,5 +349,11 @@ export class Account extends SmartDataDbDoc<Account, Account> {
|
||||
if (this.skrType !== 'SKR03' && this.skrType !== 'SKR04') {
|
||||
throw new Error(`Invalid SKR type: ${this.skrType}`);
|
||||
}
|
||||
|
||||
// Mark automatic accounts (Automatikkonten)
|
||||
// These are summary accounts that cannot be posted to directly
|
||||
if (Account.isAutomaticAccount(this.accountNumber, this.skrType)) {
|
||||
this.isAutomaticAccount = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +262,9 @@ export class ChartOfAccounts {
|
||||
* Search accounts
|
||||
*/
|
||||
public async searchAccounts(searchTerm: string): Promise<Account[]> {
|
||||
if (!this.skrType) {
|
||||
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
||||
}
|
||||
return await Account.searchAccounts(searchTerm, this.skrType);
|
||||
}
|
||||
|
||||
@@ -287,10 +290,11 @@ export class ChartOfAccounts {
|
||||
|
||||
// Apply text search if provided
|
||||
if (filter?.searchTerm) {
|
||||
const lowerSearchTerm = filter.searchTerm.toLowerCase();
|
||||
const searchTerm = filter.searchTerm;
|
||||
const lowerSearchTerm = searchTerm.toLowerCase();
|
||||
return accounts.filter(
|
||||
(account) =>
|
||||
account.accountNumber.includes(filter.searchTerm) ||
|
||||
account.accountNumber.includes(searchTerm) ||
|
||||
account.accountName.toLowerCase().includes(lowerSearchTerm) ||
|
||||
account.description.toLowerCase().includes(lowerSearchTerm),
|
||||
);
|
||||
@@ -468,9 +472,10 @@ export class ChartOfAccounts {
|
||||
await this.createCustomAccount(accountData);
|
||||
importedCount++;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
this.logger.log(
|
||||
'warn',
|
||||
`Failed to import account ${parts[0]}: ${error.message}`,
|
||||
`Failed to import account ${parts[0]}: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+179
-37
@@ -2,73 +2,96 @@ import * as plugins from './plugins.js';
|
||||
import { getDbSync } from './skr.database.js';
|
||||
import { Account } from './skr.classes.account.js';
|
||||
import { Transaction } from './skr.classes.transaction.js';
|
||||
import {
|
||||
validatePostingKey,
|
||||
validatePostingKeyConsistency,
|
||||
getPostingKeyDescription,
|
||||
} from './skr.postingkeys.js';
|
||||
import type {
|
||||
TSKRType,
|
||||
IJournalEntry,
|
||||
IJournalEntryLine,
|
||||
} from './skr.types.js';
|
||||
|
||||
const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata;
|
||||
declare abstract class SmartDataDbDocBase {
|
||||
public save(): Promise<void>;
|
||||
public delete(): Promise<void>;
|
||||
public static getInstance<T>(
|
||||
this: new (...args: any[]) => T,
|
||||
query: Record<string, any>,
|
||||
): Promise<T | null>;
|
||||
public static getInstances<T>(
|
||||
this: new (...args: any[]) => T,
|
||||
query: Record<string, any>,
|
||||
): Promise<T[]>;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Collection(() => getDbSync())
|
||||
export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
const SmartDataDbDoc = plugins.smartdata.SmartDataDbDoc as unknown as typeof SmartDataDbDocBase;
|
||||
const Collection = plugins.smartdata.Collection as any;
|
||||
const svDb = plugins.smartdata.svDb as any;
|
||||
const unI = plugins.smartdata.unI as any;
|
||||
const index = plugins.smartdata.index as any;
|
||||
const searchable = plugins.smartdata.searchable as any;
|
||||
|
||||
@Collection(() => getDbSync())
|
||||
export class JournalEntry extends SmartDataDbDoc {
|
||||
@unI()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public journalNumber: string;
|
||||
public journalNumber!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public date: Date;
|
||||
public date!: Date;
|
||||
|
||||
@svDb()
|
||||
@searchable()
|
||||
public description: string;
|
||||
public description!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public reference: string;
|
||||
public reference!: string;
|
||||
|
||||
@svDb()
|
||||
public lines: IJournalEntryLine[];
|
||||
public lines!: IJournalEntryLine[];
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public skrType: TSKRType;
|
||||
public skrType!: TSKRType;
|
||||
|
||||
@svDb()
|
||||
public totalDebits: number;
|
||||
public totalDebits!: number;
|
||||
|
||||
@svDb()
|
||||
public totalCredits: number;
|
||||
public totalCredits!: number;
|
||||
|
||||
@svDb()
|
||||
public isBalanced: boolean;
|
||||
public isBalanced!: boolean;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public status: 'draft' | 'posted' | 'reversed';
|
||||
public status!: 'draft' | 'posted' | 'reversed';
|
||||
|
||||
@svDb()
|
||||
public transactionIds: string[];
|
||||
public transactionIds!: string[];
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public period: string;
|
||||
public period!: string;
|
||||
|
||||
@svDb()
|
||||
public fiscalYear: number;
|
||||
public fiscalYear!: number;
|
||||
|
||||
@svDb()
|
||||
public createdAt: Date;
|
||||
public createdAt!: Date;
|
||||
|
||||
@svDb()
|
||||
public postedAt: Date;
|
||||
public postedAt!: Date | null;
|
||||
|
||||
@svDb()
|
||||
public createdBy: string;
|
||||
public createdBy!: string;
|
||||
|
||||
constructor(data?: Partial<IJournalEntry>) {
|
||||
super();
|
||||
@@ -96,6 +119,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
this.postedAt = null;
|
||||
this.createdBy = 'system';
|
||||
|
||||
// Normalize any negative amounts to the correct side
|
||||
this.sanitizeLines();
|
||||
// Calculate totals
|
||||
this.calculateTotals();
|
||||
}
|
||||
@@ -107,6 +132,36 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
return `JE-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
private sanitizeLines(): void {
|
||||
for (const line of this.lines) {
|
||||
// Check if both debit and credit are set (not allowed)
|
||||
if (line.debit !== undefined && line.debit !== 0 &&
|
||||
line.credit !== undefined && line.credit !== 0) {
|
||||
throw new Error('A line cannot have both debit and credit amounts');
|
||||
}
|
||||
|
||||
// Handle negative debit - convert to positive credit
|
||||
if (line.debit !== undefined && line.debit < 0) {
|
||||
line.credit = Math.abs(line.debit);
|
||||
delete (line as any).debit;
|
||||
}
|
||||
|
||||
// Handle negative credit - convert to positive debit
|
||||
if (line.credit !== undefined && line.credit < 0) {
|
||||
line.debit = Math.abs(line.credit);
|
||||
delete (line as any).credit;
|
||||
}
|
||||
|
||||
// Check that at least one side has a positive value
|
||||
const hasDebit = line.debit !== undefined && line.debit > 0;
|
||||
const hasCredit = line.credit !== undefined && line.credit > 0;
|
||||
|
||||
if (!hasDebit && !hasCredit) {
|
||||
throw new Error('Either debit or credit must be a positive number');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private calculateTotals(): void {
|
||||
this.totalDebits = 0;
|
||||
this.totalCredits = 0;
|
||||
@@ -180,22 +235,91 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
throw new Error('Journal entry must have at least 2 lines');
|
||||
}
|
||||
|
||||
// Validate all accounts exist and are active
|
||||
// Validate all accounts exist, are active, and can be posted to
|
||||
const validationErrors: string[] = [];
|
||||
const validationWarnings: string[] = [];
|
||||
|
||||
// Check if this journal entry has VAT lines (for smarter posting key validation)
|
||||
const hasVATLines = this.lines.some(line =>
|
||||
line.accountNumber === '1571' || line.accountNumber === '1771' || line.accountNumber === '1576'
|
||||
);
|
||||
|
||||
for (const line of this.lines) {
|
||||
// Validate posting key is present (REQUIRED)
|
||||
if (!line.postingKey) {
|
||||
validationErrors.push(
|
||||
`Line for account ${line.accountNumber} is missing required posting key (Buchungsschlüssel). ` +
|
||||
`Posting keys are mandatory for DATEV compliance.`
|
||||
);
|
||||
continue; // Skip further validation for this line
|
||||
}
|
||||
|
||||
// Validate account is not an automatic account (Automatikkonto)
|
||||
try {
|
||||
await Account.validateAccountForPosting(line.accountNumber, this.skrType);
|
||||
} catch (error) {
|
||||
validationErrors.push(error instanceof Error ? error.message : String(error));
|
||||
continue; // Skip further validation for this line
|
||||
}
|
||||
|
||||
// Get account for posting key validation
|
||||
const account = await Account.getAccountByNumber(
|
||||
line.accountNumber,
|
||||
this.skrType,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
validationErrors.push(
|
||||
`Account ${line.accountNumber} not found for ${this.skrType}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!account.isActive) {
|
||||
throw new Error(`Account ${line.accountNumber} is not active`);
|
||||
validationErrors.push(`Account ${line.accountNumber} is not active`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate posting key for this line
|
||||
const amount = line.debit || line.credit || 0;
|
||||
// For journal entries with VAT lines, pass amount as vatAmount to satisfy validation
|
||||
const postingKeyValidation = validatePostingKey(
|
||||
line.postingKey,
|
||||
line.accountNumber,
|
||||
amount,
|
||||
hasVATLines ? amount : undefined // If entry has VAT lines, we consider the validation satisfied
|
||||
);
|
||||
|
||||
if (!postingKeyValidation.isValid) {
|
||||
validationErrors.push(...postingKeyValidation.errors);
|
||||
}
|
||||
|
||||
if (postingKeyValidation.warnings.length > 0) {
|
||||
validationWarnings.push(...postingKeyValidation.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate posting key consistency across all lines
|
||||
const consistencyValidation = validatePostingKeyConsistency(this.lines);
|
||||
if (!consistencyValidation.isValid) {
|
||||
validationErrors.push(...consistencyValidation.errors);
|
||||
}
|
||||
if (consistencyValidation.warnings.length > 0) {
|
||||
validationWarnings.push(...consistencyValidation.warnings);
|
||||
}
|
||||
|
||||
// Log warnings but don't fail validation
|
||||
if (validationWarnings.length > 0) {
|
||||
console.warn('Journal entry validation warnings:');
|
||||
validationWarnings.forEach(warning => console.warn(` - ${warning}`));
|
||||
}
|
||||
|
||||
// Throw if any errors
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(
|
||||
'Journal entry validation failed:\n' +
|
||||
validationErrors.map(e => ` - ${e}`).join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +328,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
throw new Error('Journal entry is already posted');
|
||||
}
|
||||
|
||||
// Normalize any negative amounts to the correct side
|
||||
this.sanitizeLines();
|
||||
// Validate before posting
|
||||
await this.validate();
|
||||
|
||||
@@ -221,7 +347,7 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
date: this.date,
|
||||
debitAccount: debitLines[0].accountNumber,
|
||||
creditAccount: creditLines[0].accountNumber,
|
||||
amount: debitLines[0].debit,
|
||||
amount: debitLines[0].debit || 0,
|
||||
description: this.description,
|
||||
reference: this.reference,
|
||||
skrType: this.skrType,
|
||||
@@ -230,28 +356,41 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
transactions.push(transaction);
|
||||
} else {
|
||||
// Complex entry: multiple debits and/or credits
|
||||
// Create transactions to balance the entry
|
||||
for (const debitLine of debitLines) {
|
||||
for (const creditLine of creditLines) {
|
||||
const amount = Math.min(debitLine.debit || 0, creditLine.credit || 0);
|
||||
// Build working queues with remaining amounts (don't mutate original lines)
|
||||
const debitQueue = debitLines.map(l => ({
|
||||
line: l,
|
||||
remaining: l.debit || 0
|
||||
}));
|
||||
|
||||
if (amount > 0) {
|
||||
const creditQueue = creditLines.map(l => ({
|
||||
line: l,
|
||||
remaining: l.credit || 0
|
||||
}));
|
||||
|
||||
// Create transactions to balance the entry
|
||||
for (const d of debitQueue) {
|
||||
for (const c of creditQueue) {
|
||||
const amount = Math.min(d.remaining, c.remaining);
|
||||
|
||||
if (amount > 0.0000001) { // small epsilon to avoid float artifacts
|
||||
const transaction = await Transaction.createTransaction({
|
||||
date: this.date,
|
||||
debitAccount: debitLine.accountNumber,
|
||||
creditAccount: creditLine.accountNumber,
|
||||
amount: amount,
|
||||
description: `${this.description} - ${debitLine.description || creditLine.description || ''}`,
|
||||
debitAccount: d.line.accountNumber,
|
||||
creditAccount: c.line.accountNumber,
|
||||
amount: Math.round(amount * 100) / 100, // round to 2 decimals
|
||||
description: `${this.description} - ${d.line.description || c.line.description || ''}`,
|
||||
reference: this.reference,
|
||||
skrType: this.skrType,
|
||||
costCenter: debitLine.costCenter || creditLine.costCenter,
|
||||
costCenter: d.line.costCenter || c.line.costCenter,
|
||||
});
|
||||
transactions.push(transaction);
|
||||
|
||||
// Reduce amounts for tracking
|
||||
if (debitLine.debit) debitLine.debit -= amount;
|
||||
if (creditLine.credit) creditLine.credit -= amount;
|
||||
// Reduce remaining amounts in working copies (not original lines)
|
||||
d.remaining -= amount;
|
||||
c.remaining -= amount;
|
||||
}
|
||||
|
||||
if (d.remaining <= 0.0000001) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,6 +417,7 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
credit: line.debit, // Swap
|
||||
description: `Reversal: ${line.description || ''}`,
|
||||
costCenter: line.costCenter,
|
||||
postingKey: line.postingKey, // Keep same posting key for reversal
|
||||
}));
|
||||
|
||||
const reversalEntry = new JournalEntry({
|
||||
@@ -299,6 +439,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
}
|
||||
|
||||
public async beforeSave(): Promise<void> {
|
||||
// Normalize any negative amounts to the correct side
|
||||
this.sanitizeLines();
|
||||
// Recalculate totals before saving
|
||||
this.calculateTotals();
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ import type {
|
||||
IJournalEntryLine,
|
||||
IAccountBalance,
|
||||
} from './skr.types.js';
|
||||
import { SKR03_ACCOUNTS } from './skr03.data.js';
|
||||
import { SKR04_ACCOUNTS } from './skr04.data.js';
|
||||
|
||||
// Module-level Maps for O(1) SKR standard lookups
|
||||
const STANDARD_SKR_MAP = {
|
||||
SKR03: new Map(SKR03_ACCOUNTS.map(a => [a.accountNumber, a])),
|
||||
SKR04: new Map(SKR04_ACCOUNTS.map(a => [a.accountNumber, a])),
|
||||
};
|
||||
|
||||
export class Ledger {
|
||||
private logger: plugins.smartlog.Smartlog;
|
||||
@@ -81,6 +89,12 @@ export class Ledger {
|
||||
const accountNumbers = journalData.lines.map((line) => line.accountNumber);
|
||||
await this.validateAccounts(accountNumbers);
|
||||
|
||||
// Validate against SKR standard (warnings only by default)
|
||||
await this.validateAccountsAgainstSKR(journalData.lines, {
|
||||
strict: false, // Start with warnings only
|
||||
warnOnNameMismatch: false // Names vary, don't spam logs
|
||||
});
|
||||
|
||||
// Validate journal entry is balanced
|
||||
this.validateJournalBalance(journalData.lines);
|
||||
|
||||
@@ -139,6 +153,77 @@ export class Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate accounts against SKR standard data
|
||||
*/
|
||||
private async validateAccountsAgainstSKR(
|
||||
lines: IJournalEntryLine[],
|
||||
options?: { strict?: boolean; warnOnNameMismatch?: boolean }
|
||||
): Promise<void> {
|
||||
const { strict = false, warnOnNameMismatch = false } = options || {};
|
||||
const skrMap = STANDARD_SKR_MAP[this.skrType];
|
||||
|
||||
if (!skrMap) {
|
||||
this.logger.log('warn', `No SKR standard map available for ${this.skrType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueAccountNumbers = [...new Set(lines.map(line => line.accountNumber))];
|
||||
|
||||
for (const accountNumber of uniqueAccountNumbers) {
|
||||
const standardAccount = skrMap.get(accountNumber);
|
||||
|
||||
if (!standardAccount) {
|
||||
// Special case: SKR04 class 8 is designated for custom accounts ("frei")
|
||||
if (this.skrType === 'SKR04' && accountNumber.startsWith('8')) {
|
||||
this.logger.log('debug', `Account ${accountNumber} is in SKR04 class 8 (custom accounts allowed)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message = `Account ${accountNumber} is not a standard ${this.skrType} account`;
|
||||
if (strict) {
|
||||
throw new Error(message);
|
||||
} else {
|
||||
this.logger.log('warn', message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get actual account from database to compare
|
||||
const dbAccount = await Account.getAccountByNumber(accountNumber, this.skrType);
|
||||
if (!dbAccount) {
|
||||
// Account doesn't exist in DB, will be caught by validateAccounts()
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate type and class match SKR standard
|
||||
if (dbAccount.accountType !== standardAccount.accountType) {
|
||||
const message = `Account ${accountNumber} type mismatch: expected '${standardAccount.accountType}', got '${dbAccount.accountType}'`;
|
||||
if (strict) {
|
||||
throw new Error(message);
|
||||
} else {
|
||||
this.logger.log('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbAccount.accountClass !== standardAccount.accountClass) {
|
||||
const message = `Account ${accountNumber} class mismatch: expected ${standardAccount.accountClass}, got ${dbAccount.accountClass}`;
|
||||
if (strict) {
|
||||
throw new Error(message);
|
||||
} else {
|
||||
this.logger.log('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
// Warn on name mismatch (common and acceptable in practice)
|
||||
if (warnOnNameMismatch && dbAccount.accountName !== standardAccount.accountName) {
|
||||
this.logger.log('info',
|
||||
`Account ${accountNumber} name differs from SKR standard: '${dbAccount.accountName}' vs '${standardAccount.accountName}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a transaction
|
||||
*/
|
||||
@@ -333,6 +418,7 @@ export class Ledger {
|
||||
accountNumber: account.accountNumber,
|
||||
debit: Math.abs(balance),
|
||||
description: `Closing ${account.accountName}`,
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
totalRevenue += Math.abs(balance);
|
||||
}
|
||||
@@ -344,6 +430,7 @@ export class Ledger {
|
||||
accountNumber: closingAccountNumber,
|
||||
credit: totalRevenue,
|
||||
description: 'Revenue closing to P&L',
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
|
||||
const revenueClosingEntry = await this.postJournalEntry({
|
||||
@@ -373,6 +460,7 @@ export class Ledger {
|
||||
accountNumber: account.accountNumber,
|
||||
credit: Math.abs(balance),
|
||||
description: `Closing ${account.accountName}`,
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
totalExpense += Math.abs(balance);
|
||||
}
|
||||
@@ -384,6 +472,7 @@ export class Ledger {
|
||||
accountNumber: closingAccountNumber,
|
||||
debit: totalExpense,
|
||||
description: 'Expense closing to P&L',
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
|
||||
const expenseClosingEntry = await this.postJournalEntry({
|
||||
|
||||
+86
-23
@@ -122,11 +122,11 @@ export class Reports {
|
||||
const entry: IIncomeStatementEntry = {
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
amount: Math.abs(balance),
|
||||
amount: balance, // Keep the sign for correct calculation
|
||||
};
|
||||
|
||||
revenueEntries.push(entry);
|
||||
totalRevenue += Math.abs(balance);
|
||||
totalRevenue += balance; // Revenue accounts normally have credit balance (positive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,23 +138,24 @@ export class Reports {
|
||||
const entry: IIncomeStatementEntry = {
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
amount: Math.abs(balance),
|
||||
amount: balance, // Keep the sign - negative balance reduces expenses
|
||||
};
|
||||
|
||||
expenseEntries.push(entry);
|
||||
totalExpenses += Math.abs(balance);
|
||||
totalExpenses += balance; // Expense accounts normally have debit balance (positive)
|
||||
// But credit balances (negative) reduce total expenses
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
// Calculate percentages using absolute values to avoid negative percentages
|
||||
revenueEntries.forEach((entry) => {
|
||||
entry.percentage =
|
||||
totalRevenue > 0 ? (entry.amount / totalRevenue) * 100 : 0;
|
||||
totalRevenue !== 0 ? (Math.abs(entry.amount) / Math.abs(totalRevenue)) * 100 : 0;
|
||||
});
|
||||
|
||||
expenseEntries.forEach((entry) => {
|
||||
entry.percentage =
|
||||
totalRevenue > 0 ? (entry.amount / totalRevenue) * 100 : 0;
|
||||
totalRevenue !== 0 ? (Math.abs(entry.amount) / Math.abs(totalRevenue)) * 100 : 0;
|
||||
});
|
||||
|
||||
// Sort entries by account number
|
||||
@@ -214,7 +215,7 @@ export class Reports {
|
||||
const entry: IBalanceSheetEntry = {
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
amount: Math.abs(balance),
|
||||
amount: balance, // Keep the sign for display
|
||||
};
|
||||
|
||||
// Classify as current or fixed based on account class
|
||||
@@ -224,7 +225,7 @@ export class Reports {
|
||||
fixedAssets.push(entry);
|
||||
}
|
||||
|
||||
totalAssets += Math.abs(balance);
|
||||
totalAssets += balance; // Add with sign to get correct total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +241,7 @@ export class Reports {
|
||||
const entry: IBalanceSheetEntry = {
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
amount: Math.abs(balance),
|
||||
amount: balance, // Keep the sign for display
|
||||
};
|
||||
|
||||
// Classify as current or long-term based on account number
|
||||
@@ -253,7 +254,7 @@ export class Reports {
|
||||
longTermLiabilities.push(entry);
|
||||
}
|
||||
|
||||
totalLiabilities += Math.abs(balance);
|
||||
totalLiabilities += balance; // Add with sign to get correct total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,23 +269,27 @@ export class Reports {
|
||||
const entry: IBalanceSheetEntry = {
|
||||
accountNumber: account.accountNumber,
|
||||
accountName: account.accountName,
|
||||
amount: Math.abs(balance),
|
||||
amount: balance, // Keep the sign for display
|
||||
};
|
||||
|
||||
equityEntries.push(entry);
|
||||
totalEquity += Math.abs(balance);
|
||||
totalEquity += balance; // Add with sign to get correct total
|
||||
}
|
||||
}
|
||||
|
||||
// Add current year profit/loss
|
||||
// Add current year profit/loss only if accounts haven't been closed
|
||||
// Check if revenue/expense accounts have non-zero balances (indicates not closed)
|
||||
const incomeStatement = await this.getIncomeStatement(params);
|
||||
if (incomeStatement.netIncome !== 0) {
|
||||
|
||||
// Only add current year profit/loss if we have unclosed revenue/expense accounts
|
||||
// (i.e., the income statement shows non-zero revenue or expenses)
|
||||
if (incomeStatement.netIncome !== 0 && (incomeStatement.totalRevenue !== 0 || incomeStatement.totalExpenses !== 0)) {
|
||||
equityEntries.push({
|
||||
accountNumber: '9999',
|
||||
accountName: 'Current Year Profit/Loss',
|
||||
amount: Math.abs(incomeStatement.netIncome),
|
||||
amount: incomeStatement.netIncome, // Keep the sign
|
||||
});
|
||||
totalEquity += Math.abs(incomeStatement.netIncome);
|
||||
totalEquity += incomeStatement.netIncome; // Add with sign
|
||||
}
|
||||
|
||||
// Sort entries
|
||||
@@ -344,9 +349,28 @@ export class Reports {
|
||||
|
||||
// Apply date filter if provided
|
||||
if (params?.dateFrom || params?.dateTo) {
|
||||
// Normalize dates for inclusive comparison
|
||||
const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null;
|
||||
const dateTo = params.dateTo ? new Date(params.dateTo) : null;
|
||||
|
||||
// Set dateFrom to start of day (00:00:00.000)
|
||||
if (dateFrom) {
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
// Set dateTo to end of day (23:59:59.999) for inclusive comparison
|
||||
if (dateTo) {
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
transactions = transactions.filter((transaction) => {
|
||||
if (params.dateFrom && transaction.date < params.dateFrom) return false;
|
||||
if (params.dateTo && transaction.date > params.dateTo) return false;
|
||||
const txDate = transaction.date instanceof Date
|
||||
? transaction.date
|
||||
: new Date(transaction.date);
|
||||
const txTime = txDate.getTime();
|
||||
|
||||
if (dateFrom && txTime < dateFrom.getTime()) return false;
|
||||
if (dateTo && txTime > dateTo.getTime()) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -386,7 +410,20 @@ export class Reports {
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const ledgerEntries = [];
|
||||
const ledgerEntries: Array<{
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
accountType: string;
|
||||
entries: Array<{
|
||||
date: Date;
|
||||
reference: string;
|
||||
description: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
balance: number;
|
||||
}>;
|
||||
finalBalance: number;
|
||||
}> = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
const transactions = await this.getAccountTransactions(
|
||||
@@ -396,7 +433,14 @@ export class Reports {
|
||||
|
||||
if (transactions.length > 0) {
|
||||
let runningBalance = 0;
|
||||
const accountEntries = [];
|
||||
const accountEntries: Array<{
|
||||
date: Date;
|
||||
reference: string;
|
||||
description: string;
|
||||
debit: number;
|
||||
credit: number;
|
||||
balance: number;
|
||||
}> = [];
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const isDebit = transaction.debitAccount === account.accountNumber;
|
||||
@@ -453,9 +497,28 @@ export class Reports {
|
||||
|
||||
// Apply date filter
|
||||
if (params?.dateFrom || params?.dateTo) {
|
||||
// Normalize dates for inclusive comparison
|
||||
const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null;
|
||||
const dateTo = params.dateTo ? new Date(params.dateTo) : null;
|
||||
|
||||
// Set dateFrom to start of day (00:00:00.000)
|
||||
if (dateFrom) {
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
// Set dateTo to end of day (23:59:59.999) for inclusive comparison
|
||||
if (dateTo) {
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
transactions = transactions.filter((transaction) => {
|
||||
if (params.dateFrom && transaction.date < params.dateFrom) return false;
|
||||
if (params.dateTo && transaction.date > params.dateTo) return false;
|
||||
const txDate = transaction.date instanceof Date
|
||||
? transaction.date
|
||||
: new Date(transaction.date);
|
||||
const txTime = txDate.getTime();
|
||||
|
||||
if (dateFrom && txTime < dateFrom.getTime()) return false;
|
||||
if (dateTo && txTime > dateTo.getTime()) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,75 +7,93 @@ import type {
|
||||
ITransactionData,
|
||||
} from './skr.types.js';
|
||||
|
||||
const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata;
|
||||
declare abstract class SmartDataDbDocBase {
|
||||
public save(): Promise<void>;
|
||||
public delete(): Promise<void>;
|
||||
public static getInstance<T>(
|
||||
this: new (...args: any[]) => T,
|
||||
query: Record<string, any>,
|
||||
): Promise<T | null>;
|
||||
public static getInstances<T>(
|
||||
this: new (...args: any[]) => T,
|
||||
query: Record<string, any>,
|
||||
): Promise<T[]>;
|
||||
}
|
||||
|
||||
@plugins.smartdata.Collection(() => getDbSync())
|
||||
export class Transaction extends SmartDataDbDoc<Transaction, Transaction> {
|
||||
const SmartDataDbDoc = plugins.smartdata.SmartDataDbDoc as unknown as typeof SmartDataDbDocBase;
|
||||
const Collection = plugins.smartdata.Collection as any;
|
||||
const svDb = plugins.smartdata.svDb as any;
|
||||
const unI = plugins.smartdata.unI as any;
|
||||
const index = plugins.smartdata.index as any;
|
||||
const searchable = plugins.smartdata.searchable as any;
|
||||
|
||||
@Collection(() => getDbSync())
|
||||
export class Transaction extends SmartDataDbDoc {
|
||||
@unI()
|
||||
public id: string;
|
||||
public id!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public transactionNumber: string;
|
||||
public transactionNumber!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public date: Date;
|
||||
public date!: Date;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public debitAccount: string;
|
||||
public debitAccount!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public creditAccount: string;
|
||||
public creditAccount!: string;
|
||||
|
||||
@svDb()
|
||||
public amount: number;
|
||||
public amount!: number;
|
||||
|
||||
@svDb()
|
||||
@searchable()
|
||||
public description: string;
|
||||
public description!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public reference: string;
|
||||
public reference!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public skrType: TSKRType;
|
||||
public skrType!: TSKRType;
|
||||
|
||||
@svDb()
|
||||
public vatAmount: number;
|
||||
public vatAmount!: number;
|
||||
|
||||
@svDb()
|
||||
public costCenter: string;
|
||||
public costCenter!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public status: TTransactionStatus;
|
||||
public status!: TTransactionStatus;
|
||||
|
||||
@svDb()
|
||||
public reversalOf: string;
|
||||
public reversalOf!: string;
|
||||
|
||||
@svDb()
|
||||
public reversedBy: string;
|
||||
public reversedBy!: string;
|
||||
|
||||
@svDb()
|
||||
@index()
|
||||
public period: string; // Format: YYYY-MM
|
||||
public period!: string; // Format: YYYY-MM
|
||||
|
||||
@svDb()
|
||||
public fiscalYear: number;
|
||||
public fiscalYear!: number;
|
||||
|
||||
@svDb()
|
||||
public createdAt: Date;
|
||||
public createdAt!: Date;
|
||||
|
||||
@svDb()
|
||||
public postedAt: Date;
|
||||
public postedAt!: Date | null;
|
||||
|
||||
@svDb()
|
||||
public createdBy: string;
|
||||
public createdBy!: string;
|
||||
|
||||
constructor(data?: Partial<ITransactionData>) {
|
||||
super();
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import type { IAccountData, TSKRType } from './skr.types.js';
|
||||
|
||||
// Extended interface for export with additional fields
|
||||
export interface IAccountDataExport extends IAccountData {
|
||||
parentAccount?: string;
|
||||
defaultTaxCode?: string;
|
||||
activeFrom?: Date | string;
|
||||
activeTo?: Date | string;
|
||||
}
|
||||
|
||||
export interface IAccountExportRow {
|
||||
account_code: string;
|
||||
name: string;
|
||||
type: string;
|
||||
class: number;
|
||||
parent?: string;
|
||||
skr_set: TSKRType;
|
||||
tax_code_default?: string;
|
||||
active_from?: string;
|
||||
active_to?: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export class AccountsExporter {
|
||||
private exportPath: string;
|
||||
private accounts: IAccountExportRow[] = [];
|
||||
|
||||
constructor(exportPath: string) {
|
||||
this.exportPath = exportPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an account to the export
|
||||
*/
|
||||
public addAccount(account: IAccountDataExport): void {
|
||||
const exportRow: IAccountExportRow = {
|
||||
account_code: account.accountNumber,
|
||||
name: account.accountName,
|
||||
type: account.accountType,
|
||||
class: account.accountClass,
|
||||
parent: account.parentAccount,
|
||||
skr_set: account.skrType,
|
||||
tax_code_default: account.defaultTaxCode,
|
||||
active_from: account.activeFrom ? this.formatDate(account.activeFrom) : undefined,
|
||||
active_to: account.activeTo ? this.formatDate(account.activeTo) : undefined,
|
||||
description: account.description,
|
||||
is_active: account.isActive !== false
|
||||
};
|
||||
|
||||
this.accounts.push(exportRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports accounts to CSV format
|
||||
*/
|
||||
public async exportToCSV(): Promise<void> {
|
||||
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'accounts.csv');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||
|
||||
// Create CSV header
|
||||
const headers = [
|
||||
'account_code',
|
||||
'name',
|
||||
'type',
|
||||
'class',
|
||||
'parent',
|
||||
'skr_set',
|
||||
'tax_code_default',
|
||||
'active_from',
|
||||
'active_to',
|
||||
'description',
|
||||
'is_active'
|
||||
];
|
||||
|
||||
let csvContent = headers.join(',') + '\n';
|
||||
|
||||
// Add account rows
|
||||
for (const account of this.accounts) {
|
||||
const row = [
|
||||
this.escapeCSV(account.account_code),
|
||||
this.escapeCSV(account.name),
|
||||
this.escapeCSV(account.type),
|
||||
account.class.toString(),
|
||||
this.escapeCSV(account.parent || ''),
|
||||
this.escapeCSV(account.skr_set),
|
||||
this.escapeCSV(account.tax_code_default || ''),
|
||||
this.escapeCSV(account.active_from || ''),
|
||||
this.escapeCSV(account.active_to || ''),
|
||||
this.escapeCSV(account.description || ''),
|
||||
account.is_active.toString()
|
||||
];
|
||||
|
||||
csvContent += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports accounts to JSON format (alternative)
|
||||
*/
|
||||
public async exportToJSON(): Promise<void> {
|
||||
const jsonPath = path.join(this.exportPath, 'data', 'accounting', 'accounts.json');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(jsonPath));
|
||||
|
||||
const jsonData = {
|
||||
schema_version: '1.0',
|
||||
export_date: new Date().toISOString(),
|
||||
accounts: this.accounts
|
||||
};
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(jsonData, null, 2),
|
||||
jsonPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes CSV values
|
||||
*/
|
||||
private escapeCSV(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date to ISO date string
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
if (typeof date === 'string') {
|
||||
return date.split('T')[0];
|
||||
}
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of accounts
|
||||
*/
|
||||
public getAccountCount(): number {
|
||||
return this.accounts.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the accounts list
|
||||
*/
|
||||
public clear(): void {
|
||||
this.accounts = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import type { IAccountBalance } from './skr.types.js';
|
||||
|
||||
// Extended interface for export with additional fields
|
||||
export interface IAccountBalanceExport extends IAccountBalance {
|
||||
openingBalance?: number;
|
||||
transactionCount?: number;
|
||||
}
|
||||
|
||||
export interface IBalanceExportRow {
|
||||
account_code: string;
|
||||
account_name: string;
|
||||
fiscal_year: number;
|
||||
period?: string;
|
||||
opening_balance: string;
|
||||
closing_balance: string;
|
||||
debit_sum: string;
|
||||
credit_sum: string;
|
||||
balance: string;
|
||||
transaction_count: number;
|
||||
}
|
||||
|
||||
export class BalancesExporter {
|
||||
private exportPath: string;
|
||||
private balances: IBalanceExportRow[] = [];
|
||||
private fiscalYear: number;
|
||||
|
||||
constructor(exportPath: string, fiscalYear: number) {
|
||||
this.exportPath = exportPath;
|
||||
this.fiscalYear = fiscalYear;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a balance entry to the export
|
||||
*/
|
||||
public addBalance(
|
||||
accountCode: string,
|
||||
accountName: string,
|
||||
balance: IAccountBalanceExport,
|
||||
period?: string
|
||||
): void {
|
||||
const exportRow: IBalanceExportRow = {
|
||||
account_code: accountCode,
|
||||
account_name: accountName,
|
||||
fiscal_year: this.fiscalYear,
|
||||
period: period,
|
||||
opening_balance: (balance.openingBalance || 0).toFixed(2),
|
||||
closing_balance: balance.balance.toFixed(2),
|
||||
debit_sum: balance.debitTotal.toFixed(2),
|
||||
credit_sum: balance.creditTotal.toFixed(2),
|
||||
balance: balance.balance.toFixed(2),
|
||||
transaction_count: balance.transactionCount || 0
|
||||
};
|
||||
|
||||
this.balances.push(exportRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports balances to CSV format
|
||||
*/
|
||||
public async exportToCSV(): Promise<void> {
|
||||
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'balances.csv');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||
|
||||
// Create CSV header
|
||||
const headers = [
|
||||
'account_code',
|
||||
'account_name',
|
||||
'fiscal_year',
|
||||
'period',
|
||||
'opening_balance',
|
||||
'closing_balance',
|
||||
'debit_sum',
|
||||
'credit_sum',
|
||||
'balance',
|
||||
'transaction_count'
|
||||
];
|
||||
|
||||
let csvContent = headers.join(',') + '\n';
|
||||
|
||||
// Sort balances by account code
|
||||
this.balances.sort((a, b) => a.account_code.localeCompare(b.account_code));
|
||||
|
||||
// Add balance rows
|
||||
for (const balance of this.balances) {
|
||||
const row = [
|
||||
this.escapeCSV(balance.account_code),
|
||||
this.escapeCSV(balance.account_name),
|
||||
balance.fiscal_year.toString(),
|
||||
this.escapeCSV(balance.period || ''),
|
||||
balance.opening_balance,
|
||||
balance.closing_balance,
|
||||
balance.debit_sum,
|
||||
balance.credit_sum,
|
||||
balance.balance,
|
||||
balance.transaction_count.toString()
|
||||
];
|
||||
|
||||
csvContent += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports trial balance (Summen- und Saldenliste)
|
||||
*/
|
||||
public async exportTrialBalance(): Promise<void> {
|
||||
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'trial_balance.csv');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||
|
||||
// Create CSV header for trial balance
|
||||
const headers = [
|
||||
'Konto',
|
||||
'Bezeichnung',
|
||||
'Anfangssaldo',
|
||||
'Soll',
|
||||
'Haben',
|
||||
'Saldo',
|
||||
'Endsaldo'
|
||||
];
|
||||
|
||||
let csvContent = headers.join(',') + '\n';
|
||||
|
||||
// Add rows with German formatting
|
||||
for (const balance of this.balances) {
|
||||
const row = [
|
||||
this.escapeCSV(balance.account_code),
|
||||
this.escapeCSV(balance.account_name),
|
||||
this.formatGermanNumber(parseFloat(balance.opening_balance)),
|
||||
this.formatGermanNumber(parseFloat(balance.debit_sum)),
|
||||
this.formatGermanNumber(parseFloat(balance.credit_sum)),
|
||||
this.formatGermanNumber(parseFloat(balance.debit_sum) - parseFloat(balance.credit_sum)),
|
||||
this.formatGermanNumber(parseFloat(balance.closing_balance))
|
||||
];
|
||||
|
||||
csvContent += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
// Add totals row
|
||||
const totalDebit = this.balances.reduce((sum, b) => sum + parseFloat(b.debit_sum), 0);
|
||||
const totalCredit = this.balances.reduce((sum, b) => sum + parseFloat(b.credit_sum), 0);
|
||||
|
||||
csvContent += '\n';
|
||||
csvContent += [
|
||||
'SUMME',
|
||||
'',
|
||||
'',
|
||||
this.formatGermanNumber(totalDebit),
|
||||
this.formatGermanNumber(totalCredit),
|
||||
this.formatGermanNumber(totalDebit - totalCredit),
|
||||
''
|
||||
].join(',') + '\n';
|
||||
|
||||
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports balances to JSON format
|
||||
*/
|
||||
public async exportToJSON(): Promise<void> {
|
||||
const jsonPath = path.join(this.exportPath, 'data', 'accounting', 'balances.json');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(jsonPath));
|
||||
|
||||
const jsonData = {
|
||||
schema_version: '1.0',
|
||||
export_date: new Date().toISOString(),
|
||||
fiscal_year: this.fiscalYear,
|
||||
balances: this.balances,
|
||||
totals: {
|
||||
total_debit: this.balances.reduce((sum, b) => sum + parseFloat(b.debit_sum), 0).toFixed(2),
|
||||
total_credit: this.balances.reduce((sum, b) => sum + parseFloat(b.credit_sum), 0).toFixed(2),
|
||||
account_count: this.balances.length
|
||||
}
|
||||
};
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(jsonData, null, 2),
|
||||
jsonPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates balance summary for specific account classes
|
||||
*/
|
||||
public async exportClassSummary(): Promise<void> {
|
||||
const csvPath = path.join(this.exportPath, 'data', 'accounting', 'class_summary.csv');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(csvPath));
|
||||
|
||||
// Group balances by account class (first digit of account code)
|
||||
const classSummary: { [key: string]: { debit: number; credit: number; balance: number } } = {};
|
||||
|
||||
for (const balance of this.balances) {
|
||||
const accountClass = balance.account_code.charAt(0);
|
||||
|
||||
if (!classSummary[accountClass]) {
|
||||
classSummary[accountClass] = { debit: 0, credit: 0, balance: 0 };
|
||||
}
|
||||
|
||||
classSummary[accountClass].debit += parseFloat(balance.debit_sum);
|
||||
classSummary[accountClass].credit += parseFloat(balance.credit_sum);
|
||||
classSummary[accountClass].balance += parseFloat(balance.balance);
|
||||
}
|
||||
|
||||
// Create CSV
|
||||
let csvContent = 'Kontenklasse,Bezeichnung,Soll,Haben,Saldo\n';
|
||||
|
||||
const classNames: { [key: string]: string } = {
|
||||
'0': 'Anlagevermögen',
|
||||
'1': 'Umlaufvermögen',
|
||||
'2': 'Eigenkapital',
|
||||
'3': 'Fremdkapital',
|
||||
'4': 'Betriebliche Erträge',
|
||||
'5': 'Materialaufwand',
|
||||
'6': 'Betriebsaufwand',
|
||||
'7': 'Weitere Aufwendungen',
|
||||
'8': 'Erträge',
|
||||
'9': 'Abschlusskonten'
|
||||
};
|
||||
|
||||
for (const [classNum, summary] of Object.entries(classSummary)) {
|
||||
const row = [
|
||||
classNum,
|
||||
this.escapeCSV(classNames[classNum] || `Klasse ${classNum}`),
|
||||
this.formatGermanNumber(summary.debit),
|
||||
this.formatGermanNumber(summary.credit),
|
||||
this.formatGermanNumber(summary.balance)
|
||||
];
|
||||
|
||||
csvContent += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
await plugins.smartfile.memory.toFs(csvContent, csvPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes CSV values
|
||||
*/
|
||||
private escapeCSV(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats number in German format (1.234,56)
|
||||
*/
|
||||
private formatGermanNumber(value: number): string {
|
||||
return value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of balance entries
|
||||
*/
|
||||
public getBalanceCount(): number {
|
||||
return this.balances.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the balances list
|
||||
*/
|
||||
public clear(): void {
|
||||
this.balances = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import type { ITransactionData, IJournalEntry, IJournalEntryLine } from './skr.types.js';
|
||||
import { createWriteStream, type WriteStream } from 'fs';
|
||||
|
||||
// Extended interfaces for export with additional tracking fields
|
||||
export interface ITransactionDataExport extends ITransactionData {
|
||||
_id?: string;
|
||||
postingDate?: Date;
|
||||
currency?: string;
|
||||
createdAt?: Date | string;
|
||||
modifiedAt?: Date | string;
|
||||
reversalOf?: string;
|
||||
reversedBy?: string;
|
||||
taxCode?: string;
|
||||
project?: string;
|
||||
vatAccount?: string;
|
||||
}
|
||||
|
||||
export interface IJournalEntryExport extends IJournalEntry {
|
||||
_id?: string;
|
||||
postingDate?: Date;
|
||||
currency?: string;
|
||||
journal?: string;
|
||||
createdAt?: Date | string;
|
||||
modifiedAt?: Date | string;
|
||||
reversalOf?: string;
|
||||
reversedBy?: string;
|
||||
}
|
||||
|
||||
export interface IJournalEntryLineExport extends IJournalEntryLine {
|
||||
taxCode?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
export interface ILedgerEntry {
|
||||
schema_version: string;
|
||||
entry_id: string;
|
||||
booking_date: string;
|
||||
posting_date: string;
|
||||
period?: string;
|
||||
currency: string;
|
||||
journal: string;
|
||||
description: string;
|
||||
reference?: string;
|
||||
lines: ILedgerLine[];
|
||||
document_refs?: IDocumentRef[];
|
||||
created_at: string;
|
||||
modified_at?: string;
|
||||
user?: string;
|
||||
reversal_of?: string;
|
||||
reversed_by?: string;
|
||||
}
|
||||
|
||||
export interface ILedgerLine {
|
||||
posting_id: string;
|
||||
account_code: string;
|
||||
debit: string;
|
||||
credit: string;
|
||||
tax_code?: string;
|
||||
cost_center?: string;
|
||||
project?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IDocumentRef {
|
||||
content_hash: string;
|
||||
doc_role: 'invoice' | 'receipt' | 'contract' | 'bank-statement' | 'other';
|
||||
doc_mime: string;
|
||||
doc_original_name?: string;
|
||||
}
|
||||
|
||||
export class LedgerExporter {
|
||||
private exportPath: string;
|
||||
private stream: WriteStream | null = null;
|
||||
private entryCount: number = 0;
|
||||
|
||||
constructor(exportPath: string) {
|
||||
this.exportPath = exportPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the NDJSON export stream
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
const ledgerPath = path.join(this.exportPath, 'data', 'accounting', 'ledger.ndjson');
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(ledgerPath));
|
||||
|
||||
this.stream = createWriteStream(ledgerPath, {
|
||||
encoding: 'utf8',
|
||||
flags: 'w'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a transaction as a ledger entry
|
||||
*/
|
||||
public async exportTransaction(transaction: ITransactionDataExport): Promise<void> {
|
||||
if (!this.stream) {
|
||||
throw new Error('Ledger exporter not initialized');
|
||||
}
|
||||
|
||||
const entry: ILedgerEntry = {
|
||||
schema_version: '1.0',
|
||||
entry_id: transaction._id || plugins.smartunique.shortId(),
|
||||
booking_date: this.formatDate(transaction.date),
|
||||
posting_date: this.formatDate(transaction.postingDate || transaction.date),
|
||||
currency: transaction.currency || 'EUR',
|
||||
journal: 'GL',
|
||||
description: transaction.description,
|
||||
reference: transaction.reference,
|
||||
lines: [],
|
||||
created_at: transaction.createdAt ? new Date(transaction.createdAt).toISOString() : new Date().toISOString(),
|
||||
modified_at: transaction.modifiedAt ? new Date(transaction.modifiedAt).toISOString() : undefined,
|
||||
reversal_of: transaction.reversalOf,
|
||||
reversed_by: transaction.reversedBy
|
||||
};
|
||||
|
||||
// Add debit line
|
||||
if (transaction.amount > 0) {
|
||||
entry.lines.push({
|
||||
posting_id: `${entry.entry_id}-1`,
|
||||
account_code: transaction.debitAccount,
|
||||
debit: transaction.amount.toFixed(2),
|
||||
credit: '0.00',
|
||||
tax_code: transaction.taxCode,
|
||||
cost_center: transaction.costCenter,
|
||||
project: transaction.project
|
||||
});
|
||||
|
||||
// Add credit line
|
||||
entry.lines.push({
|
||||
posting_id: `${entry.entry_id}-2`,
|
||||
account_code: transaction.creditAccount,
|
||||
debit: '0.00',
|
||||
credit: transaction.amount.toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
// Add VAT lines if applicable
|
||||
if (transaction.vatAmount && transaction.vatAmount > 0) {
|
||||
entry.lines.push({
|
||||
posting_id: `${entry.entry_id}-3`,
|
||||
account_code: transaction.vatAccount || '1576', // Default VAT account
|
||||
debit: transaction.vatAmount.toFixed(2),
|
||||
credit: '0.00',
|
||||
description: 'Vorsteuer'
|
||||
});
|
||||
}
|
||||
|
||||
await this.writeLine(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a journal entry
|
||||
*/
|
||||
public async exportJournalEntry(journalEntry: IJournalEntryExport): Promise<void> {
|
||||
if (!this.stream) {
|
||||
throw new Error('Ledger exporter not initialized');
|
||||
}
|
||||
|
||||
const entry: ILedgerEntry = {
|
||||
schema_version: '1.0',
|
||||
entry_id: journalEntry._id || plugins.smartunique.shortId(),
|
||||
booking_date: this.formatDate(journalEntry.date),
|
||||
posting_date: this.formatDate(journalEntry.postingDate || journalEntry.date),
|
||||
currency: journalEntry.currency || 'EUR',
|
||||
journal: journalEntry.journal || 'GL',
|
||||
description: journalEntry.description,
|
||||
reference: journalEntry.reference,
|
||||
lines: [],
|
||||
created_at: journalEntry.createdAt ? new Date(journalEntry.createdAt).toISOString() : new Date().toISOString(),
|
||||
modified_at: journalEntry.modifiedAt ? new Date(journalEntry.modifiedAt).toISOString() : undefined,
|
||||
reversal_of: journalEntry.reversalOf,
|
||||
reversed_by: journalEntry.reversedBy
|
||||
};
|
||||
|
||||
// Convert journal entry lines
|
||||
journalEntry.lines.forEach((line, index) => {
|
||||
const extLine = line as IJournalEntryLineExport;
|
||||
entry.lines.push({
|
||||
posting_id: `${entry.entry_id}-${index + 1}`,
|
||||
account_code: line.accountNumber,
|
||||
debit: (line.debit || 0).toFixed(2),
|
||||
credit: (line.credit || 0).toFixed(2),
|
||||
tax_code: extLine.taxCode,
|
||||
cost_center: line.costCenter,
|
||||
project: extLine.project,
|
||||
description: line.description
|
||||
});
|
||||
});
|
||||
|
||||
await this.writeLine(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a single NDJSON line
|
||||
*/
|
||||
private async writeLine(entry: ILedgerEntry): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.stream) {
|
||||
reject(new Error('Stream not initialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
this.stream.write(line, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
this.entryCount++;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date to ISO date string
|
||||
*/
|
||||
private formatDate(date: Date | string): string {
|
||||
if (typeof date === 'string') {
|
||||
return date.split('T')[0];
|
||||
}
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the export stream
|
||||
*/
|
||||
public async close(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
if (this.stream) {
|
||||
this.stream.end(() => {
|
||||
resolve(this.entryCount);
|
||||
});
|
||||
} else {
|
||||
resolve(this.entryCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of exported entries
|
||||
*/
|
||||
public getEntryCount(): number {
|
||||
return this.entryCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import { SmartPdf } from '@push.rocks/smartpdf';
|
||||
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: SmartPdf | null = null;
|
||||
|
||||
constructor(exportPath: string, options: IPdfReportOptions) {
|
||||
this.exportPath = exportPath;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the PDF generator
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
this.pdfInstance = new SmartPdf();
|
||||
await this.pdfInstance.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the trial balance PDF report
|
||||
*/
|
||||
public async generateTrialBalancePdf(report: ITrialBalanceReport): Promise<Buffer> {
|
||||
if (!this.pdfInstance) {
|
||||
throw new Error('PDF generator not initialized');
|
||||
}
|
||||
|
||||
const html = this.generateTrialBalanceHtml(report);
|
||||
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||
return Buffer.from(pdfResult.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the income statement PDF report
|
||||
*/
|
||||
public async generateIncomeStatementPdf(report: IIncomeStatement): Promise<Buffer> {
|
||||
if (!this.pdfInstance) {
|
||||
throw new Error('PDF generator not initialized');
|
||||
}
|
||||
|
||||
const html = this.generateIncomeStatementHtml(report);
|
||||
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||
return Buffer.from(pdfResult.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the balance sheet PDF report
|
||||
*/
|
||||
public async generateBalanceSheetPdf(report: IBalanceSheet): Promise<Buffer> {
|
||||
if (!this.pdfInstance) {
|
||||
throw new Error('PDF generator not initialized');
|
||||
}
|
||||
|
||||
const html = this.generateBalanceSheetHtml(report);
|
||||
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||
return Buffer.from(pdfResult.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the comprehensive Jahresabschluss PDF
|
||||
*/
|
||||
public async generateJahresabschlussPdf(
|
||||
trialBalance: ITrialBalanceReport,
|
||||
incomeStatement: IIncomeStatement,
|
||||
balanceSheet: IBalanceSheet
|
||||
): Promise<Buffer> {
|
||||
if (!this.pdfInstance) {
|
||||
throw new Error('PDF generator not initialized');
|
||||
}
|
||||
|
||||
const html = this.generateJahresabschlussHtml(trialBalance, incomeStatement, balanceSheet);
|
||||
const pdfResult = await this.pdfInstance.getA4PdfResultForHtmlString(html);
|
||||
return Buffer.from(pdfResult.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates HTML for trial balance report
|
||||
*/
|
||||
private generateTrialBalanceHtml(report: ITrialBalanceReport): string {
|
||||
const entries = report.entries || [];
|
||||
|
||||
const tableRows = entries.map(entry => `
|
||||
<tr>
|
||||
<td>${entry.accountNumber}</td>
|
||||
<td>${entry.accountName}</td>
|
||||
<td class="number">${this.formatGermanNumber(0)}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.debitBalance)}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.creditBalance)}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.netBalance)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
${this.getBaseStyles()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${this.generateHeader('Summen- und Saldenliste')}
|
||||
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Anfangssaldo</th>
|
||||
<th>Soll</th>
|
||||
<th>Haben</th>
|
||||
<th>Saldo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tableRows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="3">Summe</td>
|
||||
<td class="number">${this.formatGermanNumber(report.totalDebits)}</td>
|
||||
<td class="number">${this.formatGermanNumber(report.totalCredits)}</td>
|
||||
<td class="number">${this.formatGermanNumber(report.totalDebits - report.totalCredits)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
${this.generateFooter()}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates HTML for income statement report
|
||||
*/
|
||||
private generateIncomeStatementHtml(report: IIncomeStatement): string {
|
||||
const revenueRows = (report.revenue || []).map(entry => `
|
||||
<tr>
|
||||
<td>${entry.accountNumber}</td>
|
||||
<td>${entry.accountName}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const expenseRows = (report.expenses || []).map(entry => `
|
||||
<tr>
|
||||
<td>${entry.accountNumber}</td>
|
||||
<td>${entry.accountName}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
${this.getBaseStyles()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${this.generateHeader('Gewinn- und Verlustrechnung')}
|
||||
|
||||
<h2>Erträge</h2>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${revenueRows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="subtotal-row">
|
||||
<td colspan="2">Summe Erträge</td>
|
||||
<td class="number">${this.formatGermanNumber(report.totalRevenue)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h2>Aufwendungen</h2>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expenseRows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="subtotal-row">
|
||||
<td colspan="2">Summe Aufwendungen</td>
|
||||
<td class="number">${this.formatGermanNumber(report.totalExpenses)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="result-section">
|
||||
<h2>Ergebnis</h2>
|
||||
<table class="summary-table">
|
||||
<tr>
|
||||
<td>Erträge</td>
|
||||
<td class="number">${this.formatGermanNumber(report.totalRevenue)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aufwendungen</td>
|
||||
<td class="number">- ${this.formatGermanNumber(report.totalExpenses)}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>${report.netIncome >= 0 ? 'Jahresüberschuss' : 'Jahresfehlbetrag'}</td>
|
||||
<td class="number ${report.netIncome >= 0 ? 'positive' : 'negative'}">
|
||||
${this.formatGermanNumber(report.netIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
${this.generateFooter()}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates HTML for balance sheet report
|
||||
*/
|
||||
private generateBalanceSheetHtml(report: IBalanceSheet): string {
|
||||
const assetRows = [...(report.assets.current || []), ...(report.assets.fixed || [])].map(entry => `
|
||||
<tr>
|
||||
<td>${entry.accountNumber}</td>
|
||||
<td>${entry.accountName}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const liabilityRows = [...(report.liabilities.current || []), ...(report.liabilities.longTerm || [])].map(entry => `
|
||||
<tr>
|
||||
<td>${entry.accountNumber}</td>
|
||||
<td>${entry.accountName}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const equityRows = (report.equity.entries || []).map(entry => `
|
||||
<tr>
|
||||
<td>${entry.accountNumber}</td>
|
||||
<td>${entry.accountName}</td>
|
||||
<td class="number">${this.formatGermanNumber(entry.amount)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
${this.getBaseStyles()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${this.generateHeader('Bilanz')}
|
||||
|
||||
<div class="balance-sheet">
|
||||
<div class="aktiva">
|
||||
<h2>Aktiva</h2>
|
||||
<table class="report-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Konto</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Betrag</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${assetRows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="2">Summe Aktiva</td>
|
||||
<td class="number">${this.formatGermanNumber(report.assets.totalAssets)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="passiva">
|
||||
<h2>Passiva</h2>
|
||||
|
||||
<h3>Eigenkapital</h3>
|
||||
<table class="report-table">
|
||||
<tbody>
|
||||
${equityRows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="subtotal-row">
|
||||
<td colspan="2">Summe Eigenkapital</td>
|
||||
<td class="number">${this.formatGermanNumber(report.equity.totalEquity)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<h3>Fremdkapital</h3>
|
||||
<table class="report-table">
|
||||
<tbody>
|
||||
${liabilityRows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="subtotal-row">
|
||||
<td colspan="2">Summe Fremdkapital</td>
|
||||
<td class="number">${this.formatGermanNumber(report.liabilities.totalLiabilities)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<table class="summary-table">
|
||||
<tr class="total-row">
|
||||
<td>Summe Passiva</td>
|
||||
<td class="number">${this.formatGermanNumber(report.liabilities.totalLiabilities + report.equity.totalEquity)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.generateFooter()}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates comprehensive Jahresabschluss HTML
|
||||
*/
|
||||
private generateJahresabschlussHtml(
|
||||
trialBalance: ITrialBalanceReport,
|
||||
incomeStatement: IIncomeStatement,
|
||||
balanceSheet: IBalanceSheet
|
||||
): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
${this.getBaseStyles()}
|
||||
.page-break { page-break-after: always; }
|
||||
.cover-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
text-align: center;
|
||||
}
|
||||
.cover-page h1 { font-size: 36px; margin-bottom: 20px; }
|
||||
.cover-page h2 { font-size: 24px; margin-bottom: 40px; }
|
||||
.toc { margin-top: 50px; }
|
||||
.toc h2 { margin-bottom: 20px; }
|
||||
.toc ul { list-style: none; padding: 0; }
|
||||
.toc li { margin: 10px 0; font-size: 16px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="cover-page">
|
||||
<h1>Jahresabschluss</h1>
|
||||
<h2>${this.options.companyName}</h2>
|
||||
<p>Geschäftsjahr ${this.options.fiscalYear}</p>
|
||||
<p>${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}</p>
|
||||
|
||||
<div class="toc">
|
||||
<h2>Inhalt</h2>
|
||||
<ul>
|
||||
<li>1. Bilanz</li>
|
||||
<li>2. Gewinn- und Verlustrechnung</li>
|
||||
<li>3. Summen- und Saldenliste</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-break"></div>
|
||||
${this.generateBalanceSheetHtml(balanceSheet)}
|
||||
|
||||
<div class="page-break"></div>
|
||||
${this.generateIncomeStatementHtml(incomeStatement)}
|
||||
|
||||
<div class="page-break"></div>
|
||||
${this.generateTrialBalanceHtml(trialBalance)}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the report header
|
||||
*/
|
||||
private generateHeader(reportTitle: string): string {
|
||||
return `
|
||||
<div class="header">
|
||||
<h1>${this.options.companyName}</h1>
|
||||
${this.options.companyAddress ? `<p>${this.options.companyAddress}</p>` : ''}
|
||||
${this.options.taxId ? `<p>Steuernummer: ${this.options.taxId}</p>` : ''}
|
||||
${this.options.registrationNumber ? `<p>Handelsregister: ${this.options.registrationNumber}</p>` : ''}
|
||||
<hr>
|
||||
<h2>${reportTitle}</h2>
|
||||
<p>Periode: ${this.formatGermanDate(this.options.dateFrom)} bis ${this.formatGermanDate(this.options.dateTo)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the report footer
|
||||
*/
|
||||
private generateFooter(): string {
|
||||
const preparedDate = this.options.preparedDate || new Date();
|
||||
return `
|
||||
<div class="footer">
|
||||
<hr>
|
||||
<p>Erstellt am: ${this.formatGermanDate(preparedDate)}</p>
|
||||
${this.options.preparedBy ? `<p>Erstellt von: ${this.options.preparedBy}</p>` : ''}
|
||||
<p class="disclaimer">
|
||||
Dieser Bericht wurde automatisch generiert und ist Teil des revisionssicheren
|
||||
Jahresabschluss-Exports gemäß GoBD.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the base CSS styles for all reports
|
||||
*/
|
||||
private getBaseStyles(): string {
|
||||
return `
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
margin: 40px;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { color: #2c3e50; margin-bottom: 10px; }
|
||||
h2 { color: #34495e; margin-top: 30px; margin-bottom: 15px; }
|
||||
h3 { color: #7f8c8d; margin-top: 20px; margin-bottom: 10px; }
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin-top: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #34495e;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.number {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
font-weight: bold;
|
||||
background-color: #ecf0f1;
|
||||
}
|
||||
|
||||
.subtotal-row {
|
||||
font-weight: 600;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.summary-table {
|
||||
max-width: 500px;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.balance-sheet {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.aktiva, .passiva {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { margin: 20px; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats number in German format (1.234,56)
|
||||
*/
|
||||
private formatGermanNumber(value: number): string {
|
||||
return value.toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats date in German format (DD.MM.YYYY)
|
||||
*/
|
||||
private formatGermanDate(date: Date): string {
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a PDF report to the export directory
|
||||
*/
|
||||
public async savePdfReport(filename: string, pdfBuffer: Buffer): Promise<string> {
|
||||
const reportsDir = path.join(this.exportPath, 'data', 'reports');
|
||||
await plugins.smartfile.fs.ensureDir(reportsDir);
|
||||
|
||||
const filePath = path.join(reportsDir, filename);
|
||||
await plugins.smartfile.memory.toFs(pdfBuffer, filePath);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the PDF generator
|
||||
*/
|
||||
public async close(): Promise<void> {
|
||||
if (this.pdfInstance) {
|
||||
await this.pdfInstance.stop();
|
||||
this.pdfInstance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import type { IAccountData, ITransactionData, IJournalEntry, TSKRType } from './skr.types.js';
|
||||
|
||||
export interface IExportOptions {
|
||||
exportPath: string;
|
||||
fiscalYear: number;
|
||||
dateFrom: Date;
|
||||
dateTo: Date;
|
||||
includeDocuments?: boolean;
|
||||
generatePdfReports?: boolean;
|
||||
signExport?: boolean;
|
||||
timestampExport?: boolean;
|
||||
companyInfo?: {
|
||||
name: string;
|
||||
taxId: string;
|
||||
registrationNumber?: string;
|
||||
address?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IExportMetadata {
|
||||
exportVersion: string;
|
||||
exportTimestamp: string;
|
||||
generator: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
company?: {
|
||||
name: string;
|
||||
taxId: string;
|
||||
registrationNumber?: string;
|
||||
address?: string;
|
||||
};
|
||||
fiscalYear: number;
|
||||
dateRange: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
skrType: TSKRType;
|
||||
schemaVersion: string;
|
||||
crypto: {
|
||||
digestAlgorithms: string[];
|
||||
signatureType?: string;
|
||||
timestampPolicy?: string;
|
||||
merkleTree: boolean;
|
||||
};
|
||||
options: {
|
||||
packagedAs: 'bagit';
|
||||
compression: 'none' | 'deflate';
|
||||
deduplication: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IBagItManifest {
|
||||
[filePath: string]: string; // filePath -> SHA256 hash
|
||||
}
|
||||
|
||||
export interface IDocumentIndex {
|
||||
contentHash: string;
|
||||
sizeBytes: number;
|
||||
mimeType: string;
|
||||
createdAt: string;
|
||||
originalFilename?: string;
|
||||
pdfaAvailable: boolean;
|
||||
zugferdXml?: string;
|
||||
retentionClass: string;
|
||||
}
|
||||
|
||||
export class SkrExport {
|
||||
private logger: plugins.smartlog.ConsoleLog;
|
||||
private options: IExportOptions;
|
||||
private exportDir: string;
|
||||
private manifest: IBagItManifest = {};
|
||||
private tagManifest: IBagItManifest = {};
|
||||
|
||||
constructor(options: IExportOptions) {
|
||||
this.options = options;
|
||||
this.logger = new plugins.smartlog.ConsoleLog();
|
||||
this.exportDir = path.join(options.exportPath, `jahresabschluss_${options.fiscalYear}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the BagIt directory structure for the export
|
||||
*/
|
||||
public async createBagItStructure(): Promise<void> {
|
||||
this.logger.log('info', 'Creating BagIt directory structure...');
|
||||
|
||||
// Create main directories
|
||||
await plugins.smartfile.fs.ensureDir(this.exportDir);
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'schemas'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'schemas', 'v1'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'signatures'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'accounting'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'accounting', 'ebilanz'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'documents'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'documents', 'by-hash'));
|
||||
await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'reports'));
|
||||
|
||||
// Create BagIt declaration file
|
||||
await this.createBagItDeclaration();
|
||||
|
||||
// Create README
|
||||
await this.createReadme();
|
||||
|
||||
this.logger.log('ok', 'BagIt structure created successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the bagit.txt declaration file
|
||||
*/
|
||||
private async createBagItDeclaration(): Promise<void> {
|
||||
const bagitContent = `BagIt-Version: 1.0
|
||||
Tag-File-Character-Encoding: UTF-8`;
|
||||
|
||||
const filePath = path.join(this.exportDir, 'bagit.txt');
|
||||
await plugins.smartfile.memory.toFs(bagitContent, filePath);
|
||||
|
||||
// Add to tag manifest
|
||||
const hash = await this.hashFile(filePath);
|
||||
this.tagManifest['bagit.txt'] = hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the README.txt file with Verfahrensdokumentation
|
||||
*/
|
||||
private async createReadme(): Promise<void> {
|
||||
const readmeContent = `SKR Jahresabschluss Export - Verfahrensdokumentation
|
||||
=====================================================
|
||||
|
||||
Dieses Archiv enthält einen revisionssicheren Export des Jahresabschlusses
|
||||
gemäß den Grundsätzen ordnungsmäßiger Buchführung (GoBD).
|
||||
|
||||
Export-Datum: ${new Date().toISOString()}
|
||||
Geschäftsjahr: ${this.options.fiscalYear}
|
||||
Zeitraum: ${this.options.dateFrom.toISOString()} bis ${this.options.dateTo.toISOString()}
|
||||
|
||||
STRUKTUR DES ARCHIVS
|
||||
--------------------
|
||||
- /data/accounting/: Buchhaltungsdaten (Journale, Konten, Salden)
|
||||
- /data/documents/: Belegdokumente (content-adressiert)
|
||||
- /data/reports/: Finanzberichte (PDF/A-3)
|
||||
- /data/metadata/: Export-Metadaten und Schemas
|
||||
- /data/metadata/signatures/: Digitale Signaturen und Zeitstempel
|
||||
|
||||
INTEGRITÄTSSICHERUNG
|
||||
--------------------
|
||||
- Alle Dateien sind mit SHA-256 gehasht (siehe manifest-sha256.txt)
|
||||
- Optional: Digitale Signatur (CAdES) über Manifest
|
||||
- Optional: RFC 3161 Zeitstempel
|
||||
|
||||
AUFBEWAHRUNG
|
||||
------------
|
||||
Dieses Archiv muss gemäß § 147 AO für 10 Jahre revisionssicher aufbewahrt werden.
|
||||
Empfohlen wird die Speicherung auf WORM-Medien.
|
||||
|
||||
REIMPORT
|
||||
--------
|
||||
Das Archiv kann mit der SKR-Software vollständig reimportiert werden.
|
||||
Die Datenintegrität wird beim Import automatisch verifiziert.
|
||||
|
||||
COMPLIANCE
|
||||
----------
|
||||
- GoBD-konform
|
||||
- E-Bilanz-fähig (XBRL)
|
||||
- ZUGFeRD/Factur-X kompatibel
|
||||
- PDF/A-3 für Langzeitarchivierung
|
||||
|
||||
© ${new Date().getFullYear()} ${this.options.companyInfo?.name || 'Export System'}`;
|
||||
|
||||
const filePath = path.join(this.exportDir, 'readme.txt');
|
||||
await plugins.smartfile.memory.toFs(readmeContent, filePath);
|
||||
|
||||
// Add to tag manifest
|
||||
const hash = await this.hashFile(filePath);
|
||||
this.tagManifest['readme.txt'] = hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the export metadata JSON file
|
||||
*/
|
||||
public async createExportMetadata(skrType: TSKRType): Promise<void> {
|
||||
const metadata: IExportMetadata = {
|
||||
exportVersion: '1.0.0',
|
||||
exportTimestamp: new Date().toISOString(),
|
||||
generator: {
|
||||
name: '@fin.cx/skr',
|
||||
version: '1.1.0' // Should be read from package.json
|
||||
},
|
||||
company: this.options.companyInfo,
|
||||
fiscalYear: this.options.fiscalYear,
|
||||
dateRange: {
|
||||
from: this.options.dateFrom.toISOString(),
|
||||
to: this.options.dateTo.toISOString()
|
||||
},
|
||||
skrType: skrType,
|
||||
schemaVersion: '1.0',
|
||||
crypto: {
|
||||
digestAlgorithms: ['sha256'],
|
||||
signatureType: this.options.signExport ? 'CAdES' : undefined,
|
||||
timestampPolicy: this.options.timestampExport ? 'RFC3161' : undefined,
|
||||
merkleTree: true
|
||||
},
|
||||
options: {
|
||||
packagedAs: 'bagit',
|
||||
compression: 'none',
|
||||
deduplication: true
|
||||
}
|
||||
};
|
||||
|
||||
const filePath = path.join(this.exportDir, 'data', 'metadata', 'export.json');
|
||||
await plugins.smartfile.memory.toFs(JSON.stringify(metadata, null, 2), filePath);
|
||||
|
||||
// Add to manifest
|
||||
const hash = await this.hashFile(filePath);
|
||||
this.manifest['data/metadata/export.json'] = hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates JSON schemas for the export data structures
|
||||
*/
|
||||
public async createSchemas(): Promise<void> {
|
||||
// Ledger schema
|
||||
const ledgerSchema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Ledger Entry",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": { "type": "string" },
|
||||
"entry_id": { "type": "string", "format": "uuid" },
|
||||
"booking_date": { "type": "string", "format": "date" },
|
||||
"posting_date": { "type": "string", "format": "date" },
|
||||
"currency": { "type": "string" },
|
||||
"journal": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"posting_id": { "type": "string" },
|
||||
"account_code": { "type": "string" },
|
||||
"debit": { "type": "string" },
|
||||
"credit": { "type": "string" },
|
||||
"tax_code": { "type": "string" },
|
||||
"document_refs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content_hash": { "type": "string" },
|
||||
"doc_role": { "type": "string" },
|
||||
"mime": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["posting_id", "account_code", "debit", "credit"]
|
||||
}
|
||||
},
|
||||
"created_at": { "type": "string", "format": "date-time" },
|
||||
"user": { "type": "string" }
|
||||
},
|
||||
"required": ["schema_version", "entry_id", "booking_date", "lines"]
|
||||
};
|
||||
|
||||
// Accounts schema
|
||||
const accountsSchema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Accounts CSV",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"account_code": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"type": { "type": "string" },
|
||||
"parent": { "type": "string" },
|
||||
"skr_set": { "type": "string" },
|
||||
"tax_code_default": { "type": "string" },
|
||||
"active_from": { "type": "string", "format": "date" },
|
||||
"active_to": { "type": "string", "format": "date" }
|
||||
},
|
||||
"required": ["account_code", "name", "type", "skr_set"]
|
||||
};
|
||||
|
||||
// Save schemas
|
||||
const schemasDir = path.join(this.exportDir, 'data', 'metadata', 'schemas', 'v1');
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(ledgerSchema, null, 2),
|
||||
path.join(schemasDir, 'ledger.schema.json')
|
||||
);
|
||||
this.manifest['data/metadata/schemas/v1/ledger.schema.json'] = await this.hashFile(
|
||||
path.join(schemasDir, 'ledger.schema.json')
|
||||
);
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(accountsSchema, null, 2),
|
||||
path.join(schemasDir, 'accounts.schema.json')
|
||||
);
|
||||
this.manifest['data/metadata/schemas/v1/accounts.schema.json'] = await this.hashFile(
|
||||
path.join(schemasDir, 'accounts.schema.json')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the BagIt manifest files
|
||||
*/
|
||||
public async writeManifests(): Promise<void> {
|
||||
// Write data manifest
|
||||
let manifestContent = '';
|
||||
for (const [filePath, hash] of Object.entries(this.manifest)) {
|
||||
manifestContent += `${hash} ${filePath}\n`;
|
||||
}
|
||||
|
||||
const manifestPath = path.join(this.exportDir, 'manifest-sha256.txt');
|
||||
await plugins.smartfile.memory.toFs(manifestContent, manifestPath);
|
||||
|
||||
// Add manifest to tag manifest
|
||||
this.tagManifest['manifest-sha256.txt'] = await this.hashFile(manifestPath);
|
||||
|
||||
// Write tag manifest
|
||||
let tagManifestContent = '';
|
||||
for (const [filePath, hash] of Object.entries(this.tagManifest)) {
|
||||
tagManifestContent += `${hash} ${filePath}\n`;
|
||||
}
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
tagManifestContent,
|
||||
path.join(this.exportDir, 'tagmanifest-sha256.txt')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates SHA-256 hash of a file
|
||||
*/
|
||||
private async hashFile(filePath: string): Promise<string> {
|
||||
const fileContent = await plugins.smartfile.fs.toBuffer(filePath);
|
||||
return await plugins.smarthash.sha256FromBuffer(fileContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a document in content-addressed storage
|
||||
*/
|
||||
public async storeDocument(content: Buffer, originalFilename?: string): Promise<string> {
|
||||
const hash = await plugins.smarthash.sha256FromBuffer(content);
|
||||
|
||||
// Create path based on hash (first 2 chars as directory)
|
||||
const hashPrefix = hash.substring(0, 2);
|
||||
const hashDir = path.join(this.exportDir, 'data', 'documents', 'by-hash', hashPrefix);
|
||||
await plugins.smartfile.fs.ensureDir(hashDir);
|
||||
|
||||
const docPath = path.join(hashDir, hash);
|
||||
|
||||
// Only store if not already exists (deduplication)
|
||||
if (!(await plugins.smartfile.fs.fileExists(docPath))) {
|
||||
await plugins.smartfile.memory.toFs(content, docPath);
|
||||
this.manifest[`data/documents/by-hash/${hashPrefix}/${hash}`] = hash;
|
||||
}
|
||||
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Merkle tree from all file hashes
|
||||
*/
|
||||
public async createMerkleTree(): Promise<string> {
|
||||
const leaves = Object.values(this.manifest).map(hash =>
|
||||
Buffer.from(hash, 'hex')
|
||||
);
|
||||
|
||||
// Create a sync hash function wrapper for MerkleTree
|
||||
const hashFn = (data: Buffer) => {
|
||||
// Convert async to sync by using crypto directly
|
||||
const crypto = require('crypto');
|
||||
return crypto.createHash('sha256').update(data).digest();
|
||||
};
|
||||
|
||||
const tree = new plugins.MerkleTree(leaves, hashFn, {
|
||||
sortPairs: true
|
||||
});
|
||||
|
||||
const root = tree.getRoot().toString('hex');
|
||||
|
||||
// Save Merkle tree data
|
||||
const merkleData = {
|
||||
root: root,
|
||||
leaves: Object.entries(this.manifest).map(([path, hash]) => ({
|
||||
path,
|
||||
hash
|
||||
})),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const merklePath = path.join(this.exportDir, 'data', 'metadata', 'merkle-tree.json');
|
||||
await plugins.smartfile.memory.toFs(JSON.stringify(merkleData, null, 2), merklePath);
|
||||
this.manifest['data/metadata/merkle-tree.json'] = await this.hashFile(merklePath);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the BagIt structure
|
||||
*/
|
||||
public async validateBagIt(): Promise<boolean> {
|
||||
this.logger.log('info', 'Validating BagIt structure...');
|
||||
|
||||
// Check required files exist
|
||||
const requiredFiles = [
|
||||
'bagit.txt',
|
||||
'manifest-sha256.txt',
|
||||
'tagmanifest-sha256.txt',
|
||||
'readme.txt'
|
||||
];
|
||||
|
||||
for (const file of requiredFiles) {
|
||||
const filePath = path.join(this.exportDir, file);
|
||||
if (!(await plugins.smartfile.fs.fileExists(filePath))) {
|
||||
this.logger.log('error', `Required file missing: ${file}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all manifest entries
|
||||
for (const [relPath, expectedHash] of Object.entries(this.manifest)) {
|
||||
const fullPath = path.join(this.exportDir, relPath);
|
||||
if (!(await plugins.smartfile.fs.fileExists(fullPath))) {
|
||||
this.logger.log('error', `Manifest file missing: ${relPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const actualHash = await this.hashFile(fullPath);
|
||||
if (actualHash !== expectedHash) {
|
||||
this.logger.log('error', `Hash mismatch for ${relPath}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('ok', 'BagIt validation successful');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
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;
|
||||
private readonly einvoiceModuleName = '@fin.cx/einvoice';
|
||||
|
||||
constructor() {
|
||||
this.logger = new plugins.smartlog.ConsoleLog();
|
||||
}
|
||||
|
||||
private async getEInvoiceClass(): Promise<{
|
||||
new (): any;
|
||||
fromXml(xmlString: string): Promise<any>;
|
||||
}> {
|
||||
const { EInvoice } = (await import(this.einvoiceModuleName)) as {
|
||||
EInvoice: {
|
||||
new (): any;
|
||||
fromXml(xmlString: string): Promise<any>;
|
||||
};
|
||||
};
|
||||
return EInvoice;
|
||||
}
|
||||
|
||||
private readonly MAX_XML_SIZE = 10 * 1024 * 1024; // 10MB max
|
||||
private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max
|
||||
|
||||
/**
|
||||
* Parse an invoice from file or buffer
|
||||
*/
|
||||
public async parseInvoice(
|
||||
file: Buffer | string,
|
||||
direction: TInvoiceDirection
|
||||
): Promise<IInvoice> {
|
||||
try {
|
||||
// Validate input size
|
||||
if (Buffer.isBuffer(file)) {
|
||||
if (file.length > this.MAX_XML_SIZE) {
|
||||
throw new Error(`Invoice file too large: ${file.length} bytes (max ${this.MAX_XML_SIZE} bytes)`);
|
||||
}
|
||||
} else if (typeof file === 'string' && file.length > this.MAX_XML_SIZE) {
|
||||
throw new Error(`Invoice XML too large: ${file.length} characters (max ${this.MAX_XML_SIZE} characters)`);
|
||||
}
|
||||
|
||||
// Parse the invoice using @fin.cx/einvoice
|
||||
const EInvoice = await this.getEInvoiceClass();
|
||||
let einvoice: any;
|
||||
if (typeof file === 'string') {
|
||||
einvoice = await EInvoice.fromXml(file);
|
||||
} else {
|
||||
// Convert buffer to string first
|
||||
const xmlString = file.toString('utf-8');
|
||||
einvoice = await 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}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invoice parsing failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an invoice using multi-level validation
|
||||
*/
|
||||
private async validateInvoice(einvoice: any): Promise<IValidationResult> {
|
||||
// Perform multi-level validation
|
||||
const validationResult = await einvoice.validate();
|
||||
|
||||
// Parse validation results into our structure
|
||||
const syntaxResult = {
|
||||
isValid: validationResult.syntax?.valid !== false,
|
||||
errors: validationResult.syntax?.errors || [],
|
||||
warnings: validationResult.syntax?.warnings || []
|
||||
};
|
||||
|
||||
const semanticResult = {
|
||||
isValid: validationResult.semantic?.valid !== false,
|
||||
errors: validationResult.semantic?.errors || [],
|
||||
warnings: validationResult.semantic?.warnings || []
|
||||
};
|
||||
|
||||
const businessResult = {
|
||||
isValid: validationResult.business?.valid !== false,
|
||||
errors: validationResult.business?.errors || [],
|
||||
warnings: validationResult.business?.warnings || []
|
||||
};
|
||||
|
||||
const countryResult = {
|
||||
isValid: validationResult.country?.valid !== false,
|
||||
errors: validationResult.country?.errors || [],
|
||||
warnings: validationResult.country?.warnings || []
|
||||
};
|
||||
|
||||
return {
|
||||
isValid: syntaxResult.isValid && semanticResult.isValid && businessResult.isValid,
|
||||
syntax: {
|
||||
valid: syntaxResult.isValid,
|
||||
errors: syntaxResult.errors || [],
|
||||
warnings: syntaxResult.warnings || []
|
||||
},
|
||||
semantic: {
|
||||
valid: semanticResult.isValid,
|
||||
errors: semanticResult.errors || [],
|
||||
warnings: semanticResult.warnings || []
|
||||
},
|
||||
businessRules: {
|
||||
valid: businessResult.isValid,
|
||||
errors: businessResult.errors || [],
|
||||
warnings: businessResult.warnings || []
|
||||
},
|
||||
countrySpecific: {
|
||||
valid: countryResult.isValid,
|
||||
errors: countryResult.errors || [],
|
||||
warnings: countryResult.warnings || []
|
||||
},
|
||||
validatedAt: new Date(),
|
||||
validatorVersion: '5.1.4'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map EN16931 Business Terms to internal invoice model
|
||||
*/
|
||||
private async mapToInternalModel(
|
||||
businessTerms: any,
|
||||
format: TInvoiceFormat,
|
||||
direction: TInvoiceDirection,
|
||||
validationResult: IValidationResult
|
||||
): Promise<IInvoice> {
|
||||
const invoice: IInvoice = {
|
||||
// Identity
|
||||
id: plugins.smartunique.shortId(),
|
||||
direction,
|
||||
format,
|
||||
|
||||
// EN16931 Business Terms
|
||||
invoiceNumber: businessTerms.BT1_InvoiceNumber,
|
||||
issueDate: new Date(businessTerms.BT2_IssueDate),
|
||||
invoiceTypeCode: businessTerms.BT3_InvoiceTypeCode || '380',
|
||||
currencyCode: businessTerms.BT5_CurrencyCode || 'EUR',
|
||||
taxCurrencyCode: businessTerms.BT6_TaxCurrencyCode,
|
||||
taxPointDate: businessTerms.BT7_TaxPointDate ? new Date(businessTerms.BT7_TaxPointDate) : undefined,
|
||||
paymentDueDate: businessTerms.BT9_PaymentDueDate ? new Date(businessTerms.BT9_PaymentDueDate) : undefined,
|
||||
buyerReference: businessTerms.BT10_BuyerReference,
|
||||
projectReference: businessTerms.BT11_ProjectReference,
|
||||
contractReference: businessTerms.BT12_ContractReference,
|
||||
orderReference: businessTerms.BT13_OrderReference,
|
||||
sellerOrderReference: businessTerms.BT14_SellerOrderReference,
|
||||
|
||||
// Parties
|
||||
supplier: this.mapParty(businessTerms.BG4_Seller),
|
||||
customer: this.mapParty(businessTerms.BG7_Buyer),
|
||||
payee: businessTerms.BG10_Payee ? this.mapParty(businessTerms.BG10_Payee) : undefined,
|
||||
|
||||
// Line items
|
||||
lines: this.mapInvoiceLines(businessTerms.BG25_InvoiceLines || []),
|
||||
|
||||
// Allowances and charges
|
||||
allowances: this.mapAllowancesCharges(businessTerms.BG20_DocumentAllowances || [], true),
|
||||
charges: this.mapAllowancesCharges(businessTerms.BG21_DocumentCharges || [], false),
|
||||
|
||||
// Amounts
|
||||
lineNetAmount: parseFloat(businessTerms.BT106_SumOfLineNetAmounts || 0),
|
||||
allowanceTotalAmount: parseFloat(businessTerms.BT107_AllowanceTotalAmount || 0),
|
||||
chargeTotalAmount: parseFloat(businessTerms.BT108_ChargeTotalAmount || 0),
|
||||
taxExclusiveAmount: parseFloat(businessTerms.BT109_TaxExclusiveAmount || 0),
|
||||
taxInclusiveAmount: parseFloat(businessTerms.BT112_TaxInclusiveAmount || 0),
|
||||
prepaidAmount: parseFloat(businessTerms.BT113_PrepaidAmount || 0),
|
||||
payableAmount: parseFloat(businessTerms.BT115_PayableAmount || 0),
|
||||
|
||||
// VAT breakdown
|
||||
vatBreakdown: this.mapVATBreakdown(businessTerms.BG23_VATBreakdown || []),
|
||||
totalVATAmount: parseFloat(businessTerms.BT110_TotalVATAmount || 0),
|
||||
|
||||
// Payment
|
||||
paymentTerms: this.mapPaymentTerms(businessTerms),
|
||||
paymentMeans: this.mapPaymentMeans(businessTerms.BG16_PaymentInstructions),
|
||||
|
||||
// Notes
|
||||
invoiceNote: businessTerms.BT22_InvoiceNote,
|
||||
|
||||
// Processing metadata
|
||||
status: 'validated',
|
||||
|
||||
// Storage (to be filled later)
|
||||
contentHash: '',
|
||||
|
||||
// Validation
|
||||
validationResult,
|
||||
|
||||
// Audit trail
|
||||
createdAt: new Date(),
|
||||
createdBy: 'system',
|
||||
|
||||
// Metadata
|
||||
metadata: {
|
||||
importedAt: new Date(),
|
||||
parserVersion: '5.1.4',
|
||||
originalFormat: format
|
||||
}
|
||||
};
|
||||
|
||||
return invoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map party information
|
||||
*/
|
||||
private mapParty(partyData: any): IInvoiceParty {
|
||||
if (!partyData) {
|
||||
return {
|
||||
id: '',
|
||||
name: '',
|
||||
address: { countryCode: 'DE' }
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: partyData.BT29_SellerID || partyData.BT46_BuyerID || plugins.smartunique.shortId(),
|
||||
name: partyData.BT27_SellerName || partyData.BT44_BuyerName || '',
|
||||
address: {
|
||||
street: partyData.BT35_SellerStreet || partyData.BT50_BuyerStreet,
|
||||
city: partyData.BT37_SellerCity || partyData.BT52_BuyerCity,
|
||||
postalCode: partyData.BT38_SellerPostalCode || partyData.BT53_BuyerPostalCode,
|
||||
countryCode: partyData.BT40_SellerCountryCode || partyData.BT55_BuyerCountryCode || 'DE'
|
||||
},
|
||||
vatId: partyData.BT31_SellerVATID || partyData.BT48_BuyerVATID,
|
||||
taxId: partyData.BT32_SellerTaxID || partyData.BT47_BuyerTaxID,
|
||||
email: partyData.BT34_SellerEmail || partyData.BT49_BuyerEmail,
|
||||
phone: partyData.BT33_SellerPhone,
|
||||
bankAccount: this.mapBankAccount(partyData)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map bank account information
|
||||
*/
|
||||
private mapBankAccount(partyData: any): IInvoiceParty['bankAccount'] | undefined {
|
||||
if (!partyData?.BT84_PaymentAccountID) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
iban: partyData.BT84_PaymentAccountID,
|
||||
bic: partyData.BT86_PaymentServiceProviderID,
|
||||
accountHolder: partyData.BT85_PaymentAccountName
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map invoice lines
|
||||
*/
|
||||
private mapInvoiceLines(linesData: any[]): IInvoiceLine[] {
|
||||
return linesData.map((line, index) => ({
|
||||
lineNumber: index + 1,
|
||||
description: line.BT154_ItemDescription || '',
|
||||
quantity: parseFloat(line.BT129_Quantity || 1),
|
||||
unitPrice: parseFloat(line.BT146_NetPrice || 0),
|
||||
netAmount: parseFloat(line.BT131_LineNetAmount || 0),
|
||||
vatCategory: this.mapVATCategory(line.BT151_ItemVATCategory, line.BT152_ItemVATRate),
|
||||
vatAmount: parseFloat(line.lineVATAmount || 0),
|
||||
grossAmount: parseFloat(line.BT131_LineNetAmount || 0) + parseFloat(line.lineVATAmount || 0),
|
||||
productCode: line.BT155_ItemSellerID,
|
||||
allowances: this.mapLineAllowancesCharges(line.BG27_LineAllowances || [], true),
|
||||
charges: this.mapLineAllowancesCharges(line.BG28_LineCharges || [], false)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map VAT category
|
||||
*/
|
||||
private mapVATCategory(categoryCode: string, rate: string | number): IVATCategory {
|
||||
const vatRate = typeof rate === 'string' ? parseFloat(rate) : rate;
|
||||
|
||||
return {
|
||||
code: categoryCode || 'S',
|
||||
rate: vatRate || 0,
|
||||
exemptionReason: this.getExemptionReason(categoryCode)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exemption reason for VAT category
|
||||
*/
|
||||
private getExemptionReason(categoryCode: string): string | undefined {
|
||||
const exemptionReasons: Record<string, string | undefined> = {
|
||||
'E': 'Tax exempt',
|
||||
'Z': 'Zero rated',
|
||||
'AE': 'Reverse charge (§13b UStG)',
|
||||
'K': 'Intra-EU supply',
|
||||
'G': 'Export outside EU',
|
||||
'O': 'Outside scope of tax',
|
||||
'S': undefined // Standard rate, no exemption
|
||||
};
|
||||
|
||||
return exemptionReasons[categoryCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map VAT breakdown
|
||||
*/
|
||||
private mapVATBreakdown(vatBreakdown: any[]): IInvoice['vatBreakdown'] {
|
||||
return vatBreakdown.map(vat => ({
|
||||
vatCategory: this.mapVATCategory(vat.BT118_VATCategory, vat.BT119_VATRate),
|
||||
taxableAmount: parseFloat(vat.BT116_TaxableAmount || 0),
|
||||
taxAmount: parseFloat(vat.BT117_TaxAmount || 0)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map allowances and charges
|
||||
*/
|
||||
private mapAllowancesCharges(data: any[], isAllowance: boolean): IAllowanceCharge[] {
|
||||
return data.map(item => ({
|
||||
reason: item.BT97_AllowanceReason || item.BT104_ChargeReason || '',
|
||||
amount: parseFloat(item.BT92_AllowanceAmount || item.BT99_ChargeAmount || 0),
|
||||
percentage: item.BT94_AllowancePercentage || item.BT101_ChargePercentage,
|
||||
vatCategory: item.BT95_AllowanceVATCategory || item.BT102_ChargeVATCategory
|
||||
? this.mapVATCategory(
|
||||
item.BT95_AllowanceVATCategory || item.BT102_ChargeVATCategory,
|
||||
item.BT96_AllowanceVATRate || item.BT103_ChargeVATRate
|
||||
)
|
||||
: undefined,
|
||||
vatAmount: parseFloat(item.allowanceVATAmount || item.chargeVATAmount || 0)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map line-level allowances and charges
|
||||
*/
|
||||
private mapLineAllowancesCharges(data: any[], isAllowance: boolean): IAllowanceCharge[] {
|
||||
return data.map(item => ({
|
||||
reason: item.BT140_LineAllowanceReason || item.BT145_LineChargeReason || '',
|
||||
amount: parseFloat(item.BT136_LineAllowanceAmount || item.BT141_LineChargeAmount || 0),
|
||||
percentage: item.BT138_LineAllowancePercentage || item.BT143_LineChargePercentage
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map payment terms
|
||||
*/
|
||||
private mapPaymentTerms(businessTerms: any): IPaymentTerms | undefined {
|
||||
if (!businessTerms.BT9_PaymentDueDate && !businessTerms.BT20_PaymentTerms) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const paymentTerms: IPaymentTerms = {
|
||||
dueDate: businessTerms.BT9_PaymentDueDate
|
||||
? new Date(businessTerms.BT9_PaymentDueDate)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Default 30 days
|
||||
paymentTermsNote: businessTerms.BT20_PaymentTerms
|
||||
};
|
||||
|
||||
// Parse skonto from payment terms note if present
|
||||
if (businessTerms.BT20_PaymentTerms) {
|
||||
paymentTerms.skonto = this.parseSkontoTerms(businessTerms.BT20_PaymentTerms);
|
||||
}
|
||||
|
||||
return paymentTerms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse skonto terms from payment terms text
|
||||
*/
|
||||
private parseSkontoTerms(paymentTermsText: string): IPaymentTerms['skonto'] {
|
||||
const skontoTerms: IPaymentTerms['skonto'] = [];
|
||||
|
||||
// Common German skonto patterns:
|
||||
// "2% Skonto bei Zahlung innerhalb von 10 Tagen"
|
||||
// "3% bei Zahlung bis 8 Tage, 2% bis 14 Tage"
|
||||
const skontoPattern = /(\d+(?:\.\d+)?)\s*%.*?(\d+)\s*(?:Tag|Day)/gi;
|
||||
let match;
|
||||
|
||||
while ((match = skontoPattern.exec(paymentTermsText)) !== null) {
|
||||
skontoTerms.push({
|
||||
percentage: parseFloat(match[1]),
|
||||
days: parseInt(match[2]),
|
||||
baseAmount: 0 // To be calculated based on invoice amount
|
||||
});
|
||||
}
|
||||
|
||||
return skontoTerms.length > 0 ? skontoTerms : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map payment means
|
||||
*/
|
||||
private mapPaymentMeans(paymentInstructions: any): IInvoice['paymentMeans'] | undefined {
|
||||
if (!paymentInstructions) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
code: paymentInstructions.BT81_PaymentMeansCode || '30', // 30 = Bank transfer
|
||||
account: paymentInstructions.BT84_PaymentAccountID
|
||||
? {
|
||||
iban: paymentInstructions.BT84_PaymentAccountID,
|
||||
bic: paymentInstructions.BT86_PaymentServiceProviderID,
|
||||
accountHolder: paymentInstructions.BT85_PaymentAccountName
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify tax scenario based on invoice data
|
||||
*/
|
||||
private classifyTaxScenario(invoice: IInvoice): TTaxScenario {
|
||||
const supplierCountry = invoice.supplier.address.countryCode;
|
||||
const customerCountry = invoice.customer.address.countryCode;
|
||||
const hasVAT = invoice.totalVATAmount > 0;
|
||||
const vatCategories = invoice.vatBreakdown.map(vb => vb.vatCategory.code);
|
||||
|
||||
// Reverse charge
|
||||
if (vatCategories.includes('AE')) {
|
||||
return 'reverse_charge';
|
||||
}
|
||||
|
||||
// Small business exemption
|
||||
if (vatCategories.includes('E') && invoice.invoiceNote?.includes('§19')) {
|
||||
return 'small_business';
|
||||
}
|
||||
|
||||
// Export outside EU
|
||||
if (vatCategories.includes('G') || (!this.isEUCountry(customerCountry) && supplierCountry === 'DE')) {
|
||||
return 'export';
|
||||
}
|
||||
|
||||
// Intra-EU transactions
|
||||
if (supplierCountry !== customerCountry && this.isEUCountry(supplierCountry) && this.isEUCountry(customerCountry)) {
|
||||
if (invoice.direction === 'outbound') {
|
||||
return 'intra_eu_supply';
|
||||
} else {
|
||||
return 'intra_eu_acquisition';
|
||||
}
|
||||
}
|
||||
|
||||
// Domestic exempt
|
||||
if (!hasVAT && supplierCountry === 'DE' && customerCountry === 'DE') {
|
||||
return 'domestic_exempt';
|
||||
}
|
||||
|
||||
// Default: Domestic taxed
|
||||
return 'domestic_taxed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if country is in EU
|
||||
*/
|
||||
private isEUCountry(countryCode: string): boolean {
|
||||
const euCountries = [
|
||||
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',
|
||||
'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',
|
||||
'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE'
|
||||
];
|
||||
return euCountries.includes(countryCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map e-invoice format from library format
|
||||
*/
|
||||
private mapEInvoiceFormat(format: string): TInvoiceFormat {
|
||||
const formatMap: Record<string, TInvoiceFormat> = {
|
||||
'xrechnung': 'xrechnung',
|
||||
'zugferd': 'zugferd',
|
||||
'factur-x': 'facturx',
|
||||
'facturx': 'facturx',
|
||||
'peppol': 'peppol',
|
||||
'ubl': 'ubl'
|
||||
};
|
||||
|
||||
return formatMap[format.toLowerCase()] || 'xrechnung';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate content hash for the invoice
|
||||
*/
|
||||
private async calculateContentHash(xmlContent: string): Promise<string> {
|
||||
const hash = await plugins.smarthash.sha256FromString(xmlContent);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert invoice to different format
|
||||
*/
|
||||
public async convertFormat(
|
||||
invoice: IInvoice,
|
||||
targetFormat: TInvoiceFormat
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Load from existing XML
|
||||
const EInvoice = await this.getEInvoiceClass();
|
||||
const einvoice: any = await 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}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Format conversion failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate invoice from internal data
|
||||
*/
|
||||
public async generateInvoice(
|
||||
invoiceData: Partial<IInvoice>,
|
||||
format: TInvoiceFormat
|
||||
): Promise<{ xml: string; pdf?: Buffer }> {
|
||||
try {
|
||||
// Create a new invoice instance
|
||||
const EInvoice = await this.getEInvoiceClass();
|
||||
const einvoice: any = new 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}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invoice generation failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map internal invoice to EN16931 Business Terms
|
||||
*/
|
||||
private mapToBusinessTerms(invoice: Partial<IInvoice>): any {
|
||||
return {
|
||||
BT1_InvoiceNumber: invoice.invoiceNumber,
|
||||
BT2_IssueDate: invoice.issueDate?.toISOString(),
|
||||
BT3_InvoiceTypeCode: invoice.invoiceTypeCode || '380',
|
||||
BT5_CurrencyCode: invoice.currencyCode || 'EUR',
|
||||
BT7_TaxPointDate: invoice.taxPointDate?.toISOString(),
|
||||
BT9_PaymentDueDate: invoice.paymentDueDate?.toISOString(),
|
||||
|
||||
// Map other Business Terms...
|
||||
// This would be a comprehensive mapping in production
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { JournalEntry } from './skr.classes.journalentry.js';
|
||||
import { SKRInvoiceMapper } from './skr.invoice.mapper.js';
|
||||
import { suggestPostingKey } from './skr.postingkeys.js';
|
||||
import type { TSKRType, IJournalEntry, IJournalEntryLine } from './skr.types.js';
|
||||
import type {
|
||||
IInvoice,
|
||||
IInvoiceLine,
|
||||
IBookingRules,
|
||||
IBookingInfo,
|
||||
TTaxScenario,
|
||||
IPaymentInfo
|
||||
} from './skr.invoice.entity.js';
|
||||
|
||||
/**
|
||||
* Options for booking an invoice
|
||||
*/
|
||||
export interface IBookingOptions {
|
||||
autoBook?: boolean;
|
||||
confidenceThreshold?: number;
|
||||
bookingDate?: Date;
|
||||
bookingReference?: string;
|
||||
skipValidation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of booking an invoice
|
||||
*/
|
||||
export interface IBookingResult {
|
||||
success: boolean;
|
||||
journalEntry?: JournalEntry;
|
||||
bookingInfo?: IBookingInfo;
|
||||
confidence: number;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic booking engine for invoices
|
||||
* Creates journal entries from invoice data based on SKR mapping rules
|
||||
*/
|
||||
export class InvoiceBookingEngine {
|
||||
private logger: plugins.smartlog.ConsoleLog;
|
||||
private skrType: TSKRType;
|
||||
private mapper: SKRInvoiceMapper;
|
||||
|
||||
constructor(skrType: TSKRType) {
|
||||
this.skrType = skrType;
|
||||
this.mapper = new SKRInvoiceMapper(skrType);
|
||||
this.logger = new plugins.smartlog.ConsoleLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Book an invoice to the ledger
|
||||
*/
|
||||
public async bookInvoice(
|
||||
invoice: IInvoice,
|
||||
bookingRules?: Partial<IBookingRules>,
|
||||
options?: IBookingOptions
|
||||
): Promise<IBookingResult> {
|
||||
try {
|
||||
// Get complete booking rules
|
||||
const rules = this.mapper.mapInvoiceToSKR(invoice, bookingRules);
|
||||
|
||||
// Calculate confidence
|
||||
const confidence = this.mapper.calculateConfidence(invoice, rules);
|
||||
|
||||
// Check if auto-booking is allowed
|
||||
if (options?.autoBook && confidence < (options.confidenceThreshold || 80)) {
|
||||
return {
|
||||
success: false,
|
||||
confidence,
|
||||
warnings: [`Confidence score ${confidence}% is below threshold ${options.confidenceThreshold || 80}%`]
|
||||
};
|
||||
}
|
||||
|
||||
// Validate invoice before booking
|
||||
if (!options?.skipValidation) {
|
||||
const validationErrors = this.validateInvoice(invoice);
|
||||
if (validationErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
confidence,
|
||||
errors: validationErrors
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build journal entry
|
||||
const journalEntry = await this.buildJournalEntry(invoice, rules, options);
|
||||
|
||||
// Create booking info
|
||||
const bookingInfo: IBookingInfo = {
|
||||
journalEntryId: journalEntry.id,
|
||||
transactionIds: journalEntry.transactionIds || [],
|
||||
bookedAt: new Date(),
|
||||
bookedBy: 'system',
|
||||
bookingRules: {
|
||||
vendorAccount: rules.vendorControlAccount,
|
||||
customerAccount: rules.customerControlAccount,
|
||||
expenseAccounts: this.getUsedExpenseAccounts(invoice, rules),
|
||||
revenueAccounts: this.getUsedRevenueAccounts(invoice, rules),
|
||||
vatAccounts: this.getUsedVATAccounts(invoice, rules)
|
||||
},
|
||||
confidence,
|
||||
autoBooked: options?.autoBook || false
|
||||
};
|
||||
|
||||
// Post the journal entry
|
||||
// TODO: When MongoDB transactions are available, wrap this in a transaction
|
||||
// Example: await db.withTransaction(async (session) => { ... })
|
||||
try {
|
||||
await journalEntry.validate();
|
||||
await journalEntry.post();
|
||||
|
||||
// Mark invoice as posted if we have a reference to it
|
||||
if (invoice.status !== 'posted') {
|
||||
invoice.status = 'posted';
|
||||
}
|
||||
} catch (postError) {
|
||||
this.logger.log('error', `Failed to post journal entry: ${postError}`);
|
||||
throw postError; // Re-throw to trigger rollback when transactions are available
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
journalEntry,
|
||||
bookingInfo,
|
||||
confidence,
|
||||
warnings: this.generateWarnings(invoice, rules)
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to book invoice: ${error}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
confidence: 0,
|
||||
errors: [`Booking failed: ${errorMessage}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build journal entry from invoice
|
||||
*/
|
||||
private async buildJournalEntry(
|
||||
invoice: IInvoice,
|
||||
rules: IBookingRules,
|
||||
options?: IBookingOptions
|
||||
): Promise<JournalEntry> {
|
||||
const lines: IJournalEntryLine[] = [];
|
||||
const isInbound = invoice.direction === 'inbound';
|
||||
const isCredit = invoice.invoiceTypeCode === '381'; // Credit note
|
||||
|
||||
// Determine if we need to reverse the normal booking direction
|
||||
const reverseDirection = isCredit;
|
||||
|
||||
if (isInbound) {
|
||||
// Inbound invoice (AP)
|
||||
lines.push(...this.buildAPEntry(invoice, rules, reverseDirection));
|
||||
} else {
|
||||
// Outbound invoice (AR)
|
||||
lines.push(...this.buildAREntry(invoice, rules, reverseDirection));
|
||||
}
|
||||
|
||||
// Create journal entry
|
||||
const journalData: IJournalEntry = {
|
||||
date: options?.bookingDate || invoice.issueDate,
|
||||
description: this.buildDescription(invoice),
|
||||
reference: options?.bookingReference || invoice.invoiceNumber,
|
||||
lines,
|
||||
skrType: this.skrType
|
||||
};
|
||||
|
||||
const journalEntry = new JournalEntry(journalData);
|
||||
return journalEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AP (Accounts Payable) journal entry lines
|
||||
*/
|
||||
private buildAPEntry(
|
||||
invoice: IInvoice,
|
||||
rules: IBookingRules,
|
||||
reverseDirection: boolean
|
||||
): IJournalEntryLine[] {
|
||||
const lines: IJournalEntryLine[] = [];
|
||||
|
||||
// Group lines by account
|
||||
const accountGroups = this.groupLinesByAccount(invoice, rules);
|
||||
|
||||
// Create expense/asset entries
|
||||
for (const [accountNumber, group] of Object.entries(accountGroups)) {
|
||||
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
|
||||
|
||||
if (reverseDirection) {
|
||||
// Credit note: credit expense account
|
||||
lines.push({
|
||||
accountNumber,
|
||||
credit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% input VAT for expenses
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: debit expense account
|
||||
lines.push({
|
||||
accountNumber,
|
||||
debit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% input VAT for expenses
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create VAT entries
|
||||
const vatLines = this.buildVATLines(invoice, rules, 'input', reverseDirection);
|
||||
lines.push(...vatLines);
|
||||
|
||||
// Create vendor control account entry
|
||||
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
||||
const totalAmount = Math.abs(invoice.payableAmount);
|
||||
|
||||
if (reverseDirection) {
|
||||
// Credit note: debit vendor account
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
debit: totalAmount,
|
||||
description: `${invoice.supplier.name} - Credit Note ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: credit vendor account
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
credit: totalAmount,
|
||||
description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AR (Accounts Receivable) journal entry lines
|
||||
*/
|
||||
private buildAREntry(
|
||||
invoice: IInvoice,
|
||||
rules: IBookingRules,
|
||||
reverseDirection: boolean
|
||||
): IJournalEntryLine[] {
|
||||
const lines: IJournalEntryLine[] = [];
|
||||
|
||||
// Group lines by account
|
||||
const accountGroups = this.groupLinesByAccount(invoice, rules);
|
||||
|
||||
// Create revenue entries
|
||||
for (const [accountNumber, group] of Object.entries(accountGroups)) {
|
||||
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
|
||||
|
||||
if (reverseDirection) {
|
||||
// Credit note: debit revenue account
|
||||
lines.push({
|
||||
accountNumber,
|
||||
debit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% output VAT for revenue
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: credit revenue account
|
||||
lines.push({
|
||||
accountNumber,
|
||||
credit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% output VAT for revenue
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create VAT entries
|
||||
const vatLines = this.buildVATLines(invoice, rules, 'output', reverseDirection);
|
||||
lines.push(...vatLines);
|
||||
|
||||
// Create customer control account entry
|
||||
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
||||
const totalAmount = Math.abs(invoice.payableAmount);
|
||||
|
||||
if (reverseDirection) {
|
||||
// Credit note: credit customer account
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
credit: totalAmount,
|
||||
description: `${invoice.customer.name} - Credit Note ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: debit customer account
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
debit: totalAmount,
|
||||
description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build VAT lines
|
||||
*/
|
||||
private buildVATLines(
|
||||
invoice: IInvoice,
|
||||
rules: IBookingRules,
|
||||
direction: 'input' | 'output',
|
||||
reverseDirection: boolean
|
||||
): IJournalEntryLine[] {
|
||||
const lines: IJournalEntryLine[] = [];
|
||||
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
||||
|
||||
// Handle reverse charge specially
|
||||
if (taxScenario === 'reverse_charge') {
|
||||
return this.buildReverseChargeVATLines(invoice, rules);
|
||||
}
|
||||
|
||||
// Standard VAT booking
|
||||
for (const vatBreak of invoice.vatBreakdown) {
|
||||
if (vatBreak.taxAmount === 0) continue;
|
||||
|
||||
const vatAccount = this.mapper.getVATAccount(
|
||||
vatBreak.vatCategory,
|
||||
direction,
|
||||
taxScenario
|
||||
);
|
||||
|
||||
const amount = Math.abs(vatBreak.taxAmount);
|
||||
const description = `VAT ${vatBreak.vatCategory.rate}%`;
|
||||
const vatRate = vatBreak.vatCategory.rate;
|
||||
// Select posting key based on VAT rate: 8 for 7%, 9 for 19%
|
||||
const postingKey = vatRate === 7 ? 8 : 9;
|
||||
|
||||
if (direction === 'input') {
|
||||
// Input VAT (Vorsteuer)
|
||||
if (reverseDirection) {
|
||||
lines.push({ accountNumber: vatAccount, credit: amount, description, postingKey });
|
||||
} else {
|
||||
lines.push({ accountNumber: vatAccount, debit: amount, description, postingKey });
|
||||
}
|
||||
} else {
|
||||
// Output VAT (Umsatzsteuer)
|
||||
if (reverseDirection) {
|
||||
lines.push({ accountNumber: vatAccount, debit: amount, description, postingKey });
|
||||
} else {
|
||||
lines.push({ accountNumber: vatAccount, credit: amount, description, postingKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate VAT amount from taxable amount and rate
|
||||
*/
|
||||
private calculateVAT(taxableAmount: number, rate: number): number {
|
||||
return Math.round(taxableAmount * rate / 100 * 100) / 100; // Round to 2 decimals
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate effective VAT rate for the invoice (weighted average)
|
||||
*/
|
||||
private calculateEffectiveVATRate(invoice: IInvoice): number {
|
||||
const totalTaxable = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxableAmount, 0);
|
||||
if (totalTaxable === 0) {
|
||||
return 19; // Default to standard German VAT rate
|
||||
}
|
||||
|
||||
// Calculate weighted average VAT rate
|
||||
const weightedRate = invoice.vatBreakdown.reduce((sum, vb) => {
|
||||
return sum + (vb.vatCategory.rate * vb.taxableAmount);
|
||||
}, 0);
|
||||
|
||||
return Math.round(weightedRate / totalTaxable * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build reverse charge VAT lines (§13b UStG)
|
||||
*/
|
||||
private buildReverseChargeVATLines(
|
||||
invoice: IInvoice,
|
||||
rules: IBookingRules
|
||||
): IJournalEntryLine[] {
|
||||
const lines: IJournalEntryLine[] = [];
|
||||
|
||||
// For reverse charge, we book both input and output VAT
|
||||
for (const vatBreak of invoice.vatBreakdown) {
|
||||
// For reverse charge, calculate VAT if not provided
|
||||
const amount = vatBreak.taxAmount > 0
|
||||
? Math.abs(vatBreak.taxAmount)
|
||||
: this.calculateVAT(Math.abs(vatBreak.taxableAmount), vatBreak.vatCategory.rate);
|
||||
|
||||
// Input VAT (deductible)
|
||||
const inputVATAccount = this.mapper.getVATAccount(
|
||||
vatBreak.vatCategory,
|
||||
'input',
|
||||
'reverse_charge'
|
||||
);
|
||||
|
||||
// Output VAT (payable)
|
||||
const outputVATAccount = this.mapper.getVATAccount(
|
||||
vatBreak.vatCategory,
|
||||
'output',
|
||||
'reverse_charge'
|
||||
);
|
||||
|
||||
lines.push(
|
||||
{
|
||||
accountNumber: inputVATAccount,
|
||||
debit: amount,
|
||||
description: `Reverse charge input VAT ${vatBreak.vatCategory.rate}%`,
|
||||
postingKey: 94 // Reverse charge posting key
|
||||
},
|
||||
{
|
||||
accountNumber: outputVATAccount,
|
||||
credit: amount,
|
||||
description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%`,
|
||||
postingKey: 94 // Reverse charge posting key
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group invoice lines by account
|
||||
*/
|
||||
private groupLinesByAccount(
|
||||
invoice: IInvoice,
|
||||
rules: IBookingRules
|
||||
): Record<string, IInvoiceLine[]> {
|
||||
const groups: Record<string, IInvoiceLine[]> = {};
|
||||
|
||||
for (const line of invoice.lines) {
|
||||
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
||||
|
||||
if (!groups[account]) {
|
||||
groups[account] = [];
|
||||
}
|
||||
groups[account].push(line);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Book payment for an invoice
|
||||
*/
|
||||
public async bookPayment(
|
||||
invoice: IInvoice,
|
||||
payment: IPaymentInfo,
|
||||
rules: IBookingRules
|
||||
): Promise<IBookingResult> {
|
||||
try {
|
||||
const lines: IJournalEntryLine[] = [];
|
||||
const isInbound = invoice.direction === 'inbound';
|
||||
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
||||
|
||||
// Check for skonto
|
||||
const skontoAmount = payment.skontoTaken || 0;
|
||||
const paymentAmount = payment.amount;
|
||||
const fullAmount = paymentAmount + skontoAmount;
|
||||
|
||||
if (isInbound) {
|
||||
// Payment for vendor invoice
|
||||
lines.push(
|
||||
{
|
||||
accountNumber: controlAccount,
|
||||
debit: fullAmount,
|
||||
description: `Payment to ${invoice.supplier.name}`,
|
||||
postingKey: 3 // Payment with VAT
|
||||
},
|
||||
{
|
||||
accountNumber: '1000', // Bank account (would be configurable)
|
||||
credit: paymentAmount,
|
||||
description: `Bank payment ${payment.endToEndId || payment.paymentId}`,
|
||||
postingKey: 40 // Tax-free for bank account
|
||||
}
|
||||
);
|
||||
|
||||
// Book skonto if taken
|
||||
if (skontoAmount > 0) {
|
||||
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
||||
lines.push({
|
||||
accountNumber: skontoAccounts.skontoAccount,
|
||||
credit: skontoAmount,
|
||||
description: `Skonto received`,
|
||||
postingKey: 40 // Tax-free for skonto
|
||||
});
|
||||
|
||||
// VAT correction for skonto
|
||||
if (rules.skontoMethod === 'gross') {
|
||||
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
||||
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
|
||||
lines.push(
|
||||
{
|
||||
accountNumber: skontoAccounts.vatCorrectionAccount,
|
||||
credit: vatCorrection,
|
||||
description: `Skonto VAT correction`,
|
||||
postingKey: 40 // Tax-free for correction
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Payment from customer
|
||||
lines.push(
|
||||
{
|
||||
accountNumber: '1000', // Bank account
|
||||
debit: paymentAmount,
|
||||
description: `Payment from ${invoice.customer.name}`,
|
||||
postingKey: 40 // Tax-free for bank account
|
||||
},
|
||||
{
|
||||
accountNumber: controlAccount,
|
||||
credit: fullAmount,
|
||||
description: `Customer payment ${payment.endToEndId || payment.paymentId}`,
|
||||
postingKey: 3 // Payment with VAT
|
||||
}
|
||||
);
|
||||
|
||||
// Book skonto if granted
|
||||
if (skontoAmount > 0) {
|
||||
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
||||
lines.push({
|
||||
accountNumber: skontoAccounts.skontoAccount,
|
||||
debit: skontoAmount,
|
||||
description: `Skonto granted`,
|
||||
postingKey: 40 // Tax-free for skonto
|
||||
});
|
||||
|
||||
// VAT correction for skonto
|
||||
if (rules.skontoMethod === 'gross') {
|
||||
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
||||
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
|
||||
lines.push(
|
||||
{
|
||||
accountNumber: skontoAccounts.vatCorrectionAccount,
|
||||
debit: vatCorrection,
|
||||
description: `Skonto VAT correction`,
|
||||
postingKey: 40 // Tax-free for correction
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create journal entry for payment
|
||||
const journalData: IJournalEntry = {
|
||||
date: payment.paymentDate,
|
||||
description: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
reference: payment.endToEndId || payment.remittanceInfo || payment.paymentId,
|
||||
lines,
|
||||
skrType: this.skrType
|
||||
};
|
||||
|
||||
const journalEntry = new JournalEntry(journalData);
|
||||
await journalEntry.validate();
|
||||
await journalEntry.post();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
journalEntry,
|
||||
confidence: 100
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to book payment: ${error}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
confidence: 0,
|
||||
errors: [`Payment booking failed: ${errorMessage}`]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate invoice before booking
|
||||
*/
|
||||
private validateInvoice(invoice: IInvoice): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check required fields
|
||||
if (!invoice.invoiceNumber) {
|
||||
errors.push('Invoice number is required');
|
||||
}
|
||||
|
||||
if (!invoice.issueDate) {
|
||||
errors.push('Issue date is required');
|
||||
}
|
||||
|
||||
if (!invoice.supplier || !invoice.supplier.name) {
|
||||
errors.push('Supplier information is required');
|
||||
}
|
||||
|
||||
if (!invoice.customer || !invoice.customer.name) {
|
||||
errors.push('Customer information is required');
|
||||
}
|
||||
|
||||
if (invoice.lines.length === 0) {
|
||||
errors.push('Invoice must have at least one line item');
|
||||
}
|
||||
|
||||
// Validate amounts
|
||||
const calculatedNet = invoice.lines.reduce((sum, line) => sum + line.netAmount, 0);
|
||||
const tolerance = 0.01;
|
||||
|
||||
if (Math.abs(calculatedNet - invoice.lineNetAmount) > tolerance) {
|
||||
errors.push(`Line net amount mismatch: calculated ${calculatedNet}, stated ${invoice.lineNetAmount}`);
|
||||
}
|
||||
|
||||
// Validate VAT
|
||||
const calculatedVAT = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxAmount, 0);
|
||||
if (Math.abs(calculatedVAT - invoice.totalVATAmount) > tolerance) {
|
||||
errors.push(`VAT amount mismatch: calculated ${calculatedVAT}, stated ${invoice.totalVATAmount}`);
|
||||
}
|
||||
|
||||
// Validate total
|
||||
const calculatedTotal = invoice.taxExclusiveAmount + invoice.totalVATAmount;
|
||||
if (Math.abs(calculatedTotal - invoice.taxInclusiveAmount) > tolerance) {
|
||||
errors.push(`Total amount mismatch: calculated ${calculatedTotal}, stated ${invoice.taxInclusiveAmount}`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate warnings for the booking
|
||||
*/
|
||||
private generateWarnings(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Warn about default account usage
|
||||
const hasDefaultAccounts = invoice.lines.some(line =>
|
||||
!line.accountNumber && !line.productCode
|
||||
);
|
||||
if (hasDefaultAccounts) {
|
||||
warnings.push('Some lines are using default expense/revenue accounts');
|
||||
}
|
||||
|
||||
// Warn about mixed VAT rates
|
||||
if (invoice.vatBreakdown.length > 1) {
|
||||
warnings.push('Invoice contains mixed VAT rates');
|
||||
}
|
||||
|
||||
// Warn about reverse charge
|
||||
if (invoice.taxScenario === 'reverse_charge') {
|
||||
warnings.push('Reverse charge procedure applied - verify VAT treatment');
|
||||
}
|
||||
|
||||
// Warn about credit notes
|
||||
if (invoice.invoiceTypeCode === '381') {
|
||||
warnings.push('This is a credit note - amounts will be reversed');
|
||||
}
|
||||
|
||||
// Warn about foreign currency
|
||||
if (invoice.currencyCode !== 'EUR') {
|
||||
warnings.push(`Invoice is in foreign currency: ${invoice.currencyCode}`);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build description for journal entry
|
||||
*/
|
||||
private buildDescription(invoice: IInvoice): string {
|
||||
const type = invoice.invoiceTypeCode === '381' ? 'Credit Note' : 'Invoice';
|
||||
const party = invoice.direction === 'inbound'
|
||||
? invoice.supplier.name
|
||||
: invoice.customer.name;
|
||||
|
||||
return `${type} ${invoice.invoiceNumber} - ${party}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account description for a group of lines
|
||||
*/
|
||||
private getAccountDescription(accountNumber: string, lines: IInvoiceLine[]): string {
|
||||
if (lines.length === 1) {
|
||||
return lines[0].description;
|
||||
}
|
||||
|
||||
return `${this.mapper.getAccountDescription(accountNumber)} (${lines.length} items)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get used expense accounts
|
||||
*/
|
||||
private getUsedExpenseAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||
if (invoice.direction !== 'inbound') return [];
|
||||
|
||||
const accounts = new Set<string>();
|
||||
for (const line of invoice.lines) {
|
||||
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
||||
accounts.add(account);
|
||||
}
|
||||
return Array.from(accounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get used revenue accounts
|
||||
*/
|
||||
private getUsedRevenueAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||
if (invoice.direction !== 'outbound') return [];
|
||||
|
||||
const accounts = new Set<string>();
|
||||
for (const line of invoice.lines) {
|
||||
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
||||
accounts.add(account);
|
||||
}
|
||||
return Array.from(accounts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get used VAT accounts
|
||||
*/
|
||||
private getUsedVATAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
||||
const accounts = new Set<string>();
|
||||
const direction = invoice.direction === 'inbound' ? 'input' : 'output';
|
||||
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
||||
|
||||
for (const vatBreak of invoice.vatBreakdown) {
|
||||
const account = this.mapper.getVATAccount(
|
||||
vatBreak.vatCategory,
|
||||
direction,
|
||||
taxScenario
|
||||
);
|
||||
accounts.add(account);
|
||||
}
|
||||
|
||||
// Add reverse charge accounts if applicable
|
||||
if (taxScenario === 'reverse_charge') {
|
||||
for (const vatBreak of invoice.vatBreakdown) {
|
||||
const inputAccount = this.mapper.getVATAccount(
|
||||
vatBreak.vatCategory,
|
||||
'input',
|
||||
'reverse_charge'
|
||||
);
|
||||
const outputAccount = this.mapper.getVATAccount(
|
||||
vatBreak.vatCategory,
|
||||
'output',
|
||||
'reverse_charge'
|
||||
);
|
||||
accounts.add(inputAccount);
|
||||
accounts.add(outputAccount);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(accounts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { TSKRType } from './skr.types.js';
|
||||
|
||||
/**
|
||||
* Invoice direction
|
||||
*/
|
||||
export type TInvoiceDirection = 'inbound' | 'outbound';
|
||||
|
||||
/**
|
||||
* Supported e-invoice formats
|
||||
*/
|
||||
export type TInvoiceFormat = 'xrechnung' | 'zugferd' | 'facturx' | 'peppol' | 'ubl';
|
||||
|
||||
/**
|
||||
* Invoice status in the system
|
||||
*/
|
||||
export type TInvoiceStatus = 'draft' | 'validated' | 'posted' | 'partially_paid' | 'paid' | 'cancelled' | 'error';
|
||||
|
||||
/**
|
||||
* Tax scenario classification
|
||||
*/
|
||||
export type TTaxScenario =
|
||||
| 'domestic_taxed' // Standard domestic with VAT
|
||||
| 'domestic_exempt' // Domestic tax-exempt
|
||||
| 'reverse_charge' // §13b UStG
|
||||
| 'intra_eu_supply' // Intra-EU supply
|
||||
| 'intra_eu_acquisition' // Intra-EU acquisition
|
||||
| 'export' // Export outside EU
|
||||
| 'small_business'; // §19 UStG small business
|
||||
|
||||
/**
|
||||
* VAT rate categories
|
||||
*/
|
||||
export interface IVATCategory {
|
||||
code: string; // S (Standard), Z (Zero), E (Exempt), AE (Reverse charge), etc.
|
||||
rate: number; // Tax rate percentage
|
||||
exemptionReason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Party information (supplier/customer)
|
||||
*/
|
||||
export interface IInvoiceParty {
|
||||
id: string;
|
||||
name: string;
|
||||
address: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
countryCode: string;
|
||||
};
|
||||
vatId?: string;
|
||||
taxId?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
bankAccount?: {
|
||||
iban: string;
|
||||
bic?: string;
|
||||
accountHolder?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice line item
|
||||
*/
|
||||
export interface IInvoiceLine {
|
||||
lineNumber: number;
|
||||
description: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
netAmount: number;
|
||||
vatCategory: IVATCategory;
|
||||
vatAmount: number;
|
||||
grossAmount: number;
|
||||
accountNumber?: string; // SKR account for booking
|
||||
costCenter?: string;
|
||||
productCode?: string;
|
||||
allowances?: IAllowanceCharge[];
|
||||
charges?: IAllowanceCharge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowance or charge
|
||||
*/
|
||||
export interface IAllowanceCharge {
|
||||
reason: string;
|
||||
amount: number;
|
||||
percentage?: number;
|
||||
vatCategory?: IVATCategory;
|
||||
vatAmount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment terms
|
||||
*/
|
||||
export interface IPaymentTerms {
|
||||
dueDate: Date;
|
||||
paymentTermsNote?: string;
|
||||
skonto?: {
|
||||
percentage: number;
|
||||
days: number;
|
||||
baseAmount: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result
|
||||
*/
|
||||
export interface IValidationResult {
|
||||
isValid: boolean;
|
||||
syntax: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
semantic: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
businessRules: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
countrySpecific?: {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
validatedAt: Date;
|
||||
validatorVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking information
|
||||
*/
|
||||
export interface IBookingInfo {
|
||||
journalEntryId: string;
|
||||
transactionIds: string[];
|
||||
bookedAt: Date;
|
||||
bookedBy: string;
|
||||
bookingRules: {
|
||||
vendorAccount?: string;
|
||||
customerAccount?: string;
|
||||
expenseAccounts?: string[];
|
||||
revenueAccounts?: string[];
|
||||
vatAccounts?: string[];
|
||||
};
|
||||
confidence: number; // 0-100
|
||||
autoBooked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment information
|
||||
*/
|
||||
export interface IPaymentInfo {
|
||||
paymentId: string;
|
||||
paymentDate: Date;
|
||||
amount: number;
|
||||
currency: string;
|
||||
bankTransactionId?: string;
|
||||
endToEndId?: string;
|
||||
remittanceInfo?: string;
|
||||
skontoTaken?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main invoice entity
|
||||
*/
|
||||
export interface IInvoice {
|
||||
// Identity
|
||||
id: string;
|
||||
direction: TInvoiceDirection;
|
||||
format: TInvoiceFormat;
|
||||
|
||||
// EN16931 Business Terms
|
||||
invoiceNumber: string; // BT-1
|
||||
issueDate: Date; // BT-2
|
||||
invoiceTypeCode?: string; // BT-3 (380=Invoice, 381=Credit note)
|
||||
currencyCode: string; // BT-5
|
||||
taxCurrencyCode?: string; // BT-6
|
||||
taxPointDate?: Date; // BT-7 (Leistungsdatum)
|
||||
paymentDueDate?: Date; // BT-9
|
||||
buyerReference?: string; // BT-10
|
||||
projectReference?: string; // BT-11
|
||||
contractReference?: string; // BT-12
|
||||
orderReference?: string; // BT-13
|
||||
sellerOrderReference?: string; // BT-14
|
||||
|
||||
// Parties
|
||||
supplier: IInvoiceParty;
|
||||
customer: IInvoiceParty;
|
||||
payee?: IInvoiceParty; // If different from supplier
|
||||
|
||||
// Line items
|
||||
lines: IInvoiceLine[];
|
||||
|
||||
// Document level allowances/charges
|
||||
allowances?: IAllowanceCharge[];
|
||||
charges?: IAllowanceCharge[];
|
||||
|
||||
// Amounts
|
||||
lineNetAmount: number; // Sum of line net amounts
|
||||
allowanceTotalAmount?: number;
|
||||
chargeTotalAmount?: number;
|
||||
taxExclusiveAmount: number; // BT-109
|
||||
taxInclusiveAmount: number; // BT-112
|
||||
prepaidAmount?: number; // BT-113
|
||||
payableAmount: number; // BT-115
|
||||
|
||||
// VAT breakdown
|
||||
vatBreakdown: {
|
||||
vatCategory: IVATCategory;
|
||||
taxableAmount: number; // BT-116
|
||||
taxAmount: number; // BT-117
|
||||
}[];
|
||||
totalVATAmount: number; // BT-110
|
||||
|
||||
// Payment
|
||||
paymentTerms?: IPaymentTerms;
|
||||
paymentMeans?: {
|
||||
code: string; // 30=Bank transfer, 48=Card, etc.
|
||||
account?: IInvoiceParty['bankAccount'];
|
||||
};
|
||||
payments?: IPaymentInfo[];
|
||||
|
||||
// Notes
|
||||
invoiceNote?: string; // BT-22
|
||||
|
||||
// Processing metadata
|
||||
status: TInvoiceStatus;
|
||||
taxScenario?: TTaxScenario;
|
||||
skrType?: TSKRType;
|
||||
|
||||
// Storage
|
||||
contentHash: string; // SHA-256 of normalized XML
|
||||
xmlContent?: string;
|
||||
pdfHash?: string;
|
||||
pdfContent?: Buffer;
|
||||
|
||||
// Validation
|
||||
validationResult?: IValidationResult;
|
||||
|
||||
// Booking
|
||||
bookingInfo?: IBookingInfo;
|
||||
|
||||
// Audit trail
|
||||
createdAt: Date;
|
||||
createdBy: string;
|
||||
modifiedAt?: Date;
|
||||
modifiedBy?: string;
|
||||
|
||||
// Additional metadata
|
||||
metadata?: {
|
||||
importSource?: string;
|
||||
importedAt?: Date;
|
||||
parserVersion?: string;
|
||||
originalFilename?: string;
|
||||
originalFormat?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice import options
|
||||
*/
|
||||
export interface IInvoiceImportOptions {
|
||||
autoBook?: boolean;
|
||||
confidenceThreshold?: number;
|
||||
validateOnly?: boolean;
|
||||
skipDuplicateCheck?: boolean;
|
||||
bookingRules?: {
|
||||
vendorDefaults?: Record<string, string>;
|
||||
customerDefaults?: Record<string, string>;
|
||||
productCategoryMapping?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice export options
|
||||
*/
|
||||
export interface IInvoiceExportOptions {
|
||||
format: TInvoiceFormat;
|
||||
embedInPdf?: boolean;
|
||||
sign?: boolean;
|
||||
validate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice search filter
|
||||
*/
|
||||
export interface IInvoiceFilter {
|
||||
direction?: TInvoiceDirection;
|
||||
status?: TInvoiceStatus;
|
||||
format?: TInvoiceFormat;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
supplierId?: string;
|
||||
customerId?: string;
|
||||
minAmount?: number;
|
||||
maxAmount?: number;
|
||||
invoiceNumber?: string;
|
||||
reference?: string;
|
||||
isPaid?: boolean;
|
||||
isOverdue?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate check result
|
||||
*/
|
||||
export interface IDuplicateCheckResult {
|
||||
isDuplicate: boolean;
|
||||
matchedInvoiceId?: string;
|
||||
matchedContentHash?: string;
|
||||
matchedFields?: string[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking rules configuration
|
||||
*/
|
||||
export interface IBookingRules {
|
||||
skrType: TSKRType;
|
||||
|
||||
// Control accounts
|
||||
vendorControlAccount: string;
|
||||
customerControlAccount: string;
|
||||
|
||||
// VAT accounts
|
||||
vatAccounts: {
|
||||
inputVAT19: string;
|
||||
inputVAT7: string;
|
||||
outputVAT19: string;
|
||||
outputVAT7: string;
|
||||
reverseChargeVAT: string;
|
||||
};
|
||||
|
||||
// Default accounts
|
||||
defaultExpenseAccount: string;
|
||||
defaultRevenueAccount: string;
|
||||
|
||||
// Mappings
|
||||
productCategoryMapping?: Record<string, string>;
|
||||
vendorMapping?: Record<string, string>;
|
||||
customerMapping?: Record<string, string>;
|
||||
|
||||
// Skonto
|
||||
skontoMethod?: 'net' | 'gross';
|
||||
skontoExpenseAccount?: string;
|
||||
skontoRevenueAccount?: string;
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import type { TSKRType } from './skr.types.js';
|
||||
import type {
|
||||
IInvoice,
|
||||
IInvoiceLine,
|
||||
IBookingRules,
|
||||
TTaxScenario,
|
||||
IVATCategory
|
||||
} from './skr.invoice.entity.js';
|
||||
|
||||
/**
|
||||
* Maps invoice data to SKR accounts
|
||||
* Handles both SKR03 and SKR04 account mappings
|
||||
*/
|
||||
export class SKRInvoiceMapper {
|
||||
private logger: plugins.smartlog.ConsoleLog;
|
||||
private skrType: TSKRType;
|
||||
|
||||
// SKR03 account mappings
|
||||
private readonly SKR03_ACCOUNTS = {
|
||||
// Control accounts
|
||||
vendorControl: '1600', // Verbindlichkeiten aus Lieferungen und Leistungen
|
||||
customerControl: '1200', // Forderungen aus Lieferungen und Leistungen
|
||||
|
||||
// VAT accounts
|
||||
inputVAT19: '1576', // Abziehbare Vorsteuer 19%
|
||||
inputVAT7: '1571', // Abziehbare Vorsteuer 7%
|
||||
outputVAT19: '1776', // Umsatzsteuer 19%
|
||||
outputVAT7: '1771', // Umsatzsteuer 7%
|
||||
reverseChargeVAT: '1577', // Abziehbare Vorsteuer §13b UStG
|
||||
reverseChargePayable: '1787', // Umsatzsteuer §13b UStG
|
||||
|
||||
// Default expense/revenue accounts
|
||||
defaultExpense: '4610', // Werbekosten
|
||||
defaultRevenue: '8400', // Erlöse 19% USt
|
||||
revenueReduced: '8300', // Erlöse 7% USt
|
||||
revenueTaxFree: '8120', // Steuerfreie Umsätze
|
||||
|
||||
// Common expense accounts by category
|
||||
materialExpense: '5000', // Aufwendungen für Roh-, Hilfs- und Betriebsstoffe
|
||||
merchandiseExpense: '5400', // Aufwendungen für Waren
|
||||
personnelExpense: '6000', // Löhne und Gehälter
|
||||
rentExpense: '4200', // Miete
|
||||
officeExpense: '4930', // Bürobedarf
|
||||
travelExpense: '4670', // Reisekosten
|
||||
vehicleExpense: '4530', // Kfz-Kosten
|
||||
|
||||
// Skonto accounts
|
||||
skontoExpense: '4736', // Erhaltene Skonti 19% USt
|
||||
skontoRevenue: '8736', // Gewährte Skonti 19% USt
|
||||
|
||||
// Intra-EU accounts
|
||||
intraEUAcquisition: '8125', // Steuerfreie innergemeinschaftliche Erwerbe
|
||||
intraEUSupply: '8125' // Steuerfreie innergemeinschaftliche Lieferungen
|
||||
};
|
||||
|
||||
// SKR04 account mappings
|
||||
private readonly SKR04_ACCOUNTS = {
|
||||
// Control accounts
|
||||
vendorControl: '3300', // Verbindlichkeiten aus Lieferungen und Leistungen
|
||||
customerControl: '1400', // Forderungen aus Lieferungen und Leistungen
|
||||
|
||||
// VAT accounts
|
||||
inputVAT19: '1406', // Abziehbare Vorsteuer 19%
|
||||
inputVAT7: '1401', // Abziehbare Vorsteuer 7%
|
||||
outputVAT19: '3806', // Umsatzsteuer 19%
|
||||
outputVAT7: '3801', // Umsatzsteuer 7%
|
||||
reverseChargeVAT: '1407', // Abziehbare Vorsteuer §13b UStG
|
||||
reverseChargePayable: '3837', // Umsatzsteuer §13b UStG
|
||||
|
||||
// Default expense/revenue accounts
|
||||
defaultExpense: '6300', // Sonstige betriebliche Aufwendungen
|
||||
defaultRevenue: '4400', // Erlöse 19% USt
|
||||
revenueReduced: '4300', // Erlöse 7% USt
|
||||
revenueTaxFree: '4120', // Steuerfreie Umsätze
|
||||
|
||||
// Common expense accounts by category
|
||||
materialExpense: '5000', // Aufwendungen für Roh-, Hilfs- und Betriebsstoffe
|
||||
merchandiseExpense: '5400', // Aufwendungen für Waren
|
||||
personnelExpense: '6000', // Löhne
|
||||
rentExpense: '6310', // Miete
|
||||
officeExpense: '6815', // Bürobedarf
|
||||
travelExpense: '6670', // Reisekosten
|
||||
vehicleExpense: '6530', // Kfz-Kosten
|
||||
|
||||
// Skonto accounts
|
||||
skontoExpense: '4736', // Erhaltene Skonti 19% USt
|
||||
skontoRevenue: '8736', // Gewährte Skonti 19% USt
|
||||
|
||||
// Intra-EU accounts
|
||||
intraEUAcquisition: '4125', // Steuerfreie innergemeinschaftliche Erwerbe
|
||||
intraEUSupply: '4125' // Steuerfreie innergemeinschaftliche Lieferungen
|
||||
};
|
||||
|
||||
// Product category to account mappings
|
||||
private readonly CATEGORY_MAPPINGS: Record<string, { skr03: string; skr04: string }> = {
|
||||
'MATERIAL': { skr03: '5000', skr04: '5000' },
|
||||
'MERCHANDISE': { skr03: '5400', skr04: '5400' },
|
||||
'SERVICE': { skr03: '4610', skr04: '6300' },
|
||||
'OFFICE': { skr03: '4930', skr04: '6815' },
|
||||
'IT': { skr03: '4940', skr04: '6825' },
|
||||
'TRAVEL': { skr03: '4670', skr04: '6670' },
|
||||
'VEHICLE': { skr03: '4530', skr04: '6530' },
|
||||
'RENT': { skr03: '4200', skr04: '6310' },
|
||||
'UTILITIES': { skr03: '4240', skr04: '6320' },
|
||||
'INSURANCE': { skr03: '4360', skr04: '6420' },
|
||||
'MARKETING': { skr03: '4610', skr04: '6600' },
|
||||
'CONSULTING': { skr03: '4640', skr04: '6650' },
|
||||
'LEGAL': { skr03: '4790', skr04: '6790' },
|
||||
'TELECOMMUNICATION': { skr03: '4920', skr04: '6805' }
|
||||
};
|
||||
|
||||
constructor(skrType: TSKRType) {
|
||||
this.skrType = skrType;
|
||||
this.logger = new plugins.smartlog.ConsoleLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account mappings for current SKR type
|
||||
*/
|
||||
private getAccounts() {
|
||||
return this.skrType === 'SKR03' ? this.SKR03_ACCOUNTS : this.SKR04_ACCOUNTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map invoice to booking rules
|
||||
*/
|
||||
public mapInvoiceToSKR(
|
||||
invoice: IInvoice,
|
||||
customMappings?: Partial<IBookingRules>
|
||||
): IBookingRules {
|
||||
const accounts = this.getAccounts();
|
||||
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
||||
|
||||
// Base booking rules
|
||||
const bookingRules: IBookingRules = {
|
||||
skrType: this.skrType,
|
||||
|
||||
// Control accounts
|
||||
vendorControlAccount: customMappings?.vendorControlAccount || accounts.vendorControl,
|
||||
customerControlAccount: customMappings?.customerControlAccount || accounts.customerControl,
|
||||
|
||||
// VAT accounts
|
||||
vatAccounts: {
|
||||
inputVAT19: accounts.inputVAT19,
|
||||
inputVAT7: accounts.inputVAT7,
|
||||
outputVAT19: accounts.outputVAT19,
|
||||
outputVAT7: accounts.outputVAT7,
|
||||
reverseChargeVAT: accounts.reverseChargeVAT
|
||||
},
|
||||
|
||||
// Default accounts
|
||||
defaultExpenseAccount: accounts.defaultExpense,
|
||||
defaultRevenueAccount: accounts.defaultRevenue,
|
||||
|
||||
// Skonto
|
||||
skontoMethod: customMappings?.skontoMethod || 'gross',
|
||||
skontoExpenseAccount: accounts.skontoExpense,
|
||||
skontoRevenueAccount: accounts.skontoRevenue,
|
||||
|
||||
// Custom mappings
|
||||
productCategoryMapping: customMappings?.productCategoryMapping || {},
|
||||
vendorMapping: customMappings?.vendorMapping || {},
|
||||
customerMapping: customMappings?.customerMapping || {}
|
||||
};
|
||||
|
||||
return bookingRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map invoice line to SKR account
|
||||
*/
|
||||
public mapInvoiceLineToAccount(
|
||||
line: IInvoiceLine,
|
||||
invoice: IInvoice,
|
||||
bookingRules: IBookingRules
|
||||
): string {
|
||||
// Check if account is already specified
|
||||
if (line.accountNumber) {
|
||||
return line.accountNumber;
|
||||
}
|
||||
|
||||
// For revenue (outbound invoices)
|
||||
if (invoice.direction === 'outbound') {
|
||||
return this.mapRevenueAccount(line, invoice, bookingRules);
|
||||
}
|
||||
|
||||
// For expenses (inbound invoices)
|
||||
return this.mapExpenseAccount(line, invoice, bookingRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map revenue account based on VAT rate and scenario
|
||||
*/
|
||||
private mapRevenueAccount(
|
||||
line: IInvoiceLine,
|
||||
invoice: IInvoice,
|
||||
bookingRules: IBookingRules
|
||||
): string {
|
||||
const accounts = this.getAccounts();
|
||||
const vatRate = line.vatCategory.rate;
|
||||
|
||||
// Check tax scenario
|
||||
switch (invoice.taxScenario) {
|
||||
case 'intra_eu_supply':
|
||||
return accounts.intraEUSupply;
|
||||
case 'export':
|
||||
case 'domestic_exempt':
|
||||
return accounts.revenueTaxFree;
|
||||
case 'domestic_taxed':
|
||||
default:
|
||||
// Map by VAT rate
|
||||
if (vatRate === 19) {
|
||||
return accounts.defaultRevenue;
|
||||
} else if (vatRate === 7) {
|
||||
return accounts.revenueReduced;
|
||||
} else if (vatRate === 0) {
|
||||
return accounts.revenueTaxFree;
|
||||
}
|
||||
return accounts.defaultRevenue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map expense account based on product category and vendor
|
||||
*/
|
||||
private mapExpenseAccount(
|
||||
line: IInvoiceLine,
|
||||
invoice: IInvoice,
|
||||
bookingRules: IBookingRules
|
||||
): string {
|
||||
const accounts = this.getAccounts();
|
||||
|
||||
// Check vendor-specific mapping
|
||||
const vendorId = invoice.supplier.id;
|
||||
if (bookingRules.vendorMapping && bookingRules.vendorMapping[vendorId]) {
|
||||
return bookingRules.vendorMapping[vendorId];
|
||||
}
|
||||
|
||||
// Try to determine category from line description
|
||||
const category = this.detectProductCategory(line.description);
|
||||
if (category) {
|
||||
const mapping = this.CATEGORY_MAPPINGS[category];
|
||||
if (mapping) {
|
||||
return this.skrType === 'SKR03' ? mapping.skr03 : mapping.skr04;
|
||||
}
|
||||
}
|
||||
|
||||
// Check product category mapping
|
||||
if (line.productCode && bookingRules.productCategoryMapping) {
|
||||
const mappedAccount = bookingRules.productCategoryMapping[line.productCode];
|
||||
if (mappedAccount) {
|
||||
return mappedAccount;
|
||||
}
|
||||
}
|
||||
|
||||
// Default expense account
|
||||
return bookingRules.defaultExpenseAccount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect product category from description
|
||||
*/
|
||||
private detectProductCategory(description: string): string | undefined {
|
||||
const lowerDesc = description.toLowerCase();
|
||||
|
||||
const categoryKeywords: Record<string, string[]> = {
|
||||
'MATERIAL': ['material', 'rohstoff', 'raw material', 'component'],
|
||||
'MERCHANDISE': ['ware', 'merchandise', 'product', 'artikel'],
|
||||
'SERVICE': ['service', 'dienstleistung', 'beratung', 'support'],
|
||||
'OFFICE': ['büro', 'office', 'papier', 'stationery'],
|
||||
'IT': ['software', 'hardware', 'computer', 'lizenz', 'license'],
|
||||
'TRAVEL': ['reise', 'travel', 'hotel', 'flug', 'flight'],
|
||||
'VEHICLE': ['kfz', 'vehicle', 'auto', 'benzin', 'fuel'],
|
||||
'RENT': ['miete', 'rent', 'lease', 'pacht'],
|
||||
'UTILITIES': ['strom', 'wasser', 'gas', 'energie', 'electricity', 'water'],
|
||||
'INSURANCE': ['versicherung', 'insurance'],
|
||||
'MARKETING': ['werbung', 'marketing', 'advertising', 'kampagne'],
|
||||
'CONSULTING': ['beratung', 'consulting', 'advisory'],
|
||||
'LEGAL': ['rechts', 'legal', 'anwalt', 'lawyer', 'notar'],
|
||||
'TELECOMMUNICATION': ['telefon', 'internet', 'mobilfunk', 'telekom']
|
||||
};
|
||||
|
||||
for (const [category, keywords] of Object.entries(categoryKeywords)) {
|
||||
if (keywords.some(keyword => lowerDesc.includes(keyword))) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAT account for given VAT category and rate
|
||||
*/
|
||||
public getVATAccount(
|
||||
vatCategory: IVATCategory,
|
||||
direction: 'input' | 'output',
|
||||
taxScenario: TTaxScenario
|
||||
): string {
|
||||
const accounts = this.getAccounts();
|
||||
|
||||
// Handle reverse charge
|
||||
if (taxScenario === 'reverse_charge' || vatCategory.code === 'AE') {
|
||||
return direction === 'input'
|
||||
? accounts.reverseChargeVAT
|
||||
: accounts.reverseChargePayable;
|
||||
}
|
||||
|
||||
// Standard VAT accounts by rate
|
||||
if (direction === 'input') {
|
||||
if (vatCategory.rate === 19) {
|
||||
return accounts.inputVAT19;
|
||||
} else if (vatCategory.rate === 7) {
|
||||
return accounts.inputVAT7;
|
||||
}
|
||||
} else {
|
||||
if (vatCategory.rate === 19) {
|
||||
return accounts.outputVAT19;
|
||||
} else if (vatCategory.rate === 7) {
|
||||
return accounts.outputVAT7;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 19% if rate is not standard
|
||||
return direction === 'input' ? accounts.inputVAT19 : accounts.outputVAT19;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get control account for party
|
||||
*/
|
||||
public getControlAccount(
|
||||
invoice: IInvoice,
|
||||
bookingRules: IBookingRules
|
||||
): string {
|
||||
if (invoice.direction === 'inbound') {
|
||||
// Check vendor-specific control account
|
||||
const vendorId = invoice.supplier.id;
|
||||
if (bookingRules.vendorMapping && bookingRules.vendorMapping[vendorId]) {
|
||||
const customAccount = bookingRules.vendorMapping[vendorId];
|
||||
// Check if it's a control account (starts with 16 for SKR03 or 33 for SKR04)
|
||||
if (this.isControlAccount(customAccount)) {
|
||||
return customAccount;
|
||||
}
|
||||
}
|
||||
return bookingRules.vendorControlAccount;
|
||||
} else {
|
||||
// Check customer-specific control account
|
||||
const customerId = invoice.customer.id;
|
||||
if (bookingRules.customerMapping && bookingRules.customerMapping[customerId]) {
|
||||
const customAccount = bookingRules.customerMapping[customerId];
|
||||
// Check if it's a control account (starts with 12 for SKR03 or 14 for SKR04)
|
||||
if (this.isControlAccount(customAccount)) {
|
||||
return customAccount;
|
||||
}
|
||||
}
|
||||
return bookingRules.customerControlAccount;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is a control account
|
||||
*/
|
||||
private isControlAccount(accountNumber: string): boolean {
|
||||
if (this.skrType === 'SKR03') {
|
||||
return accountNumber.startsWith('12') || accountNumber.startsWith('16');
|
||||
} else {
|
||||
return accountNumber.startsWith('14') || accountNumber.startsWith('33');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skonto accounts
|
||||
*/
|
||||
public getSkontoAccounts(invoice: IInvoice): {
|
||||
skontoAccount: string;
|
||||
vatCorrectionAccount: string;
|
||||
} {
|
||||
const accounts = this.getAccounts();
|
||||
|
||||
if (invoice.direction === 'inbound') {
|
||||
// Received skonto (expense reduction)
|
||||
return {
|
||||
skontoAccount: accounts.skontoExpense,
|
||||
vatCorrectionAccount: accounts.inputVAT19 // VAT correction
|
||||
};
|
||||
} else {
|
||||
// Granted skonto (revenue reduction)
|
||||
return {
|
||||
skontoAccount: accounts.skontoRevenue,
|
||||
vatCorrectionAccount: accounts.outputVAT19 // VAT correction
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account number format
|
||||
*/
|
||||
public validateAccountNumber(accountNumber: string): boolean {
|
||||
// SKR accounts are typically 4 digits, sometimes with sub-accounts
|
||||
const accountPattern = /^\d{4}(\d{0,2})?$/;
|
||||
return accountPattern.test(accountNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account description
|
||||
*/
|
||||
public getAccountDescription(accountNumber: string): string {
|
||||
// This would typically look up from a complete SKR account database
|
||||
// For now, return a basic description
|
||||
const commonAccounts: Record<string, string> = {
|
||||
// SKR03
|
||||
'1200': 'Forderungen aus Lieferungen und Leistungen',
|
||||
'1600': 'Verbindlichkeiten aus Lieferungen und Leistungen',
|
||||
'1576': 'Abziehbare Vorsteuer 19%',
|
||||
'1571': 'Abziehbare Vorsteuer 7%',
|
||||
'1776': 'Umsatzsteuer 19%',
|
||||
'1771': 'Umsatzsteuer 7%',
|
||||
'4610': 'Werbekosten',
|
||||
'8400': 'Erlöse 19% USt',
|
||||
'8300': 'Erlöse 7% USt',
|
||||
// SKR04
|
||||
'1400': 'Forderungen aus Lieferungen und Leistungen',
|
||||
'3300': 'Verbindlichkeiten aus Lieferungen und Leistungen',
|
||||
'1406': 'Abziehbare Vorsteuer 19%',
|
||||
'1401': 'Abziehbare Vorsteuer 7%',
|
||||
'3806': 'Umsatzsteuer 19%',
|
||||
'3801': 'Umsatzsteuer 7%',
|
||||
'6300': 'Sonstige betriebliche Aufwendungen',
|
||||
'4400': 'Erlöse 19% USt',
|
||||
'4300': 'Erlöse 7% USt'
|
||||
};
|
||||
|
||||
return commonAccounts[accountNumber] || `Account ${accountNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate booking confidence score
|
||||
*/
|
||||
public calculateConfidence(
|
||||
invoice: IInvoice,
|
||||
bookingRules: IBookingRules
|
||||
): number {
|
||||
let confidence = 100;
|
||||
|
||||
// Reduce confidence for missing or uncertain mappings
|
||||
invoice.lines.forEach(line => {
|
||||
if (!line.accountNumber) {
|
||||
confidence -= 10; // No explicit account mapping
|
||||
}
|
||||
|
||||
if (!line.productCode) {
|
||||
confidence -= 5; // No product code for mapping
|
||||
}
|
||||
});
|
||||
|
||||
// Reduce confidence for complex tax scenarios
|
||||
if (invoice.taxScenario === 'reverse_charge' ||
|
||||
invoice.taxScenario === 'intra_eu_acquisition') {
|
||||
confidence -= 15;
|
||||
}
|
||||
|
||||
// Reduce confidence for mixed VAT rates
|
||||
if (invoice.vatBreakdown.length > 1) {
|
||||
confidence -= 10;
|
||||
}
|
||||
|
||||
// Reduce confidence if no vendor/customer mapping exists
|
||||
if (invoice.direction === 'inbound') {
|
||||
if (!bookingRules.vendorMapping?.[invoice.supplier.id]) {
|
||||
confidence -= 10;
|
||||
}
|
||||
} else {
|
||||
if (!bookingRules.customerMapping?.[invoice.customer.id]) {
|
||||
confidence -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Reduce confidence for credit notes
|
||||
if (invoice.invoiceTypeCode === '381') {
|
||||
confidence -= 10;
|
||||
}
|
||||
|
||||
return Math.max(0, confidence);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import type {
|
||||
IInvoice,
|
||||
IInvoiceFilter,
|
||||
IDuplicateCheckResult
|
||||
} from './skr.invoice.entity.js';
|
||||
|
||||
/**
|
||||
* Invoice storage metadata
|
||||
*/
|
||||
export interface IInvoiceMetadata {
|
||||
invoiceId: string;
|
||||
invoiceNumber: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
issueDate: string;
|
||||
supplierName: string;
|
||||
customerName: string;
|
||||
totalAmount: number;
|
||||
currency: string;
|
||||
contentHash: string;
|
||||
pdfHash?: string;
|
||||
xmlHash: string;
|
||||
journalEntryId?: string;
|
||||
transactionIds?: string[];
|
||||
validationResult: {
|
||||
isValid: boolean;
|
||||
errors: number;
|
||||
warnings: number;
|
||||
};
|
||||
parserVersion: string;
|
||||
storedAt: string;
|
||||
storedBy: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice registry entry (for NDJSON streaming)
|
||||
*/
|
||||
export interface IInvoiceRegistryEntry {
|
||||
id: string;
|
||||
hash: string;
|
||||
metadata: IInvoiceMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage statistics
|
||||
*/
|
||||
export interface IStorageStats {
|
||||
totalInvoices: number;
|
||||
inboundCount: number;
|
||||
outboundCount: number;
|
||||
totalSize: number;
|
||||
duplicatesDetected: number;
|
||||
lastUpdate: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content-addressed storage for invoices
|
||||
* Integrates with BagIt archive structure for GoBD compliance
|
||||
*/
|
||||
export class InvoiceStorage {
|
||||
private exportPath: string;
|
||||
private logger: plugins.smartlog.ConsoleLog;
|
||||
private registryPath: string;
|
||||
private metadataCache: Map<string, IInvoiceMetadata>;
|
||||
private readonly MAX_CACHE_SIZE = 10000; // Maximum number of cached entries
|
||||
private cacheAccessOrder: string[] = []; // Track access order for LRU eviction
|
||||
|
||||
constructor(exportPath: string) {
|
||||
this.exportPath = exportPath;
|
||||
this.logger = new plugins.smartlog.ConsoleLog();
|
||||
this.registryPath = path.join(exportPath, 'data', 'documents', 'invoices', 'registry.ndjson');
|
||||
this.metadataCache = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage cache size using LRU eviction
|
||||
*/
|
||||
private manageCacheSize(): void {
|
||||
if (this.metadataCache.size > this.MAX_CACHE_SIZE) {
|
||||
// Remove least recently used entries
|
||||
const entriesToRemove = Math.min(100, Math.floor(this.MAX_CACHE_SIZE * 0.1)); // Remove 10% or 100 entries
|
||||
const keysToRemove = this.cacheAccessOrder.splice(0, entriesToRemove);
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
this.metadataCache.delete(key);
|
||||
}
|
||||
|
||||
this.logger.log('info', `Evicted ${entriesToRemove} entries from metadata cache`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache access order for LRU
|
||||
*/
|
||||
private touchCacheEntry(key: string): void {
|
||||
const index = this.cacheAccessOrder.indexOf(key);
|
||||
if (index > -1) {
|
||||
this.cacheAccessOrder.splice(index, 1);
|
||||
}
|
||||
this.cacheAccessOrder.push(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize storage directories
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
const dirs = [
|
||||
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
|
||||
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound', 'metadata'),
|
||||
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound'),
|
||||
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound', 'metadata'),
|
||||
path.join(this.exportPath, 'data', 'validation')
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
await plugins.smartfile.fs.ensureDir(dir);
|
||||
}
|
||||
|
||||
// Load existing registry if it exists
|
||||
await this.loadRegistry();
|
||||
}
|
||||
|
||||
private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max
|
||||
|
||||
/**
|
||||
* Store an invoice with content addressing
|
||||
*/
|
||||
public async storeInvoice(
|
||||
invoice: IInvoice,
|
||||
pdfBuffer?: Buffer
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Validate PDF size if provided
|
||||
if (pdfBuffer && pdfBuffer.length > this.MAX_PDF_SIZE) {
|
||||
throw new Error(`PDF file too large: ${pdfBuffer.length} bytes (max ${this.MAX_PDF_SIZE} bytes)`);
|
||||
}
|
||||
// Calculate hashes
|
||||
const xmlHash = await this.calculateHash(invoice.xmlContent || '');
|
||||
const pdfHash = pdfBuffer ? await this.calculateHash(pdfBuffer) : undefined;
|
||||
const contentHash = xmlHash; // Primary content hash is XML
|
||||
|
||||
// Check for duplicates
|
||||
const duplicateCheck = await this.checkDuplicate(invoice, contentHash);
|
||||
if (duplicateCheck.isDuplicate) {
|
||||
this.logger.log('warn', `Duplicate invoice detected: ${invoice.invoiceNumber}`);
|
||||
return duplicateCheck.matchedContentHash || contentHash;
|
||||
}
|
||||
|
||||
// Determine storage path
|
||||
const direction = invoice.direction;
|
||||
const basePath = path.join(
|
||||
this.exportPath,
|
||||
'data',
|
||||
'documents',
|
||||
'invoices',
|
||||
direction
|
||||
);
|
||||
|
||||
// Create filename with content hash
|
||||
const dateStr = invoice.issueDate.toISOString().split('T')[0];
|
||||
const sanitizedNumber = invoice.invoiceNumber.replace(/[^a-zA-Z0-9-_]/g, '_');
|
||||
const xmlFilename = `${contentHash.substring(0, 8)}_${dateStr}_${sanitizedNumber}.xml`;
|
||||
const xmlPath = path.join(basePath, xmlFilename);
|
||||
|
||||
// Store XML
|
||||
await plugins.smartfile.memory.toFs(invoice.xmlContent || '', xmlPath);
|
||||
|
||||
// Store PDF if available
|
||||
let pdfFilename: string | undefined;
|
||||
if (pdfBuffer) {
|
||||
pdfFilename = xmlFilename.replace('.xml', '.pdf');
|
||||
const pdfPath = path.join(basePath, pdfFilename);
|
||||
await plugins.smartfile.memory.toFs(pdfBuffer, pdfPath);
|
||||
|
||||
// Also store PDF/A-3 with embedded XML if supported
|
||||
if (invoice.format === 'zugferd' || invoice.format === 'facturx') {
|
||||
const pdfA3Filename = xmlFilename.replace('.xml', '_pdfa3.pdf');
|
||||
const pdfA3Path = path.join(basePath, pdfA3Filename);
|
||||
// The PDF should already have embedded XML if it's ZUGFeRD/Factur-X
|
||||
await plugins.smartfile.memory.toFs(pdfBuffer, pdfA3Path);
|
||||
}
|
||||
}
|
||||
|
||||
// Create and store metadata
|
||||
const metadata: IInvoiceMetadata = {
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
direction: invoice.direction,
|
||||
issueDate: invoice.issueDate.toISOString(),
|
||||
supplierName: invoice.supplier.name,
|
||||
customerName: invoice.customer.name,
|
||||
totalAmount: invoice.payableAmount,
|
||||
currency: invoice.currencyCode,
|
||||
contentHash,
|
||||
pdfHash,
|
||||
xmlHash,
|
||||
journalEntryId: invoice.bookingInfo?.journalEntryId,
|
||||
transactionIds: invoice.bookingInfo?.transactionIds,
|
||||
validationResult: {
|
||||
isValid: invoice.validationResult?.isValid || false,
|
||||
errors: this.countErrors(invoice.validationResult),
|
||||
warnings: this.countWarnings(invoice.validationResult)
|
||||
},
|
||||
parserVersion: invoice.metadata?.parserVersion || '5.1.4',
|
||||
storedAt: new Date().toISOString(),
|
||||
storedBy: invoice.createdBy
|
||||
};
|
||||
|
||||
const metadataPath = path.join(basePath, 'metadata', `${contentHash}.json`);
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(metadata, null, 2),
|
||||
metadataPath
|
||||
);
|
||||
|
||||
// Update registry
|
||||
await this.updateRegistry(invoice.id, contentHash, metadata);
|
||||
|
||||
// Cache metadata with LRU management
|
||||
this.setCacheEntry(contentHash, metadata);
|
||||
|
||||
this.logger.log('info', `Invoice stored: ${invoice.invoiceNumber} (${contentHash})`);
|
||||
|
||||
return contentHash;
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to store invoice: ${error}`);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invoice storage failed: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an invoice by content hash
|
||||
*/
|
||||
public async retrieveInvoice(contentHash: string): Promise<IInvoice | null> {
|
||||
try {
|
||||
// Check cache first
|
||||
const metadata = this.getCacheEntry(contentHash);
|
||||
if (!metadata) {
|
||||
this.logger.log('warn', `Invoice not found: ${contentHash}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load XML content
|
||||
const xmlPath = await this.findInvoiceFile(contentHash, '.xml');
|
||||
if (!xmlPath) {
|
||||
throw new Error(`XML file not found for invoice ${contentHash}`);
|
||||
}
|
||||
|
||||
const xmlContent = await plugins.smartfile.fs.toStringSync(xmlPath);
|
||||
|
||||
// Load PDF if exists
|
||||
let pdfContent: Buffer | undefined;
|
||||
const pdfPath = await this.findInvoiceFile(contentHash, '.pdf');
|
||||
if (pdfPath) {
|
||||
pdfContent = await plugins.smartfile.fs.toBuffer(pdfPath);
|
||||
}
|
||||
|
||||
// Reconstruct invoice object (partial)
|
||||
const invoice: Partial<IInvoice> = {
|
||||
id: metadata.invoiceId,
|
||||
invoiceNumber: metadata.invoiceNumber,
|
||||
direction: metadata.direction as any,
|
||||
issueDate: new Date(metadata.issueDate),
|
||||
supplier: {
|
||||
name: metadata.supplierName,
|
||||
id: '',
|
||||
address: { countryCode: 'DE' }
|
||||
},
|
||||
customer: {
|
||||
name: metadata.customerName,
|
||||
id: '',
|
||||
address: { countryCode: 'DE' }
|
||||
},
|
||||
payableAmount: metadata.totalAmount,
|
||||
currencyCode: metadata.currency,
|
||||
contentHash: metadata.contentHash,
|
||||
xmlContent,
|
||||
pdfContent,
|
||||
pdfHash: metadata.pdfHash
|
||||
};
|
||||
|
||||
return invoice as IInvoice;
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to retrieve invoice: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for duplicate invoices
|
||||
*/
|
||||
public async checkDuplicate(
|
||||
invoice: IInvoice,
|
||||
contentHash: string
|
||||
): Promise<IDuplicateCheckResult> {
|
||||
// Check by content hash (exact match)
|
||||
const existing = this.getCacheEntry(contentHash);
|
||||
if (existing) {
|
||||
return {
|
||||
isDuplicate: true,
|
||||
matchedInvoiceId: existing.invoiceId,
|
||||
matchedContentHash: contentHash,
|
||||
matchedFields: ['contentHash'],
|
||||
confidence: 100
|
||||
};
|
||||
}
|
||||
|
||||
// Check by invoice number and supplier/customer
|
||||
for (const [hash, metadata] of this.metadataCache.entries()) {
|
||||
if (
|
||||
metadata.invoiceNumber === invoice.invoiceNumber &&
|
||||
metadata.direction === invoice.direction
|
||||
) {
|
||||
// Same invoice number and direction
|
||||
if (invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) {
|
||||
// Same supplier
|
||||
return {
|
||||
isDuplicate: true,
|
||||
matchedInvoiceId: metadata.invoiceId,
|
||||
matchedContentHash: hash,
|
||||
matchedFields: ['invoiceNumber', 'supplier'],
|
||||
confidence: 95
|
||||
};
|
||||
} else if (invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name) {
|
||||
// Same customer
|
||||
return {
|
||||
isDuplicate: true,
|
||||
matchedInvoiceId: metadata.invoiceId,
|
||||
matchedContentHash: hash,
|
||||
matchedFields: ['invoiceNumber', 'customer'],
|
||||
confidence: 95
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check by amount and date within tolerance
|
||||
const dateTolerance = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const amountTolerance = 0.01;
|
||||
|
||||
if (
|
||||
Math.abs(metadata.totalAmount - invoice.payableAmount) < amountTolerance &&
|
||||
Math.abs(new Date(metadata.issueDate).getTime() - invoice.issueDate.getTime()) < dateTolerance &&
|
||||
metadata.direction === invoice.direction
|
||||
) {
|
||||
if (
|
||||
(invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) ||
|
||||
(invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name)
|
||||
) {
|
||||
return {
|
||||
isDuplicate: true,
|
||||
matchedInvoiceId: metadata.invoiceId,
|
||||
matchedContentHash: hash,
|
||||
matchedFields: ['amount', 'date', 'party'],
|
||||
confidence: 85
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isDuplicate: false,
|
||||
confidence: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search invoices by filter
|
||||
*/
|
||||
public async searchInvoices(filter: IInvoiceFilter): Promise<IInvoiceMetadata[]> {
|
||||
const results: IInvoiceMetadata[] = [];
|
||||
|
||||
for (const metadata of this.metadataCache.values()) {
|
||||
if (this.matchesFilter(metadata, filter)) {
|
||||
results.push(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by date descending
|
||||
results.sort((a, b) =>
|
||||
new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime()
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage statistics
|
||||
*/
|
||||
public async getStatistics(): Promise<IStorageStats> {
|
||||
let totalSize = 0;
|
||||
let inboundCount = 0;
|
||||
let outboundCount = 0;
|
||||
|
||||
for (const metadata of this.metadataCache.values()) {
|
||||
if (metadata.direction === 'inbound') {
|
||||
inboundCount++;
|
||||
} else {
|
||||
outboundCount++;
|
||||
}
|
||||
|
||||
// Estimate size (would need actual file sizes in production)
|
||||
totalSize += 50000; // Rough estimate
|
||||
}
|
||||
|
||||
return {
|
||||
totalInvoices: this.metadataCache.size,
|
||||
inboundCount,
|
||||
outboundCount,
|
||||
totalSize,
|
||||
duplicatesDetected: 0, // Would track this in production
|
||||
lastUpdate: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create EN16931 compliance report
|
||||
*/
|
||||
public async createComplianceReport(): Promise<void> {
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
totalInvoices: this.metadataCache.size,
|
||||
validInvoices: 0,
|
||||
invalidInvoices: 0,
|
||||
warnings: 0,
|
||||
byFormat: {} as Record<string, number>,
|
||||
byDirection: {
|
||||
inbound: 0,
|
||||
outbound: 0
|
||||
},
|
||||
validationErrors: [] as string[],
|
||||
complianceLevel: 'EN16931',
|
||||
validatorVersion: '5.1.4'
|
||||
};
|
||||
|
||||
for (const metadata of this.metadataCache.values()) {
|
||||
if (metadata.validationResult.isValid) {
|
||||
report.validInvoices++;
|
||||
} else {
|
||||
report.invalidInvoices++;
|
||||
}
|
||||
|
||||
report.warnings += metadata.validationResult.warnings;
|
||||
|
||||
if (metadata.direction === 'inbound') {
|
||||
report.byDirection.inbound++;
|
||||
} else {
|
||||
report.byDirection.outbound++;
|
||||
}
|
||||
}
|
||||
|
||||
const reportPath = path.join(
|
||||
this.exportPath,
|
||||
'data',
|
||||
'validation',
|
||||
'en16931_compliance.json'
|
||||
);
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(report, null, 2),
|
||||
reportPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load registry from disk
|
||||
*/
|
||||
private async loadRegistry(): Promise<void> {
|
||||
try {
|
||||
if (await plugins.smartfile.fs.fileExists(this.registryPath)) {
|
||||
const content = await plugins.smartfile.fs.toStringSync(this.registryPath);
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry: IInvoiceRegistryEntry = JSON.parse(line);
|
||||
this.setCacheEntry(entry.hash, entry.metadata);
|
||||
} catch (e) {
|
||||
this.logger.log('warn', `Invalid registry entry: ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('info', `Loaded ${this.metadataCache.size} invoices from registry`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to load registry: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update registry with new entry
|
||||
*/
|
||||
private async updateRegistry(
|
||||
invoiceId: string,
|
||||
contentHash: string,
|
||||
metadata: IInvoiceMetadata
|
||||
): Promise<void> {
|
||||
try {
|
||||
const entry: IInvoiceRegistryEntry = {
|
||||
id: invoiceId,
|
||||
hash: contentHash,
|
||||
metadata
|
||||
};
|
||||
|
||||
// Append to NDJSON file
|
||||
const line = JSON.stringify(entry) + '\n';
|
||||
await plugins.smartfile.fs.ensureDir(path.dirname(this.registryPath));
|
||||
|
||||
// Use native fs for atomic append (better performance and concurrency safety)
|
||||
const fs = await import('fs/promises');
|
||||
await fs.appendFile(this.registryPath, line, 'utf8');
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to update registry: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find invoice file by hash and extension
|
||||
*/
|
||||
private async findInvoiceFile(
|
||||
contentHash: string,
|
||||
extension: string
|
||||
): Promise<string | null> {
|
||||
const dirs = [
|
||||
path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
|
||||
path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound')
|
||||
];
|
||||
|
||||
for (const dir of dirs) {
|
||||
const files = await plugins.smartfile.fs.listFileTree(dir, '**/*' + extension);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.includes(contentHash.substring(0, 8))) {
|
||||
return path.join(dir, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 hash
|
||||
*/
|
||||
private async calculateHash(data: string | Buffer): Promise<string> {
|
||||
if (typeof data === 'string') {
|
||||
return await plugins.smarthash.sha256FromString(data);
|
||||
} else {
|
||||
return await plugins.smarthash.sha256FromBuffer(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if metadata matches filter
|
||||
*/
|
||||
private matchesFilter(metadata: IInvoiceMetadata, filter: IInvoiceFilter): boolean {
|
||||
if (filter.direction && metadata.direction !== filter.direction) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.dateFrom && new Date(metadata.issueDate) < filter.dateFrom) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.dateTo && new Date(metadata.issueDate) > filter.dateTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.minAmount && metadata.totalAmount < filter.minAmount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.maxAmount && metadata.totalAmount > filter.maxAmount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.invoiceNumber && !metadata.invoiceNumber.includes(filter.invoiceNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.supplierId && !metadata.supplierName.includes(filter.supplierId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filter.customerId && !metadata.customerName.includes(filter.customerId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count errors in validation result
|
||||
*/
|
||||
private countErrors(validationResult?: IInvoice['validationResult']): number {
|
||||
if (!validationResult) return 0;
|
||||
|
||||
return (
|
||||
validationResult.syntax.errors.length +
|
||||
validationResult.semantic.errors.length +
|
||||
validationResult.businessRules.errors.length +
|
||||
(validationResult.countrySpecific?.errors.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count warnings in validation result
|
||||
*/
|
||||
private countWarnings(validationResult?: IInvoice['validationResult']): number {
|
||||
if (!validationResult) return 0;
|
||||
|
||||
return (
|
||||
validationResult.syntax.warnings.length +
|
||||
validationResult.semantic.warnings.length +
|
||||
validationResult.businessRules.warnings.length +
|
||||
(validationResult.countrySpecific?.warnings.length || 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old invoices (for testing only)
|
||||
*/
|
||||
public async cleanup(olderThanDays: number = 365): Promise<number> {
|
||||
let removed = 0;
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
|
||||
|
||||
for (const [hash, metadata] of this.metadataCache.entries()) {
|
||||
if (new Date(metadata.issueDate) < cutoffDate) {
|
||||
this.metadataCache.delete(hash);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('info', `Removed ${removed} old invoices from cache`);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cache entry with LRU eviction
|
||||
*/
|
||||
private setCacheEntry(key: string, value: IInvoiceMetadata): void {
|
||||
// Remove from access order if already exists
|
||||
const existingIndex = this.cacheAccessOrder.indexOf(key);
|
||||
if (existingIndex > -1) {
|
||||
this.cacheAccessOrder.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
// Add to end (most recently used)
|
||||
this.cacheAccessOrder.push(key);
|
||||
this.metadataCache.set(key, value);
|
||||
|
||||
// Evict oldest if cache is too large
|
||||
while (this.metadataCache.size > this.MAX_CACHE_SIZE) {
|
||||
const oldestKey = this.cacheAccessOrder.shift();
|
||||
if (oldestKey) {
|
||||
this.metadataCache.delete(oldestKey);
|
||||
this.logger.log('debug', `Evicted invoice from cache: ${oldestKey}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache entry and update access order
|
||||
*/
|
||||
private getCacheEntry(key: string): IInvoiceMetadata | undefined {
|
||||
const value = this.metadataCache.get(key);
|
||||
if (value) {
|
||||
// Move to end (most recently used)
|
||||
const index = this.cacheAccessOrder.indexOf(key);
|
||||
if (index > -1) {
|
||||
this.cacheAccessOrder.splice(index, 1);
|
||||
}
|
||||
this.cacheAccessOrder.push(key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metadata in storage and cache
|
||||
*/
|
||||
public async updateMetadata(contentHash: string, updates: Partial<IInvoiceMetadata>): Promise<void> {
|
||||
const metadata = this.getCacheEntry(contentHash);
|
||||
if (!metadata) {
|
||||
this.logger.log('warn', `Cannot update metadata - invoice not found: ${contentHash}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metadata
|
||||
const updatedMetadata = { ...metadata, ...updates };
|
||||
this.setCacheEntry(contentHash, updatedMetadata);
|
||||
|
||||
// Persist to disk
|
||||
const metadataPath = path.join(
|
||||
this.exportPath,
|
||||
'data',
|
||||
'documents',
|
||||
'invoices',
|
||||
metadata.direction,
|
||||
'metadata',
|
||||
`${contentHash}.json`
|
||||
);
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(updatedMetadata, null, 2),
|
||||
metadataPath
|
||||
);
|
||||
|
||||
this.logger.log('info', `Updated metadata for invoice: ${contentHash}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* DATEV Posting Keys (Buchungsschlüssel) for German Accounting
|
||||
*
|
||||
* Posting keys control automatic VAT booking and are automatically checked
|
||||
* in German tax audits (Betriebsprüfungen). Using incorrect posting keys
|
||||
* can have serious tax consequences.
|
||||
*
|
||||
* Reference: DATEV Buchungsschlüssel-Verzeichnis
|
||||
*/
|
||||
|
||||
import type { TPostingKey, IPostingKeyRule } from './skr.types.js';
|
||||
|
||||
/**
|
||||
* Posting key definitions with validation rules
|
||||
*/
|
||||
export const POSTING_KEY_RULES: Record<TPostingKey, IPostingKeyRule> = {
|
||||
3: {
|
||||
key: 3,
|
||||
description: 'Zahlungseingang mit 19% Umsatzsteuer',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['domestic_taxed']
|
||||
},
|
||||
8: {
|
||||
key: 8,
|
||||
description: '7% Vorsteuer',
|
||||
vatRate: 7,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['domestic_taxed']
|
||||
},
|
||||
9: {
|
||||
key: 9,
|
||||
description: '19% Vorsteuer',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['domestic_taxed']
|
||||
},
|
||||
19: {
|
||||
key: 19,
|
||||
description: '19% Vorsteuer bei innergemeinschaftlichen Lieferungen',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['intra_eu']
|
||||
},
|
||||
40: {
|
||||
key: 40,
|
||||
description: 'Steuerfrei / Aufhebung der Automatik',
|
||||
vatRate: 0,
|
||||
requiresVAT: false,
|
||||
disablesVATAutomatism: true,
|
||||
allowedScenarios: ['tax_free', 'export', 'reverse_charge']
|
||||
},
|
||||
94: {
|
||||
key: 94,
|
||||
description: '19% Vorsteuer/Umsatzsteuer bei Erwerb aus EU oder Drittland (Reverse Charge)',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['reverse_charge', 'intra_eu', 'third_country']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate posting key for a journal entry line
|
||||
*/
|
||||
export function validatePostingKey(
|
||||
postingKey: TPostingKey,
|
||||
accountNumber: string,
|
||||
amount: number,
|
||||
vatAmount?: number,
|
||||
taxScenario?: string
|
||||
): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Get posting key rule
|
||||
const rule = POSTING_KEY_RULES[postingKey];
|
||||
if (!rule) {
|
||||
errors.push(`Invalid posting key: ${postingKey}`);
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Validate VAT requirement
|
||||
// Skip VAT amount requirement if:
|
||||
// 1. Posting TO a VAT account (the line itself IS the VAT)
|
||||
// 2. Posting TO a debtor/creditor account (receivable/payable settlement - VAT was already recorded)
|
||||
const isVATAccount = accountNumber === '1571' || accountNumber === '1771' || accountNumber === '1576';
|
||||
const accountNum = parseInt(accountNumber);
|
||||
const isDebtorCreditorAccount = (accountNum >= 10000 && accountNum <= 69999) || (accountNum >= 70000 && accountNum <= 99999);
|
||||
|
||||
if (rule.requiresVAT && !vatAmount && !isVATAccount && !isDebtorCreditorAccount) {
|
||||
errors.push(
|
||||
`Posting key ${postingKey} requires VAT amount, but none provided. ` +
|
||||
`Description: ${rule.description}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate VAT rate if specified
|
||||
if (rule.vatRate && vatAmount && rule.vatRate > 0) {
|
||||
const expectedVAT = Math.round(amount * rule.vatRate) / 100;
|
||||
const tolerance = 0.02; // 2 cent tolerance for rounding
|
||||
|
||||
if (Math.abs(vatAmount - expectedVAT) > tolerance) {
|
||||
warnings.push(
|
||||
`VAT amount ${vatAmount} does not match expected ${expectedVAT.toFixed(2)} ` +
|
||||
`for posting key ${postingKey} (${rule.vatRate}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tax scenario
|
||||
if (rule.allowedScenarios && taxScenario) {
|
||||
if (!rule.allowedScenarios.includes(taxScenario)) {
|
||||
errors.push(
|
||||
`Posting key ${postingKey} is not valid for tax scenario '${taxScenario}'. ` +
|
||||
`Allowed scenarios: ${rule.allowedScenarios.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate automatism disabling
|
||||
if (rule.disablesVATAutomatism && vatAmount && vatAmount > 0) {
|
||||
warnings.push(
|
||||
`Posting key ${postingKey} disables VAT automatism but VAT amount is provided. ` +
|
||||
`This may cause incorrect tax reporting.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posting key description
|
||||
*/
|
||||
export function getPostingKeyDescription(postingKey: TPostingKey): string {
|
||||
const rule = POSTING_KEY_RULES[postingKey];
|
||||
return rule ? rule.description : `Unknown posting key: ${postingKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate posting key for a transaction
|
||||
*/
|
||||
export function suggestPostingKey(params: {
|
||||
vatRate: number;
|
||||
taxScenario?: string;
|
||||
isPayment?: boolean;
|
||||
}): TPostingKey {
|
||||
const { vatRate, taxScenario, isPayment } = params;
|
||||
|
||||
// Tax-free or reverse charge scenarios
|
||||
if (taxScenario === 'tax_free' || taxScenario === 'export') {
|
||||
return 40;
|
||||
}
|
||||
|
||||
// Reverse charge
|
||||
if (taxScenario === 'reverse_charge' || taxScenario === 'third_country') {
|
||||
return 94;
|
||||
}
|
||||
|
||||
// Intra-EU with VAT
|
||||
if (taxScenario === 'intra_eu' && vatRate === 19) {
|
||||
return 19;
|
||||
}
|
||||
|
||||
// Payment with 19% VAT
|
||||
if (isPayment && vatRate === 19) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Input VAT based on rate
|
||||
if (vatRate === 19) {
|
||||
return 9;
|
||||
}
|
||||
|
||||
if (vatRate === 7) {
|
||||
return 8;
|
||||
}
|
||||
|
||||
// Default to tax-free if no VAT
|
||||
if (vatRate === 0) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
// Fallback to 19% input VAT
|
||||
return 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all posting keys for consistency
|
||||
*/
|
||||
export function validatePostingKeyConsistency(lines: Array<{
|
||||
postingKey: TPostingKey;
|
||||
accountNumber: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
vatAmount?: number;
|
||||
}>): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for mixing tax-free and taxed transactions
|
||||
const hasTaxFree = lines.some(line => line.postingKey === 40);
|
||||
const hasTaxed = lines.some(line => [3, 8, 9, 19, 94].includes(line.postingKey));
|
||||
|
||||
if (hasTaxFree && hasTaxed) {
|
||||
warnings.push(
|
||||
'Journal entry mixes tax-free (key 40) and taxed transactions. ' +
|
||||
'Verify this is intentional.'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for reverse charge consistency
|
||||
const hasReverseCharge = lines.some(line => line.postingKey === 94);
|
||||
if (hasReverseCharge) {
|
||||
const reverseChargeLines = lines.filter(line => line.postingKey === 94);
|
||||
if (reverseChargeLines.length % 2 !== 0) {
|
||||
errors.push(
|
||||
'Reverse charge (posting key 94) requires both input and output VAT entries. ' +
|
||||
'Found odd number of reverse charge lines.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if posting key requires automatic VAT booking
|
||||
*/
|
||||
export function requiresAutomaticVAT(postingKey: TPostingKey): boolean {
|
||||
const rule = POSTING_KEY_RULES[postingKey];
|
||||
return rule ? !rule.disablesVATAutomatism : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid posting keys
|
||||
*/
|
||||
export function getAllPostingKeys(): TPostingKey[] {
|
||||
return Object.keys(POSTING_KEY_RULES).map(k => Number(k) as TPostingKey);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as path from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import * as https from 'https';
|
||||
import * as nodeForge from 'node-forge';
|
||||
|
||||
export interface ISigningOptions {
|
||||
certificatePem?: string;
|
||||
privateKeyPem?: string;
|
||||
privateKeyPassphrase?: string;
|
||||
timestampServerUrl?: string;
|
||||
includeTimestamp?: boolean;
|
||||
}
|
||||
|
||||
export interface ISignatureResult {
|
||||
signature: string;
|
||||
signatureFormat: 'CAdES-B' | 'CAdES-T' | 'CAdES-LT';
|
||||
signingTime: string;
|
||||
certificateChain?: string[];
|
||||
timestampToken?: string;
|
||||
timestampTime?: string;
|
||||
}
|
||||
|
||||
export interface ITimestampResponse {
|
||||
token: string;
|
||||
time: string;
|
||||
serverUrl: string;
|
||||
hashAlgorithm: string;
|
||||
}
|
||||
|
||||
export class SecurityManager {
|
||||
private options: ISigningOptions;
|
||||
private logger: plugins.smartlog.ConsoleLog;
|
||||
|
||||
constructor(options: ISigningOptions = {}) {
|
||||
this.options = {
|
||||
timestampServerUrl: options.timestampServerUrl || 'http://timestamp.digicert.com',
|
||||
includeTimestamp: options.includeTimestamp !== false,
|
||||
...options
|
||||
};
|
||||
this.logger = new plugins.smartlog.ConsoleLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CAdES-B (Basic) signature for data
|
||||
*/
|
||||
public async createCadesSignature(
|
||||
data: Buffer | string,
|
||||
certificatePem?: string,
|
||||
privateKeyPem?: string
|
||||
): Promise<ISignatureResult> {
|
||||
const cert = certificatePem || this.options.certificatePem;
|
||||
const key = privateKeyPem || this.options.privateKeyPem;
|
||||
|
||||
if (!cert || !key) {
|
||||
throw new Error('Certificate and private key are required for signing');
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse certificate and key
|
||||
const certificate = nodeForge.pki.certificateFromPem(cert);
|
||||
const privateKey = this.options.privateKeyPassphrase
|
||||
? nodeForge.pki.decryptRsaPrivateKey(key, this.options.privateKeyPassphrase)
|
||||
: nodeForge.pki.privateKeyFromPem(key);
|
||||
|
||||
// Create PKCS#7 signed data (CMS)
|
||||
const p7 = nodeForge.pkcs7.createSignedData();
|
||||
|
||||
// Add content
|
||||
if (typeof data === 'string') {
|
||||
p7.content = nodeForge.util.createBuffer(data, 'utf8');
|
||||
} else {
|
||||
p7.content = nodeForge.util.createBuffer(data.toString('latin1'));
|
||||
}
|
||||
|
||||
// Add certificate
|
||||
p7.addCertificate(certificate);
|
||||
|
||||
// Add signer
|
||||
p7.addSigner({
|
||||
key: privateKey,
|
||||
certificate: certificate,
|
||||
digestAlgorithm: nodeForge.pki.oids.sha256,
|
||||
authenticatedAttributes: [
|
||||
{
|
||||
type: nodeForge.pki.oids.contentType,
|
||||
value: nodeForge.pki.oids.data
|
||||
},
|
||||
{
|
||||
type: nodeForge.pki.oids.messageDigest
|
||||
},
|
||||
{
|
||||
type: nodeForge.pki.oids.signingTime,
|
||||
value: new Date().toISOString()
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Sign the data
|
||||
p7.sign({ detached: true });
|
||||
|
||||
// Convert to PEM
|
||||
const pem = nodeForge.pkcs7.messageToPem(p7);
|
||||
|
||||
// Extract base64 signature
|
||||
const signature = pem
|
||||
.replace(/-----BEGIN PKCS7-----/, '')
|
||||
.replace(/-----END PKCS7-----/, '')
|
||||
.replace(/\r?\n/g, '');
|
||||
|
||||
const result: ISignatureResult = {
|
||||
signature: signature,
|
||||
signatureFormat: 'CAdES-B',
|
||||
signingTime: new Date().toISOString(),
|
||||
certificateChain: [cert]
|
||||
};
|
||||
|
||||
// Add timestamp if requested
|
||||
if (this.options.includeTimestamp && this.options.timestampServerUrl) {
|
||||
try {
|
||||
const timestampResponse = await this.requestTimestamp(signature);
|
||||
result.timestampToken = timestampResponse.token;
|
||||
result.timestampTime = timestampResponse.time;
|
||||
result.signatureFormat = 'CAdES-T';
|
||||
} catch (error) {
|
||||
this.logger.log('warn', `Failed to obtain timestamp: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create CAdES signature: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests an RFC 3161 timestamp from a TSA
|
||||
*/
|
||||
public async requestTimestamp(dataHash: string | Buffer): Promise<ITimestampResponse> {
|
||||
try {
|
||||
// Create hash of the data
|
||||
let hash: Buffer;
|
||||
if (typeof dataHash === 'string') {
|
||||
hash = crypto.createHash('sha256').update(dataHash).digest();
|
||||
} else {
|
||||
hash = crypto.createHash('sha256').update(dataHash).digest();
|
||||
}
|
||||
|
||||
// Create timestamp request (simplified - in production use proper ASN.1 encoding)
|
||||
const tsRequest = this.createTimestampRequest(hash);
|
||||
|
||||
// Send request to TSA
|
||||
const response = await this.sendTimestampRequest(tsRequest);
|
||||
|
||||
return {
|
||||
token: response.toString('base64'),
|
||||
time: new Date().toISOString(),
|
||||
serverUrl: this.options.timestampServerUrl!,
|
||||
hashAlgorithm: 'sha256'
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to obtain timestamp: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a timestamp request (simplified version)
|
||||
*/
|
||||
private createTimestampRequest(hash: Buffer): Buffer {
|
||||
// In production, use proper ASN.1 encoding library
|
||||
// This is a simplified placeholder
|
||||
const request = {
|
||||
version: 1,
|
||||
messageImprint: {
|
||||
hashAlgorithm: { algorithm: '2.16.840.1.101.3.4.2.1' }, // SHA-256 OID
|
||||
hashedMessage: hash
|
||||
},
|
||||
reqPolicy: null,
|
||||
nonce: crypto.randomBytes(8),
|
||||
certReq: true
|
||||
};
|
||||
|
||||
// Convert to DER-encoded ASN.1 (simplified)
|
||||
return Buffer.from(JSON.stringify(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends timestamp request to TSA server
|
||||
*/
|
||||
private async sendTimestampRequest(request: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.options.timestampServerUrl!);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/timestamp-query',
|
||||
'Content-Length': request.length
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const response = Buffer.concat(chunks);
|
||||
if (res.statusCode === 200) {
|
||||
resolve(response);
|
||||
} else {
|
||||
reject(new Error(`TSA server returned status ${res.statusCode}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(request);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a CAdES signature
|
||||
*/
|
||||
public async verifyCadesSignature(
|
||||
data: Buffer | string,
|
||||
signature: string,
|
||||
certificatePem?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Add PEM headers if not present
|
||||
let pemSignature = signature;
|
||||
if (!signature.includes('BEGIN PKCS7')) {
|
||||
pemSignature = `-----BEGIN PKCS7-----\n${signature}\n-----END PKCS7-----`;
|
||||
}
|
||||
|
||||
// Parse the PKCS#7 message
|
||||
const p7 = nodeForge.pkcs7.messageFromPem(pemSignature);
|
||||
|
||||
// Prepare content for verification
|
||||
let content: nodeForge.util.ByteStringBuffer;
|
||||
if (typeof data === 'string') {
|
||||
content = nodeForge.util.createBuffer(data, 'utf8');
|
||||
} else {
|
||||
content = 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 = nodeForge.pki.rsa.generateKeyPair(2048);
|
||||
const cert = 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, nodeForge.md.sha256.create());
|
||||
|
||||
// Convert to PEM
|
||||
const certificatePem = nodeForge.pki.certificateToPem(cert);
|
||||
const privateKeyPem = nodeForge.pki.privateKeyToPem(keys.privateKey);
|
||||
|
||||
return {
|
||||
certificate: certificatePem,
|
||||
privateKey: privateKeyPem
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a detached signature file
|
||||
*/
|
||||
public async createDetachedSignature(
|
||||
dataPath: string,
|
||||
outputPath: string
|
||||
): Promise<void> {
|
||||
const data = await plugins.smartfile.fs.toBuffer(dataPath);
|
||||
const signature = await this.createCadesSignature(data);
|
||||
|
||||
const signatureData = {
|
||||
signature: signature.signature,
|
||||
format: signature.signatureFormat,
|
||||
signingTime: signature.signingTime,
|
||||
timestamp: signature.timestampToken,
|
||||
timestampTime: signature.timestampTime,
|
||||
algorithm: 'SHA256withRSA',
|
||||
signedFile: path.basename(dataPath)
|
||||
};
|
||||
|
||||
await plugins.smartfile.memory.toFs(
|
||||
JSON.stringify(signatureData, null, 2),
|
||||
outputPath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a detached signature file
|
||||
*/
|
||||
public async verifyDetachedSignature(
|
||||
dataPath: string,
|
||||
signaturePath: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const data = await plugins.smartfile.fs.toBuffer(dataPath);
|
||||
const signatureJson = await plugins.smartfile.fs.toStringSync(signaturePath);
|
||||
const signatureData = JSON.parse(signatureJson);
|
||||
|
||||
return await this.verifyCadesSignature(data, signatureData.signature);
|
||||
} catch (error) {
|
||||
this.logger.log('error', `Failed to verify detached signature: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds Long-Term Validation (LTV) information
|
||||
*/
|
||||
public async addLtvInformation(
|
||||
signature: ISignatureResult,
|
||||
ocspResponse?: Buffer,
|
||||
crlData?: Buffer
|
||||
): Promise<ISignatureResult> {
|
||||
// Add OCSP response and CRL data for long-term validation
|
||||
const ltv = {
|
||||
...signature,
|
||||
signatureFormat: 'CAdES-LT' as const,
|
||||
ocsp: ocspResponse?.toString('base64'),
|
||||
crl: crlData?.toString('base64'),
|
||||
ltvTime: new Date().toISOString()
|
||||
};
|
||||
|
||||
return ltv;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,18 @@ export type TSKRType = 'SKR03' | 'SKR04';
|
||||
|
||||
export type TTransactionStatus = 'pending' | 'posted' | 'reversed';
|
||||
|
||||
/**
|
||||
* DATEV posting keys (Buchungsschlüssel) for German accounting
|
||||
* These keys control automatic VAT booking and are checked in tax audits
|
||||
*/
|
||||
export type TPostingKey =
|
||||
| 3 // Payment with 19% VAT
|
||||
| 8 // 7% input VAT
|
||||
| 9 // 19% input VAT
|
||||
| 19 // 19% input VAT (intra-EU)
|
||||
| 40 // Tax-free (disables VAT automatism)
|
||||
| 94; // 19% input/output VAT (reverse charge)
|
||||
|
||||
export type TReportType =
|
||||
| 'trial_balance'
|
||||
| 'income_statement'
|
||||
@@ -16,6 +28,18 @@ export type TReportType =
|
||||
| 'general_ledger'
|
||||
| 'cash_flow';
|
||||
|
||||
/**
|
||||
* Posting key validation rule
|
||||
*/
|
||||
export interface IPostingKeyRule {
|
||||
key: TPostingKey;
|
||||
description: string;
|
||||
vatRate?: number; // Expected VAT rate (if applicable)
|
||||
requiresVAT: boolean; // Whether VAT entry is required
|
||||
disablesVATAutomatism: boolean; // Whether this key disables automatic VAT
|
||||
allowedScenarios?: string[]; // Allowed tax scenarios (e.g., 'reverse_charge')
|
||||
}
|
||||
|
||||
export interface IAccountData {
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
@@ -25,6 +49,7 @@ export interface IAccountData {
|
||||
description?: string;
|
||||
vatRate?: number;
|
||||
isActive?: boolean;
|
||||
isAutomaticAccount?: boolean; // Automatikkonto (e.g., 1400, 1600) - cannot be posted to directly
|
||||
}
|
||||
|
||||
export interface ITransactionData {
|
||||
@@ -53,6 +78,7 @@ export interface IJournalEntryLine {
|
||||
credit?: number;
|
||||
description?: string;
|
||||
costCenter?: string;
|
||||
postingKey: TPostingKey; // REQUIRED: DATEV posting key for VAT automation control
|
||||
}
|
||||
|
||||
export interface ITrialBalanceEntry {
|
||||
@@ -136,6 +162,7 @@ export interface ITransactionFilter {
|
||||
export interface IDatabaseConfig {
|
||||
mongoDbUrl: string;
|
||||
dbName?: string;
|
||||
invoiceExportPath?: string; // Optional path for invoice storage
|
||||
}
|
||||
|
||||
export interface IReportParams {
|
||||
|
||||
@@ -159,6 +159,7 @@ export const SKR03_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR03',
|
||||
description: 'Trade receivables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use debtor accounts (10000-69999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1500',
|
||||
@@ -199,6 +200,7 @@ export const SKR03_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR03',
|
||||
description: 'Trade payables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use creditor accounts (70000-99999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1700',
|
||||
|
||||
@@ -159,6 +159,7 @@ export const SKR04_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR04',
|
||||
description: 'Trade receivables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use debtor accounts (10000-69999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1500',
|
||||
@@ -199,6 +200,7 @@ export const SKR04_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR04',
|
||||
description: 'Trade payables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use creditor accounts (70000-99999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1700',
|
||||
|
||||
+1
-5
@@ -1,15 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"useDefineForClassFields": false,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {}
|
||||
"types": ["node"]
|
||||
},
|
||||
"exclude": ["dist_*/**/*.d.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user