- 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
301 lines
7.3 KiB
TypeScript
301 lines
7.3 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import { getDbSync } from './skr.database.js';
|
|
import { Account } from './skr.classes.account.js';
|
|
import type {
|
|
TSKRType,
|
|
TTransactionStatus,
|
|
ITransactionData,
|
|
} from './skr.types.js';
|
|
|
|
const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata;
|
|
|
|
@plugins.smartdata.Collection(() => getDbSync())
|
|
export class Transaction extends SmartDataDbDoc<Transaction, Transaction> {
|
|
@unI()
|
|
public id: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public transactionNumber: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public date: Date;
|
|
|
|
@svDb()
|
|
@index()
|
|
public debitAccount: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public creditAccount: string;
|
|
|
|
@svDb()
|
|
public amount: number;
|
|
|
|
@svDb()
|
|
@searchable()
|
|
public description: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public reference: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public skrType: TSKRType;
|
|
|
|
@svDb()
|
|
public vatAmount: number;
|
|
|
|
@svDb()
|
|
public costCenter: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public status: TTransactionStatus;
|
|
|
|
@svDb()
|
|
public reversalOf: string;
|
|
|
|
@svDb()
|
|
public reversedBy: string;
|
|
|
|
@svDb()
|
|
@index()
|
|
public period: string; // Format: YYYY-MM
|
|
|
|
@svDb()
|
|
public fiscalYear: number;
|
|
|
|
@svDb()
|
|
public createdAt: Date;
|
|
|
|
@svDb()
|
|
public postedAt: Date;
|
|
|
|
@svDb()
|
|
public createdBy: string;
|
|
|
|
constructor(data?: Partial<ITransactionData>) {
|
|
super();
|
|
|
|
if (data) {
|
|
this.id = plugins.smartunique.shortId();
|
|
this.transactionNumber = this.generateTransactionNumber();
|
|
this.date = data.date || new Date();
|
|
this.debitAccount = data.debitAccount || '';
|
|
this.creditAccount = data.creditAccount || '';
|
|
this.amount = data.amount || 0;
|
|
this.description = data.description || '';
|
|
this.reference = data.reference || '';
|
|
this.skrType = data.skrType || 'SKR03';
|
|
this.vatAmount = data.vatAmount || 0;
|
|
this.costCenter = data.costCenter || '';
|
|
this.status = 'pending';
|
|
this.reversalOf = '';
|
|
this.reversedBy = '';
|
|
|
|
// Set period and fiscal year
|
|
const transDate = new Date(this.date);
|
|
this.period = `${transDate.getFullYear()}-${String(transDate.getMonth() + 1).padStart(2, '0')}`;
|
|
this.fiscalYear = transDate.getFullYear();
|
|
|
|
this.createdAt = new Date();
|
|
this.postedAt = null;
|
|
this.createdBy = 'system';
|
|
}
|
|
}
|
|
|
|
private generateTransactionNumber(): string {
|
|
const timestamp = Date.now();
|
|
const random = Math.floor(Math.random() * 1000);
|
|
return `TXN-${timestamp}-${random}`;
|
|
}
|
|
|
|
public static async createTransaction(
|
|
data: ITransactionData,
|
|
): Promise<Transaction> {
|
|
const transaction = new Transaction(data);
|
|
await transaction.validateAndPost();
|
|
return transaction;
|
|
}
|
|
|
|
public static async getTransactionById(
|
|
id: string,
|
|
): Promise<Transaction | null> {
|
|
const transaction = await Transaction.getInstance({ id });
|
|
return transaction;
|
|
}
|
|
|
|
public static async getTransactionsByAccount(
|
|
accountNumber: string,
|
|
skrType: TSKRType,
|
|
): Promise<Transaction[]> {
|
|
const transactionsDebit = await Transaction.getInstances({
|
|
debitAccount: accountNumber,
|
|
skrType,
|
|
status: 'posted',
|
|
});
|
|
const transactionsCredit = await Transaction.getInstances({
|
|
creditAccount: accountNumber,
|
|
skrType,
|
|
status: 'posted',
|
|
});
|
|
const transactions = [...transactionsDebit, ...transactionsCredit];
|
|
return transactions;
|
|
}
|
|
|
|
public static async getTransactionsByPeriod(
|
|
period: string,
|
|
skrType: TSKRType,
|
|
): Promise<Transaction[]> {
|
|
const transactions = await Transaction.getInstances({
|
|
period,
|
|
skrType,
|
|
status: 'posted',
|
|
});
|
|
return transactions;
|
|
}
|
|
|
|
public static async getTransactionsByDateRange(
|
|
dateFrom: Date,
|
|
dateTo: Date,
|
|
skrType: TSKRType,
|
|
): Promise<Transaction[]> {
|
|
const allTransactions = await Transaction.getInstances({
|
|
skrType,
|
|
status: 'posted',
|
|
});
|
|
const transactions = allTransactions.filter(
|
|
(t) => t.date >= dateFrom && t.date <= dateTo,
|
|
);
|
|
return transactions;
|
|
}
|
|
|
|
public async validateAndPost(): Promise<void> {
|
|
// Validate transaction
|
|
await this.validateTransaction();
|
|
|
|
// Update account balances
|
|
await this.updateAccountBalances();
|
|
|
|
// Mark as posted
|
|
this.status = 'posted';
|
|
this.postedAt = new Date();
|
|
|
|
await this.save();
|
|
}
|
|
|
|
private async validateTransaction(): Promise<void> {
|
|
// Check if accounts exist
|
|
const debitAccount = await Account.getAccountByNumber(
|
|
this.debitAccount,
|
|
this.skrType,
|
|
);
|
|
const creditAccount = await Account.getAccountByNumber(
|
|
this.creditAccount,
|
|
this.skrType,
|
|
);
|
|
|
|
if (!debitAccount) {
|
|
throw new Error(
|
|
`Debit account ${this.debitAccount} not found for ${this.skrType}`,
|
|
);
|
|
}
|
|
|
|
if (!creditAccount) {
|
|
throw new Error(
|
|
`Credit account ${this.creditAccount} not found for ${this.skrType}`,
|
|
);
|
|
}
|
|
|
|
// Check if accounts are active
|
|
if (!debitAccount.isActive) {
|
|
throw new Error(`Debit account ${this.debitAccount} is not active`);
|
|
}
|
|
|
|
if (!creditAccount.isActive) {
|
|
throw new Error(`Credit account ${this.creditAccount} is not active`);
|
|
}
|
|
|
|
// Validate amount
|
|
if (this.amount <= 0) {
|
|
throw new Error('Transaction amount must be greater than zero');
|
|
}
|
|
|
|
// Check for same account
|
|
if (this.debitAccount === this.creditAccount) {
|
|
throw new Error('Debit and credit accounts cannot be the same');
|
|
}
|
|
}
|
|
|
|
private async updateAccountBalances(): Promise<void> {
|
|
const debitAccount = await Account.getAccountByNumber(
|
|
this.debitAccount,
|
|
this.skrType,
|
|
);
|
|
const creditAccount = await Account.getAccountByNumber(
|
|
this.creditAccount,
|
|
this.skrType,
|
|
);
|
|
|
|
if (debitAccount) {
|
|
await debitAccount.updateBalance(this.amount, 0);
|
|
}
|
|
|
|
if (creditAccount) {
|
|
await creditAccount.updateBalance(0, this.amount);
|
|
}
|
|
}
|
|
|
|
public async reverseTransaction(): Promise<Transaction> {
|
|
if (this.status !== 'posted') {
|
|
throw new Error('Can only reverse posted transactions');
|
|
}
|
|
|
|
if (this.reversedBy) {
|
|
throw new Error('Transaction has already been reversed');
|
|
}
|
|
|
|
// Create reversal transaction
|
|
const reversalData: ITransactionData = {
|
|
date: new Date(),
|
|
debitAccount: this.creditAccount, // Swap accounts
|
|
creditAccount: this.debitAccount, // Swap accounts
|
|
amount: this.amount,
|
|
description: `Reversal of ${this.transactionNumber}: ${this.description}`,
|
|
reference: `REV-${this.transactionNumber}`,
|
|
skrType: this.skrType,
|
|
vatAmount: this.vatAmount,
|
|
costCenter: this.costCenter,
|
|
};
|
|
|
|
const reversalTransaction = new Transaction(reversalData);
|
|
reversalTransaction.reversalOf = this.id;
|
|
await reversalTransaction.validateAndPost();
|
|
|
|
// Update original transaction
|
|
this.reversedBy = reversalTransaction.id;
|
|
this.status = 'reversed';
|
|
await this.save();
|
|
|
|
return reversalTransaction;
|
|
}
|
|
|
|
public async beforeSave(): Promise<void> {
|
|
// Additional validation before saving
|
|
if (!this.debitAccount || !this.creditAccount) {
|
|
throw new Error('Both debit and credit accounts are required');
|
|
}
|
|
|
|
if (!this.date) {
|
|
throw new Error('Transaction date is required');
|
|
}
|
|
|
|
if (!this.description) {
|
|
throw new Error('Transaction description is required');
|
|
}
|
|
}
|
|
}
|