From dc7b87bb874e22ac6902fd2d48531ecdb6108c88 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:10:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(accounting):=20consolidate=20VAT/financial?= =?UTF-8?q?=20config=20into=20Settings=20=E2=86=92=20Accounting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/src/services/_renderContext.js | 4 + backend/src/services/pdfService.js | 4 +- .../admin/AccountingProfileFields.tsx | 82 +++++++++++++++++++ .../src/components/admin/VatRateSelect.tsx | 46 +++++------ frontend/src/constants/currencies.ts | 30 +++++++ .../features/settings/tabs/AccountingTab.tsx | 6 ++ .../settings/SettingsBusinessProfilePage.tsx | 69 ++++++++-------- 7 files changed, 183 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/admin/AccountingProfileFields.tsx create mode 100644 frontend/src/constants/currencies.ts diff --git a/backend/src/services/_renderContext.js b/backend/src/services/_renderContext.js index 750b9475..abd39dea 100644 --- a/backend/src/services/_renderContext.js +++ b/backend/src/services/_renderContext.js @@ -73,6 +73,10 @@ function buildIssuerBlock(profile, logoPath, options = {}) { // PDF issuer block — §14 UStG requires one or both on every // invoice. Kleinunternehmer without a USt-IdNr. carry only this. taxId: profile.tax_id || null, + // VAT-line label on the totals block (e.g. "MwSt.", "VAT"). Falls back to + // the per-locale default in pdfService when blank. Configured under + // Settings → Accounting. + vatLabel: profile.vat_label || null, // pre-resolved absolute path; renderer never re-resolves. logoPath, pdfFontTtfPath: profile.pdf_font_ttf_path, diff --git a/backend/src/services/pdfService.js b/backend/src/services/pdfService.js index 01c58100..0e4cf8d6 100644 --- a/backend/src/services/pdfService.js +++ b/backend/src/services/pdfService.js @@ -844,7 +844,9 @@ function drawTotals(doc, ctx, x, y, width) { doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(formatMinor(totals.shippingAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); y = doc.y + 4; - doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(t(locale, 'totals_vat'), labelX, y, { width: labelCol }); + // Custom VAT label (Settings → Accounting) overrides the per-locale default. + const vatLabel = (ctx.issuer && ctx.issuer.vatLabel) || t(locale, 'totals_vat'); + doc.font(doc._fonts ? doc._fonts.bold : FONT_BOLD).text(vatLabel, labelX, y, { width: labelCol }); doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).text(`${stripTrailingZeros(totals.vatRate)}%`, rateX, y, { width: rateCol, align: 'right' }); doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' }); y = doc.y + 4; diff --git a/frontend/src/components/admin/AccountingProfileFields.tsx b/frontend/src/components/admin/AccountingProfileFields.tsx new file mode 100644 index 00000000..943ffd0f --- /dev/null +++ b/frontend/src/components/admin/AccountingProfileFields.tsx @@ -0,0 +1,82 @@ +/** + * Business-profile financial fields surfaced on the Accounting tab: the + * **VAT label** (printed on invoice/quote PDFs) and the **default hourly rate** + * (install-wide fallback for hours logging). The values still live on + * `business_profile`; this is a self-contained card with its own save (mirrors + * VatCodesManager) so it can't clobber the rest of the business profile, and it + * shares the `business-profile` query cache so both pages stay in sync. + */ +import React, { useEffect, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Save } from 'lucide-react'; +import { Button, Card, CardContent, Input, Loading } from '../common'; +import { DecimalInput } from '../common/DecimalInput'; +import { businessProfileService } from '../../services/businessProfile.service'; + +const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; +const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; + +export const AccountingProfileFields: React.FC = () => { + const { t } = useTranslation(); + const qc = useQueryClient(); + const { data, isLoading } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get() }); + + const [vatLabel, setVatLabel] = useState(''); + const [hourlyMajor, setHourlyMajor] = useState(NaN); + const currency = data?.profile?.defaultCurrency || 'CHF'; + + useEffect(() => { + if (data?.profile) { + setVatLabel(data.profile.vatLabel || ''); + setHourlyMajor(data.profile.defaultHourlyRateMinor != null ? data.profile.defaultHourlyRateMinor / 100 : NaN); + } + }, [data]); + + const save = useMutation({ + mutationFn: () => businessProfileService.update({ + vatLabel: vatLabel || '', + defaultHourlyRateMinor: Number.isFinite(hourlyMajor) ? Math.max(0, Math.round(hourlyMajor * 100)) : null, + }), + onSuccess: () => { + toast.success(t('settings.accounting.profileFields.savedToast', 'Saved.')); + qc.invalidateQueries({ queryKey: ['business-profile'] }); + }, + onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + }); + + if (isLoading) return ; + + return ( + +

+ {t('settings.accounting.profileFields.title', 'VAT label & hourly rate')} +

+ +
+ + setVatLabel(e.target.value)} className={inputCls} /> +

