Files
accounted/lib/supplier-invoices/lifecycle.ts
T
Jakob Wennberg 7c44cef66d fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted (#1249)
* fix(supplier-invoices): freeze verifikat-critical fields once the registration entry is posted

invoice_date becomes the registration verifikat's entry_date and
supplier_invoice_number goes into its description, but both stayed freely
writable through the shared UpdateSupplierInvoiceSchema. Editing either on a
booked invoice moved the invoice row while the posted entry kept its original
values: the two disagreed silently, nothing landed in
journal_entry_rattelse_log, and the change bypassed both sanctioned rättelse
paths (BFL 5 kap 5-7 §).

Adds findLockedVerifikatFields() next to the other supplier-invoice lifecycle
predicates and calls it from both writers (dashboard PUT and v1 PATCH, which
also covers the API-key/MCP path). Only a differing value is refused, so a
full-form resend of the stored value still succeeds, and due_date,
payment_reference and notes stay editable for the aged-invoice flow (#1206).

Fixes #1230

* fix(supplier-invoices): make the verifikat-field lock atomic with the write

Review follow-up on #1230: the lock check read the row a moment before the
update ran, so a registration entry posted in between let exactly the drift
the guard exists to prevent slip through.

When an update moves a verifikat-critical field on a row that read as
unbooked, the write is now pinned with `registration_journal_entry_id is
null`. A concurrent posting therefore matches zero rows: the dashboard route
returns its existing SI_EDIT_CONFLICT ("reload and try again", and the retry
hits the lock with the right message), and the v1 route re-reads to answer
with SI_EDIT_VERIFIKAT_LOCKED plus reason=race rather than a guess.

The pin is conditional on the update actually moving one of those fields, so
metadata-only edits and full-form resends of unchanged values on a booked
invoice keep working (#1206).
2026-07-27 19:45:26 +02:00

169 lines
6.9 KiB
TypeScript

/**
* 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'
}
/**
* Fields that are copied onto the registration verifikat when it is posted:
*
* - invoice_date -> journal_entries.entry_date (and the fiscal period the
* entry was filed in), lib/bookkeeping/supplier-invoice-entries.ts
* - supplier_invoice_number -> the verifikat description ("Leverantörsfaktura
* <nr>, <leverantör>") and every line_description built
* from it
*
* BFL 5 kap 6-7 § makes "datum för affärshändelsen" and the identification of
* the underlying verifikation mandatory verifikat content, and 5 kap 5 §
* requires a correction to leave the original visible. Rewriting either field
* on the invoice row after the entry is posted satisfies neither: the entry
* keeps its original values, nothing lands in journal_entry_rattelse_log, and
* the invoice and its verifikat silently disagree (#1230).
*/
export const VERIFIKAT_CRITICAL_SUPPLIER_INVOICE_FIELDS = [
'invoice_date',
'supplier_invoice_number',
] as const
export type VerifikatCriticalSupplierInvoiceField =
(typeof VERIFIKAT_CRITICAL_SUPPLIER_INVOICE_FIELDS)[number]
/**
* The verifikat-critical fields an update would actually move, ignoring
* whether an entry has been posted yet.
*
* Only a *differing* value is reported: clients that PUT the whole form back
* (the dashboard edit dialog resends every field it rendered) must keep
* working, and resending the stored value changes nothing on the verifikat.
* Amounts and accounts are not listed because the update schema cannot reach
* them; the damage this guards against is metadata drift, not entry balance.
*/
export function findChangedVerifikatFields(
// Callers hand over the whole validated update body, so unrelated keys
// (due_date, notes, ...) have to be accepted rather than stripped first.
update: Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null | undefined>> & {
[key: string]: unknown
},
existing: Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null>>,
): VerifikatCriticalSupplierInvoiceField[] {
return VERIFIKAT_CRITICAL_SUPPLIER_INVOICE_FIELDS.filter((field) => {
const next = update[field]
if (next === undefined) return false
return next !== (existing[field] ?? null)
})
}
/**
* The verifikat-critical fields an update would change on an invoice whose
* registration entry is already posted. Empty means the update is safe.
*
* An empty result on an as-yet unbooked invoice is only true as of the read it
* was computed from: a registration entry can be posted between that read and
* the write. Callers must therefore pin `registration_journal_entry_id is null`
* on the update itself whenever findChangedVerifikatFields() is non-empty and
* this returns empty, so a concurrent posting turns into zero matched rows
* rather than the very drift this guards against.
*/
export function findLockedVerifikatFields(
update: Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null | undefined>> & {
[key: string]: unknown
},
existing: {
registration_journal_entry_id?: string | null
} & Partial<Record<VerifikatCriticalSupplierInvoiceField, string | null>>,
): VerifikatCriticalSupplierInvoiceField[] {
if (!existing.registration_journal_entry_id) return []
return findChangedVerifikatFields(update, existing)
}