feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)

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
This commit is contained in:
Jakob Wennberg
2026-08-30 11:56:15 +02:00
committed by GitHub
parent 749f90fe62
commit d11d0a2e90
36 changed files with 2252 additions and 158 deletions
@@ -8,6 +8,13 @@ import { matchPairs } from '@/lib/reconciliation/actions'
const PairSchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
// Bank 1:N only: the signed slice per verifikat (transaction sign
// convention). Omitted: each slice defaults to the voucher's bank line.
allocations: z
.array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
.min(2)
.max(50)
.optional(),
})
const ReconciliationLinksBodySchema = z
@@ -25,8 +32,10 @@ const ReconciliationLinksBodySchema = z
* POST /api/reconciliation/accounts/{accountKey}/links
*
* The page's "Koppla" and "Koppla N föreslagna": link outside rows to existing
* verifikat. A human clicked, so this applies directly (dry_run: true for the
* preview). Same service function as v1 and the MCP commit executor.
* verifikat (N:1), or one bank transaction to several verifikat (1:N, with
* optional allocations). A human clicked, so this applies directly (dry_run:
* true for the preview). Same service function as v1 and the MCP commit
* executor.
*/
export const POST = withRouteContext<{ params: Promise<{ accountKey: string }> }>(
'reconciliation.accounts.links.create',
@@ -53,6 +53,8 @@ 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
@@ -144,6 +146,33 @@ describe('dashboard reconciliation routes', () => {
)
})
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)
@@ -29,8 +29,10 @@ vi.mock('@/lib/auth/require-write', () => ({
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
const manualLinkMock = vi.fn()
const linkToVouchersMock = vi.fn()
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
manualLink: (...args: unknown[]) => manualLinkMock(...args),
linkTransactionToVouchers: (...args: unknown[]) => linkToVouchersMock(...args),
}))
import { POST } from '../route'
@@ -38,6 +40,7 @@ import { POST } from '../route'
const emptyParams = { params: Promise.resolve({}) }
const TX_ID = '11111111-1111-4111-8111-111111111111'
const JE_ID = '22222222-2222-4222-8222-222222222222'
const JE_ID_2 = '33333333-3333-4333-8333-333333333333'
describe('POST /api/reconciliation/bank/link', () => {
beforeEach(() => {
@@ -46,6 +49,7 @@ describe('POST /api/reconciliation/bank/link', () => {
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
requireWriteMock.mockResolvedValue({ ok: true })
manualLinkMock.mockResolvedValue({ success: true })
linkToVouchersMock.mockResolvedValue({ success: true, allocations: [] })
})
it('returns 401 when unauthenticated', async () => {
@@ -128,4 +132,102 @@ describe('POST /api/reconciliation/bank/link', () => {
'1930',
)
})
it('splits the transaction over several verifikat when allocations are given instead of journal_entry_id (1:N, #1553)', async () => {
linkToVouchersMock.mockResolvedValue({
success: true,
allocations: [
{ journal_entry_id: JE_ID, amount: -500 },
{ journal_entry_id: JE_ID_2, amount: -300 },
],
})
const request = createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: {
transaction_id: TX_ID,
account_number: '1940',
allocations: [
{ journal_entry_id: JE_ID, amount: -500 },
{ journal_entry_id: JE_ID_2, amount: -300 },
],
},
})
const response = await POST(request, emptyParams)
const { status, body } = await parseJsonResponse<{ data: { success: boolean; allocations: unknown[] } }>(response)
expect(status).toBe(200)
expect(body.data.success).toBe(true)
expect(body.data.allocations).toHaveLength(2)
expect(linkToVouchersMock).toHaveBeenCalledWith(
supabase,
'company-1',
TX_ID,
[
{ journal_entry_id: JE_ID, amount: -500 },
{ journal_entry_id: JE_ID_2, amount: -300 },
],
'user-1',
'1940',
)
expect(manualLinkMock).not.toHaveBeenCalled()
})
it('rejects a body with both journal_entry_id and allocations, with neither, or with a single allocation (400)', async () => {
const both = await POST(
createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: {
transaction_id: TX_ID,
journal_entry_id: JE_ID,
allocations: [
{ journal_entry_id: JE_ID, amount: -500 },
{ journal_entry_id: JE_ID_2, amount: -300 },
],
},
}),
emptyParams,
)
expect(both.status).toBe(400)
const neither = await POST(
createMockRequest('/api/reconciliation/bank/link', { method: 'POST', body: { transaction_id: TX_ID } }),
emptyParams,
)
expect(neither.status).toBe(400)
const single = await POST(
createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: { transaction_id: TX_ID, allocations: [{ journal_entry_id: JE_ID, amount: -800 }] },
}),
emptyParams,
)
expect(single.status).toBe(400)
expect(manualLinkMock).not.toHaveBeenCalled()
expect(linkToVouchersMock).not.toHaveBeenCalled()
})
it('surfaces a refused split as 400 with the service error', async () => {
linkToVouchersMock.mockResolvedValue({
success: false,
error: 'Fördelningen (-700) stämmer inte med transaktionens belopp (-800).',
})
const response = await POST(
createMockRequest('/api/reconciliation/bank/link', {
method: 'POST',
body: {
transaction_id: TX_ID,
allocations: [
{ journal_entry_id: JE_ID, amount: -400 },
{ journal_entry_id: JE_ID_2, amount: -300 },
],
},
}),
emptyParams,
)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toBe('Fördelningen (-700) stämmer inte med transaktionens belopp (-800).')
})
})
+20 -11
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { linkTransactionToVouchers, manualLink } from '@/lib/reconciliation/bank-reconciliation'
import { validateBody } from '@/lib/api/validate'
import { BankLinkSchema } from '@/lib/api/schemas'
@@ -12,22 +12,31 @@ export const POST = withRouteContext(
async (request, { supabase, user, companyId }) => {
const validation = await validateBody(request, BankLinkSchema)
if (!validation.success) return validation.response
const { transaction_id, journal_entry_id, account_number } = validation.data
const { transaction_id, journal_entry_id, allocations, account_number } = validation.data
const result = await manualLink(
supabase,
companyId,
transaction_id,
journal_entry_id,
user.id,
account_number ?? '1930',
)
// One verifikat: the plain link. Several: the 1:N split (#1553), all or
// nothing, slices summing to the transaction.
const result = journal_entry_id
? await manualLink(supabase, companyId, transaction_id, journal_entry_id, user.id, account_number ?? '1930')
: await linkTransactionToVouchers(
supabase,
companyId,
transaction_id,
allocations ?? [],
user.id,
account_number ?? '1930',
)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
return NextResponse.json({ data: { success: true } })
// A split echoes the slices as validated (defaults resolved); the plain
// link has nothing to add.
const resolvedAllocations = 'allocations' in result ? result.allocations : undefined
return NextResponse.json({
data: { success: true, ...(resolvedAllocations ? { allocations: resolvedAllocations } : {}) },
})
},
{ requireWrite: true },
)
@@ -126,6 +126,23 @@ describe('GET /api/transactions', () => {
expect(isCall).toEqual({ method: 'is', args: ['journal_entry_id', null] })
})
it('hides rows anchored only through transaction_voucher_links from unmatched=true (bulk-book, 1:N split, #1553)', async () => {
const txs = [
makeTransaction({ id: 'tx-open', journal_entry_id: null }),
makeTransaction({ id: 'tx-split', journal_entry_id: null }),
]
enqueue({ data: txs, error: null }) // transactions list
enqueue({ data: [{ transaction_id: 'tx-split' }], error: null }) // transaction_voucher_links
const request = createMockRequest('/api/transactions?unmatched=true')
const response = await GET(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ data: Array<{ id: string }>; has_more: boolean }>(response)
expect(status).toBe(200)
expect(body.data.map((t) => t.id)).toEqual(['tx-open'])
expect(body.has_more).toBe(false)
})
it('filters by reconciled=true', async () => {
const fromSpy = vi.fn(() => {
const chain: Record<string, unknown> = {}
+19 -3
View File
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
import { scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation'
import { fetchJunctionLinkedTxIds, scopeTransactionsToAccount } from '@/lib/reconciliation/bank-reconciliation'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateTransactionSchema } from '@/lib/api/schemas'
@@ -93,9 +93,25 @@ export const GET = withRouteContext('transaction.list', async (request, { supaba
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
}
const rows = data || []
let rows = data || []
// has_more is decided on what the DB returned, before the junction filter
// below: a page that came back full means more rows exist past it whether
// or not some of this page's rows turn out to be booked.
const hasMore = rows.length > MAX_ROWS
const truncated = hasMore ? rows.slice(0, MAX_ROWS) : rows
// journal_entry_id IS NULL is only the first of the three "booked" anchors
// (lib/transactions/is-booked.ts): a row bulk-booked into a
// samlingsverifikat or split over several verifikat (1:N, #1553) is
// anchored through transaction_voucher_links alone and must leave
// "Att bokföra" all the same.
if (unmatched && rows.length > 0) {
const junctionLinked = await fetchJunctionLinkedTxIds(
supabase,
companyId,
rows.map((row) => row.id as string),
)
if (junctionLinked.size > 0) rows = rows.filter((row) => !junctionLinked.has(row.id as string))
}
const truncated = rows.length > MAX_ROWS ? rows.slice(0, MAX_ROWS) : rows
return NextResponse.json({ data: truncated, has_more: hasMore, limit: MAX_ROWS })
})
@@ -18,6 +18,11 @@ import { matchPairs } from '@/lib/reconciliation/actions'
const PairSchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
allocations: z
.array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
.min(2)
.max(50)
.optional(),
})
const LinksRequest = z
@@ -38,6 +43,7 @@ const LinksResponse = z.object({
external_id: z.string(),
journal_entry_id: z.string(),
via: z.enum(['line', 'entry_total']).optional(),
allocated_amount: z.number().optional(),
}),
),
skipped: z.array(
@@ -55,13 +61,13 @@ registerEndpoint({
path: '/api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links',
summary: 'Link outside rows to existing verifikat (pairs or proposals).',
description:
'Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id] }] } and/or { use_proposals: true, confidence_threshold? }. Each pair is validated as the single-link paths validate (row open and not ignored, entry posted and not reversed, the entry\'s account lines settle the amount, entry not already linked) and applied independently: the response lists applied[] and skipped[{pair, code, message}] so partial success is explicit. Codes: UNSUPPORTED_PAIR_SHAPE, ALREADY_LINKED, ENTRY_NOT_FOUND, PAIR_NOT_CLOSED, ROW_IGNORED, NOT_FOUND, LINK_RACE. ?dry_run=true returns the pairs that would be attempted without writing.',
'Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id], allocations? }] } and/or { use_proposals: true, confidence_threshold? }. Each pair is validated as the single-link paths validate (row open and not ignored, entry posted and not reversed, the entry\'s account lines settle the amount, entry not already linked) and applied independently: the response lists applied[] and skipped[{pair, code, message}] so partial success is explicit. On a bank account a pair may also be ONE transaction against SEVERAL verifikat (1:N): allocations[{journal_entry_id, amount}] gives the signed slice per verifikat (omitted: each slice defaults to the voucher\'s line on the account); the slices must sum to the transaction amount, and each applied link then carries allocated_amount. Codes: UNSUPPORTED_PAIR_SHAPE, ALREADY_LINKED, ENTRY_NOT_FOUND, PAIR_NOT_CLOSED, ROW_IGNORED, NOT_FOUND, LINK_RACE. ?dry_run=true returns the pairs that would be attempted without writing (a 1:N dry run resolves the slices).',
useWhen:
'An agent or integration has decided which rows explain each other, or wants to apply the proposals the sync already computed.',
doNotUseFor:
'Booking new verifikat for rows that have no counterpart (use the transactions or skattekonto booking endpoints); reconciling across accounts.',
pitfalls: [
'A pair is one OR MANY outside rows against exactly one verifikat (bank: independent links per transaction; skattekonto: all-or-nothing, the rows must sum to what the verifikat settles). One row against several verifikat is UNSUPPORTED_PAIR_SHAPE until residual booking lands, never silently reduced.',
'A pair is one OR MANY outside rows against exactly one verifikat (bank: independent links per transaction; skattekonto: all-or-nothing, the rows must sum to what the verifikat settles), or, on a bank account only, ONE transaction against SEVERAL verifikat (all-or-nothing, the slices must sum to the transaction). Several rows against several verifikat, and a skattekonto row against several verifikat, are UNSUPPORTED_PAIR_SHAPE, never silently reduced.',
'A pair must close to the row\'s amount on the expected side (a single matching line, or the entry\'s lines on the account netting to it); a fee or rounding difference is PAIR_NOT_CLOSED here and needs a residual booking first.',
'Links never touch the ledger, so they succeed in locked periods; unlink with DELETE .../links/{linkId} (linkId = the outside row id).',
'Idempotency-Key is required; repeating the same key replays the first response.',
@@ -224,6 +224,37 @@ describe('v1 reconciliation accounts', () => {
expect(matchMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', 'skattekonto', expect.anything(), { dryRun: true })
})
it('POST links accepts a bank 1:N pair with allocations and echoes allocated_amount on the applied links (#1553)', async () => {
authOk(['reconciliation:write'])
const cash = '55555555-5555-4555-8555-555555555555'
const entry2 = '44444444-4444-4444-8444-444444444444'
const pair = {
external_ids: [ROW],
journal_entry_ids: [ENTRY, entry2],
allocations: [
{ journal_entry_id: ENTRY, amount: -500 },
{ journal_entry_id: entry2, amount: -300 },
],
}
matchMock.mockResolvedValueOnce({
dry_run: false,
considered: 1,
applied: [
{ external_id: ROW, journal_entry_id: ENTRY, allocated_amount: -500 },
{ external_id: ROW, journal_entry_id: entry2, allocated_amount: -300 },
],
skipped: [],
})
const res = await linksPOST(
req(`${BASE}/bank:${cash}/links`, { method: 'POST', body: { pairs: [pair] } }),
params({ accountKey: `bank:${cash}` }),
)
expect(res.status).toBe(200)
expect(matchMock).toHaveBeenLastCalledWith(expect.anything(), COMPANY_ID, 'user-1', `bank:${cash}`, expect.objectContaining({ pairs: [pair] }), { dryRun: false })
const body = (await res.json()) as { data: { applied: Array<{ allocated_amount?: number }> } }
expect(body.data.applied.map((a) => a.allocated_amount)).toEqual([-500, -300])
})
it('DELETE link unmatches and 404s a non-uuid link id', async () => {
authOk(['reconciliation:write'])
const ok = await linkDELETE(req(`${BASE}/skattekonto/links/${ROW}`, { method: 'DELETE' }), params({ accountKey: 'skattekonto', linkId: ROW }))