246 lines
6.3 KiB
TypeScript
246 lines
6.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
}
|