- Add backend/__tests__/integration/incomingInvoiceRebill.test.js (8 tests): disposition state machine, per-event PENDING pool, passthrough-no-markup, unwindBilledLine recompute, INVOICE_LOCKED on an issued invoice, and re-categorisation transitions. The invoice-MINTING paths can't run inside an outer transaction on SQLite (createInvoice's sequence claim deadlocks on the held write lock) — covered by buildInboundLineItem unit tests + discountLineItems instead; documented in the test. - Move logActivity out of the categorize/rebill/bundle transactions. It writes via the global db; inside a transaction a second write connection deadlocks on a SQLite-backed install (also affected SQLite-prod, not just tests). - Fix bill-editor vat_code reload: transformInvoice (adminInvoices.js) dropped vatCode, so the editor fell back to rate-matching and lost a custom-rate code on edit. Now returns vatCode: i.vat_code. - Rewrite docs/accounting-inbound-invoices.md to the current implementation (IR-vs-Expenses split, re-categorise + unwind, cadence-aware re-bill / pending pool, passthrough-at-cost, migrations 122-132, rasterised preview, tax/ledger/VAT).
10 KiB
Accounting — Incoming invoices, expenses & re-bill
Status: built on
feat/accounting-inbound-invoices(based onupstream/beta); not yet merged tomain. 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/ITSG. Seedocs/crm-disclaimers.md.
Why
The studio receives supplier invoices/receipts (hotels, equipment, Fremdleistungen). This feature lets an admin capture an incoming invoice (upload, phone/tablet camera, or IMAP email intake), confirm its fields, give it a disposition, mark the supplier payable paid, and — for client-borne costs — re-bill it to a client ("Weiterverrechnung"), consolidated onto the client's bill the same way billable hours are.
Two distinct entities (split in migration 126)
Incoming invoices and internal expenses are separate — one document never appears in both surfaces.
- Incoming invoices (
inbound_documents) — an external supplier document. The row itself is the payable: it carries the disposition, tax treatment, event booking, re-bill linkage, supplier-payment, note, and (for re-bills) the attached customer. Categorising it updates the document — it never derives anexpensesrow. Mark-paid lives here. - Expenses (
expenses,inbound_document_id IS NULL) — internal own-costs:kind = amount | mileage | per_diem(amount = quantity × rate, rate from accounting settings with per-entry override), optional proof file, booked to an event or the company. Disposition is alwayseigener_aufwand; no supplier payment.
This document covers the incoming-invoices surface. Expenses share the markup/re-bill helpers but are otherwise independent.
Lifecycle
capture (upload / camera / email)
→ inbox row, status = unsorted, parse_status = pending
triage (confirm fields + disposition + note)
├─ eigener_aufwand → company expense (pick category), booked to company
├─ durchlaufend → pass-through; optionally attach a client (billed at cost)
├─ rebill → re-bill to a client (with markup)
├─ duplikat → status = duplicate (excluded from the books)
└─ abgelehnt → status = declined (excluded from the books)
supplier payment (independent axis): markInboundSupplierPayment → supplier_paid
Dispositions
Five: rebill · durchlaufend (Durchlaufender Posten) · eigener_aufwand (company expense) · duplikat · abgelehnt.
rebill— your own supplier cost, invoiced on to a client, usually with a markup (percent or flat). Requires a customer.durchlaufend— an amount fronted on behalf of a client and passed through at cost / VAT-neutral. May optionally attach a client (then it is re-billed like a rebill, but never carries a markup — enforced in both the UI andcategorizeInbound). With no client it is only booked to an event/company.eigener_aufwand— own cost, not re-billed; pick an expense category for the Erfolgsrechnung.
The triage modal shows an inline explainer for the selected disposition (accounting.disposition.help.*) and a note field on every disposition.
Re-categorisation
Categorising is re-runnable — a categorised invoice can be changed again (e.g. pass-through → company expense), including after the supplier has been paid (supplier-payment and classification are independent axes). When the document was already re-billed, categorizeInbound first unwinds the prior re-bill line (removes the invoice line, recomputes the invoice totals) before applying the new disposition. It refuses (INVOICE_LOCKED) only when the re-bill sits on an already-issued invoice — then a Storno is required (isInvoiceMutable mirrors the hour-entry lock rules). The only hard lock is an issued invoice, never supplier-payment.
Re-bill: cadence-aware, like hours
Re-bill/pass-through-to-a-customer consolidates onto the client's bill exactly like customerHoursService:
- Monthly / manual customers — the line is appended immediately onto the customer's running monthly draft (via
invoiceService.createInvoice's accumulator intercept).billed_invoice_idis set at categorise time. - Per-event customers — the item stays PENDING in the customer's pool (
customer_account_idset,billed_invoice_idnull). The inbox surfaces a "Pending re-bills" card grouped by customer; "Bill these" (billPendingRebills) bundles all of a customer's pending items into one invoice (one line per document), then navigates to the bill editor so the admin can add more lines before sending. This mirrorsbillUnbilledEntries.
Markup resolution (rebill only): expense/document override → contract Spesen-Zuschlag clause → 0% (resolveMarkup). The re-bill line description is "{supplier} (Weiterverrechnung)" / "… (Durchlaufende Position)".
Data model (migrations 122–132)
All money is integer minor units (*_amount_minor). Additive, hasTable/hasColumn-guarded.
- 122 — seed
accountingmaster flag (default OFF; preserve-visuals auto-enable wheretaxReportwas on). - 123 —
accounting.view/accounting.managepermissions. - 124 —
inbound_documents,expenses,expense_categories(+ seed categories). - 125 — contract
expense_markup_type|_percent|_flat_minor(Spesen-Zuschlag clause). - 126 — split incoming vs expenses: disposition/tax_treatment/event_id/category_id, re-bill markup +
billed_invoice_id/billed_invoice_line_item_id, supplier-payment columns oninbound_documents;kind/quantity/rate_minoronexpenses. - 127 — separate
expensessub-flag + accountingapp_settings(km/per-diem rate, require-proof). (NB:app_settingshas nocreated_at/updated_at— seedsetting_key/value/typeonly.) - 128 — incoming mail (IMAP):
incomingMailflag +email_configs.imap_*+received_emails. - 129 —
ledger_accounts+vat_codes(Swiss/LI KMU seed) + category→account mapping. - 130 —
vat_codesnapshot column on quotes + invoices. - 132 —
inbound_documents.note+inbound_documents.customer_account_id(the attached re-bill client; loose link, indexed for the pending-pool lookup).
inbound_documents key columns: parsed fields (supplier_name, invoice_date, total/net/vat_amount_minor, iban, payment_reference) + separate untrusted qr_amount_minor (tamper cross-check — the authoritative total is the text value); status (unsorted/categorized/declined/duplicate); disposition; tax_treatment; event_id (NULL = company); category_id; customer_account_id; markup_type/_percent/_flat_minor; billed_invoice_id + _line_item_id; supplier_paid + _at/_method/_ref; note.
API (/api/admin/expenses, gated by incomingInvoices + accounting.*)
POST /inbound(multipart) — capture (upload/camera). Deduped by SHA-256.GET /inbound— list (joins the attached customer name/email).GET /inbound/pending-summary— per-customer pending re-bills (registered before/inbound/:id).POST /inbound/bill-pending— bundle one customer's pending re-bills into one invoice.GET /inbound/:id·PATCH /inbound/:id(edit/confirm fields incl.note).GET /inbound/:id/page/:n— rasterised PNG of a page.GET /inbound/:id/file— original (PDFs as attachment only, never inline).POST /inbound/:id/categorize— set disposition (re-runnable; unwinds prior re-bill).POST /inbound/:id/rebill— explicit "re-bill this one now" (forces an immediate single-doc bill).POST /inbound/:id/supplier-payment— toggle supplier paid + method/date/reference.- Expenses:
GET/POST /,GET/PATCH /:id,POST /:id/invoice,POST /:id/paid,GET /:id/proof. - Categories:
GET/POST/PATCH/DELETE /categories(accounting master).
Document preview = server-side rasterised images
Raw PDFs are never served inline. rasterizeService shells out to poppler pdftoppm (OS package in the Docker image — not a Node PDF lib, runs no JS, no egress). Pages cached under storage/business-docs/inbound/rendered/<id>/page-<n>.png, served with Content-Security-Policy: default-src 'none' + nosniff. Page count capped at 200. The triage preview defaults to the last page (the Swiss QR-bill usually sits at the bottom).
Reporting & export
- Tax report (
taxReportService) — full Einnahmen-Ausgaben: incoming invoices + expenses feed thecostsside, grouped Company vs Event; re-billed costs are kept (the matching re-bill revenue is also counted, so it nets).vatPayable= output VAT − reclaimable input VAT (excludesforeign_vat_non_reclaimable); zero when not VAT-registered. Gated onaccounting+taxReport(no longerbills). - Treuhänder export (
ledgerService) — accrual Buchungssätze → generic/Banana/bexio CSV. Accrual basis only; bank/payment postings are Layer B (deferred). Seeproject_banana_treuhaender_export_format. - VAT config (codes, rate→code + treatment→code maps, registration & reclaim countries, chart of accounts) lives under Settings → Accounting; invoices snapshot the chosen
vat_code.
Flag model
accounting is an explicit top-level master flag with sub-toggles: incomingInvoices (this surface), expenses (internal expenses), taxReport (moved permanently out of CRM, now independent of bills). incomingMail (IMAP) is a separate flag, not under accounting. accounting off forces taxReport + incomingInvoices off.
Conventions followed
Idempotent migrations; new flags 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 via useLocalizedDate; money via utils/money; every tax/legal surface carries a "verify with your Treuhänder" disclaimer.
Deferred
- OCR / auto-extract —
extractionServiceis a no-op stub (Tesseract + Swiss-QR decode); admin reads the slip and types the fields. - Capture-time VAT reclaim default —
accounting_vat_reclaim_countriesis stored but not yet consumed; needs asupplier_countrycolumn to defaulttax_treatment. - Bank reconciliation — match incoming payments to open invoices / confirm supplier invoices paid (LLB DataFeed / camt.053 / EBICS). Phased, Swiss/LI rails.
- Native double-entry (Layer B) — picpeak stays a feeder/export tool below the CHF 500k threshold; full Erfolgsrechnung/Bilanz is out of scope.