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
+1
View File
@@ -14,6 +14,7 @@ function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
currency: 'SEK',
ledger_account: '1930',
balance: null,
available_balance: null,
balance_updated_at: null,
enabled: true,
is_primary: true,
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `142`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `143`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
@@ -19,6 +19,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies",
"GET /api/v1/companies/:companyId/accounts",
"GET /api/v1/companies/:companyId/articles",
"GET /api/v1/companies/:companyId/cash-accounts",
"GET /api/v1/companies/:companyId/compliance/check",
"GET /api/v1/companies/:companyId/customers",
"GET /api/v1/companies/:companyId/customers/:id",
+1
View File
@@ -71,6 +71,7 @@ import '@/app/api/v1/companies/[companyId]/transactions/ingest/route'
import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route'
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route'
import '@/app/api/v1/companies/[companyId]/cash-accounts/route'
// Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals.
import '@/app/api/v1/companies/[companyId]/suppliers/route'
+3
View File
@@ -145,6 +145,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
// Writes: bulk
'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
'POST /api/v1/companies/:companyId/transactions/batch-categorize': 'transactions:write',
// Cash accounts: the bank/kassa register incl. the bank-reported balance
// (booked + available + balance_updated_at) from the PSD2 sync.
'GET /api/v1/companies/:companyId/cash-accounts': 'transactions:read',
// Reconciliation (legacy bank-only routes; kept as aliases of the
// account-keyed routes below, with their original scopes)
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
+104
View File
@@ -18,6 +18,7 @@ import {
defaultLedgerForCurrency,
getRevokedConnectionIds,
upsertFromPsd2,
updateBalancesFromSync,
ensureManualCashAccount,
} from '../service'
@@ -1210,3 +1211,106 @@ describe('ensureManualCashAccount', () => {
).rejects.toThrow(/boom/)
})
})
// ---------------------------------------------------------------------------
// updateBalancesFromSync: balance mirror from the PSD2 sync loop
// ---------------------------------------------------------------------------
describe('updateBalancesFromSync', () => {
interface BalanceUpdate {
payload: Record<string, unknown>
filters: Array<[string, unknown]>
}
function makeBalanceStub(updateError: { message: string } | null = null) {
const updates: BalanceUpdate[] = []
const supabase = {
from: vi.fn((table: string) => {
if (table !== 'cash_accounts') throw new Error(`unexpected table ${table}`)
return {
update: vi.fn((payload: Record<string, unknown>) => {
const entry: BalanceUpdate = { payload, filters: [] }
updates.push(entry)
const chain = {
eq: vi.fn((col: string, val: unknown) => {
entry.filters.push([col, val])
return chain
}),
lt: vi.fn((col: string, val: unknown) => {
entry.filters.push([`lt:${col}`, val])
return chain
}),
is: vi.fn((col: string, val: unknown) => {
entry.filters.push([`is:${col}`, val])
return chain
}),
then: (onFulfilled: (value: unknown) => unknown) =>
Promise.resolve({ error: updateError }).then(onFulfilled),
}
return chain
}),
}
}),
} as unknown as SupabaseClient
return { supabase, updates }
}
it('updates only balance fields, keyed on company + connection + uid', async () => {
const { supabase, updates } = makeBalanceStub()
await updateBalancesFromSync(supabase, 'c1', 'conn-1', [
{
external_uid: 'uid-1',
balance: 1000.5,
available_balance: 950.25,
balance_updated_at: '2026-09-01T05:00:00.000Z',
},
])
// Two writes per account: one for rows with an OLDER timestamp, one for
// rows with NO timestamp. Together they are the stale-writer guard: an
// older sync run finishing later must not move the mirror backwards.
expect(updates).toHaveLength(2)
for (const u of updates) {
expect(u.payload).toEqual({
balance: 1000.5,
available_balance: 950.25,
balance_updated_at: '2026-09-01T05:00:00.000Z',
})
}
expect(updates[0].filters).toEqual([
['company_id', 'c1'],
['bank_connection_id', 'conn-1'],
['external_uid', 'uid-1'],
['lt:balance_updated_at', '2026-09-01T05:00:00.000Z'],
])
expect(updates[1].filters).toEqual([
['company_id', 'c1'],
['bank_connection_id', 'conn-1'],
['external_uid', 'uid-1'],
['is:balance_updated_at', null],
])
})
it('skips accounts without a timestamped balance (never nulls a stored one)', async () => {
const { supabase, updates } = makeBalanceStub()
await updateBalancesFromSync(supabase, 'c1', 'conn-1', [
{ external_uid: 'uid-no-balance', balance: null, balance_updated_at: '2026-09-01T05:00:00.000Z' },
{ external_uid: 'uid-no-timestamp', balance: 100 },
{ external_uid: 'uid-ok', balance: 200, balance_updated_at: '2026-09-01T05:00:00.000Z' },
])
expect(updates).toHaveLength(2)
expect(updates[0].filters).toContainEqual(['external_uid', 'uid-ok'])
// A refresh without an available type writes null: a stale available
// figure next to a fresh booked figure would misstate what can be spent.
expect(updates[0].payload.available_balance).toBeNull()
})
it('logs update failures instead of throwing (mirror must not fail the sync)', async () => {
const { supabase } = makeBalanceStub({ message: 'boom' })
await expect(
updateBalancesFromSync(supabase, 'c1', 'conn-1', [
{ external_uid: 'uid-1', balance: 1, balance_updated_at: '2026-09-01T05:00:00.000Z' },
]),
).resolves.toBeUndefined()
})
})
+72
View File
@@ -45,6 +45,7 @@ export interface UpsertFromPsd2Input {
iban?: string | null
name?: string | null
balance?: number | null
available_balance?: number | null
balance_updated_at?: string | null
enabled?: boolean
/**
@@ -1088,6 +1089,76 @@ async function rebindMovableTransactions(
return moved
}
/** One account's refreshed balance snapshot, as the sync loop stores it. */
export interface SyncedBalanceInput {
external_uid: string
balance?: number | null
available_balance?: number | null
balance_updated_at?: string | null
}
/**
* Mirror freshly-synced balances from bank_connections.accounts_data into
* cash_accounts. Before this, cash_accounts.balance was written only at
* connect/selection-save time and then drifted: the transactions-page source
* picker (which reads cash_accounts) showed a connect-time snapshot as if it
* were current.
*
* Balance-only by design: routing fields (ledger_account, enabled, name) are
* owned by the picker-save and callback paths via upsertFromPsd2. Rows are
* matched on (company_id, bank_connection_id, external_uid); accounts without
* a timestamped balance are skipped (never null out a stored balance because
* one refresh was skipped or failed). Mirror failures are logged, not thrown:
* a failed mirror must not fail the sync that produced the data.
*/
export async function updateBalancesFromSync(
supabase: SupabaseClient,
companyId: string,
bankConnectionId: string,
accounts: SyncedBalanceInput[],
): Promise<void> {
for (const account of accounts) {
if (account.balance == null || !account.balance_updated_at) continue
// Manual sync and cron are not serialized per connection: an older run
// finishing later must not overwrite a newer mirror (the timestamp would
// visibly move backwards). Only rows with an older-or-missing timestamp
// accept the write. Two literal predicates instead of one .or(), and the
// payload inlined twice: the schema guard cannot resolve dynamically-built
// logical expressions or payload variables.
const { error: staleError } = await supabase
.from('cash_accounts')
.update({
balance: account.balance,
available_balance: account.available_balance ?? null,
balance_updated_at: account.balance_updated_at,
})
.eq('company_id', companyId)
.eq('bank_connection_id', bankConnectionId)
.eq('external_uid', account.external_uid)
.lt('balance_updated_at', account.balance_updated_at)
const { error: nullError } = await supabase
.from('cash_accounts')
.update({
balance: account.balance,
available_balance: account.available_balance ?? null,
balance_updated_at: account.balance_updated_at,
})
.eq('company_id', companyId)
.eq('bank_connection_id', bankConnectionId)
.eq('external_uid', account.external_uid)
.is('balance_updated_at', null)
const error = staleError ?? nullError
if (error) {
log.error('updateBalancesFromSync failed', {
companyId,
bankConnectionId,
externalUid: account.external_uid,
error: error.message,
})
}
}
}
/**
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
* (company_id, bank_connection_id, external_uid). When the row exists, balance
@@ -1110,6 +1181,7 @@ export async function upsertFromPsd2(
currency: input.currency.toUpperCase(),
ledger_account: input.ledger_account,
balance: input.balance ?? null,
available_balance: input.available_balance ?? null,
balance_updated_at: input.balance_updated_at ?? null,
enabled: input.enabled ?? true,
source: 'enable_banking' as CashAccountSource,
@@ -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,
})
})
})
+31 -3
View File
@@ -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()