feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2359)

* feat(reconciliation): propose the explaining voucher set for a bank row before offering Bokför (#2293)

The bridge table said "ej matchad" and steered to Bokför when a Bankgirot
aggregate was already booked as two or three unlinked vouchers. The booking
doors have refused that double booking since #2300 and #2346 with
detectExplainingVoucherSet; the view never ran it.

- duplicate-payment-detection: split the set detector into fetch and pure
  steps and add detectExplainingVoucherSets, the batch form (one ledger
  scan, one anchor lookup, per-row verdict identical to the single
  detector; a voucher explains at most one row per call). ExplainingVoucher
  now also carries voucher_series and voucher_number.
- reconciliation/covering-set-candidate (new): maps sets to proposals
  (0.95 same date, 0.85 within seven days), SEK accounts only, fails open.
- items: open bank rows nothing explains 1:1 are searched before they land
  in unmatched_external; a hit lands in proposed with proposal.vouchers.
- schemas: ReconciliationProposal.vouchers (optional, set proposals only).
- AccountOverview: "= A57 + A58" with the legs' amounts, one Koppla that
  posts every voucher as a 1:N pair to the existing links route.
- i18n: reconciliation.proposal_set_title and proposal_set_same_day (sv, en).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

* chore(skill): regenerate accounted-api for ReconciliationProposal.vouchers (#2293)

The set proposal field added to the reconciliation items response shape
flows into the generated agent skill; regenerated with
`npm run apiskill:generate`, which changes one line of references/banking.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:03:42 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent fcda4a75dd
commit 6776cb4fc6
12 changed files with 1055 additions and 163 deletions
+43 -1
View File
@@ -191,8 +191,13 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
if (!item.proposal) return
setBusy(item.item_id)
try {
// A set proposal (#2293) links the row to every verifikat in it (1:N,
// all or nothing, slices revalidated server-side); a 1:1 proposal to one.
const journalEntryIds = item.proposal.vouchers?.map((v) => v.journal_entry_id) ?? [
item.proposal.journal_entry_id,
]
const data = await postJson(`${base}/links`, {
pairs: [{ external_ids: [item.item_id], journal_entry_ids: [item.proposal.journal_entry_id] }],
pairs: [{ external_ids: [item.item_id], journal_entry_ids: journalEntryIds }],
})
if (data) {
const skipped = data.skipped as Array<{ message: string }>
@@ -918,6 +923,43 @@ function ItemRow({
{item.awaiting_external && <Chip>{t('chip_awaiting', { source: sourceLabel })}</Chip>}
</span>
)
} else if (item.proposal && item.proposal.vouchers && item.proposal.vouchers.length > 1) {
// An explaining set (#2293): "= A57 + A58", the legs' amounts underneath,
// one Koppla that links the row to all of them.
const p = item.proposal
const setVouchers = p.vouchers ?? []
const sameDay = setVouchers.every((v) => v.entry_date === item.date)
voucherCell = (
<span className="flex flex-col gap-0.5">
<span
className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5"
title={t('proposal_set_title', { count: setVouchers.length })}
>
<span className="text-muted-foreground" aria-hidden>
=
</span>
{setVouchers.map((v, i) => (
<Fragment key={v.journal_entry_id}>
{i > 0 && (
<span className="text-muted-foreground" aria-hidden>
+
</span>
)}
<Link href={`/bookkeeping/${v.journal_entry_id}`} className={QUIET_LINK_CLASS} data-ph-mask>
{voucherOf(v) ?? v.journal_entry_id.slice(0, 8)}
</Link>
</Fragment>
))}
<span className="text-[11px] text-muted-foreground">
{t('confidence', { percent: Math.round(p.confidence * 100) })}
</span>
</span>
<span className="truncate text-[12px] tabular-nums text-muted-foreground" data-ph-mask>
{setVouchers.map((v) => formatCurrency(v.amount, currency)).join(' + ')}
{sameDay ? ` · ${t('proposal_set_same_day')}` : ''}
</span>
</span>
)
} else if (item.proposal) {
const p = item.proposal
voucherCell = (