Files
picpeak/docs/accounting-inbound-invoices.md
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

6.5 KiB
Raw Blame History

Accounting — Inbound supplier invoices, expenses & re-bill (MVP)

Status: new feature, in development on feat/accounting-inbound-invoices (based on upstream/beta). Maintainer scope decision required before merge — this introduces a new top-level Accounting area, separate from CRM (see "Scope decisions" below). Legal: every VAT / tax-treatment surface is an example only and must be reviewed with a Treuhänder before relying on it. Jurisdiction scope is Liechtenstein-first (Swiss/LI rails — QR-bill, LI MWST), not German DATEV/ELSTER.

Why

The studio receives supplier invoices/receipts (hotels, equipment, fremdleistungen). Today they live in email/paper and are re-typed. This feature lets an admin capture an incoming invoice (upload, or phone/tablet camera), have its fields best-effort extracted, then give it a disposition — most importantly re-bill it to a client ("Weiterverrechnung") onto the relevant event's invoice with a contract-driven markup.

This mirrors the existing billable-hours model (customerHoursService): an item is parked against a customer/event and folded into an invoice as a line item.

Scope decisions (maintainer)

  1. New top-level "Accounting" area, gated behind a new accounting feature flag (default OFF) and accounting.view / accounting.manage permissions — not bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP).
  2. picpeak owns documents + books up to the export boundary; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing.
  3. No paperless-ngx sidecar — picpeak is the system of record; files live under storage/ and are covered by the existing backup_paths walker.

MVP scope (this branch)

  • Intake: file upload and camera capture (phone/tablet) → POST /api/admin/expenses/inbound (accepts PDF + JPEG/PNG). Stored as the system of record; deduped by SHA-256.
  • Best-effort extraction (extractionService): ladder of Swiss-QR decode → PDF text layer → OCR. Scaffolded with the interface in place; the heavy extractors (Tesseract OS package, QR decoder, isolated rasterise worker) are a follow-up — see "Deferred".
  • Inbox: list documents as „Neu / Unsortiert"; parsed fields are editable/confirmable (parsing is assist, never blind trust). The QR-encoded amount is stored separately and surfaced for tamper cross-check — the authoritative total is the text/line-item value.
  • 5 dispositions: rebill (Weiterverrechnen) · durchlaufend (Durchlaufender Posten) · eigener_aufwand (company expense) · duplikat · abgelehnt (with reason).
  • Re-bill flow: event-scoped (one event → one customer). Markup resolved expense override → contract Spesen-Zuschlag clause → 0% (percent or flat). Mints an editable scheduled invoice (admin can add more lines) — same pattern as billUnbilledEntries.
  • Supplier-payment status (decoupled from categorisation): „Zu zahlen / Bezahlt" with payment_method (unified with the outgoing list incl. bank_transfer).
  • Expense categories: seeded + admin-editable (colored label) — feed the future Erfolgsrechnung.
  • tax_treatment captured from day 1 (domestic default) — stored for the books; reclaim/Bezugsteuer math is future (switches on when business_profile.vat_id is set).

Data model (migrations 122125)

Numbered from 122 to avoid colliding with the in-flight feat/crm-improvements migrations 117121 (which are expected to merge first). If this lands before that branch, renumber to 117+.

  • 122 — seed accounting feature flag (default OFF).
  • 123 — seed accounting.view / accounting.manage permissions + grant to super_admin/admin.
  • 124inbound_documents, expenses, expense_categories (+ seed categories).
  • 125contracts.expense_markup_type|_percent|_flat_minor (the Spesen-Zuschlag clause).

Key tables (all money in integer minor units, *_amount_minor):

  • inbound_documents — raw received doc + parsed/confirmable fields + qr_amount_minor (separate, untrusted) + status (unsorted/categorized/declined/duplicate).
  • expenses — the booking: disposition, tax_treatment, event_id, customer_account_id, FX (original_* + chf_amount_minor + fx_locked), markup_type/_percent/_flat_minor, category_id, billed_invoice_id, supplier-payment fields, status.
  • expense_categories — seeded colored labels.

API (/api/admin/expenses, gated by accounting flag + accounting.*)

  • POST /inbound (multipart) — capture an inbound doc (upload/camera).
  • GET /inbound — list (filter by status, paginated).
  • GET /inbound/:id — one doc.
  • PATCH /inbound/:id — confirm/edit parsed fields.
  • POST /inbound/:id/categorize — create an expense with a disposition.
  • POST / — create a manual expense (no document).
  • GET / — list expenses (filter by status/disposition/customer/event).
  • GET /:id — one expense.
  • PATCH /:id — edit (locked once billed).
  • POST /:id/rebill — re-bill to a client (event-scoped, contract markup) → scheduled invoice.
  • POST /:id/supplier-payment — toggle supplier paid + method.
  • GET/POST/PATCH/DELETE /categories — manage expense categories.

Camera capture (step 3)

The POST /inbound endpoint accepts images, so a mobile web widget using <input type="file" accept="image/*" capture="environment"> already enables phone/tablet camera capture — no native app required for v1. A native document-scanner (edge-detect/dewarp, multi-page) is a later UX upgrade that improves OCR accuracy.

Deferred (follow-ups)

  • Real extraction: Tesseract OCR (OS package in the Docker image, shell-out — not a sidecar), Swiss-QR decoder, network-isolated rasterise worker (no egress), CSP-locked image preview, never serve the raw PDF.
  • Email intake (rechnungen@… IMAP poll, forwarded-message parsing, message-id dedupe).
  • Bank reconciliation, FX auto-lock backstop (30-day), Erfolgsrechnung, customer-account close guard.
  • Frontend: the Accounting tab UI (inbox, disposition actions, re-bill dialog) + the camera widget.

Conventions followed

Idempotent migrations (hasTable/hasColumn-guarded); new flag default OFF; flag reads tolerate true|1|'1'; money as integer *_minor; requirePermission guards; camelCase API ↔ snake_case service; multer + safePath containment at every file boundary; localized dates on display; tax/legal surfaces carry a "verify with Treuhänder" disclaimer.