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.
This commit is contained in:
@@ -11,32 +11,41 @@ import { Save } from 'lucide-react';
|
|||||||
import { Button, Card, CardContent, Loading } from '../../../components/common';
|
import { Button, Card, CardContent, Loading } from '../../../components/common';
|
||||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||||
import { accountingService } from '../../../services/accounting.service';
|
import { accountingService } from '../../../services/accounting.service';
|
||||||
|
import { sortedCountryOptions } from '../../../constants/countries';
|
||||||
|
|
||||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
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';
|
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 AccountingTab: React.FC = () => {
|
export const AccountingTab: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
|
||||||
|
|
||||||
const [kmMajor, setKmMajor] = useState<number>(NaN);
|
const [kmMajor, setKmMajor] = useState<number>(NaN);
|
||||||
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
||||||
const [requireProof, setRequireProof] = useState(false);
|
const [requireProof, setRequireProof] = useState(false);
|
||||||
|
const [vatRegistered, setVatRegistered] = useState(false);
|
||||||
|
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
setKmMajor(data.accounting_km_rate_minor / 100);
|
setKmMajor(data.accounting_km_rate_minor / 100);
|
||||||
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
|
setPerDiemMajor(data.accounting_per_diem_rate_minor / 100);
|
||||||
setRequireProof(data.accounting_require_proof);
|
setRequireProof(data.accounting_require_proof);
|
||||||
|
setVatRegistered(data.accounting_vat_registered);
|
||||||
|
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const countries = sortedCountryOptions(i18n.language);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () => accountingService.updateSettings({
|
mutationFn: () => accountingService.updateSettings({
|
||||||
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
|
accounting_km_rate_minor: Number.isFinite(kmMajor) ? Math.round(kmMajor * 100) : 0,
|
||||||
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
|
accounting_per_diem_rate_minor: Number.isFinite(perDiemMajor) ? Math.round(perDiemMajor * 100) : 0,
|
||||||
accounting_require_proof: requireProof,
|
accounting_require_proof: requireProof,
|
||||||
|
accounting_vat_registered: vatRegistered,
|
||||||
|
accounting_vat_reclaim_countries: reclaimCountries,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => { toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); qc.invalidateQueries({ queryKey: ['accounting-settings'] }); },
|
onSuccess: () => { toast.success(t('settings.accounting.savedToast', 'Accounting settings saved.')); qc.invalidateQueries({ queryKey: ['accounting-settings'] }); },
|
||||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||||
@@ -69,6 +78,41 @@ export const AccountingTab: React.FC = () => {
|
|||||||
<p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p>
|
<p className="text-xs text-amber-600 dark:text-amber-400">{t('settings.accounting.disclaimer', 'Rates and VAT/tax treatment are guidance only — verify with your Treuhaender.')}</p>
|
||||||
</CardContent></Card>
|
</CardContent></Card>
|
||||||
|
|
||||||
|
{/* VAT registration & reclaim — drives whether output/input VAT applies
|
||||||
|
and which countries' input VAT is deductible (cost tax-treatment +
|
||||||
|
the tax report's VAT-payable). */}
|
||||||
|
<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.vat.title', 'VAT registration & reclaim')}
|
||||||
|
</h3>
|
||||||
|
<label className="flex items-start gap-2 text-sm text-neutral-800 dark:text-neutral-200">
|
||||||
|
<input type="checkbox" checked={vatRegistered} onChange={(e) => setVatRegistered(e.target.checked)} className="mt-0.5 rounded border-neutral-300" />
|
||||||
|
<span>
|
||||||
|
{t('settings.accounting.vat.registered', 'VAT-registered (charge output VAT + reclaim input VAT)')}
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.accounting.vat.registeredHint', 'Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div className={vatRegistered ? '' : 'opacity-50 pointer-events-none'}>
|
||||||
|
<label className={labelCls}>{t('settings.accounting.vat.reclaimCountries', 'Countries where input VAT is reclaimable')}</label>
|
||||||
|
<select
|
||||||
|
multiple
|
||||||
|
size={6}
|
||||||
|
value={reclaimCountries}
|
||||||
|
onChange={(e) => setReclaimCountries(Array.from(e.target.selectedOptions, (o) => o.value))}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
{countries.map((c) => (
|
||||||
|
<option key={c.code} value={c.code}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.accounting.vat.reclaimCountriesHint', 'Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent></Card>
|
||||||
|
|
||||||
<div>
|
<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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -1723,6 +1723,13 @@
|
|||||||
"perDiemRate": "Spesenpauschale (CHF / Tag)",
|
"perDiemRate": "Spesenpauschale (CHF / Tag)",
|
||||||
"perDiemRateHint": "Standard für Pauschal-Aufwände; pro Eintrag überschreibbar.",
|
"perDiemRateHint": "Standard für Pauschal-Aufwände; pro Eintrag überschreibbar.",
|
||||||
"requireProof": "Beleg für jeden Aufwand verlangen",
|
"requireProof": "Beleg für jeden Aufwand verlangen",
|
||||||
|
"vat": {
|
||||||
|
"title": "MwSt-Registrierung & Vorsteuerabzug",
|
||||||
|
"registered": "MwSt-pflichtig (Umsatzsteuer berechnen + Vorsteuer abziehen)",
|
||||||
|
"registeredHint": "Aus = Kleinunternehmen / unter der Schwelle: keine MwSt berechnet, Vorsteuer ist Aufwand (nicht abziehbar).",
|
||||||
|
"reclaimCountries": "Länder mit abziehbarer Vorsteuer",
|
||||||
|
"reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl."
|
||||||
|
},
|
||||||
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
|
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
|
||||||
"savedToast": "Buchhaltungseinstellungen gespeichert."
|
"savedToast": "Buchhaltungseinstellungen gespeichert."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1281,6 +1281,13 @@
|
|||||||
"perDiemRate": "Per-diem rate (CHF / day)",
|
"perDiemRate": "Per-diem rate (CHF / day)",
|
||||||
"perDiemRateHint": "Default applied to per-diem expenses; overridable per entry.",
|
"perDiemRateHint": "Default applied to per-diem expenses; overridable per entry.",
|
||||||
"requireProof": "Require a proof file on every expense",
|
"requireProof": "Require a proof file on every expense",
|
||||||
|
"vat": {
|
||||||
|
"title": "VAT registration & reclaim",
|
||||||
|
"registered": "VAT-registered (charge output VAT + reclaim input VAT)",
|
||||||
|
"registeredHint": "Off = small business / under threshold: no VAT charged, input VAT is a cost (not reclaimable).",
|
||||||
|
"reclaimCountries": "Countries where input VAT is reclaimable",
|
||||||
|
"reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select."
|
||||||
|
},
|
||||||
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
|
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
|
||||||
"savedToast": "Accounting settings saved."
|
"savedToast": "Accounting settings saved."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ export interface AccountingSettings {
|
|||||||
accounting_km_rate_minor: number;
|
accounting_km_rate_minor: number;
|
||||||
accounting_per_diem_rate_minor: number;
|
accounting_per_diem_rate_minor: number;
|
||||||
accounting_require_proof: boolean;
|
accounting_require_proof: boolean;
|
||||||
|
/** VAT-registered: charge output VAT + reclaim input VAT. Off = small
|
||||||
|
* business / under threshold (no VAT). */
|
||||||
|
accounting_vat_registered: boolean;
|
||||||
|
/** ISO-2 countries whose input VAT can be reclaimed (drives cost
|
||||||
|
* tax-treatment + the report's VAT-payable). */
|
||||||
|
accounting_vat_reclaim_countries: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategorizePayload {
|
export interface CategorizePayload {
|
||||||
@@ -177,6 +183,9 @@ export const accountingService = {
|
|||||||
accounting_km_rate_minor: Number(data.accounting_km_rate_minor) || 0,
|
accounting_km_rate_minor: Number(data.accounting_km_rate_minor) || 0,
|
||||||
accounting_per_diem_rate_minor: Number(data.accounting_per_diem_rate_minor) || 0,
|
accounting_per_diem_rate_minor: Number(data.accounting_per_diem_rate_minor) || 0,
|
||||||
accounting_require_proof: data.accounting_require_proof === true,
|
accounting_require_proof: data.accounting_require_proof === true,
|
||||||
|
accounting_vat_registered: data.accounting_vat_registered === true,
|
||||||
|
accounting_vat_reclaim_countries: Array.isArray(data.accounting_vat_reclaim_countries)
|
||||||
|
? data.accounting_vat_reclaim_countries : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {
|
async updateSettings(payload: Partial<AccountingSettings>): Promise<{ updated: string[] }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user