feat(accounting): supplier-country tax default + configurable default output VAT code
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.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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<number>(NaN);
|
||||
const [perDiemMajor, setPerDiemMajor] = useState<number>(NaN);
|
||||
const [requireProof, setRequireProof] = useState(false);
|
||||
const [vatRegistered, setVatRegistered] = useState(false);
|
||||
const [reclaimCountries, setReclaimCountries] = useState<string[]>([]);
|
||||
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.')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('settings.accounting.vat.defaultOutputCode', 'Default VAT code for new invoices')}</label>
|
||||
<select value={defaultOutputVatCode} onChange={(e) => setDefaultOutputVatCode(e.target.value)} className={inputCls}>
|
||||
<option value="">{t('settings.accounting.vat.defaultOutputCodeNone', '— none (start at 0%) —')}</option>
|
||||
{outputVatCodes.map((c) => <option key={c.id} value={c.code}>{c.name} ({Number(c.rate).toFixed(1)}%)</option>)}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('settings.accounting.vat.defaultOutputCodeHint', 'New invoices and quotes start with this VAT code selected. Existing documents are unaffected.')}</p>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<number>(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<Disposition>(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[
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplier', 'Supplier')}</label><Input value={supplier} onChange={(e) => setSupplier(e.target.value)} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.total', 'Total')}</label><DecimalInput value={amountMajor} onChange={setAmountMajor} fractionDigits={2} className={selectCls} /></div>
|
||||
<div><label className={labelCls}>{t('accounting.inbox.field.currency', 'Currency')}</label><Input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} /></div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.supplierCountry', 'Supplier country')}</label>
|
||||
<select value={supplierCountry} onChange={(e) => setSupplierCountry(e.target.value)} className={selectCls}>
|
||||
<option value="">{t('accounting.inbox.field.supplierCountryNone', '— unknown —')}</option>
|
||||
{sortedCountryOptions(i18n.language).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('accounting.inbox.field.supplierCountryHint', 'Sets the tax treatment automatically: outside your VAT-reclaim countries → foreign VAT (not reclaimable).')}</p>
|
||||
</div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.invoiceDate', 'Invoice date')}</label><LocalizedDateInput value={invoiceDate} onChange={setInvoiceDate} /></div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.reference', 'Payment reference')}</label><Input value={reference} onChange={(e) => setReference(e.target.value)} placeholder={t('accounting.inbox.field.referenceHint', 'QR / ESR reference or message') as string} /></div>
|
||||
<div className="col-span-2"><label className={labelCls}>{t('accounting.inbox.field.note', 'Note')}</label>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// <CustomerPicker> (migration C.5 extraction).
|
||||
|
||||
|
||||
@@ -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<AccountingSettings>): Promise<{ updated: string[] }> {
|
||||
|
||||
Reference in New Issue
Block a user