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
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user