feat(reconciliation): manual N:1 matching: two-pane worksheet + group links (#1851)

'Matcha manuellt' as designed: outside rows on the left (multi-select),
verifikat without an outside row on the right (single-select), the
selection's arithmetic in the footer, and one Koppla that is enabled only
when the difference is 0. Mode lives in the URL (?mode=match).

Engine: a pair is now one OR MANY outside rows against one verifikat.
Bank groups link per transaction (manualLink allows N:1 by design, so
partial success is reported per row). Skattekonto groups go through the
new linkSkattekontoRows: the verifikat's 1630 side must settle the sum,
one guarded UPDATE links the whole group, and a partial hit is rolled
back as LINK_RACE. 1:M stays UNSUPPORTED_PAIR_SHAPE until the residual
link table (6c). v1 pitfalls + API skill regenerated.

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:
Jakob Wennberg
2026-08-24 16:24:07 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 4d6dd68df4
commit 34ad1b1936
11 changed files with 633 additions and 42 deletions
+1
View File
@@ -1186,4 +1186,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-24] Inline matcher (PR 6a) applies through the existing /api/reconciliation/bank/run intersection guard rather than new endpoints: the page renders the dry-run pairs and applies per row or strong-only (floor 0.85, re-enforced server-side), so a stale preview can never link a pair the fresh run would not. The old bank view keeps living until 6b (manual N:M + residual booking) reaches parity; only its matcher trips became unnecessary today.
[2026-08-24] vat_amount (categorize/bulk_book) is transaction-currency, converted to SEK at booking: the validation bound already read it in transaction currency (the underlag's denomination), and the gross line already converts through resolveSekAmount, so converting the VAT the same way was the only coherent option. Documenting it as SEK instead (the reporter's first suggestion, feedback seq 254607) would force agents to pre-convert with a settlement rate they cannot see.
[2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead.
[2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise.
[2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB.
@@ -61,7 +61,7 @@ registerEndpoint({
doNotUseFor:
'Booking new verifikat for rows that have no counterpart (use the transactions or skattekonto booking endpoints); reconciling across accounts.',
pitfalls: [
'This version links one outside row to one verifikat per pair; other shapes come back as UNSUPPORTED_PAIR_SHAPE, 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). One row against several verifikat is UNSUPPORTED_PAIR_SHAPE until residual booking lands, 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.',
@@ -0,0 +1,282 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { AttnLine } from '@/components/ui/attn-line'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { roundOre } from '@/lib/money'
import type { ReconciliationAccount, ReconciliationItem } from '@/lib/reconciliation/schemas'
import type { ReconciliationWindow } from './AccountOverview'
/**
* "Matcha manuellt": the two-pane worksheet from the approved design. Left:
* outside rows with no verifikat (bank transactions or skattekonto rows),
* multi-select. Right: verifikat on the account with no outside row,
* single-select. The footer sums both sides; Koppla is enabled only when the
* selection nets to zero, because the engine links N rows to ONE verifikat
* and (for the skattekonto) refuses a group whose sum the verifikat does not
* settle. A non-zero difference is shown, not hidden: booking the residual
* in the same gesture is the next step (6c), until then it says so.
*/
interface ManualMatchModeProps {
account: ReconciliationAccount
window: ReconciliationWindow
onChanged: () => void
}
const LIMIT = 200
export function ManualMatchMode({ account, window, onChanged }: ManualMatchModeProps) {
const t = useTranslations('reconciliation')
const { toast } = useToast()
const [external, setExternal] = useState<ReconciliationItem[] | null>(null)
const [ledger, setLedger] = useState<ReconciliationItem[] | null>(null)
const [loadError, setLoadError] = useState(false)
const [pickedExternal, setPickedExternal] = useState<Set<string>>(new Set())
const [pickedEntry, setPickedEntry] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const base = `/api/reconciliation/accounts/${encodeURIComponent(account.account_key)}`
const isSkv = account.kind === 'skattekonto'
const currency = account.currency
const load = useCallback(async () => {
try {
const qs = new URLSearchParams({ date_from: window.from, date_to: window.to, limit: String(LIMIT) })
const [extRes, ledRes] = await Promise.all([
fetch(`${base}/items?bucket=unmatched_external&${qs.toString()}`),
fetch(`${base}/items?bucket=unmatched_ledger&${qs.toString()}`),
])
setLoadError(false)
if (!extRes.ok || !ledRes.ok) {
setLoadError(true)
return
}
const ext = (await extRes.json()).data as { items: ReconciliationItem[] }
const led = (await ledRes.json()).data as { items: ReconciliationItem[] }
setExternal(ext.items)
setLedger(led.items)
} catch {
setLoadError(true)
}
}, [base, window.from, window.to])
useEffect(() => {
void load()
}, [load])
const externalSum = useMemo(
() => roundOre((external ?? []).filter((i) => pickedExternal.has(i.item_id)).reduce((s, i) => s + i.amount, 0)),
[external, pickedExternal],
)
const entry = useMemo(() => (ledger ?? []).find((i) => i.item_id === pickedEntry) ?? null, [ledger, pickedEntry])
const ledgerSum = entry ? roundOre(entry.amount) : 0
const difference = roundOre(externalSum - ledgerSum)
const canLink = pickedExternal.size > 0 && entry !== null && Math.abs(difference) < 0.005 && !busy
function toggleExternal(id: string) {
setPickedExternal((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function link() {
if (!entry || pickedExternal.size === 0) return
setBusy(true)
try {
const res = await fetch(`${base}/links`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pairs: [{ external_ids: [...pickedExternal], journal_entry_ids: [entry.item_id] }],
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
toast({ title: t('toast_failed'), description: getUserErrorMessage(json, { statusCode: res.status }), variant: 'destructive' })
return
}
const applied = (json.data?.applied as unknown[] | undefined)?.length ?? 0
const skipped = (json.data?.skipped as Array<{ message: string }> | undefined) ?? []
if (applied === 0 && skipped.length > 0) {
toast({ title: t('toast_failed'), description: skipped[0].message, variant: 'destructive' })
} else {
toast({
title: skipped.length > 0 ? t('toast_matched_skipped', { applied, skipped: skipped.length }) : t('toast_matched', { applied }),
})
}
setPickedExternal(new Set())
setPickedEntry(null)
await load()
onChanged()
} finally {
setBusy(false)
}
}
if (loadError) {
return (
<AttnLine action={{ label: t('older_show'), onClick: () => void load() }}>{t('load_failed')}</AttnLine>
)
}
if (!external || !ledger) {
return (
<div className="grid gap-6 lg:grid-cols-2" aria-busy>
<Skeleton className="h-64 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
return (
<div className="space-y-4">
<div className="grid gap-6 lg:grid-cols-2">
{/* Left: outside rows, multi-select. */}
<section aria-label={t(isSkv ? 'bucket_unmatched_external_skv' : 'bucket_unmatched_external_bank')}>
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">
{t(isSkv ? 'bucket_unmatched_external_skv' : 'bucket_unmatched_external_bank')}
<span className="ml-1.5 font-normal tabular-nums text-muted-foreground/70">{external.length}</span>
</h2>
{external.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('match_empty_left')}</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-8 px-2')} />
<th className={cn(TH_CLASS, 'w-[96px]')}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_event')}</th>
<th className={cn(TH_CLASS, 'w-[120px] text-right')}>{t('col_amount')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{external.map((item) => {
const picked = pickedExternal.has(item.item_id)
return (
<tr
key={item.item_id}
onClick={() => toggleExternal(item.item_id)}
className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')}
>
<td className={cn(TD_CLASS, 'px-2')}>
<Checkbox
checked={picked}
onCheckedChange={() => toggleExternal(item.item_id)}
onClick={(e) => e.stopPropagation()}
aria-label={item.description}
/>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>{formatDate(item.date)}</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="block truncate" data-ph-mask title={item.description}>{item.description}</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{formatCurrency(item.amount, currency)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</section>
{/* Right: verifikat without an outside row, single-select. */}
<section aria-label={t(isSkv ? 'bucket_unmatched_ledger_skv' : 'bucket_unmatched_ledger_bank')}>
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">
{t(isSkv ? 'bucket_unmatched_ledger_skv' : 'bucket_unmatched_ledger_bank')}
<span className="ml-1.5 font-normal tabular-nums text-muted-foreground/70">{ledger.length}</span>
</h2>
{ledger.length === 0 ? (
<p className="text-[13px] text-muted-foreground">{t('match_empty_right')}</p>
) : (
<table className="w-full text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'w-8 px-2')} />
<th className={cn(TH_CLASS, 'w-[96px]')}>{t('col_date')}</th>
<th className={cn(TH_CLASS, 'w-[90px]')}>{t('col_voucher')}</th>
<th className={TH_CLASS}>{t('col_event')}</th>
<th className={cn(TH_CLASS, 'w-[120px] text-right')}>{t('col_amount')}</th>
</tr>
</thead>
<tbody className="stagger-enter">
{ledger.map((item) => {
const picked = pickedEntry === item.item_id
return (
<tr
key={item.item_id}
onClick={() => setPickedEntry(picked ? null : item.item_id)}
className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')}
>
<td className={cn(TD_CLASS, 'px-2')}>
<input
type="radio"
name="manual-match-entry"
checked={picked}
onChange={() => setPickedEntry(item.item_id)}
onClick={(e) => e.stopPropagation()}
aria-label={item.description}
className="h-3.5 w-3.5 accent-foreground"
/>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>{formatDate(item.date)}</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums')} data-ph-mask>
{item.voucher_number != null
? formatVoucher({ voucher_series: item.voucher_series, voucher_number: item.voucher_number })
: item.item_id.slice(0, 8)}
</td>
<td className={cn(TD_CLASS, 'max-w-0')}>
<span className="block truncate" data-ph-mask title={item.description}>{item.description}</span>
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
{formatCurrency(item.amount, currency)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</section>
</div>
{/* Footer: the arithmetic of the selection, and the one button. */}
<div className="sticky bottom-0 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-border bg-background py-3 text-[13px]">
<span className="tabular-nums" data-ph-mask>
{t('match_selected_external', { count: pickedExternal.size, amount: formatCurrency(externalSum, currency) })}
</span>
<span className="tabular-nums" data-ph-mask>
{entry
? t('match_selected_entry', { amount: formatCurrency(ledgerSum, currency) })
: t('match_no_entry')}
</span>
<span
className={cn('tabular-nums', Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry ? 'text-warning' : 'text-muted-foreground')}
data-ph-mask
>
{t('match_difference', { amount: formatCurrency(difference, currency) })}
</span>
{Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && (
<span className="text-[12.5px] text-muted-foreground">{t('match_hint_residual')}</span>
)}
<span className="ml-auto">
<Button size="sm" onClick={() => void link()} disabled={!canLink} aria-busy={busy}>
{t('match_apply', { count: pickedExternal.size })}
</Button>
</span>
</div>
</div>
)
}
@@ -11,10 +11,12 @@ import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { FyPicker } from '@/components/common/FyPicker'
import { ReportDateRange, type DateRangeValue } from '@/components/common/ReportDateRange'
import { SegmentedControl } from '@/components/ui/segmented-control'
import type { ReconciliationAccount } from '@/lib/reconciliation/schemas'
import type { FiscalPeriod } from '@/types'
import { ReconciliationRail } from './ReconciliationRail'
import { AccountOverview, type ReconciliationWindow } from './AccountOverview'
import { ManualMatchMode } from './ManualMatchMode'
/**
* /reconciliation: one page for every account with an outside truth. The
@@ -80,6 +82,16 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re
}, [load])
const requestedKey = searchParams.get('account')
const mode: 'overview' | 'match' = searchParams.get('mode') === 'match' ? 'match' : 'overview'
const setMode = useCallback(
(next: 'overview' | 'match') => {
const params = new URLSearchParams(searchParams.toString())
if (next === 'match') params.set('mode', 'match')
else params.delete('mode')
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
},
[pathname, router, searchParams],
)
const selected = useMemo(() => {
if (!accounts || accounts.length === 0) return null
return (
@@ -108,6 +120,15 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re
}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
<SegmentedControl
value={mode}
onChange={setMode}
aria-label={t('title')}
options={[
{ value: 'overview', label: t('mode_overview') },
{ value: 'match', label: t('mode_match') },
]}
/>
<FyPicker
value={periodId}
onChange={(id, period) => {
@@ -180,7 +201,15 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re
return (
<div className="space-y-6">
{header}
{selected && (
{selected && mode === 'match' && (
<div className="grid gap-8 lg:grid-cols-[220px_1fr]">
<ReconciliationRail accounts={accounts} selectedKey={selected.account_key} onSelect={select} />
<div className="min-w-0">
<ManualMatchMode key={selected.account_key} account={selected} window={window} onChanged={() => void load()} />
</div>
</div>
)}
{selected && mode === 'overview' && (
<AccountOverview
key={selected.account_key}
account={selected}
+42 -4
View File
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const linkMock = vi.fn()
const linkGroupMock = vi.fn()
const unlinkMock = vi.fn()
const setIgnoredMock = vi.fn()
const manualLinkMock = vi.fn()
@@ -14,6 +15,7 @@ vi.mock('@/lib/skatteverket/skattekonto-link', async (importOriginal) => {
return {
...actual,
linkSkattekontoRow: (...args: unknown[]) => linkMock(...args),
linkSkattekontoRows: (...args: unknown[]) => linkGroupMock(...args),
unlinkSkattekontoRow: (...args: unknown[]) => unlinkMock(...args),
setSkattekontoRowIgnored: (...args: unknown[]) => setIgnoredMock(...args),
}
@@ -42,6 +44,7 @@ describe('matchPairs', () => {
beforeEach(() => {
vi.clearAllMocks()
linkMock.mockReset()
linkGroupMock.mockReset()
manualLinkMock.mockReset()
skvStatusMock.mockReset()
emitMock.mockResolvedValue(undefined)
@@ -53,30 +56,65 @@ describe('matchPairs', () => {
expect(await matchPairs(supabase as never, COMPANY, USER, 'manual:1910', { pairs: [] })).toBeNull()
})
it('links skattekonto pairs one by one, reports skips with codes, and emits one event per link', async () => {
it('links skattekonto pairs one by one, groups N:1 through the sum-checked helper, and emits one event per row', async () => {
const { supabase } = createQueuedMockSupabase()
linkMock
.mockResolvedValueOnce({ skattekonto_transaction_id: R1, journal_entry_id: E1, via: 'line' })
.mockRejectedValueOnce(new SkattekontoLinkError('redan kopplat', 'ENTRY_ALREADY_LINKED'))
linkGroupMock.mockResolvedValueOnce({
journal_entry_id: E2,
via: 'entry_total',
skattekonto_transaction_ids: [R1, R2],
})
const result = await matchPairs(supabase as never, COMPANY, USER, 'skattekonto', {
pairs: [
{ external_ids: [R1], journal_entry_ids: [E1] },
{ external_ids: [R2], journal_entry_ids: [E1] },
{ external_ids: [R1, R2], journal_entry_ids: [E2] },
// 1:M stays refused until the residual link table exists.
{ external_ids: [R1], journal_entry_ids: [E1, E2] },
],
})
expect(result).toMatchObject({ dry_run: false, considered: 3 })
expect(result?.applied).toEqual([{ external_id: R1, journal_entry_id: E1, via: 'line' }])
expect(result).toMatchObject({ dry_run: false, considered: 4 })
expect(result?.applied).toEqual([
{ external_id: R1, journal_entry_id: E1, via: 'line' },
{ external_id: R1, journal_entry_id: E2, via: 'entry_total' },
{ external_id: R2, journal_entry_id: E2, via: 'entry_total' },
])
expect(result?.skipped.map((s) => s.code)).toEqual(['ALREADY_LINKED', 'UNSUPPORTED_PAIR_SHAPE'])
expect(emitMock).toHaveBeenCalledTimes(1)
expect(linkGroupMock).toHaveBeenCalledWith(supabase, COMPANY, [R1, R2], E2)
expect(emitMock).toHaveBeenCalledTimes(3)
expect(emitMock.mock.calls[0][0]).toMatchObject({
type: 'reconciliation.matched',
payload: { accountKey: 'skattekonto', externalId: R1, journalEntryId: E1, method: 'manual' },
})
})
it('links a bank N:1 group per transaction and reports partial failures per row', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ledger_account: '1930' } }) // cash_accounts lookup
manualLinkMock
.mockResolvedValueOnce({ success: true })
.mockResolvedValueOnce({ success: false, error: 'Transaktionen är redan kopplad till en verifikation.' })
const result = await matchPairs(supabase as never, COMPANY, USER, `bank:${CASH}`, {
pairs: [{ external_ids: [R1, R2], journal_entry_ids: [E1] }],
})
expect(result).toMatchObject({ dry_run: false, considered: 1 })
expect(result?.applied).toEqual([{ external_id: R1, journal_entry_id: E1 }])
expect(result?.skipped).toEqual([
{
pair: { external_ids: [R2], journal_entry_ids: [E1] },
code: 'PAIR_NOT_CLOSED',
message: 'Transaktionen är redan kopplad till en verifikation.',
},
])
expect(emitMock).toHaveBeenCalledTimes(1)
})
it('dry run resolves proposals into pairs without writing', async () => {
const { supabase } = createQueuedMockSupabase()
skvStatusMock.mockResolvedValue({
+72 -33
View File
@@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import {
linkSkattekontoRow,
linkSkattekontoRows,
setSkattekontoRowIgnored,
SkattekontoLinkError,
unlinkSkattekontoRow,
@@ -115,10 +116,11 @@ async function proposalsAsPairs(
}
/**
* Link pairs on one account. Today each pair is one outside row and one
* verifikat (the N:M worksheet selection arrives with the manual-match mode);
* other shapes are reported as UNSUPPORTED_PAIR_SHAPE, never silently
* reduced. Dry run validates shapes and resolves proposals without writing.
* Link pairs on one account. A pair is one OR MANY outside rows against
* exactly one verifikat (bank: independent links per transaction; skattekonto:
* all-or-nothing with the sum settling the verifikat). One row against many
* verifikat waits for the residual link table and is reported as
* UNSUPPORTED_PAIR_SHAPE, never silently reduced. Dry run validates shapes and resolves proposals without writing.
* Partial success is first-class: `applied` and `skipped` together cover
* every considered pair.
*/
@@ -144,27 +146,65 @@ export async function matchPairs(
const applied: AppliedLink[] = []
const skipped: SkippedPair[] = []
const emitMatched = async (externalId: string, journalEntryId: string) => {
await eventBus.emit({
type: 'reconciliation.matched',
payload: {
accountKey,
externalId,
journalEntryId,
method: input.use_proposals ? 'proposal' : 'manual',
userId,
companyId,
},
})
}
for (const pair of pairs) {
if (pair.external_ids.length !== 1 || pair.journal_entry_ids.length !== 1) {
// N outside rows may settle ONE verifikat (the worksheet selection); the
// reverse shape (one row over several verifikat) waits for the residual
// link table and is refused loudly, never silently reduced.
if (pair.journal_entry_ids.length !== 1) {
skipped.push({
pair,
code: 'UNSUPPORTED_PAIR_SHAPE',
message: 'Ett par är en händelse och ett verifikat i den här versionen.',
message: 'Flera verifikat i samma par stöds inte ännu: ett par är en eller flera händelser mot ett verifikat.',
})
continue
}
const externalIds = [...new Set(pair.external_ids)]
if (externalIds.length === 0 || externalIds.length > 50) {
skipped.push({
pair,
code: 'UNSUPPORTED_PAIR_SHAPE',
message: 'Ett par kopplar mellan 1 och 50 händelser mot ett verifikat.',
})
continue
}
const [externalId] = pair.external_ids
const [journalEntryId] = pair.journal_entry_ids
if (dryRun) {
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
for (const externalId of externalIds) {
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
}
continue
}
try {
if (parsed.kind === 'skattekonto') {
const r = await linkSkattekontoRow(supabase, companyId, externalId, journalEntryId)
applied.push({ external_id: externalId, journal_entry_id: journalEntryId, via: r.via })
if (externalIds.length === 1) {
const r = await linkSkattekontoRow(supabase, companyId, externalIds[0], journalEntryId)
applied.push({ external_id: externalIds[0], journal_entry_id: journalEntryId, via: r.via })
await emitMatched(externalIds[0], journalEntryId)
} else {
// All-or-nothing: the group's sum must settle the verifikat, and a
// lost race rolls the whole group back inside the link helper.
const r = await linkSkattekontoRows(supabase, companyId, externalIds, journalEntryId)
for (const externalId of r.skattekonto_transaction_ids) {
applied.push({ external_id: externalId, journal_entry_id: journalEntryId, via: r.via })
await emitMatched(externalId, journalEntryId)
}
}
} else {
const { data: account } = await supabase
.from('cash_accounts')
@@ -172,31 +212,30 @@ export async function matchPairs(
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle<{ ledger_account: string }>()
const r = await manualLink(
supabase,
companyId,
externalId,
journalEntryId,
userId,
account?.ledger_account ?? '1930',
)
if (!r.success) {
skipped.push({ pair, code: 'PAIR_NOT_CLOSED', message: r.error ?? 'Kunde inte koppla' })
continue
// Bank N:1 is per-transaction by design (manualLink documents why the
// engine allows several transactions on one verifikat): each link is
// independent, so partial success is reported per transaction.
for (const externalId of externalIds) {
const r = await manualLink(
supabase,
companyId,
externalId,
journalEntryId,
userId,
account?.ledger_account ?? '1930',
)
if (!r.success) {
skipped.push({
pair: { external_ids: [externalId], journal_entry_ids: [journalEntryId] },
code: 'PAIR_NOT_CLOSED',
message: r.error ?? 'Kunde inte koppla',
})
continue
}
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
await emitMatched(externalId, journalEntryId)
}
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
}
await eventBus.emit({
type: 'reconciliation.matched',
payload: {
accountKey,
externalId,
journalEntryId,
method: input.use_proposals ? 'proposal' : 'manual',
userId,
companyId,
},
})
} catch (err) {
const { code, message } = skipCodeFor(err)
skipped.push({ pair, code, message })
@@ -3,6 +3,7 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
import {
entrySettlesAmount,
linkSkattekontoRow,
linkSkattekontoRows,
setSkattekontoRowIgnored,
SkattekontoLinkError,
unlinkSkattekontoRow,
@@ -134,3 +135,70 @@ describe('unlinkSkattekontoRow / setSkattekontoRowIgnored', () => {
})
})
})
describe('linkSkattekontoRows (N:1)', () => {
const ROW2 = 'row-2'
const { supabase, enqueue, reset, findCalls } = createQueuedMockSupabase()
beforeEach(() => {
vi.clearAllMocks()
reset()
})
it('links a group whose sum the verifikat settles, with one guarded update over all rows', async () => {
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
enqueue({ data: entry([{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }]) })
enqueue({ data: [] }) // nothing outside the group linked to the entry
enqueue({ data: [{ id: ROW }, { id: ROW2 }] })
const result = await linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2, ROW], ENTRY)
expect(result).toEqual({ journal_entry_id: ENTRY, via: 'line', skattekonto_transaction_ids: [ROW, ROW2] })
const updates = findCalls('skattekonto_transactions', 'update')
expect(updates).toHaveLength(1)
expect(updates[0][0]).toMatchObject({ journal_entry_id: ENTRY, suggested_journal_entry_id: null })
})
it('refuses a group where a row is already linked, ignored or upcoming', async () => {
enqueue({ data: [row(), row({ id: ROW2, journal_entry_id: 'other' })] })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'ALREADY_BOOKED' })
reset()
enqueue({ data: [row(), row({ id: ROW2, is_ignored: true })] })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'ROW_IGNORED' })
reset()
enqueue({ data: [row(), row({ id: ROW2, status: 'upcoming' })] })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
})
it('refuses when the sum does not settle the verifikat or nets to zero', async () => {
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
enqueue({ data: entry([{ account_number: '1630', debit_amount: 4999, credit_amount: 0 }]) })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
reset()
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: -3000 })] })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'INVALID_CANDIDATE' })
})
it('refuses a verifikat already linked to a row outside the group', async () => {
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
enqueue({ data: entry([{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }]) })
enqueue({ data: [{ id: 'row-elsewhere' }] })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'ENTRY_ALREADY_LINKED' })
})
it('rolls a partial hit back and reports LINK_RACE', async () => {
enqueue({ data: [row({ belopp_skatteverket: 3000 }), row({ id: ROW2, belopp_skatteverket: 2000 })] })
enqueue({ data: entry([{ account_number: '1630', debit_amount: 5000, credit_amount: 0 }]) })
enqueue({ data: [] })
enqueue({ data: [{ id: ROW }] }) // only one of two rows was still free
enqueue({ data: null }) // the revert
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'LINK_RACE' })
const updates = findCalls('skattekonto_transactions', 'update')
expect(updates).toHaveLength(2)
expect(updates[1][0]).toEqual({ journal_entry_id: null })
})
it('refuses a missing row and an empty selection', async () => {
enqueue({ data: [row()] })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [ROW, ROW2], ENTRY)).rejects.toMatchObject({ code: 'TRANSACTION_NOT_FOUND' })
await expect(linkSkattekontoRows(supabase as never, COMPANY, [], ENTRY)).rejects.toBeInstanceOf(SkattekontoLinkError)
})
})
+114
View File
@@ -238,3 +238,117 @@ export async function setSkattekontoRowIgnored(
}
return { skattekonto_transaction_id: transactionId, is_ignored: ignored }
}
export interface LinkSkattekontoRowsResult {
journal_entry_id: string
via: 'line' | 'entry_total'
skattekonto_transaction_ids: string[]
}
/**
* Link SEVERAL open SKV rows to ONE verifikat: the N:1 worksheet selection
* (one AGI verifikat settling the avdragen skatt + arbetsgivaravgift rows,
* one payment verifikat covering a row pair). The verifikat's 1630 side must
* settle the SUM of the rows; each row then gets the same guarded pointer as
* the single link. The write is ONE guarded UPDATE over the whole group: a
* concurrent link shrinks the hit set, and a partial hit is rolled back and
* reported as LINK_RACE, so a group is never left half-linked.
*/
export async function linkSkattekontoRows(
supabase: SupabaseClient,
companyId: string,
transactionIds: string[],
journalEntryId: string,
): Promise<LinkSkattekontoRowsResult> {
const ids = [...new Set(transactionIds)]
if (ids.length === 0 || ids.length > 50) {
throw new SkattekontoLinkError('Välj mellan 1 och 50 händelser.', 'INVALID_CANDIDATE')
}
const { data: rows, error: rowsError } = await supabase
.from('skattekonto_transactions')
.select('id, belopp_skatteverket, journal_entry_id, is_ignored, status')
.eq('company_id', companyId)
.in('id', ids)
if (rowsError || !rows || rows.length !== ids.length) {
throw new SkattekontoLinkError('Någon av skattekonto-transaktionerna hittades inte.', 'TRANSACTION_NOT_FOUND')
}
const typed = rows as RowForLink[]
if (typed.some((r) => r.journal_entry_id)) {
throw new SkattekontoLinkError('En av transaktionerna är redan kopplad till ett verifikat.', 'ALREADY_BOOKED')
}
if (typed.some((r) => r.is_ignored)) {
throw new SkattekontoLinkError('En av transaktionerna är ignorerad. Återställ den innan du kopplar.', 'ROW_IGNORED')
}
if (typed.some((r) => r.status !== 'booked')) {
throw new SkattekontoLinkError('En kommande händelse kan inte kopplas ännu.', 'INVALID_CANDIDATE')
}
const sum = roundOre(typed.reduce((s, r) => s + Number(r.belopp_skatteverket), 0))
if (sum === 0) {
throw new SkattekontoLinkError(
'De valda händelserna nettar till 0 och kan inte kopplas mot ett verifikat.',
'INVALID_CANDIDATE',
)
}
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, status, lines:journal_entry_lines ( account_number, debit_amount, credit_amount )')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle<EntryForLink>()
if (entryError || !entry) {
throw new SkattekontoLinkError('Verifikatet hittades inte.', 'ENTRY_NOT_FOUND')
}
if (entry.status === 'reversed') {
throw new SkattekontoLinkError('Verifikatet är makulerat och kan inte kopplas.', 'INVALID_CANDIDATE')
}
const settles = entrySettlesAmount(entry.lines, sum)
if (!settles.ok || !settles.via) {
throw new SkattekontoLinkError(
'Verifikatets rader på 1630 motsvarar inte summan av de valda händelserna.',
'INVALID_CANDIDATE',
)
}
const groupSet = new Set(ids)
const { data: linkedRows } = await supabase
.from('skattekonto_transactions')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', journalEntryId)
if ((linkedRows ?? []).some((r) => !groupSet.has((r as { id: string }).id))) {
throw new SkattekontoLinkError(
'Verifikatet är redan kopplat till en annan skattekonto-transaktion.',
'ENTRY_ALREADY_LINKED',
)
}
const { data: updated, error: updateError } = await supabase
.from('skattekonto_transactions')
.update({ journal_entry_id: journalEntryId, suggested_journal_entry_id: null, suggested_at: null })
.eq('company_id', companyId)
.in('id', ids)
.is('journal_entry_id', null)
.eq('is_ignored', false)
.select('id')
if (updateError) {
throw new SkattekontoLinkError(`Kunde inte koppla: ${updateError.message}`, 'LINK_RACE')
}
const updatedIds = ((updated ?? []) as Array<{ id: string }>).map((r) => r.id)
if (updatedIds.length !== ids.length) {
// Roll the partial hit back: the group's sum no longer settles the entry.
if (updatedIds.length > 0) {
await supabase
.from('skattekonto_transactions')
.update({ journal_entry_id: null })
.eq('company_id', companyId)
.in('id', updatedIds)
.eq('journal_entry_id', journalEntryId)
}
throw new SkattekontoLinkError('En av transaktionerna kopplades av någon annan samtidigt.', 'LINK_RACE')
}
return { journal_entry_id: journalEntryId, via: settles.via, skattekonto_transaction_ids: ids }
}
+11 -1
View File
@@ -7896,7 +7896,17 @@
"matcher_none": "No new matches found.",
"matcher_apply_strong": "Link {count} strong",
"matcher_strong": "Strong",
"matcher_close": "Close"
"matcher_close": "Close",
"mode_overview": "Overview",
"mode_match": "Match manually",
"match_empty_left": "No events without a voucher in the period.",
"match_empty_right": "No vouchers without an event in the period.",
"match_selected_external": "{count} events selected: {amount}",
"match_selected_entry": "Voucher: {amount}",
"match_no_entry": "No voucher selected",
"match_difference": "Difference {amount}",
"match_hint_residual": "The difference must be 0 to link. Book the remainder first, then link.",
"match_apply": "Link {count} events"
},
"skattekonto": {
"help_text": "The balance and events are fetched from Skatteverket and synced automatically every night. Completed events are booked against 1630 Skattekonto, usually automatically; anything that cannot be matched is flagged in the list. Pay in via bankgiro 5050-1055 with your OCR number.",
+11 -1
View File
@@ -7896,7 +7896,17 @@
"matcher_none": "Inga nya matchningar hittades.",
"matcher_apply_strong": "Koppla {count} starka",
"matcher_strong": "Stark",
"matcher_close": "Stäng"
"matcher_close": "Stäng",
"mode_overview": "Översikt",
"mode_match": "Matcha manuellt",
"match_empty_left": "Inga händelser utan verifikat i perioden.",
"match_empty_right": "Inga verifikat utan händelse i perioden.",
"match_selected_external": "{count} händelser valda: {amount}",
"match_selected_entry": "Verifikat: {amount}",
"match_no_entry": "Inget verifikat valt",
"match_difference": "Differens {amount}",
"match_hint_residual": "Differensen måste vara 0 för att koppla. Bokför mellanskillnaden först, sedan kopplar du.",
"match_apply": "Koppla {count} händelser"
},
"skattekonto": {
"help_text": "Saldot och händelserna hämtas från Skatteverket och synkas automatiskt varje natt. Genomförda händelser bokförs mot 1630 Skattekonto, oftast automatiskt; det som inte kan matchas flaggas i listan. Betala in via bankgiro 5050-1055 med ditt OCR-nummer.",
+1 -1
View File
@@ -272,7 +272,7 @@ Body: { pairs: [{ external_ids: [id], journal_entry_ids: [id] }] } and/or { use_
**Do not use for:** Booking new verifikat for rows that have no counterpart (use the transactions or skattekonto booking endpoints); reconciling across accounts.
**Pitfalls:**
- This version links one outside row to one verifikat per pair; other shapes come back as UNSUPPORTED_PAIR_SHAPE, 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). One row against several verifikat is UNSUPPORTED_PAIR_SHAPE until residual booking lands, 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.