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:
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user