Files
picpeak/backend/src/services/extractionService.js
T
Luca c305492845 feat(accounting): inbound supplier-invoice capture + expense re-bill (backend)
New top-level Accounting area (gated by an `accounting` feature flag, default
OFF, + accounting.view/manage permissions), separate from CRM. Lets an admin
capture a received supplier invoice (upload OR phone/tablet camera), give it a
disposition, and re-bill the cost to a client onto the relevant event's
invoice with a contract-driven markup. Mirrors the billable-hours model.

Backend foundation only — frontend pages (inbox / expenses UI + camera widget)
and the heavy extractors (Tesseract OCR / Swiss-QR decode / isolated rasterise
worker) are follow-ups; extractionService is scaffolded so the upload path is
already wired.

Migrations 122-125 (numbered above the in-flight feat/crm 117-121):
- 122 seed `accounting` flag (default OFF, idempotent)
- 123 seed accounting.view/manage permissions + grant super_admin/admin
- 124 inbound_documents + expenses + expense_categories (+ seed categories)
- 125 contracts Spesen-Zuschlag clause (expense_markup_type/_percent/_flat_minor)

API: /api/admin/expenses — inbound capture/list/confirm/categorize, expense
CRUD, /:id/rebill (event-scoped; markup = expense override -> contract clause
-> 0%; mints an editable scheduled invoice), /:id/supplier-payment, categories.
adminFeatureFlags KNOWN_FLAGS/DEFAULT_FLAGS gain `accounting`.

Conventions: idempotent hasTable/hasColumn-guarded migrations; money in integer
*_minor; QR amount stored separately + untrusted; requirePermission guards;
camelCase API <-> snake_case columns; multer + 15MB cap for PDF/JPEG/PNG.
VAT/tax handling is v1 capture-only — verify with a Treuhaender before relying.

Verified: node -c all files, require-graph smoke test, and a SQLite migration
harness (schema + seeds + idempotency + defaults assert green).
2026-06-11 00:04:16 +02:00

55 lines
2.7 KiB
JavaScript

/**
* Inbound-document field extraction — the assist ladder.
*
* Lightest-first: Swiss QR-bill decode → digital-PDF text layer → OCR for
* true scans. Returns BEST-EFFORT fields only; the admin always confirms
* them in the inbox. The QR amount is returned SEPARATELY as `qrAmountMinor`
* and must NEVER be treated as the authoritative total (it is the
* attacker-controllable "pay this" field) — the authoritative total comes
* from the text/line items and the admin's confirmation.
*
* ─────────────────────────────────────────────────────────────────────────
* STATUS: interface + plumbing only. The heavy extractors require infra that
* is intentionally deferred to a follow-up:
* - Swiss QR decode → a 2D-barcode decoder (zxing/jsQR class) + rasterise.
* - PDF text layer → a text extractor (NOT a 3rd PDF lib — see memory
* `feedback_pdf_libraries`; revisit the approach).
* - OCR → Tesseract installed as an OS package in the Docker
* image and shelled out, run inside a NETWORK-ISOLATED
* worker (no egress) per the locked design.
* Until those land, extract() returns { parsed: false, method: 'none' } and
* the document stays in `parse_status='pending'` for manual entry.
* ─────────────────────────────────────────────────────────────────────────
*/
const logger = require('../utils/logger');
/**
* @returns {Promise<{
* parsed: boolean,
* method: 'qr'|'pdf_text'|'ocr'|'none',
* fields: {
* supplierName?, invoiceNumber?, invoiceDate?, dueDate?, currency?,
* netAmountMinor?, vatAmountMinor?, totalAmountMinor?,
* qrAmountMinor?, iban?, paymentReference?
* },
* raw?: object,
* error?: string
* }>}
*/
async function extract(filePath, mimeType) {
try {
// 1) Swiss QR-bill (structured) — DEFERRED.
// 2) Digital-PDF text layer — DEFERRED.
// 3) OCR for scans/photos — DEFERRED.
// Plumbing is in place so the upload route can call this best-effort
// today and richer extractors can slot in without touching callers.
logger.debug?.(`extractionService: no extractor wired yet for ${mimeType || 'unknown'} (${filePath})`);
return { parsed: false, method: 'none', fields: {} };
} catch (err) {
logger.error?.(`extractionService.extract failed: ${err.message}`);
return { parsed: false, method: 'none', fields: {}, error: err.message };
}
}
module.exports = { extract };