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
@@ -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({