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
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `140`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `142`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -14,6 +14,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
"DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
"DELETE /api/v1/companies/:companyId/suppliers/:id",
"DELETE /api/v1/companies/:companyId/transactions/:id/ignore",
"DELETE /api/v1/companies/:companyId/webhooks/:id",
"GET /api/v1/companies",
"GET /api/v1/companies/:companyId/accounts",
@@ -131,6 +132,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/suppliers",
"POST /api/v1/companies/:companyId/suppliers/bulk-create",
"POST /api/v1/companies/:companyId/transactions/:id/categorize",
"POST /api/v1/companies/:companyId/transactions/:id/ignore",
"POST /api/v1/companies/:companyId/transactions/:id/match-invoice",
"POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice",
"POST /api/v1/companies/:companyId/transactions/:id/uncategorize",
+1
View File
@@ -64,6 +64,7 @@ import '@/app/api/v1/companies/[companyId]/accounts/route'
import '@/app/api/v1/companies/[companyId]/fiscal-periods/route'
import '@/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route'
import '@/app/api/v1/companies/[companyId]/transactions/[id]/uncategorize/route'
import '@/app/api/v1/companies/[companyId]/transactions/[id]/ignore/route'
import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route'
import '@/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route'
import '@/app/api/v1/companies/[companyId]/transactions/ingest/route'
+2 -1
View File
@@ -11,7 +11,7 @@
export const API_KEY_SCOPES = {
'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag' },
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura' },
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, ignorera, kvittomatchning, koppling mot faktura' },
'customers:read': { label: 'Kunder: läs', description: 'Lista kunder' },
'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder' },
'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret' },
@@ -219,6 +219,7 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_list_transactions_without_documents: 'transactions:read',
gnubok_create_transactions: 'transactions:write',
gnubok_categorize_transaction: 'transactions:write',
gnubok_ignore_transaction: 'transactions:write',
gnubok_receipt_matcher: 'transactions:write',
gnubok_get_counterparty_templates: 'transactions:read',
gnubok_suggest_categories: 'transactions:read',
+4
View File
@@ -138,6 +138,10 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/transactions/:id/uncategorize': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/:id/match-invoice': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/:id/match-supplier-invoice': 'transactions:write',
// Ignore / restore: no verifikat, so it is the locked-period escape hatch
// for rows that are not business events (issue #1661).
'POST /api/v1/companies/:companyId/transactions/:id/ignore': 'transactions:write',
'DELETE /api/v1/companies/:companyId/transactions/:id/ignore': 'transactions:write',
// Writes: bulk
'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/batch-categorize': 'transactions:write',
+1
View File
@@ -53,6 +53,7 @@ Backfilled 2026-08-26 from merged PRs. Every item is additive (new endpoints, op
- **Filed VAT declarations** (#1773, 2026-08-21): \`GET /skatteverket/vat-declarations?period_type=&year=&period=\` (scope \`compliance:read\`) reads one period's momsdeklaration as Skatteverket has it on file: \`submitted\` (SKV inlämnat) and \`decided\` (SKV beslutat), each \`null\` when nothing is on file. Live read, requires an active Skatteverket connection on the company.
- **Company settings write** (#1405, 2026-08-04): \`PATCH /companies/{companyId}/settings\` (scope \`companies:write\`, risk medium, dry-run supported) updates bank details (\`bank_name\`, \`clearing_number\`, \`account_number\`, \`bankgiro\`, \`plusgiro\`, \`swish\`, \`iban\`, \`bic\`), \`contact_person\`, \`email\`, \`phone\`, \`website\` and \`invoice_email_texts\`, with the same validation as the MCP tool.
- **Customer \`personal_number\`** (#1724 2026-08-20, #1788 2026-08-21): \`personal_number\` on customer create, bulk-create, detail, PATCH and list. Accepted only for \`customer_type=individual\`, stored encrypted, always returned masked (\`********-1234\`); sending the masked form on PATCH means "leave unchanged", \`null\` clears it. A personnummer-shaped \`org_number\` on a business customer type is refused (\`400 CUSTOMER_ORG_NUMBER_IS_PERSONAL\`); on an individual it is moved into \`personal_number\` and \`org_number\` is cleared, or refused with \`400 CUSTOMER_PERSONAL_NUMBER_CONFLICT\` when it differs from a \`personal_number\` in the same body.
- **Ignore a transaction** (#1661, 2026-08-29): \`POST /transactions/{id}/ignore\` (scope \`transactions:write\`, risk low, idempotent, dry-run supported) marks an unbooked bank transaction as ignored without writing a verifikat, so it is allowed in a locked or closed period: the path for rows that are not business events (PSD2 ghost rows, duplicates, transfers that never executed). \`DELETE\` on the same path restores the row. A booked transaction (directly, via a payment allocation, or via a voucher link) is refused with \`409 TX_IGNORE_ALREADY_BOOKED\`. In the same change, \`/categorize\` and \`/batch-categorize\` answer \`is_business: false\` in a locked period with \`400 TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED\` (\`details.suggested_action: "ignore"\`) instead of a bare \`PERIOD_LOCKED\`: a private marking is a real eget uttag / insättning booking, so the lock still applies to it.
- **Transactions by bank account** (#1809, 2026-08-23): transaction list and detail carry \`cash_account_id\`; \`GET /transactions?cash_account_id=<uuid>\` filters to one bank account.
### Bookkeeping primitives + AP + compliance (Phase 4)
@@ -216,6 +216,7 @@ The ledger is SEK-denominated; foreign amounts are converted to SEK automaticall
- **Settlement account selection matters.** The wrong settlement account silently breaks bank reconciliation later. \`1930\` (företagskonto) is the SEK default; a foreign-currency bank account uses its own asset account (e.g. \`1932\` for USD). \`/imports/bank\` resolves the settlement account automatically — to set it explicitly, ingest via \`POST /transactions/ingest\` with \`settlement_account\`.
- **Cash-method companies and partial payments don't mix.** If \`company_settings.accounting_method = 'cash'\` and you try to match a partial payment, the response is \`VALIDATION_ERROR\` rather than booking accrual entries: cash-method cannot model the per-installment moms event correctly (bokslutsmetoden reports moms at payment, per installment). Either book the partial payment as a separate categorisation or switch to accrual.
- **Batch-categorize is partial-success by default.** If one item hits a locked period, the others still commit. The summary block tells you the totals; check per-item \`ok\` flags.
- **Private is a booking; ignore is not.** \`is_business: false\` books eget uttag / insättning, so in a locked or closed period it returns \`TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED\`. A row that is not a business event at all (a PSD2 ghost row, a duplicate from a reconnect, a transfer that never executed) should be ignored instead: \`POST /transactions/{id}/ignore\` writes no verifikat and is allowed in a locked period; \`DELETE\` on the same path restores the row. Real purchases and owner withdrawals still need the period unlocked.
## Next steps
+33 -1
View File
@@ -327,7 +327,7 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_en: 'Period is locked or closed; entries cannot be added.',
remediation: {
description:
'Either unlock the period via gnubok_unlock_period (if status is "locked", not "closed") or change the entry date to fall inside an open period.',
'Either unlock the period via gnubok_unlock_period (if status is "locked", not "closed") or change the entry date to fall inside an open period. A bank transaction that is not a business event (duplicate, never executed) needs no verifikat: ignore it instead (POST /transactions/{id}/ignore, gnubok_ignore_transaction).',
tool: 'gnubok_unlock_period',
},
},
@@ -497,6 +497,38 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
description: 'Reload and retry categorization. Report the conflict if it persists.',
},
},
// Issue #1661: a private (is_business=false) marking is a real booking
// (eget uttag/insättning on 2013/2018, or 2893 for an AB), so a locked or
// closed period blocks it exactly like any other verifikat. The row the
// caller usually wants to clear (a PSD2 ghost row, a duplicate, a never
// executed transfer) is not an affärshändelse at all: ignoring it writes no
// verifikat and is therefore allowed in a locked period. Returned instead
// of PERIOD_LOCKED so the remediation names that path. The wording must not
// contain "Bokföringen är låst" (inferCode maps that phrase to PERIOD_LOCKED).
TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED: {
httpStatus: 400,
message_sv:
'Perioden är låst. En privat markering bokförs som eget uttag eller insättning i perioden. Är raden ingen affärshändelse (dubblett, aldrig genomförd)? Ignorera den i stället. Annars: lås upp perioden.',
message_en:
'The period is locked. A private marking is booked as an owner withdrawal or deposit inside the period. If the row is not a business event (a duplicate, never executed), ignore it instead; otherwise unlock the period.',
remediation: {
description:
'If the row is not a business event, ignore it: POST /api/v1/companies/{companyId}/transactions/{id}/ignore, the Ignorera action on the Transaktioner page, or gnubok_ignore_transaction. Ignoring writes no verifikat, so it is allowed in a locked or closed period. Otherwise unlock the period via gnubok_unlock_period (status "locked", not "closed") and categorize again.',
tool: 'gnubok_ignore_transaction',
},
},
TX_IGNORE_ALREADY_BOOKED: {
httpStatus: 409,
message_sv:
'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.',
message_en:
'The transaction is already booked (directly, via a payment allocation, or via a voucher link). Unlink it or reverse the verifikat (storno) before ignoring it.',
remediation: {
description:
'A booked bank row cannot be ignored: the booking IS its status. Reverse it with gnubok_uncategorize_transaction (storno) or unlink the payment/voucher first, then ignore.',
tool: 'gnubok_uncategorize_transaction',
},
},
TX_CATEGORIZE_SUGGEST_SI_MATCH: {
httpStatus: 409,
message_sv:
@@ -0,0 +1,146 @@
/**
* Executor tests for the staged ignore_transaction operation (issue #1661):
* commitIgnoreTransaction is private to commit.ts and reached through
* commitPendingOperation (same pattern as account-and-note-executors.test.ts).
* Staging-side coverage lives in
* extensions/general/mcp-server/__tests__/ignore-transaction.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn(),
}))
import { commitPendingOperation } from '../commit'
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'ignore_transaction',
status: 'pending',
title: 'test',
params: { transaction_id: TX_ID, ignored: true },
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'low',
created_at: '2026-08-29T00:00:00Z',
resolved_at: null,
updated_at: '2026-08-29T00:00:00Z',
...overrides,
} as PendingOperation
}
function enqueueNoAnchors(enqueue: (r: { data?: unknown; error?: unknown }) => void) {
enqueue({ data: [] }) // transaction_voucher_links
enqueue({ data: [] }) // invoice_payments
enqueue({ data: [] }) // supplier_invoice_payments
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: ignore_transaction', () => {
it('happy path: flips is_ignored on an unbooked row and returns committed', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: false } }) // core fetch
enqueueNoAnchors(enqueue)
enqueue({ data: null }) // update
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ transaction_id: TX_ID, is_ignored: true, changed: true })
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: true }]])
})
it('is idempotent: an already-ignored row commits with changed=false and no write', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: true } }) // core fetch
enqueueNoAnchors(enqueue)
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ is_ignored: true, changed: false })
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a row that got booked between staging and approval (TX_IGNORE_ALREADY_BOOKED)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: 'je-1', is_ignored: false } }) // core fetch: booked now
enqueue({ data: null }) // dispatcher reject/finalize update
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).not.toBe('committed')
expect(result.code).toBe('TX_IGNORE_ALREADY_BOOKED')
expect(result.error).toContain('redan bokförd')
expect(findCalls('transactions', 'update')).toEqual([])
})
it('refuses a junction-anchored row (journal_entry_id NULL, voucher link present)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: false } })
enqueue({ data: [{ transaction_id: TX_ID }] }) // transaction_voucher_links
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
expect(result.status).not.toBe('committed')
expect(result.code).toBe('TX_IGNORE_ALREADY_BOOKED')
expect(findCalls('transactions', 'update')).toEqual([])
})
it('restore (ignored: false) clears the flag', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: { id: TX_ID, journal_entry_id: null, is_ignored: true } }) // core fetch
enqueue({ data: null }) // update
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ params: { transaction_id: TX_ID, ignored: false } }),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ is_ignored: false, changed: true })
expect(findCalls('transactions', 'update')).toEqual([[{ is_ignored: false }]])
})
it('rejects tampered params (non-UUID transaction_id) at the commit boundary', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' } }) // CAS claim
enqueue({ data: null }) // finalize
const result = await commitPendingOperation(
supabase as never, 'user-1', 'company-1',
makePendingOp({ params: { transaction_id: 'not-a-uuid', ignored: true } }),
)
expect(result.status).not.toBe('committed')
expect(result.error).toMatch(/transaction_id/)
expect(findCalls('transactions', 'select')).toEqual([])
})
})
+50
View File
@@ -148,6 +148,8 @@ import { RetagLineDimensionsParamsSchema } from '@/lib/pending-operations/schema
import { CreateAccountParamsSchema, UpdateAccountParamsSchema } from '@/lib/pending-operations/schemas/account'
import { defaultRateForVatTreatment } from '@/lib/vat/account-vat-treatment'
import { SetVoucherNoteParamsSchema } from '@/lib/pending-operations/schemas/voucher-note'
import { IgnoreTransactionParamsSchema } from '@/lib/pending-operations/schemas/ignore-transaction'
import { setTransactionIgnored } from '@/lib/transactions/ignore'
import { UpdateCompanySettingsParamsSchema } from '@/lib/pending-operations/schemas/company-settings'
import { UpdateCustomerParamsSchema } from '@/lib/pending-operations/schemas/customer'
import {
@@ -1194,6 +1196,51 @@ async function commitSetVoucherNote(
}
}
async function commitIgnoreTransaction(
supabase: SupabaseClient,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
let validated
try {
validated = IgnoreTransactionParamsSchema.parse(params)
} catch (err) {
if (err instanceof z.ZodError) {
const issue = err.issues[0]
return { error: `Invalid ${issue?.path?.join('.') ?? 'params'}: ${issue?.message ?? 'validation failed'}`, status: 400 }
}
throw err
}
// Issue #1661: the same core the dashboard and v1 routes use, so the three
// doors cannot drift. It refuses a booked row through all three anchors
// (journal_entry_id, payment allocations, voucher links), is idempotent,
// and writes no verifikat: a locked or closed period does not block it.
const outcome = await setTransactionIgnored(
supabase,
companyId,
validated.transaction_id,
validated.ignored,
)
if (!outcome.ok) {
const entry = getErrorEntry(outcome.code)
return {
error: entry?.message_sv ?? 'Transaktionen kunde inte ignoreras.',
errorCode: outcome.code,
status: outcome.status,
}
}
return {
data: {
transaction_id: outcome.transaction_id,
is_ignored: outcome.is_ignored,
// false when the row was already in the requested state.
changed: outcome.changed,
},
}
}
async function commitCreateSupplier(
supabase: SupabaseClient,
userId: string,
@@ -6647,6 +6694,9 @@ async function commitPendingOperationInner(
case 'set_voucher_note':
result = await commitSetVoucherNote(supabase, companyId, pendingOp.params)
break
case 'ignore_transaction':
result = await commitIgnoreTransaction(supabase, companyId, pendingOp.params)
break
case 'create_dimension_value':
result = await commitCreateDimensionValue(supabase, userId, companyId, pendingOp.params)
break
+6
View File
@@ -45,6 +45,12 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
// notes-only diff on committed entries and rejects anything more, so the
// op cannot touch booking data even if tampered with.
set_voucher_note: 'low',
// Ignoring a bank transaction flips transactions.is_ignored and nothing
// else: no verifikat, no ledger impact, reversible with the same op
// (ignored: false). The DB CHECK transactions_is_ignored_no_journal_entry
// and the executor's isTransactionBooked() refusal keep it off booked rows,
// so the op cannot hide a booking even if tampered with (issue #1661).
ignore_transaction: 'low',
// ── Medium: reversible booking ─────────────────────────────────────
categorize_transaction: 'medium',
@@ -0,0 +1,19 @@
import { z } from 'zod'
// Commit-boundary re-validation for the staged ignore_transaction operation
// (gnubok_ignore_transaction, issue #1661). Ignoring writes no verifikat: it
// flips transactions.is_ignored so a row that is not an affärshändelse (a
// PSD2 ghost row, a duplicate from a reconnect, a transfer that never
// executed) leaves the "to book" funnels. The DB CHECK
// transactions_is_ignored_no_journal_entry guarantees an ignored row is
// unbooked, and lib/transactions/ignore.ts refuses a booked row through the
// three anchors (journal_entry_id, payment allocations, voucher links), so
// this schema only has to bound the shape.
export const IgnoreTransactionParamsSchema = z.object({
transaction_id: z.string().uuid(),
// true = ignore (default), false = restore a previously ignored row.
ignored: z.boolean().default(true),
})
export type IgnoreTransactionParams = z.infer<typeof IgnoreTransactionParamsSchema>
@@ -146,3 +146,54 @@ describe('categorizeMatchedTransaction: null engine return fails closed (issue #
expect(mockUpsertTemplate).not.toHaveBeenCalled()
})
})
describe('categorizeMatchedTransaction: private marking in a locked period (issue #1661)', () => {
it('refuses with TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED before the engine runs and writes nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueUpToEngine(enqueue)
mockCheckPeriodLock.mockResolvedValue({
locked: true,
reason: 'period_locked_at_set',
fiscal_period_id: 'fp-2024',
})
const result = await categorizeMatchedTransaction(
supabase as never,
'user-1',
'company-1',
TX_ID,
{ category: 'private' },
)
expect(result.status).toBe(400)
expect(result.errorCode).toBe('TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED')
expect(result.error).toContain('Ignorera')
expect(result.data).toBeUndefined()
// A private marking is a real booking, so the lock applies; the pre-check
// answers with the ignore-steering code instead of letting the engine run.
expect(mockCreateJE).not.toHaveBeenCalled()
expect(findCalls('transactions', 'update')).toEqual([])
expect(mockUpsertTemplate).not.toHaveBeenCalled()
})
it('keeps PERIOD_LOCKED for a business categorization in the same period', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueUpToEngine(enqueue)
mockCheckPeriodLock.mockResolvedValue({
locked: true,
reason: 'period_locked_at_set',
fiscal_period_id: 'fp-2024',
})
const result = await categorizeMatchedTransaction(
supabase as never,
'user-1',
'company-1',
TX_ID,
{ category: 'expense_software' },
)
expect(result.status).toBe(400)
expect(result.errorCode).toBe('PERIOD_LOCKED')
})
})
@@ -73,6 +73,10 @@ describe('categorizeMatchedTransaction: accountOverride', () => {
enqueue({ data: settingsRow })
enqueue({ data: [] }) // resolveSettlementAccount: no enabled cash accounts -> 1930
enqueue({ data: [{ id: 'fp-1' }] })
// Issue #1661: a private marking runs checkPeriodLock before the engine
// (company lock date, then the covering fiscal period). Open here.
enqueue({ data: { bookkeeping_locked_through: null } })
enqueue({ data: { id: 'fp-1', is_closed: false, locked_at: null } })
enqueue({
data: [txRow({
is_business: false,
@@ -137,6 +137,10 @@ describe('categorizeMatchedTransaction: pre-FY transaction gets a journal entry'
enqueue({ data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }) // settings
enqueue({ data: [] }) // ensureFiscalPeriod: no covering open period
enqueue({ data: [{ period_start: '2026-05-12' }] }) // ensureFiscalPeriod: earliest
// Issue #1661: a private marking runs checkPeriodLock before the engine.
// A pre-FY date has no covering period, which is not a lock.
enqueue({ data: { bookkeeping_locked_through: null } })
enqueue({ data: null })
// createTransactionJournalEntry clamp: earliest full row (open, unlocked)
enqueue({
data: [{ id: 'period-first', period_start: '2026-05-12', is_closed: false, locked_at: null }],
+178
View File
@@ -0,0 +1,178 @@
/**
* 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' })
})
})
+33 -2
View File
@@ -444,6 +444,31 @@ export async function categorizeMatchedTransaction(
await ensureFiscalPeriod(supabase, userId, companyId, transaction.date, fiscalYearStartMonth)
// Issue #1661: a private marking books eget uttag/insättning, so a locked
// period refuses it like any verifikat, but the trigger's message would
// only say "locked" and the MCP/bulk callers would steer to unlock. The row
// they want to clear is usually no affärshändelse at all: pre-check private
// rows so the refusal names the ignore path (staged gnubok_ignore_transaction
// or the page). Business rows keep the trigger/null handling below.
if (!isBusiness) {
const privateLock = await checkPeriodLock(supabase, companyId, transaction.date)
if (privateLock.locked) {
log.warn('private marking refused: period is locked', {
txId,
companyId,
date: transaction.date,
reason: privateLock.reason ?? null,
})
return {
error:
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.',
errorCode: 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED',
status: 400,
}
}
}
let journalEntryId: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
@@ -468,7 +493,11 @@ export async function categorizeMatchedTransaction(
// checkPeriodLock tells the two null causes apart for an honest message.
if (!journalEntryId) {
const verdict = await checkPeriodLock(supabase, companyId, transaction.date)
const code = verdict.locked ? 'PERIOD_LOCKED' : 'NO_OPEN_PERIOD_FOR_DATE'
const code = verdict.locked
? isBusiness
? 'PERIOD_LOCKED'
: 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED'
: 'NO_OPEN_PERIOD_FOR_DATE'
log.warn('journal entry refused: no open fiscal period for date', {
txId,
companyId,
@@ -688,7 +717,9 @@ export async function bulkBookMatchedInboxItems(
if (result.error) {
const reason =
result.errorCode === 'PERIOD_LOCKED' || result.errorCode === 'NO_OPEN_PERIOD_FOR_DATE'
result.errorCode === 'PERIOD_LOCKED' ||
result.errorCode === 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED' ||
result.errorCode === 'NO_OPEN_PERIOD_FOR_DATE'
? 'no_open_period'
: result.status === 404 ? 'transaction_not_found'
: result.status === 409 ? 'already_booked_or_duplicate'
+127
View File
@@ -0,0 +1,127 @@
/**
* Shared core for "ignore a bank transaction" (issue #1661).
*
* Ignoring is the legal escape hatch for a bank row that is not an
* affärshändelse (a PSD2 ghost row, a duplicate from a reconnect, a transfer
* that never executed): it writes no verifikat, so BFL 5 kap. does not apply
* and a locked or closed period does not block it. A private marking, by
* contrast, IS a booking (eget uttag/insättning, or 2893 for an AB) and stays
* subject to the period lock.
*
* Three doors call this one function so they cannot drift:
* - the dashboard route (app/api/transactions/[id]/ignore)
* - the v1 REST verb (app/api/v1/.../transactions/[id]/ignore)
* - the MCP executor for the staged ignore_transaction operation
* (lib/pending-operations/commit.ts)
*
* "Already booked" is decided by isTransactionBooked(): a bulk-booked or
* multi-allocated row keeps transactions.journal_entry_id NULL and is anchored
* through transaction_voucher_links / invoice_payments /
* supplier_invoice_payments instead. A bare journal_entry_id check would let
* those rows be ignored while a verifikat still carries them.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { dbError } from '@/lib/errors/db-error'
import { isTransactionBooked } from '@/lib/transactions/is-booked'
export type TransactionIgnoreRefusalCode = 'TX_CATEGORIZE_TX_NOT_FOUND' | 'TX_IGNORE_ALREADY_BOOKED'
export type SetTransactionIgnoredOutcome =
| {
ok: true
transaction_id: string
is_ignored: boolean
/** false when the row was already in the requested state (idempotent no-op). */
changed: boolean
/** true when the caller asked for a dry run: nothing was written. */
dry_run: boolean
}
| {
ok: false
code: TransactionIgnoreRefusalCode
status: 404 | 409
}
interface TransactionIgnoreRow {
id: string
journal_entry_id: string | null
is_ignored: boolean | null
}
/**
* Set or clear the ignore flag on one bank transaction.
*
* - Unknown row in this company: `{ ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND' }`.
* - `ignored = true` on a booked row (any of the three anchors):
* `{ ok: false, code: 'TX_IGNORE_ALREADY_BOOKED' }`. Restoring
* (`ignored = false`) needs no such check: the DB CHECK
* `transactions_is_ignored_no_journal_entry` already guarantees an ignored
* row is unbooked.
* - Same state as requested: `{ ok: true, changed: false }` (idempotent).
* - Database failures throw; callers map them through lib/errors.
*/
export async function setTransactionIgnored(
supabase: SupabaseClient,
companyId: string,
transactionId: string,
ignored: boolean,
options: { dryRun?: boolean } = {},
): Promise<SetTransactionIgnoredOutcome> {
const { data: tx, error: fetchError } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle<TransactionIgnoreRow>()
if (fetchError) throw dbError(fetchError, null)
if (!tx) return { ok: false, code: 'TX_CATEGORIZE_TX_NOT_FOUND', status: 404 }
if (ignored) {
let booked = tx.journal_entry_id != null
if (!booked) {
// The two junction shapes that keep journal_entry_id NULL on the row.
const [voucherLinks, invoicePayments, supplierPayments] = await Promise.all([
supabase
.from('transaction_voucher_links')
.select('transaction_id')
.eq('transaction_id', tx.id)
.limit(1),
supabase.from('invoice_payments').select('transaction_id').eq('transaction_id', tx.id).limit(1),
supabase
.from('supplier_invoice_payments')
.select('transaction_id')
.eq('transaction_id', tx.id)
.limit(1),
])
if (voucherLinks.error) throw dbError(voucherLinks.error, null)
if (invoicePayments.error) throw dbError(invoicePayments.error, null)
if (supplierPayments.error) throw dbError(supplierPayments.error, null)
booked = isTransactionBooked(
tx,
[...(invoicePayments.data ?? []), ...(supplierPayments.data ?? [])],
voucherLinks.data ?? [],
)
}
if (booked) return { ok: false, code: 'TX_IGNORE_ALREADY_BOOKED', status: 409 }
}
const alreadyInState = Boolean(tx.is_ignored) === ignored
if (alreadyInState || options.dryRun) {
return {
ok: true,
transaction_id: tx.id,
is_ignored: ignored,
changed: !alreadyInState,
dry_run: Boolean(options.dryRun),
}
}
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: ignored })
.eq('id', transactionId)
.eq('company_id', companyId)
if (updateError) throw dbError(updateError, null)
return { ok: true, transaction_id: tx.id, is_ignored: ignored, changed: true, dry_run: false }
}