fix(accounting): address the-luap PR #636 review

- #1 resolveTaxTreatment: an unconfigured (empty) reclaim-countries list no
  longer auto-classifies every supplier — incl. the admin's own domestic one —
  as foreign; defer auto-classification until the setting is set (+ test).
- #2 pending re-bills on customer erase: eraseCustomer now returns the
  customer's not-yet-billed inbound docs to the inbox (null customer + unsorted)
  so they aren't billable to an anonymized account. (NB: picpeak has no hard
  customer delete — erase anonymizes in place — so the orphan/404 premise can't
  occur; this is hardening.)
- #4 VatRateSelect: when >1 configured code shares the same rate, fall through
  to the legacy "(not configured)" option instead of silently picking the first.
- #5 unwindBilledLine: delete the (mutable, never-issued) invoice when the
  unwound re-bill was its only line, instead of leaving a net-zero survivor.
- #6 isInvoiceMutable: clarify in a comment that invoices have no 'draft' status
  (the editable state is 'scheduled' w/o send-at) — no behaviour change.
- nit: collapse normalizeCurrency's tautological ternary.
- Fix VAT picker i18n: t('vat.legacyRate') → 'ledger.vat.legacyRate' (the key's
  real home), so the legacy label localizes instead of always showing English.
- Remove dead i18n keys left by the settings refactor (businessProfile.field VAT
  /hourly + profileFields.title/savedToast).
This commit is contained in:
Luca
2026-06-18 18:40:11 +02:00
parent 33d5408977
commit 707c5d0277
7 changed files with 45 additions and 22 deletions
@@ -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');
});
@@ -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',
+17
View File
@@ -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);
@@ -38,10 +38,15 @@ export const VatRateSelect: React.FC<Props> = ({ 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<Props> = ({ rate, code, onChange, label, di
>
{showLegacy && (
<option value={LEGACY}>
{t('vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
{t('ledger.vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
</option>
)}
{codes.map((c) => (
+1 -3
View File
@@ -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. */
+1 -8
View File
@@ -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"
},
+1 -8
View File
@@ -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"
},