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
This commit is contained in:
Jakob Wennberg
2026-08-31 08:39:04 +01:00
committed by GitHub
parent 79edee659f
commit dc07ca8872
44 changed files with 2062 additions and 73 deletions
@@ -685,6 +685,36 @@ describe('POST /api/transactions/[id]/categorize', () => {
expect(mockSaveUserMappingRule).not.toHaveBeenCalled()
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
it('returns TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (suggested_action ignore) for a private marking in a locked period (issue #1661)', async () => {
// A private marking books eget uttag/insättning, so the lock applies,
// but the row is usually no affärshändelse: the pre-check answers with
// the ignore-steering code before the engine is asked to book anything.
enqueueUpToEngine()
mockCheckPeriodLock.mockResolvedValue({
locked: true,
reason: 'period_locked_at_set',
fiscal_period_id: 'fp-2024',
})
const request = createMockRequest('/api/transactions/tx-1/categorize', {
method: 'POST',
body: { is_business: false },
})
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{
error: { code: string; message: string; details?: { reason?: string; suggested_action?: string } }
}>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED')
expect(body.error.message).toContain('Ignorera')
expect(body.error.details?.reason).toBe('period_locked_at_set')
expect(body.error.details?.suggested_action).toBe('ignore')
expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled()
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockReverseOrphanedJournalEntry).not.toHaveBeenCalled()
})
})
it('returns 500 when transaction update fails', async () => {
+33 -7
View File
@@ -805,6 +805,27 @@ export const POST = withRouteContext(
await ensureFiscalPeriod(supabase, user.id, companyId, transaction.date, fiscalYearStartMonth, txLog)
// Issue #1661: a private marking books eget uttag/insättning, so a locked
// period refuses it like any verifikat. Pre-check only for private rows:
// the trigger path below would answer with a bare locked-period cause,
// while the row the user wants to clear (a duplicate, a PSD2 ghost) is
// usually no affärshändelse at all and should be ignored instead. The
// response carries suggested_action: 'ignore' for the one-click toast.
if (!is_business) {
const privateLock = await checkPeriodLock(supabase, companyId, transaction.date)
if (privateLock.locked) {
return errorResponseFromCode('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED', txLog, {
requestId,
details: {
transaction_date: transaction.date,
reason: privateLock.reason,
fiscal_period_id: privateLock.fiscal_period_id,
suggested_action: 'ignore',
},
})
}
}
let journalEntryCreated = false
let journalEntryId: string | null = null
let documentLinkWarning: string | null = null
@@ -863,14 +884,19 @@ export const POST = withRouteContext(
if (!journalEntryId) {
const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
if (periodLock.locked) {
return errorResponseFromCode('PERIOD_LOCKED', txLog, {
requestId,
details: {
transaction_date: transaction.date,
reason: periodLock.reason,
fiscal_period_id: periodLock.fiscal_period_id,
return errorResponseFromCode(
is_business ? 'PERIOD_LOCKED' : 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED',
txLog,
{
requestId,
details: {
transaction_date: transaction.date,
reason: periodLock.reason,
fiscal_period_id: periodLock.fiscal_period_id,
...(is_business ? {} : { suggested_action: 'ignore' }),
},
},
})
)
}
return errorResponseFromCode('NO_OPEN_PERIOD_FOR_DATE', txLog, {
requestId,
@@ -7,7 +7,7 @@ import {
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const { supabase: mockSupabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
@@ -28,6 +28,13 @@ import { POST, DELETE } from '../route'
const mockUser = { id: 'user-1', email: 'test@test.se' }
/** The three junction lookups the shared core runs for an unbooked row. */
function enqueueNoAnchors() {
enqueue({ data: [], error: null }) // transaction_voucher_links
enqueue({ data: [], error: null }) // invoice_payments
enqueue({ data: [], error: null }) // supplier_invoice_payments
}
beforeEach(() => {
vi.clearAllMocks()
reset()
@@ -68,7 +75,7 @@ describe('POST /api/transactions/[id]/ignore', () => {
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
enqueue({ data: null, error: null }) // maybeSingle: no row
const request = createMockRequest('/api/transactions/tx-999/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-999' }))
@@ -87,10 +94,29 @@ describe('POST /api/transactions/[id]/ignore', () => {
expect(status).toBe(409)
expect(body.error).toContain('redan bokförd')
expect(findCalls('transactions', 'update')).toEqual([])
})
it('returns 409 for a bulk-booked row whose verifikat lives in transaction_voucher_links (issue #1661)', async () => {
// journal_entry_id stays NULL for N>1 bulk bookings: the old bare check
// would have let this row be ignored while a verifikat still carries it.
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null })
enqueue({ data: [{ transaction_id: 'tx-1' }], error: null }) // transaction_voucher_links
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('redan bokförd')
expect(findCalls('transactions', 'update')).toEqual([])
})
it('is idempotent when the transaction is already ignored', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: true }, error: null })
enqueueNoAnchors()
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
@@ -98,10 +124,12 @@ describe('POST /api/transactions/[id]/ignore', () => {
expect(status).toBe(200)
expect(body).toEqual({ success: true, already_ignored: true })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('marks the transaction ignored (happy path)', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch
enqueueNoAnchors()
enqueue({ data: null, error: null }) // update
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
@@ -110,10 +138,12 @@ describe('POST /api/transactions/[id]/ignore', () => {
expect(status).toBe(200)
expect(body).toEqual({ success: true })
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: true }]])
})
it('returns 500 when the update fails', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch
enqueueNoAnchors()
enqueue({ data: null, error: { message: 'db down' } }) // update fails
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' })
@@ -155,7 +185,19 @@ describe('DELETE /api/transactions/[id]/ignore', () => {
expect(body).toEqual({ error: 'Forbidden' })
})
it('returns 404 when transaction not found', async () => {
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-999/ignore', { method: 'DELETE' })
const response = await DELETE(request, createMockRouteParams({ id: 'tx-999' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(404)
expect(body).toEqual({ error: 'Transaction not found' })
})
it('clears the ignore flag (happy path)', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: true }, error: null }) // fetch
enqueue({ data: null, error: null }) // update
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' })
@@ -164,9 +206,11 @@ describe('DELETE /api/transactions/[id]/ignore', () => {
expect(status).toBe(200)
expect(body).toEqual({ success: true })
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: false }]])
})
it('returns 500 when the update fails', async () => {
enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: true }, error: null }) // fetch
enqueue({ data: null, error: { message: 'db down' } }) // update fails
const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' })
+33 -46
View File
@@ -1,6 +1,8 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { getErrorEntry } from '@/lib/errors/structured-errors'
import { setTransactionIgnored } from '@/lib/transactions/ignore'
/**
* POST /api/transactions/[id]/ignore
@@ -8,50 +10,37 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m
* Mark a bank transaction as ignored so it stops surfacing in the bank
* reconciliation view (and other "to book" funnels) without creating a
* verifikation. Use case: tiny ränteintäkter, rounding noise, opening-balance
* artefacts: anything the user wants off the unmatched list but doesn't want
* to fabricate a journal entry for.
* artefacts, PSD2 ghost rows: anything the user wants off the unmatched list
* but doesn't want to fabricate a journal entry for. No verifikat is written,
* so a locked or closed period does not block it (issue #1661).
*
* Refuses when the transaction is already booked; once a verifikation exists,
* the proper way to revisit it is /uncategorize (storno).
* Refuses when the transaction is already booked (directly or through a
* payment allocation / voucher link: lib/transactions/is-booked.ts); once a
* verifikation exists, the proper way to revisit it is /uncategorize (storno).
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.ignore',
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
try {
const outcome = await setTransactionIgnored(supabase, companyId, id, true)
if (!outcome.ok) {
if (outcome.status === 404) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
return NextResponse.json(
{ error: getErrorEntry(outcome.code)?.message_sv ?? 'Transaktionen är redan bokförd.' },
{ status: outcome.status },
)
}
if (!outcome.changed) {
return NextResponse.json({ success: true, already_ignored: true })
}
return NextResponse.json({ success: true })
} catch (err) {
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
}
if (transaction.journal_entry_id) {
return NextResponse.json(
{ error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' },
{ status: 409 }
)
}
if (transaction.is_ignored) {
return NextResponse.json({ success: true, already_ignored: true })
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: true })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: getUserErrorMessage(updateError) }, { status: 500 })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
@@ -68,17 +57,15 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
async (_request, { supabase, companyId }, { params }) => {
const { id } = await params
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: false })
.eq('id', id)
.eq('company_id', companyId)
if (updateError) {
return NextResponse.json({ error: getUserErrorMessage(updateError) }, { status: 500 })
try {
const outcome = await setTransactionIgnored(supabase, companyId, id, false)
if (!outcome.ok) {
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
} catch (err) {
return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 })
}
return NextResponse.json({ success: true })
},
{ requireWrite: true },
)
@@ -451,3 +451,62 @@ describe('POST /api/v1/.../transactions/{id}/categorize orphaned counter-account
expect(createTxJE).not.toHaveBeenCalled()
})
})
describe('POST /api/v1/.../transactions/{id}/categorize private marking in a locked period (issue #1661)', () => {
function lockedPeriodSupabase() {
return makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_ID,
company_id: COMPANY_ID,
date: '2025-11-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'SWISH DUBBLETT',
cash_account_id: null,
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-2025', is_closed: false, locked_at: '2026-01-31T00:00:00Z' }, error: null },
})
}
it('answers is_business: false with TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED and suggested_action ignore', async () => {
const { supabase, updates } = lockedPeriodSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest({ is_business: false }), routeParams())
const body = await res.json()
expect(res.status).toBe(400)
expect(body.error.code).toBe('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED')
expect(body.error.details).toMatchObject({
transaction_date: '2025-11-12',
reason: 'period_locked_at_set',
fiscal_period_id: 'period-2025',
suggested_action: 'ignore',
})
expect(createTxJE).not.toHaveBeenCalled()
expect(updates.transactions).toBeUndefined()
})
it('keeps PERIOD_LOCKED (no suggested_action) for a business categorization', async () => {
const { supabase, updates } = lockedPeriodSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(
makeRequest({ is_business: true, category: 'expense_office' }),
routeParams(),
)
const body = await res.json()
expect(res.status).toBe(400)
expect(body.error.code).toBe('PERIOD_LOCKED')
expect(body.error.details.suggested_action).toBeUndefined()
expect(createTxJE).not.toHaveBeenCalled()
expect(updates.transactions).toBeUndefined()
})
})
@@ -397,14 +397,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
transaction.date,
)
if (periodLock.locked) {
return v1ErrorResponseFromCode('PERIOD_LOCKED', txLog, {
requestId: ctx.requestId,
details: {
transaction_date: transaction.date,
reason: periodLock.reason,
fiscal_period_id: periodLock.fiscal_period_id,
// Issue #1661: a private marking is a real booking (eget uttag /
// insättning), so the lock applies, but a row that is not a business
// event should be ignored, not booked: answer with the code whose
// remediation names the ignore verb instead of unlock.
return v1ErrorResponseFromCode(
is_business ? 'PERIOD_LOCKED' : 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED',
txLog,
{
requestId: ctx.requestId,
details: {
transaction_date: transaction.date,
reason: periodLock.reason,
fiscal_period_id: periodLock.fiscal_period_id,
...(is_business ? {} : { suggested_action: 'ignore' }),
},
},
})
)
}
// Live path: create the journal entry. The internal route runs a
@@ -0,0 +1,305 @@
/**
* Tests for the v1 ignore verb (issue #1661):
*
* POST /api/v1/companies/{companyId}/transactions/{id}/ignore
* DELETE /api/v1/companies/{companyId}/transactions/{id}/ignore
*
* Proxy-backed Supabase mock with per-table response queues (same pattern as
* the sibling categorize route test). The booked check is the shared core's
* (lib/transactions/ignore.ts): a junction-anchored row with journal_entry_id
* NULL must be refused like a directly booked one.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST, DELETE } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const updates: Record<string, unknown[]> = {}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (...args: unknown[]) => {
if (prop === 'update') (updates[table] ??= []).push(args[0])
return buildChain(table)
}
},
}
return new Proxy({}, handler)
}
return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, updates }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const URL = `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/ignore`
function makeRequest(method: 'POST' | 'DELETE', opts: { url?: string; idempotencyKey?: string | null } = {}): Request {
const headers: Record<string, string> = {
Authorization: 'Bearer test-fixture-not-a-real-key',
}
if (opts.idempotencyKey !== null) {
headers['Idempotency-Key'] = opts.idempotencyKey ?? 'idem1234-aaaa-4abc-8def-1234567890ab'
}
return new Request(opts.url ?? URL, { method, headers })
}
function routeParams(id: string = TX_ID) {
return { params: Promise.resolve({ companyId: COMPANY_ID, id }) }
}
function unbookedSupabase(transactionOverrides: Record<string, unknown> = {}) {
return makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: [
{ data: { id: TX_ID, journal_entry_id: null, is_ignored: false, ...transactionOverrides }, error: null },
{ data: null, error: null }, // update
],
transaction_voucher_links: { data: [], error: null },
invoice_payments: { data: [], error: null },
supplier_invoice_payments: { data: [], error: null },
idempotency_keys: { data: null, error: null },
})
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
scopes: ['transactions:write'],
mode: 'live',
})
})
describe('POST /api/v1/.../transactions/{id}/ignore', () => {
it('returns 401 without a valid bearer token', async () => {
mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 })
mockServiceClient.mockReturnValue(unbookedSupabase().supabase)
const res = await POST(makeRequest('POST'), routeParams())
expect(res.status).toBe(401)
})
it('rejects keys without transactions:write scope', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
scopes: ['transactions:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(unbookedSupabase().supabase)
const res = await POST(makeRequest('POST'), routeParams())
expect(res.status).toBe(403)
})
it('requires an Idempotency-Key (400)', async () => {
const { supabase, updates } = unbookedSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST', { idempotencyKey: null }), routeParams())
expect(res.status).toBe(400)
expect(updates.transactions).toBeUndefined()
})
it('rejects a non-UUID transaction id with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(unbookedSupabase().supabase)
const res = await POST(
makeRequest('POST', { url: `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/not-a-uuid/ignore` }),
routeParams('not-a-uuid'),
)
const body = await res.json()
expect(res.status).toBe(400)
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('returns 404 TX_CATEGORIZE_TX_NOT_FOUND when the row is not in this company', async () => {
const { supabase, updates } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: { data: null, error: null },
})
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST'), routeParams())
const body = await res.json()
expect(res.status).toBe(404)
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
expect(updates.transactions).toBeUndefined()
})
it('returns 409 TX_IGNORE_ALREADY_BOOKED for a directly booked row', async () => {
const { supabase, updates } = unbookedSupabase({ journal_entry_id: 'je-1' })
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST'), routeParams())
const body = await res.json()
expect(res.status).toBe(409)
expect(body.error.code).toBe('TX_IGNORE_ALREADY_BOOKED')
expect(body.error.details.transaction_id).toBe(TX_ID)
expect(updates.transactions).toBeUndefined()
})
it('returns 409 for a bulk-booked row anchored only through transaction_voucher_links', async () => {
const { supabase, updates } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: { data: { id: TX_ID, journal_entry_id: null, is_ignored: false }, error: null },
transaction_voucher_links: { data: [{ transaction_id: TX_ID }], error: null },
invoice_payments: { data: [], error: null },
supplier_invoice_payments: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST'), routeParams())
const body = await res.json()
expect(res.status).toBe(409)
expect(body.error.code).toBe('TX_IGNORE_ALREADY_BOOKED')
expect(updates.transactions).toBeUndefined()
})
it('returns 409 for a multi-allocated row anchored only through invoice_payments', async () => {
const { supabase } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: { data: { id: TX_ID, journal_entry_id: null, is_ignored: false }, error: null },
transaction_voucher_links: { data: [], error: null },
invoice_payments: { data: [{ transaction_id: TX_ID }], error: null },
supplier_invoice_payments: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST'), routeParams())
const body = await res.json()
expect(res.status).toBe(409)
expect(body.error.code).toBe('TX_IGNORE_ALREADY_BOOKED')
})
it('is idempotent: an already-ignored row returns already_ignored=true without writing', async () => {
const { supabase, updates } = unbookedSupabase({ is_ignored: true })
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST'), routeParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.data).toEqual({
success: true,
transaction_id: TX_ID,
is_ignored: true,
already_ignored: true,
})
expect(updates.transactions).toBeUndefined()
})
it('ignores an unbooked row (happy path)', async () => {
const { supabase, updates } = unbookedSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST'), routeParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.data).toEqual({
success: true,
transaction_id: TX_ID,
is_ignored: true,
already_ignored: false,
})
expect(updates.transactions).toEqual([{ is_ignored: true }])
})
it('dry-run previews without writing', async () => {
const { supabase, updates } = unbookedSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await POST(makeRequest('POST', { url: `${URL}?dry_run=true` }), routeParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.data.dry_run).toBe(true)
expect(body.data.preview).toEqual({
transaction_id: TX_ID,
would_set_ignored: true,
already_ignored: false,
})
expect(updates.transactions).toBeUndefined()
})
})
describe('DELETE /api/v1/.../transactions/{id}/ignore', () => {
it('returns 401 without a valid bearer token', async () => {
mockValidate.mockResolvedValue({ error: 'Invalid API key', status: 401 })
mockServiceClient.mockReturnValue(unbookedSupabase().supabase)
const res = await DELETE(makeRequest('DELETE'), routeParams())
expect(res.status).toBe(401)
})
it('returns 404 when the row is not in this company', async () => {
const { supabase } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: { data: null, error: null },
})
mockServiceClient.mockReturnValue(supabase)
const res = await DELETE(makeRequest('DELETE'), routeParams())
const body = await res.json()
expect(res.status).toBe(404)
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
})
it('restores an ignored row (happy path)', async () => {
const { supabase, updates } = unbookedSupabase({ is_ignored: true })
mockServiceClient.mockReturnValue(supabase)
const res = await DELETE(makeRequest('DELETE'), routeParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.data).toEqual({
success: true,
transaction_id: TX_ID,
is_ignored: false,
was_ignored: true,
})
expect(updates.transactions).toEqual([{ is_ignored: false }])
})
it('is idempotent: restoring a row that is not ignored returns was_ignored=false without writing', async () => {
const { supabase, updates } = unbookedSupabase()
mockServiceClient.mockReturnValue(supabase)
const res = await DELETE(makeRequest('DELETE'), routeParams())
const body = await res.json()
expect(res.status).toBe(200)
expect(body.data.was_ignored).toBe(false)
expect(updates.transactions).toBeUndefined()
})
})
@@ -0,0 +1,189 @@
/**
* POST /api/v1/companies/{companyId}/transactions/{id}/ignore
* DELETE /api/v1/companies/{companyId}/transactions/{id}/ignore
*
* Mark a bank transaction as ignored (POST) or restore it (DELETE). Ignoring
* writes no verifikat: it is the path for rows that are not affärshändelser
* (PSD2 ghost rows, duplicates from a reconnect, transfers that never
* executed), and it is the only way to clear such rows out of a locked or
* closed period, where a private marking (a real eget uttag/insättning
* booking) is refused with TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (issue #1661).
*
* Both verbs are idempotent and dry-runnable; the transaction id in the path
* is the only input. The booked check uses lib/transactions/is-booked.ts so a
* bulk-booked or multi-allocated row (journal_entry_id NULL, anchored through
* a junction table) is refused like a directly booked one.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { setTransactionIgnored } from '@/lib/transactions/ignore'
const IgnoreResponse = z.object({
success: z.boolean(),
transaction_id: z.string().uuid(),
is_ignored: z.literal(true),
/** true when the row was already ignored: nothing changed. */
already_ignored: z.boolean(),
})
const UnignoreResponse = z.object({
success: z.boolean(),
transaction_id: z.string().uuid(),
is_ignored: z.literal(false),
/** false when the row was not ignored to begin with: nothing changed. */
was_ignored: z.boolean(),
})
registerEndpoint({
operation: 'transactions.ignore',
method: 'POST',
path: '/api/v1/companies/:companyId/transactions/:id/ignore',
summary: 'Ignore a bank transaction (no verifikat, allowed in locked periods).',
description:
'Marks an unbooked bank transaction as ignored so it leaves the "to book" funnels and the reconciliation unmatched totals without creating a verifikat. Nothing is deleted and the flag is reversible with DELETE on the same path. Because no booking is written, a locked or closed fiscal period does not block it: this is the path for clearing rows that are not business events out of a closed period. A booked transaction (directly, via a payment allocation, or via a voucher link) is refused with 409 TX_IGNORE_ALREADY_BOOKED. Idempotent: ignoring an already-ignored row returns already_ignored: true. Dry-runnable.',
useWhen:
'The row is not an affärshändelse: a PSD2 ghost row, a duplicate from a bank reconnect, a transfer that never executed, rounding noise. Also the answer to TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED from /categorize when the row should not be booked at all.',
doNotUseFor:
'Real purchases, payments or owner withdrawals: those must be booked (categorize, match-invoice, or is_business: false in an open period). Ignoring is triage, not bookkeeping.',
pitfalls: [
'Idempotency-Key is mandatory.',
'A booked row cannot be ignored: reverse it first (POST /transactions/{id}/uncategorize) or unlink the payment/voucher.',
'Ignored rows still exist and are listed on the reconciliation bridge\'s ignored line; they never disappear silently.',
],
example: {
response: {
data: { success: true, transaction_id: 'tx_…', is_ignored: true, already_ignored: false },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'transactions:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
response: { success: dataEnvelope(IgnoreResponse) },
})
registerEndpoint({
operation: 'transactions.unignore',
method: 'DELETE',
path: '/api/v1/companies/:companyId/transactions/:id/ignore',
summary: 'Restore an ignored bank transaction to the "to book" list.',
description:
'Clears the ignore flag set by POST on the same path. The row comes back into the unbooked list and the reconciliation unmatched totals; no verifikat was ever written, so there is nothing to reverse. Idempotent: restoring a row that is not ignored returns was_ignored: false. Dry-runnable.',
useWhen: 'A row was ignored by mistake and should be booked after all.',
doNotUseFor:
'Undoing a booking: that is a storno via POST /transactions/{id}/uncategorize.',
pitfalls: ['Idempotency-Key is mandatory.'],
example: {
response: {
data: { success: true, transaction_id: 'tx_…', is_ignored: false, was_ignored: true },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'transactions:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
response: { success: dataEnvelope(UnignoreResponse) },
})
function parseTransactionId(id: string): string | null {
const parsed = z.string().uuid().safeParse(id)
return parsed.success ? parsed.data : null
}
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'transactions.ignore',
async (_request, ctx, params) => {
const { id } = await params.params
const txId = parseTransactionId(id)
if (!txId) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Transaction id must be a UUID.' },
})
}
try {
const outcome = await setTransactionIgnored(ctx.supabase, ctx.companyId!, txId, true, {
dryRun: ctx.dryRun,
})
if (!outcome.ok) {
return v1ErrorResponseFromCode(outcome.code, ctx.log, {
requestId: ctx.requestId,
details: { transaction_id: txId },
})
}
if (ctx.dryRun) {
return dryRunPreview(
{ transaction_id: txId, would_set_ignored: true, already_ignored: !outcome.changed },
{ requestId: ctx.requestId, log: ctx.log },
)
}
return ok(
{
success: true,
transaction_id: txId,
is_ignored: true as const,
already_ignored: !outcome.changed,
},
{ requestId: ctx.requestId },
)
} catch (err) {
ctx.log.error('transactions.ignore failed', err as Error)
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'transactions.unignore',
async (_request, ctx, params) => {
const { id } = await params.params
const txId = parseTransactionId(id)
if (!txId) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Transaction id must be a UUID.' },
})
}
try {
const outcome = await setTransactionIgnored(ctx.supabase, ctx.companyId!, txId, false, {
dryRun: ctx.dryRun,
})
if (!outcome.ok) {
return v1ErrorResponseFromCode(outcome.code, ctx.log, {
requestId: ctx.requestId,
details: { transaction_id: txId },
})
}
if (ctx.dryRun) {
return dryRunPreview(
{ transaction_id: txId, would_set_ignored: false, was_ignored: outcome.changed },
{ requestId: ctx.requestId, log: ctx.log },
)
}
return ok(
{
success: true,
transaction_id: txId,
is_ignored: false as const,
was_ignored: outcome.changed,
},
{ requestId: ctx.requestId },
)
} catch (err) {
ctx.log.error('transactions.unignore failed', err as Error)
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
@@ -237,6 +237,58 @@ describe('POST batch-categorize', () => {
)
})
it('answers a private marking in a locked period with TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED, a business one with PERIOD_LOCKED (issue #1661)', async () => {
const { supabase, updates } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_A,
company_id: COMPANY_ID,
date: '2025-11-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'SWISH DUBBLETT',
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
fiscal_periods: { data: { id: 'period-2025', is_closed: true, locked_at: null }, error: null },
})
mockServiceClient.mockReturnValue(supabase)
const res = await POST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
{
items: [
{ transaction_id: TX_A, categorization: { is_business: false } },
{ transaction_id: TX_B, categorization: { is_business: true, category: 'expense_office' } },
],
},
),
batchParams(),
)
const body = await res.json()
expect(body.data.results[0].ok).toBe(false)
expect(body.data.results[0].error.code).toBe('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED')
expect(body.data.results[0].error.message).toContain('Ignorera')
expect(body.data.results[0].error.details).toMatchObject({
transaction_date: '2025-11-12',
reason: 'period_is_closed',
fiscal_period_id: 'period-2025',
suggested_action: 'ignore',
})
expect(body.data.results[1].ok).toBe(false)
expect(body.data.results[1].error.code).toBe('PERIOD_LOCKED')
expect(body.data.results[1].error.details.suggested_action).toBeUndefined()
expect(body.data.summary).toEqual({ total: 2, succeeded: 0, failed: 2 })
// Neither item reached the engine or the CAS write.
expect(createTxJE).not.toHaveBeenCalled()
expect(updates.transactions).toBeUndefined()
})
it('returns a per-item NO_OPEN_PERIOD_FOR_DATE and writes nothing when the engine finds no covering period', async () => {
const { supabase, updates } = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
@@ -323,6 +323,30 @@ async function categorizeOne(
// than a generic INTERNAL_ERROR from the trigger exception.
const periodLock = await checkPeriodLock(supabase, companyId, transaction.date)
if (periodLock.locked) {
// Issue #1661: a private marking is a real booking (eget uttag /
// insättning), so the lock applies, but the row the caller wants to
// clear is usually not a business event at all. Name the ignore path
// instead of a bare PERIOD_LOCKED so an agent clearing PSD2 ghost rows
// out of a closed period is not steered to unlock it.
if (!is_business) {
return {
ok: false,
request_index: index,
transaction_id: transactionId,
error: {
code: 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED',
message:
getErrorEntry('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED')?.message_sv ??
'Perioden är låst. Ignorera raden i stället om den inte är en affärshändelse.',
details: {
transaction_date: transaction.date,
reason: periodLock.reason,
fiscal_period_id: periodLock.fiscal_period_id,
suggested_action: 'ignore',
},
},
}
}
return {
ok: false,
request_index: index,