feat(mcp): gnubok_reconcile_residual stages residual booking + link on a bank account (#1872)
The residual door existed for the page and the v1 API (#1862) but not for agents: an MCP client that found a 10 kr bank fee between a selection and its verifikat had to hand the last step back to the user. gnubok_reconcile_residual dry-runs lib/reconciliation/residual.ts at stage time (so zero / cap / direction / skattekonto refusals surface immediately), stages a reconciliation_residual operation with the would-book verifikat as the preview, and commitReconciliationResidual links and books on approval. Risk 'medium' (one typed verifikat bounded by RESIDUAL_MAX_AMOUNT, undone by storno + unmatch); scope transactions:write like the v1 route. The op type is added to the pending_operations CHECK (NOT VALID + VALIDATE pair, list verified against the live prod constraint 2026-08-25), and the tool joins the reconcile_month / close_period loadouts and the reconcile-month skill. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
9ebb2e518f
commit
1e6e19afe9
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* commitReconciliationResidual, driven through the public commitPendingOperation
|
||||
* dispatcher. The booking itself lives in lib/reconciliation/residual.ts (unit
|
||||
* tested there); these tests cover the wiring: param validation, the
|
||||
* refusal-to-status mapping, and the committed payload.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import type { PendingOperation } from '@/types'
|
||||
|
||||
const residualMock = vi.fn()
|
||||
vi.mock('@/lib/reconciliation/residual', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
|
||||
return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) }
|
||||
})
|
||||
|
||||
import { ReconciliationResidualError } from '@/lib/reconciliation/residual'
|
||||
import { commitPendingOperation } from '../commit'
|
||||
|
||||
const CASH = '11111111-1111-4111-8111-111111111111'
|
||||
const KEY = `bank:${CASH}`
|
||||
const T1 = '22222222-2222-4222-8222-222222222222'
|
||||
const E1 = '44444444-4444-4444-8444-444444444444'
|
||||
|
||||
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
|
||||
return {
|
||||
id: 'op-1',
|
||||
user_id: 'user-1',
|
||||
company_id: 'company-1',
|
||||
operation_type: 'reconciliation_residual',
|
||||
status: 'pending',
|
||||
title: 'test',
|
||||
params: { account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' },
|
||||
preview_data: {},
|
||||
result_data: null,
|
||||
actor_type: 'user',
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
risk_level: 'medium',
|
||||
created_at: '2026-08-25T00:00:00Z',
|
||||
resolved_at: null,
|
||||
updated_at: '2026-08-25T00:00:00Z',
|
||||
...overrides,
|
||||
} as PendingOperation
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('commitPendingOperation: reconciliation_residual', () => {
|
||||
it('fails 400 when the staged params are incomplete, without booking', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's reject update
|
||||
const op = makePendingOp({ params: { account_key: KEY, external_ids: [], journal_entry_id: E1, kind: 'bank_fee' } })
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.http_status).toBe(400)
|
||||
expect(residualMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books and links through the shared service and returns the residual verifikat', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
|
||||
enqueue({ data: null, error: null }) // dispatcher's committed update
|
||||
residualMock.mockResolvedValue({
|
||||
dry_run: false,
|
||||
residual_journal_entry_id: 'res-1',
|
||||
residual_amount: -10,
|
||||
applied: [{ external_id: T1, journal_entry_id: E1 }],
|
||||
skipped: [],
|
||||
})
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
|
||||
expect(residualMock).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
KEY,
|
||||
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined },
|
||||
{ dryRun: false },
|
||||
)
|
||||
expect(result.status).toBe('committed')
|
||||
expect(result.data).toMatchObject({ account_key: KEY, residual_journal_entry_id: 'res-1', residual_amount: -10 })
|
||||
})
|
||||
|
||||
it('maps a policy refusal to 400 with the residual code, and missing rows to a 404 auto-reject', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('över taket', 'RESIDUAL_TOO_LARGE'))
|
||||
const refused = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
|
||||
expect(refused.status).toBe('failed')
|
||||
expect(refused.http_status).toBe(400)
|
||||
expect(refused.code).toBe('RESIDUAL_TOO_LARGE')
|
||||
|
||||
const second = createQueuedMockSupabase()
|
||||
second.enqueue({ data: { id: 'op-1' }, error: null })
|
||||
second.enqueue({ data: null, error: null })
|
||||
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND'))
|
||||
const missing = await commitPendingOperation(second.supabase as never, 'user-1', 'company-1', makePendingOp({}))
|
||||
expect(missing.status).toBe('rejected')
|
||||
expect(missing.http_status).toBe(404)
|
||||
})
|
||||
|
||||
it('404s an account key the company does not own', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'op-1' }, error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
residualMock.mockResolvedValueOnce(null)
|
||||
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({}))
|
||||
expect(result.status).toBe('rejected')
|
||||
expect(result.http_status).toBe(404)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user