import { InvoicesClient } from "../api/invoicesClient"; import { SecurityClient } from "../api/securityClient"; import { HttpClient } from "../client/httpClient"; import { CryptographyService, EncryptionData } from "../crypto/cryptographyService"; import { KsefError } from "../errors/errors"; import { InvoiceExportRequest, InvoiceExportStatusResponse, InvoicePackagePart, InvoiceQueryFilters, validateInvoiceQueryFilters, } from "../types/invoices"; import { StatusInfo } from "../types/common"; import { unzip, untarGz } from "../utils/zip"; export interface ExportStartOptions { filters: InvoiceQueryFilters; encryptionData?: EncryptionData; publicCertificateBase64Der?: string; onlyMetadata?: boolean; compressionType?: "Zip" | "TarGz"; /** * @deprecated Use `onlyMetadata` instead. */ includeMetadata?: boolean; } export interface ExportResult { referenceNumber: string; encryptionData: EncryptionData; } export interface PackageProcessingResult { metadataSummaries: Array>; invoiceXmlFiles: Record; } export interface WaitForExportOptions { pollIntervalMs?: number; maxAttempts?: number; } export interface DownloadPackageOptions { /** * @deprecated Use requireExportPartHash instead. */ verifyHashes?: boolean; requireExportPartHash?: boolean; } export interface InvoiceExportWorkflowOptions { requireExportPartHash?: boolean; } export class InvoiceExportWorkflow { private readonly invoicesClient: InvoicesClient; private readonly securityClient: SecurityClient; private readonly http: HttpClient; private readonly requireExportPartHash: boolean; constructor( invoicesClient: InvoicesClient, securityClient: SecurityClient, http: HttpClient, options: InvoiceExportWorkflowOptions = {}, ) { this.invoicesClient = invoicesClient; this.securityClient = securityClient; this.http = http; this.requireExportPartHash = options.requireExportPartHash ?? true; } async startExport(options: ExportStartOptions): Promise { validateInvoiceQueryFilters(options.filters); const encryptionData = options.encryptionData ?? (await this.buildEncryptionData(options.publicCertificateBase64Der)); const request: InvoiceExportRequest = { encryption: encryptionData.encryptionInfo, filters: options.filters, ...((options.onlyMetadata ?? options.includeMetadata) !== undefined ? { onlyMetadata: options.onlyMetadata ?? options.includeMetadata } : {}), ...(options.compressionType ? { compressionType: options.compressionType } : {}), }; const response = await this.invoicesClient.exportInvoices(request); return { referenceNumber: response.referenceNumber, encryptionData, }; } async waitForExport( referenceNumber: string, options: WaitForExportOptions = {}, ): Promise { const pollIntervalMs = options.pollIntervalMs ?? 2000; const maxAttempts = options.maxAttempts ?? 60; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { const status = await this.invoicesClient.getInvoiceExportStatus(referenceNumber); const statusInfo = status.status ?? ({} as StatusInfo); if (statusInfo.code === 200) { return status; } if (statusInfo.code !== 100) { const details = statusInfo.details?.length ? ` Details: ${statusInfo.details.join(", ")}` : ""; throw new KsefError( `Export failed: ${statusInfo.code} ${statusInfo.description}${details}`, ); } await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); } throw new KsefError("Export did not complete within max attempts."); } async downloadAndProcessPackage( status: InvoiceExportStatusResponse, encryptionData: EncryptionData, options: DownloadPackageOptions = {}, ): Promise { const packageInfo = status.package ?? undefined; if (!packageInfo || packageInfo.invoiceCount === 0) { return { metadataSummaries: [], invoiceXmlFiles: {} }; } const parts = packageInfo.parts ?? []; if (parts.length === 0) { return { metadataSummaries: [], invoiceXmlFiles: {} }; } const compressionType = resolveExportCompressionType(packageInfo.compressionType); const verifyHashes = resolveRequireExportPartHash(options, this.requireExportPartHash); const encryptedParts = await this.downloadParts(parts, verifyHashes); const decryptedParts = encryptedParts.map((part) => CryptographyService.decryptAes256Cbc(part, encryptionData.cipherKey, encryptionData.cipherIv), ); const archiveBytes = Buffer.concat(decryptedParts); const entries = compressionType === "TarGz" ? await untarGz(archiveBytes) : await unzip(archiveBytes); const metadataSummaries: Array> = []; const invoiceXmlFiles: Record = {}; for (const [name, content] of entries.entries()) { if (name.toLowerCase() === "_metadata.json") { const parsed = JSON.parse(content.toString("utf8")); const invoices = parsed?.invoices ?? parsed?.invoiceList ?? []; if (Array.isArray(invoices)) { metadataSummaries.push(...(invoices as Array>)); } } else if (name.toLowerCase().endsWith(".xml")) { invoiceXmlFiles[name] = content.toString("utf8"); } } return { metadataSummaries, invoiceXmlFiles }; } private async downloadParts( parts: InvoicePackagePart[], verifyHashes = this.requireExportPartHash, ): Promise { const results: Buffer[] = []; for (const part of parts) { const data = await this.http.request({ method: part.method as "GET", path: part.url, responseType: "buffer", skipAuth: true, }); if (verifyHashes) { const expectedHash = typeof part.encryptedPartHash === "string" ? part.encryptedPartHash : ""; if (!expectedHash.trim()) { throw new KsefError(`Missing encrypted part hash for ${part.partName}.`); } const hash = CryptographyService.sha256Base64(data); if (hash !== expectedHash) { throw new KsefError( `Encrypted part hash mismatch for ${part.partName}: expected ${expectedHash}, got ${hash}.`, ); } } results.push(data); } return results; } private async buildEncryptionData(publicCertificateBase64Der?: string): Promise { if (publicCertificateBase64Der) { return CryptographyService.getEncryptionData(publicCertificateBase64Der); } const cert = await this.getCertificateByUsage("SymmetricKeyEncryption"); return CryptographyService.getEncryptionData(cert); } private async getCertificateByUsage( usage: "KsefTokenEncryption" | "SymmetricKeyEncryption", ): Promise { const certificates = await this.securityClient.getPublicKeyCertificates(); const cert = certificates.find((item) => item.usage.includes(usage)); if (!cert) { throw new KsefError(`No public certificate found for usage ${usage}.`); } return cert.certificate; } } function resolveRequireExportPartHash( options: DownloadPackageOptions, defaultValue: boolean, ): boolean { if (options.verifyHashes !== undefined) { return options.verifyHashes; } if (options.requireExportPartHash !== undefined) { return options.requireExportPartHash; } return defaultValue; } function resolveExportCompressionType(value: string | null | undefined): "Zip" | "TarGz" { if (value === undefined || value === null || value.trim() === "") { throw new KsefError("Export package is missing compressionType."); } const normalized = value.trim(); if (normalized === "Zip" || normalized.toLowerCase() === "zip") { return "Zip"; } if ( normalized === "TarGz" || normalized.toLowerCase() === "targz" || normalized.toLowerCase() === "tar.gz" ) { return "TarGz"; } throw new KsefError("Unsupported compression type. Use Zip or TarGz."); }