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
@@ -374,4 +374,63 @@ describe('getAccountStatus', () => {
|
||||
expect(s.unexplained_difference).toBe(0)
|
||||
expect(s.bank).toMatchObject({ bank_transaction_total: 122288 })
|
||||
})
|
||||
|
||||
it('exposes the bank-reported balance from cash_accounts on the bank kind (F7)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: cashAccount(ID_A, {
|
||||
balance: 125430.5,
|
||||
available_balance: 123930.5,
|
||||
balance_updated_at: '2026-08-20T05:12:00Z',
|
||||
}),
|
||||
})
|
||||
bankStatusMock.mockResolvedValue(bankStatus({ difference: -46, unexplained_difference: 0 }))
|
||||
enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } })
|
||||
|
||||
const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' })
|
||||
if (!s) throw new Error('expected status')
|
||||
// external_balance stays null for bank: sign-off persists it into
|
||||
// account_reconciliations and bokslutsbilagor computes closing - external
|
||||
// from that row, so a today-balance on a balansdag sign-off would print a
|
||||
// phantom differens. The reported balance lives only in the bank block.
|
||||
expect(s.external_balance).toBeNull()
|
||||
expect(s.difference).toBe(-46)
|
||||
expect(s.unexplained_difference).toBe(0)
|
||||
expect(s.bank).toMatchObject({
|
||||
bank_reported_balance: 125430.5,
|
||||
bank_reported_available_balance: 123930.5,
|
||||
bank_balance_updated_at: '2026-08-20T05:12:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves external_balance null on the bank kind when no balance was ever synced', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: cashAccount(ID_A, { balance: null, available_balance: null, balance_updated_at: null }) })
|
||||
bankStatusMock.mockResolvedValue(bankStatus())
|
||||
enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } })
|
||||
|
||||
const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' })
|
||||
if (!s) throw new Error('expected status')
|
||||
expect(s.external_balance).toBeNull()
|
||||
expect(s.bank).toMatchObject({
|
||||
bank_reported_balance: null,
|
||||
bank_reported_available_balance: null,
|
||||
bank_balance_updated_at: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('suppresses a stored balance that has no timestamp (age unknown = unusable)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: cashAccount(ID_A, { balance: 500, available_balance: 400, balance_updated_at: null }) })
|
||||
bankStatusMock.mockResolvedValue(bankStatus())
|
||||
enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } })
|
||||
|
||||
const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' })
|
||||
if (!s) throw new Error('expected status')
|
||||
expect(s.bank).toMatchObject({
|
||||
bank_reported_balance: null,
|
||||
bank_reported_available_balance: null,
|
||||
bank_balance_updated_at: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,9 @@ interface CashAccountRow {
|
||||
is_primary: boolean | null
|
||||
source: string | null
|
||||
bank_connection_id: string | null
|
||||
balance: number | null
|
||||
available_balance: number | null
|
||||
balance_updated_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
@@ -156,6 +159,24 @@ async function bankStatus(
|
||||
)
|
||||
const syncedAt = await latestBankSyncAt(supabase, companyId, account.id)
|
||||
const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS
|
||||
// The bank-reported (booked) balance, mirrored from the last PSD2 balance
|
||||
// refresh. Point-in-time and dated by balance_updated_at, NOT by any
|
||||
// through-date a caller asks for. It therefore lives ONLY in the bank block
|
||||
// below, never in external_balance: sign-off persists external_balance into
|
||||
// account_reconciliations and bokslutsbilagor computes closing - external
|
||||
// from that row, so a today-balance stored on a balansdag sign-off would
|
||||
// print a phantom differens in the year-end appendix (skeptic finding,
|
||||
// PR #2118). difference/unexplained stay transaction-based for the same
|
||||
// reason. A balance without its timestamp is unusable (age unknown), so
|
||||
// both fields are exposed only as a pair.
|
||||
const reportedBalance =
|
||||
account.balance == null || account.balance_updated_at == null
|
||||
? null
|
||||
: Number(account.balance)
|
||||
const reportedAvailable =
|
||||
reportedBalance == null || account.available_balance == null
|
||||
? null
|
||||
: Number(account.available_balance)
|
||||
return {
|
||||
account_key: bankAccountKey(account.id),
|
||||
kind: 'bank',
|
||||
@@ -178,7 +199,14 @@ async function bankStatus(
|
||||
ignored: raw.ignored_transaction_count,
|
||||
},
|
||||
skattekonto: null,
|
||||
bank: raw as unknown as Record<string, unknown>,
|
||||
bank: {
|
||||
...(raw as unknown as Record<string, unknown>),
|
||||
// What the bank itself reports for the account (F7): booked +
|
||||
// available + when it was fetched. Distinct from the movement fields.
|
||||
bank_reported_balance: reportedBalance,
|
||||
bank_reported_available_balance: reportedAvailable,
|
||||
bank_balance_updated_at: reportedBalance == null ? null : account.balance_updated_at,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +249,7 @@ export async function listReconciliationAccounts(
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, updated_at')
|
||||
.select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, balance, available_balance, balance_updated_at, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('enabled', true)
|
||||
.order('is_primary', { ascending: false })
|
||||
@@ -392,7 +420,7 @@ export async function getAccountStatus(
|
||||
if (parsed.kind === 'bank') {
|
||||
const { data, error } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, updated_at')
|
||||
.select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, balance, available_balance, balance_updated_at, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', parsed.cashAccountId)
|
||||
.maybeSingle()
|
||||
|
||||
Reference in New Issue
Block a user