fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice (#1227)
* fix(supplier-invoices): make the 'overdue' label two-way and stop it locking an invoice The daily cron flips unbooked payables past their due date to 'overdue' but nothing ever flipped them back, so aging alone pushed an invoice out of every workflow that gated on 'registered': it could not be edited (not even to extend the due date that made it overdue) and it could not be attested. Deletion was already unblocked in #1204; this closes the rest of #1206. - update_overdue_supplier_invoices() gains the inverse branch: a payable whose due date is no longer in the past returns to its resting status. Because the flip collapses 'registered' and 'approved', the un-flip needs a separate attest marker: new supplier_invoices.approved_at, backfilled from updated_at for rows currently sitting in 'approved'. - PUT /api/supplier-invoices/[id] accepts every unsettled status and recomputes the label from the due date it writes, in both directions, instead of leaving it up to a day stale. The update body carries metadata only (numbers, dates, reference, notes), never amounts or accounts, so a posted registration verifikat cannot be desynced by money. - Approve (web route, v1 API, MCP staging tool, staged commit executor) keys off approved_at instead of status === 'registered', so an aged invoice can still be attested. A still-late invoice keeps the 'overdue' label after attest: approving is not a reason to hide that the money is late. - One shared predicate in lib/supplier-invoices/lifecycle.ts for all five call sites, mirroring the SQL; new SI_EDIT_INVALID_STATUS replaces the raw Swedish string the edit gate used to return. Tests: 12 pg-real cases on the cron (5 new, covering both directions and the credit-note/fully-paid boundaries), plus route tests asserting the exact written payload for PUT and approve, and unit tests pinning the shared predicate against the SQL. npm test (11385), lint, check:guards clean. Closes #1206 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(migration): mark backfilled approved_at values as derived, not audit facts Compliance review on #1227 flagged that approved_at = updated_at could later be mistaken for an observed attestation moment (BFNAR 2013:2 kap 8 behandlingshistorik). The column comment and the migration now state plainly that pre-migration values are derived and that audit_log, written by the audit_supplier_invoices trigger, remains the record of what happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(supplier-invoices): guard the derived status writes with compare-and-swap Review findings on #1227. The status these paths write is derived from facts read a moment earlier, so an unconditional write could overwrite a concurrent cron flip, edit or approval with a label computed from what those changed. - PUT pins status, due_date and approved_at when (and only when) it derives a new status; zero matched rows is now a retryable 409 SI_EDIT_CONFLICT instead of a silently stale label. Metadata-only updates keep writing unconditionally: they never touch status, so they cannot clobber it. - The web approve route and the staged-commit executor gain the same pre-approval guard the v1 route already had (status in registered/overdue, approved_at IS NULL) plus a !data race check, so two concurrent approvals can no longer both stamp approved_at and both emit supplier_invoice.approved. - The v1 guard additionally pins due_date, since nextStatus is derived from it. - The list page no longer invents status/approved_at when the approve response is incomplete: it re-reads instead. An operator about to pay must not be shown a fabricated lifecycle state. - route.overdue.test.ts clears the module-level event bus like its sibling. Tests: new conflict cases for both paths (409 on PUT, refusal without an event emission on approve). npm test 11387 passed, lint 0 errors, check:guards clean, 12 pg-real cases green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f5697cfc2f
commit
df29817826
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createMockRequest, parseJsonResponse, createMockRouteParams } from '@/tests/helpers'
|
||||
|
||||
/**
|
||||
* PUT /api/supplier-invoices/[id] (#1206).
|
||||
*
|
||||
* Two behaviours are pinned here:
|
||||
* - Editing is allowed for every unsettled status, 'overdue' included. The
|
||||
* overdue cron flips unbooked invoices there just by aging, and the old
|
||||
* registered-only gate then made them permanently read-only: you could not
|
||||
* even extend the due date to un-overdue them.
|
||||
* - The write recomputes the overdue label from the due date it lands on, in
|
||||
* both directions, instead of waiting up to a day for the next cron run.
|
||||
*
|
||||
* Dates are pinned far in the past/future so the assertions hold whatever the
|
||||
* wall-clock date is when the suite runs.
|
||||
*/
|
||||
|
||||
const PAST = '2000-01-01'
|
||||
const FUTURE = '2999-01-01'
|
||||
|
||||
const updatePayloads: Record<string, unknown>[] = []
|
||||
const singleResults: { data: unknown; error: unknown }[] = []
|
||||
|
||||
// Capturing chain: .single() walks a queue (first the existing-row read, then
|
||||
// the update's returning row) and .update() records the exact payload written.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
updatePayloads.push(payload)
|
||||
return chain
|
||||
},
|
||||
eq: () => chain,
|
||||
// The write paths pin their compare-and-swap predicates with .in()/.is(),
|
||||
// so the chain has to accept them too.
|
||||
in: () => chain,
|
||||
is: () => chain,
|
||||
single: () => Promise.resolve(singleResults.shift() ?? { data: null, error: null }),
|
||||
maybeSingle: () => Promise.resolve(singleResults.shift() ?? { data: null, error: null }),
|
||||
}
|
||||
const mockSupabase = { from: () => chain, rpc: () => chain }
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { PUT } from '../route'
|
||||
|
||||
describe('PUT /api/supplier-invoices/[id]', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
updatePayloads.length = 0
|
||||
singleResults.length = 0
|
||||
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null })
|
||||
})
|
||||
|
||||
function putRequest(body: Record<string, unknown>) {
|
||||
return PUT(
|
||||
createMockRequest('/api/supplier-invoices/si-1', { method: 'PUT', body }),
|
||||
createMockRouteParams({ id: 'si-1' }),
|
||||
)
|
||||
}
|
||||
|
||||
/** Row shape the route reads before validating the body. */
|
||||
function existingRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: {
|
||||
status: 'registered',
|
||||
due_date: FUTURE,
|
||||
remaining_amount: 1000,
|
||||
is_credit_note: false,
|
||||
approved_at: null,
|
||||
...overrides,
|
||||
},
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: null,
|
||||
supabase: mockSupabase,
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await putRequest({ notes: 'x' })
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the invoice does not exist', async () => {
|
||||
singleResults.push({ data: null, error: null })
|
||||
|
||||
const response = await putRequest({ notes: 'x' })
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 for a settled invoice', async () => {
|
||||
singleResults.push(existingRow({ status: 'paid' }))
|
||||
|
||||
const response = await putRequest({ notes: 'x' })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_EDIT_INVALID_STATUS')
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects an invalid body before writing anything', async () => {
|
||||
singleResults.push(existingRow())
|
||||
|
||||
const response = await putRequest({ due_date: 'not-a-date' })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('edits an overdue invoice and un-flips it when the due date moves forward', async () => {
|
||||
singleResults.push(existingRow({ status: 'overdue', due_date: PAST }))
|
||||
singleResults.push({ data: { id: 'si-1', status: 'registered' }, error: null })
|
||||
|
||||
const response = await putRequest({ due_date: FUTURE })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(updatePayloads).toHaveLength(1)
|
||||
expect(updatePayloads[0]).toMatchObject({ due_date: FUTURE, status: 'registered' })
|
||||
})
|
||||
|
||||
it('un-flips to approved when the invoice had been attested', async () => {
|
||||
singleResults.push(
|
||||
existingRow({ status: 'overdue', due_date: PAST, approved_at: '2026-01-01T08:00:00Z' }),
|
||||
)
|
||||
singleResults.push({ data: { id: 'si-1', status: 'approved' }, error: null })
|
||||
|
||||
const response = await putRequest({ due_date: FUTURE })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0]).toMatchObject({ status: 'approved' })
|
||||
})
|
||||
|
||||
it('marks the invoice overdue right away when the due date moves into the past', async () => {
|
||||
singleResults.push(existingRow({ status: 'registered', due_date: FUTURE }))
|
||||
singleResults.push({ data: { id: 'si-1', status: 'overdue' }, error: null })
|
||||
|
||||
const response = await putRequest({ due_date: PAST })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0]).toMatchObject({ due_date: PAST, status: 'overdue' })
|
||||
})
|
||||
|
||||
it('leaves the status out of the payload when it does not change', async () => {
|
||||
singleResults.push(existingRow({ status: 'registered', due_date: FUTURE }))
|
||||
singleResults.push({ data: { id: 'si-1', status: 'registered' }, error: null })
|
||||
|
||||
const response = await putRequest({ notes: 'Betalas via autogiro' })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0]).toEqual({ notes: 'Betalas via autogiro' })
|
||||
})
|
||||
|
||||
it('keeps a still-past-due invoice on overdue when only metadata changes', async () => {
|
||||
singleResults.push(existingRow({ status: 'overdue', due_date: PAST }))
|
||||
singleResults.push({ data: { id: 'si-1', status: 'overdue' }, error: null })
|
||||
|
||||
const response = await putRequest({ payment_reference: '1234567890' })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0]).toEqual({ payment_reference: '1234567890' })
|
||||
})
|
||||
|
||||
it('reports a conflict when the compare-and-swap matches no row', async () => {
|
||||
// The status is derived from facts read a moment ago, so the write pins
|
||||
// them. Zero matched rows means the cron (or another writer) changed the row
|
||||
// in between: better a retryable 409 than a silently stale label.
|
||||
singleResults.push(existingRow({ status: 'overdue', due_date: PAST }))
|
||||
singleResults.push({ data: null, error: null })
|
||||
|
||||
const response = await putRequest({ due_date: FUTURE })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('SI_EDIT_CONFLICT')
|
||||
})
|
||||
|
||||
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.
|
||||
singleResults.push(
|
||||
existingRow({ status: 'registered', due_date: PAST, remaining_amount: 0, is_credit_note: true }),
|
||||
)
|
||||
singleResults.push({ data: { id: 'si-1', status: 'registered' }, error: null })
|
||||
|
||||
const response = await putRequest({ notes: 'Kreditnota' })
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0]).toEqual({ notes: 'Kreditnota' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse, createMockRouteParams, makeSupplierInvoice } from '@/tests/helpers'
|
||||
|
||||
/**
|
||||
* Attest of an aged supplier invoice (#1206).
|
||||
*
|
||||
* The daily cron flips unbooked payables past their due date to 'overdue', so a
|
||||
* registered-only approve gate left them with no way through attest at all.
|
||||
* approved_at (not the status) is the durable attest marker, which is also what
|
||||
* makes approval idempotent and what the overdue un-flip reads.
|
||||
*
|
||||
* Uses a capturing chain rather than the queued mock so the exact written
|
||||
* payload can be asserted. Dates are pinned far in the past/future so the
|
||||
* assertions hold whatever the wall-clock date is when the suite runs.
|
||||
*/
|
||||
|
||||
const PAST = '2000-01-01'
|
||||
const FUTURE = '2999-01-01'
|
||||
|
||||
const updatePayloads: Record<string, unknown>[] = []
|
||||
const singleResults: { data: unknown; error: unknown }[] = []
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chain: any = {
|
||||
select: () => chain,
|
||||
update: (payload: Record<string, unknown>) => {
|
||||
updatePayloads.push(payload)
|
||||
return chain
|
||||
},
|
||||
eq: () => chain,
|
||||
// The write paths pin their compare-and-swap predicates with .in()/.is(),
|
||||
// so the chain has to accept them too.
|
||||
in: () => chain,
|
||||
is: () => chain,
|
||||
single: () => Promise.resolve(singleResults.shift() ?? { data: null, error: null }),
|
||||
maybeSingle: () => Promise.resolve(singleResults.shift() ?? { data: null, error: null }),
|
||||
}
|
||||
|
||||
const mockSupabase = {
|
||||
from: () => chain,
|
||||
rpc: () => chain,
|
||||
auth: { getUser: vi.fn() },
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
describe('POST /api/supplier-invoices/[id]/approve (aged invoices)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
updatePayloads.length = 0
|
||||
singleResults.length = 0
|
||||
// The approve path emits supplier_invoice.approved on the module-level bus;
|
||||
// clear it so handlers registered elsewhere cannot leak into this suite.
|
||||
eventBus.clear()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
function approveRequest() {
|
||||
return POST(
|
||||
createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }),
|
||||
createMockRouteParams({ id: 'inv-1' }),
|
||||
)
|
||||
}
|
||||
|
||||
it('approves an overdue invoice and keeps the overdue label while it is still late', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'overdue',
|
||||
due_date: PAST,
|
||||
remaining_amount: 1000,
|
||||
})
|
||||
singleResults.push({ data: invoice, error: null })
|
||||
singleResults.push({ data: { ...invoice, approved_at: 'now' }, error: null })
|
||||
|
||||
const response = await approveRequest()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads).toHaveLength(1)
|
||||
expect(updatePayloads[0].status).toBe('overdue')
|
||||
expect(updatePayloads[0].approved_at).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('lands on approved when the invoice is no longer past due', async () => {
|
||||
const invoice = makeSupplierInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'overdue',
|
||||
due_date: FUTURE,
|
||||
remaining_amount: 1000,
|
||||
})
|
||||
singleResults.push({ data: invoice, error: null })
|
||||
singleResults.push({ data: { ...invoice, status: 'approved' }, error: null })
|
||||
|
||||
const response = await approveRequest()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0].status).toBe('approved')
|
||||
})
|
||||
|
||||
it('stamps approved_at when approving a registered invoice', async () => {
|
||||
const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered', due_date: FUTURE })
|
||||
singleResults.push({ data: invoice, error: null })
|
||||
singleResults.push({ data: { ...invoice, status: 'approved' }, error: null })
|
||||
|
||||
const response = await approveRequest()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updatePayloads[0]).toMatchObject({ status: 'approved' })
|
||||
expect(updatePayloads[0].approved_at).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('refuses when the optimistic-concurrency update matches no row', async () => {
|
||||
// Two approvals in flight: the loser's update finds no row still in a
|
||||
// pre-approval state, and must not emit a second approval event.
|
||||
const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered', due_date: FUTURE })
|
||||
singleResults.push({ data: invoice, error: null })
|
||||
singleResults.push({ data: null, error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
const response = await approveRequest()
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_APPROVE_NOT_REGISTERED')
|
||||
expect(emitSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a second approval of an already-attested overdue invoice', async () => {
|
||||
singleResults.push({
|
||||
data: makeSupplierInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'overdue',
|
||||
due_date: PAST,
|
||||
remaining_amount: 1000,
|
||||
approved_at: '2026-01-01T08:00:00Z',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const response = await approveRequest()
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SI_APPROVE_NOT_REGISTERED')
|
||||
expect(updatePayloads).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,11 @@ import { eventBus } from '@/lib/events'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
canApproveSupplierInvoice,
|
||||
resolveUnsettledStatus,
|
||||
} from '@/lib/supplier-invoices/lifecycle'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -24,26 +29,54 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('SI_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'registered') {
|
||||
// 'overdue' is approvable too: the daily cron puts unbooked invoices there
|
||||
// just by aging, and a registered-only gate left an aged invoice with no
|
||||
// way through attest at all (#1206). approved_at, not the status, is what
|
||||
// makes approval idempotent.
|
||||
if (!canApproveSupplierInvoice(invoice)) {
|
||||
return errorResponseFromCode('SI_APPROVE_NOT_REGISTERED', log, {
|
||||
requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
})
|
||||
}
|
||||
|
||||
// An invoice that is both attested and past due stays labelled 'overdue':
|
||||
// that is what the cron would do on its next run, and approving is not a
|
||||
// reason to hide that money is late.
|
||||
const approvedAt = new Date().toISOString()
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ status: 'approved' })
|
||||
.update({
|
||||
status: resolveUnsettledStatus(
|
||||
{ ...invoice, approved_at: approvedAt },
|
||||
getSwedishLocalDate(),
|
||||
),
|
||||
approved_at: approvedAt,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
// Optimistic concurrency on the pre-approval state, same guard as the v1
|
||||
// route: the eligibility check above ran on a snapshot, so without this
|
||||
// two concurrent approvals would both write (different) approved_at
|
||||
// values and both emit supplier_invoice.approved.
|
||||
.in('status', ['registered', 'overdue'])
|
||||
.is('approved_at', null)
|
||||
.select()
|
||||
.single()
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
log.error('supplier_invoice update to approved failed', error)
|
||||
return errorResponseFromCode('SI_APPROVE_UPDATE_FAILED', log, { requestId })
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
// Lost the race: another approval (or a status change) landed first.
|
||||
return errorResponseFromCode('SI_APPROVE_NOT_REGISTERED', log, {
|
||||
requestId,
|
||||
details: { reason: 'race' },
|
||||
})
|
||||
}
|
||||
|
||||
// Event emission is non-blocking: the registration entry is created by
|
||||
// the supplier-invoice handler bound to this event. If the handler throws,
|
||||
// bus.ts persists an EventHandlerFailed row for traceability.
|
||||
|
||||
@@ -4,6 +4,11 @@ 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 {
|
||||
isUnsettledSupplierInvoiceStatus,
|
||||
resolveUnsettledStatus,
|
||||
} from '@/lib/supplier-invoices/lifecycle'
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'supplier_invoice.get',
|
||||
@@ -29,13 +34,18 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'supplier_invoice.update',
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
async (request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
// Only allow editing registered invoices
|
||||
// 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.
|
||||
const { data: existing } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('status')
|
||||
.select('status, due_date, remaining_amount, is_credit_note, approved_at')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -44,29 +54,67 @@ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.status !== 'registered') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan bara redigera registrerade fakturor' },
|
||||
{ status: 400 }
|
||||
)
|
||||
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
|
||||
|
||||
const { data, error } = await supabase
|
||||
// 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(body)
|
||||
.update(rewritesStatus ? { ...body, status: restingStatus } : body)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
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 },
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve
|
||||
*
|
||||
* Transitions a `registered` supplier invoice to `approved`. No journal entry
|
||||
* is involved in this transition: the registration JE has already been posted
|
||||
* Attests a `registered` or `overdue` supplier invoice. No journal entry is
|
||||
* involved in this transition: the registration JE has already been posted
|
||||
* (under accrual) or is deferred to :mark-paid (under cash). Idempotent
|
||||
* (mandatory Idempotency-Key). Dry-runnable.
|
||||
*
|
||||
* Strict-mode: the optimistic-lock UPDATE filters on status='registered' so
|
||||
* concurrent calls (or a same-key replay racing the first) yield a clean 409
|
||||
* rather than a silent no-op.
|
||||
* The resulting status is `approved`, or `overdue` when the invoice is still
|
||||
* past its due date: 'overdue' is derived state the daily cron owns, and
|
||||
* attesting a late payable does not make it on time (#1206).
|
||||
*
|
||||
* Strict-mode: the optimistic-lock UPDATE filters on the pre-approval state
|
||||
* (status in registered/overdue, approved_at IS NULL) so concurrent calls (or
|
||||
* a same-key replay racing the first) yield a clean 409 rather than a silent
|
||||
* no-op.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
@@ -18,14 +23,20 @@ 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 { eventBus } from '@/lib/events'
|
||||
import { getSwedishLocalDate } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
canApproveSupplierInvoice,
|
||||
resolveUnsettledStatus,
|
||||
} from '@/lib/supplier-invoices/lifecycle'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
|
||||
const SI_RESPONSE_COLUMNS =
|
||||
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, registration_journal_entry_id, payment_journal_entry_id, created_at, updated_at'
|
||||
'id, supplier_id, arrival_number, supplier_invoice_number, invoice_date, due_date, status, approved_at, currency, subtotal, vat_amount, total, paid_amount, remaining_amount, is_credit_note, registration_journal_entry_id, payment_journal_entry_id, created_at, updated_at'
|
||||
|
||||
const SupplierInvoiceApproved = z.object({
|
||||
id: z.string().uuid(),
|
||||
status: z.literal('approved'),
|
||||
// 'overdue' when the attested invoice is still past its due date.
|
||||
status: z.enum(['approved', 'overdue']),
|
||||
arrival_number: z.number().int(),
|
||||
supplier_invoice_number: z.string(),
|
||||
})
|
||||
@@ -34,16 +45,17 @@ registerEndpoint({
|
||||
operation: 'supplier-invoices.approve',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/supplier-invoices/:id/approve',
|
||||
summary: 'Approve a registered supplier invoice.',
|
||||
summary: 'Approve a registered or overdue supplier invoice.',
|
||||
description:
|
||||
'Flips a supplier invoice from `registered` to `approved`. No journal entry is posted here: the registration JE was already booked at :create under accrual, or is deferred to :mark-paid under cash. Idempotent. Dry-runnable.',
|
||||
'Attests a supplier invoice that has not been approved yet (status `registered` or `overdue`). The resulting status is `approved`, or `overdue` when the invoice is still past its due date. No journal entry is posted here: the registration JE was already booked at :create under accrual, or is deferred to :mark-paid under cash. Idempotent. Dry-runnable.',
|
||||
useWhen:
|
||||
'A registered SI has been reviewed and you want to mark it ready for payment. Many AP workflows gate :mark-paid behind an explicit approval step.',
|
||||
doNotUseFor:
|
||||
'Posting a journal entry (already done at :create under accrual). Paying the SI (use :mark-paid). Re-approving an already-approved SI (returns 400 SI_APPROVE_NOT_REGISTERED).',
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory.',
|
||||
'Returns 400 SI_APPROVE_NOT_REGISTERED when current status !== "registered". Use the detail endpoint to inspect status first if unsure.',
|
||||
'Returns 400 SI_APPROVE_NOT_REGISTERED when the invoice is already approved (approved_at set) or sits in a settled status. Use the detail endpoint to inspect status first if unsure.',
|
||||
'A still-past-due invoice comes back with status "overdue", not "approved": approved_at is the attest marker, the status is derived from the due date.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
@@ -85,26 +97,48 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
if (!existing) {
|
||||
return v1ErrorResponseFromCode('SI_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if ((existing as { status: string }).status !== 'registered') {
|
||||
// 'overdue' is approvable: the daily cron flips unbooked invoices there
|
||||
// just by aging (#1206). approved_at is what makes approval idempotent.
|
||||
const invoice = existing as {
|
||||
status: string
|
||||
approved_at?: string | null
|
||||
due_date: string
|
||||
remaining_amount: number
|
||||
is_credit_note?: boolean | null
|
||||
}
|
||||
if (!canApproveSupplierInvoice(invoice)) {
|
||||
return v1ErrorResponseFromCode('SI_APPROVE_NOT_REGISTERED', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { current_status: (existing as { status: string }).status },
|
||||
details: { current_status: invoice.status },
|
||||
})
|
||||
}
|
||||
|
||||
// An attested invoice that is still past due keeps the 'overdue' label:
|
||||
// approving is not a reason to hide that the money is late.
|
||||
const approvedAt = new Date().toISOString()
|
||||
const nextStatus = resolveUnsettledStatus(
|
||||
{ ...invoice, approved_at: approvedAt },
|
||||
getSwedishLocalDate(),
|
||||
)
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{ ...(existing as object), status: 'approved' },
|
||||
{ ...(existing as object), status: nextStatus },
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ status: 'approved' })
|
||||
.update({ status: nextStatus, approved_at: approvedAt })
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', invoiceId)
|
||||
.eq('status', 'registered')
|
||||
.in('status', ['registered', 'overdue'])
|
||||
.is('approved_at', null)
|
||||
// nextStatus was derived from the due date read above, so pin that too:
|
||||
// a concurrent due-date edit must not be papered over with a label
|
||||
// computed from the pre-edit date.
|
||||
.eq('due_date', invoice.due_date)
|
||||
.select(SI_RESPONSE_COLUMNS)
|
||||
.maybeSingle()
|
||||
|
||||
|
||||
@@ -1099,11 +1099,40 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices/:id/approve', () =
|
||||
expect(body.data.status).toBe('approved')
|
||||
})
|
||||
|
||||
it('refuses on already-approved SI (400 SI_APPROVE_NOT_REGISTERED)', async () => {
|
||||
it('attests an overdue SI, which stays overdue while it is still late (#1206)', async () => {
|
||||
// The daily cron flips unbooked payables past due_date to 'overdue'. Attest
|
||||
// must stay reachable there, and it does not make late money on time.
|
||||
const overdue = { ...SAMPLE_SI, status: 'overdue', due_date: '2000-01-01', approved_at: null }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: { data: { ...SAMPLE_SI, status: 'approved' }, error: null },
|
||||
supplier_invoices: [
|
||||
{ data: overdue, error: null },
|
||||
{ data: { ...overdue, approved_at: '2026-07-27T08:00:00Z' }, error: null },
|
||||
],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await approveSI(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices/${SI_ID}/approve`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, SI_ID),
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('overdue')
|
||||
expect(body.data.approved_at).toBe('2026-07-27T08:00:00Z')
|
||||
})
|
||||
|
||||
it('refuses on an already-attested SI (400 SI_APPROVE_NOT_REGISTERED)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
supplier_invoices: {
|
||||
data: { ...SAMPLE_SI, status: 'overdue', approved_at: '2026-07-01T08:00:00Z' },
|
||||
error: null,
|
||||
},
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user