Files
accounted/lib/transactions/__tests__/ignore.test.ts
T
Jakob Wennberg dc07ca8872 feat(transactions): steer private marking in locked periods to ignore, with v1 and MCP ignore verbs (#1661) (#2031)
Decision (option a): a private marking stays a real booking (eget uttag/insattning), so it remains blocked in a locked or closed period; the legal escape for rows that are not affarshandelser is ignore. Private + locked now returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED with remediation naming the ignore paths instead of a bare PERIOD_LOCKED, on all four categorize surfaces and the bulk driver. New v1 POST/DELETE /transactions/{id}/ignore (isTransactionBooked-based 409, idempotent) and a staged MCP gnubok_ignore_transaction (+ accounted_ alias, search visibility to respect the tools/list payload ceiling) with operation_type ignore_transaction; the CHECK pair 20260831070000/070001 rebuilds the constraint from main's newest list plus the new value. Dashboard toast gains an Ignorera i stallet action. Closes #1661
2026-08-31 08:39:04 +01:00

179 lines
7.4 KiB
TypeScript

/**
* setTransactionIgnored (issue #1661): the shared core behind the dashboard
* ignore route, the v1 ignore verb and the staged MCP ignore_transaction
* executor.
*
* Pins the contract the three doors rely on: "already booked" is decided by
* isTransactionBooked() across all three anchors (journal_entry_id, payment
* allocations, voucher links), the flag flip is idempotent, restore skips the
* booked check (the DB CHECK already guarantees an ignored row is unbooked),
* dry run writes nothing, and DB failures throw with their SQLSTATE intact.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { setTransactionIgnored } from '../ignore'
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
const unbookedRow = { id: TX_ID, journal_entry_id: null, is_ignored: false }
/** Queue the three junction lookups (voucher links, invoice payments, supplier payments). */
function enqueueNoAnchors(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [] })
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('setTransactionIgnored: refusals', () => {
it('returns 404 TX_CATEGORIZE_TX_NOT_FOUND when the row is not in this company', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: null })
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toEqual({ ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND', status: 404 })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a directly booked row without consulting the junction tables', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { ...unbookedRow, journal_entry_id: 'je-1' } })
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toEqual({ ok: false, code: 'TX_IGNORE_ALREADY_BOOKED', status: 409 })
expect(findCalls('transaction_voucher_links', 'select')).toEqual([])
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a bulk-booked row anchored only through transaction_voucher_links', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
enqueue({ data: [{ transaction_id: TX_ID }] }) // voucher links
enqueue({ data: [] }) // invoice payments
enqueue({ data: [] }) // supplier payments
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toEqual({ ok: false, code: 'TX_IGNORE_ALREADY_BOOKED', status: 409 })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a multi-allocated row anchored only through invoice_payments', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
enqueue({ data: [] })
enqueue({ data: [{ transaction_id: TX_ID }] }) // invoice payments
enqueue({ data: [] })
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toEqual({ ok: false, code: 'TX_IGNORE_ALREADY_BOOKED', status: 409 })
})
it('refuses a row anchored only through supplier_invoice_payments', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: [{ transaction_id: TX_ID }] }) // supplier payments
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toEqual({ ok: false, code: 'TX_IGNORE_ALREADY_BOOKED', status: 409 })
})
it('throws (with the SQLSTATE) when the fetch itself fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'db down', code: '57P01' } })
await expect(
setTransactionIgnored(supabase as never, 'company-1', TX_ID, true),
).rejects.toMatchObject({ message: 'db down', code: '57P01' })
})
})
describe('setTransactionIgnored: writes', () => {
it('flips is_ignored on an unbooked row scoped to the company (happy path)', async () => {
const { supabase, enqueue, findCalls, calls } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
enqueueNoAnchors(enqueue)
enqueue({ data: null })
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toEqual({
ok: true,
transaction_id: TX_ID,
is_ignored: true,
changed: true,
dry_run: false,
})
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: true }]])
// Defense in depth: the update is filtered by company_id, not just id.
const eqArgs = calls.filter((c) => c.table === 'transactions' && c.method === 'eq').map((c) => c.args)
expect(eqArgs).toContainEqual(['company_id', 'company-1'])
})
it('is idempotent: an already-ignored row returns changed=false and writes nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { ...unbookedRow, is_ignored: true } })
enqueueNoAnchors(enqueue)
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true)
expect(outcome).toMatchObject({ ok: true, is_ignored: true, changed: false })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('dry run reports what would change and writes nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
enqueueNoAnchors(enqueue)
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, true, {
dryRun: true,
})
expect(outcome).toMatchObject({ ok: true, changed: true, dry_run: true })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('restore clears the flag without the booked check (the DB CHECK already guarantees unbooked)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { ...unbookedRow, is_ignored: true } })
enqueue({ data: null }) // update
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, false)
expect(outcome).toMatchObject({ ok: true, is_ignored: false, changed: true })
expect(findCalls('transaction_voucher_links', 'select')).toEqual([])
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: false }]])
})
it('restore on a row that is not ignored is a no-op', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
const outcome = await setTransactionIgnored(supabase as never, 'company-1', TX_ID, false)
expect(outcome).toMatchObject({ ok: true, is_ignored: false, changed: false })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('throws when the update fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: unbookedRow })
enqueueNoAnchors(enqueue)
enqueue({ data: null, error: { message: 'db down', code: '57P01' } })
await expect(
setTransactionIgnored(supabase as never, 'company-1', TX_ID, true),
).rejects.toMatchObject({ code: '57P01' })
})
})