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