* 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>
157 lines
7.5 KiB
TypeScript
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)
|
|
})
|
|
})
|