diff --git a/backend/__tests__/services/expenseService.markup.test.js b/backend/__tests__/services/expenseService.markup.test.js index a718a2a1..05b7b823 100644 --- a/backend/__tests__/services/expenseService.markup.test.js +++ b/backend/__tests__/services/expenseService.markup.test.js @@ -131,6 +131,11 @@ describe('resolveTaxTreatment (supplier-country auto-default)', () => { expect(resolveTaxTreatment(undefined, '', reclaim)).toBe('domestic'); expect(resolveTaxTreatment(undefined, null, reclaim)).toBe('domestic'); }); + it('an UNCONFIGURED (empty) reclaim list never auto-classifies as foreign (PR #636 #1)', () => { + expect(resolveTaxTreatment(undefined, 'CH', [])).toBe('domestic'); + expect(resolveTaxTreatment(undefined, 'DE', [])).toBe('domestic'); + expect(resolveTaxTreatment(undefined, 'US', undefined)).toBe('domestic'); + }); it('invalid explicit treatment is ignored (falls through to country logic)', () => { expect(resolveTaxTreatment('bogus', 'DE', reclaim)).toBe('foreign_vat_non_reclaimable'); }); diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js index db83b540..041790e0 100644 --- a/backend/src/services/customerAccountsService.js +++ b/backend/src/services/customerAccountsService.js @@ -785,6 +785,18 @@ async function eraseCustomer(id, erasedByAdminId) { // Active reset tokens for this customer should be invalidated. await trx('customer_password_resets').where('customer_account_id', id).del(); + + // Pending re-bills (incoming invoices, migration 132) attached to this + // customer would otherwise stay billable to the now-anonymized account — + // return the not-yet-billed ones to the inbox for re-triage so they're not + // silently lost or billed to a ghost (PR #636 review #2). Guarded for + // schema drift on installs that predate migration 132. + if (await trx.schema.hasColumn('inbound_documents', 'customer_account_id')) { + await trx('inbound_documents') + .where({ customer_account_id: id }) + .whereNull('billed_invoice_id') + .update({ customer_account_id: null, disposition: null, status: 'unsorted', updated_at: new Date() }); + } }); await logActivity('customer_erased', diff --git a/backend/src/services/expenseService.js b/backend/src/services/expenseService.js index de5499e6..8943805a 100644 --- a/backend/src/services/expenseService.js +++ b/backend/src/services/expenseService.js @@ -76,6 +76,10 @@ function resolveTaxTreatment(payloadTreatment, supplierCountry, reclaimCountries if (TAX_TREATMENTS.includes(payloadTreatment)) return payloadTreatment; const cc = String(supplierCountry || '').toUpperCase(); if (!cc) return 'domestic'; + // Don't auto-classify until the admin has actually configured their reclaim + // countries — an unset (empty) list must not make every supplier, including + // the admin's own domestic one, "foreign". (PR #636 review #1.) + if (!reclaimCountries || reclaimCountries.length === 0) return 'domestic'; return reclaimCountries.includes(cc) ? 'domestic' : 'foreign_vat_non_reclaimable'; } @@ -287,6 +291,10 @@ const BOOKING_DISPOSITIONS = ['rebill', 'durchlaufend']; function isInvoiceMutable(invoice) { if (!invoice) return true; // referenced invoice gone — treat as not billed if (invoice.is_monthly_draft === true || invoice.is_monthly_draft === 1) return true; + // NB: invoices have no 'draft' status (only quotes do). The editable, + // not-yet-sent invoice state IS 'scheduled' with no scheduled_send_at (or a + // future one), handled below — so there is no plain-'draft' case to slot in + // here (PR #636 review #6). if (invoice.status !== 'scheduled') return false; if (!invoice.scheduled_send_at) return true; return new Date(invoice.scheduled_send_at).getTime() > Date.now(); @@ -312,6 +320,15 @@ async function unwindBilledLine(trx, doc) { } if (invoice) { const allItems = await trx('invoice_line_items').where({ invoice_id: invoice.id }); + if (allItems.length === 0) { + // The unwound re-bill was the only line — a net-zero invoice has no reason + // to survive, and these would otherwise pile up over re-categorisations. + // It's mutable (checked above) and never issued, so delete it outright + // (PR #636 review #5). For a monthly draft this just means the next append + // re-creates one. + await trx('invoices').where({ id: invoice.id }).del(); + return; + } let netMinor = 0; for (const li of allItems) { if (li.parent_line_item_id == null) netMinor += Number(li.line_total_minor || 0); diff --git a/frontend/src/components/admin/VatRateSelect.tsx b/frontend/src/components/admin/VatRateSelect.tsx index 588f3f40..6427a067 100644 --- a/frontend/src/components/admin/VatRateSelect.tsx +++ b/frontend/src/components/admin/VatRateSelect.tsx @@ -38,10 +38,15 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di }); // Selected option: prefer the snapshotted code; else a code whose rate matches - // (legacy rows / no code stored); else the document's value is "off-list". + // (legacy rows / no code stored) — BUT only when that rate is unambiguous. If + // two configured codes share the rate (e.g. two 8.1% codes), rate-matching + // could silently swap one for the other on the next save, so fall through to + // the legacy "(not configured)" option and make the admin pick explicitly + // (PR #636 review #4). const matched: VatCodeOption | undefined = (code ? codes.find((c) => c.code === code) : undefined) - || (!code ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined); + || (!code && codes.filter((c) => Number(c.rate) === Number(rate)).length === 1 + ? codes.find((c) => Number(c.rate) === Number(rate)) : undefined); const showLegacy = !matched; return ( @@ -61,7 +66,7 @@ export const VatRateSelect: React.FC = ({ rate, code, onChange, label, di > {showLegacy && ( )} {codes.map((c) => ( diff --git a/frontend/src/constants/currencies.ts b/frontend/src/constants/currencies.ts index 0b2478e8..cd094b60 100644 --- a/frontend/src/constants/currencies.ts +++ b/frontend/src/constants/currencies.ts @@ -17,9 +17,7 @@ export const CURRENCY_CODES: string[] = [ * extra option so nothing is lost), or '' for empty input. */ export function normalizeCurrency(value: string | null | undefined): string { - const cleaned = (value || '').trim().toUpperCase(); - if (!cleaned) return ''; - return CURRENCY_CODES.includes(cleaned) ? cleaned : cleaned; + return (value || '').trim().toUpperCase(); } /** Build the option list, prepending an unknown-but-set value so it's preserved. */ diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f71f6aaf..c8fbd7f6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1737,13 +1737,11 @@ "disclaimer": "Sätze und MwSt-/Steuerbehandlung dienen nur als Orientierung — mit Ihrem Treuhänder prüfen.", "savedToast": "Buchhaltungseinstellungen gespeichert.", "profileFields": { - "title": "MwSt-Bezeichnung & Stundensatz", "vatLabel": "MwSt-Bezeichnung (z. B. MwSt., VAT)", "vatLabelHint": "Wird als Bezeichnung der MwSt-Zeile auf Rechnungs-/Angebots-PDFs gedruckt. Leer lassen, um die Standardbezeichnung der Dokumentsprache zu verwenden.", "hourlyRate": "Standard-Stundensatz", "hourlyRatePlaceholder": "z. B. 120.00", - "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.", - "savedToast": "Gespeichert." + "hourlyRateHint": "Verrechnungs-Fallback, wenn ein Kunde keinen eigenen Satz hat (Stundenerfassung). In {{currency}}, in Hauptwährungseinheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen." } } }, @@ -4373,11 +4371,6 @@ "defaultCurrency": "Standardwährung", "defaultLocale": "Standardsprache", "timezone": "Zeitzone (IANA)", - "vatLabel": "MwSt-Bezeichnung", - "vatRateDefault": "Standard-MwSt-Satz %", - "defaultHourlyRate": "Standard-Stundensatz", - "defaultHourlyRatePlaceholder": "z. B. 120.00", - "defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.", "defaultQrFormat": "Standard-QR-Format", "footerLine": "Fusszeile" }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e1d02ea1..26973935 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1295,13 +1295,11 @@ "disclaimer": "Rates and VAT/tax treatment are guidance only — verify with your Treuhänder.", "savedToast": "Accounting settings saved.", "profileFields": { - "title": "VAT label & hourly rate", "vatLabel": "VAT label (e.g. MwSt., VAT)", "vatLabelHint": "Printed as the VAT-line label on invoice / quote PDFs. Leave blank to use the document language default.", "hourlyRate": "Default hourly rate", "hourlyRatePlaceholder": "e.g. 120.00", - "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.", - "savedToast": "Saved." + "hourlyRateHint": "Billing fallback used when a customer has no own rate (hours logging). In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate." } } }, @@ -4371,11 +4369,6 @@ "defaultCurrency": "Default currency", "defaultLocale": "Default locale", "timezone": "Timezone (IANA)", - "vatLabel": "VAT label (e.g. MwSt., VAT)", - "vatRateDefault": "Default VAT rate %", - "defaultHourlyRate": "Default hourly rate", - "defaultHourlyRatePlaceholder": "e.g. 120.00", - "defaultHourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.", "defaultQrFormat": "Default invoice QR", "footerLine": "PDF footer line" },