- 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
319 lines
8.4 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
}
|