Files
skr/ts/skr.invoice.booking.ts
Juergen Kunz 73b46f7857
Some checks failed
Default (tags) / security (push) Successful in 48s
Default (tags) / test (push) Failing after 4m3s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
feat(invoice): add e-invoice support with XRechnung/ZUGFeRD and advanced export features
2025-08-12 12:37:01 +00:00

738 lines
22 KiB
TypeScript

import * as plugins from './plugins.js';
import { JournalEntry } from './skr.classes.journalentry.js';
import { SKRInvoiceMapper } from './skr.invoice.mapper.js';
import type { TSKRType, IJournalEntry, IJournalEntryLine } from './skr.types.js';
import type {
IInvoice,
IInvoiceLine,
IBookingRules,
IBookingInfo,
TTaxScenario,
IPaymentInfo
} from './skr.invoice.entity.js';
/**
* Options for booking an invoice
*/
export interface IBookingOptions {
autoBook?: boolean;
confidenceThreshold?: number;
bookingDate?: Date;
bookingReference?: string;
skipValidation?: boolean;
}
/**
* Result of booking an invoice
*/
export interface IBookingResult {
success: boolean;
journalEntry?: JournalEntry;
bookingInfo?: IBookingInfo;
confidence: number;
warnings?: string[];
errors?: string[];
}
/**
* Automatic booking engine for invoices
* Creates journal entries from invoice data based on SKR mapping rules
*/
export class InvoiceBookingEngine {
private logger: plugins.smartlog.ConsoleLog;
private skrType: TSKRType;
private mapper: SKRInvoiceMapper;
constructor(skrType: TSKRType) {
this.skrType = skrType;
this.mapper = new SKRInvoiceMapper(skrType);
this.logger = new plugins.smartlog.ConsoleLog();
}
/**
* Book an invoice to the ledger
*/
public async bookInvoice(
invoice: IInvoice,
bookingRules?: Partial<IBookingRules>,
options?: IBookingOptions
): Promise<IBookingResult> {
try {
// Get complete booking rules
const rules = this.mapper.mapInvoiceToSKR(invoice, bookingRules);
// Calculate confidence
const confidence = this.mapper.calculateConfidence(invoice, rules);
// Check if auto-booking is allowed
if (options?.autoBook && confidence < (options.confidenceThreshold || 80)) {
return {
success: false,
confidence,
warnings: [`Confidence score ${confidence}% is below threshold ${options.confidenceThreshold || 80}%`]
};
}
// Validate invoice before booking
if (!options?.skipValidation) {
const validationErrors = this.validateInvoice(invoice);
if (validationErrors.length > 0) {
return {
success: false,
confidence,
errors: validationErrors
};
}
}
// Build journal entry
const journalEntry = await this.buildJournalEntry(invoice, rules, options);
// Create booking info
const bookingInfo: IBookingInfo = {
journalEntryId: journalEntry.id,
transactionIds: journalEntry.transactionIds || [],
bookedAt: new Date(),
bookedBy: 'system',
bookingRules: {
vendorAccount: rules.vendorControlAccount,
customerAccount: rules.customerControlAccount,
expenseAccounts: this.getUsedExpenseAccounts(invoice, rules),
revenueAccounts: this.getUsedRevenueAccounts(invoice, rules),
vatAccounts: this.getUsedVATAccounts(invoice, rules)
},
confidence,
autoBooked: options?.autoBook || false
};
// Post the journal entry
// TODO: When MongoDB transactions are available, wrap this in a transaction
// Example: await db.withTransaction(async (session) => { ... })
try {
await journalEntry.validate();
await journalEntry.post();
// Mark invoice as posted if we have a reference to it
if (invoice.status !== 'posted') {
invoice.status = 'posted';
}
} catch (postError) {
this.logger.log('error', `Failed to post journal entry: ${postError}`);
throw postError; // Re-throw to trigger rollback when transactions are available
}
return {
success: true,
journalEntry,
bookingInfo,
confidence,
warnings: this.generateWarnings(invoice, rules)
};
} catch (error) {
this.logger.log('error', `Failed to book invoice: ${error}`);
return {
success: false,
confidence: 0,
errors: [`Booking failed: ${error.message}`]
};
}
}
/**
* Build journal entry from invoice
*/
private async buildJournalEntry(
invoice: IInvoice,
rules: IBookingRules,
options?: IBookingOptions
): Promise<JournalEntry> {
const lines: IJournalEntryLine[] = [];
const isInbound = invoice.direction === 'inbound';
const isCredit = invoice.invoiceTypeCode === '381'; // Credit note
// Determine if we need to reverse the normal booking direction
const reverseDirection = isCredit;
if (isInbound) {
// Inbound invoice (AP)
lines.push(...this.buildAPEntry(invoice, rules, reverseDirection));
} else {
// Outbound invoice (AR)
lines.push(...this.buildAREntry(invoice, rules, reverseDirection));
}
// Create journal entry
const journalData: IJournalEntry = {
date: options?.bookingDate || invoice.issueDate,
description: this.buildDescription(invoice),
reference: options?.bookingReference || invoice.invoiceNumber,
lines,
skrType: this.skrType
};
const journalEntry = new JournalEntry(journalData);
return journalEntry;
}
/**
* Build AP (Accounts Payable) journal entry lines
*/
private buildAPEntry(
invoice: IInvoice,
rules: IBookingRules,
reverseDirection: boolean
): IJournalEntryLine[] {
const lines: IJournalEntryLine[] = [];
// Group lines by account
const accountGroups = this.groupLinesByAccount(invoice, rules);
// Create expense/asset entries
for (const [accountNumber, group] of Object.entries(accountGroups)) {
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
if (reverseDirection) {
// Credit note: credit expense account
lines.push({
accountNumber,
credit: Math.abs(amount),
description: this.getAccountDescription(accountNumber, group)
});
} else {
// Regular invoice: debit expense account
lines.push({
accountNumber,
debit: Math.abs(amount),
description: this.getAccountDescription(accountNumber, group)
});
}
}
// Create VAT entries
const vatLines = this.buildVATLines(invoice, rules, 'input', reverseDirection);
lines.push(...vatLines);
// Create vendor control account entry
const controlAccount = this.mapper.getControlAccount(invoice, rules);
const totalAmount = Math.abs(invoice.payableAmount);
if (reverseDirection) {
// Credit note: debit vendor account
lines.push({
accountNumber: controlAccount,
debit: totalAmount,
description: `${invoice.supplier.name} - Credit Note ${invoice.invoiceNumber}`
});
} else {
// Regular invoice: credit vendor account
lines.push({
accountNumber: controlAccount,
credit: totalAmount,
description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}`
});
}
return lines;
}
/**
* Build AR (Accounts Receivable) journal entry lines
*/
private buildAREntry(
invoice: IInvoice,
rules: IBookingRules,
reverseDirection: boolean
): IJournalEntryLine[] {
const lines: IJournalEntryLine[] = [];
// Group lines by account
const accountGroups = this.groupLinesByAccount(invoice, rules);
// Create revenue entries
for (const [accountNumber, group] of Object.entries(accountGroups)) {
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
if (reverseDirection) {
// Credit note: debit revenue account
lines.push({
accountNumber,
debit: Math.abs(amount),
description: this.getAccountDescription(accountNumber, group)
});
} else {
// Regular invoice: credit revenue account
lines.push({
accountNumber,
credit: Math.abs(amount),
description: this.getAccountDescription(accountNumber, group)
});
}
}
// Create VAT entries
const vatLines = this.buildVATLines(invoice, rules, 'output', reverseDirection);
lines.push(...vatLines);
// Create customer control account entry
const controlAccount = this.mapper.getControlAccount(invoice, rules);
const totalAmount = Math.abs(invoice.payableAmount);
if (reverseDirection) {
// Credit note: credit customer account
lines.push({
accountNumber: controlAccount,
credit: totalAmount,
description: `${invoice.customer.name} - Credit Note ${invoice.invoiceNumber}`
});
} else {
// Regular invoice: debit customer account
lines.push({
accountNumber: controlAccount,
debit: totalAmount,
description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}`
});
}
return lines;
}
/**
* Build VAT lines
*/
private buildVATLines(
invoice: IInvoice,
rules: IBookingRules,
direction: 'input' | 'output',
reverseDirection: boolean
): IJournalEntryLine[] {
const lines: IJournalEntryLine[] = [];
const taxScenario = invoice.taxScenario || 'domestic_taxed';
// Handle reverse charge specially
if (taxScenario === 'reverse_charge') {
return this.buildReverseChargeVATLines(invoice, rules);
}
// Standard VAT booking
for (const vatBreak of invoice.vatBreakdown) {
if (vatBreak.taxAmount === 0) continue;
const vatAccount = this.mapper.getVATAccount(
vatBreak.vatCategory,
direction,
taxScenario
);
const amount = Math.abs(vatBreak.taxAmount);
const description = `VAT ${vatBreak.vatCategory.rate}%`;
if (direction === 'input') {
// Input VAT (Vorsteuer)
if (reverseDirection) {
lines.push({ accountNumber: vatAccount, credit: amount, description });
} else {
lines.push({ accountNumber: vatAccount, debit: amount, description });
}
} else {
// Output VAT (Umsatzsteuer)
if (reverseDirection) {
lines.push({ accountNumber: vatAccount, debit: amount, description });
} else {
lines.push({ accountNumber: vatAccount, credit: amount, description });
}
}
}
return lines;
}
/**
* Calculate VAT amount from taxable amount and rate
*/
private calculateVAT(taxableAmount: number, rate: number): number {
return Math.round(taxableAmount * rate / 100 * 100) / 100; // Round to 2 decimals
}
/**
* Calculate effective VAT rate for the invoice (weighted average)
*/
private calculateEffectiveVATRate(invoice: IInvoice): number {
const totalTaxable = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxableAmount, 0);
if (totalTaxable === 0) {
return 19; // Default to standard German VAT rate
}
// Calculate weighted average VAT rate
const weightedRate = invoice.vatBreakdown.reduce((sum, vb) => {
return sum + (vb.vatCategory.rate * vb.taxableAmount);
}, 0);
return Math.round(weightedRate / totalTaxable * 100) / 100;
}
/**
* Build reverse charge VAT lines (§13b UStG)
*/
private buildReverseChargeVATLines(
invoice: IInvoice,
rules: IBookingRules
): IJournalEntryLine[] {
const lines: IJournalEntryLine[] = [];
// For reverse charge, we book both input and output VAT
for (const vatBreak of invoice.vatBreakdown) {
// For reverse charge, calculate VAT if not provided
const amount = vatBreak.taxAmount > 0
? Math.abs(vatBreak.taxAmount)
: this.calculateVAT(Math.abs(vatBreak.taxableAmount), vatBreak.vatCategory.rate);
// Input VAT (deductible)
const inputVATAccount = this.mapper.getVATAccount(
vatBreak.vatCategory,
'input',
'reverse_charge'
);
// Output VAT (payable)
const outputVATAccount = this.mapper.getVATAccount(
vatBreak.vatCategory,
'output',
'reverse_charge'
);
lines.push(
{
accountNumber: inputVATAccount,
debit: amount,
description: `Reverse charge input VAT ${vatBreak.vatCategory.rate}%`
},
{
accountNumber: outputVATAccount,
credit: amount,
description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%`
}
);
}
return lines;
}
/**
* Group invoice lines by account
*/
private groupLinesByAccount(
invoice: IInvoice,
rules: IBookingRules
): Record<string, IInvoiceLine[]> {
const groups: Record<string, IInvoiceLine[]> = {};
for (const line of invoice.lines) {
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
if (!groups[account]) {
groups[account] = [];
}
groups[account].push(line);
}
return groups;
}
/**
* Book payment for an invoice
*/
public async bookPayment(
invoice: IInvoice,
payment: IPaymentInfo,
rules: IBookingRules
): Promise<IBookingResult> {
try {
const lines: IJournalEntryLine[] = [];
const isInbound = invoice.direction === 'inbound';
const controlAccount = this.mapper.getControlAccount(invoice, rules);
// Check for skonto
const skontoAmount = payment.skontoTaken || 0;
const paymentAmount = payment.amount;
const fullAmount = paymentAmount + skontoAmount;
if (isInbound) {
// Payment for vendor invoice
lines.push(
{
accountNumber: controlAccount,
debit: fullAmount,
description: `Payment to ${invoice.supplier.name}`
},
{
accountNumber: '1000', // Bank account (would be configurable)
credit: paymentAmount,
description: `Bank payment ${payment.endToEndId || payment.paymentId}`
}
);
// Book skonto if taken
if (skontoAmount > 0) {
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
lines.push({
accountNumber: skontoAccounts.skontoAccount,
credit: skontoAmount,
description: `Skonto received`
});
// VAT correction for skonto
if (rules.skontoMethod === 'gross') {
const effectiveRate = this.calculateEffectiveVATRate(invoice);
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
lines.push(
{
accountNumber: skontoAccounts.vatCorrectionAccount,
credit: vatCorrection,
description: `Skonto VAT correction`
}
);
}
}
} else {
// Payment from customer
lines.push(
{
accountNumber: '1000', // Bank account
debit: paymentAmount,
description: `Payment from ${invoice.customer.name}`
},
{
accountNumber: controlAccount,
credit: fullAmount,
description: `Customer payment ${payment.endToEndId || payment.paymentId}`
}
);
// Book skonto if granted
if (skontoAmount > 0) {
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
lines.push({
accountNumber: skontoAccounts.skontoAccount,
debit: skontoAmount,
description: `Skonto granted`
});
// VAT correction for skonto
if (rules.skontoMethod === 'gross') {
const effectiveRate = this.calculateEffectiveVATRate(invoice);
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
lines.push(
{
accountNumber: skontoAccounts.vatCorrectionAccount,
debit: vatCorrection,
description: `Skonto VAT correction`
}
);
}
}
}
// Create journal entry for payment
const journalData: IJournalEntry = {
date: payment.paymentDate,
description: `Payment for invoice ${invoice.invoiceNumber}`,
reference: payment.endToEndId || payment.remittanceInfo || payment.paymentId,
lines,
skrType: this.skrType
};
const journalEntry = new JournalEntry(journalData);
await journalEntry.validate();
await journalEntry.post();
return {
success: true,
journalEntry,
confidence: 100
};
} catch (error) {
this.logger.log('error', `Failed to book payment: ${error}`);
return {
success: false,
confidence: 0,
errors: [`Payment booking failed: ${error.message}`]
};
}
}
/**
* Validate invoice before booking
*/
private validateInvoice(invoice: IInvoice): string[] {
const errors: string[] = [];
// Check required fields
if (!invoice.invoiceNumber) {
errors.push('Invoice number is required');
}
if (!invoice.issueDate) {
errors.push('Issue date is required');
}
if (!invoice.supplier || !invoice.supplier.name) {
errors.push('Supplier information is required');
}
if (!invoice.customer || !invoice.customer.name) {
errors.push('Customer information is required');
}
if (invoice.lines.length === 0) {
errors.push('Invoice must have at least one line item');
}
// Validate amounts
const calculatedNet = invoice.lines.reduce((sum, line) => sum + line.netAmount, 0);
const tolerance = 0.01;
if (Math.abs(calculatedNet - invoice.lineNetAmount) > tolerance) {
errors.push(`Line net amount mismatch: calculated ${calculatedNet}, stated ${invoice.lineNetAmount}`);
}
// Validate VAT
const calculatedVAT = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxAmount, 0);
if (Math.abs(calculatedVAT - invoice.totalVATAmount) > tolerance) {
errors.push(`VAT amount mismatch: calculated ${calculatedVAT}, stated ${invoice.totalVATAmount}`);
}
// Validate total
const calculatedTotal = invoice.taxExclusiveAmount + invoice.totalVATAmount;
if (Math.abs(calculatedTotal - invoice.taxInclusiveAmount) > tolerance) {
errors.push(`Total amount mismatch: calculated ${calculatedTotal}, stated ${invoice.taxInclusiveAmount}`);
}
return errors;
}
/**
* Generate warnings for the booking
*/
private generateWarnings(invoice: IInvoice, rules: IBookingRules): string[] {
const warnings: string[] = [];
// Warn about default account usage
const hasDefaultAccounts = invoice.lines.some(line =>
!line.accountNumber && !line.productCode
);
if (hasDefaultAccounts) {
warnings.push('Some lines are using default expense/revenue accounts');
}
// Warn about mixed VAT rates
if (invoice.vatBreakdown.length > 1) {
warnings.push('Invoice contains mixed VAT rates');
}
// Warn about reverse charge
if (invoice.taxScenario === 'reverse_charge') {
warnings.push('Reverse charge procedure applied - verify VAT treatment');
}
// Warn about credit notes
if (invoice.invoiceTypeCode === '381') {
warnings.push('This is a credit note - amounts will be reversed');
}
// Warn about foreign currency
if (invoice.currencyCode !== 'EUR') {
warnings.push(`Invoice is in foreign currency: ${invoice.currencyCode}`);
}
return warnings;
}
/**
* Build description for journal entry
*/
private buildDescription(invoice: IInvoice): string {
const type = invoice.invoiceTypeCode === '381' ? 'Credit Note' : 'Invoice';
const party = invoice.direction === 'inbound'
? invoice.supplier.name
: invoice.customer.name;
return `${type} ${invoice.invoiceNumber} - ${party}`;
}
/**
* Get account description for a group of lines
*/
private getAccountDescription(accountNumber: string, lines: IInvoiceLine[]): string {
if (lines.length === 1) {
return lines[0].description;
}
return `${this.mapper.getAccountDescription(accountNumber)} (${lines.length} items)`;
}
/**
* Get used expense accounts
*/
private getUsedExpenseAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
if (invoice.direction !== 'inbound') return [];
const accounts = new Set<string>();
for (const line of invoice.lines) {
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
accounts.add(account);
}
return Array.from(accounts);
}
/**
* Get used revenue accounts
*/
private getUsedRevenueAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
if (invoice.direction !== 'outbound') return [];
const accounts = new Set<string>();
for (const line of invoice.lines) {
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
accounts.add(account);
}
return Array.from(accounts);
}
/**
* Get used VAT accounts
*/
private getUsedVATAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
const accounts = new Set<string>();
const direction = invoice.direction === 'inbound' ? 'input' : 'output';
const taxScenario = invoice.taxScenario || 'domestic_taxed';
for (const vatBreak of invoice.vatBreakdown) {
const account = this.mapper.getVATAccount(
vatBreak.vatCategory,
direction,
taxScenario
);
accounts.add(account);
}
// Add reverse charge accounts if applicable
if (taxScenario === 'reverse_charge') {
for (const vatBreak of invoice.vatBreakdown) {
const inputAccount = this.mapper.getVATAccount(
vatBreak.vatCategory,
'input',
'reverse_charge'
);
const outputAccount = this.mapper.getVATAccount(
vatBreak.vatCategory,
'output',
'reverse_charge'
);
accounts.add(inputAccount);
accounts.add(outputAccount);
}
}
return Array.from(accounts);
}
}