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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user