feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API (#2118)

* feat(bank): expose bank-reported balance (booked + available) in UI, reconciliation, MCP and v1 API

The PSD2 sync has fetched the bank's reported balance for years but the
data was stranded (F7): the Bank-page source picker read a cash_accounts
column no sync ever updated (frozen at connect time), reconciliation
hard-coded external_balance to null for bank accounts, and neither MCP
nor the v1 API exposed any balance at all, so the only path to a current
bank balance was logging into the bank.

- getAccountBalance now returns booked + available from the same
  quota-limited BALANCES response (previously all but one type discarded)
- every sync (manual + cron) mirrors balance, available_balance and
  balance_updated_at into cash_accounts, fixing the stale picker
- new cash_accounts.available_balance column (additive migration)
- reconciliation bank kind: external_balance = bank-reported balance,
  plus bank_reported_* fields and fetch timestamp in the bank block;
  difference math stays movement-based and untouched
- reconciliation view shows "Saldo enligt banken ... hamtat {date}"
- MCP gnubok_list_cash_accounts returns the three balance fields; the
  cash_today prompt now reports the bank's figure instead of teaching
  agents to answer with the bookkept 19xx balance
- new GET /api/v1/companies/{companyId}/cash-accounts endpoint

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

* fix(bank): keep external_balance null for bank sign-offs; never fabricate a zero balance; guard the mirror against stale writers

Post-review fixes from the skeptic pass + CodeRabbit on PR #2118:

- external_balance stays null for the bank reconciliation kind: sign-off
  persists it into account_reconciliations and bokslutsbilagor computes
  closing - external from that row, so a today-balance stored on a
  balansdag sign-off printed a phantom warning-red differens in the
  year-end appendix. The bank-reported figure lives only in the
  timestamped bank_reported_* pair in the bank block, and only when its
  fetch timestamp exists (a balance of unknown age is suppressed).
- AccountOverview no longer falls back to today's date when the balance
  timestamp is missing; the line is omitted instead.
- getAccountBalance returns null on an empty BALANCES response instead
  of fabricating amount 0 with a fresh timestamp; sync keeps the
  previous stored value.
- updateBalancesFromSync only writes over an older-or-missing
  balance_updated_at, so an older sync run finishing later cannot move
  the mirrored balance backwards.
- The inline initial backfill (picker save) now mirrors fetched
  balances into cash_accounts too (accounts_data is deliberately not
  re-written there).
- cash_today MCP prompt mentions the gnubok_call_tool bridge for hosts
  that only see the default catalog.

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

* fix(bank): express the stale-writer guard as two literal predicates for the schema guard

The .or() with a template literal pushed the no-phantom-columns
unresolvable-expression count over its ceiling. Same semantics, two
updates: one for rows with an older timestamp, one for rows with none.

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

* fix(bank): rank interimBooked (ITBD) as a booked balance type before the generic fallback

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-01 16:16:29 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a57a8d968b
commit cd40127f0e
30 changed files with 985 additions and 28 deletions
@@ -26,7 +26,7 @@ describe('gnubok_list_cash_accounts', () => {
it('maps cash_accounts rows to the qualified wire shape, primary first', async () => {
vi.mocked(listForCompany).mockResolvedValue([
{ id: 'ca-1', company_id: 'company-1', ledger_account: '1930', name: 'Företagskonto', currency: 'SEK', iban: 'SE4550000000058398257466', is_primary: true, enabled: true, source: 'enable_banking' },
{ id: 'ca-1', company_id: 'company-1', ledger_account: '1930', name: 'Företagskonto', currency: 'SEK', iban: 'SE4550000000058398257466', is_primary: true, enabled: true, source: 'enable_banking', balance: 12500.75, available_balance: 12000.5, balance_updated_at: '2026-09-01T05:00:00.000Z' },
{ id: 'ca-2', company_id: 'company-1', ledger_account: '1940', name: null, currency: 'SEK', iban: null, is_primary: false, enabled: false, source: 'manual' },
] as never)
@@ -46,8 +46,12 @@ describe('gnubok_list_cash_accounts', () => {
is_primary: true,
enabled: true,
source: 'enable_banking',
balance: 12500.75,
available_balance: 12000.5,
balance_updated_at: '2026-09-01T05:00:00.000Z',
})
expect(result.cash_accounts[1]).toMatchObject({ cash_account_id: 'ca-2', name: null, iban: null, enabled: false, source: 'manual' })
// Manual accounts have no bank-reported balance: explicit nulls, never absent.
expect(result.cash_accounts[1]).toMatchObject({ cash_account_id: 'ca-2', name: null, iban: null, enabled: false, source: 'manual', balance: null, available_balance: null, balance_updated_at: null })
// No bare `id` leaks onto the wire (qualified-ids convention).
expect(result.cash_accounts[0]).not.toHaveProperty('id')
})
@@ -17,8 +17,12 @@ export const prompts: McpPrompt[] = [
name: 'cash_today',
description: 'Visa banksaldo just nu',
text:
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_get_balance_sheet ' +
'för dagens datum och rapportera saldot på konto 1930. Visa även de senaste 5 transaktionerna ' +
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_list_cash_accounts ' +
'(syns det inte i verktygskatalogen: anropa det via gnubok_call_tool) och ' +
'rapportera bankens rapporterade saldo (balance, available_balance) per konto med tidsstämpeln ' +
'balance_updated_at. Saknas rapporterat saldo (manuellt konto eller aldrig synkat): fall tillbaka ' +
'på gnubok_get_balance_sheet för dagens datum och saldot på konto 1930, och säg att siffran är ' +
'bokförd, inte bankens. Visa även de senaste 5 transaktionerna ' +
'via gnubok_list_uncategorized_transactions (limit=5, sortera nyast först: men inkludera även ' +
'kategoriserade om verktyget tillåter). Svara kort på svenska.',
},
+17 -2
View File
@@ -11878,7 +11878,7 @@ export const tools: McpTool[] = [
name: 'gnubok_list_cash_accounts',
keywords: ['bankkonto', 'kassakonto', 'bankkonton', 'likvidkonton', 'kassa'],
title: 'List Cash Accounts',
description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag. Use cash_account_id to filter transaction listings and account_number (ledger_account) for gnubok_get_reconciliation_status.',
description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag, bank-reported balance (booked + available, with balance_updated_at). Use cash_account_id to filter transaction listings; use for "how much money is in the bank".',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -11904,8 +11904,20 @@ export const tools: McpTool[] = [
is_primary: { type: 'boolean' },
enabled: { type: 'boolean' },
source: { type: 'string', enum: ['enable_banking', 'manual', 'sie_import'] },
balance: {
type: ['number', 'null'],
description: 'Bank-reported booked balance as of balance_updated_at; null for manual accounts or before the first sync. NOT the bookkept 19xx balance.',
},
available_balance: {
type: ['number', 'null'],
description: 'Bank-reported available balance; null when the bank reports no available type.',
},
balance_updated_at: {
type: ['string', 'null'],
description: 'ISO timestamp of the last balance fetch (PSD2 quota: refreshed at most every 12h).',
},
},
required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source'],
required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source', 'balance', 'available_balance', 'balance_updated_at'],
},
},
count: { type: 'number' },
@@ -11934,6 +11946,9 @@ export const tools: McpTool[] = [
is_primary: row.is_primary === true,
enabled: row.enabled !== false,
source: row.source,
balance: row.balance ?? null,
available_balance: row.available_balance ?? null,
balance_updated_at: row.balance_updated_at ?? null,
}))
return { cash_accounts: cashAccounts, count: cashAccounts.length }
},