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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user