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
@@ -740,27 +740,65 @@ export async function getAccountBalances(accountUid: string): Promise<Balance[]>
|
||||
return data.balances || []
|
||||
}
|
||||
|
||||
// Balance-type preference orders. ASPSPs report types either as camelCase
|
||||
// names or ISO 20022 codes; both spellings of each type are accepted,
|
||||
// case-insensitively. Booked answers "what has the bank settled", available
|
||||
// answers "what can be spent right now" (the covering-decision number).
|
||||
// closingBooked (settled, definitive) wins over expected, which wins over
|
||||
// interimBooked (intraday booked): fall through to less-final booked types
|
||||
// only when the stabler one is absent, and to the generic first-entry
|
||||
// fallback only when no booked type exists at all.
|
||||
const BOOKED_BALANCE_TYPES = ['closingbooked', 'clbd', 'expected', 'xpcd', 'interimbooked', 'itbd']
|
||||
const AVAILABLE_BALANCE_TYPES = [
|
||||
'interimavailable',
|
||||
'itav',
|
||||
'closingavailable',
|
||||
'clav',
|
||||
'forwardavailable',
|
||||
'fwav',
|
||||
]
|
||||
|
||||
function pickBalanceByType(balances: Balance[], preference: string[]): Balance | undefined {
|
||||
for (const type of preference) {
|
||||
const match = balances.find(b => b.balance_type?.toLowerCase() === type)
|
||||
if (match) return match
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account balance (returns booked balance amount)
|
||||
* Get account balance from one BALANCES call: the booked amount (falling back
|
||||
* to the first reported balance, as before) plus the available amount when the
|
||||
* ASPSP reports one. One call: both figures come from the same quota-limited
|
||||
* response, so exposing `available` costs nothing extra.
|
||||
*
|
||||
* Returns null when the ASPSP reports NO balances at all. The old behavior
|
||||
* fabricated `amount: 0` here; once balances became user-facing ("how much
|
||||
* money is in the bank", covering decisions before payment runs) a fabricated
|
||||
* zero with a fresh timestamp is dangerous, so the caller keeps its previous
|
||||
* stored value instead.
|
||||
*/
|
||||
export async function getAccountBalance(
|
||||
accountUid: string
|
||||
): Promise<{ amount: number; date: string }> {
|
||||
): Promise<{ amount: number; date: string; available: number | null } | null> {
|
||||
const balances = await getAccountBalances(accountUid)
|
||||
|
||||
// Prefer closingBooked, then expected, then first available
|
||||
const balance =
|
||||
balances.find(b => b.balance_type === 'closingBooked') ||
|
||||
balances.find(b => b.balance_type === 'expected') ||
|
||||
balances[0]
|
||||
const balance = pickBalanceByType(balances, BOOKED_BALANCE_TYPES) || balances[0]
|
||||
|
||||
if (!balance) {
|
||||
return { amount: 0, date: new Date().toISOString().split('T')[0] }
|
||||
return null
|
||||
}
|
||||
|
||||
const availableBalance = pickBalanceByType(balances, AVAILABLE_BALANCE_TYPES)
|
||||
const available = availableBalance
|
||||
? parseFloat(availableBalance.balance_amount.amount)
|
||||
: null
|
||||
|
||||
return {
|
||||
amount: parseFloat(balance.balance_amount.amount),
|
||||
date: balance.reference_date || new Date().toISOString().split('T')[0]
|
||||
date: balance.reference_date || new Date().toISOString().split('T')[0],
|
||||
available: available != null && Number.isFinite(available) ? available : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user