Files
accounted/app/api/reconciliation/accounts/__tests__/route.test.ts
T
3a62c5419e feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools (#1833)
* feat(reconciliation): skattekonto bridge engine, sync-time twin proposals, account-keyed facade

The engine half of the reconciliation page (design: Avstämningsmotorn).

- lib/reconciliation/skattekonto-reconciliation.ts: getSkattekontoReconciliationStatus
  anchors at the saldo snapshot and returns the bridge (saldo hos Skatteverket,
  händelser som saknas, 1630-rader utan händelse, ignorerade, ingående skillnad,
  bokfört), the item buckets the page shows (proposed, unmatched external,
  unmatched ledger, matched, ignored, upcoming), opening_difference,
  unexplained_difference (0,00 by construction when data is consistent),
  dead-link handling (a link to a reversed/draft entry counts as unlinked and is
  flagged), awaiting_external for ledger lines within 5 days of the snapshot,
  staleness, and a window that scopes item lists without hiding older rows.
  Core reads skattekonto_transactions and the extension's snapshot row directly;
  no @/extensions import.
- lib/reconciliation/gl-balance.ts: one ledger-balance helper with the
  trial-balance predicate status IN (posted, reversed). The drift check summed
  posted only, which misstated 1630 for any company with a storno on the account;
  skattekonto-drift.ts now delegates to the helper.
- Proposals at sync: migration 20260823120000 adds suggested_journal_entry_id /
  suggested_at (ON DELETE SET NULL, partial index on open rows); the sync calls
  refreshSkattekontoProposals after the upsert. findMatchSuggestionsBulk now
  assigns one-to-one across rows (AGI period first, then nearest date) and falls
  back to an entry whose 1630 lines net to the amount (split lines); a proposal
  is never a link.
- lib/reconciliation/service.ts + schemas.ts: the account-keyed facade
  (bank:<cash_account_id> | skattekonto | manual:NNNN) with listReconciliationAccounts
  (enabled cash accounts folded per IBAN, skattekonto when configured) and
  getAccountStatus dispatching to the bank engine or the new one; shared Zod
  shapes for the v1 registry, MCP schemas and the UI (PR 2).

Tests: identity on a mixed fixture, storno pair, stale snapshot, awaiting window,
window scoping, failed ledger read, live-linked entries never proposed; matcher
one-to-one and split-line cases; proposal refresh writes/clears; service
dedupe and dispatch. No UI in this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): roundOre instead of inline öre rounding (guard ratchet)

The antipattern ratchet counts Math.round(x*100)/100; the new engine used it in
five places. Switch to roundOre from @/lib/money and ratchet the baseline down
by the three occurrences this removes net of the matcher rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(reconciliation): three doors over one engine: dashboard routes, v1 API and MCP tools for account-keyed reconciliation

PR 2 of the Avstämning build (design: Avstämning via API och MCP). Every door
calls lib/reconciliation/{service,items,actions}.ts; none re-implements a link.

- lib/reconciliation/items.ts: listAccountItems per account_key, the page's
  buckets (proposed, unmatched_external, unmatched_ledger, matched, ignored,
  upcoming), limit/offset; skattekonto from the engine, bank from the scoped
  transactions + unlinked GL lines (netted per entry).
- lib/reconciliation/actions.ts: matchPairs (pairs or use_proposals, dry run,
  partial success with codes), unmatchLink, setItemIgnored; emits
  reconciliation.matched / reconciliation.unmatched.
- lib/skatteverket/skattekonto-link.ts: canonical core link semantics for a
  skattekonto row (single line or entry net on 1630, live-link guard, race-safe
  update, unlink, ignore); the extension keeps its own matchSkattekontoToEntry
  until its tests are ported.
- Dashboard routes /api/reconciliation/accounts[...]: list, status, items,
  links (POST), links/{linkId} (DELETE), items/{itemId}/ignore (POST); apply
  directly (a human clicked).
- v1 routes /api/v1/companies/{id}/reconciliation/accounts[...]: same six,
  withApiV1, new scopes reconciliation:read / reconciliation:write (write is a
  staging scope for SoD), Idempotency-Key + dry_run on writes, registered for
  OpenAPI, load-routes, skills/accounted-api regenerated. Legacy bank routes
  and their transactions:* scopes unchanged.
