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
@@ -54,6 +54,15 @@ vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
||||
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: 0.9,
|
||||
}))
|
||||
|
||||
// The balance mirror runs against cash_accounts after every successful sync;
|
||||
// its own behavior is covered in lib/cash-accounts/__tests__/service.test.ts.
|
||||
vi.mock('@/lib/cash-accounts/service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/cash-accounts/service')>(
|
||||
'@/lib/cash-accounts/service',
|
||||
)
|
||||
return { ...actual, updateBalancesFromSync: vi.fn().mockResolvedValue(undefined) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({ isConfigured: () => false, sendEmail: vi.fn() }),
|
||||
}))
|
||||
|
||||
@@ -27,6 +27,7 @@ import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { updateBalancesFromSync } from '@/lib/cash-accounts/service'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -324,6 +325,19 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
||||
initial_sync_lookback_days: lookbackDays,
|
||||
}
|
||||
}
|
||||
// Mirror refreshed balances into cash_accounts (what the Bank-page
|
||||
// picker and reconciliation read); logs failures instead of throwing.
|
||||
await updateBalancesFromSync(
|
||||
supabase,
|
||||
connection.company_id,
|
||||
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({
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Integration tests for GET .../cash-accounts (bank/cash accounts with the
|
||||
* bank-reported balance).
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
vi.mock('@/lib/cash-accounts/service', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/cash-accounts/service')>('@/lib/cash-accounts/service')
|
||||
return { ...actual, listForCompany: vi.fn() }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { listForCompany } from '@/lib/cash-accounts/service'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
|
||||
function getRequest(url: string, withAuth = true): Request {
|
||||
return new Request(url, {
|
||||
method: 'GET',
|
||||
headers: withAuth ? { Authorization: 'Bearer test-fixture-not-a-real-key' } : {},
|
||||
})
|
||||
}
|
||||
|
||||
type MockResult = { data?: unknown; error?: unknown }
|
||||
function makeFlexibleSupabase(byTable: Record<string, MockResult | MockResult[]>) {
|
||||
const queues = new Map<string, MockResult[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
function makeMemberSupabase() {
|
||||
return makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockServiceClient.mockReturnValue(makeMemberSupabase())
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/{companyId}/cash-accounts', () => {
|
||||
beforeEach(() => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
scopes: ['transactions:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 401 without an API key', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
const res = await GET(
|
||||
getRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/cash-accounts`, false),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on an invalid enabled_only value', async () => {
|
||||
const res = await GET(
|
||||
getRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/cash-accounts?enabled_only=banana`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns the accounts with bank-reported balance fields', async () => {
|
||||
vi.mocked(listForCompany).mockResolvedValue([
|
||||
{
|
||||
id: 'ca-1',
|
||||
company_id: COMPANY_ID,
|
||||
ledger_account: '1930',
|
||||
name: 'Företagskonto',
|
||||
currency: 'SEK',
|
||||
iban: 'SE4550000000058398257466',
|
||||
is_primary: true,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
balance: 125430.5,
|
||||
available_balance: 123930.5,
|
||||
balance_updated_at: '2026-09-01T05:12:44.000Z',
|
||||
},
|
||||
{
|
||||
id: 'ca-2',
|
||||
company_id: COMPANY_ID,
|
||||
ledger_account: '1940',
|
||||
name: null,
|
||||
currency: 'SEK',
|
||||
iban: null,
|
||||
is_primary: false,
|
||||
enabled: true,
|
||||
source: 'manual',
|
||||
},
|
||||
] as never)
|
||||
|
||||
const res = await GET(
|
||||
getRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/cash-accounts`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.cash_accounts).toHaveLength(2)
|
||||
expect(body.data.cash_accounts[0]).toEqual({
|
||||
cash_account_id: 'ca-1',
|
||||
ledger_account: '1930',
|
||||
name: 'Företagskonto',
|
||||
currency: 'SEK',
|
||||
iban: 'SE4550000000058398257466',
|
||||
is_primary: true,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
balance: 125430.5,
|
||||
available_balance: 123930.5,
|
||||
balance_updated_at: '2026-09-01T05:12:44.000Z',
|
||||
})
|
||||
// Manual account: explicit nulls for the bank-reported fields.
|
||||
expect(body.data.cash_accounts[1]).toMatchObject({
|
||||
cash_account_id: 'ca-2',
|
||||
balance: null,
|
||||
available_balance: null,
|
||||
balance_updated_at: null,
|
||||
})
|
||||
// Qualified ids only: no bare `id` on the wire.
|
||||
expect(body.data.cash_accounts[0]).not.toHaveProperty('id')
|
||||
})
|
||||
|
||||
it('passes enabled_only=true through to the service', async () => {
|
||||
vi.mocked(listForCompany).mockResolvedValue([])
|
||||
const res = await GET(
|
||||
getRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/cash-accounts?enabled_only=true`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(listForCompany).toHaveBeenCalledWith(expect.anything(), COMPANY_ID, { enabledOnly: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/cash-accounts
|
||||
*
|
||||
* List the company's bank/cash accounts (cash_accounts) including the
|
||||
* bank-reported balance (booked + available) and when it was fetched.
|
||||
* The balance figures come from the PSD2 provider, not from the ledger.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { listForCompany } from '@/lib/cash-accounts/service'
|
||||
|
||||
const CashAccount = z.object({
|
||||
cash_account_id: z.string(),
|
||||
ledger_account: z.string(),
|
||||
name: z.string().nullable(),
|
||||
currency: z.string(),
|
||||
iban: z.string().nullable(),
|
||||
is_primary: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
source: z.enum(['enable_banking', 'manual', 'sie_import']),
|
||||
balance: z.number().nullable(),
|
||||
available_balance: z.number().nullable(),
|
||||
balance_updated_at: z.string().nullable(),
|
||||
})
|
||||
|
||||
const CashAccountsResponse = dataEnvelope(
|
||||
z.object({ cash_accounts: z.array(CashAccount) }),
|
||||
)
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'cash-accounts.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/cash-accounts',
|
||||
summary: 'List bank/cash accounts with the bank-reported balance.',
|
||||
description:
|
||||
'Returns the company\'s cash accounts (bank accounts, kassa) with their BAS ledger mapping and, for PSD2-connected accounts, the balance the bank itself reported at the last sync: balance (booked), available_balance, and balance_updated_at (when it was fetched). Pass ?enabled_only=true to return only accounts that sync.',
|
||||
useWhen:
|
||||
'You need the current bank balance per account (e.g. a covering decision before a payment run), or cash_account_id values to filter transaction listings.',
|
||||
doNotUseFor:
|
||||
'The bookkept 19xx balance: use the trial-balance or balance-sheet reports. The two legitimately differ (pending bookings, timing).',
|
||||
pitfalls: [
|
||||
'balance/available_balance are what the BANK reported, refreshed at most every 12h (PSD2 quota): check balance_updated_at before treating them as current.',
|
||||
'balance is null for manual and SIE-imported accounts, and for PSD2 accounts that have not completed a sync since connecting.',
|
||||
'available_balance is null when the bank reports no available balance type; that does not mean 0.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
cash_accounts: [
|
||||
{
|
||||
cash_account_id: 'ca_…',
|
||||
ledger_account: '1930',
|
||||
name: 'Företagskonto',
|
||||
currency: 'SEK',
|
||||
iban: 'SE4550000000058398257466',
|
||||
is_primary: true,
|
||||
enabled: true,
|
||||
source: 'enable_banking',
|
||||
balance: 125430.5,
|
||||
available_balance: 123930.5,
|
||||
balance_updated_at: '2026-09-01T05:12:44.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'transactions:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: CashAccountsResponse },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'cash-accounts.list',
|
||||
async (request, ctx) => {
|
||||
const url = new URL(request.url)
|
||||
const Filters = z.object({ enabled_only: z.enum(['true', 'false']).optional() })
|
||||
const parsed = Filters.safeParse({
|
||||
enabled_only: url.searchParams.get('enabled_only') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await listForCompany(ctx.supabase, ctx.companyId!, {
|
||||
enabledOnly: parsed.data.enabled_only === 'true',
|
||||
})
|
||||
const cashAccounts = rows.map((row) => ({
|
||||
cash_account_id: row.id,
|
||||
ledger_account: row.ledger_account,
|
||||
name: row.name ?? null,
|
||||
currency: row.currency,
|
||||
iban: row.iban ?? null,
|
||||
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 ok({ cash_accounts: cashAccounts }, { requestId: ctx.requestId })
|
||||
} catch (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user