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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
1e6e19afe9
commit
ebbdf96b74
@@ -6070,6 +6070,7 @@ async function commitReconciliationSignoff(
|
||||
through_date: throughDate,
|
||||
note: (params.note as string | null | undefined) ?? null,
|
||||
force: params.force === true,
|
||||
external_balance: typeof params.external_balance === 'number' ? params.external_balance : null,
|
||||
},
|
||||
{ dryRun: false },
|
||||
)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -102,6 +102,12 @@ export async function listAccountItems(
|
||||
const limit = clampLimit(options.limit)
|
||||
const offset = Math.max(0, Math.floor(options.offset ?? 0))
|
||||
|
||||
// A manual account has no external rows to bucket: its bridge is IB,
|
||||
// movement and UB against a specification or the signer's underlag.
|
||||
if (parsed.kind === 'manual') {
|
||||
return { items: [], count: 0, total_count: 0, has_more: false, older_unmatched_count: 0 }
|
||||
}
|
||||
|
||||
if (parsed.kind === 'skattekonto') {
|
||||
const status = await getSkattekontoReconciliationStatus(supabase, companyId, {
|
||||
today: options.today,
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
|
||||
import { generateReconciliation as generateSupplierReconciliation } from '@/lib/reports/supplier-reconciliation'
|
||||
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
|
||||
import {
|
||||
manualAccountKey,
|
||||
type BridgeLine,
|
||||
type ManualSpecification,
|
||||
type ReconciliationAccount,
|
||||
type ReconciliationSignoff,
|
||||
type ReconciliationStatus,
|
||||
} from './schemas'
|
||||
|
||||
const log = createLogger('reconciliation/manual')
|
||||
|
||||
/**
|
||||
* The manual adapter: every balance sheet account that has no feed of its
|
||||
* own (no bank connection, no Skatteverket sync) but still has to be
|
||||
* reconciled and attested for a bokslut (Reko 760/765: each material
|
||||
* balanspost against its underlag, documented and signed).
|
||||
*
|
||||
* The ledger side is the account's balance on the balansdag, computed the way
|
||||
* the trial balance computes it: the fiscal period's opening balance plus the
|
||||
* movement through the date. It is never an all-history sum: year-end posts
|
||||
* an opening-balance verifikat in the new year that re-books every balance
|
||||
* account, so summing across periods counts a closed year twice.
|
||||
*
|
||||
* The outside side comes from a specification the system already keeps
|
||||
* (kundreskontra for 1510, leverantörsreskontra for 2440, semesterlöneskuld
|
||||
* for 2920/2940) or, for every other account, from the balance the signer
|
||||
* states from their underlag when they sign off. The reskontra totals are the
|
||||
* open items as they stand today, not as of the balansdag; a per-date
|
||||
* reskontra is a follow-up and the bridge label says "idag" until then.
|
||||
*/
|
||||
|
||||
export const BALANCE_TOLERANCE = 0.005
|
||||
|
||||
export interface BalanceRow {
|
||||
account_number: string
|
||||
account_name: string
|
||||
opening_balance: number
|
||||
movement: number
|
||||
closing_balance: number
|
||||
}
|
||||
|
||||
export interface BalanceSheetSnapshot {
|
||||
period: { id: string; name: string; period_start: string; period_end: string }
|
||||
/** The balansdag the balances are computed through (inclusive). */
|
||||
as_of: string
|
||||
rows: Map<string, BalanceRow>
|
||||
}
|
||||
|
||||
interface FiscalPeriodRow {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Class 1-2 balances through `asOfDate`, from the fiscal period that contains
|
||||
* the date. Null when no period covers the date (nothing to reconcile there).
|
||||
*/
|
||||
export async function loadBalanceSheetSnapshot(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
asOfDate: string,
|
||||
): Promise<BalanceSheetSnapshot | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', asOfDate)
|
||||
.gte('period_end', asOfDate)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta räkenskapsår: ${error.message}`)
|
||||
const period = data as FiscalPeriodRow | null
|
||||
if (!period) return null
|
||||
|
||||
const { rows } = await generateTrialBalance(supabase, companyId, period.id, {
|
||||
closingEntry: 'include',
|
||||
toDate: asOfDate,
|
||||
})
|
||||
const map = new Map<string, BalanceRow>()
|
||||
for (const r of rows) {
|
||||
if (r.account_class !== 1 && r.account_class !== 2) continue
|
||||
map.set(r.account_number, {
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
opening_balance: roundOre(r.opening_debit - r.opening_credit),
|
||||
movement: roundOre(r.period_debit - r.period_credit),
|
||||
closing_balance: roundOre(r.closing_debit - r.closing_credit),
|
||||
})
|
||||
}
|
||||
return { period, as_of: asOfDate, rows: map }
|
||||
}
|
||||
|
||||
/** A system-kept specification for one balance account, in ledger sign (debit positive). */
|
||||
export interface SpecificationProvider {
|
||||
key: ManualSpecification['provider']
|
||||
label_sv: string
|
||||
label_en: string
|
||||
}
|
||||
|
||||
export const SPECIFICATION_PROVIDERS: Record<string, SpecificationProvider> = {
|
||||
'1510': { key: 'ar', label_sv: 'Kundreskontra, öppna fakturor', label_en: 'Customer ledger, open invoices' },
|
||||
'2440': { key: 'ap', label_sv: 'Leverantörsreskontra, öppna fakturor', label_en: 'Supplier ledger, open invoices' },
|
||||
'2920': { key: 'vacation', label_sv: 'Semesterlöneskuld enligt lönekörningar', label_en: 'Vacation liability per payroll runs' },
|
||||
'2940': {
|
||||
key: 'vacation',
|
||||
label_sv: 'Sociala avgifter på semesterlöneskuld enligt lönekörningar',
|
||||
label_en: 'Social fees on vacation liability per payroll runs',
|
||||
},
|
||||
}
|
||||
|
||||
export type SpecificationAmounts = Map<string, { amount: number; unconverted_fx_count: number }>
|
||||
|
||||
/**
|
||||
* Compute the specification amounts for the provider accounts, one read per
|
||||
* source (the two reskontra tie-outs and the vacation liability). A failed
|
||||
* source is logged and left out: the account then reconciles like any manual
|
||||
* account (outside unknown) rather than against a wrong number.
|
||||
*/
|
||||
export async function loadSpecificationAmounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
onlyAccounts?: ReadonlySet<string>,
|
||||
): Promise<SpecificationAmounts> {
|
||||
const out: SpecificationAmounts = new Map()
|
||||
const wants = (n: string) => !onlyAccounts || onlyAccounts.has(n)
|
||||
const tasks: Array<Promise<void>> = []
|
||||
|
||||
if (wants('1510')) {
|
||||
tasks.push(
|
||||
generateARReconciliation(supabase, companyId, snapshot.period.id)
|
||||
.then((r) => {
|
||||
out.set('1510', { amount: roundOre(r.ar_ledger_total), unconverted_fx_count: r.unconverted_fx_count })
|
||||
})
|
||||
.catch((err) => log.warn('AR specification failed', { companyId, error: String(err) })),
|
||||
)
|
||||
}
|
||||
if (wants('2440')) {
|
||||
tasks.push(
|
||||
generateSupplierReconciliation(supabase, companyId, snapshot.period.id)
|
||||
.then((r) => {
|
||||
// Liabilities carry credit balances: negative in ledger sign.
|
||||
out.set('2440', { amount: roundOre(-r.supplier_ledger_total), unconverted_fx_count: r.unconverted_fx_count })
|
||||
})
|
||||
.catch((err) => log.warn('AP specification failed', { companyId, error: String(err) })),
|
||||
)
|
||||
}
|
||||
if (wants('2920') || wants('2940')) {
|
||||
const year = Number(snapshot.as_of.slice(0, 4))
|
||||
tasks.push(
|
||||
generateVacationLiability(supabase, companyId, year)
|
||||
.then((r) => {
|
||||
if (wants('2920')) out.set('2920', { amount: roundOre(-r.totals.accruedAmount), unconverted_fx_count: 0 })
|
||||
if (wants('2940')) out.set('2940', { amount: roundOre(-r.totals.accruedAvgifter), unconverted_fx_count: 0 })
|
||||
})
|
||||
.catch((err) => log.warn('vacation specification failed', { companyId, error: String(err) })),
|
||||
)
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
return out
|
||||
}
|
||||
|
||||
function bridgeFor(
|
||||
row: BalanceRow,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
specification: ManualSpecification | null,
|
||||
): BridgeLine[] {
|
||||
const lines: BridgeLine[] = []
|
||||
if (specification) {
|
||||
lines.push({
|
||||
key: 'specification',
|
||||
label_sv: `${specification.label_sv} (idag)`,
|
||||
label_en: `${specification.label_en} (today)`,
|
||||
amount: specification.amount,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
})
|
||||
}
|
||||
lines.push(
|
||||
{
|
||||
key: 'opening_balance',
|
||||
label_sv: `Ingående balans ${snapshot.period.period_start}`,
|
||||
label_en: `Opening balance ${snapshot.period.period_start}`,
|
||||
amount: row.opening_balance,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
},
|
||||
{
|
||||
key: 'movement',
|
||||
label_sv: `Förändring t.o.m. ${snapshot.as_of}`,
|
||||
label_en: `Movement through ${snapshot.as_of}`,
|
||||
amount: row.movement,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
},
|
||||
{
|
||||
key: 'ledger_balance',
|
||||
label_sv: `Bokfört på ${row.account_number} per ${snapshot.as_of}`,
|
||||
label_en: `Booked on ${row.account_number} as of ${snapshot.as_of}`,
|
||||
amount: row.closing_balance,
|
||||
count: null,
|
||||
items_bucket: null,
|
||||
},
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Status from an already loaded snapshot and specification map (no reads). */
|
||||
export function buildManualStatus(
|
||||
row: BalanceRow,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
specifications: SpecificationAmounts,
|
||||
): ReconciliationStatus {
|
||||
const provider = SPECIFICATION_PROVIDERS[row.account_number]
|
||||
const spec = provider ? specifications.get(row.account_number) : undefined
|
||||
const specification: ManualSpecification | null =
|
||||
provider && spec
|
||||
? {
|
||||
provider: provider.key,
|
||||
label_sv: provider.label_sv,
|
||||
label_en: provider.label_en,
|
||||
amount: spec.amount,
|
||||
unconverted_fx_count: spec.unconverted_fx_count,
|
||||
}
|
||||
: null
|
||||
const external = specification ? specification.amount : null
|
||||
const difference = external == null ? null : roundOre(row.closing_balance - external)
|
||||
return {
|
||||
account_key: manualAccountKey(row.account_number),
|
||||
kind: 'manual',
|
||||
account_number: row.account_number,
|
||||
currency: 'SEK',
|
||||
window: { from: snapshot.period.period_start, to: snapshot.as_of },
|
||||
// The balansdag itself: the tiles read "per <date>" from it.
|
||||
as_of: `${snapshot.as_of}T00:00:00.000Z`,
|
||||
stale: false,
|
||||
external_balance: external,
|
||||
ledger_balance: row.closing_balance,
|
||||
difference,
|
||||
// Nothing explains a manual difference but the signer's note.
|
||||
unexplained_difference: difference,
|
||||
is_reconciled: difference != null && Math.abs(difference) < BALANCE_TOLERANCE,
|
||||
bridge: bridgeFor(row, snapshot, specification),
|
||||
counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 },
|
||||
skattekonto: null,
|
||||
bank: null,
|
||||
manual: {
|
||||
period_id: snapshot.period.id,
|
||||
period_start: snapshot.period.period_start,
|
||||
period_end: snapshot.period.period_end,
|
||||
opening_balance: row.opening_balance,
|
||||
movement: row.movement,
|
||||
closing_balance: row.closing_balance,
|
||||
specification,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRow(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountNumber: string,
|
||||
snapshot: BalanceSheetSnapshot,
|
||||
): Promise<BalanceRow | null> {
|
||||
const row = snapshot.rows.get(accountNumber)
|
||||
if (row) return row
|
||||
// No activity in the period: the account is still reconcilable (a zero
|
||||
// balance is a claim too) as long as it exists in the company's chart.
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class')
|
||||
.eq('company_id', companyId)
|
||||
.eq('account_number', accountNumber)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Kunde inte hämta konto: ${error.message}`)
|
||||
const account = data as { account_number: string; account_name: string; account_class: number } | null
|
||||
if (!account || (account.account_class !== 1 && account.account_class !== 2)) return null
|
||||
return {
|
||||
account_number: account.account_number,
|
||||
account_name: account.account_name,
|
||||
opening_balance: 0,
|
||||
movement: 0,
|
||||
closing_balance: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManualStatusOptions {
|
||||
/** The balansdag. Defaults to today. */
|
||||
asOf?: string | null
|
||||
today?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Status for one manual account. Null when the date falls outside every
|
||||
* fiscal period or the account is not a balance account of this company.
|
||||
*/
|
||||
export async function getManualReconciliationStatus(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
accountNumber: string,
|
||||
options: ManualStatusOptions = {},
|
||||
): Promise<ReconciliationStatus | null> {
|
||||
const today = options.today ?? new Date().toISOString().slice(0, 10)
|
||||
const asOf = options.asOf ?? today
|
||||
const snapshot = await loadBalanceSheetSnapshot(supabase, companyId, asOf)
|
||||
if (!snapshot) return null
|
||||
const row = await resolveRow(supabase, companyId, accountNumber, snapshot)
|
||||
if (!row) return null
|
||||
const specifications = SPECIFICATION_PROVIDERS[accountNumber]
|
||||
? await loadSpecificationAmounts(supabase, companyId, snapshot, new Set([accountNumber]))
|
||||
: new Map()
|
||||
return buildManualStatus(row, snapshot, specifications)
|
||||
}
|
||||
|
||||
export interface ListManualAccountsOptions {
|
||||
/** The balansdag the list is computed through. */
|
||||
asOf: string
|
||||
/** Account numbers other adapters already own (cash accounts' ledger accounts, 1630 when the skattekonto is present). */
|
||||
exclude: ReadonlySet<string>
|
||||
/** Latest active sign-off per account key, as the service already loaded them. */
|
||||
signoffs: ReadonlyMap<string, ReconciliationSignoff | null>
|
||||
withStatus?: boolean
|
||||
}
|
||||
|
||||
function manualState(
|
||||
status: ReconciliationStatus,
|
||||
signedOffThrough: string | null,
|
||||
asOf: string,
|
||||
): NonNullable<ReconciliationAccount['status']> {
|
||||
// A manual account has no live feed, so the attestation is its state: signed
|
||||
// through the balansdag counts as reconciled; otherwise the specification
|
||||
// decides, and an account without one is simply not reconciled yet.
|
||||
const attested = signedOffThrough != null && signedOffThrough >= asOf
|
||||
const state: NonNullable<ReconciliationAccount['status']>['state'] =
|
||||
attested || status.is_reconciled ? 'reconciled' : 'open'
|
||||
return {
|
||||
state,
|
||||
as_of: status.as_of,
|
||||
unexplained_difference: status.unexplained_difference,
|
||||
open_counts: { proposed: 0, unmatched_external: 0, unmatched_ledger: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every balance account with a balance or a movement through the date, plus
|
||||
* any account that carries a sign-off, minus the accounts other adapters own.
|
||||
* One trial balance read for the balances; one read per specification source.
|
||||
*/
|
||||
export async function listManualAccounts(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
options: ListManualAccountsOptions,
|
||||
): Promise<ReconciliationAccount[]> {
|
||||
const snapshot = await loadBalanceSheetSnapshot(supabase, companyId, options.asOf)
|
||||
if (!snapshot) return []
|
||||
const withStatus = options.withStatus ?? true
|
||||
|
||||
const candidates = new Map<string, BalanceRow>()
|
||||
for (const row of snapshot.rows.values()) {
|
||||
if (options.exclude.has(row.account_number)) continue
|
||||
if (Math.abs(row.closing_balance) < BALANCE_TOLERANCE && Math.abs(row.movement) < BALANCE_TOLERANCE) continue
|
||||
candidates.set(row.account_number, row)
|
||||
}
|
||||
for (const [key, signoff] of options.signoffs) {
|
||||
if (!signoff || !key.startsWith('manual:')) continue
|
||||
const accountNumber = key.slice('manual:'.length)
|
||||
if (options.exclude.has(accountNumber) || candidates.has(accountNumber)) continue
|
||||
const row = snapshot.rows.get(accountNumber) ?? {
|
||||
account_number: accountNumber,
|
||||
account_name: `Konto ${accountNumber}`,
|
||||
opening_balance: 0,
|
||||
movement: 0,
|
||||
closing_balance: 0,
|
||||
}
|
||||
candidates.set(accountNumber, row)
|
||||
}
|
||||
|
||||
const providerAccounts = new Set([...candidates.keys()].filter((n) => SPECIFICATION_PROVIDERS[n]))
|
||||
const specifications =
|
||||
withStatus && providerAccounts.size > 0
|
||||
? await loadSpecificationAmounts(supabase, companyId, snapshot, providerAccounts)
|
||||
: new Map()
|
||||
|
||||
return [...candidates.values()]
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
.map((row): ReconciliationAccount => {
|
||||
const key = manualAccountKey(row.account_number)
|
||||
const signedOffThrough = options.signoffs.get(key)?.through_date ?? null
|
||||
const status = withStatus ? buildManualStatus(row, snapshot, specifications) : null
|
||||
return {
|
||||
account_key: key,
|
||||
kind: 'manual',
|
||||
account_number: row.account_number,
|
||||
name: row.account_name,
|
||||
currency: 'SEK',
|
||||
logo_url: null,
|
||||
source: { type: 'manual', synced_at: null, stale: false },
|
||||
status: status ? manualState(status, signedOffThrough, options.asOf) : null,
|
||||
superseded_by: null,
|
||||
signed_off_through: signedOffThrough,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,9 @@ import { z } from 'zod'
|
||||
* Account keys:
|
||||
* bank:<cash_account_id> one cash_accounts row (PSD2 or file-fed)
|
||||
* skattekonto the company's Skatteverket tax account (BAS 1630)
|
||||
* manual:<account_number> later: accounts with a typed external balance
|
||||
* manual:<account_number> any other balance account: reconciled against a
|
||||
* system specification (reskontra, semesterskuld)
|
||||
* or the balance the signer states from underlag
|
||||
*/
|
||||
export const ACCOUNT_KEY_REGEX =
|
||||
/^(bank:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|skattekonto|manual:\d{4})$/
|
||||
@@ -42,6 +44,10 @@ export function bankAccountKey(cashAccountId: string): string {
|
||||
|
||||
export const SKATTEKONTO_ACCOUNT_KEY = 'skattekonto' as const
|
||||
|
||||
export function manualAccountKey(accountNumber: string): string {
|
||||
return `manual:${accountNumber}`
|
||||
}
|
||||
|
||||
export const ReconciliationSourceSchema = z.object({
|
||||
type: z.enum(['psd2', 'bank_file', 'skatteverket_api', 'skatteverket_file', 'manual']),
|
||||
/** ISO timestamp of the last successful sync / import; null when never. */
|
||||
@@ -183,6 +189,29 @@ export const SkattekontoStatusBlockSchema = z.object({
|
||||
ledger_balance_before_start: z.number().nullable(),
|
||||
})
|
||||
|
||||
/** A specification the system keeps for a manual account, in ledger sign (debit positive). */
|
||||
export const ManualSpecificationSchema = z.object({
|
||||
provider: z.enum(['ar', 'ap', 'vacation']),
|
||||
label_sv: z.string(),
|
||||
label_en: z.string(),
|
||||
amount: z.number(),
|
||||
/** Foreign-currency rows left out for lack of a rate (see lib/reports/ar-reconciliation.ts). */
|
||||
unconverted_fx_count: z.number().int(),
|
||||
})
|
||||
export type ManualSpecification = z.infer<typeof ManualSpecificationSchema>
|
||||
|
||||
export const ManualStatusBlockSchema = z.object({
|
||||
period_id: z.string(),
|
||||
period_start: z.string(),
|
||||
period_end: z.string(),
|
||||
/** IB, movement and UB through the balansdag, debit positive (lib/reconciliation/manual-reconciliation.ts). */
|
||||
opening_balance: z.number(),
|
||||
movement: z.number(),
|
||||
closing_balance: z.number(),
|
||||
/** Null for accounts whose outside balance the signer states at sign-off. */
|
||||
specification: ManualSpecificationSchema.nullable(),
|
||||
})
|
||||
|
||||
export const ReconciliationStatusSchema = z.object({
|
||||
account_key: AccountKeySchema,
|
||||
kind: ReconciliationKindSchema,
|
||||
@@ -209,6 +238,8 @@ export const ReconciliationStatusSchema = z.object({
|
||||
skattekonto: SkattekontoStatusBlockSchema.nullable(),
|
||||
/** Today's bank status fields, unchanged, for the bank kind (see bank-reconciliation.ts). */
|
||||
bank: z.record(z.string(), z.unknown()).nullable(),
|
||||
/** Balances and specification for the manual kind; absent on the other kinds. */
|
||||
manual: ManualStatusBlockSchema.nullable().optional(),
|
||||
/** Latest active sign-off on the account (lib/reconciliation/signoff.ts); null when none. */
|
||||
signoff: ReconciliationSignoffSchema.nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from './schemas'
|
||||
import { getLatestSignoff, getLatestSignoffs } from './signoff-store'
|
||||
import { bankLogoUrl } from './bank-logos'
|
||||
import { getManualReconciliationStatus, listManualAccounts } from './manual-reconciliation'
|
||||
|
||||
const log = createLogger('reconciliation/service')
|
||||
|
||||
@@ -22,9 +23,10 @@ const log = createLogger('reconciliation/service')
|
||||
*
|
||||
* The dashboard routes, the public v1 API and the MCP tools all call these
|
||||
* functions; none of them re-implements bank or skattekonto logic. Kind
|
||||
* adapters (bank today via bank-reconciliation.ts, skattekonto via
|
||||
* skattekonto-reconciliation.ts, manual later) hang off `account_key`, so
|
||||
* adding an account type is one adapter, never a new set of endpoints.
|
||||
* adapters (bank via bank-reconciliation.ts, skattekonto via
|
||||
* skattekonto-reconciliation.ts, every other balance account via
|
||||
* manual-reconciliation.ts) hang off `account_key`, so adding an account
|
||||
* type is one adapter, never a new set of endpoints.
|
||||
*
|
||||
* Core runs with zero extensions: the skattekonto adapter reads the core
|
||||
* `skattekonto_transactions` table and the snapshot row the extension leaves
|
||||
@@ -338,7 +340,24 @@ export async function listReconciliationAccounts(
|
||||
})
|
||||
}
|
||||
|
||||
return skattekonto ? [...bankAccounts, skattekonto] : bankAccounts
|
||||
// The rest of the balance sheet: every account the two feeds above do not
|
||||
// own, reconciled against a system specification or the signer's underlag.
|
||||
// A failed read costs only this group, never the bank or skattekonto rows.
|
||||
let manualAccounts: ReconciliationAccount[] = []
|
||||
try {
|
||||
const exclude = new Set<string>(cashAccounts.map((a) => a.ledger_account))
|
||||
if (skattekonto) exclude.add(skattekonto.account_number)
|
||||
manualAccounts = await listManualAccounts(supabase, companyId, {
|
||||
asOf: window.to,
|
||||
exclude,
|
||||
signoffs,
|
||||
withStatus,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('manual accounts failed', { companyId, error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
|
||||
return [...bankAccounts, ...(skattekonto ? [skattekonto] : []), ...manualAccounts]
|
||||
}
|
||||
|
||||
export interface GetAccountStatusOptions {
|
||||
@@ -385,7 +404,12 @@ export async function getAccountStatus(
|
||||
}
|
||||
status = await bankStatus(supabase, companyId, data as CashAccountRow, window, today)
|
||||
}
|
||||
// manual accounts: later adapter
|
||||
if (parsed.kind === 'manual') {
|
||||
status = await getManualReconciliationStatus(supabase, companyId, parsed.accountNumber, {
|
||||
today,
|
||||
asOf: options.windowTo ?? today,
|
||||
})
|
||||
}
|
||||
if (!status) return null
|
||||
|
||||
// The latest active sign-off rides along on every status read (page, v1,
|
||||
@@ -396,5 +420,23 @@ export async function getAccountStatus(
|
||||
log.warn('sign-off read failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
|
||||
status.signoff = null
|
||||
}
|
||||
|
||||
// A manual account without a system specification has no live outside
|
||||
// balance; the one the signer stated for this very balansdag is the
|
||||
// attested truth, so the status shows it instead of "okänt".
|
||||
if (
|
||||
status.kind === 'manual' &&
|
||||
status.external_balance == null &&
|
||||
status.signoff &&
|
||||
status.signoff.external_balance != null &&
|
||||
status.signoff.through_date === status.as_of.slice(0, 10)
|
||||
) {
|
||||
const external = status.signoff.external_balance
|
||||
const difference = status.ledger_balance == null ? null : roundOre(status.ledger_balance - external)
|
||||
status.external_balance = external
|
||||
status.difference = difference
|
||||
status.unexplained_difference = difference
|
||||
status.is_reconciled = difference != null && Math.abs(difference) < 0.005
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { parseAccountKey, type ReconciliationSignoff, type ReconciliationStatus } from './schemas'
|
||||
import { getAccountStatus } from './service'
|
||||
import { getLatestSignoff, getSignoffById, insertSignoff, stampReopen } from './signoff-store'
|
||||
@@ -31,6 +32,7 @@ export type SignoffErrorCode =
|
||||
| 'SIGNOFF_NOT_FOUND'
|
||||
| 'ALREADY_REOPENED'
|
||||
| 'SIGNOFF_RACE'
|
||||
| 'EXTERNAL_BALANCE_NOT_ALLOWED'
|
||||
|
||||
export class ReconciliationSignoffError extends Error {
|
||||
readonly code: SignoffErrorCode
|
||||
@@ -48,6 +50,13 @@ export interface SignoffInput {
|
||||
note?: string | null
|
||||
/** Sign even though the engine reports an unexplained difference or an unknown outside balance. Needs a note. */
|
||||
force?: boolean
|
||||
/**
|
||||
* The balance per the signer's underlag, in ledger sign (liabilities
|
||||
* negative). Only for manual accounts without a system specification; the
|
||||
* bank, the skattekonto and the reskontra/semesterskuld accounts have their
|
||||
* own outside truth and refuse it.
|
||||
*/
|
||||
external_balance?: number | null
|
||||
}
|
||||
|
||||
export interface SignoffOptions {
|
||||
@@ -90,8 +99,7 @@ export async function signOffAccount(
|
||||
options: SignoffOptions = {},
|
||||
): Promise<SignoffResult | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
// Manual accounts get their adapter later; until then they are not reconcilable.
|
||||
if (!parsed || parsed.kind === 'manual') return null
|
||||
if (!parsed) return null
|
||||
const today = options.today ?? isoToday()
|
||||
const throughDate = input.through_date
|
||||
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
|
||||
@@ -125,6 +133,24 @@ export async function signOffAccount(
|
||||
)
|
||||
}
|
||||
|
||||
// A stated outside balance is the manual adapter's outside truth for the
|
||||
// date. Where the system keeps a specification (or a feed) the stated
|
||||
// number would only hide a real difference, so it is refused there.
|
||||
if (input.external_balance != null) {
|
||||
if (status.kind !== 'manual' || status.manual?.specification) {
|
||||
throw new ReconciliationSignoffError(
|
||||
'Kontot har redan en sanning utanför bokföringen (bank, Skatteverket, reskontra eller beräkning). Ange inget saldo manuellt; signera med en notering om något avviker.',
|
||||
'EXTERNAL_BALANCE_NOT_ALLOWED',
|
||||
)
|
||||
}
|
||||
const external = roundOre(input.external_balance)
|
||||
const difference = status.ledger_balance == null ? null : roundOre(status.ledger_balance - external)
|
||||
status.external_balance = external
|
||||
status.difference = difference
|
||||
status.unexplained_difference = difference
|
||||
status.is_reconciled = difference != null && Math.abs(difference) < 0.005
|
||||
}
|
||||
|
||||
const unexplained = status.unexplained_difference
|
||||
const reconciled = unexplained != null && Math.abs(unexplained) < 0.005
|
||||
if (!reconciled && !force) {
|
||||
@@ -212,7 +238,7 @@ export async function reopenSignoff(
|
||||
input: { reason?: string | null } = {},
|
||||
): Promise<ReconciliationSignoff | null> {
|
||||
const parsed = parseAccountKey(accountKey)
|
||||
if (!parsed || parsed.kind === 'manual') return null
|
||||
if (!parsed) return null
|
||||
const existing = await getSignoffById(supabase, companyId, accountKey, signoffId)
|
||||
if (!existing) {
|
||||
throw new ReconciliationSignoffError('Signeringen hittades inte.', 'SIGNOFF_NOT_FOUND')
|
||||
|
||||
Reference in New Issue
Block a user