Commit Graph

1423 Commits

Author SHA1 Message Date
Luca 4ff5b84cb6 feat(accounting): relocate VAT codes + rate maps into Settings → Accounting
Move VAT-code CRUD and the rate→code / treatment→code maps off the
Chart-of-accounts page into a self-contained VatCodesManager rendered in
Settings → Accounting, so all VAT config lives in one place. CoA keeps
the accounts table, default/system accounts, and expense-category maps.

Both pages save disjoint key sets through the partial-merge updateSettings
(CoA → account keys only; VatCodesManager → ledger_vat_map +
ledger_output_vat_map only), so neither reverts the other's edits.
2026-06-16 00:45:50 +02:00
Luca d7107aaf0a feat(accounting): tax report VAT-payable honours registration + reclaim
The report's vatPayable is now: 0 when not VAT-registered; otherwise output VAT
minus the RECLAIMABLE input VAT only (costs with tax_treatment
foreign_vat_non_reclaimable are excluded from the deduction). Registration reads
accounting_vat_registered; when unset it falls back to a behaviour-preserving
heuristic (charged output VAT this period ⇒ registered), so existing reports are
unchanged and non-VAT installs correctly show 0. loadCosts now tracks
reclaimableVat. Tests updated; 32 pass.
2026-06-16 00:33:10 +02:00
Luca 4d87684882 feat(accounting): VAT registration + reclaim-country settings in the Accounting tab
Adds the 'VAT registration & reclaim' section to Settings → Accounting: a
'VAT-registered' toggle (charge output + reclaim input VAT) and a multi-select
of countries whose input VAT is reclaimable (default domestic CH/LI). Wires
accounting.service + the backend keys added earlier (accounting_vat_registered,
accounting_vat_reclaim_countries). i18n en/de. The report VAT-payable math that
consumes these is the next slice.
2026-06-16 00:27:17 +02:00
Luca 2479d87afc feat(accounting): bill editor VAT dropdown + GET returns vat_code snapshot
Slice 2 + 1b:
- Bill editor: VAT-rate field → VatRateSelect dropdown (mirrors the quote
  editor); snapshots vatCode on create + carries it from a source quote.
- getQuoteById + the invoice serializer now return vat_code, so re-editing a
  saved document preserves the snapshot instead of falling back to the
  rate→code map. Payload types (quotes + bills) carry vatCode.

72 tests pass; build green.
2026-06-16 00:20:59 +02:00
Luca 6e1924bae8 feat(accounting): VAT-code dropdown in the quote editor (+ reusable VatRateSelect)
Slice 3a — replaces the free-typed VAT rate in the quote editor with a dropdown
of configured output VAT codes (+ 'Other (custom rate)'), reading the un-gated
/admin/vat-codes endpoint. Selecting a code sends vatCode → the backend snapshots
it (migration 130) and the export emits it. New VatRateSelect component + a
read-only vatCodes.service. Create flow snapshots correctly; loading a saved code
into the editor (serialization return) + the bill editor are the next slices.
Build green.
2026-06-16 00:08:52 +02:00
Luca fbbbb8ab73 feat(accounting): VAT registration/reclaim settings + un-gated VAT-codes read
Slice 1 of the VAT consolidation backend:
- PUT /admin/settings/accounting accepts accounting_vat_registered (bool) +
  accounting_vat_reclaim_countries (ISO-2 list); GET /:type already returns
  them parsed, so no GET change needed.
- New read-only GET /api/admin/vat-codes (adminAuth, NOT accounting-gated) so
  the invoice/quote editors can populate their VAT dropdown even when the
  accounting layer is off. Management CRUD stays under /admin/ledger.
