Files
skr/ts/skr.classes.journalentry.ts
Juergen Kunz 8a9056e767
Some checks failed
Default (tags) / security (push) Successful in 44s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
feat(core): initial release of SKR03/SKR04 German accounting standards implementation
- 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
2025-08-09 12:00:40 +00:00

319 lines
8.4 KiB
TypeScript

import * as plugins from './plugins.js';
import { getDbSync } from './skr.database.js';
import { Account } from './skr.classes.account.js';
import { Transaction } from './skr.classes.transaction.js';
import type {
TSKRType,
IJournalEntry,
IJournalEntryLine,
} from './skr.types.js';
const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata;
@plugins.smartdata.Collection(() => getDbSync())
export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
@unI()
public id: string;
@svDb()
@index()
public journalNumber: string;
@svDb()
@index()
public date: Date;
@svDb()
@searchable()
public description: string;
@svDb()
@index()
public reference: string;
@svDb()
public lines: IJournalEntryLine[];
@svDb()
@index()
public skrType: TSKRType;
@svDb()
public totalDebits: number;
@svDb()
public totalCredits: number;
@svDb()
public isBalanced: boolean;
@svDb()
@index()
public status: 'draft' | 'posted' | 'reversed';
@svDb()
public transactionIds: string[];
@svDb()
@index()
public period: string;
@svDb()
public fiscalYear: number;
@svDb()
public createdAt: Date;
@svDb()
public postedAt: Date;
@svDb()
public createdBy: string;
constructor(data?: Partial<IJournalEntry>) {
super();
if (data) {
this.id = plugins.smartunique.shortId();
this.journalNumber = this.generateJournalNumber();
this.date = data.date || new Date();
this.description = data.description || '';
this.reference = data.reference || '';
this.lines = data.lines || [];
this.skrType = data.skrType || 'SKR03';
this.totalDebits = 0;
this.totalCredits = 0;
this.isBalanced = false;
this.status = 'draft';
this.transactionIds = [];
// Set period and fiscal year
const entryDate = new Date(this.date);
this.period = `${entryDate.getFullYear()}-${String(entryDate.getMonth() + 1).padStart(2, '0')}`;
this.fiscalYear = entryDate.getFullYear();
this.createdAt = new Date();
this.postedAt = null;
this.createdBy = 'system';
// Calculate totals
this.calculateTotals();
}
}
private generateJournalNumber(): string {
const timestamp = Date.now();
const random = Math.floor(Math.random() * 1000);
return `JE-${timestamp}-${random}`;
}
private calculateTotals(): void {
this.totalDebits = 0;
this.totalCredits = 0;
for (const line of this.lines) {
if (line.debit) {
this.totalDebits += line.debit;
}
if (line.credit) {
this.totalCredits += line.credit;
}
}
// Check if balanced (allowing for small rounding differences)
const difference = Math.abs(this.totalDebits - this.totalCredits);
this.isBalanced = difference < 0.01;
}
public static async createJournalEntry(
data: IJournalEntry,
): Promise<JournalEntry> {
const journalEntry = new JournalEntry(data);
await journalEntry.validate();
await journalEntry.save();
return journalEntry;
}
public addLine(line: IJournalEntryLine): void {
// Validate line
if (!line.accountNumber) {
throw new Error('Account number is required for journal entry line');
}
if (!line.debit && !line.credit) {
throw new Error('Either debit or credit amount is required');
}
if (line.debit && line.credit) {
throw new Error('A line cannot have both debit and credit amounts');
}
if (line.debit && line.debit < 0) {
throw new Error('Debit amount must be positive');
}
if (line.credit && line.credit < 0) {
throw new Error('Credit amount must be positive');
}
this.lines.push(line);
this.calculateTotals();
}
public removeLine(index: number): void {
if (index >= 0 && index < this.lines.length) {
this.lines.splice(index, 1);
this.calculateTotals();
}
}
public async validate(): Promise<void> {
// Check if entry is balanced
if (!this.isBalanced) {
throw new Error(
`Journal entry is not balanced. Debits: ${this.totalDebits}, Credits: ${this.totalCredits}`,
);
}
// Check minimum lines
if (this.lines.length < 2) {
throw new Error('Journal entry must have at least 2 lines');
}
// Validate all accounts exist and are active
for (const line of this.lines) {
const account = await Account.getAccountByNumber(
line.accountNumber,
this.skrType,
);
if (!account) {
throw new Error(
`Account ${line.accountNumber} not found for ${this.skrType}`,
);
}
if (!account.isActive) {
throw new Error(`Account ${line.accountNumber} is not active`);
}
}
}
public async post(): Promise<void> {
if (this.status === 'posted') {
throw new Error('Journal entry is already posted');
}
// Validate before posting
await this.validate();
// Create individual transactions for each debit-credit pair
const transactions: Transaction[] = [];
// Simple posting logic: match debits with credits
// For complex entries, this could be enhanced with specific pairing logic
const debitLines = this.lines.filter((l) => l.debit);
const creditLines = this.lines.filter((l) => l.credit);
if (debitLines.length === 1 && creditLines.length === 1) {
// Simple entry: one debit, one credit
const transaction = await Transaction.createTransaction({
date: this.date,
debitAccount: debitLines[0].accountNumber,
creditAccount: creditLines[0].accountNumber,
amount: debitLines[0].debit,
description: this.description,
reference: this.reference,
skrType: this.skrType,
costCenter: debitLines[0].costCenter,
});
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);
if (amount > 0) {
const transaction = await Transaction.createTransaction({
date: this.date,
debitAccount: debitLine.accountNumber,
creditAccount: creditLine.accountNumber,
amount: amount,
description: `${this.description} - ${debitLine.description || creditLine.description || ''}`,
reference: this.reference,
skrType: this.skrType,
costCenter: debitLine.costCenter || creditLine.costCenter,
});
transactions.push(transaction);
// Reduce amounts for tracking
if (debitLine.debit) debitLine.debit -= amount;
if (creditLine.credit) creditLine.credit -= amount;
}
}
}
}
// Store transaction IDs
this.transactionIds = transactions.map((t) => t.id);
// Update status
this.status = 'posted';
this.postedAt = new Date();
await this.save();
}
public async reverse(): Promise<JournalEntry> {
if (this.status !== 'posted') {
throw new Error('Can only reverse posted journal entries');
}
// Create reversal entry with swapped debits and credits
const reversalLines: IJournalEntryLine[] = this.lines.map((line) => ({
accountNumber: line.accountNumber,
debit: line.credit, // Swap
credit: line.debit, // Swap
description: `Reversal: ${line.description || ''}`,
costCenter: line.costCenter,
}));
const reversalEntry = new JournalEntry({
date: new Date(),
description: `Reversal of ${this.journalNumber}: ${this.description}`,
reference: `REV-${this.journalNumber}`,
lines: reversalLines,
skrType: this.skrType,
});
await reversalEntry.validate();
await reversalEntry.post();
// Update original entry status
this.status = 'reversed';
await this.save();
return reversalEntry;
}
public async beforeSave(): Promise<void> {
// Recalculate totals before saving
this.calculateTotals();
// Validate required fields
if (!this.date) {
throw new Error('Journal entry date is required');
}
if (!this.description) {
throw new Error('Journal entry description is required');
}
if (this.lines.length === 0) {
throw new Error('Journal entry must have at least one line');
}
}
}