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', () => {
|
||||
|
||||
Reference in New Issue
Block a user