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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number>(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 <Loading />;
|
||||
|
||||
return (
|
||||
<Card><CardContent className="p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||
{t('settings.accounting.profileFields.title', 'VAT label & hourly rate')}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.profileFields.vatLabel', 'VAT label (e.g. MwSt., VAT)')}</label>
|
||||
<Input value={vatLabel} onChange={(e) => setVatLabel(e.target.value)} className={inputCls} />
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.profileFields.vatLabelHint', 'Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.profileFields.hourlyRate', 'Default hourly rate')}</label>
|
||||
<DecimalInput
|
||||
value={hourlyMajor}
|
||||
fractionDigits={2}
|
||||
onChange={setHourlyMajor}
|
||||
className={inputCls}
|
||||
placeholder={t('settings.accounting.profileFields.hourlyRatePlaceholder', 'e.g. 120.00') as string}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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 })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}><Save className="w-4 h-4 mr-2" /> {save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
|
||||
</CardContent></Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountingProfileFields;
|
||||
@@ -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<Props> = ({ 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 (
|
||||
<div>
|
||||
@@ -46,32 +52,24 @@ export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, di
|
||||
<select
|
||||
className={selectCls}
|
||||
disabled={disabled}
|
||||
value={isCustom ? CUSTOM : String(matched!.id)}
|
||||
value={matched ? String(matched.id) : LEGACY}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM) { onChange(rate, null); return; }
|
||||
if (e.target.value === LEGACY) { onChange(rate, code); return; } // keep the legacy value
|
||||
const c = codes.find((x) => String(x.id) === e.target.value);
|
||||
if (c) onChange(Number(c.rate), c.code);
|
||||
}}
|
||||
>
|
||||
{showLegacy && (
|
||||
<option value={LEGACY}>
|
||||
{t('vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
|
||||
</option>
|
||||
)}
|
||||
{codes.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name} ({Number(c.rate).toFixed(1)}%)
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM}>{t('vat.customRate', 'Other (custom rate)')}</option>
|
||||
</select>
|
||||
{isCustom && (
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className={`${selectCls} mt-2`}
|
||||
disabled={disabled}
|
||||
value={rate}
|
||||
placeholder={t('vat.ratePercent', 'VAT rate %') as string}
|
||||
onChange={(e) => onChange(Number(e.target.value) || 0, null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = () => {
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}><Save className="w-4 h-4 mr-2" /> {save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
|
||||
</div>
|
||||
|
||||
{/* 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). */}
|
||||
<AccountingProfileFields />
|
||||
|
||||
{/* VAT codes + rate→code / treatment→code maps — relocated here from the
|
||||
Chart-of-accounts page so all VAT config lives in one place. */}
|
||||
<VatCodesManager />
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-3">{t('businessProfile.section.defaults', 'Defaults')}</h3>
|
||||
{/* Pointer so admins who look for the old VAT/hourly-rate fields here
|
||||
know where they went. */}
|
||||
<p className="mb-3 rounded-md border border-blue-200 dark:border-blue-900/50 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 text-xs text-blue-800 dark:text-blue-300">
|
||||
{t('businessProfile.movedToAccounting', 'The VAT rate, VAT label and default hourly rate now live under Settings → Accounting.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('businessProfile.field.defaultCurrency', 'Default currency') as string} value={profile.defaultCurrency}
|
||||
maxLength={3} onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value.toUpperCase() })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('businessProfile.field.defaultCurrency', 'Default currency')}
|
||||
</label>
|
||||
{/* 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. */}
|
||||
<select
|
||||
value={normalizeCurrency(profile.defaultCurrency)}
|
||||
onChange={(e) => setProfile({ ...profile, defaultCurrency: e.target.value })}
|
||||
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 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
{currencyOptions(profile.defaultCurrency).map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label={t('businessProfile.field.defaultLocale', 'Default locale') as string} value={profile.defaultLocale}
|
||||
maxLength={8} onChange={(e) => setProfile({ ...profile, defaultLocale: e.target.value })} />
|
||||
{/* Migration 137 — IANA timezone for the admin calendar + the
|
||||
@@ -149,35 +178,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Input label={t('businessProfile.field.vatLabel', 'VAT label (e.g. MwSt., VAT)') as string} value={profile.vatLabel}
|
||||
onChange={(e) => setProfile({ ...profile, vatLabel: e.target.value })} />
|
||||
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
|
||||
value={profile.vatRateDefault ?? 0}
|
||||
onChange={(e) => 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. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => 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}
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{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' })}
|
||||
</p>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
|
||||
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
|
||||
|
||||
Reference in New Issue
Block a user