2026-06-16 00:02:59 +02:00
Luca 5b52969e36 feat(accounting): snapshot the chosen VAT code on quote/invoice create + storno
Wires the vat_code snapshot (migration 130) through the write paths: quote
create/update, the main invoice create, and the Storno carry-over (so a
cancellation exports the same code as the invoice it reverses). Guarded with
hasColumnCached; reads payload.vatCode (sent by the editor dropdown, coming in a
later slice — inert until then, falls back to the rate→code map). 72 tests pass.
2026-06-15 23:57:23 +02:00
Luca 0a7dc1cf5d feat(accounting): snapshot vat_code on quotes/invoices + export prefers it (foundation)
First slice of the VAT-consolidation: migration 130 adds a nullable vat_code
snapshot column to quotes + invoices, and the Treuhänder export now prefers the
invoice's snapshotted code over the (mutable) rate→code map, so a historical
invoice's VatCode never changes when codes are re-mapped. Schema-drift guarded;
behaviour-neutral until the editors start writing the snapshot (next slices).

Part of: VAT registry → Settings→Accounting, invoice VAT dropdown, registration/
reclaim toggle.
2026-06-15 23:53:32 +02:00
Luca 53a16f9f6f fix(accounting): Banana I&E export uses the 'Category' column (not 'ContraAccount')
Real Banana Income & Expense files name the category column 'Category', not
'ContraAccount' (which the doc listed but is a double-entry concept) — so the
income/expense account never landed and Banana warned 'ContraAccount column not
found'. Use 'Category'. VatCode stays (it only warns on a non-VAT-enabled file;
amounts are gross). Test updated.
2026-06-15 23:17:57 +02:00
Luca 0c0fb29770 fix(accounting): emit ISO dates in exports (Postgres returns Date objects)
The Date column imported empty into Banana because dateOnly() did
String(d).slice(0,10) — on Postgres the date columns come back as JS Date
objects, so that yields "Thu Jan 15" instead of "2026-01-15", which Banana
rejects. (SQLite returns strings, so the tests never caught it — the
pg-date-serialisation trap.)

- ledgerService.dateOnly + taxReportService CSV now format Date objects to
  yyyy-mm-dd via local calendar parts (DATE columns are local-midnight).
- Regression test added with a real Date object (the existing tests all used
  string dates).
2026-06-15 23:01:20 +02:00
Luca 445d6d7b6d feat(accounting): add a Banana "Income & Expense" (cash-book) export format
The Banana export assumed a double-entry file; a user importing into an Income
& Expense (Einnahmen-Ausgaben) file got "AccountDebit/AccountCredit/Amount/
VatCode column not found", since those columns only exist in double-entry.

Add a second Banana format alongside the double-entry one:
- ledgerService: new `banana_ie` format → Banana I&E columns Date, Doc,
  Description, Income, Expenses, ContraAccount (the income/expense account),
  VatCode (banana.ch doc 9946). Revenue → gross in Income + revenue account;
  cost → gross in Expenses + expense account. Same tab-separated .txt shape.
- Frontend: ExportFormat + dropdown gain `banana_ie`; .txt extension covers
  both Banana variants. Labels relabelled: "Banana — double-entry" and
  "Banana — income & expense" (de equivalents). Hint de-"double-entry"-fied.
- Test added for the I&E format.

Pairs with the prior UTF-8 BOM fix (the "·" mojibake). Tests + build green.
2026-06-15 22:48:06 +02:00
Luca 74144da45f fix(accounting): UTF-8 BOM on the ledger export so Banana reads it correctly
The /ledger/export route sent the file without a BOM, so Banana (and Excel)
decoded it as the local charset — the '·' description separator and any umlauts
imported as mojibake ('·'). Prepend the EF BB BF BOM like the tax-report CSV
route already does.
2026-06-15 22:39:32 +02:00
Luca a19506749a fix(accounting): Banana export is now a tab-separated .txt (actually importable)
Banana's "Text file with column headers" import (Actions → Import into
accounting) requires a TAB-separated .txt with unquoted values — picpeak was
emitting a comma-separated, quoted .csv, which won't even show in Banana's
*.txt file picker, let alone parse into columns.

- ledgerService.exportPostings: the `banana` format now serialises TAB-separated
  with no quoting, .txt extension, text/plain content-type. generic + bexio stay
  comma-CSV (RFC 4180). Tab/newline chars in a cell are collapsed to spaces.
