Files
accounted/app/api/supplier-invoices/[id]/route.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

272 lines
10 KiB
TypeScript

import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas'
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'
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'supplier_invoice.get',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: invoice, error } = await supabase
.from('supplier_invoices')
.select(
'*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*), credited_original:supplier_invoices!credited_invoice_id(id, supplier_invoice_number, arrival_number)'
)
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error || !invoice) {
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
}
return NextResponse.json({ data: invoice })
},
)
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
'supplier_invoice.update',
async (request, { supabase, companyId, log, requestId }, { params }) => {
const { id } = await params
// Editing is allowed while the invoice is unsettled. 'overdue' is included
// because the daily cron flips unbooked invoices there just by aging, and a
// registered-only gate then made them permanently read-only: you could not
// 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, invoice_date, supplier_invoice_number, registration_journal_entry_id',
)
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
if (!isUnsettledSupplierInvoiceStatus(existing.status)) {
return errorResponseFromCode('SI_EDIT_INVALID_STATUS', log, {
requestId,
details: { currentStatus: existing.status },
})
}
const validation = await validateBody(request, UpdateSupplierInvoiceSchema)
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.
const restingStatus = resolveUnsettledStatus(
{
due_date: body.due_date ?? existing.due_date,
remaining_amount: existing.remaining_amount,
is_credit_note: existing.is_credit_note,
approved_at: existing.approved_at,
},
getSwedishLocalDate(),
)
const rewritesStatus = restingStatus !== existing.status
let update = supabase
.from('supplier_invoices')
.update(rewritesStatus ? { ...body, status: restingStatus } : body)
.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
// unconditionally would let this request overwrite a concurrent cron flip
// or approval with a status derived from what those changed. Pinning the
// three inputs turns that into zero matched rows, i.e. a conflict the
// caller can retry, instead of a silently stale label. Metadata-only
// updates need no pin: they never touch status.
update = update
.eq('status', existing.status)
.eq('due_date', existing.due_date)
update = existing.approved_at
? update.eq('approved_at', existing.approved_at)
: update.is('approved_at', null)
}
const { data, error } = await update.select().maybeSingle()
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
if (!data) {
return errorResponseFromCode('SI_EDIT_CONFLICT', log, {
requestId,
details: { expectedStatus: existing.status, expectedDueDate: existing.due_date },
})
}
return NextResponse.json({ data })
},
{ requireWrite: true },
)
export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
'supplier_invoice.delete',
async (_request, { supabase, companyId, log }, { params }) => {
const { id } = await params
// Only allow deleting registered invoices without journal entries
const { data: existing } = await supabase
.from('supplier_invoices')
.select('status, registration_journal_entry_id, is_credit_note')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!existing) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
// Block direct deletion of credit notes: deleting just the row would orphan the
// posted reversal JE and silently break momsdeklaration. The user must instead
// run "Ångra kreditering" on the original, which storno-reverses the JE and
// restores the original's status atomically.
if (existing.is_credit_note) {
return NextResponse.json(
{
error:
'Kreditfakturor kan inte tas bort direkt. Gå till originalfakturan och välj "Ångra kreditering" för att frigöra numret och återställa bokföringen.',
},
{ status: 400 }
)
}
// 'overdue' and 'approved' are included: the daily cron flips unbooked
// invoices past due_date from registered/approved to 'overdue', and a
// registered-only gate made such an invoice permanently undeletable just by
// aging (support case 2026-07-26). What actually protects the books is the
// orphan-safety checks below (no registration verifikat, no payments, no
// accrual schedule), not the lifecycle label.
if (!['registered', 'approved', 'overdue'].includes(existing.status)) {
return NextResponse.json(
{ error: 'Endast obetalda fakturor utan bokföring kan tas bort' },
{ status: 400 }
)
}
// Booked invoices must go through the credit flow (mirrors the credit-note
// guard above). Three independent blockers:
// (a) a posted registration verifikat: deleting the row would orphan it
// and silently understate 2440/2641 for the momsdeklaration;
// (b) a payment row: deleting the invoice would orphan the payment's
// journal-entry link (belt-and-braces: payments normally move the
// status to partially_paid/paid, which the gate above already blocks);
// (c) an accrual schedule: accrual_schedules.supplier_invoice_id is
// ON DELETE RESTRICT, so the invoice DELETE below would fail AFTER the
// items were already deleted, leaving a broken invoice with zero rows.
if (existing.registration_journal_entry_id) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'registration_journal_entry' },
})
}
// Both orphan-safety lookups fail CLOSED: a lookup error must block the
// delete, otherwise a transient DB/RLS failure would read as "no payment /
// no schedule" and let the delete through unverified.
const { data: linkedPayment, error: paymentLookupError } = await supabase
.from('supplier_invoice_payments')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', id)
.limit(1)
.maybeSingle()
if (paymentLookupError) {
return NextResponse.json({ error: getUserErrorMessage(paymentLookupError) }, { status: 500 })
}
if (linkedPayment) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'payments', paymentId: linkedPayment.id },
})
}
const { data: linkedSchedule, error: scheduleLookupError } = await supabase
.from('accrual_schedules')
.select('id')
.eq('company_id', companyId)
.eq('supplier_invoice_id', id)
.limit(1)
.maybeSingle()
if (scheduleLookupError) {
return NextResponse.json({ error: getUserErrorMessage(scheduleLookupError) }, { status: 500 })
}
if (linkedSchedule) {
return errorResponseFromCode('SI_DELETE_HAS_BOOKING', log, {
details: { reason: 'accrual_schedule', scheduleId: linkedSchedule.id },
})
}
// Delete items first, then invoice
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
const { error } = await supabase
.from('supplier_invoices')
.delete()
.eq('id', id)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)