One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
186 lines
8.8 KiB
TypeScript
186 lines
8.8 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'
|
|
const ENTRY_2 = '44444444-4444-4444-8444-444444444444'
|
|
const CASH = '55555555-5555-4555-8555-555555555555'
|
|
// 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('links: forwards a bank 1:N pair with allocations, and rejects a one-element allocations array (#1553)', async () => {
|
|
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [] })
|
|
const pair = {
|
|
external_ids: [ROW],
|
|
journal_entry_ids: [ENTRY, ENTRY_2],
|
|
allocations: [
|
|
{ journal_entry_id: ENTRY, amount: -500 },
|
|
{ journal_entry_id: ENTRY_2, amount: -300 },
|
|
],
|
|
}
|
|
const res = await linksPOST(
|
|
createMockRequest(`/api/reconciliation/accounts/bank:${CASH}/links`, { method: 'POST', body: { pairs: [pair] } }),
|
|
p({ accountKey: `bank:${CASH}` }),
|
|
)
|
|
expect(res.status).toBe(200)
|
|
expect(matchMock).toHaveBeenCalledWith(supabase, 'company-1', 'user-1', `bank:${CASH}`, { pairs: [pair] }, { dryRun: false })
|
|
|
|
const invalid = await linksPOST(
|
|
createMockRequest(`/api/reconciliation/accounts/bank:${CASH}/links`, {
|
|
method: 'POST',
|
|
body: { pairs: [{ ...pair, allocations: [{ journal_entry_id: ENTRY, amount: -800 }] }] },
|
|
}),
|
|
p({ accountKey: `bank:${CASH}` }),
|
|
)
|
|
expect(invalid.status).toBe(400)
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|