feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR)

Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.

- backend: capture PDF page count at upload via pdf-lib (new
  inbound_documents.page_count, added to in-flight migration 124); new
  GET /api/admin/expenses/inbound/:id/file streams the stored file inline
  (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
  uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
  the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
  <img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).

Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
This commit is contained in:
Luca
2026-06-11 00:38:37 +02:00
parent 2b5efebaff
commit 502fbad5a8
7 changed files with 101 additions and 7 deletions
@@ -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);
+21
View File
@@ -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) => {
+24 -4
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -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",
@@ -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<string | null>(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=<last> 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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
<div className="mt-10 w-full max-w-lg rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="mt-10 w-full max-w-4xl rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('accounting.inbox.triageTitle', 'Categorize document')}
@@ -112,7 +131,29 @@ const TriageModal: React.FC<{
</button>
</div>
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Document preview — PDFs open at the last page (QR-bill area). */}
<div className="order-2 lg:order-1">
{previewSrc ? (
isPdf ? (
<iframe title="document preview" src={previewSrc} className="w-full h-[60vh] rounded-md border border-neutral-200 dark:border-neutral-700" />
) : (
<img src={previewSrc} alt="document" className="w-full max-h-[60vh] object-contain rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" />
)
) : (
<div className="flex h-[60vh] items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700 text-sm text-neutral-500">
{t('accounting.inbox.previewLoading', 'Loading preview…')}
</div>
)}
{isPdf && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.inbox.qrHint', 'Opened at the last page — the Swiss QR-bill usually sits at the bottom.')}
</p>
)}
</div>
{/* Triage form */}
<div className="order-1 lg:order-2 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('accounting.inbox.field.supplier', 'Supplier')}</label>
@@ -198,6 +239,7 @@ const TriageModal: React.FC<{
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('accounting.inbox.rebillHint', 'Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.')}</p>
</div>
)}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
@@ -15,6 +15,7 @@ export interface InboundDocument {
status: InboundStatus;
parseStatus: 'pending' | 'parsed' | 'failed' | 'manual';
parseMethod: string | null;
pageCount: number | null;
supplierName: string | null;
invoiceNumber: string | null;
invoiceDate: string | null;
@@ -114,6 +115,11 @@ export const accountingService = {
return data.document;
},
async getInboundFileBlob(id: number): Promise<Blob> {
const { data } = await api.get(`/admin/expenses/inbound/${id}/file`, { responseType: 'blob' });
return data;
},
async updateInbound(id: number, fields: Partial<Pick<InboundDocument,
'supplierName' | 'invoiceNumber' | 'invoiceDate' | 'dueDate' | 'currency' |
'netAmountMinor' | 'vatAmountMinor' | 'totalAmountMinor' | 'iban' | 'paymentReference'>>): Promise<InboundDocument> {