Files
accounted/lib/invoices/matchable-statuses.ts
T
Mattsson 392e847c1e 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
2026-07-30 11:20:08 +02:00

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