Files
6a5fd6cd00 feat(supplier-invoices): Inlagd i banken mark for payments entered by hand (#2220) (#2356)
A user who types payments into the internet bank instead of uploading a
betalfil had no way to see which invoices were already handled. Adds a
nullable supplier_invoices.bank_entered_at, a POST
/api/supplier-invoices/{id}/bank-entered route, a labelled checkbox on
the list (trailing slot, once attested) and on the detail header, and a
BEFORE UPDATE trigger that clears the mark when a payment lands, so
every payment path (mark-paid, bank match, v1, MCP) retires it without
knowing it exists. Markera som betald stays a separate action.


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:05:43 +02:00

216 lines
7.5 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
canApproveSupplierInvoice,
canMarkSupplierInvoiceBankEntered,
findChangedVerifikatFields,
findLockedVerifikatFields,
isOverduePayable,
isUnsettledSupplierInvoiceStatus,
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
/**
* These assertions mirror update_overdue_supplier_invoices()
* (20260727160000_supplier_invoice_overdue_symmetric.sql). The pg-real test
* (tests/pg/supplier-invoice-overdue-cron.pg.test.ts) pins the SQL side; this
* file pins the app side so the two cannot drift apart silently.
*/
const TODAY = '2026-07-27'
const PAST = '2026-07-01'
const FUTURE = '2026-12-31'
describe('isOverduePayable', () => {
it('is true for an unpaid payable past its due date', () => {
expect(isOverduePayable({ due_date: PAST, remaining_amount: 1000 }, TODAY)).toBe(true)
})
it('is false on the due date itself (the cron uses due_date < CURRENT_DATE)', () => {
expect(isOverduePayable({ due_date: TODAY, remaining_amount: 1000 }, TODAY)).toBe(false)
})
it('is false when nothing is left to pay, öre rounding included', () => {
expect(isOverduePayable({ due_date: PAST, remaining_amount: 0 }, TODAY)).toBe(false)
expect(isOverduePayable({ due_date: PAST, remaining_amount: 0.004 }, TODAY)).toBe(false)
expect(isOverduePayable({ due_date: PAST, remaining_amount: 0.01 }, TODAY)).toBe(true)
})
it('is false for a credit note: a kreditfaktura is not a payable', () => {
expect(
isOverduePayable({ due_date: PAST, remaining_amount: 1000, is_credit_note: true }, TODAY),
).toBe(false)
})
})
describe('resolveUnsettledStatus', () => {
it('returns overdue for a past-due payable regardless of attest state', () => {
expect(resolveUnsettledStatus({ due_date: PAST, remaining_amount: 1000 }, TODAY)).toBe('overdue')
expect(
resolveUnsettledStatus(
{ due_date: PAST, remaining_amount: 1000, approved_at: '2026-07-02T08:00:00Z' },
TODAY,
),
).toBe('overdue')
})
it('un-flips to registered when the due date moves out of the past', () => {
expect(resolveUnsettledStatus({ due_date: FUTURE, remaining_amount: 1000 }, TODAY)).toBe(
'registered',
)
})
it('un-flips to approved when the invoice was attested', () => {
expect(
resolveUnsettledStatus(
{ due_date: FUTURE, remaining_amount: 1000, approved_at: '2026-07-02T08:00:00Z' },
TODAY,
),
).toBe('approved')
})
})
describe('isUnsettledSupplierInvoiceStatus', () => {
it('covers exactly the statuses the overdue flip owns', () => {
expect(isUnsettledSupplierInvoiceStatus('registered')).toBe(true)
expect(isUnsettledSupplierInvoiceStatus('approved')).toBe(true)
expect(isUnsettledSupplierInvoiceStatus('overdue')).toBe(true)
for (const settled of ['paid', 'partially_paid', 'credited', 'reversed', 'disputed']) {
expect(isUnsettledSupplierInvoiceStatus(settled)).toBe(false)
}
})
})
describe('canApproveSupplierInvoice', () => {
it('allows a registered invoice', () => {
expect(canApproveSupplierInvoice({ status: 'registered' })).toBe(true)
})
it('allows an overdue invoice that has never been attested', () => {
expect(canApproveSupplierInvoice({ status: 'overdue', approved_at: null })).toBe(true)
})
it('refuses once approved_at is set, so approval is idempotent', () => {
expect(
canApproveSupplierInvoice({ status: 'overdue', approved_at: '2026-07-02T08:00:00Z' }),
).toBe(false)
expect(
canApproveSupplierInvoice({ status: 'approved', approved_at: '2026-07-02T08:00:00Z' }),
).toBe(false)
})
it('refuses settled statuses', () => {
expect(canApproveSupplierInvoice({ status: 'paid' })).toBe(false)
expect(canApproveSupplierInvoice({ status: 'credited' })).toBe(false)
})
})
/**
* #1230: the two fields that are copied onto the registration verifikat
* (entry_date and the description) must stop being freely writable once that
* verifikat exists, on every update path that shares the schema.
*/
describe('findLockedVerifikatFields', () => {
const BOOKED = {
registration_journal_entry_id: 'je-1',
invoice_date: '2026-06-30',
supplier_invoice_number: 'F-1001',
}
it('locks nothing while the invoice is unbooked', () => {
expect(
findLockedVerifikatFields(
{ invoice_date: '2026-07-15', supplier_invoice_number: 'F-2002' },
{ ...BOOKED, registration_journal_entry_id: null },
),
).toEqual([])
})
it('locks the invoice date once the registration entry is posted', () => {
expect(findLockedVerifikatFields({ invoice_date: '2026-07-15' }, BOOKED)).toEqual([
'invoice_date',
])
})
it('locks the invoice number too: it is part of the verifikat description', () => {
expect(findLockedVerifikatFields({ supplier_invoice_number: 'F-2002' }, BOOKED)).toEqual([
'supplier_invoice_number',
])
})
it('reports every changed field so the error can name them', () => {
expect(
findLockedVerifikatFields(
{ invoice_date: '2026-07-15', supplier_invoice_number: 'F-2002' },
BOOKED,
),
).toEqual(['invoice_date', 'supplier_invoice_number'])
})
it('accepts a resent identical value: a full-form PUT changes nothing', () => {
expect(
findLockedVerifikatFields(
{ invoice_date: '2026-06-30', supplier_invoice_number: 'F-1001', due_date: '2026-08-31' },
BOOKED,
),
).toEqual([])
})
it('leaves due_date, payment_reference and notes alone', () => {
expect(
findLockedVerifikatFields(
{ due_date: '2026-09-30', payment_reference: 'OCR-1', notes: 'ny not' },
BOOKED,
),
).toEqual([])
})
it('treats a first-time delivery/invoice value against a null column as a change', () => {
expect(
findLockedVerifikatFields(
{ supplier_invoice_number: 'F-2002' },
{ ...BOOKED, supplier_invoice_number: null },
),
).toEqual(['supplier_invoice_number'])
})
})
describe('findChangedVerifikatFields', () => {
const ROW = { invoice_date: '2026-06-30', supplier_invoice_number: 'F-1001' }
it('reports the moving fields regardless of whether an entry is posted', () => {
// This is what the routes pin their write on: an unbooked invoice can be
// booked between the lock check and the update, so "would this change a
// verifikat field" has to be answerable without the booked flag.
expect(findChangedVerifikatFields({ invoice_date: '2026-07-15' }, ROW)).toEqual([
'invoice_date',
])
})
it('is empty when the update only resends stored values', () => {
expect(findChangedVerifikatFields({ ...ROW, notes: 'x' }, ROW)).toEqual([])
})
it('is empty for a metadata-only update', () => {
expect(findChangedVerifikatFields({ due_date: '2026-09-30' }, ROW)).toEqual([])
})
})
describe('canMarkSupplierInvoiceBankEntered', () => {
// "Inlagd i banken" (#2220) is the step right before "Markera som betald",
// so it is offered on exactly the rows that action is offered on.
it.each(['approved', 'overdue', 'partially_paid'])('allows a payable %s invoice', (status) => {
expect(canMarkSupplierInvoiceBankEntered({ status })).toBe(true)
})
it.each(['registered', 'paid', 'credited', 'reversed', 'disputed'])(
'refuses a %s invoice',
(status) => {
expect(canMarkSupplierInvoiceBankEntered({ status })).toBe(false)
},
)
it('refuses a credit note whatever its status', () => {
expect(canMarkSupplierInvoiceBankEntered({ status: 'approved', is_credit_note: true })).toBe(false)
})
})