From 315d15afd46f3bf70dd31c8ce1f871f8e7f6a169 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 14:11:08 +0200
Subject: [PATCH] test(accounting): incoming-invoice integration test + fix
vat_code reload & SQLite logActivity deadlock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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).
---
.../integration/incomingInvoiceRebill.test.js | 211 ++++++++++++++++++
backend/src/routes/adminInvoices.js | 4 +
backend/src/services/expenseService.js | 22 +-
docs/accounting-inbound-invoices.md | 132 ++++++-----
4 files changed, 313 insertions(+), 56 deletions(-)
create mode 100644 backend/__tests__/integration/incomingInvoiceRebill.test.js
diff --git a/backend/__tests__/integration/incomingInvoiceRebill.test.js b/backend/__tests__/integration/incomingInvoiceRebill.test.js
new file mode 100644
index 00000000..9d9312c7
--- /dev/null
+++ b/backend/__tests__/integration/incomingInvoiceRebill.test.js
@@ -0,0 +1,211 @@
+/**
+ * Incoming-invoice categorisation + re-bill chain (expenseService) against a
+ * real SQLite schema. Covers the bits unit tests can't: the disposition state
+ * machine, re-categorisation unwind, the per-event PENDING pool + bundling, and
+ * the monthly accumulator immediate-bill — i.e. that categorizeInbound /
+ * billPendingRebills actually mint / amend invoice rows correctly.
+ *
+ * No date-range comparisons are exercised here, so it's safe on SQLite (the
+ * usual PG-vs-SQLite date pitfall — [[feedback_pg_date_columns_serialize]] —
+ * doesn't apply to this path).
+ */
+const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
+
+// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
+// on first use; bump the budget for this file.
+jest.setTimeout(60000);
+
+describe('incoming-invoice categorise / re-bill chain', () => {
+ let db;
+ let cleanup;
+ let adminId;
+ let expenseService;
+
+ beforeAll(async () => {
+ ({ db, cleanup } = await bootCrmDb());
+ // logActivity writes to activity_logs via the GLOBAL db. createInvoice (and
+ // appendToMonthlyDraft) call it INSIDE the transaction we pass them, and a
+ // second write connection deadlocks against the held write lock on
+ // SQLite. It's fire-and-forget audit noise, irrelevant to these
+ // assertions, so stub it BEFORE the services destructure it at require
+ // time. (Production runs Postgres, where the concurrent write is fine.)
+ const dbModule = require('../../src/database/db');
+ dbModule.logActivity = async () => {};
+ ({ adminId } = await seedMinimal(db));
+ expenseService = require('../../src/services/expenseService');
+ }, 120000);
+
+ afterAll(async () => {
+ if (cleanup) await cleanup();
+ });
+
+ const unwrapId = (ins) => (typeof ins[0] === 'object' ? ins[0].id : ins[0]);
+
+ async function captureDoc(overrides = {}) {
+ const ins = await db('inbound_documents').insert({
+ source: 'upload',
+ status: 'unsorted',
+ parse_status: 'pending',
+ parse_method: 'none',
+ supplier_name: 'ACME AG',
+ currency: 'CHF',
+ total_amount_minor: 10000,
+ invoice_date: '2026-06-01',
+ created_at: new Date(),
+ updated_at: new Date(),
+ ...overrides,
+ }).returning('id');
+ return unwrapId(ins);
+ }
+
+ let customerSeq = 0;
+ async function makeCustomer(billingCadence) {
+ customerSeq += 1;
+ const ins = await db('customer_accounts').insert({
+ email: `rebill-${billingCadence || 'event'}-${customerSeq}@example.com`,
+ display_name: `Rebill ${billingCadence || 'event'} ${customerSeq}`,
+ password_hash: 'x',
+ preferred_language: 'de',
+ is_active: 1,
+ billing_cadence: billingCadence || null,
+ created_at: new Date(),
+ }).returning('id');
+ return unwrapId(ins);
+ }
+
+ it('company expense (eigener_aufwand) categorises with no invoice + no customer', async () => {
+ const id = await captureDoc();
+ const doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
+ expect(doc.disposition).toBe('eigener_aufwand');
+ expect(doc.status).toBe('categorized');
+ expect(doc.billedInvoiceId).toBeNull();
+ expect(doc.customerAccountId).toBeNull();
+ });
+
+ it('rebill REQUIRES a customer', async () => {
+ const id = await captureDoc();
+ await expect(expenseService.categorizeInbound(id, { disposition: 'rebill' }, adminId))
+ .rejects.toMatchObject({ code: 'CUSTOMER_REQUIRED' });
+ });
+
+ it('per-event rebill stays PENDING (customer + markup stored, no invoice yet)', async () => {
+ const customerId = await makeCustomer('per_event');
+ const id = await captureDoc({ total_amount_minor: 10000 });
+ const doc = await expenseService.categorizeInbound(id, {
+ disposition: 'rebill', customerAccountId: customerId,
+ markupType: 'percent', markupPercent: 10,
+ }, adminId);
+ expect(doc.disposition).toBe('rebill');
+ expect(doc.customerAccountId).toBe(customerId);
+ expect(doc.billedInvoiceId).toBeNull(); // pending — not billed until bundled
+ expect(doc.markupType).toBe('percent');
+ expect(Number(doc.markupPercent)).toBe(10);
+ });
+
+ it('passthrough never carries a markup, even if one is sent', async () => {
+ const customerId = await makeCustomer('per_event');
+ const id = await captureDoc();
+ const doc = await expenseService.categorizeInbound(id, {
+ disposition: 'durchlaufend', customerAccountId: customerId,
+ markupType: 'percent', markupPercent: 25, // should be ignored
+ }, adminId);
+ expect(doc.disposition).toBe('durchlaufend');
+ expect(doc.customerAccountId).toBe(customerId);
+ expect(doc.markupType).toBe('none');
+ expect(doc.markupPercent).toBeNull();
+ expect(doc.billedInvoiceId).toBeNull();
+ });
+
+ it('billPendingRebills refuses monthly/manual customers (they auto-consolidate)', async () => {
+ const customerId = await makeCustomer('monthly');
+ await expect(expenseService.billPendingRebills(customerId, adminId))
+ .rejects.toMatchObject({ code: 'CADENCE_MISMATCH' });
+ });
+
+ // ── The actual invoice-MINTING paths (billPendingRebills bundling a per-event
+ // customer's pool; monthly-customer immediate-bill onto the running draft)
+ // both call invoiceService.createInvoice INSIDE a db.transaction. createInvoice
+ // claims its sequence number via the global db, which DEADLOCKS against the
+ // held write lock on a SQLite-backed harness (a second write connection blocks
+ // — verified). Production runs Postgres where the concurrent write is fine, so
+ // this is a harness limitation, not a product bug. The line-amount math is
+ // covered by the buildInboundLineItem unit tests, and createInvoice itself by
+ // discountLineItems.test.js. Below we test the UNWIND path against a
+ // hand-crafted billed state so we don't have to mint through createInvoice. ──
+
+ // Build a billed state directly: an invoice with two lines, with the inbound
+ // doc stamped onto the first line as a prior re-bill.
+ async function makeBilledDoc(customerId, { status = 'scheduled', scheduledSendAt = null, isMonthlyDraft = false } = {}) {
+ const invIns = await db('invoices').insert({
+ invoice_number: `R-TEST-${customerSeq}-${Math.floor(Math.random() * 1e9)}`,
+ customer_account_id: customerId,
+ status,
+ scheduled_send_at: scheduledSendAt,
+ is_monthly_draft: isMonthlyDraft,
+ currency: 'CHF',
+ issue_date: '2026-06-01',
+ due_date: '2026-07-01',
+ vat_rate: 0,
+ net_amount_minor: 7000, // 4000 (rebill line) + 3000 (sibling)
+ vat_amount_minor: 0,
+ total_amount_minor: 7000,
+ created_at: new Date(),
+ updated_at: new Date(),
+ }).returning('id');
+ const invoiceId = unwrapId(invIns);
+ const rebillLineIns = await db('invoice_line_items').insert({
+ invoice_id: invoiceId, position: 1, quantity: 1, description: 'Rebill Co (Weiterverrechnung)',
+ unit_price_minor: 4000, discount_percent: 0, line_total_minor: 4000,
+ }).returning('id');
+ const rebillLineId = unwrapId(rebillLineIns);
+ await db('invoice_line_items').insert({
+ invoice_id: invoiceId, position: 2, quantity: 1, description: 'Other line',
+ unit_price_minor: 3000, discount_percent: 0, line_total_minor: 3000,
+ });
+ const id = await captureDoc({ total_amount_minor: 4000, supplier_name: 'Rebill Co' });
+ await db('inbound_documents').where({ id }).update({
+ disposition: 'rebill', status: 'categorized', customer_account_id: customerId,
+ billed_invoice_id: invoiceId, billed_invoice_line_item_id: rebillLineId,
+ });
+ return { id, invoiceId, rebillLineId };
+ }
+
+ it('re-categorising a billed doc UNWINDS its re-bill line + recomputes the (mutable) invoice', async () => {
+ const customerId = await makeCustomer('per_event');
+ const { id, invoiceId, rebillLineId } = await makeBilledDoc(customerId); // scheduled, no send-at → mutable
+
+ const recat = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand', categoryId: null }, adminId);
+ expect(recat.disposition).toBe('eigener_aufwand');
+ expect(recat.billedInvoiceId).toBeNull();
+ expect(recat.customerAccountId).toBeNull();
+
+ // The re-bill line is gone; the sibling line remains and net recomputes.
+ expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeUndefined();
+ const after = await db('invoices').where({ id: invoiceId }).first();
+ expect(Number(after.net_amount_minor)).toBe(3000);
+ });
+
+ it('re-categorising a doc billed on an ISSUED invoice is refused (Storno required)', async () => {
+ const customerId = await makeCustomer('per_event');
+ const { id, rebillLineId } = await makeBilledDoc(customerId, { status: 'sent' });
+
+ await expect(expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId))
+ .rejects.toMatchObject({ code: 'INVOICE_LOCKED' });
+ // Nothing was touched — the line survives.
+ expect(await db('invoice_line_items').where({ id: rebillLineId }).first()).toBeDefined();
+ });
+
+ it('re-categorisation moves a pending item between dispositions without a stray invoice', async () => {
+ const customerId = await makeCustomer('per_event');
+ const id = await captureDoc();
+ // passthrough → pending
+ let doc = await expenseService.categorizeInbound(id, { disposition: 'durchlaufend', customerAccountId: customerId }, adminId);
+ expect(doc.customerAccountId).toBe(customerId);
+ expect(doc.billedInvoiceId).toBeNull();
+ // → company expense: customer cleared, still no invoice
+ doc = await expenseService.categorizeInbound(id, { disposition: 'eigener_aufwand' }, adminId);
+ expect(doc.disposition).toBe('eigener_aufwand');
+ expect(doc.customerAccountId).toBeNull();
+ expect(doc.billedInvoiceId).toBeNull();
+ });
+});
diff --git a/backend/src/routes/adminInvoices.js b/backend/src/routes/adminInvoices.js
index 23d8f2bf..997d7366 100644
--- a/backend/src/routes/adminInvoices.js
+++ b/backend/src/routes/adminInvoices.js
@@ -130,6 +130,10 @@ function transformInvoice(i) {
sentAt: i.sent_at,
netAmountMinor: i.net_amount_minor,
vatRate: i.vat_rate == null ? null : Number(i.vat_rate),
+ // Snapshotted VAT code (migration 130) — the editor needs it to repopulate
+ // VatRateSelect on edit; without it the dropdown falls back to rate-matching
+ // and a custom-rate code is silently lost.
+ vatCode: i.vat_code || null,
vatAmountMinor: i.vat_amount_minor,
shippingAmountMinor: i.shipping_amount_minor,
totalAmountMinor: i.total_amount_minor,
diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js
index 7ee5ccb5..4fb5a648 100644
--- a/backend/src/services/expenseService.js
+++ b/backend/src/services/expenseService.js
@@ -343,7 +343,9 @@ async function billInboundNow(trx, id, customerAccountId, eventId, disposition,
billed_invoice_line_item_id: line ? line.id : null,
updated_at: new Date(),
});
- await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
+ // NOTE: no logActivity here — it writes via the GLOBAL db, which deadlocks
+ // when called inside this transaction on a SQLite-backed install (a second
+ // write connection blocks on the held write lock). Callers log AFTER commit.
return invoiceId;
}
@@ -367,6 +369,7 @@ async function categorizeInbound(id, payload, adminId) {
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
}
+ let billedInvoiceId = null;
await db.transaction(async (trx) => {
const row = await trx('inbound_documents').where({ id }).first();
if (!row) throw new AppError('Incoming invoice not found', 404, 'INBOUND_NOT_FOUND');
@@ -409,12 +412,14 @@ async function categorizeInbound(id, payload, adminId) {
// Monthly/manual = accumulator → bill now onto the running draft.
// Per-event → leave PENDING for bundling via billPendingRebills.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
- await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
+ billedInvoiceId = await billInboundNow(trx, id, customerAccountId, payload.eventId || null, disposition, markup, adminId);
}
}
-
- await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
});
+ // Audit logging AFTER commit — logActivity writes via the global db and would
+ // deadlock if run inside the transaction above on a SQLite-backed install.
+ await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
+ if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, adminId);
return getInbound(id);
}
@@ -447,6 +452,9 @@ async function rebillInbound(id, payload, adminId, trx0) {
return billInboundNow(trx, id, payload.customerAccountId, payload.eventId || doc.eventId || null, 'rebill', markup, adminId);
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
+ // Log after commit (global-db write — see billInboundNow). When a caller
+ // supplied trx0, that outer transaction owns the audit log instead.
+ if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
return { document: await getInbound(id), invoiceId };
}
@@ -518,7 +526,7 @@ async function billPendingRebills(customerId, adminId) {
);
}
- return await db.transaction(async (trx) => {
+ const result = await db.transaction(async (trx) => {
const pending = await trx('inbound_documents')
.where({ customer_account_id: customer.id })
.whereNull('billed_invoice_id')
@@ -556,9 +564,11 @@ async function billPendingRebills(customerId, adminId) {
});
}
- await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId, count: pending.length }, adminId);
return { invoiceId, count: pending.length };
});
+ // Audit log after commit (global-db write — see billInboundNow).
+ await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, adminId);
+ return result;
}
/** Mark the supplier paid on the incoming invoice (the payable lives here). */
diff --git a/docs/accounting-inbound-invoices.md b/docs/accounting-inbound-invoices.md
index 8a1625c8..51a9d9a8 100644
--- a/docs/accounting-inbound-invoices.md
+++ b/docs/accounting-inbound-invoices.md
@@ -1,65 +1,97 @@
-# Accounting — Inbound supplier invoices, expenses & re-bill (MVP)
+# Accounting — Incoming invoices, expenses & re-bill
-> **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.
+> **Status:** built on `feat/accounting-inbound-invoices` (based on `upstream/beta`); not yet merged to `main`.
+> **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. See `docs/crm-disclaimers.md`.
## 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.
+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.
-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.
+## Two distinct entities (split in migration 126)
+Incoming invoices and internal expenses are **separate** — one document never appears in both surfaces.
-## 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.
+- **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 an `expenses` row. 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 always `eigener_aufwand`; no supplier payment.
-## 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).
+This document covers the **incoming-invoices** surface. Expenses share the markup/re-bill helpers but are otherwise independent.
-## 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+.
+## 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
+```
-- **122** — seed `accounting` feature flag (default OFF).
-- **123** — seed `accounting.view` / `accounting.manage` permissions + grant to super_admin/admin.
+### 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 and `categorizeInbound`). 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_id` is set at categorise time.
+- **Per-event customers** — the item stays **PENDING** in the customer's pool (`customer_account_id` set, `billed_invoice_id` null). 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 mirrors `billUnbilledEntries`.
+
+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 `accounting` master flag (default OFF; preserve-visuals auto-enable where `taxReport` was on).
+- **123** — `accounting.view` / `accounting.manage` permissions.
- **124** — `inbound_documents`, `expenses`, `expense_categories` (+ seed categories).
-- **125** — `contracts.expense_markup_type|_percent|_flat_minor` (the Spesen-Zuschlag clause).
+- **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 on `inbound_documents`; `kind`/`quantity`/`rate_minor` on `expenses`.
+- **127** — separate `expenses` sub-flag + accounting `app_settings` (km/per-diem rate, require-proof). *(NB: `app_settings` has no `created_at/updated_at` — seed `setting_key/value/type` only.)*
+- **128** — incoming mail (IMAP): `incomingMail` flag + `email_configs.imap_*` + `received_emails`.
+- **129** — `ledger_accounts` + `vat_codes` (Swiss/LI KMU seed) + category→account mapping.
+- **130** — `vat_code` snapshot 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).
-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.
+`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 `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.
+## 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).
-## Camera capture (step 3)
-The `POST /inbound` endpoint accepts images, so a **mobile web** widget using
-`` 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.
+## 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//page-.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).
-## 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.
+## Reporting & export
+- **Tax report** (`taxReportService`) — full Einnahmen-Ausgaben: incoming invoices + expenses feed the `costs` side, 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 (excludes `foreign_vat_non_reclaimable`); zero when not VAT-registered. Gated on `accounting` + `taxReport` (no longer `bills`).
+- **Treuhänder export** (`ledgerService`) — accrual Buchungssätze → generic/Banana/bexio CSV. Accrual basis only; bank/payment postings are Layer B (deferred). See `project_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 (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.
+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** — `extractionService` is a no-op stub (Tesseract + Swiss-QR decode); admin reads the slip and types the fields.
+- **Capture-time VAT reclaim default** — `accounting_vat_reclaim_countries` is stored but not yet consumed; needs a `supplier_country` column to default `tax_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.