From 34ad1b19365de5a15726de49ab3907fdc4c00107 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 24 Aug 2026 16:24:07 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 1 + .../accounts/[accountKey]/links/route.ts | 2 +- components/reconciliation/ManualMatchMode.tsx | 282 ++++++++++++++++++ .../ReconciliationWorkspace.tsx | 31 +- lib/reconciliation/__tests__/actions.test.ts | 46 ++- lib/reconciliation/actions.ts | 105 +++++-- .../__tests__/skattekonto-link.test.ts | 68 +++++ lib/skatteverket/skattekonto-link.ts | 114 +++++++ messages/en.json | 12 +- messages/sv.json | 12 +- skills/accounted-api/references/banking.md | 2 +- 11 files changed, 633 insertions(+), 42 deletions(-) create mode 100644 components/reconciliation/ManualMatchMode.tsx diff --git a/DECISIONS.md b/DECISIONS.md index ddb949e3..f933e7ab 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1186,4 +1186,5 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts index 907b17e0..7f8d8294 100644 --- a/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts +++ b/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/links/route.ts @@ -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.', diff --git a/components/reconciliation/ManualMatchMode.tsx b/components/reconciliation/ManualMatchMode.tsx new file mode 100644 index 00000000..af518554 --- /dev/null +++ b/components/reconciliation/ManualMatchMode.tsx @@ -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(null) + const [ledger, setLedger] = useState(null) + const [loadError, setLoadError] = useState(false) + const [pickedExternal, setPickedExternal] = useState>(new Set()) + const [pickedEntry, setPickedEntry] = useState(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 ( + void load() }}>{t('load_failed')} + ) + } + if (!external || !ledger) { + return ( +
+ + +
+ ) + } + + return ( +
+
+ {/* Left: outside rows, multi-select. */} +
+

+ {t(isSkv ? 'bucket_unmatched_external_skv' : 'bucket_unmatched_external_bank')} + {external.length} +

+ {external.length === 0 ? ( +

{t('match_empty_left')}

+ ) : ( + + + + + + + + + + {external.map((item) => { + const picked = pickedExternal.has(item.item_id) + return ( + toggleExternal(item.item_id)} + className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')} + > + + + + + + ) + })} + +
+ {t('col_date')}{t('col_event')}{t('col_amount')}
+ toggleExternal(item.item_id)} + onClick={(e) => e.stopPropagation()} + aria-label={item.description} + /> + {formatDate(item.date)} + {item.description} + + {formatCurrency(item.amount, currency)} +
+ )} +
+ + {/* Right: verifikat without an outside row, single-select. */} +
+

+ {t(isSkv ? 'bucket_unmatched_ledger_skv' : 'bucket_unmatched_ledger_bank')} + {ledger.length} +

+ {ledger.length === 0 ? ( +

{t('match_empty_right')}

+ ) : ( + + + + + + + + + + + {ledger.map((item) => { + const picked = pickedEntry === item.item_id + return ( + setPickedEntry(picked ? null : item.item_id)} + className={cn('cursor-pointer', picked ? 'bg-secondary/60' : 'hover:bg-muted/40')} + > + + + + + + + ) + })} + +
+ {t('col_date')}{t('col_voucher')}{t('col_event')}{t('col_amount')}
+ setPickedEntry(item.item_id)} + onClick={(e) => e.stopPropagation()} + aria-label={item.description} + className="h-3.5 w-3.5 accent-foreground" + /> + {formatDate(item.date)} + {item.voucher_number != null + ? formatVoucher({ voucher_series: item.voucher_series, voucher_number: item.voucher_number }) + : item.item_id.slice(0, 8)} + + {item.description} + + {formatCurrency(item.amount, currency)} +
+ )} +
+
+ + {/* Footer: the arithmetic of the selection, and the one button. */} +
+ + {t('match_selected_external', { count: pickedExternal.size, amount: formatCurrency(externalSum, currency) })} + + + {entry + ? t('match_selected_entry', { amount: formatCurrency(ledgerSum, currency) }) + : t('match_no_entry')} + + = 0.005 && pickedExternal.size > 0 && entry ? 'text-warning' : 'text-muted-foreground')} + data-ph-mask + > + {t('match_difference', { amount: formatCurrency(difference, currency) })} + + {Math.abs(difference) >= 0.005 && pickedExternal.size > 0 && entry && ( + {t('match_hint_residual')} + )} + + + +
+
+ ) +} diff --git a/components/reconciliation/ReconciliationWorkspace.tsx b/components/reconciliation/ReconciliationWorkspace.tsx index 55a10f8e..9c015936 100644 --- a/components/reconciliation/ReconciliationWorkspace.tsx +++ b/components/reconciliation/ReconciliationWorkspace.tsx @@ -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={
+ { @@ -180,7 +201,15 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re return (
{header} - {selected && ( + {selected && mode === 'match' && ( +
+ +
+ void load()} /> +
+
+ )} + {selected && mode === 'overview' && ( { 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({ diff --git a/lib/reconciliation/actions.ts b/lib/reconciliation/actions.ts index f15830c1..8dfe8ca4 100644 --- a/lib/reconciliation/actions.ts +++ b/lib/reconciliation/actions.ts @@ -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 }) diff --git a/lib/skatteverket/__tests__/skattekonto-link.test.ts b/lib/skatteverket/__tests__/skattekonto-link.test.ts index 317664b0..24b27b7c 100644 --- a/lib/skatteverket/__tests__/skattekonto-link.test.ts +++ b/lib/skatteverket/__tests__/skattekonto-link.test.ts @@ -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) + }) +}) diff --git a/lib/skatteverket/skattekonto-link.ts b/lib/skatteverket/skattekonto-link.ts index 72854dbe..d9deebfd 100644 --- a/lib/skatteverket/skattekonto-link.ts +++ b/lib/skatteverket/skattekonto-link.ts @@ -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 { + 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() + 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 } +} diff --git a/messages/en.json b/messages/en.json index 855f1f6d..1b31852b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/sv.json b/messages/sv.json index 10471e4a..3f7cc34b 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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.", diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index 3200d907..827b868b 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -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.