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 -1
View File
@@ -46,7 +46,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
560 `route.ts` files under 55 top-level families (2026-08-26). Counts in parentheses; regenerate with `find app/api -name route.ts | awk -F/ '{print $3}' | sort | uniq -c`.
- `/api/v1/*` (110): the public API-key REST surface (`withApiV1`, `lib/api/v1/`). Companies (list + create), customers, invoices, suppliers, supplier-invoices, transactions, journal-entries, fiscal-periods, accounts, articles, documents, dimensions, employees, salary-runs, reports (16, incl. balance-sheet/income-statement PDFs), reconciliation (11, account-keyed), imports, operations, compliance, skatteverket/vat-declarations, settings, inbox-items, voucher-gap-explanations, webhooks, webhook-deliveries, openapi.json, health
- `/api/v1/*` (111): the public API-key REST surface (`withApiV1`, `lib/api/v1/`). Companies (list + create), customers, invoices, suppliers, supplier-invoices, transactions (incl. `{id}/ignore` POST/DELETE: no verifikat, the locked-period escape hatch for non-business rows), journal-entries, fiscal-periods, accounts, articles, documents, dimensions, employees, salary-runs, reports (16, incl. balance-sheet/income-statement PDFs), reconciliation (11, account-keyed), imports, operations, compliance, skatteverket/vat-declarations, settings, inbox-items, voucher-gap-explanations, webhooks, webhook-deliveries, openapi.json, health
- `/api/bookkeeping/*` (64): accounts, account-balances, account-totals, fiscal-periods, journal-entries (CRUD/reverse/correct), journal-entry-lines, mapping-rules, accruals, voucher-gaps, voucher-sequences, no-doc-required, fix-cash-mismatch
- `/api/reports/*` (58): GL, TB, BS/IS (+ balansrapport/resultatrapport), AR/supplier ledger, VAT, periodisk sammanställning, SIE, INK2, NE-bilaga, KPI, audit-trail, behandlingshistorik, bokslutsbilagor, continuity, monthly, dimension-pnl, kassaflödesanalys, statement-reconciliation, full-archive, salary-journal, vacation-liability, avgifter-basis
- `/api/salary/*` (36): employees, payroll-config, tax-tables, KU, runs, vacation-balances, vacation-year-close
+5
View File
@@ -1342,6 +1342,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-28] /migrate SIE guard skips company-info-only runs (all entity flags false) and the wizard derives "SIE already imported" from the preview OR this session's successful /import-sie results: company info writes no accounts, balances or subledger rows, so the BFL rationale does not apply; and the one-shot preview went stale after phase 1 succeeded and phase 2 failed, falsely blocking an entities-only retry (#2000 review).
[2026-08-28] get_vat_ruta_source_lines (the VAT ruta drill-down) now applies the same four exclusions as get_vat_declaration_totals (the filed figure): posted closing entries, source_type 'vat_settlement', the two kontantmetod year-end reversals, and settlement-SHAPED entries (a line on a ruta account plus a line on 2650/1650). It previously filtered on company, status and date only, so expanding a ruta listed verifikat that are not in the number it claims to explain, with no total on the panel to reveal the mismatch. Measured on prod 2026-08-28: 322 posted/reversed entries carrying 26xx lines across 214 companies sit in those excluded classes. A momsdeklaration is räkenskapsinformation (BFL 5 kap.) and this drill-down is what substantiates a filed figure, so the two must agree exactly. The exclusion CTEs are lifted VERBATIM from the figure rather than re-derived: any divergence reintroduces exactly this bug, and an identical copy is easy to diff when the figure changes. Settlement-shape is detected against journal_entry_lines directly instead of through the figure's vat_lines CTE, which is EQUIVALENT not a shortcut (p_ruta_accounts = VAT_ACCOUNTS and p_net_accounts = ['2650','1650'] are both strict subsets of the figure's p_accounts, so restricting to vat_lines first cannot change which entries match); that keeps p_accounts meaning "the accounts of the ruta being expanded" without a fourth account parameter. opening_balance entries are deliberately NOT excluded: the figure exempts them from `shaped`, which keeps their lines IN the totals, so dropping them here would break the equality in the other direction (pinned by its own test). VAT_ACCOUNTS is now exported from lib/reports/vat-declaration.ts so the route detects shape from the same list the figure uses; a second copy is what let the two disagree. DROP + CREATE OR REPLACE, not CREATE OR REPLACE alone: the signature gains p_ruta_accounts/p_net_accounts and adding parameters registers a second overload PostgREST cannot choose between (trap documented in 20260421140000); OR REPLACE on the new arity keeps the file re-runnable. Verified the new pg test actually catches the bug by reinstalling the old body and watching 3 of 4 tests fail with the real misreporting (2611: drill-down 250/240 vs figure 0/200), then restoring.
[2026-08-28] Bankavstamning NULL-link fix scoped to transfer legs with contradicting sign (20260828220000): the naive rule (NULL counts only for the primary account) and the formula-only variant (drop far-leg-settled vouchers from unexplained) were both simulated against prod and rejected; the naive rule worsened 4 of 11 affected cards (worst -37 000 kr false alarm on single-leg vouchers with no user action available), the formula variant blew up healthy cards by up to 474 550 kr. The shipped three-condition rule changes 24 vouchers on 7 cards in 6 companies, all verified per-card.
[2026-08-29] Issue #1661 resolved as option (a): a private marking (is_business=false) STAYS a real booking (eget uttag/insättning on 2013/2018, 2893 for an AB) and therefore stays blocked in a locked or closed period; the actionable path for a row that is no affärshändelse (PSD2 ghost row, duplicate from a reconnect, never-executed transfer) is ignore, which writes no verifikat and is allowed in any period. No ledger or schema semantics changed. A "no-booking private flag" (option b) was rejected: an owner withdrawal IS an affärshändelse under BFL 5 kap 1 § and must be booked, so skipping the verifikat for unbooked private rows would let real withdrawals vanish from a period with nothing in the ledger; the 2026-07-26 lock-guard entry already judged ignore the legal escape hatch and the DB CHECK transactions_is_ignored_no_journal_entry guarantees an ignored row is unbooked. Shipped as: TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (400, details.suggested_action 'ignore') on all four categorize surfaces instead of a bare PERIOD_LOCKED, a v1 ignore verb (POST/DELETE /transactions/{id}/ignore), a staged MCP tool gnubok_ignore_transaction (op type ignore_transaction, risk low), and an "Ignorera i stället" toast action on the Transaktioner page. The regex-matched phrases in period-service.ts lock/close messages are untouched.
[2026-08-29] gnubok_ignore_transaction ships in the DEFAULT MCP catalog (payload ceiling 64.4K to 65K with a ledger entry, measured 64 863), not with catalogVisibility 'search' as the #1661 plan preferred: gnubok_call_tool bridges READ tools only, so a search-only WRITE is uncallable on Claude.ai (the 2026-08-26 connect-tools and 2026-08-27 update_customer entries), and the TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation, three workflow loadouts and the reconcile-month skill instruct agents to CALL it; a search-only tool would reproduce exactly the dead end those entries fixed. Extending gnubok_categorize_transaction with an ignore flag was rejected: it would muddle a booking tool with triage (the error came from that very call), still needs the new op type, and the dashboard and v1 doors have a distinct verb. The read-demotion rule in the bench is skipped for the same reason as the set_run_salary entry: picking the read needs prod usage data.
[2026-08-29] Ignore has one core, lib/transactions/ignore.ts (setTransactionIgnored), called by the dashboard route, the v1 verb and the pending-operation executor; "already booked" is isTransactionBooked() across all three anchors (journal_entry_id, invoice/supplier payment allocations, transaction_voucher_links), replacing the dashboard route's bare journal_entry_id check, which let a bulk-booked or multi-allocated row (journal_entry_id NULL) be ignored while a verifikat still carried it. Restore (ignored=false) skips the booked check on purpose: the DB CHECK already guarantees an ignored row is unbooked, so the lookup would be three wasted queries.
[2026-08-30] The ignore_transaction CHECK migration pair is versioned 20260831070000/070001 (re-versioned from 20260830100000/100001 before merge): above every file on origin/main (20260828220000 at the time) and above the 20260829000100 seed_agent_atom_bodies version found applied on the shared local stack (:54322, another session's work in progress), so it sorts after anything already applied wherever it lands; a version that sorts below an already-applied one is the shape that forced the 20260727140000 to 170000 rename, and the pair was renamed from 20260829120000/120001 at review to sit on the day it ships. The pair was verified on that stack inside a rolled-back transaction only (accepts ignore_transaction, rejects an unknown type with 23514, keeps set_run_salary); the stack itself was left untouched because it is behind main and not this session's to mutate, so the pg-real suite runs in CI.
[2026-08-30] scripts/self-host/{backup,restore}.sh keep ACLs (--no-owner, no --no-privileges) AND restore.sh resets the restoring role's ALTER DEFAULT PRIVILEGES to PostgreSQL's built-in acldefault() before pg_restore, then diffs an ACL manifest (acl-manifest.sql: what anon/authenticated/service_role may do with every public function and relation, ordered under collate "C") that backup.sh took from the source: the migrations carry ~170 REVOKE statements that lock SECURITY DEFINER RPCs (create_company_for_brand_signup, get_dashboard_nav_flags, reserve_voucher_range, ...) away from anon/authenticated, and keeping ACLs alone does not preserve them because pg_dump writes ACLs as a diff against acldefault() (never "REVOKE FROM anon") while a Supabase target's default privileges re-grant anon/authenticated on every restored function and table; reproduced on supabase/postgres 15 (get_dashboard_nav_flags came back anon-executable) and drilled with the real scripts (576 public objects identical after restore, tampered manifest caught). Reset means exactly acldefault, not just "revoke anon/authenticated/service_role": every added grantee including PUBLIC is revoked (IN SCHEMA entries are additive and vanish once empty), and a global entry that took PUBLIC's built-in EXECUTE on functions or USAGE on types away gets it back, so a function the source never touched (NULL ACL) restores PUBLIC-executable; the restoring role's defaults are whatever the stack set, and the dump's DEFAULT ACL section re-creates them once every object exists. A manifest mismatch has no override: it is the one thing a restore drill must never pass silently. Rejected: re-applying GRANT/REVOKE statements harvested from supabase/migrations after pg_restore, because it needs a parser for 700 files, drifts as soon as a function is dropped or renamed, and still would not verify the outcome.
[2026-08-28] Skill, atom and API-doc texts must never claim a capability is absent when it is gated (#546): Peppol send has been live since 2026-08-21 behind a per-company access grant (app/api/invoices/[id]/peppol/send, scripts/peppol/access.ts), yet invoicing-rules, customer-onboarding, the swedish-invoice-compliance atom, docs/PEPPOL_FOUNDATION.md and the v1 :send/:mark-sent descriptions still told agents it did not exist, which sends B2G users to a competitor. Every touched text now says "gated per company, request under Inställningar > Fakturering", keeps the restrictions explicit (aktiebolag senders only, standard invoices only, no MCP or v1 send verb yet) so agents do not over-promise to enskild firma users, and keeps the external-provider + gnubok_mark_invoice_as_sent fallback for companies without access. The skills guard test was inverted from pinning "no Peppol send" to pinning the truthful claim, so a future regression in either direction fails CI.
[2026-08-28] Generated agent_atom_registry seed renamed from the generator's max-plus-one version (20260828220001) to 20260829000100 (#546): the migration rule for this batch is a version newer than every existing file and dated after today's parallel worktrees, which also emit seeds with generator-picked 20260828 names; skills:check compares the content-hash manifest, not the filename, and the pg replay test globs the seed, so the rename is inert for both.
@@ -1373,3 +1377,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-30] Skeptic round on PR #2047: cutoff armed by a completed sie_imports row (API clients can post source_type='import', which would false-mark never-migrated companies) and omforingsverifikation excluded by description prefix instead of voucher_series 'M' (imported vouchers keep the source file's series; 6 prod companies use series M for real vouchers). Residual known gap: skip-window covered only by the omforing stays unmarked; DuplicateBookingDialog is the backstop.
[2026-08-30] Correction to the PR #2047 residual-gap note: the DuplicateBookingDialog backstop only covers a single skipped bank movement within 7 days of the omforing's fiscal-year-end date; mid-year and aggregated-skip variants are unmitigated and accepted on rarity (conjunction of skipped voucher + tail date + synced account + fetched history). Exact closure = persist skipped-voucher max date as coverage_end on sie_imports at import time (skippedDetails already has the dates); filed as follow-up.
[2026-08-30] Receipt-hunt excludes prominent-amounts fallback candidates (amountSource tag on UnderlagCandidate): the nightly hunt scans outflows only and its 0.8 skip-adjudication threshold was calibrated for invoice totals, so a fallback pair (0.85 on date+printed-figure, no merchant) would auto-classify certain on a wrong-by-construction pairing. Fallback docs stay reachable via picker + agent candidates.
[2026-08-31] Re-versioned the ignore_transaction CHECK pair to 20260831070000/070001 and rebuilt its value list from main's 20260830160000: three op-type CHECK pairs (book_skattekonto 130000, delete_draft_invoice 150000, update_salary_run 160000) landed on main after this branch's pair was written, and a wholesale re-created CHECK from a stale list applying last would silently revoke those op types on prod (the standing migration hazard from the #1411 rebuild).
+23
View File
@@ -1803,6 +1803,29 @@ export default function TransactionsPage() {
setProcessingId(null)
return { ok: false, journalEntryId: null }
}
if (result?.error?.code === 'TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED') {
// Issue #1661: a private marking is a real booking (eget uttag /
// insättning), so a locked period refuses it. The row being cleared
// is usually no affärshändelse at all: offer Ignorera (no verifikat,
// allowed in a locked period) on the toast. Interactive escalation,
// so it shows for batch rows too: it is the only way forward.
const lockedTx = transactions.find((t) => t.id === id)
toast({
title: t('private_locked_title'),
description: getErrorMessage(result, { context: 'transaction', statusCode: response.status }),
variant: 'destructive',
action: lockedTx ? (
<ToastAction
altText={t('private_locked_ignore_action')}
onClick={() => void handleIgnoreTransaction(lockedTx)}
>
{t('private_locked_ignore_action')}
</ToastAction>
) : undefined,
})
setProcessingId(null)
return { ok: false, journalEntryId: null }
}
if (!silent) {
toast({
title: 'Kategorisering misslyckades',
@@ -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,
@@ -47,6 +47,7 @@ export const OPERATION_LABEL_KEYS: Record<string, string> = {
link_supplier_invoice_voucher: 'type_link_supplier_invoice_voucher',
link_transaction_journal_entry: 'type_link_transaction_journal_entry',
uncategorize_transaction: 'type_uncategorize_transaction',
ignore_transaction: 'type_ignore_transaction',
retag_line_dimensions: 'type_retag_line_dimensions',
set_voucher_note: 'type_set_voucher_note',
// Bulk booking / allocation
@@ -0,0 +1,188 @@
/**
* Unit tests for gnubok_ignore_transaction (issue #1661): registration,
* scope/risk/catalog wiring, the booked-row refusal through the shared core,
* and staging (dry run + persisted). Executor-side coverage
* (commitIgnoreTransaction) lives in
* lib/pending-operations/__tests__/ignore-transaction-executor.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools, isDefaultCatalogTool } from '../server'
import { RECOMMENDED_WORKFLOW_LOADOUTS } from '../recommended-tools'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
const tool = tools.find((t) => t.name === 'gnubok_ignore_transaction')!
const TX_ID = '00000000-0000-4000-8000-0000000000aa'
const TX_ROW = {
id: TX_ID,
description: 'SWISH DUBBLETT',
merchant_name: null,
amount: -250,
currency: 'SEK',
date: '2025-11-15',
is_ignored: false,
}
const CORE_ROW = { id: TX_ID, journal_entry_id: null, is_ignored: false }
const noopSupabase = { from: vi.fn() } as never
/** Tool fetch + shared-core fetch + the three junction lookups. */
function enqueueUnbooked(
enqueue: (r: { data?: unknown; error?: unknown }) => void,
overrides: { tool?: Record<string, unknown>; core?: Record<string, unknown> } = {},
) {
enqueue({ data: { ...TX_ROW, ...overrides.tool } })
enqueue({ data: { ...CORE_ROW, ...overrides.core } })
enqueue({ data: [] }) // transaction_voucher_links
enqueue({ data: [] }) // invoice_payments
enqueue({ data: [] }) // supplier_invoice_payments
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_ignore_transaction: registration', () => {
it('is registered in the DEFAULT catalog (a search-only write is uncallable on Claude.ai)', () => {
expect(tool).toBeDefined()
expect(isDefaultCatalogTool(tool)).toBe(true)
expect((tool.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
expect((tool.inputSchema as { required?: string[] }).required).toEqual(['transaction_id'])
const out = tool.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(out?.required).toContain('staged')
expect(tool.description).toMatch(/TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED/)
})
it('is mapped to transactions:write and the low risk tier', () => {
expect(TOOL_SCOPE_MAP.gnubok_ignore_transaction).toBe('transactions:write')
expect(OPERATION_RISK_TIERS.ignore_transaction).toBe('low')
})
it('is part of the categorize, close-period and reconcile loadouts', () => {
const byWorkflow = new Map(RECOMMENDED_WORKFLOW_LOADOUTS.map((l) => [l.workflow, l.tools]))
for (const workflow of ['categorize_month', 'close_period', 'reconcile_month']) {
expect(byWorkflow.get(workflow), workflow).toContain('gnubok_ignore_transaction')
}
})
})
describe('gnubok_ignore_transaction: validation gates', () => {
it('rejects a missing transaction_id before any DB call', async () => {
await expect(
tool.execute({ transaction_id: '' }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/transaction_id/)
expect((noopSupabase as { from: ReturnType<typeof vi.fn> }).from).not.toHaveBeenCalled()
})
it('rejects when the transaction does not exist in this company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
tool.execute({ transaction_id: TX_ID }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/not found/i)
})
it('refuses a directly booked row with TX_IGNORE_ALREADY_BOOKED', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: TX_ROW })
enqueue({ data: { ...CORE_ROW, journal_entry_id: 'je-1' } })
await expect(
tool.execute({ transaction_id: TX_ID }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/TX_IGNORE_ALREADY_BOOKED/)
expect(findCalls('pending_operations', 'insert')).toEqual([])
})
it('refuses a bulk-booked row anchored only through transaction_voucher_links', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: TX_ROW })
enqueue({ data: CORE_ROW })
enqueue({ data: [{ transaction_id: TX_ID }] }) // voucher links
enqueue({ data: [] })
enqueue({ data: [] })
await expect(
tool.execute({ transaction_id: TX_ID }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/TX_IGNORE_ALREADY_BOOKED/)
expect(findCalls('pending_operations', 'insert')).toEqual([])
})
})
describe('gnubok_ignore_transaction: staging', () => {
it('dry run previews the flip, marks period lock as not applicable, and writes nothing', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueUnbooked(enqueue)
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
const result = (await tool.execute(
{ transaction_id: TX_ID, dry_run: true },
'company-1', 'user-1', supabase as never, { type: 'user' },
)) as { staged: boolean; dry_run?: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(false)
expect(result.dry_run).toBe(true)
expect(result.risk_level).toBe('low')
expect(result.preview).toMatchObject({
transaction_id: TX_ID,
transaction_description: 'SWISH DUBBLETT',
amount: -250,
currently_ignored: false,
will_be_ignored: true,
already_in_state: false,
writes_verifikat: false,
period_lock_applies: false,
})
expect(findCalls('transactions', 'update')).toEqual([])
expect(findCalls('pending_operations', 'insert')).toEqual([])
})
it('stages an ignore_transaction operation with { transaction_id, ignored: true } params', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueueUnbooked(enqueue)
enqueue({ data: null }) // company_settings
enqueue({ data: null }) // fiscal_periods
enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert
const result = (await tool.execute(
{ transaction_id: TX_ID },
'company-1', 'user-1', supabase as never, { type: 'user' },
)) as { staged: boolean; operation_id?: string; next?: { tool: string } }
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-1')
expect(result.next?.tool).toBe('gnubok_list_uncategorized_transactions')
const inserts = findCalls('pending_operations', 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0][0]).toMatchObject({
operation_type: 'ignore_transaction',
title: 'Ignorera transaktion: SWISH DUBBLETT',
params: { transaction_id: TX_ID, ignored: true },
})
// Staging never flips the flag itself: that is the executor's job.
expect(findCalls('transactions', 'update')).toEqual([])
})
it('restore: true stages ignored: false without the booked check and points back at categorize', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: { ...TX_ROW, is_ignored: true } }) // tool fetch
enqueue({ data: { ...CORE_ROW, is_ignored: true } }) // core fetch (no junction lookups on restore)
enqueue({ data: null }) // company_settings
enqueue({ data: null }) // fiscal_periods
enqueue({ data: { id: 'op-2' }, error: null }) // insert
const result = (await tool.execute(
{ transaction_id: TX_ID, restore: true },
'company-1', 'user-1', supabase as never, { type: 'user' },
)) as { staged: boolean; preview: Record<string, unknown>; next?: { tool: string } }
expect(result.staged).toBe(true)
expect(result.preview).toMatchObject({ currently_ignored: true, will_be_ignored: false })
expect(result.next?.tool).toBe('gnubok_categorize_transaction')
expect(findCalls('transaction_voucher_links', 'select')).toEqual([])
expect(findCalls('pending_operations', 'insert')[0][0]).toMatchObject({
title: 'Återställ ignorerad transaktion: SWISH DUBBLETT',
params: { transaction_id: TX_ID, ignored: false },
})
})
})
@@ -295,12 +295,21 @@ describe('tools/list payload size guard', () => {
// deliberately: picking which read to demote needs prod usage data
// (MCP usage profile), not a guess inside a payroll PR: do that
// demotion as its own change and ratchet this ceiling back down.
// * 64.4K to 65K with gnubok_ignore_transaction in the default catalog
// (issue #1661: private marking in a locked period). Same reason as
// set_run_salary: a search-only WRITE is uncallable on Claude.ai, and
// the TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation, three loadouts
// and the reconcile-month skill instruct agents to CALL it. Measured
// 64 863 on the accounted projection (+548: a 4-property schema plus
// the staging envelope; the description is one sentence per fact).
// Same deliberate skip of the read-demotion rule as the entry above:
// the demotion needs prod usage data, not a guess inside this PR.
// Long-term answer to growth is no longer a ceiling bump. gnubok_call_tool
// makes `catalogVisibility: 'search'` usable for READ tools on hosts that
// can only invoke what tools/list showed them, which is the constraint that
// forced gnubok_reconcile_match back into the default catalog on
// 2026-08-26. Demote a read to search-only before proposing a bump.
expect(approxTokens).toBeLessThan(64_400)
expect(approxTokens).toBeLessThan(65_000)
})
it('keeps the accounted_* namespace as the measured worst case', () => {
@@ -49,6 +49,10 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
// verifikat: links without creating new bookkeeping. Categorizing such a
// transaction would double-book it.
'gnubok_link_transaction_to_journal_entry',
// Rows that are not business events (PSD2 ghost rows, duplicates):
// ignore writes no verifikat and is allowed in a locked period, the
// answer to TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED (issue #1661).
'gnubok_ignore_transaction',
// Tagging: check the registry before writing dimensions bags on
// categorize calls (resolve-don't-select needs real codes/names).
'gnubok_list_dimensions',
@@ -63,6 +67,9 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
tools: [
'gnubok_list_fiscal_periods',
'gnubok_list_uncategorized_transactions',
// Clears non-business rows out of the period without a verifikat; the
// lock guard counts untriaged rows, ignored ones no longer block it.
'gnubok_ignore_transaction',
'gnubok_get_reconciliation_status',
// Account-keyed reconciliation: the rows behind the bridge and the
// staged link (bank accounts and skattekonto alike).
@@ -92,6 +99,8 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
// verifikat that already holds the affärshändelse.
'gnubok_categorize_transaction',
'gnubok_link_transaction_to_journal_entry',
// A row that will never be booked (duplicate, noise line): ignore it.
'gnubok_ignore_transaction',
'gnubok_reconcile_signoff',
'gnubok_approve_pending_operation',
],
+88
View File
@@ -54,6 +54,7 @@ import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { setTransactionIgnored } from '@/lib/transactions/ignore'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getPermittedVatRates, getArticleVatRateAdoptionSet } from '@/lib/invoices/vat-rules'
@@ -15770,6 +15771,93 @@ export const tools: McpTool[] = [
},
},
{
name: 'gnubok_ignore_transaction',
title: 'Ignore Transaction (Ignorera)',
description: 'Stage ignoring an unbooked bank transaction that is no business event (PSD2 ghost row, duplicate). No verifikat is written, so a locked period does not block it: the answer to TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED. restore: true un-ignores; booked rows are refused.',
// Default catalog: gnubok_call_tool only bridges READ tools, so a
// search-only WRITE is uncallable on Claude.ai, and the
// TX_CATEGORIZE_PRIVATE_PERIOD_LOCKED remediation instructs agents to call
// this tool. Budget accounted for in payload-size.bench.test.ts (#1661).
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
transaction_id: { type: 'string', description: 'UUID of the unbooked bank transaction' },
restore: { type: 'boolean', description: 'true = clear the ignore flag instead (default false)' },
dry_run: { type: 'boolean', description: 'Validate and preview without staging.' },
idempotency_key: { type: 'string', description: 'Per-operation UUID for safe retries (24h TTL).' },
},
required: ['transaction_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const transactionId = String(args.transaction_id ?? '').trim()
if (!transactionId) throw new Error('transaction_id is required')
const restore = args.restore === true
const { data: tx, error: txError } = await supabase
.from('transactions')
.select('id, description, merchant_name, amount, currency, date, is_ignored')
.eq('id', transactionId)
.eq('company_id', companyId)
.maybeSingle()
if (txError) throw dbError(txError)
if (!tx) throw new Error('Transaction not found')
// Preflight through the shared core in dry run (issue #1661): the booked
// check covers all three anchors (journal_entry_id, payment allocations,
// voucher links) and `changed` tells the preview whether approval would
// be a no-op. Nothing is written here; the commit path re-runs the core.
const preflight = await setTransactionIgnored(supabase, companyId, transactionId, !restore, {
dryRun: true,
})
if (!preflight.ok) {
const entry = getErrorEntry(preflight.code)
throw new Error(
`Cannot ${restore ? 'restore' : 'ignore'} transaction: ${preflight.code}. ${entry?.message_en ?? ''}`.trim()
)
}
const label = tx.merchant_name || tx.description || transactionId
return stagePendingOperation(supabase, companyId, userId, 'ignore_transaction',
restore ? `Återställ ignorerad transaktion: ${label}` : `Ignorera transaktion: ${label}`,
{ transaction_id: transactionId, ignored: !restore },
{
transaction_id: transactionId,
transaction_description: label,
amount: tx.amount,
currency: tx.currency,
date: tx.date,
currently_ignored: Boolean(tx.is_ignored),
will_be_ignored: !restore,
already_in_state: !preflight.changed,
// No verifikat is written, so period_status is informational only:
// a locked or closed period does not block this operation.
writes_verifikat: false,
period_lock_applies: false,
},
actor,
restore
? {
description: 'After approval the row is back in the "to book" list: book it via gnubok_categorize_transaction.',
tool: 'gnubok_categorize_transaction',
args: { transaction_id: transactionId },
}
: {
description: 'After approval the row leaves the "to book" list. Continue with gnubok_list_uncategorized_transactions.',
tool: 'gnubok_list_uncategorized_transactions',
},
{
dryRun: Boolean(args.dry_run),
idempotencyKey: typeof args.idempotency_key === 'string' ? args.idempotency_key : undefined,
dateForPeriodCheck: tx.date,
}
)
},
},
{
name: 'gnubok_export_sie',
keywords: ['sie', 'sie-fil', 'exportera bokföring'],
@@ -29,7 +29,7 @@ Read \`Accounted://reconciliation/summary\` (optionally \`?date_from&date_to\`).
\`gnubok_list_reconciliation_items({ account_key, bucket })\`:
1. **proposed**: outside rows with a proposed verifikat (exact twin on amount/date). Link them in one staged call: \`gnubok_reconcile_match({ account_key, use_proposals: true, dry_run: true })\`, then without dry_run. The response lists \`applied[]\` and \`skipped[{code}]\`: a skip is information, not an error (ALREADY_LINKED, PAIR_NOT_CLOSED, ENTRY_NOT_FOUND).
2. **unmatched_external**: outside rows with no counterpart. Bank rows: book them (\`gnubok_categorize_transaction\`, or \`gnubok_link_transaction_to_journal_entry\` when the affärshändelse is already on a verifikat). Skattekonto rows: the user books them from /skattekonto or /reconciliation (the rule-based booking lives there); tell the user which rows and amounts. A row that will never be booked (a duplicate, a noise line) is ignored from the page, not by you.
2. **unmatched_external**: outside rows with no counterpart. Bank rows: book them (\`gnubok_categorize_transaction\`, or \`gnubok_link_transaction_to_journal_entry\` when the affärshändelse is already on a verifikat). Skattekonto rows: the user books them from /skattekonto or /reconciliation (the rule-based booking lives there); tell the user which rows and amounts. A row that will never be booked (a duplicate, a PSD2 ghost row, a noise line) is ignored with \`gnubok_ignore_transaction({ transaction_id, dry_run: true })\`, then without dry_run: it writes no verifikat, so a locked period does not block it, and the user approves it like any staged write.
3. **unmatched_ledger**: verifikat lines on the account with nothing outside. Within 5 days of the snapshot they may simply be waiting for the outside side (\`awaiting_external\`). Older ones are usually a wrong account or a missing outside row: show them to the user with voucher numbers; do not reverse anything on your own.
4. **matched** and **ignored** explain the bridge and need no work.
@@ -55,6 +55,7 @@ Per account: outside vs ledger, what was linked, what the user still has to book
- \`gnubok_get_reconciliation_status\`, \`gnubok_list_reconciliation_items\` (read)
- \`gnubok_reconcile_match\`, \`gnubok_reconcile_unmatch\`, \`gnubok_reconcile_residual\`, \`gnubok_reconcile_signoff\` (staged writes)
- \`gnubok_categorize_transaction\`, \`gnubok_link_transaction_to_journal_entry\` (bank-side booking)
- \`gnubok_ignore_transaction\` (bank rows that are not business events; no verifikat)
- \`gnubok_approve_pending_operation\` (when the user approves in chat)
- Resource: \`Accounted://reconciliation/summary\`
`
@@ -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 }
}
+3
View File
@@ -712,6 +712,7 @@
"type_link_supplier_invoice_voucher": "Link supplier invoice",
"type_link_transaction_journal_entry": "Link transaction",
"type_uncategorize_transaction": "Undo categorization",
"type_ignore_transaction": "Ignore transaction",
"type_retag_line_dimensions": "Update dimensions",
"type_set_voucher_note": "Voucher note",
"type_match_batch_allocate": "Allocate payment",
@@ -6088,6 +6089,8 @@
"partially_booked_description": "Transaction updated but the journal entry could not be created",
"booking_failed_title": "Posting failed",
"booking_failed_description": "The transaction could not be posted. Please try again.",
"private_locked_title": "The period is locked",
"private_locked_ignore_action": "Ignore instead",
"customer_invoice_matched_title": "Customer invoice matched",
"customer_invoice_matched_description": "The invoice was marked as paid",
"supplier_invoice_matched_title": "Supplier invoice matched",
+3
View File
@@ -712,6 +712,7 @@
"type_link_supplier_invoice_voucher": "Länka leverantörsfaktura",
"type_link_transaction_journal_entry": "Länka transaktion",
"type_uncategorize_transaction": "Ångra kategorisering",
"type_ignore_transaction": "Ignorera transaktion",
"type_retag_line_dimensions": "Ändra dimensioner",
"type_set_voucher_note": "Anteckning på verifikat",
"type_match_batch_allocate": "Fördela betalning",
@@ -6088,6 +6089,8 @@
"partially_booked_description": "Transaktion uppdaterad men verifikation kunde inte skapas",
"booking_failed_title": "Bokföring misslyckades",
"booking_failed_description": "Transaktionen kunde inte bokföras. Försök igen.",
"private_locked_title": "Perioden är låst",
"private_locked_ignore_action": "Ignorera i stället",
"customer_invoice_matched_title": "Kundfaktura matchad",
"customer_invoice_matched_description": "Fakturan markerades som betald",
"supplier_invoice_matched_title": "Leverantörsfaktura matchad",
+5 -3
View File
@@ -8,7 +8,7 @@ description: >-
transactions and reconciliation, payroll (lön), VAT/moms and financial
reports, SIE import/export, documents, webhooks. Covers auth with
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
pagination, scopes), and all 140 endpoints.
pagination, scopes), and all 142 endpoints.
---
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
@@ -142,7 +142,7 @@ call can undo it, e.g. invoice credit).
## Endpoint index
API version `2026-05-12`, 140 operations. Paths are shown without
API version `2026-05-12`, 142 operations. Paths are shown without
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
### Core (5)
@@ -255,7 +255,7 @@ POST /companies/{companyId}/documents/{id}/link : Link a document to a journal e
POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent]
```
### Banking (22)
### Banking (24)
Full detail: [references/banking.md](references/banking.md)
@@ -277,6 +277,8 @@ GET /companies/{companyId}/reconciliation/bank/status : Bank-reconciliation heal
GET /companies/{companyId}/transactions : List transactions for a company [scope:transactions:read risk:low idempotent]
GET /companies/{companyId}/transactions/{id} : Retrieve a single transaction by id [scope:transactions:read risk:low idempotent]
POST /companies/{companyId}/transactions/{id}/categorize : Categorize a transaction and create the journal entry [scope:transactions:write risk:medium idempotent dry-run reversible]
POST /companies/{companyId}/transactions/{id}/ignore : Ignore a bank transaction (no verifikat, allowed in locked periods) [scope:transactions:write risk:low idempotent dry-run reversible]
DELETE /companies/{companyId}/transactions/{id}/ignore : Restore an ignored bank transaction to the "to book" list [scope:transactions:write risk:low idempotent dry-run reversible]
POST /companies/{companyId}/transactions/{id}/match-invoice : Match a positive bank transaction to a customer invoice [scope:transactions:write risk:high idempotent]
POST /companies/{companyId}/transactions/{id}/match-supplier-invoice : Match a negative bank transaction to a supplier invoice [scope:transactions:write risk:high idempotent]
POST /companies/{companyId}/transactions/{id}/uncategorize : Reverse the categorization of a transaction (storno + reset) [scope:transactions:write risk:medium idempotent dry-run]
+102
View File
@@ -1329,6 +1329,108 @@ Example response `200`:
---
### `POST /api/v1/companies/{companyId}/transactions/{id}/ignore`
**Ignore a bank transaction (no verifikat, allowed in locked periods).**
`scope:transactions:write · risk:low · idempotent · dry-run · reversible`
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.
**Use when:** 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.
**Do not use for:** 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.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: { success: boolean, transaction_id: string, is_ignored: true, already_ignored: boolean },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"success": true,
"transaction_id": "tx_…",
"is_ignored": true,
"already_ignored": false
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `DELETE /api/v1/companies/{companyId}/transactions/{id}/ignore`
**Restore an ignored bank transaction to the "to book" list.**
`scope:transactions:write · risk:low · idempotent · dry-run · reversible`
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.
**Use when:** A row was ignored by mistake and should be booked after all.
**Do not use for:** Undoing a booking: that is a storno via POST /transactions/{id}/uncategorize.
**Pitfalls:**
- Idempotency-Key is mandatory.
| Parameter | In | Type | Required | Notes |
|---|---|---|---|---|
| `companyId` | path | `string` | yes | |
| `id` | path | `string` | yes | |
Response `200`:
```ts
{
data: { success: boolean, transaction_id: string, is_ignored: false, was_ignored: boolean },
meta: {
request_id: string,
api_version: string,
next_cursor?: string,
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
partial_expansions?: string[]
}
}
```
Example response `200`:
```json
{
"data": {
"success": true,
"transaction_id": "tx_…",
"is_ignored": false,
"was_ignored": true
},
"meta": {
"request_id": "req_…",
"api_version": "2026-05-12"
}
}
```
---
### `POST /api/v1/companies/{companyId}/transactions/{id}/match-invoice`
**Match a positive bank transaction to a customer invoice.**
@@ -0,0 +1,107 @@
-- Add 'ignore_transaction' to the pending_operations operation_type CHECK
-- constraint (issue #1661).
--
-- gnubok_ignore_transaction (MCP) stages "ignore this bank transaction" (or
-- restore it with ignored = false). Ignoring flips transactions.is_ignored
-- and 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 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. The user
-- approves it in Granskning and commitIgnoreTransaction in
-- lib/pending-operations/commit.ts applies it through
-- lib/transactions/ignore.ts (the same core as the dashboard and v1 routes),
-- which refuses booked rows through all three anchors. Risk 'low': no
-- ledger impact, reversible with the same op.
--
-- NOTE on the value list: this constraint is re-created wholesale (the
-- established pattern here), so the list below is every value of the
-- constraint as left by 20260830160000 (update_salary_run, which built on 20260830150000's delete_draft_invoice and 20260830130000's book_skattekonto_row / book_skattekonto_rows) PLUS the new value. Dropping any
-- existing value here would silently revoke it.
--
-- NOT VALID + separate VALIDATE migration (paired file, same pattern as
-- 20260828160000 / 20260828160001).
--
-- pg-test: tests/pg/pending-operations-ignore-transaction.pg.test.ts and
-- tests/pg/pending-operations-op-type-audit.pg.test.ts.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'post_kontantmetod_cutoff',
'run_currency_revaluation',
'import_sie',
'explain_voucher_gap',
'uncategorize_transaction',
'approve_supplier_invoice',
'credit_supplier_invoice',
'credit_invoice',
'convert_invoice',
'delete_draft_invoice',
'create_transaction',
'attach_document_to_transaction',
'create_voucher',
'correct_entry',
'reverse_entry',
'create_supplier',
'create_supplier_invoice_from_inbox',
'post_annual_depreciation',
'link_invoice_voucher',
'undo_sie_import',
'match_batch_allocate',
'bulk_book_transactions',
'create_salary_run',
'generate_agi',
'link_transaction_journal_entry',
'link_supplier_invoice_voucher',
'submit_vat_declaration',
'submit_agi',
'create_article',
'update_article',
'bulk_book_inbox_items',
'create_dimension_value',
'retag_line_dimensions',
'link_document_to_voucher',
'update_payslip_line',
'set_run_salary',
'update_salary_run',
'register_absence',
'create_employee',
'update_employee',
'set_employee_opening_balances',
'vacation_year_close',
'create_account',
'update_account',
'set_voucher_note',
'book_salary_run',
'delete_absence',
'update_company_settings',
'update_customer',
'update_invoice',
'create_recurring_schedule',
'update_recurring_schedule',
'log_mileage_trip',
'book_mileage_period',
'link_documents_to_vouchers',
'reconciliation_match',
'reconciliation_unmatch',
'reconciliation_signoff',
'reconciliation_residual',
'book_skattekonto_row',
'book_skattekonto_rows',
'ignore_transaction'
)) NOT VALID;
@@ -0,0 +1,6 @@
-- Validate the operation type CHECK re-added in 20260831070000.
-- This separate transaction avoids a full-table scan while the preceding
-- migration holds its stronger table lock.
ALTER TABLE public.pending_operations
VALIDATE CONSTRAINT pending_operations_operation_type_check;
@@ -0,0 +1,55 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
/**
* The pending_operations_operation_type_check constraint must accept the
* 'ignore_transaction' op type added by migration 20260831070000 (validated
* in 20260831070001, issue #1661). Without the constraint expansion,
* gnubok_ignore_transaction's staging INSERT fails with check_violation on
* every real call while dry_run (which skips the INSERT) previews clean: the
* exact bug class the op-type audit test documents. The second case pins that
* the re-created constraint is still a closed list.
*/
describe('pending_operations operation_type CHECK: ignore_transaction', () => {
let userId: string
let companyId: string
beforeAll(async () => {
const seeded = await seedCompany()
userId = seeded.userId
companyId = seeded.companyId
})
it('accepts ignore_transaction (constraint expanded in 20260831070000)', async () => {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO public.pending_operations (user_id, company_id, operation_type, title)
VALUES ($1, $2, 'ignore_transaction', 'regression: ignorera transaktion')`,
[userId, companyId],
)
await client.query('ROLLBACK')
} finally {
client.release()
}
})
it('still rejects an unknown op type (the list stays closed)', async () => {
const client = await getPool().connect()
try {
await client.query('BEGIN')
await expect(
client.query(
`INSERT INTO public.pending_operations (user_id, company_id, operation_type, title)
VALUES ($1, $2, 'no_such_operation', 'must fail')`,
[userId, companyId],
),
).rejects.toMatchObject({ code: '23514' })
await client.query('ROLLBACK')
} finally {
client.release()
}
})
})
+4
View File
@@ -2540,6 +2540,10 @@ export type PendingOperationType =
// Notes-only annotation on a verifikat: the immutability trigger's carve-out
// (migration 20260608120000) makes this legal even on posted entries.
| 'set_voucher_note'
// Ignore / restore a bank transaction that is not an affärshändelse (PSD2
// ghost row, duplicate, never-executed transfer). Writes no verifikat, so a
// locked or closed period does not block it (issue #1661).
| 'ignore_transaction'
// Bokslut: planenlig avskrivning (one journal entry per asset)
| 'post_annual_depreciation'
// Payroll: salary run creation + AGI declaration