import * as plugins from './plugins.js'; import * as path from 'path'; import type { IAccountData, ITransactionData, IJournalEntry, TSKRType } from './skr.types.js'; export interface IExportOptions { exportPath: string; fiscalYear: number; dateFrom: Date; dateTo: Date; includeDocuments?: boolean; generatePdfReports?: boolean; signExport?: boolean; timestampExport?: boolean; companyInfo?: { name: string; taxId: string; registrationNumber?: string; address?: string; }; } export interface IExportMetadata { exportVersion: string; exportTimestamp: string; generator: { name: string; version: string; }; company?: { name: string; taxId: string; registrationNumber?: string; address?: string; }; fiscalYear: number; dateRange: { from: string; to: string; }; skrType: TSKRType; schemaVersion: string; crypto: { digestAlgorithms: string[]; signatureType?: string; timestampPolicy?: string; merkleTree: boolean; }; options: { packagedAs: 'bagit'; compression: 'none' | 'deflate'; deduplication: boolean; }; } export interface IBagItManifest { [filePath: string]: string; // filePath -> SHA256 hash } export interface IDocumentIndex { contentHash: string; sizeBytes: number; mimeType: string; createdAt: string; originalFilename?: string; pdfaAvailable: boolean; zugferdXml?: string; retentionClass: string; } export class SkrExport { private logger: plugins.smartlog.ConsoleLog; private options: IExportOptions; private exportDir: string; private manifest: IBagItManifest = {}; private tagManifest: IBagItManifest = {}; constructor(options: IExportOptions) { this.options = options; this.logger = new plugins.smartlog.ConsoleLog(); this.exportDir = path.join(options.exportPath, `jahresabschluss_${options.fiscalYear}`); } /** * Creates the BagIt directory structure for the export */ public async createBagItStructure(): Promise { this.logger.log('info', 'Creating BagIt directory structure...'); // Create main directories await plugins.smartfile.fs.ensureDir(this.exportDir); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'schemas')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'schemas', 'v1')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'metadata', 'signatures')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'accounting')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'accounting', 'ebilanz')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'documents')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'documents', 'by-hash')); await plugins.smartfile.fs.ensureDir(path.join(this.exportDir, 'data', 'reports')); // Create BagIt declaration file await this.createBagItDeclaration(); // Create README await this.createReadme(); this.logger.log('ok', 'BagIt structure created successfully'); } /** * Creates the bagit.txt declaration file */ private async createBagItDeclaration(): Promise { const bagitContent = `BagIt-Version: 1.0 Tag-File-Character-Encoding: UTF-8`; const filePath = path.join(this.exportDir, 'bagit.txt'); await plugins.smartfile.memory.toFs(bagitContent, filePath); // Add to tag manifest const hash = await this.hashFile(filePath); this.tagManifest['bagit.txt'] = hash; } /** * Creates the README.txt file with Verfahrensdokumentation */ private async createReadme(): Promise { const readmeContent = `SKR Jahresabschluss Export - Verfahrensdokumentation ===================================================== Dieses Archiv enthält einen revisionssicheren Export des Jahresabschlusses gemäß den Grundsätzen ordnungsmäßiger Buchführung (GoBD). Export-Datum: ${new Date().toISOString()} Geschäftsjahr: ${this.options.fiscalYear} Zeitraum: ${this.options.dateFrom.toISOString()} bis ${this.options.dateTo.toISOString()} STRUKTUR DES ARCHIVS -------------------- - /data/accounting/: Buchhaltungsdaten (Journale, Konten, Salden) - /data/documents/: Belegdokumente (content-adressiert) - /data/reports/: Finanzberichte (PDF/A-3) - /data/metadata/: Export-Metadaten und Schemas - /data/metadata/signatures/: Digitale Signaturen und Zeitstempel INTEGRITÄTSSICHERUNG -------------------- - Alle Dateien sind mit SHA-256 gehasht (siehe manifest-sha256.txt) - Optional: Digitale Signatur (CAdES) über Manifest - Optional: RFC 3161 Zeitstempel AUFBEWAHRUNG ------------ Dieses Archiv muss gemäß § 147 AO für 10 Jahre revisionssicher aufbewahrt werden. Empfohlen wird die Speicherung auf WORM-Medien. REIMPORT -------- Das Archiv kann mit der SKR-Software vollständig reimportiert werden. Die Datenintegrität wird beim Import automatisch verifiziert. COMPLIANCE ---------- - GoBD-konform - E-Bilanz-fähig (XBRL) - ZUGFeRD/Factur-X kompatibel - PDF/A-3 für Langzeitarchivierung © ${new Date().getFullYear()} ${this.options.companyInfo?.name || 'Export System'}`; const filePath = path.join(this.exportDir, 'readme.txt'); await plugins.smartfile.memory.toFs(readmeContent, filePath); // Add to tag manifest const hash = await this.hashFile(filePath); this.tagManifest['readme.txt'] = hash; } /** * Creates the export metadata JSON file */ public async createExportMetadata(skrType: TSKRType): Promise { const metadata: IExportMetadata = { exportVersion: '1.0.0', exportTimestamp: new Date().toISOString(), generator: { name: '@fin.cx/skr', version: '1.1.0' // Should be read from package.json }, company: this.options.companyInfo, fiscalYear: this.options.fiscalYear, dateRange: { from: this.options.dateFrom.toISOString(), to: this.options.dateTo.toISOString() }, skrType: skrType, schemaVersion: '1.0', crypto: { digestAlgorithms: ['sha256'], signatureType: this.options.signExport ? 'CAdES' : undefined, timestampPolicy: this.options.timestampExport ? 'RFC3161' : undefined, merkleTree: true }, options: { packagedAs: 'bagit', compression: 'none', deduplication: true } }; const filePath = path.join(this.exportDir, 'data', 'metadata', 'export.json'); await plugins.smartfile.memory.toFs(JSON.stringify(metadata, null, 2), filePath); // Add to manifest const hash = await this.hashFile(filePath); this.manifest['data/metadata/export.json'] = hash; } /** * Creates JSON schemas for the export data structures */ public async createSchemas(): Promise { // Ledger schema const ledgerSchema = { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Ledger Entry", "type": "object", "properties": { "schema_version": { "type": "string" }, "entry_id": { "type": "string", "format": "uuid" }, "booking_date": { "type": "string", "format": "date" }, "posting_date": { "type": "string", "format": "date" }, "currency": { "type": "string" }, "journal": { "type": "string" }, "description": { "type": "string" }, "lines": { "type": "array", "items": { "type": "object", "properties": { "posting_id": { "type": "string" }, "account_code": { "type": "string" }, "debit": { "type": "string" }, "credit": { "type": "string" }, "tax_code": { "type": "string" }, "document_refs": { "type": "array", "items": { "type": "object", "properties": { "content_hash": { "type": "string" }, "doc_role": { "type": "string" }, "mime": { "type": "string" } } } } }, "required": ["posting_id", "account_code", "debit", "credit"] } }, "created_at": { "type": "string", "format": "date-time" }, "user": { "type": "string" } }, "required": ["schema_version", "entry_id", "booking_date", "lines"] }; // Accounts schema const accountsSchema = { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Accounts CSV", "type": "object", "properties": { "account_code": { "type": "string" }, "name": { "type": "string" }, "type": { "type": "string" }, "parent": { "type": "string" }, "skr_set": { "type": "string" }, "tax_code_default": { "type": "string" }, "active_from": { "type": "string", "format": "date" }, "active_to": { "type": "string", "format": "date" } }, "required": ["account_code", "name", "type", "skr_set"] }; // Save schemas const schemasDir = path.join(this.exportDir, 'data', 'metadata', 'schemas', 'v1'); await plugins.smartfile.memory.toFs( JSON.stringify(ledgerSchema, null, 2), path.join(schemasDir, 'ledger.schema.json') ); this.manifest['data/metadata/schemas/v1/ledger.schema.json'] = await this.hashFile( path.join(schemasDir, 'ledger.schema.json') ); await plugins.smartfile.memory.toFs( JSON.stringify(accountsSchema, null, 2), path.join(schemasDir, 'accounts.schema.json') ); this.manifest['data/metadata/schemas/v1/accounts.schema.json'] = await this.hashFile( path.join(schemasDir, 'accounts.schema.json') ); } /** * Writes the BagIt manifest files */ public async writeManifests(): Promise { // Write data manifest let manifestContent = ''; for (const [filePath, hash] of Object.entries(this.manifest)) { manifestContent += `${hash} ${filePath}\n`; } const manifestPath = path.join(this.exportDir, 'manifest-sha256.txt'); await plugins.smartfile.memory.toFs(manifestContent, manifestPath); // Add manifest to tag manifest this.tagManifest['manifest-sha256.txt'] = await this.hashFile(manifestPath); // Write tag manifest let tagManifestContent = ''; for (const [filePath, hash] of Object.entries(this.tagManifest)) { tagManifestContent += `${hash} ${filePath}\n`; } await plugins.smartfile.memory.toFs( tagManifestContent, path.join(this.exportDir, 'tagmanifest-sha256.txt') ); } /** * Calculates SHA-256 hash of a file */ private async hashFile(filePath: string): Promise { const fileContent = await plugins.smartfile.fs.toBuffer(filePath); return await plugins.smarthash.sha256FromBuffer(fileContent); } /** * Stores a document in content-addressed storage */ public async storeDocument(content: Buffer, originalFilename?: string): Promise { const hash = await plugins.smarthash.sha256FromBuffer(content); // Create path based on hash (first 2 chars as directory) const hashPrefix = hash.substring(0, 2); const hashDir = path.join(this.exportDir, 'data', 'documents', 'by-hash', hashPrefix); await plugins.smartfile.fs.ensureDir(hashDir); const docPath = path.join(hashDir, hash); // Only store if not already exists (deduplication) if (!(await plugins.smartfile.fs.fileExists(docPath))) { await plugins.smartfile.memory.toFs(content, docPath); this.manifest[`data/documents/by-hash/${hashPrefix}/${hash}`] = hash; } return hash; } /** * Creates a Merkle tree from all file hashes */ public async createMerkleTree(): Promise { const leaves = Object.values(this.manifest).map(hash => Buffer.from(hash, 'hex') ); // Create a sync hash function wrapper for MerkleTree const hashFn = (data: Buffer) => { // Convert async to sync by using crypto directly const crypto = require('crypto'); return crypto.createHash('sha256').update(data).digest(); }; const tree = new plugins.MerkleTree(leaves, hashFn, { sortPairs: true }); const root = tree.getRoot().toString('hex'); // Save Merkle tree data const merkleData = { root: root, leaves: Object.entries(this.manifest).map(([path, hash]) => ({ path, hash })), timestamp: new Date().toISOString() }; const merklePath = path.join(this.exportDir, 'data', 'metadata', 'merkle-tree.json'); await plugins.smartfile.memory.toFs(JSON.stringify(merkleData, null, 2), merklePath); this.manifest['data/metadata/merkle-tree.json'] = await this.hashFile(merklePath); return root; } /** * Validates the BagIt structure */ public async validateBagIt(): Promise { this.logger.log('info', 'Validating BagIt structure...'); // Check required files exist const requiredFiles = [ 'bagit.txt', 'manifest-sha256.txt', 'tagmanifest-sha256.txt', 'readme.txt' ]; for (const file of requiredFiles) { const filePath = path.join(this.exportDir, file); if (!(await plugins.smartfile.fs.fileExists(filePath))) { this.logger.log('error', `Required file missing: ${file}`); return false; } } // Verify all manifest entries for (const [relPath, expectedHash] of Object.entries(this.manifest)) { const fullPath = path.join(this.exportDir, relPath); if (!(await plugins.smartfile.fs.fileExists(fullPath))) { this.logger.log('error', `Manifest file missing: ${relPath}`); return false; } const actualHash = await this.hashFile(fullPath); if (actualHash !== expectedHash) { this.logger.log('error', `Hash mismatch for ${relPath}`); return false; } } this.logger.log('ok', 'BagIt validation successful'); return true; } }