@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.
Files changed (46) hide show
  1. package/dist_ts/index.d.ts +6 -0
  2. package/dist_ts/index.js +7 -1
  3. package/dist_ts/plugins.d.ts +8 -1
  4. package/dist_ts/plugins.js +10 -2
  5. package/dist_ts/skr.api.d.ts +70 -0
  6. package/dist_ts/skr.api.js +348 -1
  7. package/dist_ts/skr.export.accounts.d.ts +53 -0
  8. package/dist_ts/skr.export.accounts.js +111 -0
  9. package/dist_ts/skr.export.balances.d.ts +59 -0
  10. package/dist_ts/skr.export.balances.js +205 -0
  11. package/dist_ts/skr.export.d.ts +110 -0
  12. package/dist_ts/skr.export.js +315 -0
  13. package/dist_ts/skr.export.ledger.d.ts +95 -0
  14. package/dist_ts/skr.export.ledger.js +164 -0
  15. package/dist_ts/skr.export.pdf.d.ts +82 -0
  16. package/dist_ts/skr.export.pdf.js +548 -0
  17. package/dist_ts/skr.invoice.adapter.d.ts +98 -0
  18. package/dist_ts/skr.invoice.adapter.js +476 -0
  19. package/dist_ts/skr.invoice.booking.d.ts +102 -0
  20. package/dist_ts/skr.invoice.booking.js +556 -0
  21. package/dist_ts/skr.invoice.entity.d.ts +287 -0
  22. package/dist_ts/skr.invoice.entity.js +2 -0
  23. package/dist_ts/skr.invoice.mapper.d.ts +69 -0
  24. package/dist_ts/skr.invoice.mapper.js +401 -0
  25. package/dist_ts/skr.invoice.storage.d.ts +140 -0
  26. package/dist_ts/skr.invoice.storage.js +529 -0
  27. package/dist_ts/skr.security.d.ts +65 -0
  28. package/dist_ts/skr.security.js +319 -0
  29. package/dist_ts/skr.types.d.ts +1 -0
  30. package/package.json +17 -12
  31. package/readme.md +207 -16
  32. package/ts/index.ts +6 -0
  33. package/ts/plugins.ts +22 -1
  34. package/ts/skr.api.ts +485 -0
  35. package/ts/skr.export.accounts.ts +154 -0
  36. package/ts/skr.export.balances.ts +270 -0
  37. package/ts/skr.export.ledger.ts +249 -0
  38. package/ts/skr.export.pdf.ts +601 -0
  39. package/ts/skr.export.ts +443 -0
  40. package/ts/skr.invoice.adapter.ts +581 -0
  41. package/ts/skr.invoice.booking.ts +738 -0
  42. package/ts/skr.invoice.entity.ts +351 -0
  43. package/ts/skr.invoice.mapper.ts +486 -0
  44. package/ts/skr.invoice.storage.ts +710 -0
  45. package/ts/skr.security.ts +405 -0
  46. package/ts/skr.types.ts +1 -0
