@fin.cx/skr 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/index.d.ts +6 -0
- package/dist_ts/index.js +7 -1
- package/dist_ts/plugins.d.ts +8 -1
- package/dist_ts/plugins.js +10 -2
- package/dist_ts/skr.api.d.ts +70 -0
- package/dist_ts/skr.api.js +348 -1
- package/dist_ts/skr.export.accounts.d.ts +53 -0
- package/dist_ts/skr.export.accounts.js +111 -0
- package/dist_ts/skr.export.balances.d.ts +59 -0
- package/dist_ts/skr.export.balances.js +205 -0
- package/dist_ts/skr.export.d.ts +110 -0
- package/dist_ts/skr.export.js +315 -0
- package/dist_ts/skr.export.ledger.d.ts +95 -0
- package/dist_ts/skr.export.ledger.js +164 -0
- package/dist_ts/skr.export.pdf.d.ts +82 -0
- package/dist_ts/skr.export.pdf.js +548 -0
- package/dist_ts/skr.invoice.adapter.d.ts +98 -0
- package/dist_ts/skr.invoice.adapter.js +476 -0
- package/dist_ts/skr.invoice.booking.d.ts +102 -0
- package/dist_ts/skr.invoice.booking.js +556 -0
- package/dist_ts/skr.invoice.entity.d.ts +287 -0
- package/dist_ts/skr.invoice.entity.js +2 -0
- package/dist_ts/skr.invoice.mapper.d.ts +69 -0
- package/dist_ts/skr.invoice.mapper.js +401 -0
- package/dist_ts/skr.invoice.storage.d.ts +140 -0
- package/dist_ts/skr.invoice.storage.js +529 -0
- package/dist_ts/skr.security.d.ts +65 -0
- package/dist_ts/skr.security.js +319 -0
- package/dist_ts/skr.types.d.ts +1 -0
- package/package.json +17 -12
- package/readme.md +207 -16
- package/ts/index.ts +6 -0
- package/ts/plugins.ts +22 -1
- package/ts/skr.api.ts +485 -0
- package/ts/skr.export.accounts.ts +154 -0
- package/ts/skr.export.balances.ts +270 -0
- package/ts/skr.export.ledger.ts +249 -0
- package/ts/skr.export.pdf.ts +601 -0
- package/ts/skr.export.ts +443 -0
- package/ts/skr.invoice.adapter.ts +581 -0
- package/ts/skr.invoice.booking.ts +738 -0
- package/ts/skr.invoice.entity.ts +351 -0
- package/ts/skr.invoice.mapper.ts +486 -0
- package/ts/skr.invoice.storage.ts +710 -0
- package/ts/skr.security.ts +405 -0
- package/ts/skr.types.ts +1 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import { JournalEntry } from './skr.classes.journalentry.js';
|
|
3
|
+
import { SKRInvoiceMapper } from './skr.invoice.mapper.js';
|
|
4
|
+
import type { TSKRType, IJournalEntry, IJournalEntryLine } from './skr.types.js';
|
|
5
|
+
import type {
|
|
6
|
+
IInvoice,
|
|
7
|
+
IInvoiceLine,
|
|
8
|
+
IBookingRules,
|
|
9
|
+
IBookingInfo,
|
|
10
|
+
TTaxScenario,
|
|
11
|
+
IPaymentInfo
|
|
12
|
+
} from './skr.invoice.entity.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Options for booking an invoice
|
|
16
|
+
*/
|
|
17
|
+
export interface IBookingOptions {
|
|
18
|
+
autoBook?: boolean;
|
|
19
|
+
confidenceThreshold?: number;
|
|
20
|
+
bookingDate?: Date;
|
|
21
|
+
bookingReference?: string;
|
|
22
|
+
skipValidation?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Result of booking an invoice
|
|
27
|
+
*/
|
|
28
|
+
export interface IBookingResult {
|
|
29
|
+
success: boolean;
|
|
30
|
+
journalEntry?: JournalEntry;
|
|
31
|
+
bookingInfo?: IBookingInfo;
|
|
32
|
+
confidence: number;
|
|
33
|
+
warnings?: string[];
|
|
34
|
+
errors?: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Automatic booking engine for invoices
|
|
39
|
+
* Creates journal entries from invoice data based on SKR mapping rules
|
|
40
|
+
*/
|
|
41
|
+
export class InvoiceBookingEngine {
|
|
42
|
+
private logger: plugins.smartlog.ConsoleLog;
|
|
43
|
+
private skrType: TSKRType;
|
|
44
|
+
private mapper: SKRInvoiceMapper;
|
|
45
|
+
|
|
46
|
+
constructor(skrType: TSKRType) {
|
|
47
|
+
this.skrType = skrType;
|
|
48
|
+
this.mapper = new SKRInvoiceMapper(skrType);
|
|
49
|
+
this.logger = new plugins.smartlog.ConsoleLog();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Book an invoice to the ledger
|
|
54
|
+
*/
|
|
55
|
+
public async bookInvoice(
|
|
56
|
+
invoice: IInvoice,
|
|
57
|
+
bookingRules?: Partial<IBookingRules>,
|
|
58
|
+
options?: IBookingOptions
|
|
59
|
+
): Promise<IBookingResult> {
|
|
60
|
+
try {
|
|
61
|
+
// Get complete booking rules
|
|
62
|
+
const rules = this.mapper.mapInvoiceToSKR(invoice, bookingRules);
|
|
63
|
+
|
|
64
|
+
// Calculate confidence
|
|
65
|
+
const confidence = this.mapper.calculateConfidence(invoice, rules);
|
|
66
|
+
|
|
67
|
+
// Check if auto-booking is allowed
|
|
68
|
+
if (options?.autoBook && confidence < (options.confidenceThreshold || 80)) {
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
confidence,
|
|
72
|
+
warnings: [`Confidence score ${confidence}% is below threshold ${options.confidenceThreshold || 80}%`]
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Validate invoice before booking
|
|
77
|
+
if (!options?.skipValidation) {
|
|
78
|
+
const validationErrors = this.validateInvoice(invoice);
|
|
79
|
+
if (validationErrors.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
confidence,
|
|
83
|
+
errors: validationErrors
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Build journal entry
|
|
89
|
+
const journalEntry = await this.buildJournalEntry(invoice, rules, options);
|
|
90
|
+
|
|
91
|
+
// Create booking info
|
|
92
|
+
const bookingInfo: IBookingInfo = {
|
|
93
|
+
journalEntryId: journalEntry.id,
|
|
94
|
+
transactionIds: journalEntry.transactionIds || [],
|
|
95
|
+
bookedAt: new Date(),
|
|
96
|
+
bookedBy: 'system',
|
|
97
|
+
bookingRules: {
|
|
98
|
+
vendorAccount: rules.vendorControlAccount,
|
|
99
|
+
customerAccount: rules.customerControlAccount,
|
|
100
|
+
expenseAccounts: this.getUsedExpenseAccounts(invoice, rules),
|
|
101
|
+
revenueAccounts: this.getUsedRevenueAccounts(invoice, rules),
|
|
102
|
+
vatAccounts: this.getUsedVATAccounts(invoice, rules)
|
|
103
|
+
},
|
|
104
|
+
confidence,
|
|
105
|
+
autoBooked: options?.autoBook || false
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Post the journal entry
|
|
109
|
+
// TODO: When MongoDB transactions are available, wrap this in a transaction
|
|
110
|
+
// Example: await db.withTransaction(async (session) => { ... })
|
|
111
|
+
try {
|
|
112
|
+
await journalEntry.validate();
|
|
113
|
+
await journalEntry.post();
|
|
114
|
+
|
|
115
|
+
// Mark invoice as posted if we have a reference to it
|
|
116
|
+
if (invoice.status !== 'posted') {
|
|
117
|
+
invoice.status = 'posted';
|
|
118
|
+
}
|
|
119
|
+
} catch (postError) {
|
|
120
|
+
this.logger.log('error', `Failed to post journal entry: ${postError}`);
|
|
121
|
+
throw postError; // Re-throw to trigger rollback when transactions are available
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
success: true,
|
|
126
|
+
journalEntry,
|
|
127
|
+
bookingInfo,
|
|
128
|
+
confidence,
|
|
129
|
+
warnings: this.generateWarnings(invoice, rules)
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
this.logger.log('error', `Failed to book invoice: ${error}`);
|
|
133
|
+
return {
|
|
134
|
+
success: false,
|
|
135
|
+
confidence: 0,
|
|
136
|
+
errors: [`Booking failed: ${error.message}`]
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build journal entry from invoice
|
|
143
|
+
*/
|
|
144
|
+
private async buildJournalEntry(
|
|
145
|
+
invoice: IInvoice,
|
|
146
|
+
rules: IBookingRules,
|
|
147
|
+
options?: IBookingOptions
|
|
148
|
+
): Promise<JournalEntry> {
|
|
149
|
+
const lines: IJournalEntryLine[] = [];
|
|
150
|
+
const isInbound = invoice.direction === 'inbound';
|
|
151
|
+
const isCredit = invoice.invoiceTypeCode === '381'; // Credit note
|
|
152
|
+
|
|
153
|
+
// Determine if we need to reverse the normal booking direction
|
|
154
|
+
const reverseDirection = isCredit;
|
|
155
|
+
|
|
156
|
+
if (isInbound) {
|
|
157
|
+
// Inbound invoice (AP)
|
|
158
|
+
lines.push(...this.buildAPEntry(invoice, rules, reverseDirection));
|
|
159
|
+
} else {
|
|
160
|
+
// Outbound invoice (AR)
|
|
161
|
+
lines.push(...this.buildAREntry(invoice, rules, reverseDirection));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Create journal entry
|
|
165
|
+
const journalData: IJournalEntry = {
|
|
166
|
+
date: options?.bookingDate || invoice.issueDate,
|
|
167
|
+
description: this.buildDescription(invoice),
|
|
168
|
+
reference: options?.bookingReference || invoice.invoiceNumber,
|
|
169
|
+
lines,
|
|
170
|
+
skrType: this.skrType
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const journalEntry = new JournalEntry(journalData);
|
|
174
|
+
return journalEntry;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build AP (Accounts Payable) journal entry lines
|
|
179
|
+
*/
|
|
180
|
+
private buildAPEntry(
|
|
181
|
+
invoice: IInvoice,
|
|
182
|
+
rules: IBookingRules,
|
|
183
|
+
reverseDirection: boolean
|
|
184
|
+
): IJournalEntryLine[] {
|
|
185
|
+
const lines: IJournalEntryLine[] = [];
|
|
186
|
+
|
|
187
|
+
// Group lines by account
|
|
188
|
+
const accountGroups = this.groupLinesByAccount(invoice, rules);
|
|
189
|
+
|
|
190
|
+
// Create expense/asset entries
|
|
191
|
+
for (const [accountNumber, group] of Object.entries(accountGroups)) {
|
|
192
|
+
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
|
|
193
|
+
|
|
194
|
+
if (reverseDirection) {
|
|
195
|
+
// Credit note: credit expense account
|
|
196
|
+
lines.push({
|
|
197
|
+
accountNumber,
|
|
198
|
+
credit: Math.abs(amount),
|
|
199
|
+
description: this.getAccountDescription(accountNumber, group)
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
// Regular invoice: debit expense account
|
|
203
|
+
lines.push({
|
|
204
|
+
accountNumber,
|
|
205
|
+
debit: Math.abs(amount),
|
|
206
|
+
description: this.getAccountDescription(accountNumber, group)
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Create VAT entries
|
|
212
|
+
const vatLines = this.buildVATLines(invoice, rules, 'input', reverseDirection);
|
|
213
|
+
lines.push(...vatLines);
|
|
214
|
+
|
|
215
|
+
// Create vendor control account entry
|
|
216
|
+
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
|
217
|
+
const totalAmount = Math.abs(invoice.payableAmount);
|
|
218
|
+
|
|
219
|
+
if (reverseDirection) {
|
|
220
|
+
// Credit note: debit vendor account
|
|
221
|
+
lines.push({
|
|
222
|
+
accountNumber: controlAccount,
|
|
223
|
+
debit: totalAmount,
|
|
224
|
+
description: `${invoice.supplier.name} - Credit Note ${invoice.invoiceNumber}`
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
// Regular invoice: credit vendor account
|
|
228
|
+
lines.push({
|
|
229
|
+
accountNumber: controlAccount,
|
|
230
|
+
credit: totalAmount,
|
|
231
|
+
description: `${invoice.supplier.name} - Invoice ${invoice.invoiceNumber}`
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Build AR (Accounts Receivable) journal entry lines
|
|
240
|
+
*/
|
|
241
|
+
private buildAREntry(
|
|
242
|
+
invoice: IInvoice,
|
|
243
|
+
rules: IBookingRules,
|
|
244
|
+
reverseDirection: boolean
|
|
245
|
+
): IJournalEntryLine[] {
|
|
246
|
+
const lines: IJournalEntryLine[] = [];
|
|
247
|
+
|
|
248
|
+
// Group lines by account
|
|
249
|
+
const accountGroups = this.groupLinesByAccount(invoice, rules);
|
|
250
|
+
|
|
251
|
+
// Create revenue entries
|
|
252
|
+
for (const [accountNumber, group] of Object.entries(accountGroups)) {
|
|
253
|
+
const amount = group.reduce((sum, line) => sum + line.netAmount, 0);
|
|
254
|
+
|
|
255
|
+
if (reverseDirection) {
|
|
256
|
+
// Credit note: debit revenue account
|
|
257
|
+
lines.push({
|
|
258
|
+
accountNumber,
|
|
259
|
+
debit: Math.abs(amount),
|
|
260
|
+
description: this.getAccountDescription(accountNumber, group)
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
// Regular invoice: credit revenue account
|
|
264
|
+
lines.push({
|
|
265
|
+
accountNumber,
|
|
266
|
+
credit: Math.abs(amount),
|
|
267
|
+
description: this.getAccountDescription(accountNumber, group)
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Create VAT entries
|
|
273
|
+
const vatLines = this.buildVATLines(invoice, rules, 'output', reverseDirection);
|
|
274
|
+
lines.push(...vatLines);
|
|
275
|
+
|
|
276
|
+
// Create customer control account entry
|
|
277
|
+
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
|
278
|
+
const totalAmount = Math.abs(invoice.payableAmount);
|
|
279
|
+
|
|
280
|
+
if (reverseDirection) {
|
|
281
|
+
// Credit note: credit customer account
|
|
282
|
+
lines.push({
|
|
283
|
+
accountNumber: controlAccount,
|
|
284
|
+
credit: totalAmount,
|
|
285
|
+
description: `${invoice.customer.name} - Credit Note ${invoice.invoiceNumber}`
|
|
286
|
+
});
|
|
287
|
+
} else {
|
|
288
|
+
// Regular invoice: debit customer account
|
|
289
|
+
lines.push({
|
|
290
|
+
accountNumber: controlAccount,
|
|
291
|
+
debit: totalAmount,
|
|
292
|
+
description: `${invoice.customer.name} - Invoice ${invoice.invoiceNumber}`
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Build VAT lines
|
|
301
|
+
*/
|
|
302
|
+
private buildVATLines(
|
|
303
|
+
invoice: IInvoice,
|
|
304
|
+
rules: IBookingRules,
|
|
305
|
+
direction: 'input' | 'output',
|
|
306
|
+
reverseDirection: boolean
|
|
307
|
+
): IJournalEntryLine[] {
|
|
308
|
+
const lines: IJournalEntryLine[] = [];
|
|
309
|
+
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
|
310
|
+
|
|
311
|
+
// Handle reverse charge specially
|
|
312
|
+
if (taxScenario === 'reverse_charge') {
|
|
313
|
+
return this.buildReverseChargeVATLines(invoice, rules);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Standard VAT booking
|
|
317
|
+
for (const vatBreak of invoice.vatBreakdown) {
|
|
318
|
+
if (vatBreak.taxAmount === 0) continue;
|
|
319
|
+
|
|
320
|
+
const vatAccount = this.mapper.getVATAccount(
|
|
321
|
+
vatBreak.vatCategory,
|
|
322
|
+
direction,
|
|
323
|
+
taxScenario
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const amount = Math.abs(vatBreak.taxAmount);
|
|
327
|
+
const description = `VAT ${vatBreak.vatCategory.rate}%`;
|
|
328
|
+
|
|
329
|
+
if (direction === 'input') {
|
|
330
|
+
// Input VAT (Vorsteuer)
|
|
331
|
+
if (reverseDirection) {
|
|
332
|
+
lines.push({ accountNumber: vatAccount, credit: amount, description });
|
|
333
|
+
} else {
|
|
334
|
+
lines.push({ accountNumber: vatAccount, debit: amount, description });
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// Output VAT (Umsatzsteuer)
|
|
338
|
+
if (reverseDirection) {
|
|
339
|
+
lines.push({ accountNumber: vatAccount, debit: amount, description });
|
|
340
|
+
} else {
|
|
341
|
+
lines.push({ accountNumber: vatAccount, credit: amount, description });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return lines;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Calculate VAT amount from taxable amount and rate
|
|
351
|
+
*/
|
|
352
|
+
private calculateVAT(taxableAmount: number, rate: number): number {
|
|
353
|
+
return Math.round(taxableAmount * rate / 100 * 100) / 100; // Round to 2 decimals
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Calculate effective VAT rate for the invoice (weighted average)
|
|
358
|
+
*/
|
|
359
|
+
private calculateEffectiveVATRate(invoice: IInvoice): number {
|
|
360
|
+
const totalTaxable = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxableAmount, 0);
|
|
361
|
+
if (totalTaxable === 0) {
|
|
362
|
+
return 19; // Default to standard German VAT rate
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Calculate weighted average VAT rate
|
|
366
|
+
const weightedRate = invoice.vatBreakdown.reduce((sum, vb) => {
|
|
367
|
+
return sum + (vb.vatCategory.rate * vb.taxableAmount);
|
|
368
|
+
}, 0);
|
|
369
|
+
|
|
370
|
+
return Math.round(weightedRate / totalTaxable * 100) / 100;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Build reverse charge VAT lines (§13b UStG)
|
|
375
|
+
*/
|
|
376
|
+
private buildReverseChargeVATLines(
|
|
377
|
+
invoice: IInvoice,
|
|
378
|
+
rules: IBookingRules
|
|
379
|
+
): IJournalEntryLine[] {
|
|
380
|
+
const lines: IJournalEntryLine[] = [];
|
|
381
|
+
|
|
382
|
+
// For reverse charge, we book both input and output VAT
|
|
383
|
+
for (const vatBreak of invoice.vatBreakdown) {
|
|
384
|
+
// For reverse charge, calculate VAT if not provided
|
|
385
|
+
const amount = vatBreak.taxAmount > 0
|
|
386
|
+
? Math.abs(vatBreak.taxAmount)
|
|
387
|
+
: this.calculateVAT(Math.abs(vatBreak.taxableAmount), vatBreak.vatCategory.rate);
|
|
388
|
+
|
|
389
|
+
// Input VAT (deductible)
|
|
390
|
+
const inputVATAccount = this.mapper.getVATAccount(
|
|
391
|
+
vatBreak.vatCategory,
|
|
392
|
+
'input',
|
|
393
|
+
'reverse_charge'
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
// Output VAT (payable)
|
|
397
|
+
const outputVATAccount = this.mapper.getVATAccount(
|
|
398
|
+
vatBreak.vatCategory,
|
|
399
|
+
'output',
|
|
400
|
+
'reverse_charge'
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
lines.push(
|
|
404
|
+
{
|
|
405
|
+
accountNumber: inputVATAccount,
|
|
406
|
+
debit: amount,
|
|
407
|
+
description: `Reverse charge input VAT ${vatBreak.vatCategory.rate}%`
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
accountNumber: outputVATAccount,
|
|
411
|
+
credit: amount,
|
|
412
|
+
description: `Reverse charge output VAT ${vatBreak.vatCategory.rate}%`
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return lines;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Group invoice lines by account
|
|
422
|
+
*/
|
|
423
|
+
private groupLinesByAccount(
|
|
424
|
+
invoice: IInvoice,
|
|
425
|
+
rules: IBookingRules
|
|
426
|
+
): Record<string, IInvoiceLine[]> {
|
|
427
|
+
const groups: Record<string, IInvoiceLine[]> = {};
|
|
428
|
+
|
|
429
|
+
for (const line of invoice.lines) {
|
|
430
|
+
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
|
431
|
+
|
|
432
|
+
if (!groups[account]) {
|
|
433
|
+
groups[account] = [];
|
|
434
|
+
}
|
|
435
|
+
groups[account].push(line);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return groups;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Book payment for an invoice
|
|
443
|
+
*/
|
|
444
|
+
public async bookPayment(
|
|
445
|
+
invoice: IInvoice,
|
|
446
|
+
payment: IPaymentInfo,
|
|
447
|
+
rules: IBookingRules
|
|
448
|
+
): Promise<IBookingResult> {
|
|
449
|
+
try {
|
|
450
|
+
const lines: IJournalEntryLine[] = [];
|
|
451
|
+
const isInbound = invoice.direction === 'inbound';
|
|
452
|
+
const controlAccount = this.mapper.getControlAccount(invoice, rules);
|
|
453
|
+
|
|
454
|
+
// Check for skonto
|
|
455
|
+
const skontoAmount = payment.skontoTaken || 0;
|
|
456
|
+
const paymentAmount = payment.amount;
|
|
457
|
+
const fullAmount = paymentAmount + skontoAmount;
|
|
458
|
+
|
|
459
|
+
if (isInbound) {
|
|
460
|
+
// Payment for vendor invoice
|
|
461
|
+
lines.push(
|
|
462
|
+
{
|
|
463
|
+
accountNumber: controlAccount,
|
|
464
|
+
debit: fullAmount,
|
|
465
|
+
description: `Payment to ${invoice.supplier.name}`
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
accountNumber: '1000', // Bank account (would be configurable)
|
|
469
|
+
credit: paymentAmount,
|
|
470
|
+
description: `Bank payment ${payment.endToEndId || payment.paymentId}`
|
|
471
|
+
}
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Book skonto if taken
|
|
475
|
+
if (skontoAmount > 0) {
|
|
476
|
+
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
|
477
|
+
lines.push({
|
|
478
|
+
accountNumber: skontoAccounts.skontoAccount,
|
|
479
|
+
credit: skontoAmount,
|
|
480
|
+
description: `Skonto received`
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// VAT correction for skonto
|
|
484
|
+
if (rules.skontoMethod === 'gross') {
|
|
485
|
+
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
|
486
|
+
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
|
|
487
|
+
lines.push(
|
|
488
|
+
{
|
|
489
|
+
accountNumber: skontoAccounts.vatCorrectionAccount,
|
|
490
|
+
credit: vatCorrection,
|
|
491
|
+
description: `Skonto VAT correction`
|
|
492
|
+
}
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} else {
|
|
497
|
+
// Payment from customer
|
|
498
|
+
lines.push(
|
|
499
|
+
{
|
|
500
|
+
accountNumber: '1000', // Bank account
|
|
501
|
+
debit: paymentAmount,
|
|
502
|
+
description: `Payment from ${invoice.customer.name}`
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
accountNumber: controlAccount,
|
|
506
|
+
credit: fullAmount,
|
|
507
|
+
description: `Customer payment ${payment.endToEndId || payment.paymentId}`
|
|
508
|
+
}
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
// Book skonto if granted
|
|
512
|
+
if (skontoAmount > 0) {
|
|
513
|
+
const skontoAccounts = this.mapper.getSkontoAccounts(invoice);
|
|
514
|
+
lines.push({
|
|
515
|
+
accountNumber: skontoAccounts.skontoAccount,
|
|
516
|
+
debit: skontoAmount,
|
|
517
|
+
description: `Skonto granted`
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
// VAT correction for skonto
|
|
521
|
+
if (rules.skontoMethod === 'gross') {
|
|
522
|
+
const effectiveRate = this.calculateEffectiveVATRate(invoice);
|
|
523
|
+
const vatCorrection = Math.round(skontoAmount * effectiveRate / (100 + effectiveRate) * 100) / 100;
|
|
524
|
+
lines.push(
|
|
525
|
+
{
|
|
526
|
+
accountNumber: skontoAccounts.vatCorrectionAccount,
|
|
527
|
+
debit: vatCorrection,
|
|
528
|
+
description: `Skonto VAT correction`
|
|
529
|
+
}
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Create journal entry for payment
|
|
536
|
+
const journalData: IJournalEntry = {
|
|
537
|
+
date: payment.paymentDate,
|
|
538
|
+
description: `Payment for invoice ${invoice.invoiceNumber}`,
|
|
539
|
+
reference: payment.endToEndId || payment.remittanceInfo || payment.paymentId,
|
|
540
|
+
lines,
|
|
541
|
+
skrType: this.skrType
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const journalEntry = new JournalEntry(journalData);
|
|
545
|
+
await journalEntry.validate();
|
|
546
|
+
await journalEntry.post();
|
|
547
|
+
|
|
548
|
+
return {
|
|
549
|
+
success: true,
|
|
550
|
+
journalEntry,
|
|
551
|
+
confidence: 100
|
|
552
|
+
};
|
|
553
|
+
} catch (error) {
|
|
554
|
+
this.logger.log('error', `Failed to book payment: ${error}`);
|
|
555
|
+
return {
|
|
556
|
+
success: false,
|
|
557
|
+
confidence: 0,
|
|
558
|
+
errors: [`Payment booking failed: ${error.message}`]
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Validate invoice before booking
|
|
565
|
+
*/
|
|
566
|
+
private validateInvoice(invoice: IInvoice): string[] {
|
|
567
|
+
const errors: string[] = [];
|
|
568
|
+
|
|
569
|
+
// Check required fields
|
|
570
|
+
if (!invoice.invoiceNumber) {
|
|
571
|
+
errors.push('Invoice number is required');
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (!invoice.issueDate) {
|
|
575
|
+
errors.push('Issue date is required');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (!invoice.supplier || !invoice.supplier.name) {
|
|
579
|
+
errors.push('Supplier information is required');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (!invoice.customer || !invoice.customer.name) {
|
|
583
|
+
errors.push('Customer information is required');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (invoice.lines.length === 0) {
|
|
587
|
+
errors.push('Invoice must have at least one line item');
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Validate amounts
|
|
591
|
+
const calculatedNet = invoice.lines.reduce((sum, line) => sum + line.netAmount, 0);
|
|
592
|
+
const tolerance = 0.01;
|
|
593
|
+
|
|
594
|
+
if (Math.abs(calculatedNet - invoice.lineNetAmount) > tolerance) {
|
|
595
|
+
errors.push(`Line net amount mismatch: calculated ${calculatedNet}, stated ${invoice.lineNetAmount}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Validate VAT
|
|
599
|
+
const calculatedVAT = invoice.vatBreakdown.reduce((sum, vb) => sum + vb.taxAmount, 0);
|
|
600
|
+
if (Math.abs(calculatedVAT - invoice.totalVATAmount) > tolerance) {
|
|
601
|
+
errors.push(`VAT amount mismatch: calculated ${calculatedVAT}, stated ${invoice.totalVATAmount}`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Validate total
|
|
605
|
+
const calculatedTotal = invoice.taxExclusiveAmount + invoice.totalVATAmount;
|
|
606
|
+
if (Math.abs(calculatedTotal - invoice.taxInclusiveAmount) > tolerance) {
|
|
607
|
+
errors.push(`Total amount mismatch: calculated ${calculatedTotal}, stated ${invoice.taxInclusiveAmount}`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return errors;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Generate warnings for the booking
|
|
615
|
+
*/
|
|
616
|
+
private generateWarnings(invoice: IInvoice, rules: IBookingRules): string[] {
|
|
617
|
+
const warnings: string[] = [];
|
|
618
|
+
|
|
619
|
+
// Warn about default account usage
|
|
620
|
+
const hasDefaultAccounts = invoice.lines.some(line =>
|
|
621
|
+
!line.accountNumber && !line.productCode
|
|
622
|
+
);
|
|
623
|
+
if (hasDefaultAccounts) {
|
|
624
|
+
warnings.push('Some lines are using default expense/revenue accounts');
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Warn about mixed VAT rates
|
|
628
|
+
if (invoice.vatBreakdown.length > 1) {
|
|
629
|
+
warnings.push('Invoice contains mixed VAT rates');
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Warn about reverse charge
|
|
633
|
+
if (invoice.taxScenario === 'reverse_charge') {
|
|
634
|
+
warnings.push('Reverse charge procedure applied - verify VAT treatment');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Warn about credit notes
|
|
638
|
+
if (invoice.invoiceTypeCode === '381') {
|
|
639
|
+
warnings.push('This is a credit note - amounts will be reversed');
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Warn about foreign currency
|
|
643
|
+
if (invoice.currencyCode !== 'EUR') {
|
|
644
|
+
warnings.push(`Invoice is in foreign currency: ${invoice.currencyCode}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return warnings;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Build description for journal entry
|
|
652
|
+
*/
|
|
653
|
+
private buildDescription(invoice: IInvoice): string {
|
|
654
|
+
const type = invoice.invoiceTypeCode === '381' ? 'Credit Note' : 'Invoice';
|
|
655
|
+
const party = invoice.direction === 'inbound'
|
|
656
|
+
? invoice.supplier.name
|
|
657
|
+
: invoice.customer.name;
|
|
658
|
+
|
|
659
|
+
return `${type} ${invoice.invoiceNumber} - ${party}`;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Get account description for a group of lines
|
|
664
|
+
*/
|
|
665
|
+
private getAccountDescription(accountNumber: string, lines: IInvoiceLine[]): string {
|
|
666
|
+
if (lines.length === 1) {
|
|
667
|
+
return lines[0].description;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return `${this.mapper.getAccountDescription(accountNumber)} (${lines.length} items)`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Get used expense accounts
|
|
675
|
+
*/
|
|
676
|
+
private getUsedExpenseAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
|
677
|
+
if (invoice.direction !== 'inbound') return [];
|
|
678
|
+
|
|
679
|
+
const accounts = new Set<string>();
|
|
680
|
+
for (const line of invoice.lines) {
|
|
681
|
+
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
|
682
|
+
accounts.add(account);
|
|
683
|
+
}
|
|
684
|
+
return Array.from(accounts);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Get used revenue accounts
|
|
689
|
+
*/
|
|
690
|
+
private getUsedRevenueAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
|
691
|
+
if (invoice.direction !== 'outbound') return [];
|
|
692
|
+
|
|
693
|
+
const accounts = new Set<string>();
|
|
694
|
+
for (const line of invoice.lines) {
|
|
695
|
+
const account = this.mapper.mapInvoiceLineToAccount(line, invoice, rules);
|
|
696
|
+
accounts.add(account);
|
|
697
|
+
}
|
|
698
|
+
return Array.from(accounts);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Get used VAT accounts
|
|
703
|
+
*/
|
|
704
|
+
private getUsedVATAccounts(invoice: IInvoice, rules: IBookingRules): string[] {
|
|
705
|
+
const accounts = new Set<string>();
|
|
706
|
+
const direction = invoice.direction === 'inbound' ? 'input' : 'output';
|
|
707
|
+
const taxScenario = invoice.taxScenario || 'domestic_taxed';
|
|
708
|
+
|
|
709
|
+
for (const vatBreak of invoice.vatBreakdown) {
|
|
710
|
+
const account = this.mapper.getVATAccount(
|
|
711
|
+
vatBreak.vatCategory,
|
|
712
|
+
direction,
|
|
713
|
+
taxScenario
|
|
714
|
+
);
|
|
715
|
+
accounts.add(account);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Add reverse charge accounts if applicable
|
|
719
|
+
if (taxScenario === 'reverse_charge') {
|
|
720
|
+
for (const vatBreak of invoice.vatBreakdown) {
|
|
721
|
+
const inputAccount = this.mapper.getVATAccount(
|
|
722
|
+
vatBreak.vatCategory,
|
|
723
|
+
'input',
|
|
724
|
+
'reverse_charge'
|
|
725
|
+
);
|
|
726
|
+
const outputAccount = this.mapper.getVATAccount(
|
|
727
|
+
vatBreak.vatCategory,
|
|
728
|
+
'output',
|
|
729
|
+
'reverse_charge'
|
|
730
|
+
);
|
|
731
|
+
accounts.add(inputAccount);
|
|
732
|
+
accounts.add(outputAccount);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
return Array.from(accounts);
|
|
737
|
+
}
|
|
738
|
+
}
|