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
142 lines
5.5 KiB
TypeScript
142 lines
5.5 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
MATCHABLE_INVOICE_STATUSES,
|
|
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
|
|
getInvoiceMatchTargetState,
|
|
getSupplierInvoiceMatchTargetState,
|
|
isMatchableInvoice,
|
|
isMatchableSupplierInvoice,
|
|
} from '../matchable-statuses'
|
|
|
|
// These lists must stay in lockstep with the CAS guards in
|
|
// app/api/transactions/[id]/match-invoice/route.ts and
|
|
// .../match-supplier-invoice/route.ts. A surface offering a target the route
|
|
// rejects produces a confirm button that can only fail.
|
|
describe('matchable status lists', () => {
|
|
it('mirrors the customer-invoice route guard', () => {
|
|
expect([...MATCHABLE_INVOICE_STATUSES]).toEqual(['sent', 'overdue', 'partially_paid'])
|
|
})
|
|
|
|
it('mirrors the supplier-invoice route guard', () => {
|
|
expect([...MATCHABLE_SUPPLIER_INVOICE_STATUSES]).toEqual([
|
|
'registered',
|
|
'approved',
|
|
'overdue',
|
|
'partially_paid',
|
|
])
|
|
})
|
|
|
|
it('never treats a settled state as matchable', () => {
|
|
for (const settled of ['paid', 'credited', 'cancelled', 'draft']) {
|
|
expect([...MATCHABLE_INVOICE_STATUSES]).not.toContain(settled)
|
|
expect([...MATCHABLE_SUPPLIER_INVOICE_STATUSES]).not.toContain(settled)
|
|
}
|
|
})
|
|
})
|
|
|
|
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)
|
|
expect(isMatchableInvoice({ status: 'partially_paid', remaining_amount: 20 })).toBe(true)
|
|
expect(isMatchableInvoice({ status: 'overdue', remaining_amount: 1 })).toBe(true)
|
|
})
|
|
|
|
// The reported bug: an invoice settled by a different transaction keeps
|
|
// status 'paid' / remaining 0, and the dialog measured the bank amount
|
|
// against that 0 and called it a partial payment.
|
|
it('rejects a fully paid invoice', () => {
|
|
expect(isMatchableInvoice({ status: 'paid', remaining_amount: 0 })).toBe(false)
|
|
})
|
|
|
|
it('rejects an open status whose balance is already zero', () => {
|
|
expect(isMatchableInvoice({ status: 'sent', remaining_amount: 0 })).toBe(false)
|
|
})
|
|
|
|
it('rejects non-payable statuses', () => {
|
|
expect(isMatchableInvoice({ status: 'draft', remaining_amount: 500 })).toBe(false)
|
|
expect(isMatchableInvoice({ status: 'cancelled', remaining_amount: 500 })).toBe(false)
|
|
})
|
|
|
|
it('rejects a missing or malformed candidate rather than assuming matchable', () => {
|
|
expect(isMatchableInvoice(null)).toBe(false)
|
|
expect(isMatchableInvoice(undefined)).toBe(false)
|
|
expect(isMatchableInvoice({})).toBe(false)
|
|
expect(isMatchableInvoice({ status: 'sent' })).toBe(false)
|
|
expect(isMatchableInvoice({ status: 'sent', remaining_amount: null })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('isMatchableSupplierInvoice', () => {
|
|
it('accepts the open supplier states', () => {
|
|
expect(isMatchableSupplierInvoice({ status: 'registered', remaining_amount: 549 })).toBe(true)
|
|
expect(isMatchableSupplierInvoice({ status: 'approved', remaining_amount: 549 })).toBe(true)
|
|
expect(isMatchableSupplierInvoice({ status: 'overdue', remaining_amount: 549 })).toBe(true)
|
|
expect(isMatchableSupplierInvoice({ status: 'partially_paid', remaining_amount: 49 })).toBe(true)
|
|
})
|
|
|
|
it('rejects paid and credited targets, matching MATCH_SI_ALREADY_PAID', () => {
|
|
expect(isMatchableSupplierInvoice({ status: 'paid', remaining_amount: 0 })).toBe(false)
|
|
expect(isMatchableSupplierInvoice({ status: 'credited', remaining_amount: 0 })).toBe(false)
|
|
})
|
|
|
|
it('rejects a zero balance even on an open status', () => {
|
|
expect(isMatchableSupplierInvoice({ status: 'registered', remaining_amount: 0 })).toBe(false)
|
|
})
|
|
|
|
it('rejects a missing candidate', () => {
|
|
expect(isMatchableSupplierInvoice(null)).toBe(false)
|
|
expect(isMatchableSupplierInvoice({})).toBe(false)
|
|
})
|
|
})
|