{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}

+
+ +
+ + +

+ {t('settings.accounting.profileFields.hourlyRateHint', 'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', { currency })} +

+
+ + +
+ ); +}; + +export default AccountingProfileFields; diff --git a/frontend/src/components/admin/VatRateSelect.tsx b/frontend/src/components/admin/VatRateSelect.tsx index bac73c34..588f3f40 100644 --- a/frontend/src/components/admin/VatRateSelect.tsx +++ b/frontend/src/components/admin/VatRateSelect.tsx @@ -1,17 +1,23 @@ /** - * VAT-rate picker for the invoice/quote editors. A dropdown of the configured - * OUTPUT VAT codes (Settings → Accounting) plus an "Other (custom rate)" escape - * hatch. Controlled by `(rate, code)`: selecting a code emits its rate + code - * string (snapshotted on the document for the accounting export); "Other" emits - * the typed rate with a null code. Reads the un-gated /admin/vat-codes endpoint, - * so it works even when the accounting feature is off. + * VAT-rate picker for the invoice/quote editors. A dropdown whose ONLY options + * are the configured OUTPUT VAT codes (Settings → Accounting) — there is no + * free-text custom rate; to use a different rate, add a VAT code in Accounting. + * Controlled by `(rate, code)`: selecting a code emits its rate + code string + * (snapshotted on the document for the accounting export). Reads the un-gated + * /admin/vat-codes endpoint so it works even when the accounting feature is off. + * + * Legacy preservation: when editing a document whose stored rate/code isn't an + * accounting code anymore (an old invoice, or a deleted code), that value is + * shown as a read-only "(not configured)" option so it stays selected and is + * never silently changed — issued invoices are immutable. The admin can still + * switch it to a current code. */ import React from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service'; -const CUSTOM = '__custom__'; +const LEGACY = '__legacy__'; const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500'; @@ -32,11 +38,11 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di }); // Selected option: prefer the snapshotted code; else a code whose rate matches - // (legacy rows / no code stored); else "custom". + // (legacy rows / no code stored); else the document's value is "off-list". const matched: VatCodeOption | undefined = (code ? codes.find((c) => c.code === code) : undefined) || (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined); - const isCustom = !matched; + const showLegacy = !matched; return (
@@ -46,32 +52,24 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di - {isCustom && ( - onChange(Number(e.target.value) || 0, null)} - /> - )}
); }; diff --git a/frontend/src/constants/currencies.ts b/frontend/src/constants/currencies.ts new file mode 100644 index 00000000..0b2478e8 --- /dev/null +++ b/frontend/src/constants/currencies.ts @@ -0,0 +1,30 @@ +/** + * Currency codes for the Business-profile "Default currency" dropdown. + * CH/LI-first ordering (picpeak's primary scope), then the common EUR/USD/GBP + * and a broad set of other ISO 4217 codes. Stored value is the bare 3-letter + * code (e.g. "CHF"). + */ +export const CURRENCY_CODES: string[] = [ + 'CHF', 'EUR', 'USD', 'GBP', + 'AUD', 'CAD', 'CNY', 'CZK', 'DKK', 'HKD', 'HUF', 'ILS', 'INR', 'JPY', + 'NOK', 'NZD', 'PLN', 'RON', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR', +]; + +/** + * Normalise a stored/typed currency value to a known code. Upper-cases + trims + * (so an old free-text "chf" resolves to "CHF"). Returns the matched code, or + * the cleaned input if it isn't in the known list (caller preserves it as an + * extra option so nothing is lost), or '' for empty input. + */ +export function normalizeCurrency(value: string | null | undefined): string { + const cleaned = (value || '').trim().toUpperCase(); + if (!cleaned) return ''; + return CURRENCY_CODES.includes(cleaned) ? cleaned : cleaned; +} + +/** Build the option list, prepending an unknown-but-set value so it's preserved. */ +export function currencyOptions(current: string | null | undefined): string[] { + const cur = normalizeCurrency(current); + if (cur && !CURRENCY_CODES.includes(cur)) return [cur, ...CURRENCY_CODES]; + return CURRENCY_CODES; +} diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx index 90cb79d8..52a27047 100644 --- a/frontend/src/features/settings/tabs/AccountingTab.tsx +++ b/frontend/src/features/settings/tabs/AccountingTab.tsx @@ -14,6 +14,7 @@ import { accountingService } from '../../../services/accounting.service'; import { sortedCountryOptions } from '../../../constants/countries'; import { VatCodesManager } from '../../../components/admin/VatCodesManager'; import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager'; +import { AccountingProfileFields } from '../../../components/admin/AccountingProfileFields'; const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1'; const inputCls = 'w-full max-w-xs rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; @@ -119,6 +120,11 @@ export const AccountingTab: React.FC = () => { + {/* VAT label + default hourly rate — moved here from Business profile so + all financial/VAT config lives in one place (storage stays on + business_profile; this card has its own save). */} + + {/* VAT codes + rate→code / treatment→code maps — relocated here from the Chart-of-accounts page so all VAT config lives in one place. */} diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx index 8bf35e60..b8d188e1 100644 --- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx +++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx @@ -18,8 +18,8 @@ import { type QrFormat, } from '../../../services/businessProfile.service'; import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common'; -import { DecimalInput } from '../../../components/common/DecimalInput'; import { toast } from 'react-toastify'; +import { currencyOptions, normalizeCurrency } from '../../../constants/currencies'; // Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022 // (all current browsers); fall back to a small CH/LI-relevant set on the rare @@ -45,7 +45,16 @@ export const SettingsBusinessProfilePage: React.FC = () => { useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]); const saveProfile = useMutation({ - mutationFn: () => profile ? businessProfileService.update(profile) : Promise.reject(), + // vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and + // vatRateDefault is retired (the rates are the Accounting VAT codes). Strip + // them from this save so an open Business-profile page can't clobber an edit + // made on the Accounting tab with its stale loaded value. + mutationFn: () => { + if (!profile) return Promise.reject(); + const { vatLabel, defaultHourlyRateMinor, vatRateDefault, ...rest } = profile; + void vatLabel; void defaultHourlyRateMinor; void vatRateDefault; + return businessProfileService.update(rest); + }, onSuccess: () => { toast.success(t('businessProfile.savedToast', 'Business profile saved.')); qc.invalidateQueries({ queryKey: ['business-profile'] }); @@ -124,9 +133,29 @@ export const SettingsBusinessProfilePage: React.FC = () => {

{t('businessProfile.section.defaults', 'Defaults')}

+ {/* Pointer so admins who look for the old VAT/hourly-rate fields here + know where they went. */} +

+ {t('businessProfile.movedToAccounting', 'The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.')} +

- setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} /> +
+ + {/* Dropdown; the stored value is normalised (e.g. an old free-text + "chf" → "CHF") so it pre-selects, and an unknown code is kept as + an extra option so nothing is lost. */} + +
setProfile({ ...profile, defaultLocale: e.target.value })} /> {/* Migration 137 — IANA timezone for the admin calendar + the @@ -149,35 +178,9 @@ export const SettingsBusinessProfilePage: React.FC = () => { ))}
- setProfile({ ...profile, vatLabel: e.target.value })} /> - setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} /> - {/* Install-wide fallback hourly rate (migration 113). Stored in - minor units; entered here in major units. Blank = no global - default, so hours-logging then needs a per-customer or - per-entry rate. Comma-tolerant via DecimalInput. */} -
- - setProfile({ - ...profile, - defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null, - })} - className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm" - placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string} - /> -

- {t('businessProfile.field.defaultHourlyRateHint', - 'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.', - { currency: profile.defaultCurrency || 'CHF' })} -

-
+ {/* VAT rate %, VAT label and the default hourly rate moved to + Settings → Accounting (so all financial/VAT config lives in one + place). See the callout above. */}