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