From 267b121d66994bc57b10cd0694ab0e4320b163d9 Mon Sep 17 00:00:00 2001
From: Luca <102960244+Luca-Timo@users.noreply.github.com>
Date: Thu, 18 Jun 2026 15:35:58 +0200
Subject: [PATCH] feat(accounting): supplier-country tax default + configurable
default output VAT code
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../services/expenseService.markup.test.js | 25 ++++++++++++++-
.../core/134_add_inbound_supplier_country.js | 25 +++++++++++++++
backend/src/routes/adminSettings.js | 10 ++++++
backend/src/services/expenseService.js | 31 ++++++++++++++++---
.../features/settings/tabs/AccountingTab.tsx | 13 ++++++++
frontend/src/i18n/locales/de.json | 10 ++++--
frontend/src/i18n/locales/en.json | 10 ++++--
.../admin/accounting/AccountingInboxPage.tsx | 13 ++++++--
.../src/pages/admin/bills/BillEditorPage.tsx | 19 ++++++++++++
.../pages/admin/quotes/QuoteEditorPage.tsx | 19 ++++++++++++
frontend/src/services/accounting.service.ts | 6 ++++
11 files changed, 170 insertions(+), 11 deletions(-)
create mode 100644 backend/migrations/core/134_add_inbound_supplier_country.js
diff --git a/backend/__tests__/services/expenseService.markup.test.js b/backend/__tests__/services/expenseService.markup.test.js
index 61ae1da7..a718a2a1 100644
--- a/backend/__tests__/services/expenseService.markup.test.js
+++ b/backend/__tests__/services/expenseService.markup.test.js
@@ -4,7 +4,7 @@
*/
const expenseService = require('../../src/services/expenseService');
-const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable } = expenseService._internal;
+const { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment } = expenseService._internal;
describe('computeMarkupMinor', () => {
it('percent of base, rounded', () => {
@@ -113,6 +113,29 @@ describe('buildInboundLineItem (re-bill line)', () => {
});
});
+describe('resolveTaxTreatment (supplier-country auto-default)', () => {
+ const reclaim = ['CH', 'LI'];
+ it('explicit valid treatment always wins', () => {
+ expect(resolveTaxTreatment('reverse_charge_service', 'DE', reclaim)).toBe('reverse_charge_service');
+ expect(resolveTaxTreatment('import_goods', 'CH', reclaim)).toBe('import_goods');
+ });
+ it('country in the reclaim list → domestic', () => {
+ expect(resolveTaxTreatment(undefined, 'CH', reclaim)).toBe('domestic');
+ expect(resolveTaxTreatment(null, 'li', reclaim)).toBe('domestic'); // case-insensitive
+ });
+ it('country outside the reclaim list → foreign non-reclaimable', () => {
+ expect(resolveTaxTreatment(undefined, 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
+ expect(resolveTaxTreatment(undefined, 'US', reclaim)).toBe('foreign_vat_non_reclaimable');
+ });
+ it('unknown / empty country falls back to domestic', () => {
+ expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic');
+ expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic');
+ });
+ it('invalid explicit treatment is ignored (falls through to country logic)', () => {
+ expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable');
+ });
+});
+
describe('isInvoiceMutable (re-categorise unwind guard)', () => {
const future = new Date(Date.now() + 86400000).toISOString();
const past = new Date(Date.now() - 86400000).toISOString();
diff --git a/backend/migrations/core/134_add_inbound_supplier_country.js b/backend/migrations/core/134_add_inbound_supplier_country.js
new file mode 100644
index 00000000..1b2aa77d
--- /dev/null
+++ b/backend/migrations/core/134_add_inbound_supplier_country.js
@@ -0,0 +1,25 @@
+/**
+ * Migration 134: supplier country on incoming invoices.
+ *
+ * `supplier_country` (ISO-3166 alpha-2) lets categorisation auto-default the
+ * `tax_treatment`: a supplier whose country is in the install's VAT reclaim
+ * list (Settings → Accounting → `accounting_vat_reclaim_countries`, typically
+ * CH / LI) → `domestic` (input VAT reclaimable); otherwise →
+ * `foreign_vat_non_reclaimable`. Closes the dangling VAT-consolidation slice
+ * where the reclaim-countries setting was stored but never consumed.
+ *
+ * Additive + hasColumn-guarded.
+ */
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('inbound_documents'))) return;
+ if (!(await knex.schema.hasColumn('inbound_documents', 'supplier_country'))) {
+ await knex.schema.alterTable('inbound_documents', (t) => t.string('supplier_country', 2));
+ }
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('inbound_documents'))) return;
+ if (await knex.schema.hasColumn('inbound_documents', 'supplier_country')) {
+ await knex.schema.alterTable('inbound_documents', (t) => t.dropColumn('supplier_country'));
+ }
+};
diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js
index 87040945..8402fcdd 100644
--- a/backend/src/routes/adminSettings.js
+++ b/backend/src/routes/adminSettings.js
@@ -258,6 +258,16 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
setting_type: 'accounting',
});
}
+ // Default OUTPUT VAT code stamped onto NEW invoices/quotes (the editor
+ // seeds its VAT picker from it). Stored as the code string; '' clears it.
+ if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_default_output_vat_code')) {
+ const code = String(req.body.accounting_default_output_vat_code || '').trim().slice(0, 16);
+ updates.push({
+ setting_key: 'accounting_default_output_vat_code',
+ setting_value: JSON.stringify(code),
+ setting_type: 'accounting',
+ });
+ }
if (Object.prototype.hasOwnProperty.call(req.body, 'accounting_vat_reclaim_countries')) {
const arr = Array.isArray(req.body.accounting_vat_reclaim_countries)
? req.body.accounting_vat_reclaim_countries
diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js
index 4fb5a648..de5499e6 100644
--- a/backend/src/services/expenseService.js
+++ b/backend/src/services/expenseService.js
@@ -44,7 +44,8 @@ function toIsoDate(v) {
// ── Accounting settings (app_settings, type 'accounting') ───────────────────
async function getAccountingSettings() {
- const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof'];
+ const keys = ['accounting_km_rate_minor', 'accounting_per_diem_rate_minor', 'accounting_require_proof',
+ 'accounting_vat_reclaim_countries'];
let rows = [];
try {
rows = await db('app_settings').whereIn('setting_key', keys).select('setting_key', 'setting_value');
@@ -59,9 +60,25 @@ async function getAccountingSettings() {
kmRateMinor: Number.isFinite(Number(map.accounting_km_rate_minor)) ? Number(map.accounting_km_rate_minor) : 0,
perDiemRateMinor: Number.isFinite(Number(map.accounting_per_diem_rate_minor)) ? Number(map.accounting_per_diem_rate_minor) : 0,
requireProof: map.accounting_require_proof === true || map.accounting_require_proof === 1 || map.accounting_require_proof === '1',
+ vatReclaimCountries: Array.isArray(map.accounting_vat_reclaim_countries)
+ ? map.accounting_vat_reclaim_countries.map((c) => String(c || '').toUpperCase()) : [],
};
}
+/**
+ * Default tax treatment from the supplier country: explicit payload wins; else
+ * a country in the reclaim list (typically CH / LI) is `domestic` (input VAT
+ * reclaimable), an out-of-list country is `foreign_vat_non_reclaimable`, and an
+ * unknown country falls back to `domestic`. reverse_charge / import_goods stay
+ * admin-set (can't be auto-detected).
+ */
+function resolveTaxTreatment(payloadTreatment, supplierCountry, reclaimCountries) {
+ if (TAX_TREATMENTS.includes(payloadTreatment)) return payloadTreatment;
+ const cc = String(supplierCountry || '').toUpperCase();
+ if (!cc) return 'domestic';
+ return reclaimCountries.includes(cc) ? 'domestic' : 'foreign_vat_non_reclaimable';
+}
+
// ── Incoming invoices (inbound_documents) ───────────────────────────────────
function transformInbound(row) {
if (!row) return null;
@@ -102,6 +119,7 @@ function transformInbound(row) {
customerAccountId: row.customer_account_id || null,
customerName: row.customer_display_name || row.customer_company_name || null,
customerEmail: row.customer_email || null,
+ supplierCountry: row.supplier_country || null,
note: row.note || null,
// supplier payment (paid on the incoming invoice itself)
supplierPaid: !!row.supplier_paid,
@@ -210,7 +228,7 @@ const INBOUND_EDITABLE = {
supplierName: 'supplier_name', invoiceNumber: 'invoice_number', invoiceDate: 'invoice_date',
dueDate: 'due_date', currency: 'currency', netAmountMinor: 'net_amount_minor',
vatAmountMinor: 'vat_amount_minor', totalAmountMinor: 'total_amount_minor', iban: 'iban',
- paymentReference: 'payment_reference', note: 'note',
+ paymentReference: 'payment_reference', note: 'note', supplierCountry: 'supplier_country',
};
async function updateInbound(id, payload, adminId) {
@@ -369,6 +387,10 @@ async function categorizeInbound(id, payload, adminId) {
throw new AppError('customerAccountId is required to re-bill', 400, 'CUSTOMER_REQUIRED');
}
+ // Reclaim-country list for the tax-treatment auto-default (loaded before the
+ // transaction — a global-db read).
+ const { vatReclaimCountries } = await getAccountingSettings();
+
let billedInvoiceId = null;
await db.transaction(async (trx) => {
const row = await trx('inbound_documents').where({ id }).first();
@@ -390,7 +412,8 @@ async function categorizeInbound(id, payload, adminId) {
const patch = {
disposition,
- tax_treatment: TAX_TREATMENTS.includes(payload.taxTreatment) ? payload.taxTreatment : 'domestic',
+ // Explicit treatment wins; else auto-default from the supplier country.
+ tax_treatment: resolveTaxTreatment(payload.taxTreatment, doc.supplierCountry, vatReclaimCountries),
event_id: BOOKING_DISPOSITIONS.includes(disposition) ? (payload.eventId || null) : null,
category_id: disposition === 'eigener_aufwand' ? (payload.categoryId || null) : null,
customer_account_id: customerAccountId,
@@ -797,5 +820,5 @@ module.exports = {
PAYMENT_METHODS,
EXPENSE_KINDS,
// unit-test surface
- _internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable },
+ _internal: { computeMarkupMinor, resolveMarkup, computeExpenseAmount, buildExpenseInsert, transformExpense, transformInbound, buildInboundLineItem, isInvoiceMutable, resolveTaxTreatment },
};
diff --git a/frontend/src/features/settings/tabs/AccountingTab.tsx b/frontend/src/features/settings/tabs/AccountingTab.tsx
index 52a27047..dc2b588a 100644
--- a/frontend/src/features/settings/tabs/AccountingTab.tsx
+++ b/frontend/src/features/settings/tabs/AccountingTab.tsx
@@ -11,6 +11,7 @@ import { Save } from 'lucide-react';
import { Button, Card, CardContent, Loading } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { accountingService } from '../../../services/accounting.service';
+import { vatCodesService } from '../../../services/vatCodes.service';
import { sortedCountryOptions } from '../../../constants/countries';
import { VatCodesManager } from '../../../components/admin/VatCodesManager';
import { ChartOfAccountsManager } from '../../../components/admin/ChartOfAccountsManager';
@@ -23,12 +24,14 @@ export const AccountingTab: React.FC = () => {
const { t, i18n } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
+ const { data: outputVatCodes = [] } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
const [kmMajor, setKmMajor] = useState(NaN);
const [perDiemMajor, setPerDiemMajor] = useState(NaN);
const [requireProof, setRequireProof] = useState(false);
const [vatRegistered, setVatRegistered] = useState(false);
const [reclaimCountries, setReclaimCountries] = useState([]);
+ const [defaultOutputVatCode, setDefaultOutputVatCode] = useState('');
useEffect(() => {
if (data) {
@@ -37,6 +40,7 @@ export const AccountingTab: React.FC = () => {
setRequireProof(data.accounting_require_proof);
setVatRegistered(data.accounting_vat_registered);
setReclaimCountries(data.accounting_vat_reclaim_countries || []);
+ setDefaultOutputVatCode(data.accounting_default_output_vat_code || '');
}
}, [data]);
@@ -49,6 +53,7 @@ export const AccountingTab: React.FC = () => {
accounting_require_proof: requireProof,
accounting_vat_registered: vatRegistered,
accounting_vat_reclaim_countries: reclaimCountries,
+ accounting_default_output_vat_code: defaultOutputVatCode,
}),
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'),
@@ -114,6 +119,14 @@ export const AccountingTab: React.FC = () => {
{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.')}
+
+
+
+
{t('settings.accounting.vat.defaultOutputCodeHint', 'New invoices and quotes start with this VAT code selected. Existing documents are unaffected.')}
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index efcdd68f..ed02f635 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -1729,7 +1729,10 @@
"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."
+ "reclaimCountriesHint": "Üblicherweise Ihr Inland (CH / LI). Kosten aus anderen Ländern gelten als nicht abziehbare ausländische MwSt. Cmd/Ctrl-Klick für Mehrfachauswahl.",
+ "defaultOutputCode": "Standard-MwSt-Code für neue Rechnungen",
+ "defaultOutputCodeNone": "— keiner (bei 0% beginnen) —",
+ "defaultOutputCodeHint": "Neue Rechnungen und Angebote starten mit diesem MwSt-Code. Bestehende Dokumente bleiben unverändert."
},
"disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.",
"savedToast": "Buchhaltungseinstellungen gespeichert.",
@@ -3572,7 +3575,10 @@
"referenceHint": "QR-/ESR-Referenz oder Mitteilung",
"note": "Notiz",
"noteHint": "Interne Notiz zu dieser Rechnung (optional)",
- "passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen."
+ "passthroughCustomerHint": "Optional — einen Kunden zuordnen, um diese durchlaufende Position weiterzuverrechnen; leer lassen, um sie nur auf das Event zu buchen.",
+ "supplierCountry": "Lieferantenland",
+ "supplierCountryNone": "— unbekannt —",
+ "supplierCountryHint": "Setzt die Steuerbehandlung automatisch: ausserhalb deiner Vorsteuer-Länder → ausländische MwSt (nicht abziehbar)."
}
},
"expenseStatus": {
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index ec11d9f8..aa75abc3 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1287,7 +1287,10 @@
"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."
+ "reclaimCountriesHint": "Typically your domestic country (CH / LI). Costs from other countries are treated as non-reclaimable foreign VAT. Cmd/Ctrl-click to multi-select.",
+ "defaultOutputCode": "Default VAT code for new invoices",
+ "defaultOutputCodeNone": "— none (start at 0%) —",
+ "defaultOutputCodeHint": "New invoices and quotes start with this VAT code selected. Existing documents are unaffected."
},
"disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.",
"savedToast": "Accounting settings saved.",
@@ -3572,7 +3575,10 @@
"referenceHint": "QR / ESR reference or message",
"note": "Note",
"noteHint": "Internal note for this invoice (optional)",
- "passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event."
+ "passthroughCustomerHint": "Optional — attach a client to re-bill this passthrough; leave empty to only book it to the event.",
+ "supplierCountry": "Supplier country",
+ "supplierCountryNone": "— unknown —",
+ "supplierCountryHint": "Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable)."
}
},
"expenseStatus": {
diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
index ff95c061..58d34cdc 100644
--- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
+++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx
@@ -16,6 +16,7 @@ import { DecimalInput } from '../../../components/common/DecimalInput';
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { EventBookingSelect } from '../../../components/admin/EventBookingSelect';
import { formatMoneyMinor } from '../../../utils/money';
+import { sortedCountryOptions } from '../../../constants/countries';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import {
accountingService, categoryLabel,
@@ -170,13 +171,14 @@ const ViewModal: React.FC<{ doc: InboundDocument; onClose: () => void }> = ({ do
};
const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[]; onClose: () => void; onDone: () => void }> = ({ doc, categories, onClose, onDone }) => {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const [supplier, setSupplier] = useState(doc.supplierName || '');
const [amountMajor, setAmountMajor] = useState(doc.totalAmountMinor != null ? doc.totalAmountMinor / 100 : NaN);
const [currency, setCurrency] = useState(doc.currency || 'CHF');
const [invoiceDate, setInvoiceDate] = useState(doc.invoiceDate || '');
const [reference, setReference] = useState(doc.paymentReference || '');
const [note, setNote] = useState(doc.note || '');
+ const [supplierCountry, setSupplierCountry] = useState(doc.supplierCountry || '');
// Pre-fill from the existing disposition so a categorized invoice can be
// re-categorized (#1) — falls back to "company expense" for fresh docs.
const [disposition, setDisposition] = useState(doc.disposition || 'eigener_aufwand');
@@ -205,7 +207,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
// entered) — no second dialog — so "Save & mark paid" actually pays.
const save = useMutation({
mutationFn: async (pay: boolean) => {
- await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null });
+ await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null });
await accountingService.categorizeInbound(doc.id, {
disposition,
eventId: BOOKING_DISPOSITIONS.includes(disposition) ? eventId : null,
@@ -243,6 +245,13 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
setSupplier(e.target.value)} />
setCurrency(e.target.value.toUpperCase())} />
+
+
+
{t('accounting.inbox.field.supplierCountryHint', 'Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable).')}
+
setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} />
diff --git a/frontend/src/pages/admin/bills/BillEditorPage.tsx b/frontend/src/pages/admin/bills/BillEditorPage.tsx
index 1980d1e6..c2f0245f 100644
--- a/frontend/src/pages/admin/bills/BillEditorPage.tsx
+++ b/frontend/src/pages/admin/bills/BillEditorPage.tsx
@@ -15,6 +15,8 @@ import { contractsService } from '../../../services/contracts.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
+import { accountingService } from '../../../services/accounting.service';
+import { vatCodesService } from '../../../services/vatCodes.service';
import { LineItemsTable, type EditableLineItem } from '../../../components/admin/LineItemsTable';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
@@ -197,6 +199,23 @@ export const BillEditorPage: React.FC = () => {
setCcPdfEmail((cur) => cur || currentAdmin.email);
}, [currentAdmin?.email, isEdit]);
+ // Seed the VAT from the configured default OUTPUT code (Settings →
+ // Accounting) on a brand-new, blank invoice — so new invoices don't silently
+ // start at 0%. Skips edits and conversions (quote/contract bring their own
+ // VAT), and never clobbers a value the admin already touched.
+ const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
+ const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
+ const didSeedVatRef = useRef(false);
+ useEffect(() => {
+ if (isEdit || didSeedVatRef.current) return;
+ if (searchParams.get('fromContractId') || searchParams.get('fromQuoteId')) return;
+ if (vatCode || vatRate) return;
+ const code = acctSettings?.accounting_default_output_vat_code;
+ if (!code || !outputVatCodes) return;
+ const match = outputVatCodes.find((c) => c.code === code);
+ if (match) { didSeedVatRef.current = true; setVatRate(Number(match.rate)); setVatCode(match.code); }
+ }, [isEdit, searchParams, acctSettings, outputVatCodes, vatCode, vatRate]);
+
// Pre-fill the customer when the editor is opened from a customer
// detail page via `?customerAccountId=42`. Runs once on mount, only
// when creating a new invoice, and skips if the user has already
diff --git a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
index a9ee3f2d..4e3ac177 100644
--- a/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
+++ b/frontend/src/pages/admin/quotes/QuoteEditorPage.tsx
@@ -26,6 +26,8 @@ import { LineItemsTable, type EditableLineItem } from '../../../components/admin
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
import { VatRateSelect } from '../../../components/admin/VatRateSelect';
+import { accountingService } from '../../../services/accounting.service';
+import { vatCodesService } from '../../../services/vatCodes.service';
import { InstallmentsPanel } from '../../../components/admin/InstallmentsPanel';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
@@ -189,6 +191,23 @@ export const QuoteEditorPage: React.FC = () => {
}
})();
}, [isEdit, searchParams]);
+
+ // Seed the VAT from the configured default OUTPUT code (Settings →
+ // Accounting) on a brand-new, blank quote — so quotes (and the invoices they
+ // convert to) don't silently start at 0%. Never clobbers a touched value.
+ const { data: acctSettings } = useQuery({ queryKey: ['accounting-settings'], queryFn: () => accountingService.getSettings() });
+ const { data: outputVatCodes } = useQuery({ queryKey: ['vat-codes', 'output'], queryFn: () => vatCodesService.listOutput() });
+ const didSeedVatRef = useRef(false);
+ useEffect(() => {
+ if (isEdit || didSeedVatRef.current) return;
+ const code = acctSettings?.accounting_default_output_vat_code;
+ if (!code || !outputVatCodes) return;
+ const match = outputVatCodes.find((c) => c.code === code);
+ if (!match) return;
+ setForm((prev) => (prev.vatCode || prev.vatRate ? prev : { ...prev, vatRate: Number(match.rate), vatCode: match.code }));
+ didSeedVatRef.current = true;
+ }, [isEdit, acctSettings, outputVatCodes]);
+
// Customer search + inline-create state now lives inside
// (migration C.5 extraction).
diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts
index 90d54e5c..c2d4d093 100644
--- a/frontend/src/services/accounting.service.ts
+++ b/frontend/src/services/accounting.service.ts
@@ -39,6 +39,8 @@ export interface InboundDocument {
customerAccountId: number | null;
customerName: string | null;
customerEmail: string | null;
+ /** ISO-2 supplier country — auto-defaults the tax treatment (reclaim list). */
+ supplierCountry: string | null;
/** Free-text categorisation note. */
note: string | null;
supplierPaid: boolean;
@@ -112,6 +114,8 @@ export interface AccountingSettings {
/** ISO-2 countries whose input VAT can be reclaimed (drives cost
* tax-treatment + the report's VAT-payable). */
accounting_vat_reclaim_countries: string[];
+ /** Output VAT code stamped onto NEW invoices/quotes ('' = none). */
+ accounting_default_output_vat_code: string;
}
export interface CategorizePayload {
@@ -210,6 +214,8 @@ export const accountingService = {
accounting_vat_registered: data.accounting_vat_registered === true,
accounting_vat_reclaim_countries: Array.isArray(data.accounting_vat_reclaim_countries)
? data.accounting_vat_reclaim_countries : [],
+ accounting_default_output_vat_code: typeof data.accounting_default_output_vat_code === 'string'
+ ? data.accounting_default_output_vat_code : '',
};
},
async updateSettings(payload: Partial): Promise<{ updated: string[] }> {