fix(transactions): block invalid invoice match targets (#1294)

Classify customer and supplier invoice targets as matchable, settled, or otherwise not open. Block invalid targets with localized guidance while retaining the valid partial-payment flow and add focused regression coverage.

Fixes #1260
This commit is contained in:
Mattsson
2026-07-30 11:20:08 +02:00
committed by GitHub
parent 17a7a62ceb
commit 392e847c1e
6 changed files with 167 additions and 53 deletions
+2
View File
@@ -697,3 +697,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+67 -39
View File
@@ -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<DuplicateCandidate | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -383,7 +403,7 @@ export default function InvoiceMatchDialog({
{transaction && (isCustomerInvoice || isSupplierInvoice) && (
<div className="space-y-4">
{/* Duplicate-payment warning: customer-side only, only when a candidate exists */}
{candidate && isCustomerInvoice && (
{!targetBlocked && candidate && isCustomerInvoice && (
<div className="rounded-lg border border-warning/40 bg-warning/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-warning-foreground" />
@@ -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 (
<div className="flex items-start gap-2 p-3 rounded-lg bg-warning/10 text-warning-foreground">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium">{t('target_settled_title')}</p>
<p>{t('target_settled_description')}</p>
<p className="font-medium">
{t(isSettled ? 'target_settled_title' : 'target_not_open_title')}
</p>
<p>
{t(
isSettled
? 'target_settled_description'
: 'target_not_open_description',
)}
</p>
</div>
</div>
)
@@ -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 && (
<div className="rounded-lg border border-warning/40 bg-warning/5 p-4">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 text-warning-foreground flex-shrink-0" />
@@ -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)) && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">{t('booking_title')}</p>
@@ -928,15 +955,16 @@ export default function InvoiceMatchDialog({
</div>
)}
{/* What will happen */}
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}</li>
<li>• {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}</li>
<li>• {t('on_confirm_voucher')}</li>
</ul>
</div>
{!targetBlocked && (
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
<p className="text-sm font-medium">{t('on_confirm_title')}</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li>• {isSupplierInvoice ? t('on_confirm_link_supplier') : t('on_confirm_link_customer')}</li>
<li>• {isSupplierInvoice ? t('on_confirm_mark_paid_supplier') : t('on_confirm_mark_paid_customer')}</li>
<li>• {t('on_confirm_voucher')}</li>
</ul>
</div>
)}
</div>
)}
@@ -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
@@ -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)
+37 -12
View File
@@ -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'
}
+4 -1
View File
@@ -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}",
+4 -1
View File
@@ -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}",