diff --git a/DECISIONS.md b/DECISIONS.md index a9766fdd..bbecc9c0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -697,3 +697,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-29] Center Node AB support deletion (Anders Orback) run manually service-side, bypassing the product's AAL2/consent gate on anonymize_user_account: his written support request is the consent (documented in the audit_log row 8501e9e0), and support had already replied "du behöver inte göra något mer", so the fix-the-button-and-let-him-click alternative would have contradicted a sent mail. Manual run mirrored the delete route + RPC body byte-for-byte; GoTrue admin logout endpoint 404s on our GoTrue version, so global signout was done by deleting auth.sessions rows (equivalent effect, ban blocks refresh regardless). [2026-07-29] Booking-feedback parity: extracted runCategorize's success tail into one finishBooking() rather than copying the toast into the counterparty branch. The counterparty path was already a second, thinner implementation of the same tail (the reason it silently lacked confirmation, undo and the count decrement), so a third copy was the wrong shape. Also caught while wiring the parity test: handleTransactionBooked (manual booking dialog / voucher match) never decremented totalUncategorizedCount either, so the header count stayed one high until the next refetch; fixed. Deliberately NOT given an Ångra action: its `matched` branch links the transaction to a PRE-EXISTING verifikat, and /uncategorize storno-reverses whatever journal_entry_id the transaction points at, so an undo there would reverse a voucher the user never created in that flow. + +[2026-07-30] InvoiceMatchDialog classifies stale targets as matchable, settled, or not open instead of calling every invalid status fully paid: paid and zero-balance targets need different copy from cancelled, credited, disputed, reversed, draft, or malformed targets, while valid partially paid invoices keep the existing amount-difference flow. Blocked targets do not fetch or show a voucher preview or a confirm-outcome panel because neither match route has a reachable success path for them. diff --git a/components/transactions/InvoiceMatchDialog.tsx b/components/transactions/InvoiceMatchDialog.tsx index ecc58f84..39d8b5f6 100644 --- a/components/transactions/InvoiceMatchDialog.tsx +++ b/components/transactions/InvoiceMatchDialog.tsx @@ -10,8 +10,8 @@ import { formatCurrency, formatDate, cn } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' import { isInvoiceBookingRateMissing, previewedFxGainSek } from './invoice-match-fx' import { - isMatchableInvoice, - isMatchableSupplierInvoice, + getInvoiceMatchTargetState, + getSupplierInvoiceMatchTargetState, } from '@/lib/invoices/matchable-statuses' import { CheckCircle2, AlertTriangle, Trash2, Plus, Pencil } from 'lucide-react' import type { TransactionWithInvoice } from './transaction-types' @@ -153,14 +153,16 @@ export default function InvoiceMatchDialog({ // still be stale (fetched before the other match, or settled in another // tab), so re-check here rather than trust the pointer. // - // This is not an advisory guard: both match routes reject a settled target - // outright (MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID), so there is - // no "match anyway" that could succeed. Say so and block, instead of - // computing a diff against a 0 kr remaining balance and calling the result - // a partial payment. - const targetSettled = - (isSupplierInvoice && !isMatchableSupplierInvoice(transaction!.potential_supplier_invoice)) || - (isCustomerInvoice && !isMatchableInvoice(transaction!.potential_invoice)) + // This is not an advisory guard: the match routes reject any target outside + // their open-status CAS lists, so there is no "match anyway" that could + // succeed. Distinguish a paid or zero-balance target from a different + // non-open status so the blocking copy explains the actual problem. + const targetMatchState = isSupplierInvoice + ? getSupplierInvoiceMatchTargetState(transaction!.potential_supplier_invoice) + : isCustomerInvoice + ? getInvoiceMatchTargetState(transaction!.potential_invoice) + : null + const targetBlocked = targetMatchState !== null && targetMatchState !== 'matchable' const [candidate, setCandidate] = useState(null) const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false) @@ -200,7 +202,7 @@ export default function InvoiceMatchDialog({ }, [open]) useEffect(() => { - if (!open || !transactionId) { + if (!open || !transactionId || targetBlocked) { setPreview(null) setPreviewFailure(null) setIsEditing(false) @@ -260,11 +262,27 @@ export default function InvoiceMatchDialog({ return () => { cancelled = true } - }, [open, transactionId, isCustomerInvoice, isSupplierInvoice, invoiceId, supplierInvoiceId, uiLocale]) + }, [ + open, + transactionId, + isCustomerInvoice, + isSupplierInvoice, + invoiceId, + supplierInvoiceId, + targetBlocked, + uiLocale, + ]) useEffect(() => { - if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) { + if ( + !open || + !transactionId || + !isCustomerInvoice || + !onLinkToExisting || + targetBlocked + ) { setCandidate(null) + setIsCheckingDuplicate(false) return } let cancelled = false @@ -285,7 +303,7 @@ export default function InvoiceMatchDialog({ return () => { cancelled = true } - }, [open, transactionId, isCustomerInvoice, onLinkToExisting]) + }, [open, transactionId, isCustomerInvoice, onLinkToExisting, targetBlocked]) // Live balance + validity. The dialog disables Confirm while edit mode is // active and the entry is invalid; an out-of-balance entry can't be sent. @@ -368,9 +386,11 @@ export default function InvoiceMatchDialog({ } const matchTitle = isSupplierInvoice ? t('title_supplier') : t('title_customer') - const matchDescription = isSupplierInvoice - ? t('description_supplier') - : t('description_customer') + const matchDescription = targetBlocked + ? t('description_blocked') + : isSupplierInvoice + ? t('description_supplier') + : t('description_customer') return ( @@ -383,7 +403,7 @@ export default function InvoiceMatchDialog({ {transaction && (isCustomerInvoice || isSupplierInvoice) && (
{/* Duplicate-payment warning: customer-side only, only when a candidate exists */} - {candidate && isCustomerInvoice && ( + {!targetBlocked && candidate && isCustomerInvoice && (
@@ -528,17 +548,24 @@ export default function InvoiceMatchDialog({ The customer branch previously fell back to .total; both branches now mirror the supplier branch's correct logic. */} {(() => { - // Settled target: the amount comparison below would be - // meaningless (it measures against a 0 kr remaining balance and - // reports the whole transaction as a "differens"), and no - // outcome it describes is reachable. Replace it outright. - if (targetSettled) { + // A blocked target makes the amount comparison below + // meaningless, and no outcome it describes is reachable. + if (targetBlocked) { + const isSettled = targetMatchState === 'settled' return (
-

{t('target_settled_title')}

-

{t('target_settled_description')}

+

+ {t(isSettled ? 'target_settled_title' : 'target_not_open_title')} +

+

+ {t( + isSettled + ? 'target_settled_description' + : 'target_not_open_description', + )} +

) @@ -624,7 +651,7 @@ export default function InvoiceMatchDialog({ front instead of showing a confident zero. Rendered on its own rather than inside the Valutaomräkning card below, because in this state the preview 400s and that card never renders. */} - {invoiceRateMissing && ( + {!targetBlocked && invoiceRateMissing && (
@@ -651,7 +678,7 @@ export default function InvoiceMatchDialog({ When the payment-date rate lookup failed, swaps in a manual-rate input so the user can type the rate from their bank statement and retry. */} - {preview?.fx_conversion?.required && (() => { + {!targetBlocked && preview?.fx_conversion?.required && (() => { const fx = preview.fx_conversion if (!fx?.required) return null // fx_conversion is only produced by the customer-invoice preview @@ -755,7 +782,7 @@ export default function InvoiceMatchDialog({ blocked the preview: the ochre panel above already owns that story, and an empty "Bokföring" card with a second phrasing of the same refusal reads as two separate problems. */} - {(preview || (previewFailure && !invoiceRateMissing)) && ( + {!targetBlocked && (preview || (previewFailure && !invoiceRateMissing)) && (

{t('booking_title')}

@@ -928,15 +955,16 @@ export default function InvoiceMatchDialog({
)} - {/* What will happen */} -
-

{t('on_confirm_title')}

-
    -
  • • {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}
  • -
  • • {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}
  • -
  • • {t('on_confirm_voucher')}
  • -
-
+ {!targetBlocked && ( +
+

{t('on_confirm_title')}

+
    +
  • • {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}
  • +
  • • {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}
  • +
  • • {t('on_confirm_voucher')}
  • +
+
+ )}
)} @@ -949,9 +977,9 @@ export default function InvoiceMatchDialog({ disabled={ isConfirming || isCheckingDuplicate || - // Settled target: the route rejects this unconditionally, so the + // Blocked target: the route rejects this unconditionally, so the // button has no reachable success path. - targetSettled || + targetBlocked || (isEditing && !editValidation.isValid) || // Block confirm when cross-currency lookup failed and the user // hasn't typed a manual rate yet. Same-currency and auto-rate diff --git a/lib/invoices/__tests__/matchable-statuses.test.ts b/lib/invoices/__tests__/matchable-statuses.test.ts index 6032b883..5b2ce7d8 100644 --- a/lib/invoices/__tests__/matchable-statuses.test.ts +++ b/lib/invoices/__tests__/matchable-statuses.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest' import { MATCHABLE_INVOICE_STATUSES, MATCHABLE_SUPPLIER_INVOICE_STATUSES, + getInvoiceMatchTargetState, + getSupplierInvoiceMatchTargetState, isMatchableInvoice, isMatchableSupplierInvoice, } from '../matchable-statuses' @@ -32,6 +34,57 @@ describe('matchable status lists', () => { }) }) +describe('invoice match target states', () => { + it('keeps customer partial payments matchable', () => { + expect( + getInvoiceMatchTargetState({ status: 'partially_paid', remaining_amount: 20 }), + ).toBe('matchable') + }) + + it('distinguishes a settled customer invoice from another non-open state', () => { + expect(getInvoiceMatchTargetState({ status: 'paid', remaining_amount: 0 })).toBe('settled') + expect(getInvoiceMatchTargetState({ status: 'sent', remaining_amount: 0 })).toBe('settled') + expect(getInvoiceMatchTargetState({ status: 'cancelled', remaining_amount: 500 })).toBe( + 'not_open', + ) + expect(getInvoiceMatchTargetState({ status: 'credited', remaining_amount: 0 })).toBe( + 'not_open', + ) + }) + + it('keeps supplier partial payments matchable', () => { + expect( + getSupplierInvoiceMatchTargetState({ + status: 'partially_paid', + remaining_amount: 49, + }), + ).toBe('matchable') + }) + + it('distinguishes a settled supplier invoice from another non-open state', () => { + expect( + getSupplierInvoiceMatchTargetState({ status: 'paid', remaining_amount: 0 }), + ).toBe('settled') + expect( + getSupplierInvoiceMatchTargetState({ status: 'registered', remaining_amount: 0 }), + ).toBe('settled') + + for (const status of ['credited', 'disputed', 'reversed']) { + expect( + getSupplierInvoiceMatchTargetState({ status, remaining_amount: 500 }), + ).toBe('not_open') + } + }) + + it('fails closed when a candidate is missing or malformed', () => { + expect(getInvoiceMatchTargetState(null)).toBe('not_open') + expect(getInvoiceMatchTargetState({})).toBe('not_open') + expect( + getSupplierInvoiceMatchTargetState({ status: 'approved', remaining_amount: null }), + ).toBe('settled') + }) +}) + describe('isMatchableInvoice', () => { it('accepts an open invoice with an outstanding balance', () => { expect(isMatchableInvoice({ status: 'sent', remaining_amount: 1250 })).toBe(true) diff --git a/lib/invoices/matchable-statuses.ts b/lib/invoices/matchable-statuses.ts index f5b22541..f5ccae6b 100644 --- a/lib/invoices/matchable-statuses.ts +++ b/lib/invoices/matchable-statuses.ts @@ -21,6 +21,39 @@ export const MATCHABLE_SUPPLIER_INVOICE_STATUSES = [ 'partially_paid', ] as const +export type InvoiceMatchTargetState = 'matchable' | 'settled' | 'not_open' + +type MatchCandidate = { + status?: string | null + remaining_amount?: number | null +} + +function getMatchTargetState( + candidate: MatchCandidate | null | undefined, + matchableStatuses: readonly string[], +): InvoiceMatchTargetState { + if (!candidate?.status) return 'not_open' + + const hasMatchableStatus = matchableStatuses.includes(candidate.status) + if (!hasMatchableStatus) { + return candidate.status === 'paid' ? 'settled' : 'not_open' + } + + return (candidate.remaining_amount ?? 0) > 0 ? 'matchable' : 'settled' +} + +export function getInvoiceMatchTargetState( + candidate: MatchCandidate | null | undefined, +): InvoiceMatchTargetState { + return getMatchTargetState(candidate, MATCHABLE_INVOICE_STATUSES) +} + +export function getSupplierInvoiceMatchTargetState( + candidate: MatchCandidate | null | undefined, +): InvoiceMatchTargetState { + return getMatchTargetState(candidate, MATCHABLE_SUPPLIER_INVOICE_STATUSES) +} + /** * A candidate is matchable when its status is still open AND it has an * outstanding balance. Both columns are NOT NULL in the schema (migrations @@ -28,21 +61,13 @@ export const MATCHABLE_SUPPLIER_INVOICE_STATUSES = [ * legitimate suggestion here. */ export function isMatchableInvoice( - candidate: { status?: string | null; remaining_amount?: number | null } | null | undefined, + candidate: MatchCandidate | null | undefined, ): boolean { - if (!candidate?.status) return false - return ( - (MATCHABLE_INVOICE_STATUSES as readonly string[]).includes(candidate.status) && - (candidate.remaining_amount ?? 0) > 0 - ) + return getInvoiceMatchTargetState(candidate) === 'matchable' } export function isMatchableSupplierInvoice( - candidate: { status?: string | null; remaining_amount?: number | null } | null | undefined, + candidate: MatchCandidate | null | undefined, ): boolean { - if (!candidate?.status) return false - return ( - (MATCHABLE_SUPPLIER_INVOICE_STATUSES as readonly string[]).includes(candidate.status) && - (candidate.remaining_amount ?? 0) > 0 - ) + return getSupplierInvoiceMatchTargetState(candidate) === 'matchable' } diff --git a/messages/en.json b/messages/en.json index b2bb957d..86fcf097 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2579,6 +2579,7 @@ "title_customer": "Confirm invoice match", "description_supplier": "Link this transaction to the supplier invoice? The invoice will be marked as paid and a payment voucher created.", "description_customer": "Link this transaction to the invoice? The invoice will be marked as paid.", + "description_blocked": "The selected invoice can no longer be matched against this transaction.", "duplicate_title": "Possible duplicate posting", "duplicate_body_same_date": "There is already a posted journal entry {label} for the same amount ({amount}) on the same date. Have you already posted this payment manually?", "duplicate_body_window": "There is already a posted journal entry {label} for the same amount ({amount}) within ±7 days ({date}). Have you already posted this payment manually?", @@ -2599,7 +2600,9 @@ "partial_payment_note": ": the invoice will become partially paid.", "ore_rounding_note": "The {amount} difference is booked as rounding (account 3740). The invoice is marked as paid.", "target_settled_title": "This invoice is already fully paid", - "target_settled_description": "This match suggestion is out of date: the invoice was paid by another transaction and cannot be matched again. Close this dialog and match the transaction against the right invoice, or book it as usual.", + "target_settled_description": "No unpaid amount remains on this invoice, so it cannot be matched again. Close this dialog and select the right invoice, or book the transaction as usual.", + "target_not_open_title": "This invoice is not open for payment", + "target_not_open_description": "The invoice has changed since this match was suggested and can no longer be matched. Close this dialog and select an open invoice, or book the transaction as usual.", "fx_title": "Currency conversion", "fx_rate_description": "Riksbanken mid-rate {date}: 1 {invoiceCurrency} = {rate} SEK", "fx_paid_in_invoice_currency": "Payment equals: {amount}", diff --git a/messages/sv.json b/messages/sv.json index 077371bc..548b4278 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -2579,6 +2579,7 @@ "title_customer": "Bekräfta fakturamatchning", "description_supplier": "Vill du koppla denna transaktion till leverantörsfakturan? Fakturan kommer att markeras som betald och en betalningsverifikation skapas.", "description_customer": "Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald.", + "description_blocked": "Den valda fakturan kan inte längre matchas mot transaktionen.", "duplicate_title": "Möjlig dubblettbokning", "duplicate_body_same_date": "Det finns redan en bokförd verifikation {label} på samma belopp ({amount}) på samma datum. Har du redan bokfört denna betalning manuellt?", "duplicate_body_window": "Det finns redan en bokförd verifikation {label} på samma belopp ({amount}) inom ±7 dagar ({date}). Har du redan bokfört denna betalning manuellt?", @@ -2599,7 +2600,9 @@ "partial_payment_note": ": fakturan blir delbetald.", "ore_rounding_note": "Differens {amount} bokförs som öresavrundning (konto 3740). Fakturan markeras som betald.", "target_settled_title": "Fakturan är redan slutbetald", - "target_settled_description": "Matchningsförslaget är inaktuellt: fakturan har betalats av en annan transaktion och kan inte matchas igen. Stäng dialogen och matcha transaktionen mot rätt faktura, eller bokför den som vanligt.", + "target_settled_description": "Ingen obetald summa återstår på fakturan, så den kan inte matchas igen. Stäng dialogen och välj rätt faktura, eller bokför transaktionen som vanligt.", + "target_not_open_title": "Fakturan är inte öppen för betalning", + "target_not_open_description": "Fakturan har ändrats sedan matchningsförslaget skapades och kan inte längre matchas. Stäng dialogen och välj en öppen faktura, eller bokför transaktionen som vanligt.", "fx_title": "Valutaomräkning", "fx_rate_description": "Riksbankens mittkurs {date}: 1 {invoiceCurrency} = {rate} SEK", "fx_paid_in_invoice_currency": "Inbetalning motsvarar: {amount}",