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
@@ -26,8 +26,10 @@ const ctx = (supabase: ReturnType<typeof createQueuedMockSupabase>['supabase'])
* Tests can override individual slots before invoking by enqueueing in advance.
*/
function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) {
// 1. unbookedHead
enqueue({ count: 0 })
// 1. unbookedIds (every pointer-null business row; the junction-linked
// ones are subtracted by a lookup that runs after slot 15 and only
// when this list is non-empty)
enqueue({ data: [] })
// 2. unbookedSamples
enqueue({ data: [] })
// 3. overdueRows
@@ -80,7 +82,7 @@ describe('Accounted://attention', () => {
{ id: 't-2', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' },
]
enqueue({ count: 2 }) // unbookedHead
enqueue({ data: txns.map((t) => ({ id: t.id })) }) // unbookedIds
enqueue({ data: txns }) // unbookedSamples
enqueue({ data: [] }) // overdueRows
enqueue({ count: 0 }) // pendingSupplierHead
@@ -108,12 +110,33 @@ describe('Accounted://attention', () => {
expect(result.summary).toEqual({ total_items: 2, critical: 0, warning: 1, info: 0 })
})
it('does not count a row anchored only through transaction_voucher_links as unbooked (bulk-book, 1:N split)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const today = new Date().toISOString().slice(0, 10)
const txns = [
{ id: 't-split', date: today, amount: -800, currency: 'SEK', description: 'Utlägg', merchant_name: null },
{ id: 't-open', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' },
]
enqueue({ data: txns.map((t) => ({ id: t.id })) }) // 1. unbookedIds
enqueue({ data: txns }) // 2. unbookedSamples
for (let i = 3; i <= 14; i += 1) enqueue({ data: i === 13 || i === 14 ? null : [], count: 0 })
enqueue({ data: [] }) // 15. unlinked-document candidates
enqueue({ data: [{ transaction_id: 't-split' }] }) // 16. fetchJunctionLinkedTxIds
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'unbooked_transactions')
expect(cat?.count).toBe(1)
expect(cat?.samples.map((s) => s.id)).toEqual(['t-open'])
expect(cat?.next?.args).toEqual({ transaction_id: 't-open' })
})
it('escalates unbooked transactions to critical when oldest is > 30 days old', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
const txns = [{ id: 't-old', date: fortyDaysAgo, amount: -100, currency: 'SEK', description: 'X', merchant_name: null }]
enqueue({ count: 1 })
enqueue({ data: [{ id: 't-old' }] })
enqueue({ data: txns })
enqueue({ data: [] })
enqueue({ count: 0 })
@@ -353,7 +376,7 @@ describe('Accounted://attention', () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const today = new Date().toISOString().slice(0, 10)
enqueue({ count: 1 }) // unbookedHead
enqueue({ data: [{ id: 't-1' }] }) // unbookedIds
enqueue({ data: [{ id: 't-1', date: today, amount: -50, currency: 'SEK', description: 'X', merchant_name: null }] })
enqueue({ data: [] }) // overdueRows
enqueue({ count: 1 }) // pendingSupplierHead
@@ -54,6 +54,7 @@ function createRecordingSupabase(results: Record<string, TableResult> = {}) {
return chain
}
chain.eq = passthrough
chain.in = passthrough
chain.order = passthrough
chain.limit = passthrough
chain.then = (resolve: (v: unknown) => void) =>
@@ -130,6 +131,26 @@ describe('Accounted://recent-activity', () => {
expect(result.uncategorized_transaction_count).toBe(1)
})
it('does not count a pointer-null row anchored through transaction_voucher_links (bulk-book, 1:N split)', async () => {
const { supabase, selects } = createRecordingSupabase({
transactions: {
data: [
{ id: 't-1', journal_entry_id: 'je-1' },
{ id: 't-split', journal_entry_id: null },
{ id: 't-open', journal_entry_id: null },
],
},
transaction_voucher_links: { data: [{ transaction_id: 't-split' }] },
})
const result = (await recentActivityResource.read(ctx(supabase))) as {
uncategorized_transaction_count: number
}
expect(selects.transaction_voucher_links).toBe('transaction_id')
expect(result.uncategorized_transaction_count).toBe(1)
})
it('surfaces a query failure instead of reporting zero invoices', async () => {
const { supabase } = createRecordingSupabase({
invoices: { error: { message: 'column invoices.total_amount does not exist' } },
@@ -144,6 +144,66 @@ describe('reconciliation MCP tools', () => {
expect(out.next).toMatchObject({ tool: 'gnubok_get_reconciliation_status' })
})
it('reconcile_match folds the links of a bank 1:N split back into ONE staged pair with explicit allocations (#1553)', async () => {
const { supabase } = createQueuedMockSupabase()
const cash = '55555555-5555-4555-8555-555555555555'
const entry2 = '44444444-4444-4444-8444-444444444444'
const otherRow = '66666666-6666-4666-8666-666666666666'
matchMock.mockResolvedValue({
dry_run: true,
considered: 2,
applied: [
{ external_id: otherRow, journal_entry_id: ENTRY },
{ external_id: ROW, journal_entry_id: ENTRY, allocated_amount: -500 },
{ external_id: ROW, journal_entry_id: entry2, allocated_amount: -300 },
],
skipped: [],
})
const out = (await tool('gnubok_reconcile_match').execute(
{
account_key: `bank:${cash}`,
pairs: [
{ external_ids: [otherRow], journal_entry_ids: [ENTRY] },
{ external_ids: [ROW], journal_entry_ids: [ENTRY, entry2] },
],
dry_run: true,
},
COMPANY,
USER,
supabase as never,
{ type: 'api_key', id: 'key-1' } as never,
)) as Record<string, unknown>
expect(matchMock).toHaveBeenCalledWith(
supabase,
COMPANY,
USER,
`bank:${cash}`,
expect.objectContaining({
pairs: [
{ external_ids: [otherRow], journal_entry_ids: [ENTRY] },
{ external_ids: [ROW], journal_entry_ids: [ENTRY, entry2] },
],
}),
{ dryRun: true },
)
const preview = out.preview as Record<string, unknown>
expect(preview.pair_count).toBe(2)
// The 1:1 link stays a pair of its own; the two split links become one
// pair carrying the slices the reviewer saw, so the executor re-validates
// exactly those amounts (never two independent manualLink calls).
expect(preview.pairs).toEqual([
{ external_ids: [otherRow], journal_entry_ids: [ENTRY] },
{
external_ids: [ROW],
journal_entry_ids: [ENTRY, entry2],
allocations: [
{ journal_entry_id: ENTRY, amount: -500 },
{ journal_entry_id: entry2, amount: -300 },
],
},
])
})
it('reconcile_match refuses an empty request and a request with nothing linkable', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
@@ -5,6 +5,8 @@ import {
UNLINKED_DOCUMENT_SCAN_CAP,
} from '@/lib/documents/unlinked-documents'
import { countReconciliationDue } from '@/lib/worklist/categories'
import { fetchJunctionLinkedTxIds } from '@/lib/reconciliation/bank-reconciliation'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
type Severity = 'critical' | 'warning' | 'info'
@@ -42,7 +44,7 @@ export const attentionResource: McpResource = {
const horizon = horizonDate.toISOString().slice(0, 10)
const [
unbookedHead,
unbookedIds,
unbookedSamples,
overdueRows,
pendingSupplierHead,
@@ -58,12 +60,20 @@ export const attentionResource: McpResource = {
companySettingsRow,
unlinkedDocuments,
] = await Promise.all([
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_business', true),
// Ids, not a head count: journal_entry_id IS NULL is only the first of
// the "booked" anchors (lib/transactions/is-booked.ts). Rows bulk-booked
// into a samlingsverifikat or split over several verifikat (1:N) are
// anchored through transaction_voucher_links and are subtracted below.
fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_business', true)
.order('id')
.range(from, to),
).catch(() => [] as Array<{ id: string }>),
supabase
.from('transactions')
.select('id, date, amount, currency, description, merchant_name')
@@ -71,7 +81,7 @@ export const attentionResource: McpResource = {
.is('journal_entry_id', null)
.eq('is_business', true)
.order('date', { ascending: true })
.limit(SAMPLE_LIMIT),
.limit(SAMPLE_LIMIT * 4),
supabase
.from('invoices')
.select('id, invoice_number, customer_id, due_date, total, currency, status')
@@ -154,16 +164,24 @@ export const attentionResource: McpResource = {
const categories: AttentionCategory[] = []
// ── Unbooked business transactions ──────────────────────────────
const unbookedCount = unbookedHead.count ?? 0
const pointerUnbookedIds = unbookedIds.map((row) => row.id)
const junctionLinked =
pointerUnbookedIds.length > 0
? await fetchJunctionLinkedTxIds(supabase, companyId, pointerUnbookedIds)
: new Set<string>()
const unbookedCount = pointerUnbookedIds.filter((id) => !junctionLinked.has(id)).length
const samples = (unbookedSamples.data ?? [])
.filter((row) => !junctionLinked.has(row.id as string))
.slice(0, SAMPLE_LIMIT)
if (unbookedCount > 0) {
const oldest = unbookedSamples.data?.[0]
const oldest = samples[0]
const oldestAgeDays = oldest?.date ? daysBetween(oldest.date, today) : 0
categories.push({
key: 'unbooked_transactions',
label_sv: 'Obokförda affärstransaktioner',
severity: oldestAgeDays > 30 ? 'critical' : 'warning',
count: unbookedCount,
samples: unbookedSamples.data ?? [],
samples,
next: {
description: 'Kategorisera den äldsta obokförda transaktionen.',
tool: 'gnubok_categorize_transaction',
@@ -1,4 +1,5 @@
import type { McpResource } from './types'
import { fetchJunctionLinkedTxIds } from '@/lib/reconciliation/bank-reconciliation'
export const recentActivityResource: McpResource = {
uri: 'Accounted://recent-activity',
@@ -41,14 +42,27 @@ export const recentActivityResource: McpResource = {
throw new Error(`Failed to read recent transactions: ${transactions.error.message}`)
}
// A NULL pointer is not "unbooked" on its own: rows bulk-booked into a
// samlingsverifikat or split over several verifikat (1:N) are anchored
// through transaction_voucher_links (lib/transactions/is-booked.ts).
const recentTx = transactions.data ?? []
const pointerUnbooked = recentTx.filter((t) => !t.journal_entry_id)
const junctionLinked =
pointerUnbooked.length > 0
? await fetchJunctionLinkedTxIds(
supabase,
companyId,
pointerUnbooked.map((t) => t.id as string),
)
: new Set<string>()
return {
limit,
journal_entries: journalEntries.data ?? [],
invoices: invoices.data ?? [],
transactions: transactions.data ?? [],
uncategorized_transaction_count: (transactions.data ?? []).filter(
(t) => !t.journal_entry_id
).length,
transactions: recentTx,
uncategorized_transaction_count: pointerUnbooked.filter((t) => !junctionLinked.has(t.id as string))
.length,
}
},
}
+48 -9
View File
@@ -10953,13 +10953,13 @@ export const tools: McpTool[] = [
properties: {
account_key: {
type: 'string',
description: '"skattekonto", "bank:<cash_account_id>" or "manual:<BAS>". Returns the account-keyed status (bridge[], counts, kind block); for manual keys date_to is the balansdag.',
description: '"skattekonto", "bank:<cash_account_id>" or "manual:<BAS>"; for manual keys date_to is the balansdag.',
},
date_from: { type: 'string', description: 'Start date YYYY-MM-DD' },
date_to: { type: 'string', description: 'End date YYYY-MM-DD' },
account_number: {
type: 'string',
description: 'Legacy: cash-account BAS code to reconcile, e.g. "1940". Defaults to "1930". Ignored when account_key is set.',
description: 'Legacy: BAS code, default "1930". Ignored when account_key is set.',
},
},
},
@@ -11008,7 +11008,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_list_reconciliation_items',
title: 'Reconciliation Items',
description: 'Rows behind one account\'s reconciliation bridge, bucketed (proposed, unmatched_external, unmatched_ledger, matched, ignored, upcoming): side, qualified id, amount, proposal with confidence + reasons, allowed actions. Link via gnubok_reconcile_match.',
description: 'Rows behind one account\'s reconciliation bridge, by bucket: side, qualified id, amount, proposal with confidence + reasons, allowed actions. Link via gnubok_reconcile_match.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -11073,13 +11073,23 @@ export const tools: McpTool[] = [
account_key: { type: 'string', description: '"skattekonto" or "bank:<cash_account_id>"' },
pairs: {
type: 'array',
description: 'One outside row id + one journal_entry_id per pair',
description: 'Rows against one verifikat, or (bank) one row against several',
items: {
type: 'object',
additionalProperties: false,
properties: {
external_ids: { type: 'array', items: { type: 'string' } },
journal_entry_ids: { type: 'array', items: { type: 'string' } },
allocations: {
type: 'array',
description: 'Bank 1:N: signed slice per verifikat, summing to the row',
items: {
type: 'object',
additionalProperties: false,
properties: { journal_entry_id: { type: 'string' }, amount: { type: 'number' } },
required: ['journal_entry_id', 'amount'],
},
},
},
required: ['external_ids', 'journal_entry_ids'],
},
@@ -11100,7 +11110,14 @@ export const tools: McpTool[] = [
},
async execute(args, companyId, userId, supabase, actor) {
const accountKey = args.account_key as string
const pairs = (args.pairs as Array<{ external_ids: string[]; journal_entry_ids: string[] }> | undefined) ?? []
const pairs =
(args.pairs as
| Array<{
external_ids: string[]
journal_entry_ids: string[]
allocations?: Array<{ journal_entry_id: string; amount: number }>
}>
| undefined) ?? []
const useProposals = args.use_proposals === true
if (pairs.length === 0 && !useProposals) {
throw new Error('Pass pairs, or use_proposals: true')
@@ -11119,10 +11136,32 @@ export const tools: McpTool[] = [
{ dryRun: true },
)
if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`)
const resolvedPairs = preview.applied.map((a) => ({
external_ids: [a.external_id],
journal_entry_ids: [a.journal_entry_id],
}))
// Rebuild the staged pairs from the preview: 1:1 links stay one pair
// each; the links of a bank 1:N split (they carry allocated_amount, all
// on the same row) fold back into ONE pair with explicit allocations, so
// the executor re-validates the exact slices the reviewer approved.
const resolvedPairs: Array<{
external_ids: string[]
journal_entry_ids: string[]
allocations?: Array<{ journal_entry_id: string; amount: number }>
}> = []
const splitByRow = new Map<string, Array<{ journal_entry_id: string; amount: number }>>()
for (const a of preview.applied) {
if (typeof a.allocated_amount === 'number') {
const slices = splitByRow.get(a.external_id) ?? []
slices.push({ journal_entry_id: a.journal_entry_id, amount: a.allocated_amount })
splitByRow.set(a.external_id, slices)
continue
}
resolvedPairs.push({ external_ids: [a.external_id], journal_entry_ids: [a.journal_entry_id] })
}
for (const [externalId, slices] of splitByRow) {
resolvedPairs.push({
external_ids: [externalId],
journal_entry_ids: slices.map((s) => s.journal_entry_id),
allocations: slices,
})
}
if (resolvedPairs.length === 0) {
throw new Error('No linkable pairs: nothing to stage')
}
@@ -46,7 +46,7 @@ Per account: outside vs ledger, what was linked, what the user still has to book
## Rules
- Links and sign-offs never touch the ledger; booking does, and always stages.
- One or more outside rows link to one verifikat; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A small fee, interest or rounding difference on a bank account is closed with \`gnubok_reconcile_residual({ account_key, external_ids, journal_entry_id, kind, dry_run: true })\`, then without dry_run: it links the rows and books the difference (6570 / 8410 / 8310 / 3740) in one staged step. Anything larger than the cap is a missing booking, not a fee.
- One or more outside rows link to one verifikat. On a bank account one row may also settle several verifikat (a lump payout over utlägg booked per receipt, a Bankgirot deposit over two customer payments): pass \`journal_entry_ids\` with several ids, optionally \`allocations: [{ journal_entry_id, amount }]\` (signed, the row's sign convention; omitted = each voucher's bank line); the slices must sum to the row. Other shapes come back as UNSUPPORTED_PAIR_SHAPE. A small fee, interest or rounding difference on a bank account is closed with \`gnubok_reconcile_residual({ account_key, external_ids, journal_entry_id, kind, dry_run: true })\`, then without dry_run: it links the rows and books the difference (6570 / 8410 / 8310 / 3740) in one staged step. Anything larger than the cap is a missing booking, not a fee.
- Never judge on \`difference\`; the bridge explains it. Judge on \`unexplained_difference\`.
- A skattekonto sign-off date cannot pass the saldo snapshot; ask for a fetch.