fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion (#1204)

* fix(bookkeeping): let a rättelseverifikation be stornoed; unblock aged supplier-invoice deletion

A user who corrected a booking (storno + rättelse) and then discovered the
affärshändelse was already booked by another verifikat had no sanctioned way
out: reverseEntry refused source_type 'correction' alongside 'storno', and
correctEntry rightly rejects a zeroing rättelse (BFL 5 kap 5 §). The same
guard also broke uncategorize-after-rättelse, since bank transactions are
relinked to the correction entry.

- reverseEntry now blocks only 'storno' (storno-of-a-storno keeps the chain
  ambiguity problem); a correction entry is a regular live verifikat and can
  be stornoed, with correction_of_id keeping the chain traceable.
- CANNOT_REVERSE_STORNO copy narrowed to stornos + remediation hint.
- Supplier-invoice DELETE now allows unbooked, unpaid invoices in
  registered/approved/overdue: the daily overdue cron flipped unbooked
  invoices past due_date into a state where deletion was blocked forever.
  Orphan-safety checks (registration JE, payments, accrual schedule) are what
  actually protect the books. UI shows the delete button accordingly.
- LinkVoucherPicker showed customer-side copy (kundfordran/1510) in
  supplier-invoice mode; supplier mode now explains the 2440-debit
  requirement, including why a direct-cost verifikat cannot be linked.

Support case 2026-07-26 (marcus@).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(supplier-invoices): review fixes: fail-closed orphan lookups, hide delete when payments loaded

- The payment and accrual-schedule lookups in DELETE now fail closed: a
  lookup error returns 500 instead of reading as "nothing linked" and
  letting the delete proceed unverified.
- The delete button also requires the loaded payment list to be empty,
  matching the server predicate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: authorize 'approved' in supplier-invoice delete allow-list (compliance-swarm V2.3)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-26 12:49:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 968161b42b
commit 1270b6daeb
12 changed files with 291 additions and 68 deletions
+98 -29
View File
@@ -612,10 +612,12 @@ describe('reverseEntry: entry_date defaults to original entry date', () => {
})
})
describe('reverseEntry: rejects reversing a storno or correction', () => {
describe('reverseEntry: storno guard', () => {
// BFL 5 kap 5§: a storno-of-a-storno makes the original verifikat's
// cancellation chain ambiguous. The UI hides "Återför" for these source
// types; the engine is the server-side backstop against a direct API call.
// cancellation chain ambiguous, so stornos are never reversible. A
// correction entry, by contrast, is a regular live verifikation (it can be
// a duplicate of an affärshändelse booked by another verifikat) and must
// stay reversible: the guard covers 'storno' only.
function supabaseReturningOriginal(original: Record<string, unknown>) {
return {
rpc: vi.fn(),
@@ -631,34 +633,101 @@ describe('reverseEntry: rejects reversing a storno or correction', () => {
}
}
for (const sourceType of ['storno', 'correction'] as const) {
it(`throws CannotReverseStornoError for source_type '${sourceType}'`, async () => {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 3,
entry_date: '2024-11-15',
description: 'Makulering: Hyra november',
source_type: sourceType,
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
{ account_number: '5010', debit_amount: 0, credit_amount: 10000 },
],
}
const supabase = supabaseReturningOriginal(original)
it(`throws CannotReverseStornoError for source_type 'storno'`, async () => {
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 3,
entry_date: '2024-11-15',
description: 'Makulering: Hyra november',
source_type: 'storno',
source_id: null,
lines: [
{ account_number: '1930', debit_amount: 10000, credit_amount: 0 },
{ account_number: '5010', debit_amount: 0, credit_amount: 10000 },
],
}
const supabase = supabaseReturningOriginal(original)
await expect(
reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1'),
).rejects.toBeInstanceOf(CannotReverseStornoError)
await expect(
reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1'),
).rejects.toBeInstanceOf(CannotReverseStornoError)
// No reversal was written: the guard fires before any voucher number is drawn.
expect(supabase.rpc).not.toHaveBeenCalled()
})
}
// No reversal was written: the guard fires before any voucher number is drawn.
expect(supabase.rpc).not.toHaveBeenCalled()
})
it(`reverses a correction entry like any regular verifikat`, async () => {
// A rättelseverifikation that turned out to duplicate another booking
// (support case 2026-07-26) is nullified with a normal storno; the
// correction_of_id link keeps the chain traceable.
const original = {
id: 'entry-1',
company_id: 'company-1',
status: 'posted',
fiscal_period_id: 'period-1',
voucher_series: 'A',
voucher_number: 3,
entry_date: '2024-11-15',
description: 'Rättelse: Hyra november',
source_type: 'correction',
source_id: null,
correction_of_id: 'entry-0',
lines: [
{ account_number: '5010', debit_amount: 10000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 10000 },
],
}
const reversal = { id: 'reversal-1', reverses_id: 'entry-1' }
let jeCall = 0
const jeResults = [
{ data: original, error: null },
{ data: reversal, error: null },
{ data: null, error: null },
{ data: [{ id: 'entry-1' }], error: null },
{ data: { ...reversal, lines: [] }, error: null },
]
let insertedEntry: Record<string, unknown> | undefined
function jeBuilder() {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'update']) b[m] = vi.fn().mockReturnValue(b)
b.insert = vi.fn().mockImplementation((payload: unknown) => {
insertedEntry = payload as Record<string, unknown>
return b
})
b.single = vi.fn().mockImplementation(async () => jeResults[jeCall++])
b.then = (resolve: (v: unknown) => void) => resolve(jeResults[jeCall++])
return b
}
const supabase = {
rpc: vi.fn().mockResolvedValue({ data: 4, error: null }),
from: vi.fn().mockImplementation((table: string) => {
if (table === 'journal_entries') return jeBuilder()
if (table === 'chart_of_accounts') {
const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) b[m] = vi.fn().mockReturnValue(b)
b.then = (resolve: (v: unknown) => void) =>
resolve({ data: [{ id: 'acc-5010', account_number: '5010' }, { id: 'acc-1930', account_number: '1930' }], error: null })
return b
}
if (table === 'journal_entry_lines') return { insert: vi.fn().mockResolvedValue({ error: null }) }
return createMockChain()
}),
}
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(insertedEntry).toBeDefined()
expect(insertedEntry!.source_type).toBe('storno')
expect(insertedEntry!.reverses_id).toBe('entry-1')
expect(insertedEntry!.description).toBe('Makulering: Rättelse: Hyra november')
})
})
describe('reverseEntry: bank transaction unlink', () => {
+10 -5
View File
@@ -733,11 +733,16 @@ export async function reverseEntry(
throw new CannotReverseNonPostedError(original.status)
}
// A storno or correction entry must never itself be reversed: a
// storno-of-a-storno makes the original verifikat's cancellation chain
// ambiguous (BFL 5 kap 5§). The UI hides "Återför" for these source types;
// this is the server-side backstop against a direct API call.
if (original.source_type === 'storno' || original.source_type === 'correction') {
// A storno entry must never itself be reversed: a storno-of-a-storno makes
// the original verifikat's cancellation chain ambiguous (BFL 5 kap 5§). A
// correction entry, by contrast, is a regular live verifikation and must
// stay reversible: it can be a duplicate (the affärshändelse already booked
// by another verifikat) or plain wrong, and blocking it left users with no
// sanctioned way out (support case 2026-07-26). Its correction_of_id link
// keeps the chain traceable either way; the original it corrected stays
// 'reversed'. The UI hides "Återför" for stornos; this is the server-side
// backstop against a direct API call.
if (original.source_type === 'storno') {
throw new CannotReverseStornoError(original.source_type)
}
+7 -5
View File
@@ -147,16 +147,18 @@ export class CannotReverseNonPostedError extends Error {
/**
* Raised when a storno (reversal) is attempted on an entry that is itself a
* storno or a correction. Reversing such an entry would produce a
* storno-of-a-storno and make the original verifikat's cancellation chain
* ambiguous, violating the traceable-correction requirement of BFL 5 kap 5§.
* The UI hides the "Återför" action for these source types; this is the
* storno. Reversing a storno would produce a storno-of-a-storno and make the
* original verifikat's cancellation chain ambiguous, violating the
* traceable-correction requirement of BFL 5 kap 5§. Correction entries are
* NOT covered: a rättelseverifikation is a regular live verifikat and may be
* stornoed like any other (its correction_of_id link keeps the chain
* traceable). The UI hides the "Återför" action for stornos; this is the
* server-side backstop so a direct API call cannot bypass it.
*/
export class CannotReverseStornoError extends Error {
readonly code = CANNOT_REVERSE_STORNO
constructor(public readonly sourceType: string) {
super('Cannot reverse a storno or correction entry')
super('Cannot reverse a storno entry')
this.name = 'CannotReverseStornoError'
}
}
@@ -137,14 +137,18 @@ describe('getErrorMessage: typed bookkeeping Error instances (issue #337)', () =
})
it('CannotReverseStornoError instance → registry Swedish message (no dynamic branch)', () => {
const msg = getErrorMessage(new CannotReverseStornoError('reversal'))
expect(msg).toBe('En stornering eller rättelse kan inte stornas.')
const msg = getErrorMessage(new CannotReverseStornoError('storno'))
expect(msg).toBe(
'En stornering kan inte stornas. Om verifikationen makulerades av misstag, bokför den på nytt (kopiera originalet).',
)
expect(msg).not.toContain('Cannot reverse')
})
it('locale "en" on a typed instance → registry English message', () => {
const msg = getErrorMessage(new CannotReverseStornoError('reversal'), { locale: 'en' })
expect(msg).toBe('A storno or correction entry cannot be reversed.')
const msg = getErrorMessage(new CannotReverseStornoError('storno'), { locale: 'en' })
expect(msg).toBe(
'A storno entry cannot be reversed. If the entry was cancelled by mistake, re-book it (copy the original).',
)
})
it('regression: plain-object bare envelope with a Swedish message passes through unchanged', () => {
+6 -4
View File
@@ -191,8 +191,10 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
},
CANNOT_REVERSE_STORNO: {
httpStatus: 400,
message_sv: 'En stornering eller rättelse kan inte stornas.',
message_en: 'A storno or correction entry cannot be reversed.',
message_sv:
'En stornering kan inte stornas. Om verifikationen makulerades av misstag, bokför den på nytt (kopiera originalet).',
message_en:
'A storno entry cannot be reversed. If the entry was cancelled by mistake, re-book it (copy the original).',
},
CANNOT_CORRECT_NON_POSTED: {
httpStatus: 400,
@@ -1934,9 +1936,9 @@ const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
SI_DELETE_HAS_BOOKING: {
httpStatus: 400,
message_sv:
'Leverantörsfakturan är bokförd eller har en periodisering och kan inte tas bort. Skapa en kreditfaktura i stället för att återställa bokföringen.',
'Leverantörsfakturan är bokförd, har registrerade betalningar eller en periodisering och kan inte tas bort. Skapa en kreditfaktura i stället för att återställa bokföringen.',
message_en:
'The supplier invoice has a posted journal entry or an accrual schedule and cannot be deleted. Create a credit note instead to reverse the bookkeeping.',
'The supplier invoice has a posted journal entry, recorded payments, or an accrual schedule and cannot be deleted. Create a credit note instead to reverse the bookkeeping.',
},
SI_PAID_ALREADY: {
httpStatus: 409,