- MCP: gnubok_get_reconciliation_status takes account_key (legacy bank path
  untouched), new gnubok_list_reconciliation_items (default catalog),
  gnubok_reconcile_match (stages reconciliation_match, preflight = status) and
  gnubok_reconcile_unmatch (stages reconciliation_unmatch), both search-only to
  stay under the tools/list payload ceiling; gnubok_link_transaction_to_journal_entry
  moved to search. Executors in commit.ts; risk tiers medium/low; migration pair
  20260823130000/130001 adds the two op types to the CHECK constraint (value
  list = live prod as of 2026-08-23 + the two); close_period loadout updated.

Tests: service/actions/items/link unit tests, v1 route tests (401/403/400/404/
happy, idempotency, dry run), dashboard route tests, MCP tool tests + the guard
suite (payload ceiling, descriptions, staging meta, qualified ids). Guards and
apiskill:check green; no type errors in changed files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): refresh the v1 spec snapshot and keep the ignore update readable by the phantom-column guard

The six new v1 reconciliation endpoints and the two new scopes were not
recorded in the spec snapshot, and setSkattekontoRowIgnored updated
through one conditional payload, which the phantom-column scanner cannot
read (ceiling 380 -> 381). Two literal payloads instead; snapshot updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:03:20 +02:00

157 lines
7.5 KiB
TypeScript

