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