* fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted invoice_date becomes the registration verifikat's entry_date and supplier_invoice_number goes into its description, but both stayed freely writable through the shared UpdateSupplierInvoiceSchema. Editing either on a booked invoice moved the invoice row while the posted entry kept its original values: the two disagreed silently, nothing landed in journal_entry_rattelse_log, and the change bypassed both sanctioned rättelse paths (BFL 5 kap 5-7 §). Adds findLockedVerifikatFields() next to the other supplier-invoice lifecycle predicates and calls it from both writers (dashboard PUT and v1 PATCH, which also covers the API-key/MCP path). Only a differing value is refused, so a full-form resend of the stored value still succeeds, and due_date, payment_reference and notes stay editable for the aged-invoice flow (#1206). Fixes #1230 * fix(supplier-invoices): make the verifikat-field lock atomic with the write Review follow-up on #1230: the lock check read the row a moment before the update ran, so a registration entry posted in between let exactly the drift the guard exists to prevent slip through. When an update moves a verifikat-critical field on a row that read as unbooked, the write is now pinned with `registration_journal_entry_id is null`. A concurrent posting therefore matches zero rows: the dashboard route returns its existing SI_EDIT_CONFLICT ("reload and try again", and the retry hits the lock with the right message), and the v1 route re-reads to answer with SI_EDIT_VERIFIKAT_LOCKED plus reason=race rather than a guess. The pin is conditional on the update actually moving one of those fields, so metadata-only edits and full-form resends of unchanged values on a booked invoice keep working (#1206).
196 lines
6.7 KiB
TypeScript
196 lines
6.7 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
canApproveSupplierInvoice,
|
|
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([])
|
|
})
|
|
})
|