feat(reconciliation): residual booking + junction-aware bridge (#1862)

When the worksheet selection (N bank rows vs one verifikat) misses by a
few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad /
Ränteintäkt / Öresavrundning och koppla' books the remainder on
6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the
main verifikat and anchors the residual verifikat through
transaction_voucher_links. Bank accounts only (Skatteverket posts ränta
and avgifter as rows of their own), capped at 5 000 kr, direction-checked
against the kind; links are made first and undone if the booking is
refused. Dashboard + v1 doors (transactions:write, Idempotency-Key,
dry run), API skill regenerated.

The bridge now treats transaction_voucher_links as links on both sides:
migration 20260824190000 re-creates get_unlinked_gl_lines and
get_account_gl_lines_for_matching to count junction-linked verifikat as
matched (pg-real test), and the TS engine + items do the same for the
transactions. This also stops bulk-booked samlingsverifikat from
polluting the open buckets. 'Koppla bort' drops the junction rows too.

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-25 08:30:54 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 0a3cb1a31a
commit d88df74b85
21 changed files with 1496 additions and 15 deletions
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `134`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `135`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -106,6 +106,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/journal-entries/batch-create",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/items/:itemId/ignore",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/links",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff",
"POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen",
"POST /api/v1/companies/:companyId/reconciliation/bank/run",
+1
View File
@@ -162,6 +162,7 @@ import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/items/[itemId]/ignore/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/signoff/[signoffId]/reopen/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/accounts/[accountKey]/residual/route'
// Dimensions PR2: registry list + value creation (kostnadsställe/projekt).
import '@/app/api/v1/companies/[companyId]/dimensions/route'
+1
View File
@@ -145,6 +145,7 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'GET /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:read',
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff': 'reconciliation:signoff',
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/signoff/:signoffId/reopen': 'reconciliation:signoff',
'POST /api/v1/companies/:companyId/reconciliation/accounts/:accountKey/residual': 'transactions:write',
// Phase 5 PR-3: Reports + import async. Reports are read-only over
// existing lib/reports/* generators; imports are async over the Phase 4
@@ -2178,7 +2178,7 @@ describe('unscoped cash-account diagnostic', () => {
expect(firstFrom?.args[0]).toBe('transactions')
const firstSelect = calls.find((c) => c.method === 'select')
expect(firstSelect?.args[0]).toBe(
'date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
'id, date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id',
)
})
+10 -2
View File
@@ -3,12 +3,14 @@ import { createQueuedMockSupabase } from '@/tests/helpers'
const skvStatusMock = vi.fn()
const fetchUnlinkedMock = vi.fn()
const junctionMock = vi.fn()
vi.mock('../skattekonto-reconciliation', () => ({
getSkattekontoReconciliationStatus: (...args: unknown[]) => skvStatusMock(...args),
}))
vi.mock('../bank-reconciliation', () => ({
fetchUnlinkedGLLines: (...args: unknown[]) => fetchUnlinkedMock(...args),
fetchJunctionLinkedTxIds: (...args: unknown[]) => junctionMock(...args),
scopeTransactionsToAccount: (q: unknown) => q,
}))
@@ -26,6 +28,8 @@ describe('listAccountItems', () => {
vi.clearAllMocks()
skvStatusMock.mockReset()
fetchUnlinkedMock.mockReset()
junctionMock.mockReset()
junctionMock.mockResolvedValue(new Set())
})
it('returns null for an invalid key', async () => {
@@ -69,8 +73,11 @@ describe('listAccountItems', () => {
{ id: 't-link', date: '2026-08-03', description: 'Lön', merchant_name: null, amount: -31200, currency: 'SEK', journal_entry_id: 'e-1', potential_journal_entry_id: null, potential_match_method: 'manual', potential_match_confidence: null, is_ignored: false, reconciliation_method: 'manual' },
{ id: 't-prop', date: '2026-08-02', description: 'Swish', merchant_name: 'Swish 123', amount: 2400, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: 'e-2', potential_match_method: 'auto_fuzzy', potential_match_confidence: '0.82', is_ignored: false, reconciliation_method: null },
{ id: 't-open', date: '2026-08-01', description: 'Elgiganten', merchant_name: null, amount: -1046, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null },
// Anchored only through transaction_voucher_links (bulk-book / residual): matched, pointer NULL.
{ id: 't-junc', date: '2026-07-31', description: 'Samlingsverifikat', merchant_name: null, amount: -500, currency: 'SEK', journal_entry_id: null, potential_journal_entry_id: null, potential_match_method: null, potential_match_confidence: null, is_ignored: false, reconciliation_method: null },
],
})
junctionMock.mockResolvedValue(new Set(['t-junc']))
fetchUnlinkedMock.mockResolvedValue([
{ line_id: 'l1', journal_entry_id: 'e-3', debit_amount: 0, credit_amount: 600, line_description: null, entry_date: '2026-08-18', voucher_number: 231, voucher_series: 'A', entry_description: 'Elgiganten', source_type: 'manual' },
{ line_id: 'l2', journal_entry_id: 'e-3', debit_amount: 0, credit_amount: 400, line_description: null, entry_date: '2026-08-18', voucher_number: 231, voucher_series: 'A', entry_description: 'Elgiganten', source_type: 'manual' },
@@ -83,10 +90,11 @@ describe('listAccountItems', () => {
expect(byId['t-open']).toMatchObject({ bucket: 'unmatched_external', actions: ['book', 'match', 'ignore'] })
expect(byId['t-link']).toMatchObject({ bucket: 'matched', linked_journal_entry_id: 'e-1', actions: ['unmatch'] })
expect(byId['t-ign']).toMatchObject({ bucket: 'ignored', actions: ['unignore'] })
expect(byId['t-junc']).toMatchObject({ bucket: 'matched', actions: ['unmatch'] })
expect(byId['e-3']).toMatchObject({ bucket: 'unmatched_ledger', side: 'ledger', amount: -1000, voucher_number: 231 })
// Work order: proposed, unmatched external, unmatched ledger, ignored, upcoming, matched
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched'])
expect(result?.total_count).toBe(5)
expect(result?.items.map((i) => i.bucket)).toEqual(['proposed', 'unmatched_external', 'unmatched_ledger', 'ignored', 'matched', 'matched'])
expect(result?.total_count).toBe(6)
})
it('returns null for an unknown cash account', async () => {
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const createEntryMock = vi.fn()
const findPeriodMock = vi.fn()
const matchMock = vi.fn()
const unmatchMock = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: (...args: unknown[]) => createEntryMock(...args),
findFiscalPeriod: (...args: unknown[]) => findPeriodMock(...args),
}))
vi.mock('../actions', () => ({
matchPairs: (...args: unknown[]) => matchMock(...args),
unmatchLink: (...args: unknown[]) => unmatchMock(...args),
}))
import { bookResidualAndLink, ReconciliationResidualError } from '../residual'
const COMPANY = 'company-1'
const USER = 'user-1'
const CASH = '11111111-1111-4111-8111-111111111111'
const KEY = `bank:${CASH}`
const T1 = '22222222-2222-4222-8222-222222222222'
const E1 = '44444444-4444-4444-8444-444444444444'
const RES = '55555555-5555-4555-8555-555555555555'
function tx(overrides: Record<string, unknown> = {}) {
return { id: T1, date: '2026-08-05', amount: -1010, description: 'Leverantör AB', journal_entry_id: null, is_ignored: false, cash_account_id: CASH, ...overrides }
}
function entry(net = -1000) {
return {
id: E1,
status: 'posted',
description: 'Faktura 1234',
lines: [
{ account_number: '2440', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: net > 0 ? net : 0, credit_amount: net < 0 ? -net : 0 },
],
}
}
describe('bookResidualAndLink', () => {
beforeEach(() => {
vi.clearAllMocks()
createEntryMock.mockReset()
findPeriodMock.mockReset()
matchMock.mockReset()
unmatchMock.mockReset()
findPeriodMock.mockResolvedValue('fp-1')
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [{ external_id: T1, journal_entry_id: E1 }], skipped: [] })
createEntryMock.mockResolvedValue({ id: RES })
})
it('returns null for an unknown account, refuses the skattekonto, refuses an unknown kind', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
expect(await bookResidualAndLink(supabase as never, COMPANY, USER, 'nope', { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' })).toBeNull()
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, 'skattekonto', { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_UNSUPPORTED_KIND' })
enqueue({ data: null }) // cash account lookup: unknown for this company
expect(await bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' })).toBeNull()
})
it('previews a bank fee: expense on 6570 against the bank account, dated on the transaction', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
const result = await bookResidualAndLink(
supabase as never,
COMPANY,
USER,
KEY,
{ external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' },
{ dryRun: true },
)
expect(result).toMatchObject({
dry_run: true,
would_book: {
residual_amount: -10,
counter_account: '6570',
entry_date: '2026-08-05',
description: 'Bankavgift: Faktura 1234',
lines: [
{ account_number: '6570', debit_amount: 10, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 10 },
],
},
})
expect(matchMock).not.toHaveBeenCalled()
expect(createEntryMock).not.toHaveBeenCalled()
})
it('links first, then books the residual, then anchors it through the junction', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
enqueue({ data: null }) // junction insert
const result = await bookResidualAndLink(supabase as never, COMPANY, USER, KEY, {
external_ids: [T1],
journal_entry_id: E1,
kind: 'bank_fee',
})
expect(result).toMatchObject({ dry_run: false, residual_journal_entry_id: RES, residual_amount: -10 })
expect(matchMock).toHaveBeenCalledWith(supabase, COMPANY, USER, KEY, { pairs: [{ external_ids: [T1], journal_entry_ids: [E1] }] }, { dryRun: false })
expect(createEntryMock).toHaveBeenCalledWith(
supabase,
COMPANY,
USER,
expect.objectContaining({ fiscal_period_id: 'fp-1', entry_date: '2026-08-05', source_type: 'manual' }),
)
expect(findCall('transaction_voucher_links', 'insert')?.[0]).toMatchObject({
transaction_id: T1,
journal_entry_id: RES,
allocated_amount: -10,
role: 'other',
})
})
it('refuses a zero residual, a residual above the cap, and a kind in the wrong direction', async () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: -1000 })] })
enqueue({ data: entry(-1000) })
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_ZERO' })
reset()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: -9000 })] })
enqueue({ data: entry(-1000) })
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_TOO_LARGE' })
reset()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: 1005 })] })
enqueue({ data: entry(1000) })
// The bank received MORE than booked: a bank fee (expense) is the wrong kind.
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toMatchObject({ code: 'RESIDUAL_DIRECTION' })
expect(matchMock).not.toHaveBeenCalled()
})
it('books interest income on the income side when the bank received more', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx({ amount: 1002.5 })] })
enqueue({ data: entry(1000) })
const result = await bookResidualAndLink(
supabase as never,
COMPANY,
USER,
KEY,
{ external_ids: [T1], journal_entry_id: E1, kind: 'interest_income' },
{ dryRun: true },
)
expect(result).toMatchObject({
dry_run: true,
would_book: {
residual_amount: 2.5,
lines: [
{ account_number: '1930', debit_amount: 2.5, credit_amount: 0 },
{ account_number: '8310', debit_amount: 0, credit_amount: 2.5 },
],
},
})
})
it('undoes the links when the residual booking is refused', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
createEntryMock.mockRejectedValue(new Error('Perioden är låst'))
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toThrow(/låst/)
expect(unmatchMock).toHaveBeenCalledWith(supabase, COMPANY, USER, KEY, T1)
})
it('reports a failed link without booking anything', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: CASH, ledger_account: '1930', currency: 'SEK' } })
enqueue({ data: [tx()] })
enqueue({ data: entry(-1000) })
matchMock.mockResolvedValue({ dry_run: false, considered: 1, applied: [], skipped: [{ pair: { external_ids: [T1], journal_entry_ids: [E1] }, code: 'PAIR_NOT_CLOSED', message: 'Verifikationen saknar rad på 1930' }] })
await expect(
bookResidualAndLink(supabase as never, COMPANY, USER, KEY, { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }),
).rejects.toBeInstanceOf(ReconciliationResidualError)
expect(createEntryMock).not.toHaveBeenCalled()
})
})
+51 -3
View File
@@ -731,6 +731,7 @@ export async function getReconciliationStatus(
// manufacture a phantom, unexplainable difference. Ordered on id (unique) so
// pages never duplicate or skip rows across boundaries.
type StatusTxRow = {
id?: string | null
date: string | null
amount: number | string | null
journal_entry_id: string | null
@@ -741,7 +742,7 @@ export async function getReconciliationStatus(
const transactions = await fetchAllRows<StatusTxRow>(({ from, to }) => {
let txQuery = supabase
.from('transactions')
.select('date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
.select('id, date, amount, journal_entry_id, reconciliation_method, is_ignored, cash_account_id')
.eq('company_id', companyId)
txQuery = scopeTransactionsToAccount(txQuery, cashAccountId, currency, includeUnassigned)
if (dateFrom) txQuery = txQuery.gte('date', dateFrom)
@@ -756,6 +757,16 @@ export async function getReconciliationStatus(
currency,
})
// Transactions anchored through transaction_voucher_links (bulk-booked
// samlingsverifikat, residual bookings) carry journal_entry_id = NULL on the
// row itself. They are settled all the same, so the matched/unmatched split
// below must see them; is_transaction_booked() is the SQL twin of this.
const junctionLinkedTxIds = await fetchJunctionLinkedTxIds(
supabase,
companyId,
transactions.map((tx) => tx.id).filter((id): id is string => typeof id === 'string'),
)
// Get GL bank-account lines. We fetch posted AND reversed entries and count
// them TOGETHER: the exact inclusion rule the trial balance and balance sheet
// use (see lib/reports/trial-balance.ts, which sums `['posted','reversed']`).
@@ -926,13 +937,15 @@ export async function getReconciliationStatus(
// Matched/unmatched partition the RECONCILABLE (non-ignored) set, so
// matched_count + unmatched_transaction_count always equals the number of
// rows behind bank_transaction_total.
const matchedCount = reconcilableTx.filter((tx) => tx.journal_entry_id !== null).length
const isLinked = (tx: StatusTxRow): boolean =>
tx.journal_entry_id !== null || (typeof tx.id === 'string' && junctionLinkedTxIds.has(tx.id))
const matchedCount = reconcilableTx.filter(isLinked).length
// The gross split behind the net: what the user actually recognises as
// "what moved on the bank", so the page never has to explain "netto".
const bankInflow = reconcilableTx.reduce((sum, tx) => sum + Math.max(Number(tx.amount) || 0, 0), 0)
const bankOutflow = reconcilableTx.reduce((sum, tx) => sum + Math.min(Number(tx.amount) || 0, 0), 0)
const unmatchedTx = reconcilableTx.filter((tx) => tx.journal_entry_id === null)
const unmatchedTx = reconcilableTx.filter((tx) => !isLinked(tx))
const unmatchedTransactionCount = unmatchedTx.length
const unmatchedTransactionTotal = unmatchedTx.reduce(
(sum, tx) => sum + (Number(tx.amount) || 0),
@@ -1207,6 +1220,14 @@ export async function unlinkReconciliation(
return { success: false, error: 'Failed to unlink transaction' }
}
// A residual booking (or a bulk-book) anchors the same transaction through
// transaction_voucher_links as well; "koppla bort" means every anchor goes.
await supabase
.from('transaction_voucher_links')
.delete()
.eq('company_id', companyId)
.eq('transaction_id', transactionId)
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
previousState: {
journal_entry_id: tx.journal_entry_id,
@@ -1467,6 +1488,33 @@ export async function fetchUnlinkedGLLines(
}
}
/**
* Ids of the given transactions that are anchored to a verifikat through
* transaction_voucher_links (journal_entry_id NULL on the row itself). Chunked
* on the id list so a busy window never pushes the .in() past URL limits;
* a failed read returns the empty set rather than throwing, mirroring
* fetchUnlinkedGLLines' legacy contract.
*/
export async function fetchJunctionLinkedTxIds(
supabase: SupabaseClient,
companyId: string,
transactionIds: string[],
): Promise<Set<string>> {
const out = new Set<string>()
const CHUNK = 150
for (let i = 0; i < transactionIds.length; i += CHUNK) {
const chunk = transactionIds.slice(i, i + CHUNK)
const { data, error } = await supabase
.from('transaction_voucher_links')
.select('transaction_id')
.eq('company_id', companyId)
.in('transaction_id', chunk)
if (error) return out
for (const row of (data ?? []) as Array<{ transaction_id: string }>) out.add(row.transaction_id)
}
return out
}
/** A match candidate that carries how many transactions already point at it. */
export interface GLLineForMatching extends UnlinkedGLLine {
/** Transactions settling this entry ON THE REQUESTED ACCOUNT (plus legacy
+9 -2
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { roundOre } from '@/lib/money'
import { fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
import { fetchJunctionLinkedTxIds, fetchUnlinkedGLLines, scopeTransactionsToAccount } from './bank-reconciliation'
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
import {
parseAccountKey,
@@ -147,11 +147,18 @@ export async function listAccountItems(
const { data, error: txError } = await query.order('date', { ascending: false }).order('id', { ascending: true })
if (txError) throw new Error(`Kunde inte hämta transaktioner: ${txError.message}`)
const rows = (data ?? []) as BankTxRow[]
// Rows anchored only through transaction_voucher_links (bulk-book,
// residual bookings) are matched too; their pointer column is NULL.
const junctionLinked = await fetchJunctionLinkedTxIds(
supabase,
companyId,
rows.filter((tx) => !tx.journal_entry_id && !tx.is_ignored).map((tx) => tx.id),
)
{
for (const tx of rows) {
const bucket: ReconciliationItemBucket = tx.is_ignored
? 'ignored'
: tx.journal_entry_id
: tx.journal_entry_id || junctionLinked.has(tx.id)
? 'matched'
: tx.potential_journal_entry_id
? 'proposed'
+336
View File
@@ -0,0 +1,336 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { roundOre } from '@/lib/money'
import { ISO_DATE_RE } from '@/lib/invariants'
import { createLogger } from '@/lib/logger'
import { matchPairs, unmatchLink, type AppliedLink, type SkippedPair } from './actions'
import { parseAccountKey } from './schemas'
const log = createLogger('reconciliation/residual')
/**
* Residual booking: the worksheet selection (N bank rows against one
* verifikat) misses by a few kronor, and the remainder is a real event the
* books lack: a bank fee, interest, öresavrundning. This books that remainder
* as its own small verifikat on the bank account and settles the selection in
* the same gesture: the rows point at the main verifikat as usual, and the
* residual verifikat is anchored to the first row through
* transaction_voucher_links, which the bridge treats as a link.
*
* Bank accounts only: Skatteverket posts ränta and avgifter as rows of their
* own on the skattekonto, so a skattekonto pair that does not close is a
* wrong booking, never a missing fee.
*/
export type ResidualKind = 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense'
export const RESIDUAL_KINDS: Record<
ResidualKind,
{ account: string; label_sv: string; label_en: string; direction: 'out' | 'in' | 'any' }
> = {
// Money that left the bank without a booking: expenses.
bank_fee: { account: '6570', label_sv: 'Bankavgift', label_en: 'Bank fee', direction: 'out' },
interest_expense: { account: '8410', label_sv: 'Räntekostnad', label_en: 'Interest expense', direction: 'out' },
// Money that reached the bank without a booking: income.
interest_income: { account: '8310', label_sv: 'Ränteintäkt', label_en: 'Interest income', direction: 'in' },
// Either way, öre-level.
rounding: { account: '3740', label_sv: 'Öresavrundning', label_en: 'Rounding', direction: 'any' },
}
export type ResidualErrorCode =
| 'RESIDUAL_UNSUPPORTED_KIND'
| 'RESIDUAL_ROWS_NOT_FOUND'
| 'RESIDUAL_ROW_NOT_OPEN'
| 'RESIDUAL_ENTRY_NOT_FOUND'
| 'RESIDUAL_ENTRY_NOT_POSTED'
| 'RESIDUAL_ZERO'
| 'RESIDUAL_TOO_LARGE'
| 'RESIDUAL_DIRECTION'
| 'RESIDUAL_NO_PERIOD'
| 'RESIDUAL_LINK_FAILED'
| 'RESIDUAL_INVALID_DATE'
export class ReconciliationResidualError extends Error {
readonly code: ResidualErrorCode
constructor(message: string, code: ResidualErrorCode) {
super(message)
this.name = 'ReconciliationResidualError'
this.code = code
}
}
/** Above this, a "residual" is a missing booking, not a fee: refuse so the user books it properly. */
export const RESIDUAL_MAX_AMOUNT = 5000
export interface ResidualInput {
external_ids: string[]
journal_entry_id: string
kind: ResidualKind
/** Defaults to the latest selected transaction's date. */
entry_date?: string
/** Defaults to "<kind label>: <main verifikat description>". */
description?: string
}
export interface ResidualPreview {
account_key: string
kind: ResidualKind
counter_account: string
ledger_account: string
currency: string
transactions_total: number
entry_net: number
/** transactions_total - entry_net: negative = the bank paid more than booked. */
residual_amount: number
entry_date: string
description: string
lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }>
}
export type ResidualResult =
| { dry_run: true; would_book: ResidualPreview }
| {
dry_run: false
residual_journal_entry_id: string
residual_amount: number
applied: AppliedLink[]
skipped: SkippedPair[]
}
interface TxRow {
id: string
date: string
amount: number | string
description: string | null
journal_entry_id: string | null
is_ignored: boolean | null
cash_account_id: string | null
}
export async function bookResidualAndLink(
supabase: SupabaseClient,
companyId: string,
userId: string,
accountKey: string,
input: ResidualInput,
options: { dryRun?: boolean } = {},
): Promise<ResidualResult | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
if (parsed.kind !== 'bank') {
throw new ReconciliationResidualError(
'Restbelopp bokförs bara på bankkonton. På skattekontot är ränta och avgifter egna händelser: koppla dem i stället.',
'RESIDUAL_UNSUPPORTED_KIND',
)
}
const spec = RESIDUAL_KINDS[input.kind]
if (!spec) {
throw new ReconciliationResidualError('Okänd typ av restbelopp.', 'RESIDUAL_UNSUPPORTED_KIND')
}
if (input.entry_date && !ISO_DATE_RE.test(input.entry_date)) {
throw new ReconciliationResidualError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'RESIDUAL_INVALID_DATE')
}
const { data: account } = await supabase
.from('cash_accounts')
.select('id, ledger_account, currency')
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle<{ id: string; ledger_account: string; currency: string | null }>()
if (!account) return null
const ledgerAccount = account.ledger_account
const currency = account.currency ?? 'SEK'
const ids = [...new Set(input.external_ids)]
if (ids.length === 0 || ids.length > 50) {
throw new ReconciliationResidualError('Välj mellan 1 och 50 banktransaktioner.', 'RESIDUAL_ROWS_NOT_FOUND')
}
const { data: txRows, error: txError } = await supabase
.from('transactions')
.select('id, date, amount, description, journal_entry_id, is_ignored, cash_account_id')
.eq('company_id', companyId)
.in('id', ids)
if (txError || !txRows || txRows.length !== ids.length) {
throw new ReconciliationResidualError('Någon av banktransaktionerna hittades inte.', 'RESIDUAL_ROWS_NOT_FOUND')
}
const txs = txRows as TxRow[]
if (txs.some((t) => t.journal_entry_id || t.is_ignored)) {
throw new ReconciliationResidualError(
'En av transaktionerna är redan kopplad eller ignorerad.',
'RESIDUAL_ROW_NOT_OPEN',
)
}
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
.select('id, status, description, lines:journal_entry_lines ( account_number, debit_amount, credit_amount )')
.eq('id', input.journal_entry_id)
.eq('company_id', companyId)
.maybeSingle<{
id: string
status: string
description: string | null
lines: Array<{ account_number: string; debit_amount: number | string; credit_amount: number | string }> | null
}>()
if (entryError || !entry) {
throw new ReconciliationResidualError('Verifikatet hittades inte.', 'RESIDUAL_ENTRY_NOT_FOUND')
}
if (entry.status !== 'posted') {
throw new ReconciliationResidualError('Verifikatet är inte bokfört.', 'RESIDUAL_ENTRY_NOT_POSTED')
}
const entryNet = roundOre(
(entry.lines ?? [])
.filter((l) => l.account_number === ledgerAccount)
.reduce((s, l) => s + Number(l.debit_amount || 0) - Number(l.credit_amount || 0), 0),
)
const txTotal = roundOre(txs.reduce((s, t) => s + Number(t.amount || 0), 0))
const residual = roundOre(txTotal - entryNet)
if (Math.abs(residual) < 0.005) {
throw new ReconciliationResidualError(
'Summorna stämmer redan: koppla utan restbelopp.',
'RESIDUAL_ZERO',
)
}
if (Math.abs(residual) > RESIDUAL_MAX_AMOUNT) {
throw new ReconciliationResidualError(
`Restbeloppet är större än ${RESIDUAL_MAX_AMOUNT} kr. Det är en saknad bokföring, inte en avgift: bokför den som ett eget verifikat.`,
'RESIDUAL_TOO_LARGE',
)
}
const direction: 'out' | 'in' = residual < 0 ? 'out' : 'in'
if (spec.direction !== 'any' && spec.direction !== direction) {
throw new ReconciliationResidualError(
direction === 'out'
? 'Banken har betalat ut mer än vad som är bokfört: restbeloppet är en kostnad (avgift eller räntekostnad), inte en intäkt.'
: 'Banken har tagit emot mer än vad som är bokfört: restbeloppet är en intäkt (ränteintäkt), inte en kostnad.',
'RESIDUAL_DIRECTION',
)
}
const amount = roundOre(Math.abs(residual))
// Expense: the bank line is a credit (money left), the counter account a debit.
// Income: the bank line is a debit (money arrived), the counter a credit.
const lines =
direction === 'out'
? [
{ account_number: spec.account, debit_amount: amount, credit_amount: 0 },
{ account_number: ledgerAccount, debit_amount: 0, credit_amount: amount },
]
: [
{ account_number: ledgerAccount, debit_amount: amount, credit_amount: 0 },
{ account_number: spec.account, debit_amount: 0, credit_amount: amount },
]
const latestTxDate = txs.map((t) => t.date).sort().at(-1) ?? new Date().toISOString().slice(0, 10)
const entryDate = input.entry_date ?? latestTxDate
const description =
input.description?.trim() ||
`${spec.label_sv}: ${entry.description?.trim() || txs[0].description?.trim() || 'avstämning'}`
const preview: ResidualPreview = {
account_key: accountKey,
kind: input.kind,
counter_account: spec.account,
ledger_account: ledgerAccount,
currency,
transactions_total: txTotal,
entry_net: entryNet,
residual_amount: residual,
entry_date: entryDate,
description,
lines,
}
if (options.dryRun) return { dry_run: true, would_book: preview }
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
throw new ReconciliationResidualError(
`Det finns inget räkenskapsår för ${entryDate}.`,
'RESIDUAL_NO_PERIOD',
)
}
// Link first (reversible), then book. If the booking is refused (a lock, a
// trigger), the links are undone so the selection is back where it was.
const linkResult = await matchPairs(
supabase,
companyId,
userId,
accountKey,
{ pairs: [{ external_ids: ids, journal_entry_ids: [entry.id] }] },
{ dryRun: false },
)
if (!linkResult || linkResult.applied.length !== ids.length) {
// Undo whatever did link before reporting.
for (const a of linkResult?.applied ?? []) {
try {
await unmatchLink(supabase, companyId, userId, accountKey, a.external_id)
} catch {
// best effort
}
}
const first = linkResult?.skipped[0]
throw new ReconciliationResidualError(
first ? first.message : 'Kunde inte koppla transaktionerna till verifikatet.',
'RESIDUAL_LINK_FAILED',
)
}
let residualEntry: { id: string }
try {
residualEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
source_type: 'manual',
lines,
})
} catch (err) {
for (const id of ids) {
try {
await unmatchLink(supabase, companyId, userId, accountKey, id)
} catch {
// best effort
}
}
throw err
}
// Anchor the residual verifikat to the selection through the junction (the
// rows' pointer column already holds the main verifikat). One row carries
// the whole residual so Sum(allocated_amount) per verifikat equals its bank
// side, the invariant bulk-book keeps.
const { error: linkError } = await supabase.from('transaction_voucher_links').insert({
user_id: userId,
company_id: companyId,
transaction_id: ids[0],
journal_entry_id: residualEntry.id,
allocated_amount: residual,
role: 'other',
})
if (linkError) {
log.error('residual verifikat booked but the junction link failed', linkError, {
companyId,
accountKey,
residualEntryId: residualEntry.id,
})
}
log.info('residual booked and linked', {
companyId,
accountKey,
kind: input.kind,
residual,
residualEntryId: residualEntry.id,
linked: ids.length,
})
return {
dry_run: false,
residual_journal_entry_id: residualEntry.id,
residual_amount: residual,
applied: linkResult.applied,
skipped: linkResult.skipped,
}
}