Files
accounted/lib/reconciliation/actions.ts
T
34ad1b1936 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>
2026-08-24 16:24:07 +02:00

331 lines
12 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import {
linkSkattekontoRow,
linkSkattekontoRows,
setSkattekontoRowIgnored,
SkattekontoLinkError,
unlinkSkattekontoRow,
} from '@/lib/skatteverket/skattekonto-link'
import { manualLink, unlinkReconciliation } from './bank-reconciliation'
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
import { parseAccountKey } from './schemas'
const log = createLogger('reconciliation/actions')
/**
* Write actions of the account-keyed reconciliation surface. Every door (page
* route, v1, MCP commit executor) calls these; none of them links on its own.
*
* Links never touch the ledger: they pair an outside row with an existing
* verifikat, so they are allowed in locked periods and reversible by
* unmatch. Bookings (residual postings) are a separate, later action.
*/
export interface ReconciliationPair {
/** Outside rows: transaction ids (bank) or skattekonto_transaction ids. */
external_ids: string[]
journal_entry_ids: string[]
}
export type PairSkipCode =
| 'UNSUPPORTED_PAIR_SHAPE'
| 'ALREADY_LINKED'
| 'ENTRY_NOT_FOUND'
| 'ENTRY_REVERSED'
| 'PAIR_NOT_CLOSED'
| 'ROW_IGNORED'
| 'NOT_FOUND'
| 'LINK_RACE'
| 'UNKNOWN'
export interface AppliedLink {
external_id: string
journal_entry_id: string
via?: 'line' | 'entry_total'
}
export interface SkippedPair {
pair: ReconciliationPair
code: PairSkipCode
message: string
}
export interface MatchPairsInput {
pairs?: ReconciliationPair[]
/** Use the persisted proposals (skattekonto) or potential matches (bank) as pairs. */
use_proposals?: boolean
/** Only with use_proposals: skip proposals below this confidence. */
confidence_threshold?: number
}
export interface MatchPairsResult {
dry_run: boolean
applied: AppliedLink[]
skipped: SkippedPair[]
considered: number
}
function skipCodeFor(err: unknown): { code: PairSkipCode; message: string } {
if (err instanceof SkattekontoLinkError) {
const map: Record<string, PairSkipCode> = {
TRANSACTION_NOT_FOUND: 'NOT_FOUND',
ALREADY_BOOKED: 'ALREADY_LINKED',
ROW_IGNORED: 'ROW_IGNORED',
ENTRY_NOT_FOUND: 'ENTRY_NOT_FOUND',
ENTRY_ALREADY_LINKED: 'ALREADY_LINKED',
INVALID_CANDIDATE: 'PAIR_NOT_CLOSED',
NOT_LINKED: 'UNKNOWN',
LINK_RACE: 'LINK_RACE',
}
return { code: map[err.code] ?? 'UNKNOWN', message: err.message }
}
return { code: 'UNKNOWN', message: err instanceof Error ? err.message : String(err) }
}
async function proposalsAsPairs(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
threshold: number,
): Promise<ReconciliationPair[]> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return []
if (parsed.kind === 'skattekonto') {
const status = await getSkattekontoReconciliationStatus(supabase, companyId)
if (!status) return []
return status.items.proposed
.filter((i) => i.proposal && i.proposal.confidence >= threshold)
.map((i) => ({ external_ids: [i.item_id], journal_entry_ids: [i.proposal!.journal_entry_id] }))
}
if (parsed.kind === 'bank') {
const { data } = await supabase
.from('transactions')
.select('id, potential_journal_entry_id, potential_match_confidence')
.eq('company_id', companyId)
.eq('cash_account_id', parsed.cashAccountId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
.not('potential_journal_entry_id', 'is', null)
return ((data ?? []) as Array<{ id: string; potential_journal_entry_id: string; potential_match_confidence: number | string | null }>)
.filter((r) => Number(r.potential_match_confidence ?? 0) >= threshold)
.map((r) => ({ external_ids: [r.id], journal_entry_ids: [r.potential_journal_entry_id] }))
}
return []
}
/**
* 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.
*/
export async function matchPairs(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
input: MatchPairsInput,
options: { dryRun?: boolean } = {},
): Promise<MatchPairsResult | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed || parsed.kind === 'manual') return null
const dryRun = options.dryRun ?? false
const pairs: ReconciliationPair[] = [...(input.pairs ?? [])]
if (input.use_proposals) {
pairs.push(
...(await proposalsAsPairs(supabase, companyId, accountKey, input.confidence_threshold ?? 0)),
)
}
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) {
// 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: '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 [journalEntryId] = pair.journal_entry_ids
if (dryRun) {
for (const externalId of externalIds) {
applied.push({ external_id: externalId, journal_entry_id: journalEntryId })
}
continue
}
try {
if (parsed.kind === 'skattekonto') {
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')
.select('ledger_account')
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle<{ ledger_account: string }>()
// 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)
}
}
} catch (err) {
const { code, message } = skipCodeFor(err)
skipped.push({ pair, code, message })
}
}
if (!dryRun && applied.length > 0) {
log.info('reconciliation pairs linked', { companyId, accountKey, applied: applied.length, skipped: skipped.length })
}
return { dry_run: dryRun, applied, skipped, considered: pairs.length }
}
export interface UnmatchResult {
external_id: string
previous_journal_entry_id: string | null
}
/**
* Remove one link. link id = the outside row's id (transaction or
* skattekonto row), which is the one-link-per-row identity both kinds share.
*/
export async function unmatchLink(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
linkId: string,
): Promise<UnmatchResult | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed || parsed.kind === 'manual') return null
let previous: string | null = null
if (parsed.kind === 'skattekonto') {
const r = await unlinkSkattekontoRow(supabase, companyId, linkId)
previous = r.previous_journal_entry_id
} else {
const { data: tx } = await supabase
.from('transactions')
.select('journal_entry_id')
.eq('company_id', companyId)
.eq('id', linkId)
.maybeSingle<{ journal_entry_id: string | null }>()
previous = tx?.journal_entry_id ?? null
const r = await unlinkReconciliation(supabase, companyId, linkId, userId)
if (!r.success) throw new Error(r.error ?? 'Kunde inte koppla bort')
}
await eventBus.emit({
type: 'reconciliation.unmatched',
payload: { accountKey, externalId: linkId, previousJournalEntryId: previous, userId, companyId },
})
return { external_id: linkId, previous_journal_entry_id: previous }
}
/**
* Ignore / restore one outside row. Ignored rows leave the unmatched totals
* and surface on the bridge's exclusion line (bank #1705 precedent).
*/
export async function setItemIgnored(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
itemId: string,
ignored: boolean,
): Promise<{ external_id: string; is_ignored: boolean } | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed || parsed.kind === 'manual') return null
if (parsed.kind === 'skattekonto') {
const r = await setSkattekontoRowIgnored(supabase, companyId, itemId, ignored)
return { external_id: r.skattekonto_transaction_id, is_ignored: r.is_ignored }
}
const { data: tx, error } = await supabase
.from('transactions')
.select('id, journal_entry_id, is_ignored')
.eq('company_id', companyId)
.eq('id', itemId)
.maybeSingle<{ id: string; journal_entry_id: string | null; is_ignored: boolean | null }>()
if (error) throw new Error(`Kunde inte hämta transaktionen: ${error.message}`)
if (!tx) throw new SkattekontoLinkError('Transaktionen hittades inte.', 'TRANSACTION_NOT_FOUND')
if (ignored && tx.journal_entry_id) {
throw new SkattekontoLinkError('En bokförd transaktion kan inte ignoreras.', 'ALREADY_BOOKED')
}
if (Boolean(tx.is_ignored) !== ignored) {
const { error: updateError } = await supabase
.from('transactions')
.update({ is_ignored: ignored })
.eq('company_id', companyId)
.eq('id', itemId)
if (updateError) throw new Error(`Kunde inte uppdatera: ${updateError.message}`)
}
return { external_id: itemId, is_ignored: ignored }
}