feat(reconciliation): manual adapter for the whole balance sheet (Reko bilagor, PR 1) (#1854)

* feat(reconciliation): manual adapter so the whole balance sheet is reconcilable and signable (Reko bilagor, PR 1)

Every class 1-2 account the bank and skattekonto adapters do not own now
appears on the Avstämning page under "Övriga balanskonton" with IB, movement
and UB through the balansdag, a system specification where one exists
(1510 kundreskontra, 2440 leverantörsreskontra, 2920/2940 semesterlöneskuld)
and, for every other account, the balance the signer states from their
underlag at sign-off. Same three doors as before: dashboard routes, v1 API
and the MCP tools take manual:<BAS> keys and an external_balance.

The ledger side is computed per fiscal period via generateTrialBalance,
never as an all-history sum: year-end re-books every balance account in an
opening_balance verifikat, so an all-history sum counts a closed year twice.

A stated external_balance is refused (EXTERNAL_BALANCE_NOT_ALLOWED) wherever
the system already has an outside truth, so it can never hide a difference.

No migration: account_reconciliations already accepts manual:NNNN keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* chore(api-skill): regenerate banking reference for the sign-off external_balance field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

---------

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 09:23:14 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 1e6e19afe9
commit ebbdf96b74
22 changed files with 1312 additions and 116 deletions
@@ -0,0 +1,236 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const trialBalanceMock = vi.fn()
const arMock = vi.fn()
const apMock = vi.fn()
const vacationMock = vi.fn()
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: (...args: unknown[]) => trialBalanceMock(...args),
}))
vi.mock('@/lib/reports/ar-reconciliation', () => ({
generateARReconciliation: (...args: unknown[]) => arMock(...args),
}))
vi.mock('@/lib/reports/supplier-reconciliation', () => ({
generateReconciliation: (...args: unknown[]) => apMock(...args),
}))
vi.mock('@/lib/reports/vacation-liability', () => ({
generateVacationLiability: (...args: unknown[]) => vacationMock(...args),
}))
import {
buildManualStatus,
getManualReconciliationStatus,
listManualAccounts,
loadBalanceSheetSnapshot,
type BalanceSheetSnapshot,
} from '../manual-reconciliation'
const COMPANY = 'company-1'
const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' }
function tbRow(
account_number: string,
account_name: string,
account_class: number,
opening: number,
movement: number,
) {
const closing = opening + movement
return {
account_number,
account_name,
account_class,
opening_debit: opening > 0 ? opening : 0,
opening_credit: opening < 0 ? -opening : 0,
period_debit: movement > 0 ? movement : 0,
period_credit: movement < 0 ? -movement : 0,
closing_debit: closing > 0 ? closing : 0,
closing_credit: closing < 0 ? -closing : 0,
}
}
const TB_ROWS = [
tbRow('1510', 'Kundfordringar', 1, 8000, 4000),
tbRow('1930', 'Företagskonto', 1, 50000, -12000),
tbRow('1630', 'Skattekonto', 1, 1200, 300),
tbRow('2350', 'Banklån', 2, -260000, 10000),
tbRow('2440', 'Leverantörsskulder', 2, -6000, -1500),
tbRow('2920', 'Semesterlöneskuld', 2, -30000, -2000),
tbRow('2990', 'Övriga upplupna kostnader', 2, 0, 0),
tbRow('3001', 'Försäljning', 3, 0, -90000),
]
function snapshot(): BalanceSheetSnapshot {
const rows = new Map()
for (const r of TB_ROWS) {
if (r.account_class > 2) continue
rows.set(r.account_number, {
account_number: r.account_number,
account_name: r.account_name,
opening_balance: r.opening_debit - r.opening_credit,
movement: r.period_debit - r.period_credit,
closing_balance: r.closing_debit - r.closing_credit,
})
}
return { period: PERIOD, as_of: '2026-07-31', rows }
}
beforeEach(() => {
vi.clearAllMocks()
trialBalanceMock.mockReset()
arMock.mockReset()
apMock.mockReset()
vacationMock.mockReset()
trialBalanceMock.mockResolvedValue({ rows: TB_ROWS })
arMock.mockResolvedValue({ ar_ledger_total: 12000, account_1510_balance: 12000, difference: 0, is_reconciled: true, unconverted_fx_count: 0 })
apMock.mockResolvedValue({ supplier_ledger_total: 7000, account_2440_balance: -7500, difference: -500, is_reconciled: false, unconverted_fx_count: 1 })
vacationMock.mockResolvedValue({ rows: [], totals: { accruedAmount: 32000, accruedAvgifter: 10054, totalLiability: 42054 }, asOfDate: '2026-07-31' })
})
describe('loadBalanceSheetSnapshot', () => {
it('reads the period containing the date and the trial balance through it, class 1-2 only', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
const snap = await loadBalanceSheetSnapshot(supabase as never, COMPANY, '2026-07-31')
expect(trialBalanceMock).toHaveBeenCalledWith(supabase, COMPANY, 'fy-2026', { closingEntry: 'include', toDate: '2026-07-31' })
expect(snap?.rows.has('3001')).toBe(false)
expect(snap?.rows.get('2350')).toEqual({
account_number: '2350',
account_name: 'Banklån',
opening_balance: -260000,
movement: 10000,
closing_balance: -250000,
})
})
it('returns null when no fiscal period covers the date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
expect(await loadBalanceSheetSnapshot(supabase as never, COMPANY, '2019-12-31')).toBeNull()
expect(trialBalanceMock).not.toHaveBeenCalled()
})
})
describe('buildManualStatus', () => {
it('compares a liability against its specification in ledger sign and carries the balances', () => {
const snap = snapshot()
const specs = new Map([['2440', { amount: -7000, unconverted_fx_count: 1 }]])
const s = buildManualStatus(snap.rows.get('2440')!, snap, specs)
expect(s).toMatchObject({
account_key: 'manual:2440',
kind: 'manual',
as_of: '2026-07-31T00:00:00.000Z',
external_balance: -7000,
ledger_balance: -7500,
difference: -500,
unexplained_difference: -500,
is_reconciled: false,
manual: {
period_id: 'fy-2026',
opening_balance: -6000,
movement: -1500,
closing_balance: -7500,
specification: { provider: 'ap', amount: -7000, unconverted_fx_count: 1 },
},
})
expect(s.bridge.map((l) => l.key)).toEqual(['specification', 'opening_balance', 'movement', 'ledger_balance'])
expect(s.bridge[0].label_sv).toMatch(/idag/)
})
it('leaves the outside side unknown for an account without a specification', () => {
const snap = snapshot()
const s = buildManualStatus(snap.rows.get('2350')!, snap, new Map())
expect(s).toMatchObject({ external_balance: null, difference: null, unexplained_difference: null, is_reconciled: false })
expect(s.manual?.specification).toBeNull()
expect(s.bridge.map((l) => l.key)).toEqual(['opening_balance', 'movement', 'ledger_balance'])
})
})
describe('getManualReconciliationStatus', () => {
it('computes the reskontra specification only for its account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
const s = await getManualReconciliationStatus(supabase as never, COMPANY, '1510', { asOf: '2026-07-31' })
expect(s).toMatchObject({ external_balance: 12000, ledger_balance: 12000, difference: 0, is_reconciled: true })
expect(arMock).toHaveBeenCalledWith(supabase, COMPANY, 'fy-2026')
expect(apMock).not.toHaveBeenCalled()
expect(vacationMock).not.toHaveBeenCalled()
})
it('resolves a dormant balance account from the chart as a zero row, and refuses a result account', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
enqueue({ data: { account_number: '2390', account_name: 'Övriga långfristiga skulder', account_class: 2 } })
const s = await getManualReconciliationStatus(supabase as never, COMPANY, '2390', { asOf: '2026-07-31' })
expect(s).toMatchObject({ account_number: '2390', ledger_balance: 0, external_balance: null })
enqueue({ data: PERIOD })
enqueue({ data: { account_number: '3001', account_name: 'Försäljning', account_class: 3 } })
expect(await getManualReconciliationStatus(supabase as never, COMPANY, '3001', { asOf: '2026-07-31' })).toBeNull()
})
it('falls back to an unknown outside side when a specification source fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
vacationMock.mockRejectedValue(new Error('salary module down'))
const s = await getManualReconciliationStatus(supabase as never, COMPANY, '2920', { asOf: '2026-07-31' })
expect(s).toMatchObject({ external_balance: null, unexplained_difference: null })
expect(s?.manual?.specification).toBeNull()
})
})
describe('listManualAccounts', () => {
it('lists balance accounts with a balance or movement, minus the fed ones, plus signed dormant ones, with states', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
const signoffs = new Map<string, { through_date: string } | null>([
['manual:2350', { through_date: '2026-07-31' }],
['manual:2990', { through_date: '2026-06-30' }],
['bank:11111111-1111-4111-8111-111111111111', { through_date: '2026-07-31' }],
])
const accounts = await listManualAccounts(supabase as never, COMPANY, {
asOf: '2026-07-31',
exclude: new Set(['1930', '1630']),
signoffs: signoffs as never,
})
expect(accounts.map((a) => a.account_number)).toEqual(['1510', '2350', '2440', '2920', '2990'])
const byNo = Object.fromEntries(accounts.map((a) => [a.account_number, a]))
// Specification agrees: reconciled without a sign-off.
expect(byNo['1510'].status?.state).toBe('reconciled')
// Specification differs by 500: open.
expect(byNo['2440'].status).toMatchObject({ state: 'open', unexplained_difference: -500 })
// No specification, but signed through the balansdag: reconciled.
expect(byNo['2350']).toMatchObject({ signed_off_through: '2026-07-31', status: { state: 'reconciled' } })
// Vacation liability from payroll: 2920 booked -32000 vs -32000 accrued.
expect(byNo['2920'].status).toMatchObject({ state: 'reconciled', unexplained_difference: 0 })
// Dormant but once signed: listed, not attested for this date.
expect(byNo['2990']).toMatchObject({ signed_off_through: '2026-06-30', status: { state: 'open' } })
expect(byNo['2990'].source).toEqual({ type: 'manual', synced_at: null, stale: false })
// One trial balance read, one read per specification source.
expect(trialBalanceMock).toHaveBeenCalledTimes(1)
expect(arMock).toHaveBeenCalledTimes(1)
expect(apMock).toHaveBeenCalledTimes(1)
expect(vacationMock).toHaveBeenCalledTimes(1)
})
it('skips the specification reads when statuses are not wanted', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD })
const accounts = await listManualAccounts(supabase as never, COMPANY, {
asOf: '2026-07-31',
exclude: new Set(),
signoffs: new Map(),
withStatus: false,
})
expect(accounts.every((a) => a.status === null)).toBe(true)
expect(arMock).not.toHaveBeenCalled()
})
it('returns nothing when no period covers the date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
expect(await listManualAccounts(supabase as never, COMPANY, { asOf: '2019-12-31', exclude: new Set(), signoffs: new Map() })).toEqual([])
})
})
@@ -10,6 +10,12 @@ vi.mock('../skattekonto-reconciliation', () => ({
vi.mock('../bank-reconciliation', () => ({
getReconciliationStatus: (...args: unknown[]) => bankStatusMock(...args),
}))
const listManualMock = vi.fn()
const manualStatusMock = vi.fn()
vi.mock('../manual-reconciliation', () => ({
listManualAccounts: (...args: unknown[]) => listManualMock(...args),
getManualReconciliationStatus: (...args: unknown[]) => manualStatusMock(...args),
}))
import { bankAccountKey, parseAccountKey } from '../schemas'
import { getAccountStatus, listReconciliationAccounts } from '../service'
@@ -75,6 +81,66 @@ describe('listReconciliationAccounts', () => {
vi.clearAllMocks()
skattekontoStatusMock.mockReset()
bankStatusMock.mockReset()
listManualMock.mockReset()
listManualMock.mockResolvedValue([])
})
it('appends the manual accounts after the fed ones, excluding the accounts the feeds own', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [cashAccount(ID_A, { is_primary: true }), cashAccount(ID_C, { ledger_account: '1931', iban: 'SE2' })] })
enqueue({ data: [{ id: 's1', account_key: 'manual:2440', through_date: '2026-06-30', reopened_at: null }] }) // latest sign-offs
enqueue({ data: [] }) // bank names for logos
enqueue({ data: null })
enqueue({ data: null })
skattekontoStatusMock.mockResolvedValue({
account_key: 'skattekonto',
kind: 'skattekonto',
account_number: '1630',
currency: 'SEK',
as_of: '2026-08-20T04:00:00.000Z',
stale: false,
is_reconciled: true,
unexplained_difference: 0,
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 1, ignored: 0 },
skattekonto: { fetched_at: '2026-08-20T04:00:00.000Z' },
})
listManualMock.mockResolvedValue([
{ account_key: 'manual:1510', kind: 'manual', account_number: '1510', name: 'Kundfordringar' },
{ account_key: 'manual:2440', kind: 'manual', account_number: '2440', name: 'Leverantörsskulder' },
])
const accounts = await listReconciliationAccounts(supabase as never, COMPANY, {
today: '2026-08-20',
windowFrom: '2026-01-01',
windowTo: '2026-07-31',
withStatus: false,
})
expect(accounts.map((a) => a.account_key)).toEqual([
bankAccountKey(ID_A),
bankAccountKey(ID_C),
'skattekonto',
'manual:1510',
'manual:2440',
])
const [, , opts] = listManualMock.mock.calls[0] as [unknown, unknown, { asOf: string; exclude: Set<string>; withStatus: boolean; signoffs: Map<string, unknown> }]
expect(opts.asOf).toBe('2026-07-31')
expect([...opts.exclude].sort()).toEqual(['1630', '1930', '1931'])
expect(opts.withStatus).toBe(false)
expect(opts.signoffs.has('manual:2440')).toBe(true)
})
it('keeps the fed accounts when the manual read fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [cashAccount(ID_A, { is_primary: true })] })
enqueue({ data: [] })
enqueue({ data: [] })
enqueue({ data: null })
skattekontoStatusMock.mockResolvedValue(null)
listManualMock.mockRejectedValue(new Error('trial balance down'))
const accounts = await listReconciliationAccounts(supabase as never, COMPANY, { today: '2026-08-20', withStatus: false })
expect(accounts.map((a) => a.account_key)).toEqual([bankAccountKey(ID_A)])
})
it('lists enabled cash accounts, folds reconnect duplicates by IBAN, and appends the skattekonto when configured', async () => {
@@ -190,6 +256,69 @@ describe('getAccountStatus', () => {
vi.clearAllMocks()
skattekontoStatusMock.mockReset()
bankStatusMock.mockReset()
manualStatusMock.mockReset()
})
function manualStatus(overrides: Record<string, unknown> = {}) {
return {
account_key: 'manual:2350',
kind: 'manual',
account_number: '2350',
currency: 'SEK',
as_of: '2026-07-31T00:00:00.000Z',
stale: false,
external_balance: null,
ledger_balance: -250000,
difference: null,
unexplained_difference: null,
is_reconciled: false,
bridge: [],
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 },
skattekonto: null,
bank: null,
manual: { specification: null },
...overrides,
}
}
it('dispatches manual keys to the manual adapter with the window end as the balansdag', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
manualStatusMock.mockResolvedValue(manualStatus())
enqueue({ data: null }) // latest sign-off
const s = await getAccountStatus(supabase as never, COMPANY, 'manual:2350', { today: '2026-08-20', windowTo: '2026-07-31' })
expect(manualStatusMock).toHaveBeenCalledWith(supabase, COMPANY, '2350', { today: '2026-08-20', asOf: '2026-07-31' })
expect(s).toMatchObject({ account_key: 'manual:2350', external_balance: null, signoff: null })
})
it('shows the stated balance from a sign-off made for the same balansdag as the outside side', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
manualStatusMock.mockResolvedValue(manualStatus())
enqueue({
data: {
id: 's1',
account_key: 'manual:2350',
through_date: '2026-07-31',
external_balance: -250000,
ledger_balance: -250000,
unexplained_difference: 0,
note: 'Enligt engagemangsbesked',
signed_by: 'u1',
signed_at: '2026-08-01T08:00:00Z',
reopened_at: null,
reopened_by: null,
reopen_reason: null,
},
})
const s = await getAccountStatus(supabase as never, COMPANY, 'manual:2350', { today: '2026-08-20', windowTo: '2026-07-31' })
expect(s).toMatchObject({ external_balance: -250000, difference: 0, unexplained_difference: 0, is_reconciled: true })
})
it('leaves the outside side unknown when the sign-off was for another date', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
manualStatusMock.mockResolvedValue(manualStatus())
enqueue({ data: { id: 's1', account_key: 'manual:2350', through_date: '2026-06-30', external_balance: -260000, reopened_at: null } })
const s = await getAccountStatus(supabase as never, COMPANY, 'manual:2350', { today: '2026-08-20', windowTo: '2026-07-31' })
expect(s).toMatchObject({ external_balance: null, unexplained_difference: null, is_reconciled: false })
})
it('returns null for an invalid key and for an unknown cash account', async () => {
+120 -2
View File
@@ -79,11 +79,129 @@ describe('signOffAccount', () => {
)
})
it('returns null for an unknown or manual account key', async () => {
it('returns null for an unknown account key and for a key the engine does not resolve', async () => {
const { supabase } = createQueuedMockSupabase()
expect(await signOffAccount(supabase as never, COMPANY, USER, 'nope', { through_date: '2026-07-31' })).toBeNull()
expect(await signOffAccount(supabase as never, COMPANY, USER, 'manual:1910', { through_date: '2026-07-31' })).toBeNull()
expect(statusMock).not.toHaveBeenCalled()
statusMock.mockResolvedValue(null)
expect(
await signOffAccount(supabase as never, COMPANY, USER, 'manual:1910', { through_date: '2026-07-31' }, { today: TODAY }),
).toBeNull()
expect(statusMock).toHaveBeenCalledWith(supabase, COMPANY, 'manual:1910', { today: TODAY, windowTo: '2026-07-31' })
})
describe('manual accounts', () => {
function manualStatus(overrides: Record<string, unknown> = {}) {
return status({
account_key: 'manual:2350',
kind: 'manual',
account_number: '2350',
as_of: '2026-07-31T00:00:00.000Z',
external_balance: null,
ledger_balance: -250000,
difference: null,
unexplained_difference: null,
is_reconciled: false,
manual: { specification: null },
...overrides,
})
}
it('signs with the stated balance when it equals the booked one, recording both', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(manualStatus())
const result = await signOffAccount(
supabase as never,
COMPANY,
USER,
'manual:2350',
{ through_date: '2026-07-31', external_balance: -250000, note: 'Enligt engagemangsbesked' },
{ today: TODAY },
)
expect(result).toMatchObject({ dry_run: false })
expect(insertMock).toHaveBeenCalledWith(
supabase,
COMPANY,
expect.objectContaining({
account_key: 'manual:2350',
external_balance: -250000,
ledger_balance: -250000,
unexplained_difference: 0,
}),
)
})
it('treats the gap between stated and booked as the unexplained difference', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(manualStatus())
await expect(
signOffAccount(
supabase as never,
COMPANY,
USER,
'manual:2350',
{ through_date: '2026-07-31', external_balance: -249500 },
{ today: TODAY },
),
).rejects.toMatchObject({ code: 'NOT_RECONCILED' })
const forced = await signOffAccount(
supabase as never,
COMPANY,
USER,
'manual:2350',
{ through_date: '2026-07-31', external_balance: -249500, force: true, note: 'Amortering bokförs i augusti.' },
{ today: TODAY, dryRun: true },
)
expect(forced).toMatchObject({
dry_run: true,
would_sign: { external_balance: -249500, ledger_balance: -250000, unexplained_difference: -500, forced: true },
})
})
it('still needs force + note when no balance is stated', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(manualStatus())
await expect(
signOffAccount(supabase as never, COMPANY, USER, 'manual:2350', { through_date: '2026-07-31' }, { today: TODAY }),
).rejects.toMatchObject({ code: 'OUTSIDE_UNKNOWN' })
})
it('refuses a stated balance where the system already has an outside truth', async () => {
const { supabase } = createQueuedMockSupabase()
statusMock.mockResolvedValue(
manualStatus({
account_key: 'manual:1510',
account_number: '1510',
external_balance: 12000,
ledger_balance: 12000,
unexplained_difference: 0,
is_reconciled: true,
manual: { specification: { provider: 'ar', amount: 12000 } },
}),
)
await expect(
signOffAccount(
supabase as never,
COMPANY,
USER,
'manual:1510',
{ through_date: '2026-07-31', external_balance: 12000 },
{ today: TODAY },
),
).rejects.toMatchObject({ code: 'EXTERNAL_BALANCE_NOT_ALLOWED' })
statusMock.mockResolvedValue(status())
await expect(
signOffAccount(
supabase as never,
COMPANY,
USER,
'skattekonto',
{ through_date: '2026-07-31', external_balance: 1000 },
{ today: TODAY },
),
).rejects.toMatchObject({ code: 'EXTERNAL_BALANCE_NOT_ALLOWED' })
expect(insertMock).not.toHaveBeenCalled()
})
})
it('rejects a malformed date and a date in the future', async () => {