feat: Enhance journal entry and transaction handling with posting keys

- Added posting key support to SKR03 and SKR04 journal entries and transactions to ensure DATEV compliance.
- Implemented validation for posting keys in journal entries, ensuring all lines have a posting key and that they are consistent across the entry.
- Introduced automatic account checks to prevent posting to accounts that cannot be directly posted to (e.g., 1400, 1600).
- Updated account validation to include checks for debtor and creditor ranges.
- Enhanced invoice booking logic to include appropriate posting keys based on VAT rates and scenarios.
- Created a new module for posting key definitions and validation rules, including functions for validating posting keys and suggesting appropriate keys based on transaction parameters.
- Updated tests to cover new posting key functionality and ensure compliance with accounting rules.
This commit is contained in:
2025-10-27 08:34:28 +00:00
parent 73b46f7857
commit 4f1066da2e
13 changed files with 758 additions and 229 deletions

View File

@@ -56,6 +56,9 @@ export class Account extends SmartDataDbDoc<Account, Account> {
@svDb()
public isSystemAccount: boolean;
@svDb()
public isAutomaticAccount: boolean;
@svDb()
public createdAt: Date;
@@ -90,6 +93,7 @@ export class Account extends SmartDataDbDoc<Account, Account> {
this.debitTotal = 0;
this.creditTotal = 0;
this.isSystemAccount = true;
this.isAutomaticAccount = data.isAutomaticAccount || false;
this.createdAt = new Date();
this.updatedAt = new Date();
}
@@ -157,6 +161,84 @@ export class Account extends SmartDataDbDoc<Account, Account> {
);
}
/**
* 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)
// SKR04: 1400 (Forderungen), 1600 (Verbindlichkeiten), 3300 (Alternative Verbindlichkeiten)
if (skrType === 'SKR03') {
return accountNumber === '1400' || accountNumber === '1600';
} else {
return accountNumber === '1400' || accountNumber === '1600' || accountNumber === '3300';
}
}
/**
* 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);
}
public async updateBalance(
debitAmount: number = 0,
creditAmount: number = 0,
@@ -209,19 +291,33 @@ export class Account extends SmartDataDbDoc<Account, Account> {
public async beforeSave(): Promise<void> {
// Validate account number format
if (!this.accountNumber || this.accountNumber.length !== 4) {
const accountLength = this.accountNumber?.length || 0;
if (!this.accountNumber || (accountLength !== 4 && accountLength !== 5)) {
throw new Error(
`Invalid account number format: ${this.accountNumber}. Must be 4 digits.`,
`Invalid account number format: ${this.accountNumber}. Must be 4 digits (standard SKR) or 5 digits (debtor/creditor).`,
);
}
// Validate account number is numeric
if (!/^\d{4}$/.test(this.accountNumber)) {
if (!/^\d{4,5}$/.test(this.accountNumber)) {
throw new Error(
`Account number must contain only digits: ${this.accountNumber}`,
);
}
// 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).`,
);
}
}
// Validate account class matches first digit
const firstDigit = parseInt(this.accountNumber[0]);
if (this.accountClass !== firstDigit) {
@@ -234,5 +330,11 @@ export class Account extends SmartDataDbDoc<Account, Account> {
if (this.skrType !== 'SKR03' && this.skrType !== 'SKR04') {
throw new Error(`Invalid SKR type: ${this.skrType}`);
}
// Mark automatic accounts (Automatikkonten)
// These are summary accounts that cannot be posted to directly
if (Account.isAutomaticAccount(this.accountNumber, this.skrType)) {
this.isAutomaticAccount = true;
}
}
}