Incoming Invoices
Capture supplier invoices (the bills you receive — from your accountant, hosting provider, equipment supplier, etc.), classify them, pay the supplier, and optionally re-bill the cost to your customer through the CRM. Documents land in the inbox via two paths: manual upload, or the IMAP poller that watches your rechnungen@… mailbox.
Tax treatment is your call. PicPeak captures the supplier invoice and offers a set of standard treatments (domestic, reverse_charge_service, foreign_vat_non_reclaimable, import_goods), but the right treatment for a specific bill depends on your jurisdiction, your VAT status, and the supplier’s country. Verify with your Treuhänder before relying on the report numbers.
Prerequisites
- Feature flag:
incomingInvoicesenabled in Settings → Features. The parentaccountingflag must also be on. - Optional — IMAP intake:
incomingMailflag plus IMAP credentials in Settings → Email. See IMAP intake below. - Settings → Accounting configured: at minimum the VAT codes + tax-treatment → VAT-code map, so categorised documents pick up the right rate.
Where to find it
/admin/accounting/inbox — the Incoming invoices page in the Accounting section.
Lifecycle
Each row in the inbox carries a status field:
| Status | Meaning |
|---|---|
unsorted | Newly captured; needs a disposition |
duplicate | A file with the same SHA-256 was already in the inbox; flagged for the Duplikat workflow |
categorized | Disposition set (rebill / durchlaufend / eigener Aufwand) |
declined | Rejected — bill we don’t want to book at all (spam, duplicate-from-supplier, mis-sent) |
Capture paths
Manual upload
Click Upload invoice in the inbox. Accepted MIME types: application/pdf, image/jpeg, image/png. The file is hashed (SHA-256) on save; if a file with the same hash already exists in the inbox the new row is flagged duplicate and linked to the original via duplicate_of_id so you can decide whether to keep, reject, or merge.
The PDF preview surface rasterises each page via poppler’s pdftoppm into a PNG and serves it under a strict CSP (default-src 'none'; img-src 'self' data:). The raw PDF is never displayed inline — admins see the rasterised PNG only, so a malicious supplier PDF can’t execute scripts or phone home from the browser. Documents are capped at 200 renderable pages; anything larger refuses to preview but still captures the file.
IMAP intake
When the incomingMail flag is on and Settings → Email has IMAP credentials configured, a background poller checks your mailbox every minute and drops attachments into the inbox automatically.
IMAP configuration
Under Settings → Email (alongside the existing SMTP outgoing config):
| Setting | Notes |
|---|---|
imap_host | Your IMAP server (e.g. imap.gmail.com, imap.mail.me.com). RFC1918 addresses are refused on save + test |
imap_port | Defaults to 993 |
imap_secure | TLS — defaults on |
imap_user | Mailbox login — usually the email address you receive bills at |
imap_pass | Mailbox password. Stored masked (********) on GET; the real value is preserved when masked is sent back, as long as host, port, user and TLS stay the same. Changing any of them requires entering the password again — see Mail server passwords |
imap_folder | Folder to poll — defaults to INBOX. The Settings UI offers a folder-picker that lists the available paths |
Three test actions on the page (Test connection and List folders need the email.edit permission):
- Test connection — proves login + opens the folder, reports message + unread counts. Non-destructive.
- List folders — populates the folder dropdown with what your server exposes.
- Round-trip test — sends a uniquely-tagged email through the saved SMTP config to the IMAP mailbox, then polls IMAP until it arrives. Proves the whole pipeline (outgoing delivery → incoming reception) in one click. The test message is auto-deleted on receipt so it never reaches the inbox.
Round-trip test polling uses an exponential backoff (×1.5, capped at 8s) so a 30-second test does about 5 polls — friendlier to IMAP servers that throttle frequent SELECT/SEARCH requests.
What the poller does
Every 60 seconds, when the incomingMail flag is on:
- Open the configured folder;
SEARCHfor messages within the last 90 days (regardless of\Seen— so mail you already read in another client is still logged). - Cheap envelope-only fetch to get UIDs + message-IDs; drop anything already in
received_emails(the audit log). - For each fresh message: claim a
received_emailsrow withstatus='processing'BEFORE downloading the body. Themessage_idcolumn isUNIQUE(migration 128), so on a multi-replica install a second poller racing the same message loses the unique-constraint write and skips cleanly — no double-ingest. - Parse the message, extract attachments with
application/pdf/image/jpeg/image/pngMIME types, save each into the inbox. - Finalise the
received_emailsrow (status=ingested/no_attachment/error), mark the IMAP message\Seen.
If a worker crashes between claim and finalise, the stuck processing row is reclaimed after 10 minutes so the attachment isn’t orphaned forever.
The Received tab
The audit log surfaces at Settings → Email → Received (mirrors the existing Sent emails view). Every message the poller processed shows up — even ones with no attachments — so you can verify “did the bill from supplier X arrive yesterday?” without diving into your mail client.
Dispositions
Once a document is in the inbox, you assign a disposition — what to do with the cost. Five values:
| Disposition | Effect | When to use |
|---|---|---|
rebill (Weiterverrechnung) | Books a line item onto a customer invoice with optional markup. Customer pays you, you pay the supplier. | A subcontractor’s invoice that goes onto the customer’s bill |
durchlaufend (pass-through) | Booked as a pass-through cost — neutral on your books, recharged to the customer at the same amount. | Travel costs you pre-pay and bill back without markup |
eigener_aufwand (own cost) | Booked to your company as an expense. Customer doesn’t see it. | Office rent, your accounting software subscription, etc. |
duplikat | Marks the doc as a duplicate of an existing row. Carries forward duplicate_of_id. Doesn’t appear in cost totals. | The same bill arrived twice (e.g. supplier resent) |
abgelehnt (rejected) | Doc kept for audit but excluded from all cost reporting. | Spam, mis-sent invoice, anything you decided not to book |
rebill and durchlaufend both require an event_id (which event / customer the cost goes against). eigener_aufwand requires a category_id (which expense bucket — Equipment, Software, etc.).
Tax treatments
Alongside the disposition, each booked document gets a tax treatment that drives the VAT-code lookup:
| Treatment | What it means |
|---|---|
domestic | Supplier in your country — input VAT is reclaimable when you’re VAT-registered |
reverse_charge_service | Service from an EU supplier (Bezugsteuer / acquisition VAT) — you book BOTH the input and output VAT, net effect zero |
foreign_vat_non_reclaimable | Supplier outside your reclaim countries — VAT charged is a real cost, not reclaimable |
import_goods | Goods import — VAT cleared at customs, separate handling |
The treatment maps through Settings → Accounting → VAT codes to a specific VAT code (e.g. VST81 for domestic 8.1% input). That code drives the chart-of-accounts booking in the Treuhänder export.
Re-billing the customer (Weiterverrechnung)
When you set disposition='rebill' and pick an event, a markup option becomes available:
- None — recharge the gross amount as-is.
- Percent — apply N% on top (e.g. 10% admin overhead).
- Flat — fixed Rappen / cent amount.
The booking spawns a line item on a new draft invoice for that customer (or appends to the running monthly draft if the customer is on monthly billing). The inbound_documents row is linked back via billed_invoice_id + billed_invoice_line_item_id so the chain is auditable both directions.
The document is NOT removed from the inbox — it stays categorized so you can prove which supplier bill drove which customer-facing line item.
Attaching the supplier’s proof
The client’s invoice email can carry the stored supplier document as a separate attachment, so the client sees the bill behind the recharge without it being merged into your invoice PDF — invoice immutability holds either way.
It is off by default globally, can be overridden per customer, and the individual files are selectable in the send dialog. The attached filename follows a configurable template with {INVOICE}, {SUPPLIER}, {YEAR}, {MONTH} and {SEQ} tokens.
If a proof is missing at the moment you issue, the send is not blocked: the invoice goes out and the failure is recorded against the document so you can see what happened rather than discovering a silent omission later.
Re-bills & passthrough panel
CRM → Customer carries a panel grouping that customer’s re-bills into Open, Sent and Paid. The status is derived from the linked invoice’s own lifecycle rather than stored twice, so it cannot drift out of sync with the invoice.
Cancelling an invoice releases the supplier costs it covered back to Open, so a Storno never strands a cost as permanently billed.
Combining hours and re-bills
A cross-add dialog rolls open hours and open re-bills into a single invoice, reachable from either side. The two stay as distinct, contiguous groups of line items and are never merged into shared lines, so the client can still see what was time and what was a passed-through cost.
Paying the supplier
The cost is your payable until you settle it with the supplier. Mark a document Supplier paid in the detail view:
supplier_paid_at— when you transferredsupplier_payment_method—bank_transfer/cash/twint/paypal/card/othersupplier_payment_ref— bank reference, transaction ID, whatever lets you reconcile against your bank statement
This is admin-only bookkeeping — picpeak does NOT move money, it records that you did.
Duplicate handling
The SHA-256 of the file is checked on every capture. A match auto-flags the new row duplicate with duplicate_of_id pointing at the original. From the row’s detail view you can:
- Keep both — leave the duplicate flagged but visible (rare; e.g. supplier reissued with a corrected amount).
- Reject — set disposition
abgelehnt, drop it from cost totals. - Merge — manually mark this row as the canonical one and reject the original.
Why we don’t UNIQUE the SHA column. The duplicate flow is a manual decision; a unique constraint would silently drop the second row before you got to decide. The IMAP poller’s de-dup uses the message_id UNIQUE on received_emails instead, which catches multi-replica races on email ingestion. The file SHA stays a soft key.
Storage
Captured files live under storage/business-docs/inbound/<year>/ (so the directory tree groups by year). Rasterised page PNGs live alongside under inbound/rendered/<docId>/. Both are local-disk only — there is no S3 path for inbound documents today.
Path-traversal protection is enforced by assertPathInside([storagePath/business-docs]) on every download / render route — the captured filename is whitelisted at intake (email-<timestamp>-<rand>.<ext> for IMAP, <subdir>-<timestamp>.<ext> for manual upload) so nothing supplier-controlled ever reaches the filesystem path.
Per-page resource bounds
To prevent a hostile 1000-page PDF from walking your rasteriser, the document’s stored page_count is capped to 200 at ingest (Math.min(pageCount, 200)). The render endpoint refuses any page number outside [1, 200]. pdftoppm itself is timeout-bounded to 25 seconds with a 4MB stderr cap.
Permissions
accounting.view— see the inbox, open documents, view the previewsaccounting.manage— capture, set disposition / tax treatment, mark supplier paid, re-bill
Both granted to super_admin and admin by default.