- Frontend ledger.service: download filename uses .txt for banana.
- Tests updated for the new banana shape (tab header, .txt, text/plain).

The column names already matched Banana's NameXml; only the serialisation was
wrong. bexio left as comma-CSV (verify against bexio's import spec separately).
2026-06-15 22:28:10 +02:00
Luca b584aaf7a6 test(accounting): update tax-report CSV tests for the unified ledger format
The CSV rework (unified, typed ledger) replaced the 'Rechnung' column with
'Referenz' (+ a 'Typ' column) and dropped the separate cancelled 0/1 column in
favour of a localised '(Cancelled)' suffix on the Reference cell. Update the
two assertions in taxReportPdf.test.js accordingly. All 11 cases pass.
2026-06-15 19:54:18 +02:00
Luca 7e586cd3a1 style(accounting): align tax-export buttons + solid divider between groups
- Give all four export controls (CSV / PDF / format select / Accountant export)
  a matching min-width so the two rows form a tidy right-aligned button grid
  (CSV over format select, Export PDF over Accountant export).
- Replace the dashed sub-divider between the Report and Accounting journal
  groups with a solid line so the separation reads clearly.
2026-06-15 19:30:22 +02:00
Luca 3edd832103 feat(accounting): clearer tax-export window + gate journal export on accounting flag
- Restructure the export area into two labelled groups: 'Report' (PDF/CSV,
  for you) and 'Accounting journal' (for your accountant), each with a
  one-line caption — instead of two unlabelled button rows.
- i18n: the English label was the German 'Treuhänder export' → now 'Accountant
  export' (de stays 'Treuhänder-Export'); hint reworded.
- Feature flags: the journal export is an accounting-layer feature (needs the
  Chart-of-accounts mapping), so gate it on the 'accounting' master — the
  group only renders when accounting is on, and the backend /export route no
  longer requires the 'taxReport' sub-flag (the router already requires accounting).

Build + node --check + JSON parse green.
2026-06-15 19:18:36 +02:00
Luca b1f73c1df9 feat(accounting): move Treuhänder export onto the Tax page
The standalone 'Treuhänder export' tab duplicated the Tax page's period/
currency filters over the same data. Fold the collective-journal export into
the Tax page as a third export action (target-tool format picker: generic /
Banana / bexio), beside Export CSV/PDF, with a link to its Chart-of-accounts
config. Removes the Accounting sub-nav 'export' tab (old /export route now
redirects to the Tax page); keeps Chart of accounts as its own setup tab.
Deletes the now-orphaned LedgerExportPage.

Build + JSON parse green.
2026-06-15 18:57:43 +02:00
Luca f3e77e7807 fix(accounting): label the outgoing-invoice totals block in the tax summary
The summary card's top block (Total net/VAT/gross) is the outgoing-invoice
totals but had no section header, unlike the 'Income / costs' block below.
Add an 'Outgoing invoices' (de: 'Ausgangsrechnungen') header to match.
2026-06-15 17:48:22 +02:00
Luca fd1dd81e8d feat(accounting): unify tax report into one signed, typed, sortable ledger
Replaces the separate revenue + costs tables with a single ledger across the
screen, CSV and PDF. Every row is typed (outgoing invoice / incoming invoice /
expense) and signed — outgoing positive, incoming + expenses negative — so
sorting by value runs income → costs and the column nets toward the Result.

- getTaxReport now returns a `ledger` array (signed, typed, date-sorted);
  legacy rows/costs/summary kept for back-compat.
- Frontend: one sortable table (click Type/Date/Party/Net/VAT/Gross), coloured
  type badges, cancelled rows greyed with lineage badges; Income/Costs/Result
  summary box unchanged.
- CSV + PDF reworked to the same unified, signed layout; PDF totals show
  Income / Costs (negative) / Result.
- i18n: en/de (frontend) + pdf-i18n (en/de real; fr/nl/pt/ru English-fallback,
  flagged for native review).

Build + node --check + JSON parse green.
2026-06-15 17:30:55 +02:00
Luca ab65a470a0 fix(accounting): tax report cost side queried a non-existent column
The tax-report cost query selected inbound_documents.description, but that
column only exists on the 'expenses' table — inbound_documents has none. On
Postgres this threw 'column inbound_documents.description does not exist',
so the whole cost side failed with 'Costs could not be loaded'.

Use inbound_documents.invoice_number (an existing column, same descriptor
ledgerService surfaces) as the cost-row label instead. Expense rows still
use their real expenses.description column.
2026-06-15 16:58:44 +02:00
Luca 402dbde0a1 Merge origin/beta into feat/accounting-inbound-invoices
Resolves the 7 feature-flag / i18n conflicts (accounting flags vs upstream's
Project Overview 'projects' flag, both registered in the same files) as
additive unions — accounting + incomingInvoices + expenses AND projects all
coexist. Migrations slot cleanly: projects 117-121, accounting 122-129, no
collisions. Frontend build + backend node --check pass.
2026-06-15 16:37:23 +02:00
Paul Nothaft 539f93551a Merge pull request #618 from Luca-Timo/fix/maintenance-locks-out-admin-login
Enabling maintenance mode locks every admin out of the panel
2026-06-14 00:06:06 +02:00
Paul Nothaft b757235b1a Merge pull request #619 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-backend (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and Push Docker Images / merge-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
chore(beta): release 3.61.0-beta.0
v3.61.0-beta.0
2026-06-14 00:05:54 +02:00
github-actions[bot] bd07fc474d chore(beta): release 3.61.0-beta.0 2026-06-13 21:47:18 +00:00
Paul Nothaft 58f93ae713 Merge pull request #616 from Luca-Timo/feat/crm-improvements
feat(projects): Project Overview cockpit — link (multiple) quotes/contracts/hours into projects
2026-06-13 23:46:51 +02:00
Luca fdde4696e7 fix(maintenance): never block /admin/* with the maintenance screen
The previous wrapper gated /admin/* on a /auth/session check and only showed
the panel when an admin session was detected. Two failures:

1. The session check effect depended on `isAdminRoute` (a boolean), so the
   client-side login → dashboard navigation (both /admin/*) never re-ran it.
   hasAdminSession stayed stale-false from the logged-out /admin/login render,
   so a freshly logged-in admin landed on the maintenance screen anyway.
2. It also hid /admin/login itself (the catch-22).

Fix: the maintenance screen only blocks customer/gallery/public routes —
/admin/* is never blocked. The admin auth layer already handles access
(AdminLayout redirects a logged-out admin to /admin/login), so no session
probe is needed here. Removes the fragile /auth/session dependency entirely.

Backend skipPaths (/api/auth/admin/login + /api/auth/session) stays: login and
AdminAuthContext's token validation must still work during maintenance.
2026-06-13 14:18:02 +02:00
Luca 249313072b fix(maintenance): enabling maintenance mode no longer locks admins out
Turning on maintenance mode locked out every admin — including ones already
logged in — with no way back in from the browser. Two causes:

1. Backend (middleware/maintenance.js): the skipPaths allow-list pointed at
   /api/admin/login and /api/admin/auth/login, but the real admin auth routes
   live under /api/auth (POST /api/auth/admin/login, GET /api/auth/session).
   So during maintenance both the login POST and the session check 503'd. The
   503 on /auth/session made the frontend read every admin as logged-out, and
   also tripped the axios interceptor that force-enables maintenance globally.
   Fixed the allow-list to the actual endpoints.

2. Frontend (MaintenanceWrapper.tsx): the maintenance screen rendered over
   every /admin/* route unless an admin session already existed — covering the
   /admin/login page itself. A logged-out admin could never reach the form to
   get a session (catch-22). /admin/login is now always allowed through.

With both: a logged-in admin keeps working (session check passes), and a
logged-out admin can reach /admin/login and sign back in, all while
maintenance mode correctly blocks customers.
2026-06-13 13:57:21 +02:00
Luca f74d8d4e8c fix(projects): "one customer matches" rule for deal-lineage attach
Single-customer projects, but content is addable whenever ONE of its customers
is the project's customer (not only when the first lineage customer equals it):

- linkDealToProject: collect ALL customers across the deal's quote/contract/
  invoice lineage and reject only when none is the project's customer. Mirrors
  the events path, where a multi-customer event already attaches if any of its
  customers matches. Adoption onto an empty project unchanged.
- assignDocument: a document carries one customer, so equality stays correct;
  message aligned with the lineage check.
2026-06-13 13:12:58 +02:00
Luca 4b1e85c855 fix(projects): enforce single-customer projects (guard event attach + re-label)
A project must stay tied to one customer. The quote/contract/hours attach
paths already rejected a foreign customer (equality on project.customer_account_id);
the two remaining holes are closed here:

- assignEvent: an event may only join a project that shares its customer. The
  event's customer(s) come from event_customer_assignments; a customer-assigned
  project rejects an event for a different customer (PROJECT_CUSTOMER_MISMATCH),
  and an empty project ADOPTS a single-customer event's customer. This is why
  a foreign-customer event could previously be attached.

- updateProject: re-labelling a project to a customer that conflicts with the
  events/quotes/contracts it already holds is rejected (clearing to null is
  still allowed), so the customer can't be swapped out from under existing
  content.

Frontend: the cockpit attach-event action surfaces the translated mismatch
message; projects.error.customerMismatch reworded to read for both documents
and events (de + en).
2026-06-13 13:09:55 +02:00
Luca 9d13880f2b fix(projects): address review — cross-customer guards + email/queue hardening
Resolves the two blockers and the actionable concerns/nits from review.

Blockers (cross-customer leak):
- linkDealToProject: collect the deal's customer + events BEFORE any write,
  then reject a cross-customer link with PROJECT_CUSTOMER_MISMATCH (422) before
  re-pointing events/quotes/contracts or adopting a customer. The editors set
  project_id via quoteService/contractService → linkDealToProject (not
  assignDocument), so the guard lives at that chokepoint. Null-project adoption
  ("first deal wins") preserved as intended.
- assignDocument: boundary guard mirroring customerHoursService, defense-in-depth
  ahead of the cascade.
- Frontend: translated PROJECT_CUSTOMER_MISMATCH (projects.error.customerMismatch,
  de+en) wired into HoursSection + quote/contract editor onError (concern 5).

Concerns:
- 1: processEmailQueue gains an onlyId option; cockpit "send now" scopes the
  flush to the single row so it can't force-retry other dead-lettered emails.
- 2: resendEmail re-stringifies email_data when PG returns a parsed object,
  matching the canonical enqueue — no jsonb double-encode.
- 3: cockpit email feed scoped to the project's own document numbers (event_id
  for gallery mails; email_data doc-number match for CRM mails) instead of the
  recipient string — a shared inbox no longer leaks another customer's mail.
- 4: migration 117 backfill wrapped in a transaction (adds atomicity on SQLite,
  where the runner does not wrap; PG already wraps the whole migration).
- 6: resend/cancel/retry/sendNow now logActivity uniformly (project_email_*),
  adminId threaded from the route.
- 8: validator optional({ values: 'null' }) → optional({ nullable: true }).
- 9: pre-121 list valuation falls back to customer-scoped quotes so the list
  isn't all-zero during the upgrade window.

Nits:
- milestone selection uses Array.at(-1); removed redundant in-loop require in
  emailProcessor; clarifying comments for the list/detail perms split and the
  count-vs-value (0 vs em-dash) convention.
2026-06-13 11:57:32 +02:00
Luca 3b70a09773 fix(accounting): 'Save & mark paid' actually pays; incoming invoices appear in tax/export
#1 Triage 'Save & mark paid' now marks the invoice paid directly (categorize +
   markInboundPaid with the entered reference) instead of opening the pay dialog
   and leaving it unpaid. Removed the PayModal chain.
#2 Cost side missed captured incoming invoices: the query required
   currency='CHF', but email/upload invoices often have a null currency →
   silently excluded. Now include null-currency rows (treated as the report
   currency). Also replaced COALESCE(invoice_date, created_at) with a split
   date filter (invoice_date BETWEEN, else created_at range) to avoid the
   mixed date/timestamp comparison risk on Postgres. Same fix in the ledger
   export (buildPostings).
en/de: categorizedPaidToast.
2026-06-12 18:20:55 +02:00
Luca 663daf50ff fix(accounting): always show Income/Costs/Result summary on tax page (even with zero costs)
The Einnahmen-Ausgaben summary only rendered when costs existed, so a period
with no incoming invoices/expenses looked revenue-only. Now it shows whenever
the cost side loaded successfully (costs default to 0 → Result = Income), so
the income/result is always visible. Still hidden when the cost side errored
(the amber banner covers that case).
2026-06-12 17:57:54 +02:00
Luca bbceac6cb0 ci: make GHA cache export non-fatal (ignore-error=true)
The frontend/backend image builds + pushes succeed, then the final
'exporting to GitHub Actions Cache' step intermittently fails with
'error writing layer blob: not_found' (a known flaky type=gha cache backend
issue), failing the whole job. Add ignore-error=true to every cache-to so a
cache-write hiccup can't break an otherwise-successful, already-pushed build.
2026-06-12 17:56:06 +02:00
Luca 9f8511114a fix(accounting): tax report degrades gracefully if cost side fails (+ surface the error)
The cost side is supplementary — it must never 500 the core revenue report.
getTaxReport now wraps loadCosts in try/catch: on failure it returns empty
costs + a costsError string and logs the real error. The tax page shows the
revenue report plus a non-fatal amber banner with the cost-side error message,
so the actual cause is visible in the UI instead of an opaque 500.
2026-06-12 17:43:42 +02:00
Luca ea8f6bc88a fix(accounting): tax report 500 on Postgres — drop SQL date() from cost queries
The #4 cost side used 'date(COALESCE(invoice_date, created_at)) BETWEEN ...'
and 'date(created_at) BETWEEN ...'. The mocked unit tests never execute the
SQL, so the Postgres failure (date()/COALESCE(date,timestamp)) slipped through
and surfaced as a 500 on the live tax report. Replaced with plain range
comparisons (col >= from AND col <= '<to> 23:59:59.999') — valid on both PG and
SQLite, inclusive of the whole end day. Same fix applied to ledgerService
buildPostings (the Treuhänder export would have 500'd identically).
2026-06-12 16:57:34 +02:00
Luca 9514f5cb8e fix(accounting): distinguish Categorized (purple) from Paid (green)
Both badges were green; recolor the 'categorized' status to purple so the
status (categorized) and payment state (paid) read distinctly.
2026-06-12 16:41:59 +02:00
Luca 5fcb96c723 fix(accounting): PDF pager always shown, click categorized→pay, drop duplicate Paid chip
#1 DocumentPreview renders the page pager for every PDF (disabled at the ends),
   not only multi-page ones — so the control is visible on single-page invoices.
#2 Clicking a categorized (unpaid) invoice opens the Mark-paid dialog; new →
   categorize, paid/declined/duplicate → view.
#3 A paid row no longer shows two 'Paid' chips — the front badge is the status,
   and the right action becomes a quiet 'Mark unpaid' (revert).
2026-06-12 16:41:06 +02:00
Luca 72b784c9d7 feat(accounting): incoming-invoice triage refinements (paid badge, click-to-categorize, reference, categorize+pay)
#1 Row status reads 'Paid' (green) once supplierPaid — no longer the stale
   'categorized' badge.
#2 Clicking a new (unsorted) invoice opens the Categorize modal; sorted ones
   still open the read-only view.
#3 Triage gains a Payment reference field (persisted via updateInbound →
   payment_reference).
#5 Triage has two actions: 'Save' (categorize only) and 'Save & mark paid'
   (categorize, then chain into the mark-paid dialog with the reference
   prefilled).
#4 (mark-paid PDF nav) was already present via DocumentPreview — no change.

en/de strings added.
2026-06-12 16:27:29 +02:00
Luca c36797db2d fix(email): log all received mail, not just unseen (90-day lookback + dedup)
Cause of 'not all received emails listed': the poller fetched {seen:false}
only, so any message already read in another client was never pulled or logged.

Now the poller scans a LOOKBACK_DAYS (90) window regardless of \Seen via a
cheap envelope-only pass, dedups by message-id against received_emails, and only
downloads + processes (fetchOne source) messages not yet logged — so the
Received tab is complete while each poll stays light. Marks processed messages
seen; re-checks the parsed message-id before insert.
2026-06-12 16:06:40 +02:00
Luca cee692c10a fix(accounting): lock company-expense to company, first-page categorise preview, auto-refresh inbox
#1 Incoming-invoice triage: 'Company expense' (eigener_aufwand) no longer shows
   the event picker — it always books to the company (removed from
   BOOKING_DISPOSITIONS, so categorize sends event_id null).
#2 Auto-refresh: AccountingInboxPage + ReceivedEmailsPanel poll every 30s
   (refetchInterval) so background IMAP ingests appear without a manual reload.
#3 DocumentPreview defaults to the FIRST page (invoice header) for triage/view;
   PayModal opts into the LAST page (Swiss QR-bill) via initialPage='last'.
2026-06-12 15:43:21 +02:00
Luca 9c18dcf377 fix(email): always log incoming mail to received_emails (was lost on insert error)
Symptom: an emailed attachment landed in Incoming invoices but the message
never appeared under Received emails. The attachment is saved BEFORE the
received_emails insert, so any throw there left the audit row unwritten and
silently swallowed.

- coerce a malformed Date: header (Invalid Date) to now — it would otherwise
  throw on the Postgres timestamp insert (most likely root cause)
- isolate each attachment in its own try so one bad file can't skip the audit
- truncate from_address to the column width; persist attachment errors + an
  'error' status so partial failures are visible
- log loudly when the received_emails insert itself fails (no more silent loss)

Self-healing: the stuck message was never marked \Seen, so the next poll
re-processes it and writes the row.
2026-06-12 15:14:16 +02:00
Luca 8a54c6f6b1 fix(email): fail-fast IMAP timeouts + manual 'Check now' poll
- Root cause of the 502s: ImapFlow had no connect timeout, so a wrong host/port
  (e.g. IMAP on an SMTP port) hung the request until the proxy returned 502 with
  no message. Added connectionTimeout/greetingTimeout/socketTimeout + a hard
  connectWithTimeout() race on every IMAP client (detect/test/roundtrip/poll).
- Error routes now return 422 with the underlying reason (was 502, which
  collided with the proxy's own 502 and hid the message).
- New 'Check now' button + POST /incoming-config/poll runs the poller on demand
  (respects the incomingMail flag) and reports disabled/unconfigured/busy or N
  ingested — so 'nothing in Received' is diagnosable without waiting 60s.
- en/de strings
2026-06-12 15:08:12 +02:00
Luca e258472391 fix(email): guard round-trip test when IMAP username isn't an email
The round-trip recipient is imap_user (not hardcoded). Some hosts use a
non-email IMAP login — guard against silently sending to a bogus address:
return a clear 'recipient_not_email' error explaining to use a mailbox whose
username is its email, or test connection + manual send instead.
2026-06-12 14:54:18 +02:00
Luca 04be51a008 feat(email): round-trip test — send via SMTP to the IMAP mailbox and confirm arrival
- emailIntakeService.roundTripTest(): sends a uniquely-tagged email through the
  saved SMTP config to the IMAP mailbox (imap_user), then polls IMAP up to 30s
  for that subject token; deletes the test message on arrival so it never hits
  the accounting inbox. Returns {ok, seconds, recipient} or a typed reason.
- route POST /admin/email/incoming-config/roundtrip (email.send)
- IMAP card: 'Round-trip test' button beside 'Test connection' + Save; toast
  reports recipient + delivery time. Distinct reasons mapped (smtp/imap
  unconfigured, send_failed, not_received→504).
- en/de strings
2026-06-12 14:52:14 +02:00
Luca f017649bd5 feat(email): add 'Test connection' to incoming mail + tidy IMAP label
- emailIntakeService.testConnection(): logs in, opens the configured folder,
  reports message/unread counts (non-destructive). Accepts current form creds
  so it works before saving; masked password falls back to stored.
- route POST /admin/email/incoming-config/test
- IMAP card: 'Test connection' button beside Save; toast shows folder + counts
- capitalize 'IMAP Host' label to match 'SMTP Host'

Note: incoming uses IMAP (receiving) vs outgoing SMTP (sending) — genuinely
different servers/credentials, hence the distinct field set (Folder; no From).
2026-06-12 14:47:14 +02:00
Luca d04a6978e9 fix(email): IMAP Security dropdown matches outgoing — no port in label, manual port
Reverts the auto-fill; drops the (993)/(143) from the Security option labels so
incoming behaves exactly like the outgoing SMTP card (plain SSL/TLS vs
STARTTLS, port set manually).
2026-06-12 14:41:49 +02:00
Luca bd402d2e89 fix(email): IMAP Security dropdown auto-fills the conventional port
Selecting SSL/TLS sets port 993 and STARTTLS/none sets 143, so the port in
the dropdown label is no longer just decoration. A non-standard custom port
(anything other than 993/143/empty) is left untouched.
2026-06-12 14:39:09 +02:00
Luca fb48ba4cb4 fix(email): mark required fields on incoming mail to match outgoing SMTP
The IMAP card was restyled to match SMTP but didn't carry the required-field
markers. Aligned the required set (protocol differences kept):
- red asterisks on Host *, Port *, Username * (SMTP marks Host/Port/From-Email;
  IMAP has no From-Email but always needs a login)
- client-side guard mirroring handleSaveSmtp (block save without host/port/user)
- backend POST /incoming-config now requires imap_user (the poller's
  getImapConfig returns null without it)
- en/de requiredFields string
2026-06-12 12:33:04 +02:00
Luca abb23f01c7 fix(email): match IMAP card to SMTP styling + auto-detect mailbox folders
- IncomingMailConfigCard rebuilt to mirror the outgoing SMTP card: Card
  padding=md, icon inputs (Server/User/Lock), password eye toggle, stacked
  full-width fields, full-width primary Save button
- Folder is now a dropdown auto-populated by a 'Detect' button instead of a
  free-text path: backend emailIntakeService.listFolders() lists IMAP
  mailboxes (POST /admin/email/incoming-config/folders, accepts current form
  creds, masked password falls back to stored); UI auto-selects the inbox
  (special-use) folder
- en/de strings added
2026-06-12 01:11:31 +02:00
Luca 7e0098edcd feat(accounting): Layer A frontend — chart of accounts CRUD + Treuhänder export UI
- ledger.service.ts: accounts/VAT-codes/mappings CRUD + export client
- ChartOfAccountsPage: full CRUD for the Swiss/LI KMU chart + MWST codes,
  category→account mapping, default/system accounts + tax-treatment/rate→VAT
  maps, with a 'guideline only' note
- LedgerExportPage: period + currency + target tool (generic/Banana/bexio)
  → collective-journal CSV download, with accrual-only + Treuhänder disclaimer
- AccountingLayout: 'Treuhänder export' (taxReport flag) + 'Chart of accounts'
  (accounting flag) sub-nav entries; App routes wired
- en/de translations (accounting.taxTreatment.* enum + ledger.* namespace)

de/en authored natively; no machine-translated locales touched here.
2026-06-12 00:48:39 +02:00