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:
co-authored by
Claude Fable 5
parent
968161b42b
commit
1270b6daeb
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user