Files
accounted/lib/supplier-invoices/__tests__/lifecycle.test.ts
T
Jakob Wennberg 7c44cef66d fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted (#1249)
* 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).
2026-07-27 19:45:26 +02:00

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([])
})
})