diff --git a/backend/migrations/core/124_create_inbound_documents_and_expenses.js b/backend/migrations/core/124_create_inbound_documents_and_expenses.js index d494e8e3..88a600c5 100644 --- a/backend/migrations/core/124_create_inbound_documents_and_expenses.js +++ b/backend/migrations/core/124_create_inbound_documents_and_expenses.js @@ -59,6 +59,7 @@ exports.up = async function (knex) { table.string('parse_status', 16).notNullable().defaultTo('pending'); // pending|parsed|failed|manual table.text('parse_error'); table.string('parse_method', 24); // qr|pdf_text|ocr|none + table.integer('page_count'); // PDF page count (for "jump to last page / QR") // Best-effort parsed fields (assist only — always editable/confirmable): table.string('supplier_name', 255); table.string('invoice_number', 128); diff --git a/backend/src/routes/adminExpenses.js b/backend/src/routes/adminExpenses.js index 04f806af..18ad3214 100644 --- a/backend/src/routes/adminExpenses.js +++ b/backend/src/routes/adminExpenses.js @@ -14,6 +14,8 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); const { getStoragePath } = require('../config/storage'); +const { createReadStream } = require('fs'); +const { assertPathInside } = require('../utils/safePath'); const { db } = require('../database/db'); const expenseService = require('../services/expenseService'); const expenseCategoriesService = require('../services/expenseCategoriesService'); @@ -116,6 +118,25 @@ router.get('/inbound/:id', requirePermission('accounting.view'), return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) }); })); +// Stream the stored file for in-browser preview (PDF / image). Admin-only, +// path-containment guarded. NOTE: this serves the raw file inline — the +// locked design's hardened path (rasterise in a network-isolated worker, never +// serve raw) is a follow-up; acceptable here as the admin views their own +// uploaded documents. +router.get('/inbound/:id/file', requirePermission('accounting.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const row = await db('inbound_documents').where({ id: parseInt(req.params.id, 10) }) + .first('file_path', 'mime_type'); + if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' }); + const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]); + res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', 'inline'); + res.setHeader('X-Content-Type-Options', 'nosniff'); + createReadStream(safe).pipe(res); + })); + router.patch('/inbound/:id', requirePermission('accounting.manage'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => { diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index 18de60c8..67153bea 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -15,6 +15,7 @@ */ const crypto = require('crypto'); const fsp = require('fs').promises; +const { PDFDocument } = require('pdf-lib'); const { db, logActivity } = require('../database/db'); const { AppError } = require('../utils/errors'); const { hasColumnCached } = require('../utils/schemaCache'); @@ -57,6 +58,7 @@ function transformInbound(row) { parseStatus: row.parse_status, parseMethod: row.parse_method, parseError: row.parse_error, + pageCount: row.page_count, supplierName: row.supplier_name, invoiceNumber: row.invoice_number, invoiceDate: toIsoDate(row.invoice_date), @@ -119,9 +121,21 @@ function clampPage(page, pageSize) { return { p, ps }; } -async function sha256OfFile(filePath) { +// Read the file once: SHA-256 (for dedup) + PDF page count (for the +// "jump to last page / QR" preview). +async function inspectFile(filePath, mimeType) { const buf = await fsp.readFile(filePath); - return crypto.createHash('sha256').update(buf).digest('hex'); + const sha = crypto.createHash('sha256').update(buf).digest('hex'); + let pageCount = null; + if ((mimeType || '').includes('pdf')) { + try { + const pdf = await PDFDocument.load(buf, { updateMetadata: false }); + pageCount = pdf.getPageCount(); + } catch (e) { + logger.warn?.(`expenseService: could not read PDF page count for ${filePath}: ${e.message}`); + } + } + return { sha, pageCount }; } // ── Inbound documents ────────────────────────────────────────────────────── @@ -132,8 +146,13 @@ async function sha256OfFile(filePath) { */ async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) { let fileSha256 = null; - try { fileSha256 = await sha256OfFile(filePath); } catch (e) { - logger.warn?.(`expenseService: could not hash ${filePath}: ${e.message}`); + let pageCount = null; + try { + const info = await inspectFile(filePath, mimeType); + fileSha256 = info.sha; + pageCount = info.pageCount; + } catch (e) { + logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`); } let duplicateOfId = null; @@ -164,6 +183,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'), parse_method: parse.method || 'none', parse_error: parse.error || null, + page_count: pageCount, supplier_name: f.supplierName || null, invoice_number: f.invoiceNumber || null, invoice_date: f.invoiceDate || null, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 23f68055..dbf5d8be 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3428,6 +3428,8 @@ "untitled": "Unbenanntes Dokument", "noAmount": "Betrag nicht erfasst", "rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.", + "previewLoading": "Vorschau wird geladen…", + "qrHint": "An der letzten Seite geöffnet — der Schweizer QR-Einzahlschein sitzt meist unten.", "status": { "unsorted": "Neu", "categorized": "Kategorisiert", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 06b6f8c4..829fbe83 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3428,6 +3428,8 @@ "untitled": "Untitled document", "noAmount": "amount not entered", "rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.", + "previewLoading": "Loading preview…", + "qrHint": "Opened at the last page — the Swiss QR-bill usually sits at the bottom.", "status": { "unsorted": "New", "categorized": "Categorized", diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 562b40bf..5088d2f9 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -9,7 +9,7 @@ * Parsing is assist-only and currently a no-op on the backend (extractionService * scaffold) — fields are entered/confirmed manually until OCR lands. */ -import React, { useRef, useState } from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; @@ -57,6 +57,25 @@ const TriageModal: React.FC<{ const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null; + // Authenticated file preview: fetch as a blob (Bearer auth) and embed. + // PDFs open at the last page scrolled into the QR-bill area (no OCR — the + // admin reads the payment slip and types the fields). + const isPdf = (doc.mimeType || '').includes('pdf'); + const [fileUrl, setFileUrl] = useState(null); + useEffect(() => { + let url: string | null = null; + let cancelled = false; + accountingService.getInboundFileBlob(doc.id) + .then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setFileUrl(url); } }) + .catch(() => { if (!cancelled) setFileUrl(null); }); + return () => { cancelled = true; if (url) URL.revokeObjectURL(url); }; + }, [doc.id]); + // #page= jumps to the last page; view=FitH,300 fits the width and + // positions ~300pt from the bottom — the Swiss QR-bill payment part. + const previewSrc = fileUrl + ? (isPdf ? `${fileUrl}#page=${doc.pageCount || 1}&view=FitH,300` : fileUrl) + : null; + const save = useMutation({ mutationFn: async () => { // 1) Confirm the document's fields (assist is never blind-trusted). @@ -102,7 +121,7 @@ const TriageModal: React.FC<{ return (
-
+

{t('accounting.inbox.triageTitle', 'Categorize document')} @@ -112,7 +131,29 @@ const TriageModal: React.FC<{

-
+
+ {/* Document preview — PDFs open at the last page (QR-bill area). */} +
+ {previewSrc ? ( + isPdf ? ( +