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
+1
View File
@@ -625,3 +625,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] Applied to anthropics/claude-plugins-community, not claude-plugins-official: the official marketplace is curated by Anthropic at its own discretion with no application process, and the submission form explicitly does not feed it. Community listing is the only route we control.
[2026-07-27] support_feedback_submitted now reports BOTH channels (email + ticket) with a derived `lost` flag, not just email delivery. Both channels fail silently from the user's side: email is the guarantee so the UI still shows success when only the ticket failed, and a ticket that never opened leaves nothing in PostHog Support to look at either. Answering "did the ticket open?" previously required reproducing it with devtools open, which is exactly what happened the first time Support shipped. ticket: 'unavailable' is kept distinct from 'failed' because unavailable is the expected steady state (Support off, analytics off) while failed means conversations were live and the call still did not land; only the second is worth alerting on. `lost` (neither channel worked) is the single property to alert on. Still carries no message body, pinned by a test.
[2026-07-27] support_feedback_submitted reports both channels (email + ticket) with a derived `lost` flag, and the ticket call runs concurrently with a 4s cap instead of being awaited after the email: both channels fail silently from the user's side, so "did the ticket open?" was previously only answerable by reproducing the submission with devtools open, and awaiting the ticket sequentially let a hung sendMessage hold the confirmation dialog open despite the code comment claiming it could not. ticket: 'unavailable' stays distinct from 'failed' and 'timeout' because unavailable is the expected steady state (Support off, analytics off, self-hosted) while the other two mean conversations were live and the call still did not land; only those deserve an alert. `lost` (neither channel worked) is the single property to alert on. Still carries no message body, pinned by a test.
[2026-07-27] Supplier-invoice edits (#1230): block invoice_date / supplier_invoice_number once registration_journal_entry_id is set, rather than propagating the change into the verifikat via correct_entry_metadata: the friendlier propagate option turns a metadata PUT into a bookkeeping write (voucher rättelse, rattelse-log, period-lock checks) and needs a deliberate product call; blocking is the minimal legally correct behaviour and leaves due_date/payment_reference/notes editable for the aged-invoice flow (#1206).
@@ -22,6 +22,8 @@ const FUTURE = '2999-01-01'
const updatePayloads: Record<string, unknown>[] = []
const singleResults: { data: unknown; error: unknown }[] = []
/** Columns pinned with .is(col, null) on the write, so CAS guards are assertable. */
const isPredicates: string[] = []
// Capturing chain: .single() walks a queue (first the existing-row read, then
// the update's returning row) and .update() records the exact payload written.
@@ -36,7 +38,10 @@ const chain: any = {
// The write paths pin their compare-and-swap predicates with .in()/.is(),
// so the chain has to accept them too.
in: () => chain,
is: () => chain,
is: (column: string) => {
isPredicates.push(column)
return chain
},
single: () => Promise.resolve(singleResults.shift() ?? { data: null, error: null }),
maybeSingle: () => Promise.resolve(singleResults.shift() ?? { data: null, error: null }),
}
@@ -65,6 +70,7 @@ describe('PUT /api/supplier-invoices/[id]', () => {
vi.clearAllMocks()
updatePayloads.length = 0
singleResults.length = 0
isPredicates.length = 0
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
})
@@ -84,6 +90,9 @@ describe('PUT /api/supplier-invoices/[id]', () => {
remaining_amount: 1000,
is_credit_note: false,
approved_at: null,
invoice_date: '2026-06-30',
supplier_invoice_number: 'F-1001',
registration_journal_entry_id: null,
...overrides,
},
error: null,
@@ -196,6 +205,93 @@ describe('PUT /api/supplier-invoices/[id]', () => {
expect(body.error.code).toBe('SI_EDIT_CONFLICT')
})
// #1230: invoice_date is the registration verifikat's entry_date and
// supplier_invoice_number is in its description. Once that entry exists, a
// metadata PUT must not move them: nothing would land in
// journal_entry_rattelse_log and the row and its verifikat would disagree.
it('refuses to move the invoice date once the registration entry is posted', async () => {
singleResults.push(existingRow({ registration_journal_entry_id: 'je-1' }))
const response = await putRequest({ invoice_date: '2026-07-15' })
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { fields?: string[] } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_EDIT_VERIFIKAT_LOCKED')
expect(body.error.details?.fields).toEqual(['invoice_date'])
expect(updatePayloads).toHaveLength(0)
})
it('refuses to rewrite the invoice number once the registration entry is posted', async () => {
singleResults.push(existingRow({ registration_journal_entry_id: 'je-1' }))
const response = await putRequest({ supplier_invoice_number: 'F-2002' })
const { status, body } = await parseJsonResponse<{
error: { code: string; details?: { fields?: string[] } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('SI_EDIT_VERIFIKAT_LOCKED')
expect(body.error.details?.fields).toEqual(['supplier_invoice_number'])
expect(updatePayloads).toHaveLength(0)
})
it('still allows those fields while the invoice is unbooked, pinned against a concurrent posting', async () => {
singleResults.push(existingRow({ registration_journal_entry_id: null }))
singleResults.push({ data: { id: 'si-1', status: 'registered' }, error: null })
const response = await putRequest({ invoice_date: '2026-07-15' })
expect(response.status).toBe(200)
expect(updatePayloads[0]).toEqual({ invoice_date: '2026-07-15' })
// The lock check read an unbooked row; the write must stay conditional on
// that, or a registration entry posted in between slips past the guard.
expect(isPredicates).toContain('registration_journal_entry_id')
})
it('reports a conflict when a registration entry lands mid-flight', async () => {
singleResults.push(existingRow({ registration_journal_entry_id: null }))
singleResults.push({ data: null, error: null }) // pinned update matched nothing
const response = await putRequest({ invoice_date: '2026-07-15' })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(409)
expect(body.error.code).toBe('SI_EDIT_CONFLICT')
})
it('does not pin the entry column for a metadata-only update', async () => {
// Pinning unconditionally would break editing a booked invoice's due date.
singleResults.push(existingRow({ registration_journal_entry_id: 'je-1' }))
singleResults.push({ data: { id: 'si-1', status: 'registered' }, error: null })
const response = await putRequest({ notes: 'Autogiro' })
expect(response.status).toBe(200)
expect(isPredicates).not.toContain('registration_journal_entry_id')
})
it('lets a booked invoice keep editing due date and notes, and resend unchanged values', async () => {
// The aged-invoice flow (#1206) has to keep working on booked invoices:
// only the two verifikat fields are frozen, and resending them unchanged
// (as a full-form PUT does) changes nothing on the verifikat.
singleResults.push(
existingRow({ status: 'overdue', due_date: PAST, registration_journal_entry_id: 'je-1' }),
)
singleResults.push({ data: { id: 'si-1', status: 'registered' }, error: null })
const response = await putRequest({
due_date: FUTURE,
invoice_date: '2026-06-30',
supplier_invoice_number: 'F-1001',
notes: 'Uppgörelse om ny förfallodag',
})
expect(response.status).toBe(200)
expect(updatePayloads[0]).toMatchObject({ due_date: FUTURE, status: 'registered' })
})
it('does not un-flip a credit note into a payable label', async () => {
// Credit notes are created registered/remaining 0 and are never payables:
// the cron ignores them in both directions.
+34 -1
View File
@@ -6,6 +6,8 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
import {
findChangedVerifikatFields,
findLockedVerifikatFields,
isUnsettledSupplierInvoiceStatus,
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
@@ -43,9 +45,12 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
// even extend the due date to un-overdue them (#1206). The update body only
// carries metadata (numbers, dates, reference, notes), never amounts or
// accounts, so a posted registration verifikat cannot be desynced by money.
// The two fields that DO reach the verifikat are gated separately below.
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status, due_date, remaining_amount, is_credit_note, approved_at')
.select(
'status, due_date, remaining_amount, is_credit_note, approved_at, invoice_date, supplier_invoice_number, registration_journal_entry_id',
)
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -65,6 +70,25 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response
const body = validation.data
// A posted registration verifikat carries the invoice date (as entry_date)
// and the invoice number (in the description). Rewriting either here would
// desync the two silently and outside both sanctioned rättelse paths, so it
// is refused with a pointer at the right route (#1230).
const lockedFields = findLockedVerifikatFields(body, existing)
if (lockedFields.length > 0) {
return errorResponseFromCode('SI_EDIT_VERIFIKAT_LOCKED', log, {
requestId,
details: {
fields: lockedFields,
journalEntryId: existing.registration_journal_entry_id,
},
})
}
// Unbooked, but the update does move a verifikat-critical field: the write
// below has to stay conditional on the invoice still being unbooked.
const movesVerifikatFields = findChangedVerifikatFields(body, existing).length > 0
// Keep the overdue label in step with the due date this update lands on
// instead of waiting for the next cron run: extending the due date should
// clear "Förfallen" immediately, and moving it into the past should set it.
@@ -86,6 +110,15 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
.eq('id', id)
.eq('company_id', companyId)
if (movesVerifikatFields) {
// The lock check above read a row that was still unbooked. A registration
// entry posted between that read and this write would let exactly the
// drift this guards against slip through, so pin the column: a concurrent
// posting then matches zero rows and the caller is told to reload (the
// retry hits the lock with the right message).
update = update.is('registration_journal_entry_id', null)
}
if (rewritesStatus) {
// Compare-and-swap, but only when this write derives a new status. The
// label is computed from facts read a moment ago, so writing it back
@@ -20,6 +20,10 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
import {
findChangedVerifikatFields,
findLockedVerifikatFields,
} from '@/lib/supplier-invoices/lifecycle'
// V1-only strict variant. The shared `UpdateSupplierInvoiceSchema` is also
// consumed by the dashboard, where unknown keys are silently stripped, fine
@@ -177,12 +181,13 @@ registerEndpoint({
description:
'Patches a supplier invoice with the supplied fields. Only allowed on `registered` status: once approved, paid, or credited, the record is effectively immutable from the API\'s perspective. Idempotent (mandatory Idempotency-Key). Dry-runnable.',
useWhen:
'You need to fix a typo in supplier_invoice_number, adjust dates, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first.',
'You need to adjust due_date, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first.',
doNotUseFor:
'Editing line items (immutable: credit the SI and register a new one). Changing status (use action verbs). Approved/paid/credited SIs (returns 400 SI_NOT_DRAFT).',
'Editing line items (immutable: credit the SI and register a new one). Changing status (use action verbs). Approved/paid/credited SIs (returns 400 SI_NOT_DRAFT). invoice_date / supplier_invoice_number on an SI that already has a registration verifikat (returns 400 SI_EDIT_VERIFIKAT_LOCKED).',
pitfalls: [
'Returns 400 SI_NOT_DRAFT when current status !== "registered".',
'invoice_date / due_date changes do not re-post the registration JE; if the entry date needs to change, credit the SI and re-register.',
'invoice_date and supplier_invoice_number are on the posted registration verifikat (entry_date and description). Once registration_journal_entry_id is set, patching them returns 400 SI_EDIT_VERIFIKAT_LOCKED: correct the entry via a rättelse (gnubok_correct_entry) or credit the SI and re-register. Resending the unchanged value is accepted.',
'Patching a field never re-posts the registration JE.',
],
example: {
request: { payment_reference: 'OCR-1234567890' },
@@ -276,11 +281,46 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// For a company without defer_invoice_booking the registration verifikat is
// posted at create time, so a 'registered' SI is normally already booked.
// invoice_date is that entry's entry_date and supplier_invoice_number is in
// its description: patching either here would desync the verifikat outside
// both sanctioned rättelse paths (#1230). Dry-run is gated too, so the
// preview never promises a write the real call refuses.
const lockedFields = findLockedVerifikatFields(
body,
existing as {
registration_journal_entry_id: string | null
invoice_date: string | null
supplier_invoice_number: string | null
},
)
if (lockedFields.length > 0) {
return v1ErrorResponseFromCode('SI_EDIT_VERIFIKAT_LOCKED', ctx.log, {
requestId: ctx.requestId,
details: {
fields: lockedFields,
journal_entry_id: (existing as { registration_journal_entry_id: string | null })
.registration_journal_entry_id,
},
})
}
if (ctx.dryRun) {
return dryRunPreview({ ...existing, ...updateData }, { requestId: ctx.requestId, log: ctx.log })
}
const { data, error } = await ctx.supabase
// The lock check read a row that was still unbooked. If this patch moves a
// verifikat-critical field, the write stays conditional on that: a
// registration entry posted in between must lose the race rather than end
// up disagreeing with the invoice it was built from.
const movesVerifikatFields =
findChangedVerifikatFields(
body,
existing as { invoice_date: string | null; supplier_invoice_number: string | null },
).length > 0
let write = ctx.supabase
.from('supplier_invoices')
.update(updateData)
.eq('company_id', ctx.companyId!)
@@ -288,13 +328,46 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
// Race guard: another request may have approved / paid between the
// pre-flight status check and this update.
.eq('status', 'registered')
.select(SI_DETAIL_COLUMNS)
.maybeSingle()
if (movesVerifikatFields) {
write = write.is('registration_journal_entry_id', null)
}
const { data, error } = await write.select(SI_DETAIL_COLUMNS).maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
// Either the status moved or a registration entry landed. Re-read so the
// caller gets the reason that actually applies instead of a guess.
const { data: current } = await ctx.supabase
.from('supplier_invoices')
.select('status, registration_journal_entry_id, invoice_date, supplier_invoice_number')
.eq('company_id', ctx.companyId!)
.eq('id', invoiceId)
.maybeSingle()
const nowLocked = current
? findLockedVerifikatFields(
body,
current as {
registration_journal_entry_id: string | null
invoice_date: string | null
supplier_invoice_number: string | null
},
)
: []
if (nowLocked.length > 0) {
return v1ErrorResponseFromCode('SI_EDIT_VERIFIKAT_LOCKED', ctx.log, {
requestId: ctx.requestId,
details: {
fields: nowLocked,
journal_entry_id: (current as { registration_journal_entry_id: string | null })
.registration_journal_entry_id,
reason: 'race',
},
})
}
return v1ErrorResponseFromCode('SI_NOT_DRAFT', ctx.log, {
requestId: ctx.requestId,
details: { reason: 'race' },
@@ -136,6 +136,7 @@ function makeFlexibleSupabase(
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const SUPPLIER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const SI_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const JE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const USER_ID = 'user-1'
function makeRequest(url: string, init?: RequestInit): Request {
@@ -1051,6 +1052,85 @@ describe('PATCH /api/v1/companies/:companyId/supplier-invoices/:id', () => {
expect(body.error.code).toBe('SI_NOT_DRAFT')
})
// #1230: the shared guard has to hold on the API-key path too, not just in
// the dashboard route: invoice_date is the posted entry's entry_date and
// supplier_invoice_number is in its description.
it('refuses to move invoice_date on an SI that already has a registration verifikat', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: {
data: { ...SAMPLE_SI, registration_journal_entry_id: JE_ID },
error: null,
},
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`, {
method: 'PATCH',
body: JSON.stringify({ invoice_date: '2026-06-01' }),
}),
detailParams(COMPANY_ID, SI_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SI_EDIT_VERIFIKAT_LOCKED')
expect(body.error.details.fields).toEqual(['invoice_date'])
})
it('still patches due_date on a booked SI, and accepts unchanged verifikat fields', async () => {
const booked = { ...SAMPLE_SI, registration_journal_entry_id: JE_ID }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: { data: { ...booked, due_date: '2026-07-31' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`, {
method: 'PATCH',
body: JSON.stringify({
due_date: '2026-07-31',
invoice_date: SAMPLE_SI.invoice_date,
supplier_invoice_number: SAMPLE_SI.supplier_invoice_number,
}),
}),
detailParams(COMPANY_ID, SI_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.due_date).toBe('2026-07-31')
})
it('reports the lock, not a generic race, when a registration entry lands mid-flight', async () => {
// Queue: pre-flight read (unbooked) -> pinned update matches nothing ->
// re-read shows the entry that landed in between.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
supplier_invoices: [
{ data: SAMPLE_SI, error: null },
{ data: null, error: null },
{ data: { ...SAMPLE_SI, registration_journal_entry_id: JE_ID }, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateSI(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}`, {
method: 'PATCH',
body: JSON.stringify({ invoice_date: '2026-06-01' }),
}),
detailParams(COMPANY_ID, SI_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SI_EDIT_VERIFIKAT_LOCKED')
expect(body.error.details.reason).toBe('race')
})
it('rejects unknown body keys (V4.5 strict schema)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
+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)
}