392e847c1e
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
74 lines
2.4 KiB
TypeScript
74 lines
2.4 KiB
TypeScript
/**
|
|
* Invoice statuses a bank transaction can still be matched against.
|
|
*
|
|
* These mirror the CAS guards the match routes actually enforce:
|
|
* app/api/transactions/[id]/match-invoice/route.ts (.in('status', ...))
|
|
* app/api/transactions/[id]/match-supplier-invoice/route.ts (.in('status', ...))
|
|
*
|
|
* Every surface that offers a match (suggestion lists, the match dialog, the
|
|
* batch-allocation picker) must filter on the same lists. Offering a target
|
|
* outside them produces a confirm button that can only ever fail with
|
|
* MATCH_INVOICE_ALREADY_PAID / MATCH_SI_ALREADY_PAID.
|
|
*
|
|
* Dependency-free on purpose: client components import this too.
|
|
*/
|
|
export const MATCHABLE_INVOICE_STATUSES = ['sent', 'overdue', 'partially_paid'] as const
|
|
|
|
export const MATCHABLE_SUPPLIER_INVOICE_STATUSES = [
|
|
'registered',
|
|
'approved',
|
|
'overdue',
|
|
'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
|
|
* 20240101000025 / 20260323120001), so a missing value cannot silently hide a
|
|
* legitimate suggestion here.
|
|
*/
|
|
export function isMatchableInvoice(
|
|
candidate: MatchCandidate | null | undefined,
|
|
): boolean {
|
|
return getInvoiceMatchTargetState(candidate) === 'matchable'
|
|
}
|
|
|
|
export function isMatchableSupplierInvoice(
|
|
candidate: MatchCandidate | null | undefined,
|
|
): boolean {
|
|
return getSupplierInvoiceMatchTargetState(candidate) === 'matchable'
|
|
}
|