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:
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user