Files
accounted/lib/reconciliation/manual-reconciliation.ts
T
ebbdf96b74 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>
2026-08-25 09:23:14 +02:00

414 lines
15 KiB
TypeScript

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,
}
})
}