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
@@ -1437,6 +1437,10 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
|||||||
[2026-09-01] Anon-callable SECURITY DEFINER writes: the guard shape `IF auth.uid() IS NOT NULL AND NOT EXISTS (membership)` is unsafe on its own. The anon JWT carries no `sub` claim, so auth.uid() is NULL for role anon too and the guard short-circuits into the trusted branch. It is defense in depth behind a REVOKE FROM PUBLIC, anon, never a substitute for one. Every new SECURITY DEFINER function ships with that REVOKE; tests/pg/definer-function-grants.pg.test.ts enforces it from a sweep rather than a hand list, because hand-listing is exactly how three guarded numbering RPCs were wrongly declared safe.
|
[2026-09-01] Anon-callable SECURITY DEFINER writes: the guard shape `IF auth.uid() IS NOT NULL AND NOT EXISTS (membership)` is unsafe on its own. The anon JWT carries no `sub` claim, so auth.uid() is NULL for role anon too and the guard short-circuits into the trusted branch. It is defense in depth behind a REVOKE FROM PUBLIC, anon, never a substitute for one. Every new SECURITY DEFINER function ships with that REVOKE; tests/pg/definer-function-grants.pg.test.ts enforces it from a sweep rather than a hand list, because hand-listing is exactly how three guarded numbering RPCs were wrongly declared safe.
|
||||||
[2026-09-01] public.create_invoice_with_items(jsonb,jsonb) is revoked, not dropped. It is prod-only, uncalled and already non-functional, so removing it is cleanup rather than security, and the revoke closes the hole in full. An irreversible schema deletion against production belongs in its own reviewable migration.
|
[2026-09-01] public.create_invoice_with_items(jsonb,jsonb) is revoked, not dropped. It is prod-only, uncalled and already non-functional, so removing it is cleanup rather than security, and the revoke closes the hole in full. An irreversible schema deletion against production belongs in its own reviewable migration.
|
||||||
[2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead).
|
[2026-09-01] SKV connector refresh classification fixed broker-side (skeptic refutation on PR #2103, found independently by two skeptics): the broker's /oauth/token catch-all had collapsed SKV's terminal dead-refresh-token dialects (404 id_not_found, 400 invalid_grant, "Refresh Token status is expired": the DOMINANT refresh outcome, per-flow tokens live 65 min) into the generic 502, so a connector instance could never classify ordinary session expiry: raw English 500s instead of the reconnect flow, staged filing ops consumed as non-recoverable, crons retrying raw forever. The broker now re-codes those dialects (refresh grant only, never the code exchange where invalid_grant means an expired one-shot code) as 401 CONNECTOR_SKV_REFRESH_DEAD, which the instance maps to SESSION_EXPIRED; the generic 502 remains raw so a transient SKV outage still never re-arms the reconnect banner (#1155). Same pass: the data proxy now forwards WWW-Authenticate + x-skv-*/x-amzn-*/x-api-* response headers (the instance's MISSING_SCOPE classification reads them; nothing secret rides in them), and the instance's gateway-refusal guidance is connector-aware (a self-host has no SKATTEVERKET_APIGW_CLIENT_ID or Utvecklarportalen access: point at /api/connector/status + support instead).
|
||||||
|
[2026-09-01] Bank balance exposure (F7): external_balance on the bank reconciliation kind now carries the bank-reported booked balance but difference/unexplained_difference stay movement-based: ledger_balance for bank is a period movement, so comparing it against a point-in-time balance would produce a nonsense difference.
|
||||||
|
[2026-09-01] Bank balance exposure (F7): the enable-banking callback still stores accounts WITHOUT balances (GDPR data-minimization comment in callback/route.ts: deselected accounts must never have their balance pulled), so a reconnect nulls cash_accounts.balance until the first sync repopulates it. Left as-is on purpose.
|
||||||
|
[2026-09-01] Bank balance exposure (F7), post-skeptic revision: external_balance stays NULL for the bank reconciliation kind after all. 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 printed a phantom warning-red differens in the year-end appendix. The bank-reported figure is exposed only as the timestamped bank_reported_* pair in the bank block.
|
||||||
|
[2026-09-01] Bank balance exposure (F7): getAccountBalance returns null on an empty BALANCES response instead of fabricating amount 0; a fabricated zero with a fresh timestamp would pin "banken rapporterar 0 kr" for 12h on payment-decision surfaces. Callers keep the previous stored value.
|
||||||
[2026-09-01] WooCommerce failed orders: excluded 'failed' from order sync + remove-on-transition; kept 'cancelled' importing and 'trash' skip-only: cancelled is a real order some users want visible (asked in user reply), trash can be restored in wp-admin and may mirror a paid event. Removal deletes app-side with freeze guards incl. frozen-refund-child veto (parent_order_id cascades, table has no delete trigger).
|
[2026-09-01] WooCommerce failed orders: excluded 'failed' from order sync + remove-on-transition; kept 'cancelled' importing and 'trash' skip-only: cancelled is a real order some users want visible (asked in user reply), trash can be restored in wp-admin and may mirror a paid event. Removal deletes app-side with freeze guards incl. frozen-refund-child veto (parent_order_id cascades, table has no delete trigger).
|
||||||
[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents).
|
[2026-09-01] Skeptic BLOCK on woo failed-order removal fixed by: freeze guards repeated on the DELETE statement (TOCTOU), is_paid=false + legacy_transaction_id null guards, orderRemoves gated on !orderIsPaid. No BEFORE DELETE trigger/RPC: the is_paid guard makes the cascade race unreachable (refund children only exist under paid parents).
|
||||||
[2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR).
|
[2026-09-01] EB claim guard, skeptic round (PR #2116): active-company standing state (enabled cash_accounts + enabled accounts on its live-ish rows) outranks sibling claims COMPANY-wide, not row-wide: a bank-list renewal arrives on a fresh row and must not switch a working feed off. pending_selection rows neither claim nor remember deselections (unconfirmed callback output; also stops fail-closed writes from poisoning later connects). Guard-disabled accounts are never mirrored from the callback (mirroring enabled:false can promote the seeded primary 1930 manual row and disable it under a foreign identity) and the selection save skips allocation+mirror for disabled never-mirrored accounts, so the no-slot-burned invariant holds end to end. Deselection carry got a picker note; enabling an account clears the guard flags. Legacy both-companies-enabled overlaps stay untouched (Swedish review advisory: prod sweep is a follow-up, not this PR).
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
|
|||||||
DEFAULT_UNATTENDED_CONFIDENCE_THRESHOLD: 0.9,
|
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', () => ({
|
vi.mock('@/lib/email/service', () => ({
|
||||||
getEmailService: () => ({ isConfigured: () => false, sendEmail: vi.fn() }),
|
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 { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||||
import { getBranding } from '@/lib/branding/service'
|
import { getBranding } from '@/lib/branding/service'
|
||||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||||
|
import { updateBalancesFromSync } from '@/lib/cash-accounts/service'
|
||||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
@@ -324,6 +325,19 @@ export const GET = withCronContext('cron.bank_sync', async (_request, ctx) => {
|
|||||||
initial_sync_lookback_days: lookbackDays,
|
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
|
await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.update({
|
.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 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -470,6 +470,36 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// What the bank itself reports (F7): booked + available balance from the
|
||||||
|
// last PSD2 balance refresh, with its fetch date. Point-in-time, so it
|
||||||
|
// lives outside the movement-based tiles.
|
||||||
|
const bankReportedRaw =
|
||||||
|
!isSkv && !isManual
|
||||||
|
? (status.bank as {
|
||||||
|
bank_reported_balance?: number | null
|
||||||
|
bank_reported_available_balance?: number | null
|
||||||
|
bank_balance_updated_at?: string | null
|
||||||
|
} | null)
|
||||||
|
: null
|
||||||
|
// Both the amount and its fetch timestamp must exist: a balance of unknown
|
||||||
|
// age labeled with today's date is exactly the misleading staleness the
|
||||||
|
// timestamp exists to prevent, so without it the line is omitted entirely.
|
||||||
|
const bankReportedLine =
|
||||||
|
bankReportedRaw &&
|
||||||
|
typeof bankReportedRaw.bank_reported_balance === 'number' &&
|
||||||
|
bankReportedRaw.bank_balance_updated_at
|
||||||
|
? typeof bankReportedRaw.bank_reported_available_balance === 'number'
|
||||||
|
? t('bank_reported_line_available', {
|
||||||
|
amount: formatCurrency(bankReportedRaw.bank_reported_balance, currency),
|
||||||
|
available: formatCurrency(bankReportedRaw.bank_reported_available_balance, currency),
|
||||||
|
date: formatDate(bankReportedRaw.bank_balance_updated_at),
|
||||||
|
})
|
||||||
|
: t('bank_reported_line', {
|
||||||
|
amount: formatCurrency(bankReportedRaw.bank_reported_balance, currency),
|
||||||
|
date: formatDate(bankReportedRaw.bank_balance_updated_at),
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
const unexplained = status.unexplained_difference
|
const unexplained = status.unexplained_difference
|
||||||
const attn = status.stale
|
const attn = status.stale
|
||||||
? t('stale_line', { source: sourceLabel })
|
? t('stale_line', { source: sourceLabel })
|
||||||
@@ -535,6 +565,12 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window,
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{bankReportedLine && (
|
||||||
|
<p className="text-[12.5px] text-muted-foreground" data-ph-mask>
|
||||||
|
{bankReportedLine}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{attn ? (
|
{attn ? (
|
||||||
<AttnLine>{attn}</AttnLine>
|
<AttnLine>{attn}</AttnLine>
|
||||||
) : status.is_reconciled ? (
|
) : status.is_reconciled ? (
|
||||||
|
|||||||
@@ -848,6 +848,23 @@ export const enableBankingExtension: Extension = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const syncedAt = new Date().toISOString()
|
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
|
await supabase
|
||||||
.from('bank_connections')
|
.from('bank_connections')
|
||||||
.update({
|
.update({
|
||||||
@@ -1376,6 +1393,7 @@ export const enableBankingExtension: Extension = {
|
|||||||
iban: a.iban ?? null,
|
iban: a.iban ?? null,
|
||||||
name: a.name ?? null,
|
name: a.name ?? null,
|
||||||
balance: a.balance ?? null,
|
balance: a.balance ?? null,
|
||||||
|
available_balance: a.available_balance ?? null,
|
||||||
balance_updated_at: a.balance_updated_at ?? null,
|
balance_updated_at: a.balance_updated_at ?? null,
|
||||||
enabled: a.enabled ?? true,
|
enabled: a.enabled ?? true,
|
||||||
reuse_cash_account_id: reuseCashAccountId,
|
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()
|
const completedAt = new Date().toISOString()
|
||||||
// Don't re-write accounts_data here: the first update already wrote it.
|
// 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
|
// 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 {
|
import {
|
||||||
getASPSPs,
|
getASPSPs,
|
||||||
|
getAccountBalance,
|
||||||
getAccountBalances,
|
getAccountBalances,
|
||||||
getAccountTransactions,
|
getAccountTransactions,
|
||||||
getAllTransactions,
|
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
|
// Retry
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -608,7 +608,7 @@ describe('syncAccountTransactions', () => {
|
|||||||
|
|
||||||
it('refreshes the balance when the stored balance is older than 12 hours', async () => {
|
it('refreshes the balance when the stored balance is older than 12 hours', async () => {
|
||||||
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
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 staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
|
||||||
const account = makeAccount({ balance: 500, balance_updated_at: staleAt })
|
const account = makeAccount({ balance: 500, balance_updated_at: staleAt })
|
||||||
@@ -620,9 +620,48 @@ describe('syncAccountTransactions', () => {
|
|||||||
|
|
||||||
expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1')
|
expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1')
|
||||||
expect(account.balance).toBe(1234.56)
|
expect(account.balance).toBe(1234.56)
|
||||||
|
expect(account.available_balance).toBe(1100.5)
|
||||||
expect(account.balance_updated_at).not.toBe(staleAt)
|
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 () => {
|
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
|
// Clock skew or bad data can store a future timestamp; its negative age
|
||||||
// must not count as fresh, or refreshes would be suppressed indefinitely.
|
// 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 || []
|
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(
|
export async function getAccountBalance(
|
||||||
accountUid: string
|
accountUid: string
|
||||||
): Promise<{ amount: number; date: string }> {
|
): Promise<{ amount: number; date: string; available: number | null } | null> {
|
||||||
const balances = await getAccountBalances(accountUid)
|
const balances = await getAccountBalances(accountUid)
|
||||||
|
|
||||||
// Prefer closingBooked, then expected, then first available
|
// Prefer closingBooked, then expected, then first available
|
||||||
const balance =
|
const balance = pickBalanceByType(balances, BOOKED_BALANCE_TYPES) || balances[0]
|
||||||
balances.find(b => b.balance_type === 'closingBooked') ||
|
|
||||||
balances.find(b => b.balance_type === 'expected') ||
|
|
||||||
balances[0]
|
|
||||||
|
|
||||||
if (!balance) {
|
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 {
|
return {
|
||||||
amount: parseFloat(balance.balance_amount.amount),
|
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 {
|
} else {
|
||||||
try {
|
try {
|
||||||
const balance = await getAccountBalance(account.uid)
|
const balance = await getAccountBalance(account.uid)
|
||||||
account.balance = balance.amount
|
// null = the ASPSP returned no balances at all. Keep the previous
|
||||||
account.balance_updated_at = new Date().toISOString()
|
// 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 {
|
} catch {
|
||||||
// Keep previous balance, don't update timestamp
|
// Keep previous balance, don't update timestamp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export interface StoredAccount {
|
|||||||
name?: string
|
name?: string
|
||||||
currency: string
|
currency: string
|
||||||
balance?: number
|
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
|
balance_updated_at?: string
|
||||||
// When false, the account is part of the PSD2 consent but the user has
|
// 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
|
// 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 () => {
|
it('maps cash_accounts rows to the qualified wire shape, primary first', async () => {
|
||||||
vi.mocked(listForCompany).mockResolvedValue([
|
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' },
|
{ 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)
|
] as never)
|
||||||
|
|
||||||
@@ -46,8 +46,12 @@ describe('gnubok_list_cash_accounts', () => {
|
|||||||
is_primary: true,
|
is_primary: true,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
source: 'enable_banking',
|
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).
|
// No bare `id` leaks onto the wire (qualified-ids convention).
|
||||||
expect(result.cash_accounts[0]).not.toHaveProperty('id')
|
expect(result.cash_accounts[0]).not.toHaveProperty('id')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export const prompts: McpPrompt[] = [
|
|||||||
name: 'cash_today',
|
name: 'cash_today',
|
||||||
description: 'Visa banksaldo just nu',
|
description: 'Visa banksaldo just nu',
|
||||||
text:
|
text:
|
||||||
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_get_balance_sheet ' +
|
'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_list_cash_accounts ' +
|
||||||
'för dagens datum och rapportera saldot på konto 1930. Visa även de senaste 5 transaktionerna ' +
|
'(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 ' +
|
'via gnubok_list_uncategorized_transactions (limit=5, sortera nyast först: men inkludera även ' +
|
||||||
'kategoriserade om verktyget tillåter). Svara kort på svenska.',
|
'kategoriserade om verktyget tillåter). Svara kort på svenska.',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11878,7 +11878,7 @@ export const tools: McpTool[] = [
|
|||||||
name: 'gnubok_list_cash_accounts',
|
name: 'gnubok_list_cash_accounts',
|
||||||
keywords: ['bankkonto', 'kassakonto', 'bankkonton', 'likvidkonton', 'kassa'],
|
keywords: ['bankkonto', 'kassakonto', 'bankkonton', 'likvidkonton', 'kassa'],
|
||||||
title: 'List Cash Accounts',
|
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: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
@@ -11904,8 +11904,20 @@ export const tools: McpTool[] = [
|
|||||||
is_primary: { type: 'boolean' },
|
is_primary: { type: 'boolean' },
|
||||||
enabled: { type: 'boolean' },
|
enabled: { type: 'boolean' },
|
||||||
source: { type: 'string', enum: ['enable_banking', 'manual', 'sie_import'] },
|
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' },
|
count: { type: 'number' },
|
||||||
@@ -11934,6 +11946,9 @@ export const tools: McpTool[] = [
|
|||||||
is_primary: row.is_primary === true,
|
is_primary: row.is_primary === true,
|
||||||
enabled: row.enabled !== false,
|
enabled: row.enabled !== false,
|
||||||
source: row.source,
|
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 }
|
return { cash_accounts: cashAccounts, count: cashAccounts.length }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ function makeCashAccount(overrides: Partial<CashAccount> = {}): CashAccount {
|
|||||||
currency: 'SEK',
|
currency: 'SEK',
|
||||||
ledger_account: '1930',
|
ledger_account: '1930',
|
||||||
balance: null,
|
balance: null,
|
||||||
|
available_balance: null,
|
||||||
balance_updated_at: null,
|
balance_updated_at: null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
is_primary: true,
|
is_primary: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// 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`] = `
|
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",
|
||||||
"GET /api/v1/companies/:companyId/accounts",
|
"GET /api/v1/companies/:companyId/accounts",
|
||||||
"GET /api/v1/companies/:companyId/articles",
|
"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/compliance/check",
|
||||||
"GET /api/v1/companies/:companyId/customers",
|
"GET /api/v1/companies/:companyId/customers",
|
||||||
"GET /api/v1/companies/:companyId/customers/:id",
|
"GET /api/v1/companies/:companyId/customers/:id",
|
||||||
|
|||||||
@@ -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]/transactions/batch-categorize/route'
|
||||||
import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/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]/reconciliation/bank/status/route'
|
||||||
|
import '@/app/api/v1/companies/[companyId]/cash-accounts/route'
|
||||||
|
|
||||||
// Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals.
|
// Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals.
|
||||||
import '@/app/api/v1/companies/[companyId]/suppliers/route'
|
import '@/app/api/v1/companies/[companyId]/suppliers/route'
|
||||||
|
|||||||
@@ -145,6 +145,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
|||||||
// Writes: bulk
|
// Writes: bulk
|
||||||
'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
|
'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write',
|
||||||
'POST /api/v1/companies/:companyId/transactions/batch-categorize': '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
|
// Reconciliation (legacy bank-only routes; kept as aliases of the
|
||||||
// account-keyed routes below, with their original scopes)
|
// account-keyed routes below, with their original scopes)
|
||||||
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
|
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
defaultLedgerForCurrency,
|
defaultLedgerForCurrency,
|
||||||
getRevokedConnectionIds,
|
getRevokedConnectionIds,
|
||||||
upsertFromPsd2,
|
upsertFromPsd2,
|
||||||
|
updateBalancesFromSync,
|
||||||
ensureManualCashAccount,
|
ensureManualCashAccount,
|
||||||
} from '../service'
|
} from '../service'
|
||||||
|
|
||||||
@@ -1210,3 +1211,106 @@ describe('ensureManualCashAccount', () => {
|
|||||||
).rejects.toThrow(/boom/)
|
).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export interface UpsertFromPsd2Input {
|
|||||||
iban?: string | null
|
iban?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
balance?: number | null
|
balance?: number | null
|
||||||
|
available_balance?: number | null
|
||||||
balance_updated_at?: string | null
|
balance_updated_at?: string | null
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
/**
|
/**
|
||||||
@@ -1088,6 +1089,76 @@ async function rebindMovableTransactions(
|
|||||||
return moved
|
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
|
* Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on
|
||||||
* (company_id, bank_connection_id, external_uid). When the row exists, balance
|
* (company_id, bank_connection_id, external_uid). When the row exists, balance
|
||||||
@@ -1110,6 +1181,7 @@ export async function upsertFromPsd2(
|
|||||||
currency: input.currency.toUpperCase(),
|
currency: input.currency.toUpperCase(),
|
||||||
ledger_account: input.ledger_account,
|
ledger_account: input.ledger_account,
|
||||||
balance: input.balance ?? null,
|
balance: input.balance ?? null,
|
||||||
|
available_balance: input.available_balance ?? null,
|
||||||
balance_updated_at: input.balance_updated_at ?? null,
|
balance_updated_at: input.balance_updated_at ?? null,
|
||||||
enabled: input.enabled ?? true,
|
enabled: input.enabled ?? true,
|
||||||
source: 'enable_banking' as CashAccountSource,
|
source: 'enable_banking' as CashAccountSource,
|
||||||
|
|||||||
@@ -374,4 +374,63 @@ describe('getAccountStatus', () => {
|
|||||||
expect(s.unexplained_difference).toBe(0)
|
expect(s.unexplained_difference).toBe(0)
|
||||||
expect(s.bank).toMatchObject({ bank_transaction_total: 122288 })
|
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
|
is_primary: boolean | null
|
||||||
source: string | null
|
source: string | null
|
||||||
bank_connection_id: string | null
|
bank_connection_id: string | null
|
||||||
|
balance: number | null
|
||||||
|
available_balance: number | null
|
||||||
|
balance_updated_at: string | null
|
||||||
updated_at: string | null
|
updated_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +159,24 @@ async function bankStatus(
|
|||||||
)
|
)
|
||||||
const syncedAt = await latestBankSyncAt(supabase, companyId, account.id)
|
const syncedAt = await latestBankSyncAt(supabase, companyId, account.id)
|
||||||
const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS
|
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 {
|
return {
|
||||||
account_key: bankAccountKey(account.id),
|
account_key: bankAccountKey(account.id),
|
||||||
kind: 'bank',
|
kind: 'bank',
|
||||||
@@ -178,7 +199,14 @@ async function bankStatus(
|
|||||||
ignored: raw.ignored_transaction_count,
|
ignored: raw.ignored_transaction_count,
|
||||||
},
|
},
|
||||||
skattekonto: null,
|
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
|
const { data, error } = await supabase
|
||||||
.from('cash_accounts')
|
.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('company_id', companyId)
|
||||||
.eq('enabled', true)
|
.eq('enabled', true)
|
||||||
.order('is_primary', { ascending: false })
|
.order('is_primary', { ascending: false })
|
||||||
@@ -392,7 +420,7 @@ export async function getAccountStatus(
|
|||||||
if (parsed.kind === 'bank') {
|
if (parsed.kind === 'bank') {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('cash_accounts')
|
.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('company_id', companyId)
|
||||||
.eq('id', parsed.cashAccountId)
|
.eq('id', parsed.cashAccountId)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
|
|||||||
@@ -8242,6 +8242,8 @@
|
|||||||
"load_failed": "Could not load the reconciliation. Try again in a moment.",
|
"load_failed": "Could not load the reconciliation. Try again in a moment.",
|
||||||
"tile_external_skv": "Balance at Skatteverket",
|
"tile_external_skv": "Balance at Skatteverket",
|
||||||
"tile_external_bank": "Movement on the bank in the period",
|
"tile_external_bank": "Movement on the bank in the period",
|
||||||
|
"bank_reported_line": "Bank-reported balance: {amount} · fetched {date}",
|
||||||
|
"bank_reported_line_available": "Bank-reported balance: {amount} (available {available}) · fetched {date}",
|
||||||
"tile_bank_breakdown": "{inflow} in · {outflow} out · {count} transactions",
|
"tile_bank_breakdown": "{inflow} in · {outflow} out · {count} transactions",
|
||||||
"tile_ledger": "Booked on {account}",
|
"tile_ledger": "Booked on {account}",
|
||||||
"tile_ledger_bank": "Booked on {account} in the period",
|
"tile_ledger_bank": "Booked on {account} in the period",
|
||||||
|
|||||||
@@ -8242,6 +8242,8 @@
|
|||||||
"load_failed": "Kunde inte hämta avstämningen. Försök igen om en stund.",
|
"load_failed": "Kunde inte hämta avstämningen. Försök igen om en stund.",
|
||||||
"tile_external_skv": "Saldo hos Skatteverket",
|
"tile_external_skv": "Saldo hos Skatteverket",
|
||||||
"tile_external_bank": "Rörelse på banken i perioden",
|
"tile_external_bank": "Rörelse på banken i perioden",
|
||||||
|
"bank_reported_line": "Saldo enligt banken: {amount} · hämtat {date}",
|
||||||
|
"bank_reported_line_available": "Saldo enligt banken: {amount} (tillgängligt {available}) · hämtat {date}",
|
||||||
"tile_bank_breakdown": "{inflow} in · {outflow} ut · {count} transaktioner",
|
"tile_bank_breakdown": "{inflow} in · {outflow} ut · {count} transaktioner",
|
||||||
"tile_ledger": "Bokfört på {account}",
|
"tile_ledger": "Bokfört på {account}",
|
||||||
"tile_ledger_bank": "Bokfört på {account} i perioden",
|
"tile_ledger_bank": "Bokfört på {account} i perioden",
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ const GROUPS: Array<{ file: string; title: string; members: string[]; blurb: str
|
|||||||
{
|
{
|
||||||
file: 'banking.md',
|
file: 'banking.md',
|
||||||
title: 'Banking',
|
title: 'Banking',
|
||||||
members: ['transactions', 'reconciliation', 'imports'],
|
members: ['transactions', 'cash-accounts', 'reconciliation', 'imports'],
|
||||||
blurb:
|
blurb:
|
||||||
'Bank transactions (ingest, categorize, match against invoices), bank reconciliation runs, ' +
|
'Bank transactions (ingest, categorize, match against invoices), cash accounts with the ' +
|
||||||
'and file imports (SIE, bank statements).',
|
'bank-reported balance, bank reconciliation runs, and file imports (SIE, bank statements).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
file: 'employees.md',
|
file: 'employees.md',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: >-
|
|||||||
transactions and reconciliation, payroll (lön), VAT/moms and financial
|
transactions and reconciliation, payroll (lön), VAT/moms and financial
|
||||||
reports, SIE import/export, documents, webhooks. Covers auth with
|
reports, SIE import/export, documents, webhooks. Covers auth with
|
||||||
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
|
gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor
|
||||||
pagination, scopes), and all 142 endpoints.
|
pagination, scopes), and all 143 endpoints.
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
|
<!-- GENERATED FILE, do not edit. Source: lib/api/v1 registry + scripts/api-skill/overlays. Regenerate with `npm run apiskill:generate`. -->
|
||||||
@@ -142,7 +142,7 @@ call can undo it, e.g. invoice credit).
|
|||||||
|
|
||||||
## Endpoint index
|
## Endpoint index
|
||||||
|
|
||||||
API version `2026-05-12`, 142 operations. Paths are shown without
|
API version `2026-05-12`, 143 operations. Paths are shown without
|
||||||
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
|
their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`).
|
||||||
|
|
||||||
### Core (5)
|
### Core (5)
|
||||||
@@ -255,11 +255,12 @@ POST /companies/{companyId}/documents/{id}/link : Link a document to a journal e
|
|||||||
POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent]
|
POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Banking (24)
|
### Banking (25)
|
||||||
|
|
||||||
Full detail: [references/banking.md](references/banking.md)
|
Full detail: [references/banking.md](references/banking.md)
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
GET /companies/{companyId}/cash-accounts : List bank/cash accounts with the bank-reported balance [scope:transactions:read risk:low idempotent]
|
||||||
POST /companies/{companyId}/imports/bank : Import a bank-file (CSV / XML / CAMT053) [scope:transactions:write risk:medium idempotent]
|
POST /companies/{companyId}/imports/bank : Import a bank-file (CSV / XML / CAMT053) [scope:transactions:write risk:medium idempotent]
|
||||||
POST /companies/{companyId}/imports/sie : Import a SIE4 file [scope:bookkeeping:write risk:high idempotent]
|
POST /companies/{companyId}/imports/sie : Import a SIE4 file [scope:bookkeeping:write risk:high idempotent]
|
||||||
GET /companies/{companyId}/reconciliation/accounts : List the accounts that can be reconciled, with status per account [scope:reconciliation:read risk:low idempotent]
|
GET /companies/{companyId}/reconciliation/accounts : List the accounts that can be reconciled, with status per account [scope:reconciliation:read risk:low idempotent]
|
||||||
|
|||||||
@@ -2,11 +2,75 @@
|
|||||||
|
|
||||||
# Banking endpoints
|
# Banking endpoints
|
||||||
|
|
||||||
Bank transactions (ingest, categorize, match against invoices), bank reconciliation runs, and file imports (SIE, bank statements).
|
Bank transactions (ingest, categorize, match against invoices), cash accounts with the bank-reported balance, bank reconciliation runs, and file imports (SIE, bank statements).
|
||||||
|
|
||||||
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)
|
Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)
|
||||||
are in SKILL.md and are not repeated per endpoint.
|
are in SKILL.md and are not repeated per endpoint.
|
||||||
|
|
||||||
|
### `GET /api/v1/companies/{companyId}/cash-accounts`
|
||||||
|
|
||||||
|
**List bank/cash accounts with the bank-reported balance.**
|
||||||
|
`scope:transactions:read · risk:low · idempotent`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**Use when:** 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.
|
||||||
|
**Do not use for:** 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.
|
||||||
|
|
||||||
|
| Parameter | In | Type | Required | Notes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `companyId` | path | `string` | yes | |
|
||||||
|
|
||||||
|
Response `200`:
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
cash_accounts: { cash_account_id: string, ledger_account: string, name: string, currency: string, iban: string, is_primary: boolean, enabled: boolean, source: "enable_banking" | "manual" | "sie_import", balance: number, available_balance: number, balance_updated_at: string }[]
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
request_id: string,
|
||||||
|
api_version: string,
|
||||||
|
next_cursor?: string,
|
||||||
|
audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string },
|
||||||
|
partial_expansions?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response `200`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### `POST /api/v1/companies/{companyId}/imports/bank`
|
### `POST /api/v1/companies/{companyId}/imports/bank`
|
||||||
|
|
||||||
**Import a bank-file (CSV / XML / CAMT053).**
|
**Import a bank-file (CSV / XML / CAMT053).**
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Bank-reported AVAILABLE balance alongside the booked balance (issue: PSD2
|
||||||
|
-- flow delivers transactions but no usable saldo; the Enable Banking BALANCES
|
||||||
|
-- response carries both types and the available one was discarded).
|
||||||
|
-- Nullable and additive: rows without a PSD2 connection, or synced before this
|
||||||
|
-- shipped, simply have no value yet.
|
||||||
|
ALTER TABLE public.cash_accounts
|
||||||
|
ADD COLUMN IF NOT EXISTS available_balance NUMERIC;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.cash_accounts.available_balance IS
|
||||||
|
'Bank-reported available balance (PSD2 interimAvailable/closingAvailable), as of balance_updated_at. NULL when the bank returns no available type or the account is not PSD2-sourced.';
|
||||||
|
|
||||||
|
NOTIFY pgrst, 'reload schema';
|
||||||
@@ -685,6 +685,7 @@ export interface CashAccount {
|
|||||||
// tolerate future currencies without DB-driven enum drift
|
// tolerate future currencies without DB-driven enum drift
|
||||||
ledger_account: string
|
ledger_account: string
|
||||||
balance: number | null
|
balance: number | null
|
||||||
|
available_balance: number | null
|
||||||
balance_updated_at: string | null
|
balance_updated_at: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
is_primary: boolean
|
is_primary: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user