Adds the workflows.* block (list, approvals inbox, canvas editor) to en.json
and de.json so the Workflows UI no longer renders English inline fallbacks
under a German UI. DE authored natively.
Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.
NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
Boot self-heal seeds the corrected gate-in-loop dunning graph (wait→due,
grace wait, invoice_paid check, confirm-no-payment gate, bounded reminder
loop with re-check, final notice) keyed on builtin_key='invoice_dunning',
sized from the reminder_first/second_days settings. Seeded DISABLED and
is_builtin: live reminder behaviour is UNCHANGED (the hardcoded scheduler
ladder still runs) — enabling it pre-cutover would double-send, so the
engine cutover is a deliberate follow-up. Idempotent (preserves admin edits).
Built-ins refuse delete (enforced in the CRUD route). Test covers seed shape
+ idempotency.
GET/POST/PUT/PATCH/DELETE /api/admin/workflows with graph read/write (PUT
writes a fresh node/edge set under version+1 and bumps workflows.version so
in-flight runs keep their pinned version). Run-history (/:id/runs,
/runs/:runId/steps) and the pending-approval inbox (GET /approvals,
POST /approvals/:id/:action → actById) round it out. Gated by the workflows
flag + RBAC (view for reads, manage for writes); built-in flows refuse
delete; graph validated (exactly one trigger, unique keys, edges reference
known nodes). Route tests cover CRUD, validation, version bump, toggle,
inbox, and the 403 permission gate.
gate_setup action creates a workflow_approvals row (single-use token stored
as SHA-256 hash) and emails the admin confirm/deny links immediately
(internal mail, no business-hours floor). actByToken / actById finalize the
approval and resume the run down the matching confirm/deny edge; both are
idempotent (a second click → 'already recorded') and respect expiry. Public
GET /api/public/workflow-approvals/:token/:action returns a small HTML
confirmation page (clickable from email, single-use so prefetch can't
double-act). listPending backs the webview inbox (wired in the CRUD phase).
Test covers gate→approval→email→token-confirm→resume + idempotency.
Wires the workflow event bus into the hot paths, AFTER each commit:
- invoiceService.sendInvoice → invoice.sent (idempotent per invoice id, so
overdue re-sends don't double-fire)
- invoiceService.markPaid → invoice.paid, only on the transition into paid
(transaction result captured so the emit runs post-commit, never rolling
back a recorded payment)
- quoteService.recordResponse / adminAcceptQuote / adminDeclineQuote →
quote.accepted / quote.declined via a shared emitQuoteEvent helper that
resolves the customer email for downstream send_email actions
All emits are best-effort and fail closed when the workflows flag is off.
Existing invoice/quote integration tests still green.
Adds send_email (INTERNAL/admin = immediate, EXTERNAL/customer = business-
hours floor via queueEmail's respectBusinessHours) and the invoice_paid
condition (paid_at / status / cumulative paid_amount). Registers the
prepare_quote/contract/event/gallery/invoice + send_document + reserve_date
document actions as recognized-but-not-yet-wired (record an observable
skipped step rather than crashing a flow). index.js side-effect-imports the
handlers. Tests cover the customer-mail routing + the invoice_paid logic.
Adds engine.runDueWaits() — polls waiting runs whose wake_at has passed and
resumes the ones parked on a wait node (gate timeouts handled later by the
approvals layer). Flag-gated (fails closed when workflows is off). Wired into
the existing hourly invoiceScheduler tick in its own try/catch so a workflow
failure never suppresses the invoice/reminder jobs. Test covers not-due vs
elapsed resume.
Graph executor that walks nodes/edges per run: trigger, condition/branch
(registered conditions → yes/no edge), bounded loop (counter in context +
maxIterations cap), wait (status=waiting + wake_at for the scheduler), gate
(status=waiting; resumed via confirm/deny edge), action/webhook (registered
handlers). emitWorkflowEvent creates one idempotent run per matching enabled
workflow (unique dedup_key) and fails CLOSED if the flag system is
unavailable; never throws into callers (safe to call after commit). Every
node records a workflow_run_steps row. Registry seeds primitive
conditions (always/never/expr) + actions (noop/log/set_context). Integration
test covers loop+wait resume, gate confirm, and dedup.
Adds the workflow engine's graph data model — workflows, workflow_nodes,
workflow_edges (versioned so in-flight runs keep their version),
workflow_runs (status/current_node/context, wake_at for the scheduler,
unique dedup_key for idempotency), workflow_run_steps (per-node audit),
and workflow_approvals (hashed email confirm/deny token + webview inbox).
Seeds workflows.view / workflows.manage and grants them to super_admin +
admin. Loose-FK integers per the whatsapp_queue/expenses convention;
idempotent hasTable guards + reversible down().
New opt-in 'workflows' master flag (default off) across the backend
KNOWN_FLAGS/DEFAULT_FLAGS and the frontend FeatureKey union, context
defaults, and a new Automation section card in the Features tab. Gates
the upcoming Workflows admin area and the engine runtime. en/de i18n
added (DE native).
PR #649 takes migration 137 (add_whatsapp_template_language). Renumber the
slideshow migrations to slot in after it:
- 137_add_slideshow_share.js -> 138_add_slideshow_share.js
- 138_add_slideshow_styling.js -> 139_add_slideshow_styling.js
and update the slideshow migration-number references in comments/types. No
content change — both are additive + addColumnIfNotExists-guarded, so re-running
under the new filename on an already-migrated DB is a safe no-op.
Each /state poll fired ~10 getAppSetting reads to resolve the watermark/fit;
a leaked link x N tabs amplified that linearly (review concern 2). Add a
5s-TTL cached bundle (utils/slideshowGlobals) for the global slideshow_* +
branding-logo settings, invalidated on PUT /admin/settings/slideshow so admin
live-edit stays instant. slideshowSettings now does ~2 reads per poll (event
row + photo count) on a cache hit. Also documents the frontend
optimistic-default nit.
The slideshow JWT reuses type:'gallery', so verifyGalleryAccess accepts it on
every gallery route — a leaked projector link could download (single/all/
selected), upload (when allow_user_uploads), or post feedback for up to ~12h,
beyond its display-only contract. Add a `denySlideshowToken` middleware (403
when req.accessLevel==='slideshow') after verifyGalleryAccess on those 5 routes.
The photo-display routes (/photos, photo/thumbnail/preview/hero) stay open — the
kiosk needs them. +4 tests mint a real slideshow JWT and assert 403. Docs note
that Regenerate/Disable isn't instant revocation (~12h) and the feature flag is
the hard cut-off.
- docs/live-slideshow.md: full feature guide (enable, generate link, run on a
projector, global Settings -> Slideshow defaults, per-event overrides, how
live updates work, security notes).
- README: Live Slideshow bullet under Key Features, a Live Events use case, and
a Documentation quick link.
25 tests over two files, using the integration test-DB helper (real sqlite,
all migrations):
- slideshowPublic: resolveSlideshow guards (feature-flag kill-switch -> 404,
unknown/null token, expired/draft/archived), the watermark cascade (global
look + per-event on/off + source->URL resolution + "null when no logo"),
image fit, and /session minting (token + cookie). Regression-guards the
app_settings reads (vs the nonexistent `settings` table bug).
- slideshowAdmin: generate/disable/regenerate, PATCH display + watermark mode,
feature-flag 403, no-token 401, and PUT /admin/settings/slideshow validation
+ clamping. Both generate and PATCH assert success despite events having no
`updated_at` column (the original 500).
The slideshow display preset (transition / interval / speed / color filter) was
set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single
picpeak-wide default in Settings -> Slideshow ("Default style for new
slideshows"). New events seed their show_* columns from this global preset
(was: from the event type's slideshow_preset); the per-event override is
unchanged.
- Removed event_types.slideshow_preset usage everywhere (EventTypeModal section,
eventTypes.service types, eventTypeService whitelist, adminEventTypes
validators/POST). The DB column from migration 138 is left inert.
- Global preset stored in app_settings (slideshow_interval_ms/transition/
transition_ms/colorfilter), saved via PUT /admin/settings/slideshow.
- adminEvents create-seeding now reads the global preset (getAppSetting) instead
of the event type.
- en/de: presetTitle + presetHint.
object-fit was hardcoded to 'cover', which crops portrait photos heavily. Add a
global `slideshow_fit` setting (Settings -> Slideshow): 'cover' fills + crops,
'contain' shows the whole image with black bars (no crop). Default 'cover'
(unchanged). Stored in app_settings (no migration), resolved server-side into
the slideshow settings + /state poll so a running projector picks it up live.
Disabling the `slideshow` feature previously only hid the admin UI — the public
/show/:token route ignored the flag, so already-minted links kept working. Gate
resolveSlideshow on isFeatureEnabled('slideshow') so every /session and /state
404s when the feature is off: clicking Start shows "link not active" and a
running projector stops within one /state poll. Belt-and-braces: also gate the
admin generate + settings PATCH endpoints with requireFeatureFlag so links can't
be minted/changed while off (disable stays open so stale tokens can be cleared).
The watermark look (logo / position / opacity / style) was configurable in three
places — the global Settings tab, the per-event-type preset, and the per-event
card. Consolidate it to ONE: the global Settings -> Slideshow tab. Per-event and
per-event-type now carry only the watermark MODE (inherit / on / off) — the
override structure — and render with the global look.
- New global "Size (% of screen)" control (slideshow_watermark_size, vmin-based)
so the logo can be scaled; resolved server-side into the watermark payload and
applied to the kiosk <img>.
- Backend slideshowSettings resolves the whole look from app_settings always;
per-event show_watermark only toggles enabled. adminEvents PATCH + type-preset
seeding no longer accept/seed per-event look fields; unused enums removed.
- Frontend SlideshowStyle drops the look fields (mode only); SlideshowStyleFields
watermark section is a single mode select with a "configured under Settings"
hint; SlideshowSettingsCard + Event type cleaned up.
- en/de: watermarkSizeLabel + watermarkModeHint.
(events.show_watermark_{source,position,opacity,style} columns from migration
138 are left in place but inert — the look is global now.)
- New `slideshow` feature flag (backend KNOWN_FLAGS/DEFAULT_FLAGS, frontend
FeatureKey + context default, a toggle card under Settings -> Features -> Core).
Default off; strictly opt-in.
- Move the global watermark defaults off the Event Types page into a dedicated
Settings -> Slideshow tab (new SlideshowSettingsPage), shown only when the flag
is on.
- Gate the per-event Live Slideshow card and the per-event-type preset section
behind the flag too (and stop writing a type preset when it's off).
- en/de strings for the feature card + settings tab.
The flash overlay had no base opacity and the keyframe animation has fill-mode
none, so after the first dip it reverted to opacity 1 and stayed opaque between
slides — hiding the image, then briefly revealing it on each advance. Set base
opacity 0, and swap the image at the flash peak so the cut stays hidden.
The slide <img> used maxWidth/maxHeight:100% with no width/height, so it
rendered at the photo's intrinsic size (e.g. the 1920px preview) and never
scaled up to the projector, leaving black bars all around. Pin the image to
100% x 100% and use object-fit: cover so it fills the whole page.
slideshowSettings used settingsService.getSetting, which queries db('settings')
- a table that does not exist in this app (globals live in app_settings). Every
GET /gallery/:slug/show/:token/session and /state therefore threw and returned
500 INTERNAL_ERROR once a valid token resolved. Switch to getAppSetting
(utils/appSettings), which reads app_settings where the slideshow_watermark_*
and branding_* values are actually written.
Log the failing request (status + body) to the console and show the backend
error message in the toast instead of a generic "Error", so failures are
diagnosable without server log access.
The events table has no updated_at column (only created_at, and no migration
adds one), so the slideshow generate/disable/settings endpoints 500'd with
'column "updated_at" does not exist'. Write only the show_* columns, and guard
the settings PATCH against an empty update.
Adds the slideshow.* block (transitions, color filters, watermark mode/style/
source, global defaults) and eventTypes.form.slideshowPreset labels in English
and German.
- per-event Live Slideshow card on the event detail page: generate/copy/
regenerate/disable the share link + live style (transition, timing, color
filter, watermark).
- shared SlideshowStyleFields, reused by the per-event card and the per-event-
type preset section in the Edit Event Type modal.
- global watermark default card on the Event Types page (Settings -> slideshow).
- WatermarkSourcePicker: visible logo tiles with previews (light logo / dark-mode
logo / favicon / event logo) instead of a blind dropdown.
- watermark mode tri-state (inherit/on/off) + white-vs-original style.
- supporting service methods + Event/EventType types.
- /gallery/:slug/show/:token route + SlideshowPage: splash -> fullscreen kiosk,
crossfade/cut/slide/kenburns/dip-to-white/dip-to-black transitions, color
filters, white/original logo watermark overlay, contain/letterbox, cursor
auto-hide, quiet-append of new uploads, live settings poll, and decode-ahead
preload (first slide decoded before playback) so transitions do not struggle.
- slideshow.service for session/state + shared style types.
- public GET /gallery/:slug/show/:token/session (validates token, mints a
slideshow-scoped gallery JWT + sets the per-slug cookie so <img> requests
authorize) and /state (cheap settings + photo-count poll). Reuse /photos for
the list; skip the view-log for slideshow access so the kiosk does not pollute
visitor analytics.
- admin slideshow link generate/disable + live style PATCH on events.
- event-type slideshow_preset whitelisted in CRUD; create-event seeds the new
event's show_* columns from the type preset.
- global watermark defaults via PUT /admin/settings/slideshow; watermark cascade
(global default -> per-event override) resolving the light/dark/favicon/event
logo url.
LineItemsTable's live preview did `Math.round(subtotal * vatRate) / 100` where
subtotal is in major units and vatRate is a fraction (0.081) — rounding to whole
units before the /100 divided the VAT by 100 (CHF 0.63 instead of 63.18). Add the
missing *100 inside the round so it rounds to cents. Backend computeTotals + the
PDF + the tax report were always correct; this preview-only bug just surfaced now
that new invoices seed a non-zero default VAT code instead of 0%.
Two pre-existing HIGH bugs surfaced by the codebase audit (accounting surface):
- taxReportService: income totals excluded only `status='cancelled'`, never
`kind='storno'`. A Storno (status='sent', amounts stored negative) netted into
the totals on top of the already-excluded cancelled original → double-subtract,
so a cancel-and-reissue read as 0 income instead of the reissued amount.
Now exclude storno rows from grandTotal*/byRate (kept visible in the row list).
Regression test reproduces the real cancel→storno→reissue 3-row flow.
- customerHoursService.buildLineItemFromEntry: `String(entry.entry_date).slice(0,10)`
on a `date` column → Postgres returns a JS Date, baking "Wed Apr 06" into the
invoice line + PDF (SQLite returns the bare string, so SQLite-only tests pass).
Normalise via the Date branch like every other date read.
- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
longer auto-classifies every supplier — incl. the admin's own domestic one —
as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
so they aren't billable to an anonymized account. (NB: picpeak has no hard
customer delete — erase anonymizes in place — so the orphan/404 premise can't
occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
(the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
/hourly + profileFields.title/savedToast).
- Box 1 "Default rates": mileage, daily allowance, hourly rate, require-proof.
Hints now make the cost-vs-billing split explicit (daily allowance = expense,
hourly = billing fallback).
- Box 2 retitled "VAT": registration, reclaim, default invoice VAT code, and
the VAT label (moved out of its own card).
- Drop the third card (AccountingProfileFields deleted); the two Save buttons
become one — it persists both the app_settings and the two business_profile
fields (VAT label + hourly rate) together.
- Rename "Per-diem" → "Daily allowance" (EN) for clarity; German keeps the
established "Spesenpauschale".
VAT supplier-country reclaim default:
- Migration 134 adds inbound_documents.supplier_country.
- categorizeInbound auto-derives tax_treatment via resolveTaxTreatment:
explicit treatment wins; else country in the reclaim list → domestic,
outside it → foreign_vat_non_reclaimable, unknown → domestic. Consumes the
previously-stored-but-unused accounting_vat_reclaim_countries.
- Triage modal gains a Supplier country dropdown (saved via updateInbound).
+5 unit tests for resolveTaxTreatment.
Configurable default output VAT code for new invoices:
- New accounting_default_output_vat_code setting (PUT wired; getSettings/type).
- Settings → Accounting dropdown to pick it.
- Invoice + quote editors seed their VAT picker (rate + code) from it on a
blank new document — skipping edits/conversions, never clobbering a touched
value. New docs no longer silently start at 0%.
i18n en + de.
logActivity writes via the global db; called inside a db.transaction it
deadlocks against the held write lock on a SQLite-backed install (a second
write connection blocks). Stage the audit info inside each transaction and
fire it AFTER commit in createEntry / updateEntry / deleteEntry /
billUnbilledEntries — same fix already applied to expenseService. Return
shapes unchanged. (The monthly/billing paths still route through
createInvoice, whose own internal logActivity remains the shared root
limitation — tracked in feedback_sqlite_global_write_in_transaction.)
Invoice VAT config (codes + label) and the hourly rate now live under
Settings → Accounting, so an install with Invoices must have Accounting
available.
- applyDependencyRules (backend adminFeatureFlags.js + frontend
FeatureFlagsContext.tsx): bills on → accounting on, before the
accounting→children rule so the sub-features keep their own state.
- Migration 133 corrects existing installs: set the STORED accounting=true
where bills is on. requireFeatureFlag('accounting') reads the raw row, so
without this an upgraded install (invoices on, accounting off) would show
the tab but 403 its endpoints. Idempotent; only flips on; no down.
- Features tab: the Accounting card shows locked-on (disabled + hint) while
Invoices is enabled.
Also includes the i18n keys (en/de) for the VAT/financial settings move.
- Remove the orphaned "Default VAT rate %" from Business profile; the rates
are the Accounting VAT codes. The invoice/quote VAT picker (VatRateSelect)
is now code-only — options are exactly the Accounting output codes, no
free-text custom rate. Off-list legacy values on existing invoices are
preserved as a read-only "(not configured)" option so issued documents
aren't silently changed.
- Move VAT label + default hourly rate to the Accounting tab (new
AccountingProfileFields card; storage stays on business_profile, own save).
Wire vat_label onto the PDF VAT-line label via the issuer block (covers
invoices + quotes), falling back to the locale default when blank.
- Default currency stays on Business profile but becomes a normalizing
dropdown (an old free-text "chf" auto-selects "CHF"; unknown values
preserved). Add a moved-note callout. Strip the moved fields from the
Business-profile save so it can't clobber an Accounting-tab edit.
- 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).
- Add a per-disposition info line under the Disposition dropdown so re-bill
vs pass-through vs company expense is clear in-context (en + de).
- Markup is a re-bill concept only: the control now renders solely for
rebill, and a pass-through always bills at cost. Enforced server-side too
(categorizeInbound applies markup only when disposition === 'rebill').
- Clarify "Book to" with a hint — it attributes the supplier cost to an
event in the tax report / ledger export, separate from who you re-bill to.
Address three incoming-invoice issues:
1. Re-categorization: a categorized invoice can now be changed again (e.g.
passthrough → company expense). New "Re-categorize" button pre-fills the
triage modal from the existing disposition/customer/markup/note.
categorizeInbound is re-runnable — it unwinds any prior re-bill line
(removes the invoice line + recomputes totals) before applying the new
disposition, and refuses (INVOICE_LOCKED) when the re-bill is on an
already-issued invoice.
2. Note field: new `note` column (migration 132 — 126 is already on beta)
captured in triage and shown in the read-only view.
3. Re-bill like hours: rebill/passthrough now persist customer_account_id.
Per-event customers accumulate as PENDING items, surfaced in a new
"Pending re-bills" card and bundled into one invoice via "Bill these"
(mirrors unbilled-hours billing). Monthly/manual customers keep
auto-consolidating onto their running draft. Passthrough (durchlaufend)
can now also attach to a customer with optional markup.
Adds backend unit tests for buildInboundLineItem + isInvoiceMutable and
en/de translations (other locales fall back to English defaults).
The scope <select> inherited `w-full` from the shared selectClassName, so it
stretched the whole row on its own line (the ledger-format select overrides it
with w-auto; this one didn't). Give it `w-auto min-w-[140px]` and wrap it in an
inline "Scope" label so the Report row reads compactly as
"Scope [Complete ▾] [Export CSV] [Export PDF]", consistent with the journal row.
- New "For Studios — CRM & Accounting (Beta)" subsection under Key Features
(quotes→contracts→invoices+Storno, hours/calendar, inbound supplier invoices +
expenses, tax report + Treuhänder/Banana export, VAT) and updated the Roadmap
beta-table row to "CRM & Accounting Module".
- Broadened the disclaimers section to CRM & Accounting and added a Tax/VAT
bullet: figures are guidance only + jurisdiction-specific (e.g. the LI 20%
Gewinnungskosten flat rate), and every operator must verify their own tax/VAT
regulations with their accountant/Treuhänder/tax authority before relying on
any figure or export.
- Updated the @Luca-Timo contributor entry with a concise CRM + accounting credit.
Closes the test gaps from the PR #622 work + the export-scope feature:
- export scope: scopeLedger/normalizeScope (exported via _internal) unit tests +
renderTaxReportCsv income/cost/all output assertions (income drops supplier
rows, cost drops invoice rows, filename gets the scope tag).
- isUniqueViolation: Postgres 23505 / SQLITE_CONSTRAINT / "UNIQUE constraint
failed" message, false for FK + nullish (the IMAP claim-first race detector).
- getRenderedPagePath: out-of-range pages reject with PAGE_OUT_OF_RANGE before
touching pdftoppm/disk (the per-file resource bound).
Adds a Complete / Income only / Cost only selector to the readable PDF + CSV
export (the on-screen report stays complete). Income-only emits just the
outgoing rows + the income summary line (+ the per-rate breakdown in the PDF);
cost-only emits the incoming-invoice + expense rows + the cost line and drops
the income-by-rate breakdown. Useful in Liechtenstein where, under the income
threshold, a flat 20% Gewinnungskosten deduction is sometimes better than actual
costs — handing the Treuhänder just the income (or just the cost) basis is
cleaner.
Backend: renderTaxReportPdf/Csv take a `scope` param (all|income|cost) that
filters report.ledger by row.type + the summary lines; the /pdf + /csv routes
accept & validate `?scope=`; filenames get an income_/cost_ tag. Frontend:
scope <select> beside the export buttons, threaded through buildQueryString.
i18n en/de. The 20% calculation itself is intentionally NOT in-app (applied by
the Treuhänder) per the scoping decision.
1. Remove the committed test artifact backend/storage/business-docs/quote/2026/
Q-2026-0001.pdf and gitignore backend/storage/business-docs/ so generated CRM
docs can't be committed again.
2. adminLedger + adminExpenses dropped their local requireFlag copies and now
import the shared (now cached) requireFeatureFlag middleware.
4. roundTripTest polls IMAP with ×1.5 backoff (cap 8s) instead of a flat 3s, so a
30s test takes ~5 SELECT/SEARCH locks not ~10 (some servers throttle).
Nit 3 (dashboard + events pages still on the gallery-theme vars, not dark-mode-
swapped) is left as a documented follow-up per the review.
1. requireFeatureFlag now caches each flag for 10s (the accounting area is 10+
gated endpoints); PUT /admin/feature-flags invalidates the cache so toggles
still take effect immediately.
2. Customer routes (/quotes, /invoices, /contracts + their PDFs) now gate via
getEffectiveFeaturesForCustomer — the global MASTER flag AND the per-customer
override — instead of the per-customer column alone, via a shared
customerFeatureAllowed() helper. Admin disabling a feature globally is now
honoured for customers too.
4. Tax-report VAT-payable: when accounting_vat_registered is UNSET, stop guessing
from grandTotalVat>0 (a zero-output-VAT quarter silently flipped to "not
registered" and hid the reclaim). Treat null as "not configured":
vatPayableMinor=null + vatRegistrationConfigured=false; the UI renders "—" and
a "configure VAT registration" warning. Tests updated.
5. Shared upsertAppSetting() in utils/appSettings — the two adminSettings upsert
loops use it, so the app_settings created_at class can't be re-introduced.
6. PDF rasterise per-file bound: getRenderedPagePath refuses pages beyond
MAX_RENDERABLE_PAGES (200); page_count is capped to match at ingest, so a
hostile high-page PDF can't drive an unbounded pager.
7. (no code) original_filename is only rendered via auto-escaped JSX; the two
dangerouslySetInnerHTML sites are admin-authored content — paranoia pass clean.
Concerns 3 (foreign-VAT reclaim-country) and 8 (imap_pass plaintext) are PR-reply
/ doc items, addressed in the PR response, not code.
Blocker 1 — CSV/Banana formula injection. Neither csvEscape (ledgerService) nor
the tax-report CSV escape nor the unquoted tab-separated Banana cell formatter
prefixed risky leading chars, so an admin-/sender-controlled cell beginning with
= + - @ TAB CR executes as a formula when the Treuhänder opens the export. New
shared util neutralizeSpreadsheetFormula() prepends a single quote; wired into
all three sinks (quoted CSV + unquoted Banana). Unit test pins one of each char.
Blocker 2 — IMAP intake double-ingest race. received_emails.message_id was
INDEX, not UNIQUE, and the poller ingested attachments BEFORE writing the audit
row, so a second replica / rolling-deploy overlap double-ingested the same mail.
Migration 128 makes message_id UNIQUE (nulls stay distinct); the intake now
CLAIMS the message row (status='processing') BEFORE ingesting — a concurrent
claim hits the unique constraint and skips cleanly (shared isUniqueViolation
helper). Stale 'processing' rows (worker crashed mid-ingest) are reclaimed after
10 min so no attachment is orphaned.
NOT done (deliberate): the suggested UNIQUE on inbound_documents.file_sha256 —
that column is a SOFT dedup key by design (manual re-uploads are kept as flagged
'duplicate' rows + duplicate_of_id for the Duplikat disposition); a unique index
would break that feature. The file race only yields an extra 'unsorted' row (a
data-quality nit, caught by the existing manual Duplikat backstop), not a
double-count. Rationale to be added to the PR reply.
Reworks the previous force-mode UX per the intended model:
- Branding page IS the global preset — keep presets, colors, fonts and style
fully visible. The only change under a force lock: hide the redundant
per-theme Color Mode picker (light/dark is the Force control), with a hint.
- Force only locks light/dark again: reverted applyForceColorMode to swap the
surface palette only — it no longer resets typography/style, so the branding
fonts/style always apply.
- Per-event GALLERY theme editors now receive the global force value and, when
a lock is active, hide the colour pickers AND the light/dark picker (a gallery
can't override the site-wide lock). Presets/fonts/layout stay. Force off →
everything returns.
Wiring: CreateEventPage + EventDetailsPage pass
forceColorMode={publicSettings?.branding_force_color_mode} (value only, no Force
control). Branding keeps both the value and the onForceColorModeChange handler,
which is how the component tells the two contexts apart. i18n en/de.
Follow-up to the force-mode change: hide ALL gallery theme customization while
a force light/dark lock is on, not only colors + typography. Theme presets,
gallery layout, header style, controls style, the colour pickers (incl. accent),
Typography & Style, CSS templates, the PDF-typography slot and event custom CSS
are all hidden — only the Force color mode control (with an explanatory note)
and the Reset/Apply actions remain. Accent brand colours still apply to the
gallery; their picker is just hidden while the lock is on. Turning the lock off
restores the full customizer. Note text + i18n (en/de) updated.
When a force light/dark lock is active it now means "use the clean standard
look": applyForceColorMode also resets typography & style (fonts, size, corner
radius, shadow, background pattern) to defaults — on top of the surface/text
palette it already swapped — so those settings genuinely don't apply while the
lock is on. Accent brand colours and the structural cards (header/controls/
gallery layout/hero divider) are preserved. Override-only: the saved theme keeps
the admin's custom values, so turning the lock off restores them.
In the theme customizer, when a force mode is active, hide the now-dead controls
to avoid confusion — the per-theme Color Mode picker, the Surfaces + Text colour
pickers, and the whole Typography & Style card — and show an explanatory note.
The Force picker itself and the Accent pickers stay visible. i18n en/de.
This pairs with the admin dark-mode fix: admin surfaces follow the `.dark` class
which AdminDarkModeContext drives from the force lock, so force is respected
end-to-end.
CRM + accounting admin surfaces read gallery-theme tokens — the
`text-theme`/`text-muted-theme` utility classes and raw `var(--color-surface|
text|surface-border|elevated)` inline styles — which `ThemeContext` writes as
inline `--color-*` on <html> for every route. Those inline vars beat the admin
`.dark` toggle, so e.g. the "Create passive customer" modal (#620) renders dark
while the admin is in light mode (and the reverse).
Repoint every CRM/accounting admin surface to Tailwind `dark:` classes so it
tracks the admin toggle deterministically:
- text-theme → text-neutral-900 dark:text-neutral-100; text-muted-theme →
text-neutral-500 dark:text-neutral-400 (across the CRM list/detail/editor
pages, hours, calendar, lineage, installments, CRM settings).
- modal/dropdown/chip/divider inline `var(--color-surface*)` → bg-white
dark:bg-neutral-900 / border-neutral-200 dark:border-neutral-700 etc.
(CustomerManagement + CustomerDetail modals = the #620 fix).
- toggle off-track surface-border → bg-neutral-300 dark:bg-neutral-600; brand
accent ON-state kept (var(--color-accent)).
- CalendarPage FullCalendar chrome: scope local --cal-* vars under .fc / .dark
.fc so the calendar follows the admin toggle (was reading gallery vars).
- PasswordResetModal bare neutrals + TaxReport Storno/Reissue badges gain dark
pairings.
Brand accent/primary tokens and the branded admin login are intentionally left
on the gallery theme. The same leak exists in non-CRM admin areas (dashboard,
events) — out of scope here.
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:
- Hours logging: only createEntry checked the flag — edit/delete/bill and
the list/summary routes were permission-only. Gate all six
/hour-entries routes on the hoursLogging master so a disabled feature
can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
the list + quotes routes have. Also fixes the quotes-PDF gate, which
read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
neither the master nor a per-customer column.
Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
detail page gains the toggle; service/validator/serializer wired.
Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
to Accounting); add the missing `projects` so it mirrors the context
derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
The "VAT code by revenue rate" rows were hardcoded to the Swiss/LI rates
(8.1/2.6/3.8/0), so a code at any other rate (e.g. DE 19%/7%) had no row
to map. Derive the rows from the distinct rates of the OUTPUT VAT codes
instead — retype a code to a local rate and its row appears automatically;
remove the last code at a rate and the row drops. The CH/LI seeds are
unchanged and still produce the same four rows.
Frontend rateKey() mirrors backend ledgerService.rateKey so the saved map
keys keep matching the export-time lookup. Each rate's dropdown is scoped
to output codes at that rate. Empty state when no output codes exist.
app_settings has no created_at column (src/database/db.js defines only
setting_key/value/type + updated_at), so inserting one threw — which
broke saving any FIRST-TIME setting key. Existing keys took the UPDATE
path and worked, hiding the bug; it surfaced on the new VAT-registration
toggle + reclaim-countries keys ("Failed to save accounting settings").
Also fixes the same latent failure on the customer-surface settings route.
Consolidate all accounting configuration in one place. The Chart of
accounts (accounts table + category/default-account mappings) becomes a
self-contained ChartOfAccountsManager rendered in Settings → Accounting,
next to the VAT codes that already moved there. The /admin/accounting
section is now purely operational (Incoming invoices · Expenses · Tax).
The old /admin/accounting/ledger route redirects to the settings tab so
bookmarks keep working; the Tax page "Configure" link points there too.
ChartOfAccountsManager saves only the account keys (partial-merge safe,
same as VatCodesManager), so the two never revert each other's edits.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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).
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.
- 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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
#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.
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).
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.
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.
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).
#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).
#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.
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.
#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'.
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.
- 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
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.
- 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
- 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).
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).