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
@@ -848,6 +848,23 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
|
||||
const syncedAt = new Date().toISOString()
|
||||
// Mirror refreshed balances into cash_accounts: the Bank-page source
|
||||
// picker and the reconciliation status read that table, and without
|
||||
// this the balance there froze at connect time.
|
||||
{
|
||||
const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service')
|
||||
await updateBalancesFromSync(
|
||||
supabase,
|
||||
companyId,
|
||||
connection.id,
|
||||
allAccounts.map((a) => ({
|
||||
external_uid: a.uid,
|
||||
balance: a.balance,
|
||||
available_balance: a.available_balance,
|
||||
balance_updated_at: a.balance_updated_at,
|
||||
})),
|
||||
)
|
||||
}
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
@@ -1376,6 +1393,7 @@ export const enableBankingExtension: Extension = {
|
||||
iban: a.iban ?? null,
|
||||
name: a.name ?? null,
|
||||
balance: a.balance ?? null,
|
||||
available_balance: a.available_balance ?? null,
|
||||
balance_updated_at: a.balance_updated_at ?? null,
|
||||
enabled: a.enabled ?? true,
|
||||
reuse_cash_account_id: reuseCashAccountId,
|
||||
@@ -1598,6 +1616,30 @@ export const enableBankingExtension: Extension = {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the balances the backfill just fetched into cash_accounts.
|
||||
// accounts_data is deliberately NOT re-written here (see below), so
|
||||
// without this the balances fetched during the initial sync would
|
||||
// reach neither store until the next scheduled sync.
|
||||
try {
|
||||
const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service')
|
||||
await updateBalancesFromSync(
|
||||
supabase,
|
||||
companyId,
|
||||
connection.id,
|
||||
updatedAccounts.map((a) => ({
|
||||
external_uid: a.uid,
|
||||
balance: a.balance,
|
||||
available_balance: a.available_balance,
|
||||
balance_updated_at: a.balance_updated_at,
|
||||
})),
|
||||
)
|
||||
} catch (mirrorErr) {
|
||||
log.error('[enable-banking] Balance mirror after initial backfill failed', {
|
||||
connectionId: connection.id,
|
||||
error: mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr),
|
||||
})
|
||||
}
|
||||
|
||||
const completedAt = new Date().toISOString()
|
||||
// Don't re-write accounts_data here: the first update already wrote it.
|
||||
// Including it again races with any concurrent writer (e.g. cron firing in
|
||||
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -608,7 +608,7 @@ describe('syncAccountTransactions', () => {
|
||||
|
||||
it('refreshes the balance when the stored balance is older than 12 hours', async () => {
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01' })
|
||||
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01', available: 1100.5 })
|
||||
|
||||
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
|
||||
const account = makeAccount({ balance: 500, balance_updated_at: staleAt })
|
||||
@@ -620,9 +620,48 @@ describe('syncAccountTransactions', () => {
|
||||
|
||||
expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1')
|
||||
expect(account.balance).toBe(1234.56)
|
||||
expect(account.available_balance).toBe(1100.5)
|
||||
expect(account.balance_updated_at).not.toBe(staleAt)
|
||||
})
|
||||
|
||||
it('keeps the previous balance and timestamp when the bank reports no balances (null result)', async () => {
|
||||
// A 200 with zero balances used to fabricate amount 0; with balances now
|
||||
// user-facing that would pin "banken rapporterar 0 kr" for 12h.
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
mockGetAccountBalance.mockResolvedValue(null)
|
||||
|
||||
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
|
||||
const account = makeAccount({ balance: 500, available_balance: 480, balance_updated_at: staleAt })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
||||
'2026-01-01', '2026-06-01', mockIngest
|
||||
)
|
||||
|
||||
expect(mockGetAccountBalance).toHaveBeenCalledTimes(1)
|
||||
expect(account.balance).toBe(500)
|
||||
expect(account.available_balance).toBe(480)
|
||||
expect(account.balance_updated_at).toBe(staleAt)
|
||||
})
|
||||
|
||||
it('clears a stored available balance when the refresh reports none', async () => {
|
||||
// A stale available figure next to a fresh booked figure would misstate
|
||||
// what can be spent: null from the bank overwrites, never keeps.
|
||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
||||
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01', available: null })
|
||||
|
||||
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
|
||||
const account = makeAccount({ balance: 500, available_balance: 480, balance_updated_at: staleAt })
|
||||
|
||||
await syncAccountTransactions(
|
||||
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
||||
'2026-01-01', '2026-06-01', mockIngest
|
||||
)
|
||||
|
||||
expect(account.balance).toBe(1234.56)
|
||||
expect(account.available_balance).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a future balance_updated_at as stale and refreshes', async () => {
|
||||
// Clock skew or bad data can store a future timestamp; its negative age
|
||||
// must not count as fresh, or refreshes would be suppressed indefinitely.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -253,8 +253,17 @@ export async function syncAccountTransactions(
|
||||
} else {
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
account.balance = balance.amount
|
||||
account.balance_updated_at = new Date().toISOString()
|
||||
// null = the ASPSP returned no balances at all. Keep the previous
|
||||
// stored value and timestamp; writing a fabricated 0 with a fresh
|
||||
// timestamp would pin "the bank reports 0 kr" for the next 12h on
|
||||
// every balance surface.
|
||||
if (balance) {
|
||||
account.balance = balance.amount
|
||||
// Overwrite (not keep) on null: a stale available figure next to a
|
||||
// fresh booked figure would misstate what can be spent.
|
||||
account.available_balance = balance.available ?? undefined
|
||||
account.balance_updated_at = new Date().toISOString()
|
||||
}
|
||||
} catch {
|
||||
// Keep previous balance, don't update timestamp
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface StoredAccount {
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
// Bank-reported available balance from the same BALANCES response as
|
||||
// `balance` (booked). Absent when the ASPSP returns no available type.
|
||||
available_balance?: number
|
||||
balance_updated_at?: string
|
||||
// When false, the account is part of the PSD2 consent but the user has
|
||||
// chosen not to sync transactions from it. Treated as true if missing
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('gnubok_list_cash_accounts', () => {
|
||||
|
||||
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' },
|
||||
{ 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)
|
||||
|
||||
@@ -46,8 +46,12 @@ describe('gnubok_list_cash_accounts', () => {
|
||||
is_primary: true,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
balance: 12500.75,
|
||||
available_balance: 12000.5,
|
||||
balance_updated_at: '2026-09-01T05:00:00.000Z',
|
||||
})
|
||||
expect(result.cash_accounts[1]).toMatchObject({ cash_account_id: 'ca-2', name: null, iban: null, enabled: false, source: 'manual' })
|
||||
// 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')
|
||||
})
|
||||
|
||||
@@ -17,8 +17,12 @@ export const prompts: McpPrompt[] = [
|
||||
name: 'cash_today',
|
||||
description: 'Visa banksaldo just nu',
|
||||
text:
|
||||
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_get_balance_sheet ' +
|
||||
'för dagens datum och rapportera saldot på konto 1930. Visa även de senaste 5 transaktionerna ' +
|
||||
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_list_cash_accounts ' +
|
||||
'(syns det inte i verktygskatalogen: anropa det via gnubok_call_tool) och ' +
|
||||
'rapportera bankens rapporterade saldo (balance, available_balance) per konto med tidsstämpeln ' +
|
||||
'balance_updated_at. Saknas rapporterat saldo (manuellt konto eller aldrig synkat): fall tillbaka ' +
|
||||
'på gnubok_get_balance_sheet för dagens datum och saldot på konto 1930, och säg att siffran är ' +
|
||||
'bokförd, inte bankens. Visa även de senaste 5 transaktionerna ' +
|
||||
'via gnubok_list_uncategorized_transactions (limit=5, sortera nyast först: men inkludera även ' +
|
||||
'kategoriserade om verktyget tillåter). Svara kort på svenska.',
|
||||
},
|
||||
|
||||
@@ -11878,7 +11878,7 @@ export const tools: McpTool[] = [
|
||||
name: 'gnubok_list_cash_accounts',
|
||||
keywords: ['bankkonto', 'kassakonto', 'bankkonton', 'likvidkonton', 'kassa'],
|
||||
title: 'List Cash Accounts',
|
||||
description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag. Use cash_account_id to filter transaction listings and account_number (ledger_account) for gnubok_get_reconciliation_status.',
|
||||
description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag, bank-reported balance (booked + available, with balance_updated_at). Use cash_account_id to filter transaction listings; use for "how much money is in the bank".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -11904,8 +11904,20 @@ export const tools: McpTool[] = [
|
||||
is_primary: { type: 'boolean' },
|
||||
enabled: { type: 'boolean' },
|
||||
source: { type: 'string', enum: ['enable_banking', 'manual', 'sie_import'] },
|
||||
balance: {
|
||||
type: ['number', 'null'],
|
||||
description: 'Bank-reported booked balance as of balance_updated_at; null for manual accounts or before the first sync. NOT the bookkept 19xx balance.',
|
||||
},
|
||||
available_balance: {
|
||||
type: ['number', 'null'],
|
||||
description: 'Bank-reported available balance; null when the bank reports no available type.',
|
||||
},
|
||||
balance_updated_at: {
|
||||
type: ['string', 'null'],
|
||||
description: 'ISO timestamp of the last balance fetch (PSD2 quota: refreshed at most every 12h).',
|
||||
},
|
||||
},
|
||||
required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source'],
|
||||
required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source', 'balance', 'available_balance', 'balance_updated_at'],
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
@@ -11934,6 +11946,9 @@ export const tools: McpTool[] = [
|
||||
is_primary: row.is_primary === true,
|
||||
enabled: row.enabled !== false,
|
||||
source: row.source,
|
||||
balance: row.balance ?? null,
|
||||
available_balance: row.available_balance ?? null,
|
||||
balance_updated_at: row.balance_updated_at ?? null,
|
||||
}))
|
||||
return { cash_accounts: cashAccounts, count: cashAccounts.length }
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user