c305492845
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).
55 lines
2.7 KiB
JavaScript
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 };
|