2025-08-09 12:00:40 +00:00
|
|
|
import * as plugins from './plugins.js';
|
|
|
|
|
import { getDb, getDbSync } from './skr.database.js';
|
|
|
|
|
import type { TAccountType, TSKRType, IAccountData } from './skr.types.js';
|
|
|
|
|
|
|
|
|
|
const { SmartDataDbDoc, svDb, unI, index, searchable } = plugins.smartdata;
|
|
|
|
|
|
|
|
|
|
@plugins.smartdata.Collection(() => getDbSync())
|
|
|
|
|
export class Account extends SmartDataDbDoc<Account, Account> {
|
|
|
|
|
@unI()
|
|
|
|
|
public id: string;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
@index()
|
|
|
|
|
public accountNumber: string;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
@searchable()
|
|
|
|
|
public accountName: string;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
@index()
|
|
|
|
|
public accountClass: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public accountGroup: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public accountSubgroup: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public accountType: TAccountType;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
@index()
|
|
|
|
|
public skrType: TSKRType;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
@searchable()
|
|
|
|
|
public description: string;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public vatRate: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public balance: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public debitTotal: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public creditTotal: number;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public isActive: boolean;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public isSystemAccount: boolean;
|
|
|
|
|
|
2025-10-27 08:34:28 +00:00
|
|
|
@svDb()
|
|
|
|
|
public isAutomaticAccount: boolean;
|
|
|
|
|
|
2025-08-09 12:00:40 +00:00
|
|
|
@svDb()
|
|
|
|
|
public createdAt: Date;
|
|
|
|
|
|
|
|
|
|
@svDb()
|
|
|
|
|
public updatedAt: Date;
|
|
|
|
|
|
|
|
|
|
constructor(data?: Partial<IAccountData>) {
|
|
|
|
|
super();
|
|
|
|
|
|
|
|
|
|
if (data) {
|
|
|
|
|
this.id = plugins.smartunique.shortId();
|
|
|
|
|
this.accountNumber = data.accountNumber || '';
|
|
|
|
|
this.accountName = data.accountName || '';
|
|
|
|
|
this.accountClass = data.accountClass || 0;
|
|
|
|
|
this.accountType = data.accountType || 'asset';
|
|
|
|
|
this.skrType = data.skrType || 'SKR03';
|
|
|
|
|
this.description = data.description || '';
|
|
|
|
|
this.vatRate = data.vatRate || 0;
|
|
|
|
|
this.isActive = data.isActive !== undefined ? data.isActive : true;
|
|
|
|
|
|
|
|
|
|
// Parse account structure from number
|
|
|
|
|
if (this.accountNumber && this.accountNumber.length === 4) {
|
|
|
|
|
this.accountClass = parseInt(this.accountNumber[0]);
|
|
|
|
|
this.accountGroup = parseInt(this.accountNumber[1]);
|
|
|
|
|
this.accountSubgroup = parseInt(this.accountNumber[2]);
|
|
|
|
|
} else {
|
|
|
|
|
this.accountGroup = 0;
|
|
|
|
|
this.accountSubgroup = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.balance = 0;
|
|
|
|
|
this.debitTotal = 0;
|
|
|
|
|
this.creditTotal = 0;
|
|
|
|
|
this.isSystemAccount = true;
|
2025-10-27 08:34:28 +00:00
|
|
|
this.isAutomaticAccount = data.isAutomaticAccount || false;
|
2025-08-09 12:00:40 +00:00
|
|
|
this.createdAt = new Date();
|
|
|
|
|
this.updatedAt = new Date();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async createAccount(data: IAccountData): Promise<Account> {
|
|
|
|
|
const account = new Account(data);
|
|
|
|
|
await account.save();
|
|
|
|
|
return account;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async getAccountByNumber(
|
|
|
|
|
accountNumber: string,
|
|
|
|
|
skrType: TSKRType,
|
|
|
|
|
): Promise<Account | null> {
|
|
|
|
|
const account = await Account.getInstance({
|
|
|
|
|
accountNumber,
|
|
|
|
|
skrType,
|
|
|
|
|
});
|
|
|
|
|
return account;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async getAccountsByClass(
|
|
|
|
|
accountClass: number,
|
|
|
|
|
skrType: TSKRType,
|
|
|
|
|
): Promise<Account[]> {
|
|
|
|
|
const accounts = await Account.getInstances({
|
|
|
|
|
accountClass,
|
|
|
|
|
skrType,
|
|
|
|
|
isActive: true,
|
|
|
|
|
});
|
|
|
|
|
return accounts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async getAccountsByType(
|
|
|
|
|
accountType: TAccountType,
|
|
|
|
|
skrType: TSKRType,
|
|
|
|
|
): Promise<Account[]> {
|
|
|
|
|
const accounts = await Account.getInstances({
|
|
|
|
|
accountType,
|
|
|
|
|
skrType,
|
|
|
|
|
isActive: true,
|
|
|
|
|
});
|
|
|
|
|
return accounts;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static async searchAccounts(
|
|
|
|
|
searchTerm: string,
|
|
|
|
|
skrType?: TSKRType,
|
|
|
|
|
): Promise<Account[]> {
|
|
|
|
|
const query: any = {};
|
|
|
|
|
if (skrType) {
|
|
|
|
|
query.skrType = skrType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const accounts = await Account.getInstances(query);
|
|
|
|
|
|
|
|
|
|
// Filter by search term
|
|
|
|
|
const lowerSearchTerm = searchTerm.toLowerCase();
|
|
|
|
|
return accounts.filter(
|
|
|
|
|
(account) =>
|
|
|
|
|
account.accountNumber.includes(searchTerm) ||
|
|
|
|
|
account.accountName.toLowerCase().includes(lowerSearchTerm) ||
|
|
|
|
|
account.description.toLowerCase().includes(lowerSearchTerm),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 08:34:28 +00:00
|
|
|
/**
|
|
|
|
|
* Check if account number is in debtor range (10000-69999)
|
|
|
|
|
* Debtor accounts (Debitorenkonten) are individual customer accounts
|
|
|
|
|
*/
|
|
|
|
|
public static isInDebtorRange(accountNumber: string): boolean {
|
|
|
|
|
const num = parseInt(accountNumber);
|
|
|
|
|
return num >= 10000 && num <= 69999;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if account number is in creditor range (70000-99999)
|
|
|
|
|
* Creditor accounts (Kreditorenkonten) are individual vendor accounts
|
|
|
|
|
*/
|
|
|
|
|
public static isInCreditorRange(accountNumber: string): boolean {
|
|
|
|
|
const num = parseInt(accountNumber);
|
|
|
|
|
return num >= 70000 && num <= 99999;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if account is an automatic account (Automatikkonto)
|
|
|
|
|
* Automatic accounts like 1400/1600 cannot be posted to directly
|
|
|
|
|
*/
|
|
|
|
|
public static isAutomaticAccount(accountNumber: string, skrType: TSKRType): boolean {
|
|
|
|
|
// SKR03: 1400 (Forderungen), 1600 (Verbindlichkeiten)
|
2025-10-28 08:50:32 +00:00
|
|
|
// SKR04: 1400 (Forderungen), 1600 (Verbindlichkeiten)
|
|
|
|
|
// Note: In SKR04, 3300 is "Fahrzeugkosten" (vehicle costs), NOT an automatic account
|
2025-10-27 08:34:28 +00:00
|
|
|
if (skrType === 'SKR03') {
|
|
|
|
|
return accountNumber === '1400' || accountNumber === '1600';
|
|
|
|
|
} else {
|
2025-10-28 08:50:32 +00:00
|
|
|
return accountNumber === '1400' || accountNumber === '1600';
|
2025-10-27 08:34:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate account for posting - throws error if account cannot be posted to
|
|
|
|
|
*/
|
|
|
|
|
public static async validateAccountForPosting(
|
|
|
|
|
accountNumber: string,
|
|
|
|
|
skrType: TSKRType,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
// Check if automatic account
|
|
|
|
|
if (Account.isAutomaticAccount(accountNumber, skrType)) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Account ${accountNumber} is an automatic account (Automatikkonto) and cannot be posted to directly. ` +
|
|
|
|
|
`Use debtor accounts (10000-69999) or creditor accounts (70000-99999) instead.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get account to verify it exists
|
|
|
|
|
const account = await Account.getAccountByNumber(accountNumber, skrType);
|
|
|
|
|
if (!account) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Account ${accountNumber} not found in ${skrType}. ` +
|
|
|
|
|
`Please create the account before posting.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if account is active
|
|
|
|
|
if (!account.isActive) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Account ${accountNumber} is inactive and cannot be posted to.`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this account instance is a debtor account
|
|
|
|
|
*/
|
|
|
|
|
public isDebtorAccount(): boolean {
|
|
|
|
|
return Account.isInDebtorRange(this.accountNumber);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if this account instance is a creditor account
|
|
|
|
|
*/
|
|
|
|
|
public isCreditorAccount(): boolean {
|
|
|
|
|
return Account.isInCreditorRange(this.accountNumber);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-09 12:00:40 +00:00
|
|
|
public async updateBalance(
|
|
|
|
|
debitAmount: number = 0,
|
|
|
|
|
creditAmount: number = 0,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
this.debitTotal += debitAmount;
|
|
|
|
|
this.creditTotal += creditAmount;
|
|
|
|
|
|
|
|
|
|
// Calculate balance based on account type
|
|
|
|
|
switch (this.accountType) {
|
|
|
|
|
case 'asset':
|
|
|
|
|
case 'expense':
|
|
|
|
|
// Normal debit accounts
|
|
|
|
|
this.balance = this.debitTotal - this.creditTotal;
|
|
|
|
|
break;
|
|
|
|
|
case 'liability':
|
|
|
|
|
case 'equity':
|
|
|
|
|
case 'revenue':
|
|
|
|
|
// Normal credit accounts
|
|
|
|
|
this.balance = this.creditTotal - this.debitTotal;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.updatedAt = new Date();
|
|
|
|
|
await this.save();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async deactivate(): Promise<void> {
|
|
|
|
|
this.isActive = false;
|
|
|
|
|
this.updatedAt = new Date();
|
|
|
|
|
await this.save();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async activate(): Promise<void> {
|
|
|
|
|
this.isActive = true;
|
|
|
|
|
this.updatedAt = new Date();
|
|
|
|
|
await this.save();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public getNormalBalance(): 'debit' | 'credit' {
|
|
|
|
|
switch (this.accountType) {
|
|
|
|
|
case 'asset':
|
|
|
|
|
case 'expense':
|
|
|
|
|
return 'debit';
|
|
|
|
|
case 'liability':
|
|
|
|
|
case 'equity':
|
|
|
|
|
case 'revenue':
|
|
|
|
|
return 'credit';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async beforeSave(): Promise<void> {
|
|
|
|
|
// Validate account number format
|
2025-10-27 08:34:28 +00:00
|
|
|
const accountLength = this.accountNumber?.length || 0;
|
|
|
|
|
if (!this.accountNumber || (accountLength !== 4 && accountLength !== 5)) {
|
2025-08-09 12:00:40 +00:00
|
|
|
throw new Error(
|
2025-10-27 08:34:28 +00:00
|
|
|
`Invalid account number format: ${this.accountNumber}. Must be 4 digits (standard SKR) or 5 digits (debtor/creditor).`,
|
2025-08-09 12:00:40 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate account number is numeric
|
2025-10-27 08:34:28 +00:00
|
|
|
if (!/^\d{4,5}$/.test(this.accountNumber)) {
|
2025-08-09 12:00:40 +00:00
|
|
|
throw new Error(
|
|
|
|
|
`Account number must contain only digits: ${this.accountNumber}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 08:34:28 +00:00
|
|
|
// For 5-digit accounts, validate they are in debtor (10000-69999) or creditor (70000-99999) ranges
|
|
|
|
|
if (accountLength === 5) {
|
|
|
|
|
const accountNum = parseInt(this.accountNumber);
|
|
|
|
|
const isDebtor = accountNum >= 10000 && accountNum <= 69999;
|
|
|
|
|
const isCreditor = accountNum >= 70000 && accountNum <= 99999;
|
|
|
|
|
|
|
|
|
|
if (!isDebtor && !isCreditor) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`5-digit account number ${this.accountNumber} must be in debtor range (10000-69999) or creditor range (70000-99999).`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-09 12:00:40 +00:00
|
|
|
// Validate account class matches first digit
|
|
|
|
|
const firstDigit = parseInt(this.accountNumber[0]);
|
|
|
|
|
if (this.accountClass !== firstDigit) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Account class ${this.accountClass} does not match account number ${this.accountNumber}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate SKR type
|
|
|
|
|
if (this.skrType !== 'SKR03' && this.skrType !== 'SKR04') {
|
|
|
|
|
throw new Error(`Invalid SKR type: ${this.skrType}`);
|
|
|
|
|
}
|
2025-10-27 08:34:28 +00:00
|
|
|
|
|
|
|
|
// Mark automatic accounts (Automatikkonten)
|
|
|
|
|
// These are summary accounts that cannot be posted to directly
|
|
|
|
|
if (Account.isAutomaticAccount(this.accountNumber, this.skrType)) {
|
|
|
|
|
this.isAutomaticAccount = true;
|
|
|
|
|
}
|
2025-08-09 12:00:40 +00:00
|
|
|
}
|
|
|
|
|
}
|