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:
Jakob Wennberg
2026-07-27 14:49:22 +02:00
committed by GitHub
parent f5697cfc2f
commit df29817826
18 changed files with 1014 additions and 49 deletions
+1
View File
@@ -588,3 +588,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] hashApiKey stays SHA-256 against CodeQL js/insufficient-password-hash: the input is 32 CSPRNG bytes, not a user-chosen password, so no KDF work factor is meaningful against 256 bits; the hash is also the primary-key lookup on every MCP request, and changing it would invalidate every live gnubok_sk_ key since the hash IS the stored credential.
[2026-07-27] mcp-oauth consent form action is HTML-escaped even though the CodeQL js/reflected-xss finding is not exploitable (WHATWG URL parsing already percent-encodes " < > in the query component): & is not in that encode set so the attribute was emitting invalid raw ampersands, and resting the page on an unstated parser-normalisation invariant is one refactor away from being wrong.
[2026-07-27] Compliance-review artifact unpacks to runner.temp instead of the workspace root: extracting fork-influenced content over the trusted checkout, with AWS secrets in scope, was safe only because stage 1 happens to write fixed filenames; moving it makes overwrite unreachable by construction.
[2026-07-27] Supplier-invoice 'overdue' stays a stored status, made symmetric instead of derived (#1206): added approved_at as the durable attest marker and an un-flip branch in update_overdue_supplier_invoices(), rather than computing overdue at read time. Computing it would have touched every list/filter/report query that reads status plus the v1 API contract; the symmetric-cron fix is the same user-visible outcome at a fraction of the blast radius.
@@ -26,6 +26,7 @@ import { DocumentViewButton } from '@/components/bookkeeping/DocumentViewButton'
import { useCompanySettings } from '@/components/settings/useSettings'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
interface EditableLine {
@@ -490,7 +491,10 @@ export default function SupplierInvoiceDetailPage() {
contextRef={`supplier_invoice:${invoice.id}`}
size="default"
/>
{invoice.status === 'registered' && !invoice.is_credit_note && (
{/* Attest keys off approved_at, not the status: the overdue cron
flips unbooked invoices to 'overdue' just by aging, and gating on
'registered' alone left them with no way through attest (#1206). */}
{canApproveSupplierInvoice(invoice) && !invoice.is_credit_note && (
<Button
onClick={handleApprove}
disabled={isProcessing || !canWrite}
+20 -2
View File
@@ -21,6 +21,7 @@ import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import type { FiscalPeriod, SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
@@ -160,7 +161,22 @@ export default function SupplierInvoicesPage() {
fetchInvoices()
} else {
toast({ title: t('approved_title'), description: t('approved_description') })
setInvoices((prev) => prev.map((inv) => (inv.id === id ? { ...inv, status: 'approved' as const } : inv)))
// Trust the server's status: an attested invoice that is still past due
// stays labelled 'overdue' rather than flipping to 'approved'. An
// incomplete payload is not an excuse to invent either field: the row an
// operator is about to pay must show real state, so re-read instead.
const approved = result?.data as Partial<SupplierInvoice> | undefined
if (!approved?.status || !approved.approved_at) {
fetchInvoices()
return
}
setInvoices((prev) =>
prev.map((inv) =>
inv.id === id
? { ...inv, status: approved.status!, approved_at: approved.approved_at! }
: inv,
),
)
}
} catch {
toast({ title: t('approve_failed_title'), description: getErrorMessage(null, { context: 'supplier_invoice' }), variant: 'destructive' })
@@ -291,8 +307,10 @@ export default function SupplierInvoicesPage() {
: STATUS_LABEL_KEYS[inv.status]
? t(STATUS_LABEL_KEYS[inv.status])
: inv.status
// Aged-but-unapproved invoices sit on 'overdue' (the cron flips
// them there), so attest keys off approved_at, not the status.
const canApprove =
inv.status === 'registered' && !inv.is_credit_note && canWrite
canApproveSupplierInvoice(inv) && !inv.is_credit_note && canWrite
return (
<tr
key={inv.id}
@@ -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.
+60 -12
View File
@@ -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 },
}),
)
+8 -3
View File
@@ -13,6 +13,7 @@ import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mappi
import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getPermittedVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
@@ -12683,7 +12684,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_approve_supplier_invoice',
title: 'Approve Supplier Invoice',
description: 'Stage approval of a registered supplier invoice (registered → approved). High-risk, always staged.',
description: 'Stage approval of a supplier invoice that has not been attested yet (registered or overdue). An invoice that is still past its due date keeps the overdue label after approval. High-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -12698,10 +12699,14 @@ export const tools: McpTool[] = [
const { data: inv } = await supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, total, currency, status, supplier:suppliers(name)')
.select('id, supplier_invoice_number, invoice_date, total, currency, status, approved_at, supplier:suppliers(name)')
.eq('id', id).eq('company_id', companyId).single()
if (!inv) throw new Error('Supplier invoice not found')
if (inv.status !== 'registered') throw new Error('Kan bara godkänna registrerade fakturor')
// 'overdue' is approvable: the daily cron puts unbooked invoices there
// just by aging (#1206). approved_at is the durable attest marker.
if (!canApproveSupplierInvoice(inv)) {
throw new Error('Fakturan är redan godkänd eller kan inte godkännas i nuvarande status')
}
return stagePendingOperation(supabase, companyId, userId, 'approve_supplier_invoice',
`Godkänn leverantörsfaktura ${inv.supplier_invoice_number}`,
+16 -2
View File
@@ -1151,8 +1151,22 @@ const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
},
SI_APPROVE_NOT_REGISTERED: {
httpStatus: 400,
message_sv: 'Endast registrerade fakturor kan godkännas.',
message_en: 'Only invoices in registered status can be approved.',
message_sv: 'Fakturan är redan godkänd eller kan inte godkännas i nuvarande status.',
message_en: 'The invoice is already approved, or cannot be approved in its current status.',
},
SI_EDIT_CONFLICT: {
httpStatus: 409,
message_sv:
'Leverantörsfakturan ändrades av någon annan (eller av den dagliga förfallokontrollen) medan du redigerade. Ladda om fakturan och försök igen.',
message_en:
'The supplier invoice changed elsewhere (or in the daily overdue check) while you were editing. Reload the invoice and try again.',
},
SI_EDIT_INVALID_STATUS: {
httpStatus: 400,
message_sv:
'Bara obetalda leverantörsfakturor kan redigeras. Betalda, krediterade och återförda fakturor rättas genom kreditfaktura eller storno.',
message_en:
'Only unsettled supplier invoices can be edited. Paid, credited and reversed invoices are corrected with a credit note or a storno.',
},
SI_APPROVE_UPDATE_FAILED: {
httpStatus: 500,
+33 -6
View File
@@ -33,7 +33,11 @@ import {
} from '@/lib/bookkeeping/invoice-entries'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { ensureManualCashAccount } from '@/lib/cash-accounts/service'
import { createJournalEntry, findFiscalPeriod, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import { createJournalEntry, findFiscalPeriod, getSwedishLocalDate, reverseEntry, validateBalance } from '@/lib/bookkeeping/engine'
import {
canApproveSupplierInvoice,
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { runWithActor } from '@/lib/bookkeeping/actor-context-node'
@@ -3206,19 +3210,42 @@ async function commitApproveSupplierInvoice(
.from('supplier_invoices').select('*').eq('id', id).eq('company_id', companyId).single()
if (!invoice) return { error: 'Supplier invoice not found', status: 404 }
if (invoice.status !== 'registered') {
return { error: 'Kan bara godkänna registrerade fakturor', status: 400 }
// 'overdue' is approvable: the daily cron flips unbooked invoices there just
// by aging, and a registered-only gate left an aged invoice with no way
// through attest (#1206). approved_at makes the approval idempotent.
if (!canApproveSupplierInvoice(invoice)) {
return {
error: 'Fakturan är redan godkänd eller kan inte godkännas i nuvarande status',
status: 400,
}
}
// A still-past-due invoice keeps the 'overdue' label after attest: that is
// what the cron would do on its next run.
const approvedAt = new Date().toISOString()
const nextStatus = resolveUnsettledStatus(
{ ...invoice, approved_at: approvedAt },
getSwedishLocalDate(),
)
const { data, error } = await supabase
.from('supplier_invoices')
.update({ status: 'approved' })
.update({ status: nextStatus, approved_at: approvedAt })
.eq('id', id)
.eq('company_id', companyId)
// Optimistic concurrency on the pre-approval state, same guard as the web
// and v1 approve routes. Staged operations can be committed twice (retry,
// two approvers): without this both writes would land and both would emit
// supplier_invoice.approved.
.in('status', ['registered', 'overdue'])
.is('approved_at', null)
.select()
.single()
.maybeSingle()
if (error) return { error: error.message, status: 500 }
if (!data) {
return { error: 'Fakturan godkändes av någon annan medan operationen väntade', status: 409 }
}
try {
await eventBus.emit({
@@ -3227,7 +3254,7 @@ async function commitApproveSupplierInvoice(
})
} catch { /* non-blocking */ }
return { data: { supplier_invoice_id: id, status: 'approved' } }
return { data: { supplier_invoice_id: id, status: nextStatus, approved_at: approvedAt } }
}
async function commitCreateSupplierInvoiceFromInbox(
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import {
canApproveSupplierInvoice,
isOverduePayable,
isUnsettledSupplierInvoiceStatus,
resolveUnsettledStatus,
} from '@/lib/supplier-invoices/lifecycle'
/**
* These assertions mirror update_overdue_supplier_invoices()
* (20260727160000_supplier_invoice_overdue_symmetric.sql). The pg-real test
* (tests/pg/supplier-invoice-overdue-cron.pg.test.ts) pins the SQL side; this
* file pins the app side so the two cannot drift apart silently.
*/
const TODAY = '2026-07-27'
const PAST = '2026-07-01'
const FUTURE = '2026-12-31'
describe('isOverduePayable', () => {
it('is true for an unpaid payable past its due date', () => {
expect(isOverduePayable({ due_date: PAST, remaining_amount: 1000 }, TODAY)).toBe(true)
})
it('is false on the due date itself (the cron uses due_date < CURRENT_DATE)', () => {
expect(isOverduePayable({ due_date: TODAY, remaining_amount: 1000 }, TODAY)).toBe(false)
})
it('is false when nothing is left to pay, öre rounding included', () => {
expect(isOverduePayable({ due_date: PAST, remaining_amount: 0 }, TODAY)).toBe(false)
expect(isOverduePayable({ due_date: PAST, remaining_amount: 0.004 }, TODAY)).toBe(false)
expect(isOverduePayable({ due_date: PAST, remaining_amount: 0.01 }, TODAY)).toBe(true)
})
it('is false for a credit note: a kreditfaktura is not a payable', () => {
expect(
isOverduePayable({ due_date: PAST, remaining_amount: 1000, is_credit_note: true }, TODAY),
).toBe(false)
})
})
describe('resolveUnsettledStatus', () => {
it('returns overdue for a past-due payable regardless of attest state', () => {
expect(resolveUnsettledStatus({ due_date: PAST, remaining_amount: 1000 }, TODAY)).toBe('overdue')
expect(
resolveUnsettledStatus(
{ due_date: PAST, remaining_amount: 1000, approved_at: '2026-07-02T08:00:00Z' },
TODAY,
),
).toBe('overdue')
})
it('un-flips to registered when the due date moves out of the past', () => {
expect(resolveUnsettledStatus({ due_date: FUTURE, remaining_amount: 1000 }, TODAY)).toBe(
'registered',
)
})
it('un-flips to approved when the invoice was attested', () => {
expect(
resolveUnsettledStatus(
{ due_date: FUTURE, remaining_amount: 1000, approved_at: '2026-07-02T08:00:00Z' },
TODAY,
),
).toBe('approved')
})
})
describe('isUnsettledSupplierInvoiceStatus', () => {
it('covers exactly the statuses the overdue flip owns', () => {
expect(isUnsettledSupplierInvoiceStatus('registered')).toBe(true)
expect(isUnsettledSupplierInvoiceStatus('approved')).toBe(true)
expect(isUnsettledSupplierInvoiceStatus('overdue')).toBe(true)
for (const settled of ['paid', 'partially_paid', 'credited', 'reversed', 'disputed']) {
expect(isUnsettledSupplierInvoiceStatus(settled)).toBe(false)
}
})
})
describe('canApproveSupplierInvoice', () => {
it('allows a registered invoice', () => {
expect(canApproveSupplierInvoice({ status: 'registered' })).toBe(true)
})
it('allows an overdue invoice that has never been attested', () => {
expect(canApproveSupplierInvoice({ status: 'overdue', approved_at: null })).toBe(true)
})
it('refuses once approved_at is set, so approval is idempotent', () => {
expect(
canApproveSupplierInvoice({ status: 'overdue', approved_at: '2026-07-02T08:00:00Z' }),
).toBe(false)
expect(
canApproveSupplierInvoice({ status: 'approved', approved_at: '2026-07-02T08:00:00Z' }),
).toBe(false)
})
it('refuses settled statuses', () => {
expect(canApproveSupplierInvoice({ status: 'paid' })).toBe(false)
expect(canApproveSupplierInvoice({ status: 'credited' })).toBe(false)
})
})
+96
View File
@@ -0,0 +1,96 @@
/**
* Supplier-invoice lifecycle helpers for the 'overdue' label.
*
* 'overdue' is derived state (an unpaid payable past its due date) that we
* store as a lifecycle status: the daily pg_cron job
* update_overdue_supplier_invoices() flips 'registered'/'approved' rows there.
* Because it is stored rather than computed, every path that can change
* due_date, or that gates on the status, has to use the same predicate as the
* cron. When they diverge the label sticks: before #1206 nothing ever flipped
* back, so an unbooked invoice that aged past its due date became read-only
* and could not even have its due date extended.
*
* Keep this file in sync with update_overdue_supplier_invoices()
* (supabase/migrations/20260727160000_supplier_invoice_overdue_symmetric.sql).
*/
/**
* "Nothing left to pay" threshold, mirroring the cron and the payment/match
* paths: öre-level rounding must not leave a payable looking unsettled.
*/
const FULLY_PAID_EPSILON = 0.005
/**
* Statuses the overdue flip/un-flip owns. They are also exactly the statuses
* in which an invoice is still unsettled, so metadata editing is allowed:
* 'paid'/'partially_paid'/'credited'/'reversed'/'disputed' are settled or
* disputed states that other flows own.
*/
export const UNSETTLED_SUPPLIER_INVOICE_STATUSES = [
'registered',
'approved',
'overdue',
] as const
export type UnsettledSupplierInvoiceStatus =
(typeof UNSETTLED_SUPPLIER_INVOICE_STATUSES)[number]
export function isUnsettledSupplierInvoiceStatus(
status: string,
): status is UnsettledSupplierInvoiceStatus {
return (UNSETTLED_SUPPLIER_INVOICE_STATUSES as readonly string[]).includes(status)
}
/** The facts the overdue predicate reads. `today` is an ISO yyyy-MM-dd date. */
export type SupplierInvoiceLifecycleFacts = {
due_date: string
remaining_amount: number
is_credit_note?: boolean | null
/** Set when the invoice has been attested; null/undefined when it has not. */
approved_at?: string | null
}
/**
* True when the invoice is a payable that has fallen due: the exact predicate
* update_overdue_supplier_invoices() flips on. Credit notes are not payables,
* and a fully settled row has nothing to fall due.
*/
export function isOverduePayable(
facts: Pick<SupplierInvoiceLifecycleFacts, 'due_date' | 'remaining_amount' | 'is_credit_note'>,
today: string,
): boolean {
if (facts.is_credit_note) return false
if (facts.remaining_amount <= FULLY_PAID_EPSILON) return false
return facts.due_date < today
}
/**
* The status an unsettled invoice should rest at right now.
*
* The flip collapses 'registered' and 'approved' into 'overdue', so the way
* back needs approved_at: without it an un-flip would silently strip an
* attested invoice of its approval (and with it the "Markera som betald"
* path). Rows that were already 'overdue' when approved_at was introduced
* carry no timestamp and therefore return to 'registered', where they can be
* re-approved.
*/
export function resolveUnsettledStatus(
facts: SupplierInvoiceLifecycleFacts,
today: string,
): UnsettledSupplierInvoiceStatus {
if (isOverduePayable(facts, today)) return 'overdue'
return facts.approved_at ? 'approved' : 'registered'
}
/**
* True when the invoice can still be attested. 'overdue' is included because
* the cron puts unbooked invoices there just by aging; approved_at (not the
* status) is what makes approval idempotent.
*/
export function canApproveSupplierInvoice(invoice: {
status: string
approved_at?: string | null
}): boolean {
if (invoice.approved_at) return false
return invoice.status === 'registered' || invoice.status === 'overdue'
}
@@ -0,0 +1,76 @@
-- Migration: supplier_invoice_overdue_symmetric
--
-- Issue #1206: 'overdue' was a one-way label. update_overdue_supplier_invoices()
-- (the daily pg_cron job from 20260303145744, guarded in 20260607120000) flips
-- 'registered'/'approved' payables past their due date to 'overdue', but
-- nothing ever flipped them back. Extending an unbooked invoice's due date
-- (renegotiated terms, a mistyped date) therefore left it "Förfallen" forever,
-- and until #1204 it could not even be deleted.
--
-- Two parts:
-- 1. approved_at: the flip collapses 'registered' and 'approved' into the
-- same 'overdue' row, so the way back needs a separate record of whether
-- the invoice was ever attested. Without it every un-flip would strip an
-- approved invoice of its approval.
-- 2. The cron becomes symmetric: a payable whose due date is no longer in
-- the past returns to its resting status.
-- 1. Attest timestamp --------------------------------------------------------
ALTER TABLE public.supplier_invoices
ADD COLUMN IF NOT EXISTS approved_at timestamptz;
COMMENT ON COLUMN public.supplier_invoices.approved_at IS
'When the invoice was attested (godkänd). Written by the approve paths; the overdue un-flip reads it to choose between ''registered'' and ''approved''. Workflow marker, not räkenskapsinformation: values on rows updated before 2026-07-27 were backfilled from updated_at (no approval log existed) and are therefore derived, not observed attestation moments. Do not use it as an audit fact for those rows; audit_log holds the actual transitions.';
-- Backfill for rows that currently sit in 'approved': updated_at is the closest
-- available proxy (there is no approval log), and the value only ever decides
-- an un-flip target, never a money field. Rows already ON 'overdue' are
-- deliberately left NULL: whether they were approved before the flip is
-- unknowable, and 'registered' is the safe, re-approvable resting state.
--
-- These backfilled timestamps are derived, not observed: the column comment
-- above says so, and audit_log (via the audit_supplier_invoices trigger) stays
-- the record of what actually happened and when. Nothing reads approved_at as
-- an audit fact; it is a workflow marker for the flip/un-flip decision.
UPDATE public.supplier_invoices
SET approved_at = updated_at
WHERE approved_at IS NULL
AND status = 'approved';
-- 2. Symmetric cron ----------------------------------------------------------
-- CREATE OR REPLACE rewrites the whole definition, so re-declare the
-- search_path that 20260304191528_set_search_path_on_functions.sql pinned.
CREATE OR REPLACE FUNCTION public.update_overdue_supplier_invoices()
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
-- Flip. Unchanged from 20260607120000: 0.005 mirrors the "fully paid"
-- threshold used by the payment/match paths, and credit notes
-- (kreditfakturor) are not payables.
UPDATE supplier_invoices
SET status = 'overdue',
updated_at = NOW()
WHERE due_date < CURRENT_DATE
AND status IN ('registered', 'approved')
AND remaining_amount > 0.005
AND COALESCE(is_credit_note, false) = false;
-- Un-flip: the exact inverse of the predicate above. Once the due date is no
-- longer in the past the invoice is not overdue, so it returns to the status
-- the flip collapsed. Fully-paid and credit-note rows stuck on 'overdue' are
-- left alone here: they were repaired once by the backfill in 20260607120000
-- and the flip can no longer produce them.
UPDATE supplier_invoices
SET status = CASE WHEN approved_at IS NOT NULL THEN 'approved' ELSE 'registered' END,
updated_at = NOW()
WHERE status = 'overdue'
AND due_date >= CURRENT_DATE
AND remaining_amount > 0.005
AND COALESCE(is_credit_note, false) = false;
END;
$$;
NOTIFY pgrst, 'reload schema';
+1
View File
@@ -472,6 +472,7 @@ export function makeSupplierInvoice(
received_date: '2024-06-02',
delivery_date: null,
status: 'registered',
approved_at: null,
currency: 'SEK',
exchange_rate: null,
exchange_rate_date: null,
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it } from 'vitest'
import { seedCompany } from '@/tests/pg/fixtures'
import { getPool } from '@/tests/pg/setup'
@@ -38,6 +38,20 @@ const MIGRATION_SQL = readFileSync(
'utf8',
)
/**
* Re-running MIGRATION_SQL also CREATE OR REPLACEs the function with its
* pre-#1206, flip-only definition, and that replacement outlives the describe
* block in the shared test database. Keep the current definition on hand so the
* backfill block can put it back.
*/
const SYMMETRIC_MIGRATION_SQL = readFileSync(
join(process.cwd(), 'supabase/migrations/20260727160000_supplier_invoice_overdue_symmetric.sql'),
'utf8',
)
const SYMMETRIC_FUNCTION_SQL = SYMMETRIC_MIGRATION_SQL.slice(
SYMMETRIC_MIGRATION_SQL.indexOf('CREATE OR REPLACE FUNCTION'),
)
async function insertSupplier(userId: string, companyId: string): Promise<string> {
const id = randomUUID()
await getPool().query(
@@ -60,6 +74,7 @@ async function insertSupplierInvoice(params: {
paidAmount?: number
isCreditNote?: boolean
paidAt?: string | null
approvedAt?: string | null
}): Promise<string> {
const id = randomUUID()
const arrivalNumber = (Date.now() % 1_000_000_000) + Math.floor(Math.random() * 100_000)
@@ -68,9 +83,9 @@ async function insertSupplierInvoice(params: {
(id, user_id, company_id, supplier_id, arrival_number, supplier_invoice_number,
invoice_date, due_date, received_date, status, currency,
subtotal, vat_amount, total, paid_amount, remaining_amount, paid_at,
vat_treatment, reverse_charge, is_credit_note)
vat_treatment, reverse_charge, is_credit_note, approved_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $7, $8, 'SEK',
$9, 0, $9, $10, $11, $12, 'standard_25', false, $13)`,
$9, 0, $9, $10, $11, $12, 'standard_25', false, $13, $14)`,
[
id,
params.userId,
@@ -85,6 +100,7 @@ async function insertSupplierInvoice(params: {
params.remaining,
params.paidAt ?? null,
params.isCreditNote ?? false,
params.approvedAt ?? null,
],
)
return id
@@ -155,7 +171,86 @@ describe('update_overdue_supplier_invoices()', () => {
})
})
/**
* Symmetry, added by 20260727160000_supplier_invoice_overdue_symmetric.sql
* (#1206): before it, the label was one-way. Extending an unbooked invoice's
* due date left it "Förfallen" forever, which also made it read-only.
*/
describe('update_overdue_supplier_invoices() un-flip', () => {
it('returns an overdue invoice to registered once the due date is no longer past', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: FUTURE, total: 1000, remaining: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('registered')
})
it('returns it to approved when it had been attested (approved_at set)', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: FUTURE, total: 1000, remaining: 1000,
approvedAt: '2026-01-01T08:00:00Z',
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('approved')
})
it('leaves a still-past-due invoice on overdue', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: PAST, total: 1000, remaining: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('overdue')
})
it('does not resurrect a settled invoice: paid stays paid', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'paid', dueDate: FUTURE, total: 1000, remaining: 0, paidAmount: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('paid')
})
it('is a no-op on an overdue row with nothing left to pay (repaired once by 20260607120000)', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
const id = await insertSupplierInvoice({
userId, companyId, supplierId,
status: 'overdue', dueDate: FUTURE, total: 1000, remaining: 0, paidAmount: 1000,
})
await getPool().query('SELECT public.update_overdue_supplier_invoices()')
expect(await statusOf(id)).toBe('overdue')
})
})
describe('overdue backfill (migration 20260607120000)', () => {
// Replaying the old migration downgrades the function definition; restore the
// current (symmetric) one so nothing later in the run sees a stale version.
afterAll(async () => {
await getPool().query(SYMMETRIC_FUNCTION_SQL)
})
it('reverts a credit note wrongly stuck on overdue back to registered', async () => {
const { userId, companyId } = await seedCompany()
const supplierId = await insertSupplier(userId, companyId)
+6
View File
@@ -788,6 +788,12 @@ export interface SupplierInvoice {
delivery_date: string | null
status: SupplierInvoiceStatus
/**
* When the invoice was attested. The overdue cron collapses 'registered' and
* 'approved' into 'overdue', so this is the only durable attest marker: use
* it, not the status, to tell whether approval has happened.
*/
approved_at: string | null
currency: string
exchange_rate: number | null