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
This commit is contained in:
318
ts/skr.classes.journalentry.ts
Normal file
318
ts/skr.classes.journalentry.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user