feat(validation): add SKR standard validation for account compliance
Some checks failed
Default (tags) / security (push) Successful in 43s
Default (tags) / test (push) Failing after 4m4s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped

This commit is contained in:
2025-08-11 11:06:49 +00:00
parent db46612ea2
commit 08d7803be2
8 changed files with 890 additions and 150 deletions

View File

@@ -9,6 +9,14 @@ import type {
IJournalEntryLine,
IAccountBalance,
} from './skr.types.js';
import { SKR03_ACCOUNTS } from './skr03.data.js';
import { SKR04_ACCOUNTS } from './skr04.data.js';
// Module-level Maps for O(1) SKR standard lookups
const STANDARD_SKR_MAP = {
SKR03: new Map(SKR03_ACCOUNTS.map(a => [a.accountNumber, a])),
SKR04: new Map(SKR04_ACCOUNTS.map(a => [a.accountNumber, a])),
};
export class Ledger {
private logger: plugins.smartlog.Smartlog;
@@ -81,6 +89,12 @@ export class Ledger {
const accountNumbers = journalData.lines.map((line) => line.accountNumber);
await this.validateAccounts(accountNumbers);
// Validate against SKR standard (warnings only by default)
await this.validateAccountsAgainstSKR(journalData.lines, {
strict: false, // Start with warnings only
warnOnNameMismatch: false // Names vary, don't spam logs
});
// Validate journal entry is balanced
this.validateJournalBalance(journalData.lines);
@@ -139,6 +153,77 @@ export class Ledger {
}
}
/**
* Validate accounts against SKR standard data
*/
private async validateAccountsAgainstSKR(
lines: IJournalEntryLine[],
options?: { strict?: boolean; warnOnNameMismatch?: boolean }
): Promise<void> {
const { strict = false, warnOnNameMismatch = false } = options || {};
const skrMap = STANDARD_SKR_MAP[this.skrType];
if (!skrMap) {
this.logger.log('warn', `No SKR standard map available for ${this.skrType}`);
return;
}
const uniqueAccountNumbers = [...new Set(lines.map(line => line.accountNumber))];
for (const accountNumber of uniqueAccountNumbers) {
const standardAccount = skrMap.get(accountNumber);
if (!standardAccount) {
// Special case: SKR04 class 8 is designated for custom accounts ("frei")
if (this.skrType === 'SKR04' && accountNumber.startsWith('8')) {
this.logger.log('debug', `Account ${accountNumber} is in SKR04 class 8 (custom accounts allowed)`);
continue;
}
const message = `Account ${accountNumber} is not a standard ${this.skrType} account`;
if (strict) {
throw new Error(message);
} else {
this.logger.log('warn', message);
}
continue;
}
// Get actual account from database to compare
const dbAccount = await Account.getAccountByNumber(accountNumber, this.skrType);
if (!dbAccount) {
// Account doesn't exist in DB, will be caught by validateAccounts()
continue;
}
// Validate type and class match SKR standard
if (dbAccount.accountType !== standardAccount.accountType) {
const message = `Account ${accountNumber} type mismatch: expected '${standardAccount.accountType}', got '${dbAccount.accountType}'`;
if (strict) {
throw new Error(message);
} else {
this.logger.log('warn', message);
}
}
if (dbAccount.accountClass !== standardAccount.accountClass) {
const message = `Account ${accountNumber} class mismatch: expected ${standardAccount.accountClass}, got ${dbAccount.accountClass}`;
if (strict) {
throw new Error(message);
} else {
this.logger.log('warn', message);
}
}
// Warn on name mismatch (common and acceptable in practice)
if (warnOnNameMismatch && dbAccount.accountName !== standardAccount.accountName) {
this.logger.log('info',
`Account ${accountNumber} name differs from SKR standard: '${dbAccount.accountName}' vs '${standardAccount.accountName}'`
);
}
}
}
/**
* Reverse a transaction
*/