feat(reconciliation): residual booking + junction-aware bridge (#1862)

When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

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:
Jakob Wennberg
2026-08-25 08:30:54 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 0a3cb1a31a
commit d88df74b85
21 changed files with 1496 additions and 15 deletions
@@ -0,0 +1,76 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
import { ensureInitialized } from '@/lib/init'
// Booking a residual commits a verifikat; the engine emits journal_entry
// events that extension handlers subscribe to.
ensureInitialized()
const ResidualBodySchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_id: z.string().uuid(),
kind: z.enum(['bank_fee', 'rounding', 'interest_income', 'interest_expense']),
entry_date: z.string().regex(ISO_DATE_RE).optional(),
description: z.string().max(200).optional(),
dry_run: z.boolean().optional(),
})
/**
* POST /api/reconciliation/accounts/{accountKey}/residual
*
* The worksheet's "bokför mellanskillnaden och koppla": books the remainder
* of a selection (bank rows vs one verifikat) as a bank fee / interest /
* rounding verifikat and links the selection. Bank accounts only.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.residual',
async (request, { supabase, user, companyId }, { params }) => {
const { accountKey } = await params
if (!AccountKeySchema.safeParse(accountKey).success) {
return NextResponse.json({ error: 'Okänt konto' }, { status: 404 })
}
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Ogiltig JSON' }, { status: 400 })
}
const parsed = ResidualBodySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: 'Ogiltig body: external_ids, journal_entry_id och kind krävs' }, { status: 400 })
}
try {
const result = await bookResidualAndLink(
supabase,
companyId,
user.id,
accountKey,
{
external_ids: parsed.data.external_ids,
journal_entry_id: parsed.data.journal_entry_id,
kind: parsed.data.kind,
entry_date: parsed.data.entry_date,
description: parsed.data.description,
},
{ dryRun: parsed.data.dry_run === true },
)
if (!result) {
return NextResponse.json({ error: 'Okänt konto för det här företaget' }, { status: 404 })
}
return NextResponse.json({ data: result })
} catch (err) {
if (err instanceof ReconciliationResidualError) {
const status =
err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 404 : 400
return NextResponse.json({ error: getErrorMessage(err), code: err.code }, { status })
}
throw err
}
},
{ requireWrite: true },
)
@@ -0,0 +1,98 @@
/**
* Tests for POST /api/reconciliation/accounts/{accountKey}/residual (cookie
* session, withRouteContext). The residual engine 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 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 { POST } from '../[accountKey]/residual/route'
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'
const URL = `http://localhost/api/reconciliation/accounts/${KEY}/residual`
const p = (obj: Record<string, string>) => ({ params: Promise.resolve(obj) }) as never
const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }
describe('POST /api/reconciliation/accounts/{accountKey}/residual', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [], skipped: [] })
})
it('401 without a session', async () => {
requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) })
const res = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(res.status).toBe(401)
})
it('400 on a body without kind, 404 on a malformed key', async () => {
const bad = await POST(createMockRequest(URL, { method: 'POST', body: { external_ids: [T1], journal_entry_id: E1 } }), p({ accountKey: KEY }))
expect(bad.status).toBe(400)
const badKey = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: '1930' }))
expect(badKey.status).toBe(404)
expect(residualMock).not.toHaveBeenCalled()
})
it('books and links, forwarding dry_run', async () => {
const res = await POST(createMockRequest(URL, { method: 'POST', body: { ...body, dry_run: true } }), p({ accountKey: KEY }))
expect(res.status).toBe(200)
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: true },
)
const { body: out } = await parseJsonResponse<{ data: { residual_journal_entry_id: string } }>(res)
expect(out.data.residual_journal_entry_id).toBe('res-1')
})
it('maps refusals to 400 + code, missing rows to 404, unknown account to 404, and requires write', async () => {
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('för stort', 'RESIDUAL_TOO_LARGE'))
const refused = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(refused.status).toBe(400)
expect((await parseJsonResponse<{ code: string }>(refused)).body.code).toBe('RESIDUAL_TOO_LARGE')
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND'))
const missingRows = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(missingRows.status).toBe(404)
residualMock.mockResolvedValueOnce(null)
const unknown = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(unknown.status).toBe(404)
requireWriteMock.mockResolvedValue({ ok: false, response: NextResponse.json({ error: 'Läsbehörighet' }, { status: 403 }) })
const forbidden = await POST(createMockRequest(URL, { method: 'POST', body }), p({ accountKey: KEY }))
expect(forbidden.status).toBe(403)
})
})