* 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>
65 lines
3.0 KiB
TypeScript
65 lines
3.0 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
|
|
|
vi.mock('@/lib/cash-accounts/service', async () => {
|
|
const actual = await vi.importActual<typeof import('@/lib/cash-accounts/service')>('@/lib/cash-accounts/service')
|
|
return { ...actual, listForCompany: vi.fn() }
|
|
})
|
|
|
|
import { listForCompany } from '@/lib/cash-accounts/service'
|
|
import { tools, isDefaultCatalogTool } from '../server'
|
|
|
|
const tool = tools.find((t) => t.name === 'gnubok_list_cash_accounts')!
|
|
|
|
describe('gnubok_list_cash_accounts', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('is a read-only, search-only transactions:read discovery tool', () => {
|
|
expect(tool).toBeDefined()
|
|
expect(TOOL_SCOPE_MAP.gnubok_list_cash_accounts).toBe('transactions:read')
|
|
expect(tool.annotations).toMatchObject({ readOnlyHint: true, destructiveHint: false, idempotentHint: true })
|
|
expect(tool.catalogVisibility).toBe('search')
|
|
expect(isDefaultCatalogTool(tool)).toBe(false)
|
|
})
|
|
|
|
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', 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)
|
|
|
|
const result = (await tool.execute({}, 'company-1', 'user-1', {} as never)) as {
|
|
cash_accounts: Array<Record<string, unknown>>
|
|
count: number
|
|
}
|
|
|
|
expect(listForCompany).toHaveBeenCalledWith({}, 'company-1', { enabledOnly: false })
|
|
expect(result.count).toBe(2)
|
|
expect(result.cash_accounts[0]).toEqual({
|
|
cash_account_id: 'ca-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',
|
|
})
|
|
// 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')
|
|
})
|
|
|
|
it('passes enabled_only through', async () => {
|
|
vi.mocked(listForCompany).mockResolvedValue([])
|
|
await tool.execute({ enabled_only: true }, 'company-1', 'user-1', {} as never)
|
|
expect(listForCompany).toHaveBeenCalledWith({}, 'company-1', { enabledOnly: true })
|
|
})
|
|
})
|