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)
})
})
@@ -0,0 +1,147 @@
/**
* POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual
*
* Book the remainder of a bank selection (N transactions vs one verifikat)
* as a small verifikat (bank fee / interest / rounding) and link the
* selection in the same call. Writes to the ledger: transactions:write, the
* same scope that books a bank transaction. Dry-runnable; Idempotency-Key
* required.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AccountKeySchema } from '@/lib/reconciliation/schemas'
import { bookResidualAndLink, ReconciliationResidualError, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ISO_DATE_RE } from '@/lib/invariants'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
const ResidualRequest = 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(),
})
const LineSchema = z.object({ account_number: z.string(), debit_amount: z.number(), credit_amount: z.number() })
const ResidualResponse = z.object({
dry_run: z.boolean(),
residual_journal_entry_id: z.string().optional(),
residual_amount: z.number().optional(),
applied: z.array(z.object({ external_id: z.string(), journal_entry_id: z.string() })).optional(),
skipped: z.array(z.object({ code: z.string(), message: z.string() })).optional(),
would_book: z
.object({
kind: z.string(),
counter_account: z.string(),
ledger_account: z.string(),
currency: z.string(),
transactions_total: z.number(),
entry_net: z.number(),
residual_amount: z.number(),
entry_date: z.string(),
description: z.string(),
lines: z.array(LineSchema),
})
.optional(),
})
registerEndpoint({
operation: 'reconciliation.accounts.residual',
method: 'POST',
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual',
summary: 'Book the remainder of a bank selection as a fee/interest/rounding verifikat and link the selection.',
description:
`Body: { external_ids: [transaction ids], journal_entry_id, kind: "bank_fee" | "interest_expense" | "interest_income" | "rounding", entry_date?, description? }. Computes the difference between the transactions' sum and the verifikat's net on the bank account, books it on 6570 / 8410 / 8310 / 3740 against the bank account (dated on the latest transaction by default), links the transactions to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (bank:<cash_account_id>). Refused when the difference is 0 (RESIDUAL_ZERO), above ${RESIDUAL_MAX_AMOUNT} kr (RESIDUAL_TOO_LARGE: that is a missing booking, not a fee), or when the kind points the wrong way (RESIDUAL_DIRECTION). ?dry_run=true returns would_book without writing.`,
useWhen:
'A manual match misses by a small amount that is genuinely a bank fee, interest or rounding, and you want to close it in one step instead of booking a verifikat and then linking.',
doNotUseFor:
'Skattekonto rows (Skatteverket posts ränta and avgifter as their own rows: link them), or differences that are really a missing booking (book that properly).',
pitfalls: [
'The kind must match the direction: money that left the bank unbooked is bank_fee / interest_expense; money that arrived unbooked is interest_income; rounding works either way.',
'Links are made before the booking and undone if the booking is refused (a locked period), so a refusal leaves nothing half done.',
'Idempotency-Key is required; repeating the same key replays the first response.',
],
example: {
request: { external_ids: ['22222222-2222-4222-8222-222222222222'], journal_entry_id: '44444444-4444-4444-8444-444444444444', kind: 'bank_fee' },
response: {
data: {
dry_run: false,
residual_journal_entry_id: '55555555-5555-4555-8555-555555555555',
residual_amount: -10,
applied: [{ external_id: '22222222-2222-4222-8222-222222222222', journal_entry_id: '44444444-4444-4444-8444-444444444444' }],
skipped: [],
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'transactions:write',
risk: 'medium',
idempotent: false,
reversible: false,
dryRunSupported: true,
request: { body: ResidualRequest },
response: { success: dataEnvelope(ResidualResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; accountKey: string }> }>(
'reconciliation.accounts.residual',
async (request, ctx, params) => {
const { accountKey } = await params.params
if (!AccountKeySchema.safeParse(accountKey).success) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = ResidualRequest.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { issues: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message })) },
})
}
try {
const result = await bookResidualAndLink(ctx.supabase, ctx.companyId!, ctx.userId, accountKey, parsed.data, {
dryRun: ctx.dryRun,
})
if (!result) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { field: 'accountKey', message: 'Okänt konto för det här företaget.' },
})
}
if (ctx.dryRun) {
return dryRunPreview(result, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result, { requestId: ctx.requestId })
} catch (err) {
if (err instanceof ReconciliationResidualError) {
const v1Code =
err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 'NOT_FOUND' : 'VALIDATION_ERROR'
return v1ErrorResponseFromCode(v1Code, ctx.log, {
requestId: ctx.requestId,
details: { code: err.code, message: getErrorMessage(err) },
})
}
return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId })
}
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,129 @@
/**
* Tests for POST /api/v1/companies/{companyId}/reconciliation/accounts/{accountKey}/residual:
* real withApiV1 wrapper (auth, scope, membership, idempotency, dry-run), engine mocked.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const { residualMock } = vi.hoisted(() => ({ 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: residualMock }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { ReconciliationResidualError } from '@/lib/reconciliation/residual'
import { POST } from '../[accountKey]/residual/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
type MockResult = { data?: unknown; error?: unknown }
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
const queues = new Map<string, MockResult[]>()
for (const [t, val] of Object.entries(byTable)) queues.set(t, Array.isArray(val) ? [...val] : [val])
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
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/v1/companies/${COMPANY_ID}/reconciliation/accounts/${KEY}/residual`
const body = { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }
function req(init: { body?: unknown; idem?: boolean; dryRun?: boolean } = {}): Request {
const headers: Record<string, string> = {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Content-Type': 'application/json',
}
if (init.idem !== false) headers['Idempotency-Key'] = `idem-${Math.random().toString(36).slice(2)}-aaaa-4abc-8def-1234567890ab`
if (init.dryRun) headers['X-Dry-Run'] = 'true'
return new Request(URL, { method: 'POST', headers, body: init.body !== undefined ? JSON.stringify(init.body) : undefined })
}
function authOk(scopes: string[]) {
mockValidate.mockResolvedValue({ valid: true, userId: 'user-1', keyId: 'key-1', keyName: 'Test key', scopes, mode: 'live' })
}
const params = (accountKey = KEY) => ({ params: Promise.resolve({ companyId: COMPANY_ID, accountKey }) }) as never
describe('v1 reconciliation residual', () => {
beforeEach(() => {
vi.clearAllMocks()
mockServiceClient.mockReturnValue(makeFlexibleSupabase({ company_members: { data: { role: 'owner' } }, idempotency_keys: { data: null } }))
residualMock.mockResolvedValue({ dry_run: false, residual_journal_entry_id: 'res-1', residual_amount: -10, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] })
})
it('401 without a valid key; 403 without transactions:write', async () => {
mockValidate.mockResolvedValue({ valid: false, error: 'invalid' })
expect((await POST(req({ body }), params())).status).toBe(401)
authOk(['reconciliation:write'])
expect((await POST(req({ body }), params())).status).toBe(403)
})
it('needs an Idempotency-Key, validates the body, 404s a bad key', async () => {
authOk(['transactions:write'])
expect((await POST(req({ body, idem: false }), params())).status).toBe(400)
expect((await POST(req({ body: { external_ids: [T1] } }), params())).status).toBe(400)
expect((await POST(req({ body }), params('1930'))).status).toBe(404)
expect(residualMock).not.toHaveBeenCalled()
})
it('books and links, and previews on dry run', async () => {
authOk(['transactions:write'])
const res = await POST(req({ body }), params())
expect(res.status).toBe(200)
expect((await res.json()).data.residual_journal_entry_id).toBe('res-1')
expect(residualMock).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: false })
residualMock.mockResolvedValue({ dry_run: true, would_book: { residual_amount: -10 } })
const dry = await POST(req({ body, dryRun: true }), params())
expect(dry.status).toBe(200)
expect(residualMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', KEY, body, { dryRun: true })
})
it('maps refusals to VALIDATION_ERROR with the residual code, and missing rows to NOT_FOUND', async () => {
authOk(['transactions:write'])
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('fel riktning', 'RESIDUAL_DIRECTION'))
const refused = await POST(req({ body }), params())
expect(refused.status).toBe(400)
const out = await refused.json()
expect(out.error.code).toBe('VALIDATION_ERROR')
expect(out.error.details.code).toBe('RESIDUAL_DIRECTION')
residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ENTRY_NOT_FOUND'))
expect((await POST(req({ body }), params())).status).toBe(404)
})
})