- Complete implementation of German standard charts of accounts - SKR03 (Process Structure Principle) for trading/service companies - SKR04 (Financial Classification Principle) for manufacturing companies - Double-entry bookkeeping with MongoDB persistence - Comprehensive reporting suite with DATEV export - Full TypeScript support and type safety
509 lines
14 KiB
TypeScript
509 lines
14 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import { getDb, closeDb } from './skr.database.js';
|
|
import { Account } from './skr.classes.account.js';
|
|
import { Transaction } from './skr.classes.transaction.js';
|
|
import { JournalEntry } from './skr.classes.journalentry.js';
|
|
import { SKR03_ACCOUNTS, SKR03_ACCOUNT_CLASSES } from './skr03.data.js';
|
|
import { SKR04_ACCOUNTS, SKR04_ACCOUNT_CLASSES } from './skr04.data.js';
|
|
import type {
|
|
IDatabaseConfig,
|
|
TSKRType,
|
|
IAccountData,
|
|
IAccountFilter,
|
|
ITransactionFilter,
|
|
ITransactionData,
|
|
IJournalEntry,
|
|
} from './skr.types.js';
|
|
|
|
export class ChartOfAccounts {
|
|
private logger: plugins.smartlog.Smartlog;
|
|
private initialized: boolean = false;
|
|
private skrType: TSKRType | null = null;
|
|
|
|
constructor(private config?: IDatabaseConfig) {
|
|
this.logger = new plugins.smartlog.Smartlog({
|
|
logContext: {
|
|
company: 'fin.cx',
|
|
companyunit: 'skr',
|
|
containerName: 'ChartOfAccounts',
|
|
environment: 'local',
|
|
runtime: 'node',
|
|
zone: 'local',
|
|
},
|
|
});
|
|
this.logger.enableConsole();
|
|
}
|
|
|
|
/**
|
|
* Initialize the database connection
|
|
*/
|
|
public async init(): Promise<void> {
|
|
if (this.initialized) {
|
|
this.logger.log('info', 'ChartOfAccounts already initialized');
|
|
return;
|
|
}
|
|
|
|
if (!this.config) {
|
|
throw new Error('Database configuration required for initialization');
|
|
}
|
|
|
|
await getDb(this.config);
|
|
this.initialized = true;
|
|
this.logger.log('info', 'ChartOfAccounts initialized successfully');
|
|
}
|
|
|
|
/**
|
|
* Initialize SKR03 chart of accounts
|
|
*/
|
|
public async initializeSKR03(): Promise<void> {
|
|
await this.init();
|
|
|
|
this.logger.log('info', 'Initializing SKR03 chart of accounts');
|
|
|
|
// Check if SKR03 accounts already exist
|
|
const existingAccounts = await Account.getInstances({ skrType: 'SKR03' });
|
|
if (existingAccounts.length > 0) {
|
|
this.logger.log(
|
|
'info',
|
|
`SKR03 already initialized with ${existingAccounts.length} accounts`,
|
|
);
|
|
this.skrType = 'SKR03';
|
|
return;
|
|
}
|
|
|
|
// Create all SKR03 accounts
|
|
const accounts: Account[] = [];
|
|
for (const accountData of SKR03_ACCOUNTS) {
|
|
const account = await Account.createAccount(accountData);
|
|
accounts.push(account);
|
|
}
|
|
|
|
this.skrType = 'SKR03';
|
|
this.logger.log(
|
|
'info',
|
|
`Successfully initialized SKR03 with ${accounts.length} accounts`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Initialize SKR04 chart of accounts
|
|
*/
|
|
public async initializeSKR04(): Promise<void> {
|
|
await this.init();
|
|
|
|
this.logger.log('info', 'Initializing SKR04 chart of accounts');
|
|
|
|
// Check if SKR04 accounts already exist
|
|
const existingAccounts = await Account.getInstances({ skrType: 'SKR04' });
|
|
if (existingAccounts.length > 0) {
|
|
this.logger.log(
|
|
'info',
|
|
`SKR04 already initialized with ${existingAccounts.length} accounts`,
|
|
);
|
|
this.skrType = 'SKR04';
|
|
return;
|
|
}
|
|
|
|
// Create all SKR04 accounts
|
|
const accounts: Account[] = [];
|
|
for (const accountData of SKR04_ACCOUNTS) {
|
|
const account = await Account.createAccount(accountData);
|
|
accounts.push(account);
|
|
}
|
|
|
|
this.skrType = 'SKR04';
|
|
this.logger.log(
|
|
'info',
|
|
`Successfully initialized SKR04 with ${accounts.length} accounts`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the current SKR type
|
|
*/
|
|
public getSKRType(): TSKRType | null {
|
|
return this.skrType;
|
|
}
|
|
|
|
/**
|
|
* Set the active SKR type
|
|
*/
|
|
public setSKRType(skrType: TSKRType): void {
|
|
this.skrType = skrType;
|
|
}
|
|
|
|
/**
|
|
* Get account by number
|
|
*/
|
|
public async getAccountByNumber(
|
|
accountNumber: string,
|
|
): Promise<Account | null> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
return await Account.getAccountByNumber(accountNumber, this.skrType);
|
|
}
|
|
|
|
/**
|
|
* Get accounts by class
|
|
*/
|
|
public async getAccountsByClass(accountClass: number): Promise<Account[]> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
return await Account.getAccountsByClass(accountClass, this.skrType);
|
|
}
|
|
|
|
/**
|
|
* Get accounts by type
|
|
*/
|
|
public async getAccountsByType(
|
|
accountType: IAccountData['accountType'],
|
|
): Promise<Account[]> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
return await Account.getAccountsByType(accountType, this.skrType);
|
|
}
|
|
|
|
/**
|
|
* Create a custom account
|
|
*/
|
|
public async createCustomAccount(
|
|
accountData: Partial<IAccountData>,
|
|
): Promise<Account> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
// Ensure the account uses the current SKR type
|
|
const fullAccountData: IAccountData = {
|
|
accountNumber: accountData.accountNumber || '',
|
|
accountName: accountData.accountName || '',
|
|
accountClass: accountData.accountClass || 0,
|
|
accountType: accountData.accountType || 'asset',
|
|
skrType: this.skrType,
|
|
description: accountData.description,
|
|
vatRate: accountData.vatRate,
|
|
isActive:
|
|
accountData.isActive !== undefined ? accountData.isActive : true,
|
|
};
|
|
|
|
// Validate account number doesn't already exist
|
|
const existing = await this.getAccountByNumber(
|
|
fullAccountData.accountNumber,
|
|
);
|
|
if (existing) {
|
|
throw new Error(
|
|
`Account ${fullAccountData.accountNumber} already exists`,
|
|
);
|
|
}
|
|
|
|
return await Account.createAccount(fullAccountData);
|
|
}
|
|
|
|
/**
|
|
* Update an existing account
|
|
*/
|
|
public async updateAccount(
|
|
accountNumber: string,
|
|
updates: Partial<IAccountData>,
|
|
): Promise<Account> {
|
|
const account = await this.getAccountByNumber(accountNumber);
|
|
if (!account) {
|
|
throw new Error(`Account ${accountNumber} not found`);
|
|
}
|
|
|
|
// Apply updates
|
|
if (updates.accountName !== undefined)
|
|
account.accountName = updates.accountName;
|
|
if (updates.description !== undefined)
|
|
account.description = updates.description;
|
|
if (updates.vatRate !== undefined) account.vatRate = updates.vatRate;
|
|
if (updates.isActive !== undefined) account.isActive = updates.isActive;
|
|
|
|
account.updatedAt = new Date();
|
|
await account.save();
|
|
|
|
return account;
|
|
}
|
|
|
|
/**
|
|
* Delete a custom account (only non-system accounts)
|
|
*/
|
|
public async deleteAccount(accountNumber: string): Promise<void> {
|
|
const account = await this.getAccountByNumber(accountNumber);
|
|
if (!account) {
|
|
throw new Error(`Account ${accountNumber} not found`);
|
|
}
|
|
|
|
if (account.isSystemAccount) {
|
|
throw new Error(`Cannot delete system account ${accountNumber}`);
|
|
}
|
|
|
|
// Check if account has transactions
|
|
const transactions = await Transaction.getTransactionsByAccount(
|
|
accountNumber,
|
|
account.skrType,
|
|
);
|
|
if (transactions.length > 0) {
|
|
throw new Error(
|
|
`Cannot delete account ${accountNumber} with existing transactions`,
|
|
);
|
|
}
|
|
|
|
await account.delete();
|
|
}
|
|
|
|
/**
|
|
* Search accounts
|
|
*/
|
|
public async searchAccounts(searchTerm: string): Promise<Account[]> {
|
|
return await Account.searchAccounts(searchTerm, this.skrType);
|
|
}
|
|
|
|
/**
|
|
* Get all accounts
|
|
*/
|
|
public async getAllAccounts(filter?: IAccountFilter): Promise<Account[]> {
|
|
const query: any = {};
|
|
|
|
if (this.skrType) {
|
|
query.skrType = this.skrType;
|
|
}
|
|
|
|
if (filter) {
|
|
if (filter.accountClass !== undefined)
|
|
query.accountClass = filter.accountClass;
|
|
if (filter.accountType !== undefined)
|
|
query.accountType = filter.accountType;
|
|
if (filter.isActive !== undefined) query.isActive = filter.isActive;
|
|
}
|
|
|
|
const accounts = await Account.getInstances(query);
|
|
|
|
// Apply text search if provided
|
|
if (filter?.searchTerm) {
|
|
const lowerSearchTerm = filter.searchTerm.toLowerCase();
|
|
return accounts.filter(
|
|
(account) =>
|
|
account.accountNumber.includes(filter.searchTerm) ||
|
|
account.accountName.toLowerCase().includes(lowerSearchTerm) ||
|
|
account.description.toLowerCase().includes(lowerSearchTerm),
|
|
);
|
|
}
|
|
|
|
return accounts;
|
|
}
|
|
|
|
/**
|
|
* Post a simple transaction
|
|
*/
|
|
public async postTransaction(
|
|
transactionData: ITransactionData,
|
|
): Promise<Transaction> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
// Ensure the transaction uses the current SKR type
|
|
const fullTransactionData: ITransactionData = {
|
|
...transactionData,
|
|
skrType: this.skrType,
|
|
};
|
|
|
|
return await Transaction.createTransaction(fullTransactionData);
|
|
}
|
|
|
|
/**
|
|
* Post a journal entry
|
|
*/
|
|
public async postJournalEntry(
|
|
journalData: IJournalEntry,
|
|
): Promise<JournalEntry> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
// Ensure the journal entry uses the current SKR type
|
|
const fullJournalData: IJournalEntry = {
|
|
...journalData,
|
|
skrType: this.skrType,
|
|
};
|
|
|
|
const journalEntry = await JournalEntry.createJournalEntry(fullJournalData);
|
|
await journalEntry.post();
|
|
|
|
return journalEntry;
|
|
}
|
|
|
|
/**
|
|
* Get transactions for an account
|
|
*/
|
|
public async getAccountTransactions(
|
|
accountNumber: string,
|
|
): Promise<Transaction[]> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
return await Transaction.getTransactionsByAccount(
|
|
accountNumber,
|
|
this.skrType,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get transactions by filter
|
|
*/
|
|
public async getTransactions(
|
|
filter?: ITransactionFilter,
|
|
): Promise<Transaction[]> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
const query: any = {
|
|
skrType: this.skrType,
|
|
status: 'posted',
|
|
};
|
|
|
|
if (filter) {
|
|
if (filter.dateFrom || filter.dateTo) {
|
|
query.date = {};
|
|
if (filter.dateFrom) query.date.$gte = filter.dateFrom;
|
|
if (filter.dateTo) query.date.$lte = filter.dateTo;
|
|
}
|
|
|
|
if (filter.accountNumber) {
|
|
query.$or = [
|
|
{ debitAccount: filter.accountNumber },
|
|
{ creditAccount: filter.accountNumber },
|
|
];
|
|
}
|
|
|
|
if (filter.minAmount || filter.maxAmount) {
|
|
query.amount = {};
|
|
if (filter.minAmount) query.amount.$gte = filter.minAmount;
|
|
if (filter.maxAmount) query.amount.$lte = filter.maxAmount;
|
|
}
|
|
}
|
|
|
|
const transactions = await Transaction.getInstances(query);
|
|
|
|
// Apply text search if provided
|
|
if (filter?.searchTerm) {
|
|
const lowerSearchTerm = filter.searchTerm.toLowerCase();
|
|
return transactions.filter(
|
|
(transaction) =>
|
|
transaction.description.toLowerCase().includes(lowerSearchTerm) ||
|
|
transaction.reference.toLowerCase().includes(lowerSearchTerm),
|
|
);
|
|
}
|
|
|
|
return transactions;
|
|
}
|
|
|
|
/**
|
|
* Reverse a transaction
|
|
*/
|
|
public async reverseTransaction(transactionId: string): Promise<Transaction> {
|
|
const transaction = await Transaction.getTransactionById(transactionId);
|
|
if (!transaction) {
|
|
throw new Error(`Transaction ${transactionId} not found`);
|
|
}
|
|
|
|
return await transaction.reverseTransaction();
|
|
}
|
|
|
|
/**
|
|
* Get account class description
|
|
*/
|
|
public getAccountClassDescription(accountClass: number): string {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
const classes =
|
|
this.skrType === 'SKR03' ? SKR03_ACCOUNT_CLASSES : SKR04_ACCOUNT_CLASSES;
|
|
return (
|
|
classes[accountClass as keyof typeof classes] || `Class ${accountClass}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Import accounts from CSV
|
|
*/
|
|
public async importAccountsFromCSV(csvContent: string): Promise<number> {
|
|
if (!this.skrType) {
|
|
throw new Error('SKR type not set. Initialize SKR03 or SKR04 first.');
|
|
}
|
|
|
|
const lines = csvContent.split('\n').filter((line) => line.trim());
|
|
let importedCount = 0;
|
|
|
|
for (const line of lines) {
|
|
// Parse CSV line (expecting format: "account";"name";"description";"type";"active")
|
|
const parts = line
|
|
.split(';')
|
|
.map((part) => part.replace(/"/g, '').trim());
|
|
|
|
if (parts.length >= 5) {
|
|
const accountData: IAccountData = {
|
|
accountNumber: parts[0],
|
|
accountName: parts[1],
|
|
accountClass: parseInt(parts[0][0]),
|
|
accountType: parts[3] as IAccountData['accountType'],
|
|
skrType: this.skrType,
|
|
description: parts[2],
|
|
isActive:
|
|
parts[4].toLowerCase() === 'standard' ||
|
|
parts[4].toLowerCase() === 'active',
|
|
};
|
|
|
|
try {
|
|
await this.createCustomAccount(accountData);
|
|
importedCount++;
|
|
} catch (error) {
|
|
this.logger.log(
|
|
'warn',
|
|
`Failed to import account ${parts[0]}: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return importedCount;
|
|
}
|
|
|
|
/**
|
|
* Export accounts to CSV
|
|
*/
|
|
public async exportAccountsToCSV(): Promise<string> {
|
|
const accounts = await this.getAllAccounts();
|
|
|
|
const csvLines: string[] = [];
|
|
csvLines.push('"Account";"Name";"Description";"Type";"Active"');
|
|
|
|
for (const account of accounts) {
|
|
csvLines.push(
|
|
`"${account.accountNumber}";"${account.accountName}";"${account.description}";"${account.accountType}";"${account.isActive ? 'Active' : 'Inactive'}"`,
|
|
);
|
|
}
|
|
|
|
return csvLines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Close the database connection
|
|
*/
|
|
public async close(): Promise<void> {
|
|
await closeDb();
|
|
this.initialized = false;
|
|
this.logger.log('info', 'ChartOfAccounts closed');
|
|
}
|
|
}
|