Commit Graph
1447 Commits
Author SHA1 Message Date
Luca 707c5d0277 fix(accounting): address the-luap PR #636 review
- #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).
2026-06-18 18:40:11 +02:00
Luca 33d5408977 refactor(accounting): consolidate the Accounting tab into two cards + one Save
- 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".
2026-06-18 15:56:52 +02:00
Luca 267b121d66 feat(accounting): supplier-country tax default + configurable default output VAT code
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.
2026-06-18 15:35:58 +02:00
Luca 348955b261 fix(hours): move logActivity out of the entry transactions (SQLite deadlock)
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.)
2026-06-18 15:35:48 +02:00
Luca 51837c3a88 feat(accounting): invoices force-enable the Accounting master
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.
2026-06-18 15:11:11 +02:00
Luca dc7b87bb87 feat(accounting): consolidate VAT/financial config into Settings → Accounting
- 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.
2026-06-18 15:10:44 +02:00
Luca 315d15afd4 test(accounting): incoming-invoice integration test + fix vat_code reload & SQLite logActivity deadlock
- 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).
2026-06-18 14:11:08 +02:00
Luca 9a023c0197 feat(accounting): explain dispositions inline, drop markup from pass-through
- 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.
2026-06-18 12:44:18 +02:00
Luca 36a8e42f90 feat(accounting): re-categorize incoming invoices, note field, pending re-bill pool
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).
2026-06-18 12:23:13 +02:00
Luca 8deb7e0741 fix(accounting): tidy the tax-export scope selector styling
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.
2026-06-16 19:13:25 +02:00
Luca 116743ba43 docs(readme): add CRM + accounting to features, tax disclaimer, update contributor
- 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.
2026-06-16 19:05:38 +02:00
Luca 86dff75898 test(accounting): cover export scope, unique-violation detector, PDF page cap
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).
2026-06-16 18:54:55 +02:00
Luca 9f3b28684f feat(accounting): scope the tax-report export to income-only or cost-only
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.
2026-06-16 18:51:11 +02:00
Luca d6da89f48a chore(accounting): PR #622 nits — stray artifact, dedupe requireFlag, IMAP poll backoff
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.
2026-06-16 18:36:35 +02:00
Luca a93b6dc232 fix(accounting): PR #622 concerns — flag-cache, customer master gate, VAT-unconfigured, helpers, page cap
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.
2026-06-16 18:33:47 +02:00
Luca cd6d57839b fix(accounting): PR #622 blockers — CSV formula injection + IMAP double-ingest race
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.
2026-06-16 18:21:02 +02:00
Luca a7c19135bb fix(branding): force lock = light/dark only; Branding stays the full preset, galleries hide color+mode
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.
2026-06-16 15:54:59 +02:00
Luca 1ac653ad1b fix(branding): when a force lock is active, collapse the theme customizer to just the Force control
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.
2026-06-16 15:42:10 +02:00
Luca 4749e222dc feat(branding): force color mode = standard look; hide overridden theme controls
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.
2026-06-16 15:08:14 +02:00
Luca d3266a0d1c fix(crm): admin surfaces follow the admin light/dark toggle, not the gallery theme (#620)
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.
2026-06-16 14:54:25 +02:00
Luca 03fa3d8296 fix(flags): close CRM/accounting feature-gating gaps from the audit
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).
2026-06-16 13:12:26 +02:00
Luca 873be910a5 feat(accounting): data-driven revenue-rate VAT map (multi-country)
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.
2026-06-16 12:43:36 +02:00
Luca 8621338c48 fix(settings): don't insert non-existent created_at into app_settings
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.
2026-06-16 01:23:54 +02:00
Luca 97795f6d1e feat(accounting): move Chart of accounts into Settings → Accounting
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.
2026-06-16 01:11:12 +02:00
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/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
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (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