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
+106 -2
View File
@@ -16,12 +16,21 @@ async function linkThroughJunction(params: {
transactionId: string
journalEntryId: string
amount: number
role?: 'bank_line' | 'other'
}): Promise<void> {
await getPool().query(
`INSERT INTO public.transaction_voucher_links
(id, user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role)
VALUES ($1, $2, $3, $4, $5, $6, 'other')`,
[randomUUID(), params.userId, params.companyId, params.transactionId, params.journalEntryId, params.amount],
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
randomUUID(),
params.userId,
params.companyId,
params.transactionId,
params.journalEntryId,
params.amount,
params.role ?? 'other',
],
)
}
@@ -115,4 +124,99 @@ describe('GL line RPCs treat transaction_voucher_links as links', () => {
)
expect(withMatched.rows.find((r) => r.journal_entry_id === entry)?.linked_transaction_count).toBe(1)
})
it('treats ONE transaction split over TWO verifikat (1:N, #1553) as a link on each, counted once per verifikat', async () => {
// A lump payout of -800 settles two utlägg verifikat booked per receipt
// (-500 and -300 on 1930). linkTransactionToVouchers leaves the pointer
// NULL and writes one bank_line slice per verifikat; the GL RPCs must see
// both verifikat as matched, each with exactly one linked transaction,
// and the pointer-based reader must agree through is_transaction_booked().
const { userId, companyId, fiscalPeriodId } = await seedCompany()
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
const controlEntry = await insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-09',
description: 'Utan koppling',
lines: [
{ accountNumber: '6570', debitAmount: 45, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: 45 },
],
})
const utlagg1 = await insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-10',
description: 'Utlägg kvitto 1',
lines: [
{ accountNumber: '5410', debitAmount: 500, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: 500 },
],
})
const utlagg2 = await insertPostedJournalEntry({
userId,
companyId,
fiscalPeriodId,
entryDate: '2026-06-10',
description: 'Utlägg kvitto 2',
lines: [
{ accountNumber: '6110', debitAmount: 300, creditAmount: 0 },
{ accountNumber: '1930', debitAmount: 0, creditAmount: 300 },
],
})
const txId = await insertTransaction({
companyId,
userId,
amount: -800,
date: '2026-06-11',
cashAccountId,
journalEntryId: null,
})
await linkThroughJunction({ userId, companyId, transactionId: txId, journalEntryId: utlagg1, amount: -500, role: 'bank_line' })
await linkThroughJunction({ userId, companyId, transactionId: txId, journalEntryId: utlagg2, amount: -300, role: 'bank_line' })
const unlinked = await getPool().query<{ journal_entry_id: string }>(
`SELECT journal_entry_id FROM public.get_unlinked_gl_lines($1, '1930', NULL, NULL)`,
[companyId],
)
const unlinkedIds = unlinked.rows.map((r) => r.journal_entry_id)
expect(unlinkedIds).toContain(controlEntry)
expect(unlinkedIds).not.toContain(utlagg1)
expect(unlinkedIds).not.toContain(utlagg2)
const candidates = await getPool().query<{ journal_entry_id: string }>(
`SELECT journal_entry_id FROM public.get_account_gl_lines_for_matching($1, '1930', NULL, NULL, false)`,
[companyId],
)
const candidateIds = candidates.rows.map((r) => r.journal_entry_id)
expect(candidateIds).toContain(controlEntry)
expect(candidateIds).not.toContain(utlagg1)
expect(candidateIds).not.toContain(utlagg2)
const withMatched = await getPool().query<{ journal_entry_id: string; linked_transaction_count: number }>(
`SELECT journal_entry_id, linked_transaction_count
FROM public.get_account_gl_lines_for_matching($1, '1930', NULL, NULL, true)`,
[companyId],
)
expect(withMatched.rows.find((r) => r.journal_entry_id === utlagg1)?.linked_transaction_count).toBe(1)
expect(withMatched.rows.find((r) => r.journal_entry_id === utlagg2)?.linked_transaction_count).toBe(1)
expect(withMatched.rows.find((r) => r.journal_entry_id === controlEntry)?.linked_transaction_count).toBe(0)
// The slices explain the whole row, and the canonical predicate agrees.
const slices = await getPool().query<{ total: string }>(
`SELECT sum(allocated_amount)::text AS total
FROM public.transaction_voucher_links
WHERE transaction_id = $1 AND role = 'bank_line'`,
[txId],
)
expect(Number(slices.rows[0].total)).toBe(-800)
const booked = await getPool().query<{ booked: boolean }>(
`SELECT public.is_transaction_booked($1) AS booked`,
[txId],
)
expect(booked.rows[0].booked).toBe(true)
})
})