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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
0a3cb1a31a
commit
d88df74b85
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user