443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
|
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<void> {
|
||
|
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<void> {
|
||
|
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<void> {
|
||
|
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<void> {
|
||
|
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<void> {
|
||
|
// 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<void> {
|
||
|
// 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<string> {
|
||
|
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<string> {
|
||
|
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<string> {
|
||
|
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<boolean> {
|
||
|
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;
|
||
|
}
|
||
|
}
|