5 Commits

Author SHA1 Message Date
08d7803be2 feat(validation): add SKR standard validation for account compliance
Some checks failed
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
db46612ea2 feat(reports): adjust financial report calculations to maintain sign for accuracy 2025-08-10 20:13:04 +00:00
10ca6f2992 feat(tests): integrate qenv for dynamic configuration and enhance SKR API tests 2025-08-10 19:52:23 +00:00
f42c8539a6 update services 2025-08-10 17:11:53 +00:00
c7f06b6529 chore(package): remove private field for public npm release 2025-08-09 12:03:11 +00:00
16 changed files with 1959 additions and 256 deletions

3
.gitignore vendored
View File

@@ -16,4 +16,5 @@ node_modules/
dist/
dist_*/
#------# custom
#------# custom
.serena

View File

@@ -5,6 +5,23 @@ 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.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

View File

@@ -1,6 +1,6 @@
{
"name": "@fin.cx/skr",
"version": "1.0.0",
"version": "1.1.0",
"description": "SKR03 and SKR04 German accounting standards for double-entry bookkeeping",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
@@ -33,7 +33,8 @@
"devDependencies": {
"@git.zone/tsbuild": "^2.6.4",
"@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^2.3.2"
"@git.zone/tstest": "^2.3.2",
"@push.rocks/qenv": "^6.1.0"
},
"repository": {
"type": "git",
@@ -43,7 +44,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/**/*",

3
pnpm-lock.yaml generated
View File

@@ -30,6 +30,9 @@ importers:
'@git.zone/tstest':
specifier: ^2.3.2
version: 2.3.2(@aws-sdk/credential-providers@3.864.0)(socks@2.8.6)(typescript@5.8.3)
'@push.rocks/qenv':
specifier: ^6.1.0
version: 6.1.0
packages:

430
readme.md
View File

@@ -1,7 +1,7 @@
# @fin.cx/skr 📊
> **Enterprise-grade German accounting standards implementation for SKR03 and SKR04**
> Double-entry bookkeeping with MongoDB persistence and full TypeScript support
> Rock-solid double-entry bookkeeping with MongoDB persistence and full TypeScript support
## 🚀 Why @fin.cx/skr?
@@ -9,12 +9,14 @@ Building compliant German accounting software? You've come to the right place! T
### 🎯 What makes it awesome?
- **🏢 Enterprise-Ready**: Production-tested implementation following DATEV standards
- **⚡ Lightning Fast**: MongoDB-powered with optimized indexing and caching
- **🏢 Enterprise-Ready**: Production-tested implementation following HGB/GoBD standards
- **⚡ Lightning Fast**: MongoDB-powered with optimized indexing and real-time balance updates
- **🔒 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
- **🔄 Transaction Safety**: Built-in double-entry validation and automatic reversals
- **✅ Battle-Tested**: 65+ comprehensive tests covering all edge cases
- **🛡️ SKR Validation**: Automatic validation against official SKR standards
## 📦 Installation
@@ -67,42 +69,47 @@ const journalEntry = await api.postJournalEntry({
reference: 'SAL-2024-03',
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: '6100', debit: 1000.00, description: 'Social security employer' },
{ accountNumber: '1800', credit: 1500.00, description: 'Tax withholding' },
{ accountNumber: '1200', credit: 4500.00, description: 'Net payment' }
]
});
```
### 📊 Generating Reports
### 📊 Generating Financial Reports
```typescript
// Trial Balance
// Trial Balance (Summen- und Saldenliste)
const trialBalance = await api.generateTrialBalance({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
// Income Statement (P&L)
// Income Statement (GuV - Gewinn- und Verlustrechnung)
const incomeStatement = await api.generateIncomeStatement({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
// Balance Sheet
// Balance Sheet (Bilanz)
const balanceSheet = await api.generateBalanceSheet({
date: new Date('2024-12-31')
});
// Export for DATEV
const datevExport = await api.exportDatev({
// General Ledger Export
const generalLedger = await api.generateGeneralLedger({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31'),
format: 'CSV'
dateTo: new Date('2024-12-31')
});
// Cash Flow Statement
const cashFlow = await api.generateCashFlowStatement({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
```
## 🏗️ Core Architecture
## 🏗️ Core Features
### Account Management
@@ -117,20 +124,50 @@ const account = await api.createAccount({
isActive: true
});
// Search accounts
// Batch create multiple accounts for efficiency
const accounts = await api.createBatchAccounts([
{ accountNumber: '1298', accountName: 'Stripe Account', accountClass: 1, accountType: 'asset' },
{ accountNumber: '1297', accountName: 'Wise Business', accountClass: 1, accountType: 'asset' }
]);
// Search accounts by name or number
const accounts = await api.searchAccounts('bank');
// Get account balance
// Get account with full details
const account = await api.getAccount('1200');
// Update account information
await api.updateAccount('1200', {
accountName: 'Main Business Bank Account',
description: 'Primary operating account'
});
// Get account balance with running totals
const balance = await api.getAccountBalance('1200');
console.log(`Balance: ${balance.balance} EUR`);
console.log(`Debits: ${balance.debitTotal} EUR`);
console.log(`Credits: ${balance.creditTotal} EUR`);
console.log(`Balance: ${balance.balance}`);
console.log(`Total Debits: ${balance.debitTotal}`);
console.log(`Total Credits: ${balance.creditTotal}`);
// List accounts by classification
const assetAccounts = await api.getAccountsByType('asset');
const class4Accounts = await api.getAccountsByClass(4);
// Paginated account access for large datasets
const pagedAccounts = await api.getAccountsPaginated({
page: 1,
limit: 50,
sortBy: 'accountNumber',
sortOrder: 'asc'
});
```
### Transaction Management
```typescript
// Get transaction history
// Get transaction by ID
const transaction = await api.getTransaction(transactionId);
// Get transaction history with filtering
const transactions = await api.listTransactions({
accountNumber: '1200',
dateFrom: new Date('2024-01-01'),
@@ -139,15 +176,35 @@ const transactions = await api.listTransactions({
maxAmount: 10000
});
// Reverse a transaction
// Get all transactions for a specific account
const accountTransactions = await api.getAccountTransactions('1200', {
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
// Reverse transactions (Storno)
const reversal = await api.reverseTransaction(transactionId);
// Batch processing
// Reverse complex journal entries
const journalReversal = await api.reverseJournalEntry(journalEntryId);
// Batch processing for performance
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 }
]);
// Paginated access for large datasets
const pagedTransactions = await api.getTransactionsPaginated({
page: 1,
limit: 50,
sortBy: 'date',
sortOrder: 'desc'
});
// Find unbalanced transactions for audit
const unbalanced = await api.getUnbalancedTransactions();
```
## 📚 SKR03 vs SKR04: Which One to Choose?
@@ -170,7 +227,7 @@ const batchResults = await api.postBatchTransactions([
## 🎯 Account Structure
Both SKR standards follow the same hierarchical structure:
Both SKR standards follow the same 4-digit hierarchical structure:
```
[0-9] → Account Class (Kontenklasse)
@@ -183,77 +240,91 @@ Both SKR standards follow the same hierarchical structure:
| 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 |
| **0** | Fixed Assets (Anlagevermögen) | Fixed Assets | Asset |
| **1** | Current Assets (Umlaufvermögen) | Financial & Current Assets | Asset |
| **2** | Equity (Eigenkapital) | Expenses Part 1 | Equity/Expense |
| **3** | Liabilities (Fremdkapital) | Expenses Part 2 | Liability/Expense |
| **4** | Operating Income (Betriebliche Erträge) | Revenues Part 1 | Revenue |
| **5** | Material Costs (Materialaufwand) | Revenues Part 2 | Expense/Revenue |
| **6** | Operating Expenses (Betriebsaufwand) | Special Accounts | Expense |
| **7** | Other Costs (Weitere Aufwendungen) | Cost Accounting | Expense |
| **8** | Income (Erträge) | Free for Use (Custom) | Revenue |
| **9** | Closing Accounts (Abschlusskonten) | Equity & Closing | System |
## 🔧 Advanced Features
### Ledger Operations
### Period Management
```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 with automatic adjustments
await api.closePeriod('2024-01', {
performYearEndAdjustments: true,
generateReports: true
});
// Close accounting period
await ledger.closePeriod('2024-01');
```
### Custom Reporting
```typescript
import { Reports } from '@fin.cx/skr';
const reports = new Reports('SKR03');
// Generate custom report
const customReport = await reports.generateCustomReport({
accounts: ['1200', '1300', '1400'],
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31'),
groupBy: 'month',
includeSubAccounts: true
});
// Cash flow statement
const cashFlow = await reports.generateCashFlowStatement({
year: 2024
});
// Recalculate all account balances
await api.recalculateBalances();
```
### Data Import/Export
```typescript
// Import from CSV
// Import accounts from CSV
const importedCount = await api.importAccountsFromCSV(csvContent);
// Export to CSV
// Export accounts to CSV
const csvExport = await api.exportAccountsToCSV();
// DATEV-compatible export
const datevData = await api.exportDatev({
consultantNumber: '12345',
clientNumber: '67890',
// Export to DATEV format (for tax advisors)
const datevExport = await api.exportToDATEV({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
// Export reports to CSV
const reportCsv = await api.exportReportToCSV('income_statement', {
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
```
### Validation & Integrity
```typescript
// Find unbalanced transactions
const unbalanced = await api.getUnbalancedTransactions();
// Validate double-entry before posting
const isValid = await api.validateDoubleEntry({
debitAccount: '1000',
creditAccount: '8400',
amount: 100
});
// The API automatically validates all journal entries
// Will throw error if entry is unbalanced
try {
await api.postJournalEntry({
date: new Date(),
lines: [
{ accountNumber: '1000', debit: 100 },
{ accountNumber: '8400', credit: 99 } // Unbalanced!
]
});
} catch (error) {
console.error('Journal entry is not balanced!');
}
```
### Utility Functions
```typescript
// Get SKR type description for account classes
const classDesc = api.getAccountClassDescription(4);
// Returns: "Operating Income (SKR03)" or "Revenues Part 1 (SKR04)"
// Get current SKR type
const skrType = api.getSKRType(); // Returns: 'SKR03' or 'SKR04'
```
## 🛡️ Type Safety
@@ -266,9 +337,16 @@ import type {
IAccountData,
ITransactionData,
IJournalEntry,
IJournalEntryLine,
ITrialBalanceReport,
IIncomeStatement,
IBalanceSheet
IBalanceSheet,
IAccountFilter,
ITransactionFilter,
IPaginationParams,
IAccountBalance,
ICashFlowStatement,
IGeneralLedger
} from '@fin.cx/skr';
// All operations are fully typed
@@ -278,101 +356,155 @@ const account: IAccountData = {
accountClass: 1,
accountType: 'asset',
skrType: 'SKR03',
vatRate: 0,
isActive: true
};
// TypeScript will catch errors at compile time
const filter: IAccountFilter = {
accountType: 'asset',
isActive: true,
accountClass: 1
};
// Journal entries are validated at type level
const journalEntry: IJournalEntry = {
date: new Date(),
description: 'Year-end closing',
lines: [
{ accountNumber: '8400', debit: 0, credit: 1000 },
{ accountNumber: '9000', debit: 1000, credit: 0 }
]
};
```
## 🌟 Real-World Example
## 🌟 Real-World Example: Complete Annual Closing
Here's a complete example of setting up a basic accounting system:
Here's how to perform a complete Jahresabschluss (annual financial closing):
```typescript
import { SkrApi } from '@fin.cx/skr';
async function setupAccounting() {
// Initialize
async function performJahresabschluss() {
const api = new SkrApi({
mongoDbUrl: process.env.MONGODB_URL!,
dbName: 'my_company_accounting'
dbName: 'company_accounting'
});
await api.initialize('SKR03');
await api.initialize('SKR04'); // Using SKR04 for better reporting structure
// Create custom accounts for your business
await api.createAccount({
accountNumber: '1299',
accountName: 'Stripe Account',
accountClass: 1,
accountType: 'asset',
description: 'Stripe payment gateway account'
// 1. Post year-end adjustments
const adjustments = await api.postJournalEntry({
date: new Date('2024-12-31'),
description: 'Jahresabschlussbuchungen',
reference: 'JA-2024',
lines: [
// Depreciation (AfA)
{ accountNumber: '3700', debit: 10000, description: 'AfA auf Anlagen' },
{ accountNumber: '0210', credit: 10000, description: 'Wertberichtigung Gebäude' },
// Provisions (Rückstellungen)
{ accountNumber: '3500', debit: 5000, description: 'Bildung Rückstellungen' },
{ accountNumber: '0800', credit: 5000, description: 'Sonstige Rückstellungen' },
// VAT clearing
{ accountNumber: '1771', debit: 19000, description: 'USt-Saldo' },
{ accountNumber: '1571', credit: 17000, description: 'Vorsteuer-Saldo' },
{ accountNumber: '1700', credit: 2000, description: 'USt-Zahllast' }
]
});
// Post daily transactions
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')
// 2. Generate financial statements
const incomeStatement = await api.generateIncomeStatement({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
console.log('Revenue:', report.totalRevenue);
console.log('Expenses:', report.totalExpenses);
console.log('Net Income:', report.netIncome);
const balanceSheet = await api.generateBalanceSheet({
date: new Date('2024-12-31')
});
const trialBalance = await api.generateTrialBalance({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
const cashFlow = await api.generateCashFlowStatement({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
// 3. Export for tax advisor
const datevExport = await api.exportToDATEV({
dateFrom: new Date('2024-01-01'),
dateTo: new Date('2024-12-31')
});
// 4. Close the period
await api.closePeriod('2024-12', {
performYearEndAdjustments: true,
generateReports: true
});
console.log('=== Jahresabschluss 2024 ===');
console.log(`Umsatz: €${incomeStatement.totalRevenue}`);
console.log(`Aufwendungen: €${incomeStatement.totalExpenses}`);
console.log(`Jahresergebnis: €${incomeStatement.netIncome}`);
console.log(`Bilanzsumme: €${balanceSheet.assets.totalAssets}`);
console.log(`Cash Flow: €${cashFlow.netCashFlow}`);
console.log(incomeStatement.netIncome > 0 ? '✅ Gewinn!' : '📉 Verlust');
// Close the connection when done
await api.close();
}
setupAccounting().catch(console.error);
performJahresabschluss().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
| Class | Description |
|-------|-------------|
| **`SkrApi`** | Main API entry point for all operations |
| **`ChartOfAccounts`** | Account management and initialization |
| **`Ledger`** | General ledger and transaction posting with SKR validation |
| **`Reports`** | Financial reporting and exports |
| **`Account`** | Account model with balance tracking |
| **`Transaction`** | Double-entry transaction model |
| **`JournalEntry`** | Complex multi-line journal entries |
### 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 |
| `postTransaction(data)` | Post a simple two-line transaction |
| `postJournalEntry(data)` | Post complex multi-line journal entry |
| `postBatchTransactions(transactions)` | Post multiple transactions efficiently |
| `reverseTransaction(id)` | Create reversal (Storno) entry |
| `reverseJournalEntry(id)` | Reverse complex journal entries |
| `generateTrialBalance(params)` | Generate Summen- und Saldenliste |
| `generateIncomeStatement(params)` | Generate GuV (P&L) statement |
| `generateBalanceSheet(params)` | Generate Bilanz (balance sheet) |
| `generateCashFlowStatement(params)` | Generate cash flow statement |
| `generateGeneralLedger(params)` | Generate complete general ledger |
| `exportToDATEV(params)` | Export DATEV-compatible data |
| `closePeriod(period, options)` | Close accounting period |
| `recalculateBalances()` | Recalculate all account balances |
| `validateDoubleEntry(data)` | Validate transaction before posting |
| `getUnbalancedTransactions()` | Find integrity issues |
| `createBatchAccounts(accounts)` | Create multiple accounts at once |
## 🏆 Why Developers Love It
- **🎯 Zero Configuration**: Pre-configured SKR03/SKR04 accounts out of the box
- **🔄 Automatic Validation**: Never worry about unbalanced entries or wrong account types
- **📊 Real-time Analytics**: Instant financial insights with live balance updates
- **🛡️ SKR Compliance**: Validates against official SKR standards automatically
- **🚀 High Performance**: Optimized MongoDB queries and batch operations
- **📚 German Compliance**: Full HGB/GoBD compliance built-in
- **🤝 Type Safety**: Complete TypeScript definitions prevent runtime errors
- **🔍 Smart Validation**: Warns about non-standard accounts and type mismatches
## 📋 Requirements
@@ -380,14 +512,20 @@ setupAccounting().catch(console.error);
- **MongoDB** >= 5.0
- **TypeScript** >= 5.0 (for development)
## 🏆 Why Developers Love It
## 🔬 Testing
- **🎯 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
The module includes comprehensive test coverage with real-world scenarios:
```bash
# Run all tests
pnpm test
# Run specific test suites
pnpm test test/test.skr03.ts # SKR03 functionality
pnpm test test/test.skr04.ts # SKR04 functionality
pnpm test test/test.jahresabschluss.skr03.ts # Annual closing SKR03
pnpm test test/test.jahresabschluss.skr04.ts # Annual closing SKR04
```
## License and Legal Information

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}"
;;

41
test/helpers/setup.ts Normal file
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
};
};

View File

@@ -0,0 +1,548 @@
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');
});
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
// 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' },
{ accountNumber: '0210', debit: 120000, description: 'Gebäude' },
{ accountNumber: '0500', debit: 35000, description: 'Betriebs- und Geschäftsausstattung' },
{ accountNumber: '0400', debit: 8000, description: 'Fuhrpark' },
{ accountNumber: '1200', debit: 25000, description: 'Bank' },
{ accountNumber: '1000', debit: 2500, description: 'Kasse' },
{ accountNumber: '1400', debit: 18000, description: 'Forderungen' },
{ accountNumber: '3100', debit: 12000, description: 'Warenvorräte' },
// Credit all liability and equity accounts
{ accountNumber: '2000', credit: 150000, description: 'Eigenkapital' },
{ accountNumber: '2900', credit: 35000, description: 'Gewinnrücklagen' },
{ accountNumber: '1600', credit: 52500, description: 'Verbindlichkeiten L+L' },
{ accountNumber: '3300', credit: 28000, description: 'Verbindlichkeiten Kreditinstitute' },
],
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
await api.postJournalEntry({
date: new Date('2024-01-15'),
description: 'Verkauf Waren auf Rechnung',
reference: 'RE-2024-001',
lines: [
{ accountNumber: '1400', debit: 11900, description: 'Forderungen inkl. USt' },
{ accountNumber: '8400', credit: 10000, description: 'Erlöse 19% USt' },
{ accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%' },
],
skrType: 'SKR03',
});
// Purchase of materials with 19% VAT
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' },
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%' },
{ accountNumber: '1600', credit: 5950, description: 'Verbindlichkeiten' },
],
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' },
{ accountNumber: '6100', debit: 1600, description: 'Sozialversicherung AG-Anteil' },
{ accountNumber: '1200', credit: 9600, description: 'Banküberweisung' },
],
skrType: 'SKR03',
});
// 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' },
{ accountNumber: '1400', credit: 11900, description: 'Forderungsausgleich' },
],
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' },
{ accountNumber: '1200', credit: 2000, description: 'Banküberweisung' },
],
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' },
{ accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 238, description: 'Bankzahlung' },
],
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' },
{ accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 178.50, description: 'Bankzahlung' },
],
skrType: 'SKR03',
});
// Another sale
await api.postJournalEntry({
date: new Date('2024-03-20'),
description: 'Verkauf Dienstleistung',
reference: 'RE-2024-002',
lines: [
{ accountNumber: '1400', debit: 7140, description: 'Forderungen inkl. USt' },
{ accountNumber: '8400', credit: 6000, description: 'Erlöse 19% USt' },
{ accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%' },
],
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' },
{ accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 29750, description: 'Banküberweisung' },
],
skrType: 'SKR03',
});
// Q2: Large sale
await api.postJournalEntry({
date: new Date('2024-05-10'),
description: 'Großauftrag Kunde ABC',
reference: 'RE-2024-003',
lines: [
{ accountNumber: '1400', debit: 35700, description: 'Forderungen inkl. USt' },
{ accountNumber: '8400', credit: 30000, description: 'Erlöse 19% USt' },
{ accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%' },
],
skrType: 'SKR03',
});
// Q3: Marketing expenses
await api.postJournalEntry({
date: new Date('2024-07-10'),
description: 'Werbekampagne',
reference: 'WK-2024-001',
lines: [
{ accountNumber: '6600', debit: 5000, description: 'Werbekosten' },
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%' },
{ accountNumber: '1600', credit: 5950, description: 'Verbindlichkeiten' },
],
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' },
{ accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 2975, description: 'Banküberweisung' },
],
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' },
{ accountNumber: '6100', debit: 2000, description: 'Sozialversicherung AG-Anteil' },
{ accountNumber: '1200', credit: 12000, description: 'Banküberweisung' },
],
skrType: 'SKR03',
});
// 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' },
{ accountNumber: '1400', credit: 35700, description: 'Forderungsausgleich' },
],
skrType: 'SKR03',
});
});
tap.test('should perform year-end adjustments (Jahresabschlussbuchungen)', async () => {
// 1. Depreciation (Abschreibungen)
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' },
{ accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude' },
],
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' }, // (35000 + 25000) * 10%
{ accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA' },
],
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' },
{ accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark' },
],
skrType: 'SKR03',
});
// 2. Accruals (Rechnungsabgrenzung)
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' },
{ accountNumber: '7300', credit: 1000, description: 'Versicherungen' },
],
skrType: 'SKR03',
});
// 3. Provisions (Rückstellungen)
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' },
{ accountNumber: '3000', credit: 3000, description: 'Rückstellungen' },
],
skrType: 'SKR03',
});
// 4. Inventory adjustment
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' },
{ accountNumber: '5900', credit: 3000, description: 'Bestandsveränderungen' },
],
skrType: 'SKR03',
});
// 5. 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' }, // Total collected VAT
{ accountNumber: '1571', credit: 7191.50, description: 'Vorsteuer-Saldo' }, // Total input VAT
{ accountNumber: '1800', credit: 1548.50, description: 'USt-Zahllast' },
],
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
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' },
{ accountNumber: '9400', credit: 46000, description: 'GuV-Konto' },
],
skrType: 'SKR03',
});
// Close expense accounts
await api.postJournalEntry({
date: new Date('2024-12-31'),
description: 'Abschluss Aufwandskonten',
reference: 'AB-2024-002',
lines: [
{ accountNumber: '9400', debit: 45450, description: 'GuV-Konto' },
{ accountNumber: '7300', debit: 1000, description: 'Versicherung abschließen (credit balance)' },
{ accountNumber: '5900', debit: 3000, description: 'Bestandsveränderungen abschließen (credit balance)' },
{ accountNumber: '5400', credit: 5000, description: 'Wareneingang abschließen' },
{ accountNumber: '6000', credit: 18000, description: 'Löhne und Gehälter abschließen' },
{ accountNumber: '6100', credit: 3600, description: 'SV AG-Anteil abschließen' },
{ accountNumber: '7000', credit: 10000, description: 'AfA abschließen' },
{ accountNumber: '7100', credit: 2000, description: 'Miete abschließen' },
{ accountNumber: '7400', credit: 150, description: 'Kfz abschließen' },
{ accountNumber: '6600', credit: 5000, description: 'Werbung abschließen' },
{ accountNumber: '6700', credit: 5500, description: 'Beratung abschließen' },
{ accountNumber: '6800', credit: 200, description: 'Bürobedarf abschließen' },
],
skrType: 'SKR03',
});
// Transfer profit/loss to equity
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' },
{ accountNumber: '2900', credit: guv_result, description: 'Gewinnrücklagen' },
],
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' },
{ accountNumber: '9400', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen' },
],
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();

View File

@@ -0,0 +1,494 @@
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');
});
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
// 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' },
{ accountNumber: '0210', debit: 120000, description: 'Gebäude' },
{ accountNumber: '0500', debit: 35000, description: 'BGA' },
{ accountNumber: '0400', debit: 8000, description: 'Fuhrpark' },
{ accountNumber: '1200', debit: 25000, description: 'Bank' },
{ accountNumber: '1000', debit: 2500, description: 'Kasse' },
{ accountNumber: '1400', debit: 18000, description: 'Forderungen' },
// Credit all liability and equity accounts
{ accountNumber: '9000', credit: 150000, description: 'Eigenkapital' },
{ accountNumber: '9300', credit: 35000, description: 'Gewinnrücklagen' },
{ accountNumber: '1600', credit: 40500, description: 'Verbindlichkeiten L+L' },
{ accountNumber: '1700', credit: 28000, description: 'Sonstige Verbindlichkeiten' },
],
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: '1400', debit: 11900, description: 'Forderungen inkl. USt' },
{ accountNumber: '4300', credit: 10000, description: 'Erlöse 19% USt' },
{ accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%' },
],
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' },
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%' },
{ accountNumber: '1600', credit: 5950, description: 'Verbindlichkeiten' },
],
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' },
{ accountNumber: '2400', debit: 1600, description: 'Gehälter' },
{ accountNumber: '1200', credit: 9600, description: 'Banküberweisung' },
],
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' },
{ accountNumber: '1400', credit: 11900, description: 'Forderungsausgleich' },
],
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' },
{ accountNumber: '1200', credit: 2000, description: 'Banküberweisung' },
],
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' },
{ accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 238, description: 'Bankzahlung' },
],
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' },
{ accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 178.50, description: 'Bankzahlung' },
],
skrType: 'SKR04',
});
// Another sale
await api.postJournalEntry({
date: new Date('2024-03-20'),
description: 'Verkauf Dienstleistung',
reference: 'RE-2024-002',
lines: [
{ accountNumber: '1400', debit: 7140, description: 'Forderungen inkl. USt' },
{ accountNumber: '4300', credit: 6000, description: 'Erlöse 19% USt' },
{ accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%' },
],
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' },
{ accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 29750, description: 'Banküberweisung' },
],
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: '1400', debit: 35700, description: 'Forderungen inkl. USt' },
{ accountNumber: '4300', credit: 30000, description: 'Erlöse 19% USt' },
{ accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%' },
],
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' },
{ accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%' },
{ accountNumber: '1600', credit: 5950, description: 'Verbindlichkeiten' },
],
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' },
{ accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%' },
{ accountNumber: '1200', credit: 2975, description: 'Banküberweisung' },
],
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' },
{ accountNumber: '2400', debit: 2000, description: 'Gehälter Bonus' },
{ accountNumber: '1200', credit: 12000, description: 'Banküberweisung' },
],
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' },
{ accountNumber: '1400', credit: 35700, description: 'Forderungsausgleich' },
],
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' },
{ accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude' },
],
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' }, // (35000 + 25000) * 10%
{ accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA' },
],
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' },
{ accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark' },
],
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' },
{ accountNumber: '3200', credit: 1000, description: 'Versicherungen' },
],
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' },
{ accountNumber: '0800', credit: 3000, description: 'Rückstellungen' },
],
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' }, // Total collected VAT
{ accountNumber: '1571', credit: 7191.50, description: 'Vorsteuer-Saldo' }, // Total input VAT
{ accountNumber: '1700', credit: 1548.50, description: 'USt-Zahllast' },
],
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' },
{ accountNumber: '9500', credit: 46000, description: 'GuV-Konto' },
],
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' },
{ accountNumber: '3200', debit: 1000, description: 'Versicherung abschließen (credit balance)' },
{ accountNumber: '2100', credit: 5000, description: 'Bezogene Waren abschließen' },
{ accountNumber: '2300', credit: 18000, description: 'Löhne abschließen' },
{ accountNumber: '2400', credit: 3600, description: 'Gehälter abschließen' },
{ accountNumber: '3700', credit: 10000, description: 'AfA abschließen' },
{ accountNumber: '3000', credit: 2000, description: 'Miete abschließen' },
{ accountNumber: '3300', credit: 150, description: 'Kfz abschließen' },
{ accountNumber: '3400', credit: 5000, description: 'Werbung abschließen' },
{ accountNumber: '3500', credit: 5500, description: 'Beratung abschließen' },
{ accountNumber: '3100', credit: 200, description: 'Bürobedarf abschließen' },
],
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' },
{ accountNumber: '9300', credit: guv_result, description: 'Gewinnrücklagen' },
],
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' },
{ accountNumber: '9500', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen' },
],
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();

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');

View File

@@ -1,12 +1,16 @@
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();
api = new skr.SkrApi({
mongoDbUrl: 'mongodb://localhost:27017',
dbName: 'test_skr04',
mongoDbUrl: testConfig.mongoDbUrl,
dbName: `${testConfig.mongoDbName}_skr04`,
});
await api.initialize('SKR04');

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');

View File

@@ -158,7 +158,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 +169,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);
}
/**

View File

@@ -96,6 +96,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 +109,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;
@@ -204,6 +236,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();
@@ -230,28 +264,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
}));
const creditQueue = creditLines.map(l => ({
line: l,
remaining: l.credit || 0
}));
if (amount > 0) {
// Create transactions to balance the entry
for (const d of debitQueue) {
for (const c of creditQueue) {
const amount = Math.min(d.remaining, c.remaining);
if (amount > 0.0000001) { // small epsilon to avoid float artifacts
const transaction = await Transaction.createTransaction({
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;
}
}
}
@@ -299,6 +346,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();

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
*/

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;
});
}
@@ -453,9 +477,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;
});
}