import { FlattenCancelled } from "../pdf/flatten"; import type { FlattenResult } from "../pdf/flatten"; import type { AssemblePageData } from "../pdf/render/jobs"; import { exportFileName, type ExportMode } from "./filename"; import type { Invoice, InvoiceInput } from "./types"; export type { ExportMode }; export type ExportSource = "original" | "rerender"; /** Everything the flow touches outside itself, so it can run against fakes. */ export interface ExportDeps { issueInvoice(input: InvoiceInput, renderPrefs: object): Promise; /** Renders with the invoice's frozen prefs and supplier snapshot and the current template code. */ render(invoice: Invoice): Promise<{ bytes: Uint8Array; fingerprint: string }>; archivePdf(invoiceId: number, bytes: Uint8Array, fingerprint: string): Promise; readArchive(invoiceId: number): Promise; /** Rasterises PDF bytes page by page. Throws FlattenError / FlattenCancelled. */ flatten(bytes: Uint8Array, opts: { signal?: AbortSignal; onProgress?: (page: number, pages: number) => void }): Promise; assemble(pages: AssemblePageData[], title: string): Promise; getLastExportDir(): Promise; /** The save dialog: the chosen path, or null when it is dismissed. */ save(opts: { defaultPath: string }): Promise; writeExportFile(path: string, bytes: Uint8Array): Promise; } export type ExportPhase = "rendering" | "flattening" | "assembling" | "saving"; export interface ExportProgress { phase: ExportPhase; /** While flattening: the page being rendered and the page count. */ page?: number; pages?: number; } export type ExportErrorCode = "RENDER_FAILED" | "ARCHIVE_FAILED" | "FLATTEN_FAILED" | "WRITE_FAILED"; export class ExportError extends Error { constructor( readonly code: ExportErrorCode, message: string, readonly cause?: unknown, ) { super(message); this.name = "ExportError"; } } const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e)); export const isFlattenFailure = (e: unknown): boolean => (e as { code?: unknown } | null)?.code === "FLATTEN_FAILED"; export const isExportCancelled = (e: unknown): boolean => e instanceof FlattenCancelled || (e as { name?: string } | null)?.name === "FlattenCancelled"; const checkAbort = (signal?: AbortSignal) => { if (signal?.aborted) throw new FlattenCancelled(); }; // --- archive at issue ------------------------------------------------------------------------------ export interface ArchiveOutcome { /** The invoice with its archive fields filled in when archiving worked. */ invoice: Invoice; archived: boolean; error?: string; } /** Fields the backend sets when it archives, mirrored locally so the UI needs no refetch. */ const withArchive = (invoice: Invoice, sha256: string, fingerprint: string): Invoice => ({ ...invoice, archivedPdfSha256: sha256, archivedFingerprint: fingerprint, archivedAt: invoice.archivedAt ?? new Date().toISOString(), }); /** * Renders an issued invoice with its frozen prefs and archives the result. Never throws: a failure * comes back as `archived: false` with the reason, because the invoice already exists. */ export async function renderAndArchive(invoice: Invoice, deps: Pick): Promise { let rendered: { bytes: Uint8Array; fingerprint: string }; try { rendered = await deps.render(invoice); } catch (e) { return { invoice, archived: false, error: `The PDF could not be rendered: ${messageOf(e)}` }; } try { const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint); return { invoice: withArchive(invoice, sha, rendered.fingerprint), archived: true }; } catch (e) { return { invoice, archived: false, error: `The PDF could not be archived: ${messageOf(e)}` }; } } /** * Issues the invoice, then renders it with the prefs the backend froze and archives that PDF. The * issue call's own failure throws (nothing was issued); everything after it is reported, never thrown. */ export async function issueAndArchive( input: InvoiceInput, renderPrefs: object, deps: Pick, ): Promise { const invoice = await deps.issueInvoice(input, renderPrefs); return renderAndArchive(invoice, deps); } // --- export ---------------------------------------------------------------------------------------- export interface ExportRequest { invoice: Invoice; mode: ExportMode; source: ExportSource; signal?: AbortSignal; onProgress?: (p: ExportProgress) => void; } export interface ExportResult { path: string; mode: ExportMode; /** Only for a re-render of an archived invoice: did the layout come out the same as at issue? */ identicalToIssued?: boolean; /** The invoice with archive fields filled in when this export had to archive it first. */ invoice: Invoice; /** The export used a fresh render because archiving the missing original failed. */ archiveError?: string; } function joinPath(dir: string, name: string): string { if (!dir) return name; const sep = dir.includes("\\") && !dir.includes("/") ? "\\" : "/"; return /[\\/]$/.test(dir) ? `${dir}${name}` : `${dir}${sep}${name}`; } /** * Produces the PDF bytes for one export and saves them. Returns null when the save dialog is * dismissed. Errors: FlattenCancelled when aborted; an ExportError (or a FlattenError, which carries * the same `FLATTEN_FAILED` code) otherwise. A failed flatten is never replaced by a searchable export * or a lower resolution; the caller offers that choice. */ export async function exportInvoice(req: ExportRequest, deps: ExportDeps): Promise { const { mode, source, signal, onProgress } = req; let invoice = req.invoice; checkAbort(signal); let bytes: Uint8Array; let identicalToIssued: boolean | undefined; let archiveError: string | undefined; if (source === "rerender") { onProgress?.({ phase: "rendering" }); const rendered = await renderOrThrow(invoice, deps); bytes = rendered.bytes; if (invoice.archivedPdfSha256 && invoice.archivedFingerprint) { identicalToIssued = rendered.fingerprint === invoice.archivedFingerprint; } } else if (invoice.archivedPdfSha256) { onProgress?.({ phase: "rendering" }); bytes = await deps.readArchive(invoice.id); } else { // Issued but never archived (the render failed at issue): do it now, then use that file. onProgress?.({ phase: "rendering" }); const rendered = await renderOrThrow(invoice, deps); bytes = rendered.bytes; try { const sha = await deps.archivePdf(invoice.id, rendered.bytes, rendered.fingerprint); invoice = withArchive(invoice, sha, rendered.fingerprint); } catch (e) { // The invoice is intact; export the fresh render and let the caller say the archive is still missing. archiveError = `The PDF could not be archived: ${messageOf(e)}`; } } checkAbort(signal); if (mode === "flattened") { try { const flat = await deps.flatten(bytes, { signal, onProgress: (page, pages) => onProgress?.({ phase: "flattening", page, pages }), }); checkAbort(signal); onProgress?.({ phase: "assembling" }); // The title is the invoice number only; anything more would leak what the image-only file hides. bytes = await deps.assemble( flat.pages.map((p) => ({ jpeg: p.jpeg, widthPt: p.widthPt, heightPt: p.heightPt })), invoice.number, ); } catch (e) { if (isExportCancelled(e) || isFlattenFailure(e)) throw e; throw new ExportError("FLATTEN_FAILED", `The flattened PDF could not be built: ${messageOf(e)}`, e); } checkAbort(signal); } let dir = ""; try { dir = await deps.getLastExportDir(); } catch { // No remembered folder: the dialog opens wherever the system prefers. } const name = exportFileName({ number: invoice.number, client: invoice.clientName, date: invoice.invoiceDate, mode }); onProgress?.({ phase: "saving" }); const chosen = await deps.save({ defaultPath: joinPath(dir, name) }); if (!chosen) return null; let path: string; try { path = (await deps.writeExportFile(chosen, bytes)) || chosen; } catch (e) { throw new ExportError("WRITE_FAILED", `The file could not be saved: ${messageOf(e)}`, e); } return { path, mode, invoice, ...(identicalToIssued === undefined ? {} : { identicalToIssued }), ...(archiveError ? { archiveError } : {}) }; } async function renderOrThrow(invoice: Invoice, deps: Pick) { try { return await deps.render(invoice); } catch (e) { throw new ExportError("RENDER_FAILED", `The PDF could not be rendered: ${messageOf(e)}`, e); } }