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).
This commit is contained in:
Jakob Wennberg
2026-07-27 19:45:26 +02:00
committed by GitHub
parent de461c2cf8
commit 7c44cef66d
8 changed files with 472 additions and 8 deletions
+16
View File
@@ -1168,6 +1168,22 @@ const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
message_en:
'Only unsettled supplier invoices can be edited. Paid, credited and reversed invoices are corrected with a credit note or a storno.',
},
SI_EDIT_VERIFIKAT_LOCKED: {
httpStatus: 400,
message_sv:
'Fakturadatum och fakturanummer står på det bokförda verifikatet och kan inte ändras här. ' +
'Rätta verifikatet (rättelse i öppen period, annars storno + ny bokföring) eller kreditera fakturan. ' +
'Förfallodatum, betalningsreferens och anteckningar går fortfarande att ändra.',
message_en:
'Invoice date and invoice number are part of the posted verifikat and cannot be changed here. ' +
'Correct the entry instead (inline rättelse in an open period, otherwise storno + re-book), or credit the invoice. ' +
'due_date, payment_reference and notes remain editable.',
remediation: {
description:
'Correct the registration verifikat through a sanctioned rättelse path, or credit the supplier invoice and register a corrected one.',
tool: 'gnubok_correct_entry',
},
},
SI_APPROVE_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte godkänna leverantörsfakturan.',
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
canApproveSupplierInvoice,
findChangedVerifikatFields,
findLockedVerifikatFields,
isOverduePayable,
isUnsettledSupplierInvoiceStatus,
resolveUnsettledStatus,
@@ -100,3 +102,94 @@ describe('canApproveSupplierInvoice', () => {
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([])
})
})
+72
View File
@@ -94,3 +94,75 @@ export function canApproveSupplierInvoice(invoice: {
if (invoice.approved_at) return false
return invoice.status === 'registered' || invoice.status === 'overdue'
}
/**
* Fields that are copied onto the registration verifikat when it is posted:
*
* - invoice_date -> journal_entries.entry_date (and the fiscal period the
* entry was filed in), lib/bookkeeping/supplier-invoice-entries.ts
* - supplier_invoice_number -> the verifikat description ("Leverantörsfaktura
* <nr>, <leverantör>") and every line_description built
* from it
*
* BFL 5 kap 6-7 § makes "datum för affärshändelsen" and the identification of
* the underlying verifikation mandatory verifikat content, and 5 kap 5 §
* requires a correction to leave the original visible. Rewriting either field
* on the invoice row after the entry is posted satisfies neither: the entry
* keeps its original values, nothing lands in journal_entry_rattelse_log, and
* the invoice and its verifikat silently disagree (#1230).
*/
export const VERIFIKAT_CRITICAL_SUPPLIER_INVOICE_FIELDS = [
'invoice_date',
'supplier_invoice_number',
] as const
export type VerifikatCriticalSupplierInvoiceField =
(typeof VERIFIKAT_CRITICAL_SUPPLIER_INVOICE_FIELDS)[number]
/**
* The verifikat-critical fields an update would actually move, ignoring
* whether an entry has been posted yet.
*
* Only a *differing* value is reported: clients that PUT the whole form back
* (the dashboard edit dialog resends every field it rendered) must keep
* working, and resending the stored value changes nothing on the verifikat.
* Amounts and accounts are not listed because the update schema cannot reach
* them; the damage this guards against is metadata drift, not entry balance.
*/
export function findChangedVerifikatFields(
// Callers hand over the whole validated update body, so unrelated keys
// (due_date, notes, ...) have to be accepted rather than stripped first.
update: Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null | undefined>> & {
[key: string]: unknown
},
existing: Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null>>,
): VerifikatCriticalSupplierInvoiceField[] {
return VERIFIKAT_CRITICAL_SUPPLIER_INVOICE_FIELDS.filter((field) => {
const next = update[field]
if (next === undefined) return false
return next !== (existing[field] ?? null)
})
}
/**
* The verifikat-critical fields an update would change on an invoice whose
* registration entry is already posted. Empty means the update is safe.
*
* An empty result on an as-yet unbooked invoice is only true as of the read it
* was computed from: a registration entry can be posted between that read and
* the write. Callers must therefore pin `registration_journal_entry_id is null`
* on the update itself whenever findChangedVerifikatFields() is non-empty and
* this returns empty, so a concurrent posting turns into zero matched rows
* rather than the very drift this guards against.
*/
export function findLockedVerifikatFields(
update: Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null | undefined>> & {
[key: string]: unknown
},
existing: {
registration_journal_entry_id?: string | null
} & Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null>>,
): VerifikatCriticalSupplierInvoiceField[] {
if (!existing.registration_journal_entry_id) return []
return findChangedVerifikatFields(update, existing)
}