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,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'
}