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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ 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 {
|
||||
validatePostingKey,
|
||||
validatePostingKeyConsistency,
|
||||
getPostingKeyDescription,
|
||||
} from './skr.postingkeys.js';
|
||||
import type {
|
||||
TSKRType,
|
||||
IJournalEntry,
|
||||
@@ -212,22 +217,84 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
throw new Error('Journal entry must have at least 2 lines');
|
||||
}
|
||||
|
||||
// Validate all accounts exist and are active
|
||||
// Validate all accounts exist, are active, and can be posted to
|
||||
const validationErrors: string[] = [];
|
||||
const validationWarnings: string[] = [];
|
||||
|
||||
for (const line of this.lines) {
|
||||
// Validate posting key is present (REQUIRED)
|
||||
if (!line.postingKey) {
|
||||
validationErrors.push(
|
||||
`Line for account ${line.accountNumber} is missing required posting key (Buchungsschlüssel). ` +
|
||||
`Posting keys are mandatory for DATEV compliance.`
|
||||
);
|
||||
continue; // Skip further validation for this line
|
||||
}
|
||||
|
||||
// Validate account is not an automatic account (Automatikkonto)
|
||||
try {
|
||||
await Account.validateAccountForPosting(line.accountNumber, this.skrType);
|
||||
} catch (error) {
|
||||
validationErrors.push(error.message);
|
||||
continue; // Skip further validation for this line
|
||||
}
|
||||
|
||||
// Get account for posting key validation
|
||||
const account = await Account.getAccountByNumber(
|
||||
line.accountNumber,
|
||||
this.skrType,
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(
|
||||
validationErrors.push(
|
||||
`Account ${line.accountNumber} not found for ${this.skrType}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!account.isActive) {
|
||||
throw new Error(`Account ${line.accountNumber} is not active`);
|
||||
validationErrors.push(`Account ${line.accountNumber} is not active`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate posting key for this line
|
||||
const amount = line.debit || line.credit || 0;
|
||||
const postingKeyValidation = validatePostingKey(
|
||||
line.postingKey,
|
||||
line.accountNumber,
|
||||
amount
|
||||
);
|
||||
|
||||
if (!postingKeyValidation.isValid) {
|
||||
validationErrors.push(...postingKeyValidation.errors);
|
||||
}
|
||||
|
||||
if (postingKeyValidation.warnings.length > 0) {
|
||||
validationWarnings.push(...postingKeyValidation.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate posting key consistency across all lines
|
||||
const consistencyValidation = validatePostingKeyConsistency(this.lines);
|
||||
if (!consistencyValidation.isValid) {
|
||||
validationErrors.push(...consistencyValidation.errors);
|
||||
}
|
||||
if (consistencyValidation.warnings.length > 0) {
|
||||
validationWarnings.push(...consistencyValidation.warnings);
|
||||
}
|
||||
|
||||
// Log warnings but don't fail validation
|
||||
if (validationWarnings.length > 0) {
|
||||
console.warn('Journal entry validation warnings:');
|
||||
validationWarnings.forEach(warning => console.warn(` - ${warning}`));
|
||||
}
|
||||
|
||||
// Throw if any errors
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(
|
||||
'Journal entry validation failed:\n' +
|
||||
validationErrors.map(e => ` - ${e}`).join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +392,7 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
|
||||
credit: line.debit, // Swap
|
||||
description: `Reversal: ${line.description || ''}`,
|
||||
costCenter: line.costCenter,
|
||||
postingKey: line.postingKey, // Keep same posting key for reversal
|
||||
}));
|
||||
|
||||
const reversalEntry = new JournalEntry({
|
||||
|
||||
@@ -418,6 +418,7 @@ export class Ledger {
|
||||
accountNumber: account.accountNumber,
|
||||
debit: Math.abs(balance),
|
||||
description: `Closing ${account.accountName}`,
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
totalRevenue += Math.abs(balance);
|
||||
}
|
||||
@@ -429,6 +430,7 @@ export class Ledger {
|
||||
accountNumber: closingAccountNumber,
|
||||
credit: totalRevenue,
|
||||
description: 'Revenue closing to P&L',
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
|
||||
const revenueClosingEntry = await this.postJournalEntry({
|
||||
@@ -458,6 +460,7 @@ export class Ledger {
|
||||
accountNumber: account.accountNumber,
|
||||
credit: Math.abs(balance),
|
||||
description: `Closing ${account.accountName}`,
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
totalExpense += Math.abs(balance);
|
||||
}
|
||||
@@ -469,6 +472,7 @@ export class Ledger {
|
||||
accountNumber: closingAccountNumber,
|
||||
debit: totalExpense,
|
||||
description: 'Expense closing to P&L',
|
||||
postingKey: 40, // Tax-free - internal closing entry
|
||||
});
|
||||
|
||||
const expenseClosingEntry = await this.postJournalEntry({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { JournalEntry } from './skr.classes.journalentry.js';
|
||||
import { SKRInvoiceMapper } from './skr.invoice.mapper.js';
|
||||
import { suggestPostingKey } from './skr.postingkeys.js';
|
||||
import type { TSKRType, IJournalEntry, IJournalEntryLine } from './skr.types.js';
|
||||
import type {
|
||||
IInvoice,
|
||||
@@ -196,14 +197,16 @@ export class InvoiceBookingEngine {
|
||||
lines.push({
|
||||
accountNumber,
|
||||
credit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group)
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% input VAT for expenses
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: debit expense account
|
||||
lines.push({
|
||||
accountNumber,
|
||||
debit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group)
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% input VAT for expenses
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -221,14 +224,16 @@ export class InvoiceBookingEngine {
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
debit: totalAmount,
|
||||
description: `${invoice.supplier.name} - Credit Note ${invoice.invoiceNumber}`
|
||||
description: `${invoice.supplier.name} - Credit Note ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: credit vendor account
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
credit: totalAmount,
|
||||
description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}`
|
||||
description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,14 +262,16 @@ export class InvoiceBookingEngine {
|
||||
lines.push({
|
||||
accountNumber,
|
||||
debit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group)
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% output VAT for revenue
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: credit revenue account
|
||||
lines.push({
|
||||
accountNumber,
|
||||
credit: Math.abs(amount),
|
||||
description: this.getAccountDescription(accountNumber, group)
|
||||
description: this.getAccountDescription(accountNumber, group),
|
||||
postingKey: 9 // 19% output VAT for revenue
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -282,14 +289,16 @@ export class InvoiceBookingEngine {
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
credit: totalAmount,
|
||||
description: `${invoice.customer.name} - Credit Note ${invoice.invoiceNumber}`
|
||||
description: `${invoice.customer.name} - Credit Note ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
} else {
|
||||
// Regular invoice: debit customer account
|
||||
lines.push({
|
||||
accountNumber: controlAccount,
|
||||
debit: totalAmount,
|
||||
description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}`
|
||||
description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}`,
|
||||
postingKey: 40 // Tax-free for control account
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,20 +334,23 @@ export class InvoiceBookingEngine {
|
||||
|
||||
const amount = Math.abs(vatBreak.taxAmount);
|
||||
const description = `VAT ${vatBreak.vatCategory.rate}%`;
|
||||
|
||||
const vatRate = vatBreak.vatCategory.rate;
|
||||
// Select posting key based on VAT rate: 8 for 7%, 9 for 19%
|
||||
const postingKey = vatRate === 7 ? 8 : 9;
|
||||
|
||||
if (direction === 'input') {
|
||||
// Input VAT (Vorsteuer)
|
||||
if (reverseDirection) {
|
||||
lines.push({ accountNumber: vatAccount, credit: amount, description });
|
||||
lines.push({ accountNumber: vatAccount, credit: amount, description, postingKey });
|
||||
} else {
|
||||
lines.push({ accountNumber: vatAccount, debit: amount, description });
|
||||
lines.push({ accountNumber: vatAccount, debit: amount, description, postingKey });
|
||||
}
|
||||
} else {
|
||||
// Output VAT (Umsatzsteuer)
|
||||
if (reverseDirection) {
|
||||
lines.push({ accountNumber: vatAccount, debit: amount, description });
|
||||
lines.push({ accountNumber: vatAccount, debit: amount, description, postingKey });
|
||||
} else {
|
||||
lines.push({ accountNumber: vatAccount, credit: amount, description });
|
||||
lines.push({ accountNumber: vatAccount, credit: amount, description, postingKey });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -404,12 +416,14 @@ export class InvoiceBookingEngine {
|
||||
{
|
||||
accountNumber: inputVATAccount,
|
||||
debit: amount,
|
||||
description: `Reverse charge input VAT ${vatBreak.vatCategory.rate}%`
|
||||
description: `Reverse charge input VAT ${vatBreak.vatCategory.rate}%`,
|
||||
postingKey: 94 // Reverse charge posting key
|
||||
},
|
||||
{
|
||||
accountNumber: outputVATAccount,
|
||||
credit: amount,
|
||||
description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%`
|
||||
description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%`,
|
||||
postingKey: 94 // Reverse charge posting key
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -462,24 +476,27 @@ export class InvoiceBookingEngine {
|
||||
{
|
||||
accountNumber: controlAccount,
|
||||
debit: fullAmount,
|
||||
description: `Payment to ${invoice.supplier.name}`
|
||||
description: `Payment to ${invoice.supplier.name}`,
|
||||
postingKey: 3 // Payment with VAT
|
||||
},
|
||||
{
|
||||
accountNumber: '1000', // Bank account (would be configurable)
|
||||
credit: paymentAmount,
|
||||
description: `Bank payment ${payment.endToEndId || payment.paymentId}`
|
||||
description: `Bank payment ${payment.endToEndId || payment.paymentId}`,
|
||||
postingKey: 40 // Tax-free for bank account
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Book skonto if taken
|
||||
if (skontoAmount > 0) {
|
||||
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
||||
lines.push({
|
||||
accountNumber: skontoAccounts.skontoAccount,
|
||||
credit: skontoAmount,
|
||||
description: `Skonto received`
|
||||
description: `Skonto received`,
|
||||
postingKey: 40 // Tax-free for skonto
|
||||
});
|
||||
|
||||
|
||||
// VAT correction for skonto
|
||||
if (rules.skontoMethod === 'gross') {
|
||||
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
||||
@@ -488,7 +505,8 @@ export class InvoiceBookingEngine {
|
||||
{
|
||||
accountNumber: skontoAccounts.vatCorrectionAccount,
|
||||
credit: vatCorrection,
|
||||
description: `Skonto VAT correction`
|
||||
description: `Skonto VAT correction`,
|
||||
postingKey: 40 // Tax-free for correction
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -499,24 +517,27 @@ export class InvoiceBookingEngine {
|
||||
{
|
||||
accountNumber: '1000', // Bank account
|
||||
debit: paymentAmount,
|
||||
description: `Payment from ${invoice.customer.name}`
|
||||
description: `Payment from ${invoice.customer.name}`,
|
||||
postingKey: 40 // Tax-free for bank account
|
||||
},
|
||||
{
|
||||
accountNumber: controlAccount,
|
||||
credit: fullAmount,
|
||||
description: `Customer payment ${payment.endToEndId || payment.paymentId}`
|
||||
description: `Customer payment ${payment.endToEndId || payment.paymentId}`,
|
||||
postingKey: 3 // Payment with VAT
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Book skonto if granted
|
||||
if (skontoAmount > 0) {
|
||||
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
||||
lines.push({
|
||||
accountNumber: skontoAccounts.skontoAccount,
|
||||
debit: skontoAmount,
|
||||
description: `Skonto granted`
|
||||
description: `Skonto granted`,
|
||||
postingKey: 40 // Tax-free for skonto
|
||||
});
|
||||
|
||||
|
||||
// VAT correction for skonto
|
||||
if (rules.skontoMethod === 'gross') {
|
||||
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
||||
@@ -525,7 +546,8 @@ export class InvoiceBookingEngine {
|
||||
{
|
||||
accountNumber: skontoAccounts.vatCorrectionAccount,
|
||||
debit: vatCorrection,
|
||||
description: `Skonto VAT correction`
|
||||
description: `Skonto VAT correction`,
|
||||
postingKey: 40 // Tax-free for correction
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
245
ts/skr.postingkeys.ts
Normal file
245
ts/skr.postingkeys.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* DATEV Posting Keys (Buchungsschlüssel) for German Accounting
|
||||
*
|
||||
* Posting keys control automatic VAT booking and are automatically checked
|
||||
* in German tax audits (Betriebsprüfungen). Using incorrect posting keys
|
||||
* can have serious tax consequences.
|
||||
*
|
||||
* Reference: DATEV Buchungsschlüssel-Verzeichnis
|
||||
*/
|
||||
|
||||
import type { TPostingKey, IPostingKeyRule } from './skr.types.js';
|
||||
|
||||
/**
|
||||
* Posting key definitions with validation rules
|
||||
*/
|
||||
export const POSTING_KEY_RULES: Record<TPostingKey, IPostingKeyRule> = {
|
||||
3: {
|
||||
key: 3,
|
||||
description: 'Zahlungseingang mit 19% Umsatzsteuer',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['domestic_taxed']
|
||||
},
|
||||
8: {
|
||||
key: 8,
|
||||
description: '7% Vorsteuer',
|
||||
vatRate: 7,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['domestic_taxed']
|
||||
},
|
||||
9: {
|
||||
key: 9,
|
||||
description: '19% Vorsteuer',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['domestic_taxed']
|
||||
},
|
||||
19: {
|
||||
key: 19,
|
||||
description: '19% Vorsteuer bei innergemeinschaftlichen Lieferungen',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['intra_eu']
|
||||
},
|
||||
40: {
|
||||
key: 40,
|
||||
description: 'Steuerfrei / Aufhebung der Automatik',
|
||||
vatRate: 0,
|
||||
requiresVAT: false,
|
||||
disablesVATAutomatism: true,
|
||||
allowedScenarios: ['tax_free', 'export', 'reverse_charge']
|
||||
},
|
||||
94: {
|
||||
key: 94,
|
||||
description: '19% Vorsteuer/Umsatzsteuer bei Erwerb aus EU oder Drittland (Reverse Charge)',
|
||||
vatRate: 19,
|
||||
requiresVAT: true,
|
||||
disablesVATAutomatism: false,
|
||||
allowedScenarios: ['reverse_charge', 'intra_eu', 'third_country']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate posting key for a journal entry line
|
||||
*/
|
||||
export function validatePostingKey(
|
||||
postingKey: TPostingKey,
|
||||
accountNumber: string,
|
||||
amount: number,
|
||||
vatAmount?: number,
|
||||
taxScenario?: string
|
||||
): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Get posting key rule
|
||||
const rule = POSTING_KEY_RULES[postingKey];
|
||||
if (!rule) {
|
||||
errors.push(`Invalid posting key: ${postingKey}`);
|
||||
return { isValid: false, errors, warnings };
|
||||
}
|
||||
|
||||
// Validate VAT requirement
|
||||
if (rule.requiresVAT && !vatAmount) {
|
||||
errors.push(
|
||||
`Posting key ${postingKey} requires VAT amount, but none provided. ` +
|
||||
`Description: ${rule.description}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate VAT rate if specified
|
||||
if (rule.vatRate && vatAmount && rule.vatRate > 0) {
|
||||
const expectedVAT = Math.round(amount * rule.vatRate) / 100;
|
||||
const tolerance = 0.02; // 2 cent tolerance for rounding
|
||||
|
||||
if (Math.abs(vatAmount - expectedVAT) > tolerance) {
|
||||
warnings.push(
|
||||
`VAT amount ${vatAmount} does not match expected ${expectedVAT.toFixed(2)} ` +
|
||||
`for posting key ${postingKey} (${rule.vatRate}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate tax scenario
|
||||
if (rule.allowedScenarios && taxScenario) {
|
||||
if (!rule.allowedScenarios.includes(taxScenario)) {
|
||||
errors.push(
|
||||
`Posting key ${postingKey} is not valid for tax scenario '${taxScenario}'. ` +
|
||||
`Allowed scenarios: ${rule.allowedScenarios.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate automatism disabling
|
||||
if (rule.disablesVATAutomatism && vatAmount && vatAmount > 0) {
|
||||
warnings.push(
|
||||
`Posting key ${postingKey} disables VAT automatism but VAT amount is provided. ` +
|
||||
`This may cause incorrect tax reporting.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posting key description
|
||||
*/
|
||||
export function getPostingKeyDescription(postingKey: TPostingKey): string {
|
||||
const rule = POSTING_KEY_RULES[postingKey];
|
||||
return rule ? rule.description : `Unknown posting key: ${postingKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get appropriate posting key for a transaction
|
||||
*/
|
||||
export function suggestPostingKey(params: {
|
||||
vatRate: number;
|
||||
taxScenario?: string;
|
||||
isPayment?: boolean;
|
||||
}): TPostingKey {
|
||||
const { vatRate, taxScenario, isPayment } = params;
|
||||
|
||||
// Tax-free or reverse charge scenarios
|
||||
if (taxScenario === 'tax_free' || taxScenario === 'export') {
|
||||
return 40;
|
||||
}
|
||||
|
||||
// Reverse charge
|
||||
if (taxScenario === 'reverse_charge' || taxScenario === 'third_country') {
|
||||
return 94;
|
||||
}
|
||||
|
||||
// Intra-EU with VAT
|
||||
if (taxScenario === 'intra_eu' && vatRate === 19) {
|
||||
return 19;
|
||||
}
|
||||
|
||||
// Payment with 19% VAT
|
||||
if (isPayment && vatRate === 19) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
// Input VAT based on rate
|
||||
if (vatRate === 19) {
|
||||
return 9;
|
||||
}
|
||||
|
||||
if (vatRate === 7) {
|
||||
return 8;
|
||||
}
|
||||
|
||||
// Default to tax-free if no VAT
|
||||
if (vatRate === 0) {
|
||||
return 40;
|
||||
}
|
||||
|
||||
// Fallback to 19% input VAT
|
||||
return 9;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all posting keys for consistency
|
||||
*/
|
||||
export function validatePostingKeyConsistency(lines: Array<{
|
||||
postingKey: TPostingKey;
|
||||
accountNumber: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
vatAmount?: number;
|
||||
}>): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Check for mixing tax-free and taxed transactions
|
||||
const hasTaxFree = lines.some(line => line.postingKey === 40);
|
||||
const hasTaxed = lines.some(line => [3, 8, 9, 19, 94].includes(line.postingKey));
|
||||
|
||||
if (hasTaxFree && hasTaxed) {
|
||||
warnings.push(
|
||||
'Journal entry mixes tax-free (key 40) and taxed transactions. ' +
|
||||
'Verify this is intentional.'
|
||||
);
|
||||
}
|
||||
|
||||
// Check for reverse charge consistency
|
||||
const hasReverseCharge = lines.some(line => line.postingKey === 94);
|
||||
if (hasReverseCharge) {
|
||||
const reverseChargeLines = lines.filter(line => line.postingKey === 94);
|
||||
if (reverseChargeLines.length % 2 !== 0) {
|
||||
errors.push(
|
||||
'Reverse charge (posting key 94) requires both input and output VAT entries. ' +
|
||||
'Found odd number of reverse charge lines.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if posting key requires automatic VAT booking
|
||||
*/
|
||||
export function requiresAutomaticVAT(postingKey: TPostingKey): boolean {
|
||||
const rule = POSTING_KEY_RULES[postingKey];
|
||||
return rule ? !rule.disablesVATAutomatism : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid posting keys
|
||||
*/
|
||||
export function getAllPostingKeys(): TPostingKey[] {
|
||||
return Object.keys(POSTING_KEY_RULES).map(k => Number(k) as TPostingKey);
|
||||
}
|
||||
@@ -9,6 +9,18 @@ export type TSKRType = 'SKR03' | 'SKR04';
|
||||
|
||||
export type TTransactionStatus = 'pending' | 'posted' | 'reversed';
|
||||
|
||||
/**
|
||||
* DATEV posting keys (Buchungsschlüssel) for German accounting
|
||||
* These keys control automatic VAT booking and are checked in tax audits
|
||||
*/
|
||||
export type TPostingKey =
|
||||
| 3 // Payment with 19% VAT
|
||||
| 8 // 7% input VAT
|
||||
| 9 // 19% input VAT
|
||||
| 19 // 19% input VAT (intra-EU)
|
||||
| 40 // Tax-free (disables VAT automatism)
|
||||
| 94; // 19% input/output VAT (reverse charge)
|
||||
|
||||
export type TReportType =
|
||||
| 'trial_balance'
|
||||
| 'income_statement'
|
||||
@@ -16,6 +28,18 @@ export type TReportType =
|
||||
| 'general_ledger'
|
||||
| 'cash_flow';
|
||||
|
||||
/**
|
||||
* Posting key validation rule
|
||||
*/
|
||||
export interface IPostingKeyRule {
|
||||
key: TPostingKey;
|
||||
description: string;
|
||||
vatRate?: number; // Expected VAT rate (if applicable)
|
||||
requiresVAT: boolean; // Whether VAT entry is required
|
||||
disablesVATAutomatism: boolean; // Whether this key disables automatic VAT
|
||||
allowedScenarios?: string[]; // Allowed tax scenarios (e.g., 'reverse_charge')
|
||||
}
|
||||
|
||||
export interface IAccountData {
|
||||
accountNumber: string;
|
||||
accountName: string;
|
||||
@@ -25,6 +49,7 @@ export interface IAccountData {
|
||||
description?: string;
|
||||
vatRate?: number;
|
||||
isActive?: boolean;
|
||||
isAutomaticAccount?: boolean; // Automatikkonto (e.g., 1400, 1600) - cannot be posted to directly
|
||||
}
|
||||
|
||||
export interface ITransactionData {
|
||||
@@ -53,6 +78,7 @@ export interface IJournalEntryLine {
|
||||
credit?: number;
|
||||
description?: string;
|
||||
costCenter?: string;
|
||||
postingKey: TPostingKey; // REQUIRED: DATEV posting key for VAT automation control
|
||||
}
|
||||
|
||||
export interface ITrialBalanceEntry {
|
||||
|
||||
@@ -159,6 +159,7 @@ export const SKR03_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR03',
|
||||
description: 'Trade receivables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use debtor accounts (10000-69999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1500',
|
||||
@@ -199,6 +200,7 @@ export const SKR03_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR03',
|
||||
description: 'Trade payables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use creditor accounts (70000-99999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1700',
|
||||
|
||||
@@ -159,6 +159,7 @@ export const SKR04_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'asset',
|
||||
skrType: 'SKR04',
|
||||
description: 'Trade receivables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use debtor accounts (10000-69999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1500',
|
||||
@@ -199,6 +200,7 @@ export const SKR04_ACCOUNTS: IAccountData[] = [
|
||||
accountType: 'liability',
|
||||
skrType: 'SKR04',
|
||||
description: 'Trade payables',
|
||||
isAutomaticAccount: true, // Automatikkonto - cannot be posted to directly, use creditor accounts (70000-99999)
|
||||
},
|
||||
{
|
||||
accountNumber: '1700',
|
||||
|
||||
Reference in New Issue
Block a user