14 Commits

Author SHA1 Message Date
jkunz 8dc566a709 v1.3.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 20:39:11 +00:00
jkunz 0c20005db2 feat(deps): bump @fin.cx/einvoice to 5.2.0 2026-04-16 20:39:11 +00:00
jkunz b9df310aaf v1.2.2
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-16 19:41:55 +00:00
jkunz 40ffc2b355 fix(exports): stabilize published types and compatibility with updated dependencies 2026-04-16 19:41:55 +00:00
jkunz cb6b3db15a 1.2.1
Default (tags) / security (push) Successful in 47s
Default (tags) / test (push) Failing after 4m6s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-10-28 08:50:32 +00:00
jkunz 119c12901a fix(skr.classes.account): Remove incorrect SKR04 automatic account 3300; improve VAT posting validation and test isolation; update readme hints and CI settings 2025-10-28 08:50:32 +00:00
jkunz d21876c14f feat(postingkeys): allow VAT account postings to skip VAT amount validation 2025-10-27 08:36:33 +00:00
jkunz 4f1066da2e feat: Enhance journal entry and transaction handling with posting keys
- Added posting key support to SKR03 and SKR04 journal entries and transactions to ensure DATEV compliance.
- Implemented validation for posting keys in journal entries, ensuring all lines have a posting key and that they are consistent across the entry.
- Introduced automatic account checks to prevent posting to accounts that cannot be directly posted to (e.g., 1400, 1600).
- Updated account validation to include checks for debtor and creditor ranges.
- Enhanced invoice booking logic to include appropriate posting keys based on VAT rates and scenarios.
- Created a new module for posting key definitions and validation rules, including functions for validating posting keys and suggesting appropriate keys based on transaction parameters.
- Updated tests to cover new posting key functionality and ensure compliance with accounting rules.
2025-10-27 08:34:28 +00:00
jkunz 73b46f7857 feat(invoice): add e-invoice support with XRechnung/ZUGFeRD and advanced export features
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 4m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-12 12:37:01 +00:00
jkunz 08d7803be2 feat(validation): add SKR standard validation for account compliance
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-08-11 11:06:49 +00:00
jkunz db46612ea2 feat(reports): adjust financial report calculations to maintain sign for accuracy 2025-08-10 20:13:04 +00:00
jkunz 10ca6f2992 feat(tests): integrate qenv for dynamic configuration and enhance SKR API tests 2025-08-10 19:52:23 +00:00
jkunz f42c8539a6 update services 2025-08-10 17:11:53 +00:00
jkunz c7f06b6529 chore(package): remove private field for public npm release 2025-08-09 12:03:11 +00:00
45 changed files with 12698 additions and 5016 deletions
+1
View File
@@ -17,3 +17,4 @@ dist/
dist_*/
#------# custom
.serena
+11 -5
View File
@@ -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": []
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {
+61
View File
@@ -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
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Task Venture Capital GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25 -12
View File
@@ -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": {}
+4277 -4455
View File
File diff suppressed because it is too large Load Diff
+54 -1
View File
@@ -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
+242 -330
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
}
+41
View File
@@ -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
};
};
+569
View File
@@ -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();
+513
View File
@@ -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
View File
@@ -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
View File
@@ -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',
+24 -9
View File
@@ -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',
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
+8 -3
View File
@@ -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
View File
@@ -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();
+89
View File
@@ -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
View File
@@ -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;
});
}
+40 -22
View File
@@ -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();
+154
View File
@@ -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 = [];
}
}
+270
View File
@@ -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 = [];
}
}
+249
View File
@@ -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;
}
}
+602
View File
@@ -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;
}
}
}
+443
View File
@@ -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;
}
}
+601
View File
@@ -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
};
}
}
+762
View File
@@ -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);
}
}
+351
View File
@@ -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;
}
+486
View File
@@ -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);
}
}
+711
View File
@@ -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}`);
}
}
+252
View File
@@ -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);
}
+406
View File
@@ -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;
}
}
+27
View File
@@ -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 {
+2
View File
@@ -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',
+2
View File
@@ -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
View File
@@ -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"]
}