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[] }> {