feat(invoice): add e-invoice support with XRechnung/ZUGFeRD and advanced export features
This commit is contained in:
738
ts/skr.invoice.booking.ts
Normal file
738
ts/skr.invoice.booking.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user