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
+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 }
}