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).
6.5 KiB
Accounting — Inbound supplier invoices, expenses & re-bill (MVP)
Status: new feature, in development on
feat/accounting-inbound-invoices(based onupstream/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)
- New top-level "Accounting" area, gated behind a new
accountingfeature flag (default OFF) andaccounting.view/accounting.managepermissions — not bolted onto CRM. The existing tax-export page is a candidate to move here later (not in this MVP). - picpeak owns documents + books up to the export boundary; certified external systems (Treuhänder / Abacus / Bexio) own statutory filing.
- No paperless-ngx sidecar — picpeak is the system of record; files live under
storage/and are covered by the existingbackup_pathswalker.
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-Zuschlagclause → 0% (percent or flat). Mints an editable scheduled invoice (admin can add more lines) — same pattern asbillUnbilledEntries. - 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_treatmentcaptured from day 1 (domesticdefault) — stored for the books; reclaim/Bezugsteuer math is future (switches on whenbusiness_profile.vat_idis set).
Data model (migrations 122–125)
Numbered from 122 to avoid colliding with the in-flight feat/crm-improvements migrations 117–121 (which are expected to merge first). If this lands before that branch, renumber to 117+.
- 122 — seed
accountingfeature flag (default OFF). - 123 — seed
accounting.view/accounting.managepermissions + grant to super_admin/admin. - 124 —
inbound_documents,expenses,expense_categories(+ seed categories). - 125 —
contracts.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.