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:
co-authored by
Claude Fable 5
parent
a57a8d968b
commit
cd40127f0e
@@ -13,6 +13,7 @@ vi.stubEnv('ENABLE_BANKING_API_URL', 'https://api.test.com')
|
||||
|
||||
import {
|
||||
getASPSPs,
|
||||
getAccountBalance,
|
||||
getAccountBalances,
|
||||
getAccountTransactions,
|
||||
getAllTransactions,
|
||||
@@ -53,6 +54,97 @@ describe('api-client', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Balance-type selection
|
||||
// -------------------------------------------------------------------------
|
||||
describe('getAccountBalance', () => {
|
||||
function balancesResponse(balances: unknown[]): Response {
|
||||
return new Response(JSON.stringify({ balances }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
it('returns booked (closingBooked) plus available (interimAvailable) from one response', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
balancesResponse([
|
||||
{ balance_type: 'interimAvailable', balance_amount: { amount: '900.50', currency: 'SEK' } },
|
||||
{ balance_type: 'closingBooked', balance_amount: { amount: '1000.00', currency: 'SEK' }, reference_date: '2026-09-01' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result).toEqual({ amount: 1000, date: '2026-09-01', available: 900.5 })
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('accepts ISO 20022 codes (CLBD/ITAV) case-insensitively', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
balancesResponse([
|
||||
{ balance_type: 'ITAV', balance_amount: { amount: '450.25', currency: 'SEK' } },
|
||||
{ balance_type: 'CLBD', balance_amount: { amount: '500.00', currency: 'SEK' }, reference_date: '2026-09-01' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result?.amount).toBe(500)
|
||||
expect(result?.available).toBe(450.25)
|
||||
})
|
||||
|
||||
it('returns available: null when the bank reports no available type', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
balancesResponse([
|
||||
{ balance_type: 'closingBooked', balance_amount: { amount: '1000.00', currency: 'SEK' }, reference_date: '2026-09-01' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result).toEqual({ amount: 1000, date: '2026-09-01', available: null })
|
||||
})
|
||||
|
||||
it('falls back to the first balance for booked, never to an available type by preference', async () => {
|
||||
// Only an unknown type: the pre-existing first-entry fallback applies.
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
balancesResponse([
|
||||
{ balance_type: 'somethingElse', balance_amount: { amount: '42.00', currency: 'SEK' }, reference_date: '2026-08-31' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result).toEqual({ amount: 42, date: '2026-08-31', available: null })
|
||||
})
|
||||
|
||||
it('prefers interimBooked (ITBD) over the generic first-entry fallback', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
balancesResponse([
|
||||
{ balance_type: 'somethingElse', balance_amount: { amount: '1.00', currency: 'SEK' } },
|
||||
{ balance_type: 'ITBD', balance_amount: { amount: '3.00', currency: 'SEK' }, reference_date: '2026-09-01' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result?.amount).toBe(3)
|
||||
})
|
||||
|
||||
it('returns null (never a fabricated 0) when the bank reports no balances at all', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(balancesResponse([]))
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers expected over the first entry when closingBooked is missing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
balancesResponse([
|
||||
{ balance_type: 'other', balance_amount: { amount: '1.00', currency: 'SEK' } },
|
||||
{ balance_type: 'expected', balance_amount: { amount: '2.00', currency: 'SEK' }, reference_date: '2026-09-01' },
|
||||
])
|
||||
)
|
||||
|
||||
const result = await getAccountBalance('acc-1')
|
||||
expect(result?.amount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Retry
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user