/**
* Tests for the dashboard reconciliation routes (cookie session, withRouteContext):
* GET /api/reconciliation/accounts, GET .../accounts/{accountKey},
* GET .../accounts/{accountKey}/items, POST .../links, DELETE .../links/{linkId},
* POST .../items/{itemId}/ignore. The service layer is mocked; the wrapper is real.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
const { supabase, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
const requireWriteMock = vi.fn()
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
}))
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const listAccountsMock = vi.fn()
const statusMock = vi.fn()
const itemsMock = vi.fn()
const matchMock = vi.fn()
const unmatchMock = vi.fn()
const ignoreMock = vi.fn()
vi.mock('@/lib/reconciliation/service', () => ({
listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
getAccountStatus: (...args: unknown[]) => statusMock(...args),
}))
vi.mock('@/lib/reconciliation/items', async () => {
const actual = await vi.importActual<typeof import('@/lib/reconciliation/items')>('@/lib/reconciliation/items')
return { ...actual, listAccountItems: (...args: unknown[]) => itemsMock(...args) }
})
vi.mock('@/lib/reconciliation/actions', () => ({
matchPairs: (...args: unknown[]) => matchMock(...args),
unmatchLink: (...args: unknown[]) => unmatchMock(...args),
setItemIgnored: (...args: unknown[]) => ignoreMock(...args),
}))
import { GET as listGET } from '../route'
import { GET as statusGET } from '../[accountKey]/route'
import { GET as itemsGET } from '../[accountKey]/items/route'
import { POST as linksPOST } from '../[accountKey]/links/route'
import { DELETE as linkDELETE } from '../[accountKey]/links/[linkId]/route'
import { POST as ignorePOST } from '../[accountKey]/items/[itemId]/ignore/route'
const ROW = '22222222-2222-4222-8222-222222222222'
const ENTRY = '33333333-3333-4333-8333-333333333333'
// Dynamic-route params for the handlers under test; `never` keeps each
// handler's own params type while letting one helper serve all of them.
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
describe('dashboard reconciliation routes', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
listAccountsMock.mockResolvedValue([{ account_key: 'skattekonto' }])
statusMock.mockResolvedValue({ account_key: 'skattekonto', bridge: [] })
itemsMock.mockResolvedValue({ items: [], count: 0, total_count: 0, has_more: false, older_unmatched_count: 0 })
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [] })
unmatchMock.mockResolvedValue({ external_id: ROW, previous_journal_entry_id: ENTRY })
ignoreMock.mockResolvedValue({ external_id: ROW, is_ignored: true })
})
it('401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const res = await listGET(createMockRequest('/api/reconciliation/accounts'), { params: Promise.resolve({}) })
expect(res.status).toBe(401)
})
it('lists accounts, forwarding the window and with_status', async () => {
const res = await listGET(
createMockRequest('/api/reconciliation/accounts?date_from=2026-01-01&date_to=2026-08-20&with_status=false'),
{ params: Promise.resolve({}) },
)
expect(res.status).toBe(200)
const { body } = await parseJsonResponse<{ data: { accounts: unknown[] } }>(res)
expect(body.data.accounts).toHaveLength(1)
expect(listAccountsMock).toHaveBeenCalledWith(supabase, 'company-1', {
windowFrom: '2026-01-01',
windowTo: '2026-08-20',
withStatus: false,
})
})
it('status: 404 on an invalid key, 404 when the service finds nothing, 200 otherwise', async () => {
expect((await statusGET(createMockRequest('/api/reconciliation/accounts/1930'), p({ accountKey: '1930' }))).status).toBe(404)
statusMock.mockResolvedValueOnce(null)
expect((await statusGET(createMockRequest('/api/reconciliation/accounts/skattekonto'), p({ accountKey: 'skattekonto' }))).status).toBe(404)
const res = await statusGET(createMockRequest('/api/reconciliation/accounts/skattekonto?date_from=2026-07-01'), p({ accountKey: 'skattekonto' }))
expect(res.status).toBe(200)
expect(statusMock).toHaveBeenLastCalledWith(supabase, 'company-1', 'skattekonto', { windowFrom: '2026-07-01', windowTo: null })
})
it('items: validates the bucket and forwards paging', async () => {
expect((await itemsGET(createMockRequest('/api/reconciliation/accounts/skattekonto/items?bucket=x'), p({ accountKey: 'skattekonto' }))).status).toBe(400)
const res = await itemsGET(createMockRequest('/api/reconciliation/accounts/skattekonto/items?bucket=proposed&limit=10&offset=20'), p({ accountKey: 'skattekonto' }))
expect(res.status).toBe(200)
expect(itemsMock).toHaveBeenCalledWith(supabase, 'company-1', 'skattekonto', expect.objectContaining({ bucket: 'proposed', limit: 10, offset: 20 }))
})
it('links: requires write, validates the body, forwards dry_run', async () => {
requireWriteMock.mockResolvedValueOnce({ ok: false, response: NextResponse.json({ error: 'Read only' }, { status: 403 }) })
const forbidden = await linksPOST(
createMockRequest('/api/reconciliation/accounts/skattekonto/links', { method: 'POST', body: { use_proposals: true } }),
p({ accountKey: 'skattekonto' }),
)
expect(forbidden.status).toBe(403)
const invalid = await linksPOST(
createMockRequest('/api/reconciliation/accounts/skattekonto/links', { method: 'POST', body: { pairs: [] } }),
p({ accountKey: 'skattekonto' }),
)
expect(invalid.status).toBe(400)
const res = await linksPOST(
createMockRequest('/api/reconciliation/accounts/skattekonto/links', {
method: 'POST',
body: { pairs: [{ external_ids: [ROW], journal_entry_ids: [ENTRY] }], dry_run: true },
}),
p({ accountKey: 'skattekonto' }),
)
expect(res.status).toBe(200)
expect(matchMock).toHaveBeenCalledWith(
supabase,
'company-1',
'user-1',
'skattekonto',
{ pairs: [{ external_ids: [ROW], journal_entry_ids: [ENTRY] }] },
{ dryRun: true },
)
})
it('unlink and ignore call the service with the ids', async () => {
const del = await linkDELETE(createMockRequest(`/api/reconciliation/accounts/skattekonto/links/${ROW}`, { method: 'DELETE' }), p({ accountKey: 'skattekonto', linkId: ROW }))
expect(del.status).toBe(200)
expect(unmatchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', 'skattekonto', ROW)
const ign = await ignorePOST(createMockRequest(`/api/reconciliation/accounts/skattekonto/items/${ROW}/ignore`, { method: 'POST', body: { ignored: false } }), p({ accountKey: 'skattekonto', itemId: ROW }))
expect(ign.status).toBe(200)
expect(ignoreMock).toHaveBeenCalledWith(supabase, 'company-1', 'skattekonto', ROW, false)
})
})