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.
Capacity and retention
Stable from v3.134.2 and beta from v3.167.2-beta.0 (PicPeak PR #1888 ) bound how much unhandled mail the intake holds. The limits are checked before a new message is downloaded; they never delete mail from the IMAP server. They are set by environment variables (see Environment Variables):
| Variable | Default | Meaning |
|---|---|---|
EMAIL_INTAKE_INSTALLATION_BYTES | 2147483648 (2 GiB) | Shared byte budget for the unhandled intake backlog |
EMAIL_INTAKE_MAILBOX_BYTES | 536870912 (512 MiB) | Per-mailbox byte budget for the unhandled intake backlog |
EMAIL_INTAKE_INSTALLATION_ROWS | 50000 | Shared ceiling on unhandled backlog items |
EMAIL_INTAKE_MAILBOX_ROWS | 10000 | Per-mailbox ceiling on unhandled backlog items |
EMAIL_INTAKE_INSTALLATION_PER_HOUR | 1000 | Shared admissions per hour |
EMAIL_INTAKE_MAILBOX_PER_HOUR | 500 | Per-mailbox admissions per hour |
EMAIL_INTAKE_SENDER_PER_HOUR | 50 | Admissions per hour for one sender in one mailbox |
EMAIL_INTAKE_RETENTION_DAYS | unset (keep forever) | Opt-in age limit for captured messages and untouched intake documents |
EMAIL_INTAKE_METADATA_RETENTION_DAYS | 7 | How long bare refusal / failure records are kept |
Values must be positive integers; an invalid value falls back to the default. Byte settings accept at most 4 TiB, row and rate settings at most 2147483647, retention settings at most 36500 days. A sender is the envelope address, not an authenticated identity, so the mailbox and installation budgets still apply when a sender rotates addresses.
What the budgets count. Only the unhandled backlog: messages being fetched, and captured documents nobody has handled yet (unsorted, duplicate or declined, with no admin edit, disposition, booking or customer link), each counted as its file plus a 64 KiB allowance. Booked or edited documents, stored message bodies and everything captured before the upgrade never count. An install that already holds more than a budget keeps receiving mail; booking an invoice frees its share.
When a limit is reached, the message is neither recorded nor marked seen. It stays unread on the mail server and a later poll admits it once the backlog shrinks or the hourly window passes. The backend logs one warning per mailbox, limit and hour that names the variable, for example Incoming mail for mailbox "accounting" is waiting: EMAIL_INTAKE_MAILBOX_BYTES (536870912) is reached. To get mail flowing again, handle the waiting documents (edit, categorise or book them) or raise the named limit. A message over the per-message size limit is different: it can never be admitted, so it is recorded once as an error and marked seen.
Retention. Nothing is deleted by age unless EMAIL_INTAKE_RETENTION_DAYS is a positive number; unset or 0 keeps every captured message and document. When it is set:
- It must be larger than the 90-day window the poller searches. A smaller value is raised to 91 days and a warning is logged.
- Age is counted from capture time, not from the sender’s Date header.
- A message expires only when it is not in the Archived folder and no captured document of it still exists. Messages in the inbox and in the trash expire.
- An expired message leaves a hidden, body-less record (Message-ID, mailbox, capture time) until it is 97 days old, so a message still on the mail server is not imported again.
- Only untouched automated intake documents are removed. An admin edit, disposition, payment, booking, customer or re-bill link, or an expense or duplicate reference protects a document, and a protected document also protects the message it came from.
Independently of that setting, a scheduler that runs every minute (also while incoming mail is off) turns claims of crashed workers into error records, removes error records that never held a sender, subject, body or document after EMAIL_INTAKE_METADATA_RETENTION_DAYS (the message is then tried again if it is still within the 90-day window), and removes files of the mail intake that nothing references any more. Manual uploads are never swept. Permanently deleting a message as an admin does not delete its accounting proof.
These are application budgets, not filesystem quotas: keep enough disk headroom for the rest of PicPeak and for backups.
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.
PDF checks
In builds containing PicPeak PR #1809 every PDF, from the mailbox or from a manual upload, is inspected in an isolated worker before PicPeak opens it. A PDF that is encrypted, malformed, too complex, that contains scripts, actions, forms or embedded files, or that defines the same object twice in a way viewers and the check could read differently is not processed: a mail attachment is kept in the inbox as declined with the reason, and is not retried; a manual upload is refused with the reason. Accepted files are stored byte for byte as received, so a digitally signed invoice keeps its signature.
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. An admin who is not a super admin reaches only the incoming invoices they recorded, those booked to galleries they created, and untriaged mailbox intake; categorising or re-billing an untriaged invoice makes it theirs. See Accounting → Tenancy model.