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, options?: IBookingOptions ): Promise { 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 { 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 { const groups: Record = {}; 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 { 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(); 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(); 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(); 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); } }