@@ -0,0 +1,710 @@
1
+ import * as plugins from './plugins.js';
2
+ import * as path from 'path';
3
+ import type {
4
+ IInvoice,
5
+ IInvoiceFilter,
6
+ IDuplicateCheckResult
7
+ } from './skr.invoice.entity.js';
8
+
9
+ /**
10
+ * Invoice storage metadata
11
+ */
12
+ export interface IInvoiceMetadata {
13
+ invoiceId: string;
14
+ invoiceNumber: string;
15
+ direction: 'inbound' | 'outbound';
16
+ issueDate: string;
17
+ supplierName: string;
18
+ customerName: string;
19
+ totalAmount: number;
20
+ currency: string;
21
+ contentHash: string;
22
+ pdfHash?: string;
23
+ xmlHash: string;
24
+ journalEntryId?: string;
25
+ transactionIds?: string[];
26
+ validationResult: {
27
+ isValid: boolean;
28
+ errors: number;
29
+ warnings: number;
30
+ };
31
+ parserVersion: string;
32
+ storedAt: string;
33
+ storedBy: string;
34
+ }
35
+
36
+ /**
37
+ * Invoice registry entry (for NDJSON streaming)
38
+ */
39
+ export interface IInvoiceRegistryEntry {
40
+ id: string;
41
+ hash: string;
42
+ metadata: IInvoiceMetadata;
43
+ }
44
+
45
+ /**
46
+ * Storage statistics
47
+ */
48
+ export interface IStorageStats {
49
+ totalInvoices: number;
50
+ inboundCount: number;
51
+ outboundCount: number;
52
+ totalSize: number;
53
+ duplicatesDetected: number;
54
+ lastUpdate: Date;
55
+ }
56
+
57
+ /**
58
+ * Content-addressed storage for invoices
59
+ * Integrates with BagIt archive structure for GoBD compliance
60
+ */
61
+ export class InvoiceStorage {
62
+ private exportPath: string;
63
+ private logger: plugins.smartlog.ConsoleLog;
64
+ private registryPath: string;
65
+ private metadataCache: Map<string, IInvoiceMetadata>;
66
+ private readonly MAX_CACHE_SIZE = 10000; // Maximum number of cached entries
67
+ private cacheAccessOrder: string[] = []; // Track access order for LRU eviction
68
+
69
+ constructor(exportPath: string) {
70
+ this.exportPath = exportPath;
71
+ this.logger = new plugins.smartlog.ConsoleLog();
72
+ this.registryPath = path.join(exportPath, 'data', 'documents', 'invoices', 'registry.ndjson');
73
+ this.metadataCache = new Map();
74
+ }
75
+
76
+ /**
77
+ * Manage cache size using LRU eviction
78
+ */
79
+ private manageCacheSize(): void {
80
+ if (this.metadataCache.size > this.MAX_CACHE_SIZE) {
81
+ // Remove least recently used entries
82
+ const entriesToRemove = Math.min(100, Math.floor(this.MAX_CACHE_SIZE * 0.1)); // Remove 10% or 100 entries
83
+ const keysToRemove = this.cacheAccessOrder.splice(0, entriesToRemove);
84
+
85
+ for (const key of keysToRemove) {
86
+ this.metadataCache.delete(key);
87
+ }
88
+
89
+ this.logger.log('info', `Evicted ${entriesToRemove} entries from metadata cache`);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Update cache access order for LRU
95
+ */
96
+ private touchCacheEntry(key: string): void {
97
+ const index = this.cacheAccessOrder.indexOf(key);
98
+ if (index > -1) {
99
+ this.cacheAccessOrder.splice(index, 1);
100
+ }
101
+ this.cacheAccessOrder.push(key);
102
+ }
103
+
104
+ /**
105
+ * Initialize storage directories
106
+ */
107
+ public async initialize(): Promise<void> {
108
+ const dirs = [
109
+ path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
110
+ path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound', 'metadata'),
111
+ path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound'),
112
+ path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound', 'metadata'),
113
+ path.join(this.exportPath, 'data', 'validation')
114
+ ];
115
+
116
+ for (const dir of dirs) {
117
+ await plugins.smartfile.fs.ensureDir(dir);
118
+ }
119
+
120
+ // Load existing registry if it exists
121
+ await this.loadRegistry();
122
+ }
123
+
124
+ private readonly MAX_PDF_SIZE = 50 * 1024 * 1024; // 50MB max
125
+
126
+ /**
127
+ * Store an invoice with content addressing
128
+ */
129
+ public async storeInvoice(
130
+ invoice: IInvoice,
131
+ pdfBuffer?: Buffer
132
+ ): Promise<string> {
133
+ try {
134
+ // Validate PDF size if provided
135
+ if (pdfBuffer && pdfBuffer.length > this.MAX_PDF_SIZE) {
136
+ throw new Error(`PDF file too large: ${pdfBuffer.length} bytes (max ${this.MAX_PDF_SIZE} bytes)`);
137
+ }
138
+ // Calculate hashes
139
+ const xmlHash = await this.calculateHash(invoice.xmlContent || '');
140
+ const pdfHash = pdfBuffer ? await this.calculateHash(pdfBuffer) : undefined;
141
+ const contentHash = xmlHash; // Primary content hash is XML
142
+
143
+ // Check for duplicates
144
+ const duplicateCheck = await this.checkDuplicate(invoice, contentHash);
145
+ if (duplicateCheck.isDuplicate) {
146
+ this.logger.log('warn', `Duplicate invoice detected: ${invoice.invoiceNumber}`);
147
+ return duplicateCheck.matchedContentHash || contentHash;
148
+ }
149
+
150
+ // Determine storage path
151
+ const direction = invoice.direction;
152
+ const basePath = path.join(
153
+ this.exportPath,
154
+ 'data',
155
+ 'documents',
156
+ 'invoices',
157
+ direction
158
+ );
159
+
160
+ // Create filename with content hash
161
+ const dateStr = invoice.issueDate.toISOString().split('T')[0];
162
+ const sanitizedNumber = invoice.invoiceNumber.replace(/[^a-zA-Z0-9-_]/g, '_');
163
+ const xmlFilename = `${contentHash.substring(0, 8)}_${dateStr}_${sanitizedNumber}.xml`;
164
+ const xmlPath = path.join(basePath, xmlFilename);
165
+
166
+ // Store XML
167
+ await plugins.smartfile.memory.toFs(invoice.xmlContent || '', xmlPath);
168
+
169
+ // Store PDF if available
170
+ let pdfFilename: string | undefined;
171
+ if (pdfBuffer) {
172
+ pdfFilename = xmlFilename.replace('.xml', '.pdf');
173
+ const pdfPath = path.join(basePath, pdfFilename);
174
+ await plugins.smartfile.memory.toFs(pdfBuffer, pdfPath);
175
+
176
+ // Also store PDF/A-3 with embedded XML if supported
177
+ if (invoice.format === 'zugferd' || invoice.format === 'facturx') {
178
+ const pdfA3Filename = xmlFilename.replace('.xml', '_pdfa3.pdf');
179
+ const pdfA3Path = path.join(basePath, pdfA3Filename);
180
+ // The PDF should already have embedded XML if it's ZUGFeRD/Factur-X
181
+ await plugins.smartfile.memory.toFs(pdfBuffer, pdfA3Path);
182
+ }
183
+ }
184
+
185
+ // Create and store metadata
186
+ const metadata: IInvoiceMetadata = {
187
+ invoiceId: invoice.id,
188
+ invoiceNumber: invoice.invoiceNumber,
189
+ direction: invoice.direction,
190
+ issueDate: invoice.issueDate.toISOString(),
191
+ supplierName: invoice.supplier.name,
192
+ customerName: invoice.customer.name,
193
+ totalAmount: invoice.payableAmount,
194
+ currency: invoice.currencyCode,
195
+ contentHash,
196
+ pdfHash,
197
+ xmlHash,
198
+ journalEntryId: invoice.bookingInfo?.journalEntryId,
199
+ transactionIds: invoice.bookingInfo?.transactionIds,
200
+ validationResult: {
201
+ isValid: invoice.validationResult?.isValid || false,
202
+ errors: this.countErrors(invoice.validationResult),
203
+ warnings: this.countWarnings(invoice.validationResult)
204
+ },
205
+ parserVersion: invoice.metadata?.parserVersion || '5.1.4',
206
+ storedAt: new Date().toISOString(),
207
+ storedBy: invoice.createdBy
208
+ };
209
+
210
+ const metadataPath = path.join(basePath, 'metadata', `${contentHash}.json`);
211
+ await plugins.smartfile.memory.toFs(
212
+ JSON.stringify(metadata, null, 2),
213
+ metadataPath
214
+ );
215
+
216
+ // Update registry
217
+ await this.updateRegistry(invoice.id, contentHash, metadata);
218
+
219
+ // Cache metadata with LRU management
220
+ this.setCacheEntry(contentHash, metadata);
221
+
222
+ this.logger.log('info', `Invoice stored: ${invoice.invoiceNumber} (${contentHash})`);
223
+
224
+ return contentHash;
225
+ } catch (error) {
226
+ this.logger.log('error', `Failed to store invoice: ${error}`);
227
+ throw new Error(`Invoice storage failed: ${error.message}`);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Retrieve an invoice by content hash
233
+ */
234
+ public async retrieveInvoice(contentHash: string): Promise<IInvoice | null> {
235
+ try {
236
+ // Check cache first
237
+ const metadata = this.getCacheEntry(contentHash);
238
+ if (!metadata) {
239
+ this.logger.log('warn', `Invoice not found: ${contentHash}`);
240
+ return null;
241
+ }
242
+
243
+ // Load XML content
244
+ const xmlPath = await this.findInvoiceFile(contentHash, '.xml');
245
+ if (!xmlPath) {
246
+ throw new Error(`XML file not found for invoice ${contentHash}`);
247
+ }
248
+
249
+ const xmlContent = await plugins.smartfile.fs.toStringSync(xmlPath);
250
+
251
+ // Load PDF if exists
252
+ let pdfContent: Buffer | undefined;
253
+ const pdfPath = await this.findInvoiceFile(contentHash, '.pdf');
254
+ if (pdfPath) {
255
+ pdfContent = await plugins.smartfile.fs.toBuffer(pdfPath);
256
+ }
257
+
258
+ // Reconstruct invoice object (partial)
259
+ const invoice: Partial<IInvoice> = {
260
+ id: metadata.invoiceId,
261
+ invoiceNumber: metadata.invoiceNumber,
262
+ direction: metadata.direction as any,
263
+ issueDate: new Date(metadata.issueDate),
264
+ supplier: {
265
+ name: metadata.supplierName,
266
+ id: '',
267
+ address: { countryCode: 'DE' }
268
+ },
269
+ customer: {
270
+ name: metadata.customerName,
271
+ id: '',
272
+ address: { countryCode: 'DE' }
273
+ },
274
+ payableAmount: metadata.totalAmount,
275
+ currencyCode: metadata.currency,
276
+ contentHash: metadata.contentHash,
277
+ xmlContent,
278
+ pdfContent,
279
+ pdfHash: metadata.pdfHash
280
+ };
281
+
282
+ return invoice as IInvoice;
283
+ } catch (error) {
284
+ this.logger.log('error', `Failed to retrieve invoice: ${error}`);
285
+ return null;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Check for duplicate invoices
291
+ */
292
+ public async checkDuplicate(
293
+ invoice: IInvoice,
294
+ contentHash: string
295
+ ): Promise<IDuplicateCheckResult> {
296
+ // Check by content hash (exact match)
297
+ const existing = this.getCacheEntry(contentHash);
298
+ if (existing) {
299
+ return {
300
+ isDuplicate: true,
301
+ matchedInvoiceId: existing.invoiceId,
302
+ matchedContentHash: contentHash,
303
+ matchedFields: ['contentHash'],
304
+ confidence: 100
305
+ };
306
+ }
307
+
308
+ // Check by invoice number and supplier/customer
309
+ for (const [hash, metadata] of this.metadataCache.entries()) {
310
+ if (
311
+ metadata.invoiceNumber === invoice.invoiceNumber &&
312
+ metadata.direction === invoice.direction
313
+ ) {
314
+ // Same invoice number and direction
315
+ if (invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) {
316
+ // Same supplier
317
+ return {
318
+ isDuplicate: true,
319
+ matchedInvoiceId: metadata.invoiceId,
320
+ matchedContentHash: hash,
321
+ matchedFields: ['invoiceNumber', 'supplier'],
322
+ confidence: 95
323
+ };
324
+ } else if (invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name) {
325
+ // Same customer
326
+ return {
327
+ isDuplicate: true,
328
+ matchedInvoiceId: metadata.invoiceId,
329
+ matchedContentHash: hash,
330
+ matchedFields: ['invoiceNumber', 'customer'],
331
+ confidence: 95
332
+ };
333
+ }
334
+ }
335
+
336
+ // Check by amount and date within tolerance
337
+ const dateTolerance = 7 * 24 * 60 * 60 * 1000; // 7 days
338
+ const amountTolerance = 0.01;
339
+
340
+ if (
341
+ Math.abs(metadata.totalAmount - invoice.payableAmount) < amountTolerance &&
342
+ Math.abs(new Date(metadata.issueDate).getTime() - invoice.issueDate.getTime()) < dateTolerance &&
343
+ metadata.direction === invoice.direction
344
+ ) {
345
+ if (
346
+ (invoice.direction === 'inbound' && metadata.supplierName === invoice.supplier.name) ||
347
+ (invoice.direction === 'outbound' && metadata.customerName === invoice.customer.name)
348
+ ) {
349
+ return {
350
+ isDuplicate: true,
351
+ matchedInvoiceId: metadata.invoiceId,
352
+ matchedContentHash: hash,
353
+ matchedFields: ['amount', 'date', 'party'],
354
+ confidence: 85
355
+ };
356
+ }
357
+ }
358
+ }
359
+
360
+ return {
361
+ isDuplicate: false,
362
+ confidence: 0
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Search invoices by filter
368
+ */
369
+ public async searchInvoices(filter: IInvoiceFilter): Promise<IInvoiceMetadata[]> {
370
+ const results: IInvoiceMetadata[] = [];
371
+
372
+ for (const metadata of this.metadataCache.values()) {
373
+ if (this.matchesFilter(metadata, filter)) {
374
+ results.push(metadata);
375
+ }
376
+ }
377
+
378
+ // Sort by date descending
379
+ results.sort((a, b) =>
380
+ new Date(b.issueDate).getTime() - new Date(a.issueDate).getTime()
381
+ );
382
+
383
+ return results;
384
+ }
385
+
386
+ /**
387
+ * Get storage statistics
388
+ */
389
+ public async getStatistics(): Promise<IStorageStats> {
390
+ let totalSize = 0;
391
+ let inboundCount = 0;
392
+ let outboundCount = 0;
393
+
394
+ for (const metadata of this.metadataCache.values()) {
395
+ if (metadata.direction === 'inbound') {
396
+ inboundCount++;
397
+ } else {
398
+ outboundCount++;
399
+ }
400
+
401
+ // Estimate size (would need actual file sizes in production)
402
+ totalSize += 50000; // Rough estimate
403
+ }
404
+
405
+ return {
406
+ totalInvoices: this.metadataCache.size,
407
+ inboundCount,
408
+ outboundCount,
409
+ totalSize,
410
+ duplicatesDetected: 0, // Would track this in production
411
+ lastUpdate: new Date()
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Create EN16931 compliance report
417
+ */
418
+ public async createComplianceReport(): Promise<void> {
419
+ const report = {
420
+ timestamp: new Date().toISOString(),
421
+ totalInvoices: this.metadataCache.size,
422
+ validInvoices: 0,
423
+ invalidInvoices: 0,
424
+ warnings: 0,
425
+ byFormat: {} as Record<string, number>,
426
+ byDirection: {
427
+ inbound: 0,
428
+ outbound: 0
429
+ },
430
+ validationErrors: [] as string[],
431
+ complianceLevel: 'EN16931',
432
+ validatorVersion: '5.1.4'
433
+ };
434
+
435
+ for (const metadata of this.metadataCache.values()) {
436
+ if (metadata.validationResult.isValid) {
437
+ report.validInvoices++;
438
+ } else {
439
+ report.invalidInvoices++;
440
+ }
441
+
442
+ report.warnings += metadata.validationResult.warnings;
443
+
444
+ if (metadata.direction === 'inbound') {
445
+ report.byDirection.inbound++;
446
+ } else {
447
+ report.byDirection.outbound++;
448
+ }
449
+ }
450
+
451
+ const reportPath = path.join(
452
+ this.exportPath,
453
+ 'data',
454
+ 'validation',
455
+ 'en16931_compliance.json'
456
+ );
457
+
458
+ await plugins.smartfile.memory.toFs(
459
+ JSON.stringify(report, null, 2),
460
+ reportPath
461
+ );
462
+ }
463
+
464
+ /**
465
+ * Load registry from disk
466
+ */
467
+ private async loadRegistry(): Promise<void> {
468
+ try {
469
+ if (await plugins.smartfile.fs.fileExists(this.registryPath)) {
470
+ const content = await plugins.smartfile.fs.toStringSync(this.registryPath);
471
+ const lines = content.split('\n').filter(line => line.trim());
472
+
473
+ for (const line of lines) {
474
+ try {
475
+ const entry: IInvoiceRegistryEntry = JSON.parse(line);
476
+ this.setCacheEntry(entry.hash, entry.metadata);
477
+ } catch (e) {
478
+ this.logger.log('warn', `Invalid registry entry: ${line}`);
479
+ }
480
+ }
481
+
482
+ this.logger.log('info', `Loaded ${this.metadataCache.size} invoices from registry`);
483
+ }
484
+ } catch (error) {
485
+ this.logger.log('error', `Failed to load registry: ${error}`);
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Update registry with new entry
491
+ */
492
+ private async updateRegistry(
493
+ invoiceId: string,
494
+ contentHash: string,
495
+ metadata: IInvoiceMetadata
496
+ ): Promise<void> {
497
+ try {
498
+ const entry: IInvoiceRegistryEntry = {
499
+ id: invoiceId,
500
+ hash: contentHash,
501
+ metadata
502
+ };
503
+
504
+ // Append to NDJSON file
505
+ const line = JSON.stringify(entry) + '\n';
506
+ await plugins.smartfile.fs.ensureDir(path.dirname(this.registryPath));
507
+
508
+ // Use native fs for atomic append (better performance and concurrency safety)
509
+ const fs = await import('fs/promises');
510
+ await fs.appendFile(this.registryPath, line, 'utf8');
511
+ } catch (error) {
512
+ this.logger.log('error', `Failed to update registry: ${error}`);
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Find invoice file by hash and extension
518
+ */
519
+ private async findInvoiceFile(
520
+ contentHash: string,
521
+ extension: string
522
+ ): Promise<string | null> {
523
+ const dirs = [
524
+ path.join(this.exportPath, 'data', 'documents', 'invoices', 'inbound'),
525
+ path.join(this.exportPath, 'data', 'documents', 'invoices', 'outbound')
526
+ ];
527
+
528
+ for (const dir of dirs) {
529
+ const files = await plugins.smartfile.fs.listFileTree(dir, '**/*' + extension);
530
+
531
+ for (const file of files) {
532
+ if (file.includes(contentHash.substring(0, 8))) {
533
+ return path.join(dir, file);
534
+ }
535
+ }
536
+ }
537
+
538
+ return null;
539
+ }
540
+
541
+ /**
542
+ * Calculate SHA-256 hash
543
+ */
544
+ private async calculateHash(data: string | Buffer): Promise<string> {
545
+ if (typeof data === 'string') {
546
+ return await plugins.smarthash.sha256FromString(data);
547
+ } else {
548
+ return await plugins.smarthash.sha256FromBuffer(data);
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Check if metadata matches filter
554
+ */
555
+ private matchesFilter(metadata: IInvoiceMetadata, filter: IInvoiceFilter): boolean {
556
+ if (filter.direction && metadata.direction !== filter.direction) {
557
+ return false;
558
+ }
559
+
560
+ if (filter.dateFrom && new Date(metadata.issueDate) < filter.dateFrom) {
561
+ return false;
562
+ }
563
+
564
+ if (filter.dateTo && new Date(metadata.issueDate) > filter.dateTo) {
565
+ return false;
566
+ }
567
+
568
+ if (filter.minAmount && metadata.totalAmount < filter.minAmount) {
569
+ return false;
570
+ }
571
+
572
+ if (filter.maxAmount && metadata.totalAmount > filter.maxAmount) {
573
+ return false;
574
+ }
575
+
576
+ if (filter.invoiceNumber && !metadata.invoiceNumber.includes(filter.invoiceNumber)) {
577
+ return false;
578
+ }
579
+
580
+ if (filter.supplierId && !metadata.supplierName.includes(filter.supplierId)) {
581
+ return false;
582
+ }
583
+
584
+ if (filter.customerId && !metadata.customerName.includes(filter.customerId)) {
585
+ return false;
586
+ }
587
+
588
+ return true;
589
+ }
590
+
591
+ /**
592
+ * Count errors in validation result
593
+ */
594
+ private countErrors(validationResult?: IInvoice['validationResult']): number {
595
+ if (!validationResult) return 0;
596
+
597
+ return (
598
+ validationResult.syntax.errors.length +
599
+ validationResult.semantic.errors.length +
600
+ validationResult.businessRules.errors.length +
601
+ (validationResult.countrySpecific?.errors.length || 0)
602
+ );
603
+ }
604
+
605
+ /**
606
+ * Count warnings in validation result
607
+ */
608
+ private countWarnings(validationResult?: IInvoice['validationResult']): number {
609
+ if (!validationResult) return 0;
610
+
611
+ return (
612
+ validationResult.syntax.warnings.length +
613
+ validationResult.semantic.warnings.length +
614
+ validationResult.businessRules.warnings.length +
615
+ (validationResult.countrySpecific?.warnings.length || 0)
616
+ );
617
+ }
618
+
619
+ /**
620
+ * Clean up old invoices (for testing only)
621
+ */
622
+ public async cleanup(olderThanDays: number = 365): Promise<number> {
623
+ let removed = 0;
624
+ const cutoffDate = new Date();
625
+ cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
626
+
627
+ for (const [hash, metadata] of this.metadataCache.entries()) {
628
+ if (new Date(metadata.issueDate) < cutoffDate) {
629
+ this.metadataCache.delete(hash);
630
+ removed++;
631
+ }
632
+ }
633
+
634
+ this.logger.log('info', `Removed ${removed} old invoices from cache`);
635
+ return removed;
636
+ }
637
+
638
+ /**
639
+ * Set cache entry with LRU eviction
640
+ */
641
+ private setCacheEntry(key: string, value: IInvoiceMetadata): void {
642
+ // Remove from access order if already exists
643
+ const existingIndex = this.cacheAccessOrder.indexOf(key);
644
+ if (existingIndex > -1) {
645
+ this.cacheAccessOrder.splice(existingIndex, 1);
646
+ }
647
+
648
+ // Add to end (most recently used)
649
+ this.cacheAccessOrder.push(key);
650
+ this.metadataCache.set(key, value);
651
+
652
+ // Evict oldest if cache is too large
653
+ while (this.metadataCache.size > this.MAX_CACHE_SIZE) {
654
+ const oldestKey = this.cacheAccessOrder.shift();
655
+ if (oldestKey) {
656
+ this.metadataCache.delete(oldestKey);
657
+ this.logger.log('debug', `Evicted invoice from cache: ${oldestKey}`);
658
+ }
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Get cache entry and update access order
664
+ */
665
+ private getCacheEntry(key: string): IInvoiceMetadata | undefined {
666
+ const value = this.metadataCache.get(key);
667
+ if (value) {
668
+ // Move to end (most recently used)
669
+ const index = this.cacheAccessOrder.indexOf(key);
670
+ if (index > -1) {
671
+ this.cacheAccessOrder.splice(index, 1);
672
+ }
673
+ this.cacheAccessOrder.push(key);
674
+ }
675
+ return value;
676
+ }
677
+
678
+ /**
679
+ * Update metadata in storage and cache
680
+ */
681
+ public async updateMetadata(contentHash: string, updates: Partial<IInvoiceMetadata>): Promise<void> {
682
+ const metadata = this.getCacheEntry(contentHash);
683
+ if (!metadata) {
684
+ this.logger.log('warn', `Cannot update metadata - invoice not found: ${contentHash}`);
685
+ return;
686
+ }
687
+
688
+ // Update metadata
689
+ const updatedMetadata = { ...metadata, ...updates };
690
+ this.setCacheEntry(contentHash, updatedMetadata);
691
+
692
+ // Persist to disk
693
+ const metadataPath = path.join(
694
+ this.exportPath,
695
+ 'data',
696
+ 'documents',
697
+ 'invoices',
698
+ metadata.direction,
699
+ 'metadata',
700
+ `${contentHash}.json`
701
+ );
702
+
703
+ await plugins.smartfile.memory.toFs(
704
+ JSON.stringify(updatedMetadata, null, 2),
705
+ metadataPath
706
+ );
707
+
708
+ this.logger.log('info', `Updated metadata for invoice: ${contentHash}`);
709
+ }
710
+ }