diff --git a/DECISIONS.md b/DECISIONS.md index bbf8799d..b9cd6471 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1437,6 +1437,10 @@ One line per decision: `[YYYY-MM-DD] : `. 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] 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] 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] 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). diff --git a/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts b/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts index 34865d65..dccada67 100644 --- a/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts +++ b/app/api/extensions/enable-banking/sync/cron/__tests__/route.test.ts @@ -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( + '@/lib/cash-accounts/service', + ) + return { ...actual, updateBalancesFromSync: vi.fn().mockResolvedValue(undefined) } +}) + vi.mock('@/lib/email/service', () => ({ getEmailService: () => ({ isConfigured: () => false, sendEmail: vi.fn() }), })) diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 2b51050a..d0a0f8f9 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/cash-accounts/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/cash-accounts/__tests__/route.test.ts new file mode 100644 index 00000000..50c974bf --- /dev/null +++ b/app/api/v1/companies/[companyId]/cash-accounts/__tests__/route.test.ts @@ -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('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) +vi.mock('@/lib/cash-accounts/service', async () => { + const actual = await vi.importActual('@/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 +const mockServiceClient = createServiceClientNoCookies as ReturnType + +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) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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 }) + }) +}) diff --git a/app/api/v1/companies/[companyId]/cash-accounts/route.ts b/app/api/v1/companies/[companyId]/cash-accounts/route.ts new file mode 100644 index 00000000..b4a88235 --- /dev/null +++ b/app/api/v1/companies/[companyId]/cash-accounts/route.ts @@ -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 }) + } + }, +) diff --git a/components/reconciliation/AccountOverview.tsx b/components/reconciliation/AccountOverview.tsx index 1d3658b0..8f311641 100644 --- a/components/reconciliation/AccountOverview.tsx +++ b/components/reconciliation/AccountOverview.tsx @@ -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 attn = status.stale ? t('stale_line', { source: sourceLabel }) @@ -535,6 +565,12 @@ export function AccountOverview({ account, rail, otherBankAccounts = [], window, ))} + {bankReportedLine && ( +

+ {bankReportedLine} +

+ )} + {attn ? ( {attn} ) : status.is_reconciled ? ( diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index 6e4cad6c..1430a771 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -848,6 +848,23 @@ export const enableBankingExtension: Extension = { } const syncedAt = new Date().toISOString() + // Mirror refreshed balances into cash_accounts: the Bank-page source + // picker and the reconciliation status read that table, and without + // this the balance there froze at connect time. + { + const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service') + await updateBalancesFromSync( + supabase, + companyId, + connection.id, + allAccounts.map((a) => ({ + external_uid: a.uid, + balance: a.balance, + available_balance: a.available_balance, + balance_updated_at: a.balance_updated_at, + })), + ) + } await supabase .from('bank_connections') .update({ @@ -1376,6 +1393,7 @@ export const enableBankingExtension: Extension = { iban: a.iban ?? null, name: a.name ?? null, balance: a.balance ?? null, + available_balance: a.available_balance ?? null, balance_updated_at: a.balance_updated_at ?? null, enabled: a.enabled ?? true, reuse_cash_account_id: reuseCashAccountId, @@ -1598,6 +1616,30 @@ export const enableBankingExtension: Extension = { } } + // Mirror the balances the backfill just fetched into cash_accounts. + // accounts_data is deliberately NOT re-written here (see below), so + // without this the balances fetched during the initial sync would + // reach neither store until the next scheduled sync. + try { + const { updateBalancesFromSync } = await import('@/lib/cash-accounts/service') + await updateBalancesFromSync( + supabase, + companyId, + connection.id, + updatedAccounts.map((a) => ({ + external_uid: a.uid, + balance: a.balance, + available_balance: a.available_balance, + balance_updated_at: a.balance_updated_at, + })), + ) + } catch (mirrorErr) { + log.error('[enable-banking] Balance mirror after initial backfill failed', { + connectionId: connection.id, + error: mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr), + }) + } + const completedAt = new Date().toISOString() // Don't re-write accounts_data here: the first update already wrote it. // Including it again races with any concurrent writer (e.g. cron firing in diff --git a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts index bf9ffa1a..c38c0f7b 100644 --- a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts @@ -13,6 +13,7 @@ vi.stubEnv('ENABLE_BANKING_API_URL', 'https://api.test.com') import { getASPSPs, + getAccountBalance, getAccountBalances, getAccountTransactions, getAllTransactions, @@ -53,6 +54,97 @@ describe('api-client', () => { }) }) + // ------------------------------------------------------------------------- + // Balance-type selection + // ------------------------------------------------------------------------- + describe('getAccountBalance', () => { + function balancesResponse(balances: unknown[]): Response { + return new Response(JSON.stringify({ balances }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + it('returns booked (closingBooked) plus available (interimAvailable) from one response', async () => { + fetchSpy.mockResolvedValueOnce( + balancesResponse([ + { balance_type: 'interimAvailable', balance_amount: { amount: '900.50', currency: 'SEK' } }, + { balance_type: 'closingBooked', balance_amount: { amount: '1000.00', currency: 'SEK' }, reference_date: '2026-09-01' }, + ]) + ) + + const result = await getAccountBalance('acc-1') + expect(result).toEqual({ amount: 1000, date: '2026-09-01', available: 900.5 }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + it('accepts ISO 20022 codes (CLBD/ITAV) case-insensitively', async () => { + fetchSpy.mockResolvedValueOnce( + balancesResponse([ + { balance_type: 'ITAV', balance_amount: { amount: '450.25', currency: 'SEK' } }, + { balance_type: 'CLBD', balance_amount: { amount: '500.00', currency: 'SEK' }, reference_date: '2026-09-01' }, + ]) + ) + + const result = await getAccountBalance('acc-1') + expect(result?.amount).toBe(500) + expect(result?.available).toBe(450.25) + }) + + it('returns available: null when the bank reports no available type', async () => { + fetchSpy.mockResolvedValueOnce( + balancesResponse([ + { balance_type: 'closingBooked', balance_amount: { amount: '1000.00', currency: 'SEK' }, reference_date: '2026-09-01' }, + ]) + ) + + const result = await getAccountBalance('acc-1') + expect(result).toEqual({ amount: 1000, date: '2026-09-01', available: null }) + }) + + it('falls back to the first balance for booked, never to an available type by preference', async () => { + // Only an unknown type: the pre-existing first-entry fallback applies. + fetchSpy.mockResolvedValueOnce( + balancesResponse([ + { balance_type: 'somethingElse', balance_amount: { amount: '42.00', currency: 'SEK' }, reference_date: '2026-08-31' }, + ]) + ) + + const result = await getAccountBalance('acc-1') + expect(result).toEqual({ amount: 42, date: '2026-08-31', available: null }) + }) + + it('prefers interimBooked (ITBD) over the generic first-entry fallback', async () => { + fetchSpy.mockResolvedValueOnce( + balancesResponse([ + { balance_type: 'somethingElse', balance_amount: { amount: '1.00', currency: 'SEK' } }, + { balance_type: 'ITBD', balance_amount: { amount: '3.00', currency: 'SEK' }, reference_date: '2026-09-01' }, + ]) + ) + + const result = await getAccountBalance('acc-1') + expect(result?.amount).toBe(3) + }) + + it('returns null (never a fabricated 0) when the bank reports no balances at all', async () => { + fetchSpy.mockResolvedValueOnce(balancesResponse([])) + const result = await getAccountBalance('acc-1') + expect(result).toBeNull() + }) + + it('prefers expected over the first entry when closingBooked is missing', async () => { + fetchSpy.mockResolvedValueOnce( + balancesResponse([ + { balance_type: 'other', balance_amount: { amount: '1.00', currency: 'SEK' } }, + { balance_type: 'expected', balance_amount: { amount: '2.00', currency: 'SEK' }, reference_date: '2026-09-01' }, + ]) + ) + + const result = await getAccountBalance('acc-1') + expect(result?.amount).toBe(2) + }) + }) + // ------------------------------------------------------------------------- // Retry // ------------------------------------------------------------------------- diff --git a/extensions/general/enable-banking/lib/__tests__/sync.test.ts b/extensions/general/enable-banking/lib/__tests__/sync.test.ts index 54e259cc..f95e0011 100644 --- a/extensions/general/enable-banking/lib/__tests__/sync.test.ts +++ b/extensions/general/enable-banking/lib/__tests__/sync.test.ts @@ -608,7 +608,7 @@ describe('syncAccountTransactions', () => { it('refreshes the balance when the stored balance is older than 12 hours', async () => { mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] }) - mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01' }) + mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01', available: 1100.5 }) const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString() const account = makeAccount({ balance: 500, balance_updated_at: staleAt }) @@ -620,9 +620,48 @@ describe('syncAccountTransactions', () => { expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1') expect(account.balance).toBe(1234.56) + expect(account.available_balance).toBe(1100.5) expect(account.balance_updated_at).not.toBe(staleAt) }) + it('keeps the previous balance and timestamp when the bank reports no balances (null result)', async () => { + // A 200 with zero balances used to fabricate amount 0; with balances now + // user-facing that would pin "banken rapporterar 0 kr" for 12h. + mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] }) + mockGetAccountBalance.mockResolvedValue(null) + + const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString() + const account = makeAccount({ balance: 500, available_balance: 480, balance_updated_at: staleAt }) + + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account, + '2026-01-01', '2026-06-01', mockIngest + ) + + expect(mockGetAccountBalance).toHaveBeenCalledTimes(1) + expect(account.balance).toBe(500) + expect(account.available_balance).toBe(480) + expect(account.balance_updated_at).toBe(staleAt) + }) + + it('clears a stored available balance when the refresh reports none', async () => { + // A stale available figure next to a fresh booked figure would misstate + // what can be spent: null from the bank overwrites, never keeps. + mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] }) + mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01', available: null }) + + const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString() + const account = makeAccount({ balance: 500, available_balance: 480, balance_updated_at: staleAt }) + + await syncAccountTransactions( + {} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account, + '2026-01-01', '2026-06-01', mockIngest + ) + + expect(account.balance).toBe(1234.56) + expect(account.available_balance).toBeUndefined() + }) + it('treats a future balance_updated_at as stale and refreshes', async () => { // Clock skew or bad data can store a future timestamp; its negative age // must not count as fresh, or refreshes would be suppressed indefinitely. diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts index d770dacd..a57854e7 100644 --- a/extensions/general/enable-banking/lib/api-client.ts +++ b/extensions/general/enable-banking/lib/api-client.ts @@ -740,27 +740,65 @@ export async function getAccountBalances(accountUid: string): Promise return data.balances || [] } +// Balance-type preference orders. ASPSPs report types either as camelCase +// names or ISO 20022 codes; both spellings of each type are accepted, +// case-insensitively. Booked answers "what has the bank settled", available +// answers "what can be spent right now" (the covering-decision number). +// closingBooked (settled, definitive) wins over expected, which wins over +// interimBooked (intraday booked): fall through to less-final booked types +// only when the stabler one is absent, and to the generic first-entry +// fallback only when no booked type exists at all. +const BOOKED_BALANCE_TYPES = ['closingbooked', 'clbd', 'expected', 'xpcd', 'interimbooked', 'itbd'] +const AVAILABLE_BALANCE_TYPES = [ + 'interimavailable', + 'itav', + 'closingavailable', + 'clav', + 'forwardavailable', + 'fwav', +] + +function pickBalanceByType(balances: Balance[], preference: string[]): Balance | undefined { + for (const type of preference) { + const match = balances.find(b => b.balance_type?.toLowerCase() === type) + if (match) return match + } + return undefined +} + /** - * Get account balance (returns booked balance amount) + * Get account balance from one BALANCES call: the booked amount (falling back + * to the first reported balance, as before) plus the available amount when the + * ASPSP reports one. One call: both figures come from the same quota-limited + * response, so exposing `available` costs nothing extra. + * + * Returns null when the ASPSP reports NO balances at all. The old behavior + * fabricated `amount: 0` here; once balances became user-facing ("how much + * money is in the bank", covering decisions before payment runs) a fabricated + * zero with a fresh timestamp is dangerous, so the caller keeps its previous + * stored value instead. */ export async function getAccountBalance( accountUid: string -): Promise<{ amount: number; date: string }> { +): Promise<{ amount: number; date: string; available: number | null } | null> { const balances = await getAccountBalances(accountUid) // Prefer closingBooked, then expected, then first available - const balance = - balances.find(b => b.balance_type === 'closingBooked') || - balances.find(b => b.balance_type === 'expected') || - balances[0] + const balance = pickBalanceByType(balances, BOOKED_BALANCE_TYPES) || balances[0] if (!balance) { - return { amount: 0, date: new Date().toISOString().split('T')[0] } + return null } + const availableBalance = pickBalanceByType(balances, AVAILABLE_BALANCE_TYPES) + const available = availableBalance + ? parseFloat(availableBalance.balance_amount.amount) + : null + return { amount: parseFloat(balance.balance_amount.amount), - date: balance.reference_date || new Date().toISOString().split('T')[0] + date: balance.reference_date || new Date().toISOString().split('T')[0], + available: available != null && Number.isFinite(available) ? available : null, } } diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index 961e0008..5200db34 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -253,8 +253,17 @@ export async function syncAccountTransactions( } else { try { const balance = await getAccountBalance(account.uid) - account.balance = balance.amount - account.balance_updated_at = new Date().toISOString() + // null = the ASPSP returned no balances at all. Keep the previous + // stored value and timestamp; writing a fabricated 0 with a fresh + // timestamp would pin "the bank reports 0 kr" for the next 12h on + // every balance surface. + if (balance) { + account.balance = balance.amount + // Overwrite (not keep) on null: a stale available figure next to a + // fresh booked figure would misstate what can be spent. + account.available_balance = balance.available ?? undefined + account.balance_updated_at = new Date().toISOString() + } } catch { // Keep previous balance, don't update timestamp } diff --git a/extensions/general/enable-banking/types.ts b/extensions/general/enable-banking/types.ts index 7c7a4620..525e32b4 100644 --- a/extensions/general/enable-banking/types.ts +++ b/extensions/general/enable-banking/types.ts @@ -6,6 +6,9 @@ export interface StoredAccount { name?: string currency: string balance?: number + // Bank-reported available balance from the same BALANCES response as + // `balance` (booked). Absent when the ASPSP returns no available type. + available_balance?: number balance_updated_at?: string // When false, the account is part of the PSD2 consent but the user has // chosen not to sync transactions from it. Treated as true if missing diff --git a/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts b/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts index 351d6822..de83b8a3 100644 --- a/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts +++ b/extensions/general/mcp-server/__tests__/list-cash-accounts.test.ts @@ -26,7 +26,7 @@ describe('gnubok_list_cash_accounts', () => { it('maps cash_accounts rows to the qualified wire shape, primary first', async () => { vi.mocked(listForCompany).mockResolvedValue([ - { id: 'ca-1', company_id: 'company-1', ledger_account: '1930', name: 'Företagskonto', currency: 'SEK', iban: 'SE4550000000058398257466', is_primary: true, enabled: true, source: 'enable_banking' }, + { id: 'ca-1', company_id: 'company-1', ledger_account: '1930', name: 'Företagskonto', currency: 'SEK', iban: 'SE4550000000058398257466', is_primary: true, enabled: true, source: 'enable_banking', balance: 12500.75, available_balance: 12000.5, balance_updated_at: '2026-09-01T05:00:00.000Z' }, { id: 'ca-2', company_id: 'company-1', ledger_account: '1940', name: null, currency: 'SEK', iban: null, is_primary: false, enabled: false, source: 'manual' }, ] as never) @@ -46,8 +46,12 @@ describe('gnubok_list_cash_accounts', () => { is_primary: true, enabled: true, source: 'enable_banking', + balance: 12500.75, + available_balance: 12000.5, + balance_updated_at: '2026-09-01T05:00:00.000Z', }) - expect(result.cash_accounts[1]).toMatchObject({ cash_account_id: 'ca-2', name: null, iban: null, enabled: false, source: 'manual' }) + // Manual accounts have no bank-reported balance: explicit nulls, never absent. + expect(result.cash_accounts[1]).toMatchObject({ cash_account_id: 'ca-2', name: null, iban: null, enabled: false, source: 'manual', balance: null, available_balance: null, balance_updated_at: null }) // No bare `id` leaks onto the wire (qualified-ids convention). expect(result.cash_accounts[0]).not.toHaveProperty('id') }) diff --git a/extensions/general/mcp-server/prompts/index.ts b/extensions/general/mcp-server/prompts/index.ts index 9b71aa07..276491fc 100644 --- a/extensions/general/mcp-server/prompts/index.ts +++ b/extensions/general/mcp-server/prompts/index.ts @@ -17,8 +17,12 @@ export const prompts: McpPrompt[] = [ name: 'cash_today', description: 'Visa banksaldo just nu', text: - 'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_get_balance_sheet ' + - 'för dagens datum och rapportera saldot på konto 1930. Visa även de senaste 5 transaktionerna ' + + 'Hur mycket pengar har jag på företagskontot just nu? Anropa gnubok_list_cash_accounts ' + + '(syns det inte i verktygskatalogen: anropa det via gnubok_call_tool) och ' + + 'rapportera bankens rapporterade saldo (balance, available_balance) per konto med tidsstämpeln ' + + 'balance_updated_at. Saknas rapporterat saldo (manuellt konto eller aldrig synkat): fall tillbaka ' + + 'på gnubok_get_balance_sheet för dagens datum och saldot på konto 1930, och säg att siffran är ' + + 'bokförd, inte bankens. Visa även de senaste 5 transaktionerna ' + 'via gnubok_list_uncategorized_transactions (limit=5, sortera nyast först: men inkludera även ' + 'kategoriserade om verktyget tillåter). Svara kort på svenska.', }, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 97316fbe..0e381591 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -11878,7 +11878,7 @@ export const tools: McpTool[] = [ name: 'gnubok_list_cash_accounts', keywords: ['bankkonto', 'kassakonto', 'bankkonton', 'likvidkonton', 'kassa'], title: 'List Cash Accounts', - description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag. Use cash_account_id to filter transaction listings and account_number (ledger_account) for gnubok_get_reconciliation_status.', + description: 'List the company bank/cash accounts (cash_accounts): BAS ledger, currency, IBAN, primary flag, bank-reported balance (booked + available, with balance_updated_at). Use cash_account_id to filter transaction listings; use for "how much money is in the bank".', inputSchema: { type: 'object', additionalProperties: false, @@ -11904,8 +11904,20 @@ export const tools: McpTool[] = [ is_primary: { type: 'boolean' }, enabled: { type: 'boolean' }, source: { type: 'string', enum: ['enable_banking', 'manual', 'sie_import'] }, + balance: { + type: ['number', 'null'], + description: 'Bank-reported booked balance as of balance_updated_at; null for manual accounts or before the first sync. NOT the bookkept 19xx balance.', + }, + available_balance: { + type: ['number', 'null'], + description: 'Bank-reported available balance; null when the bank reports no available type.', + }, + balance_updated_at: { + type: ['string', 'null'], + description: 'ISO timestamp of the last balance fetch (PSD2 quota: refreshed at most every 12h).', + }, }, - required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source'], + required: ['cash_account_id', 'ledger_account', 'name', 'currency', 'iban', 'is_primary', 'enabled', 'source', 'balance', 'available_balance', 'balance_updated_at'], }, }, count: { type: 'number' }, @@ -11934,6 +11946,9 @@ export const tools: McpTool[] = [ is_primary: row.is_primary === true, enabled: row.enabled !== false, source: row.source, + balance: row.balance ?? null, + available_balance: row.available_balance ?? null, + balance_updated_at: row.balance_updated_at ?? null, })) return { cash_accounts: cashAccounts, count: cashAccounts.length } }, diff --git a/lib/__tests__/resolve-account.test.ts b/lib/__tests__/resolve-account.test.ts index a88cc0c6..3affafcd 100644 --- a/lib/__tests__/resolve-account.test.ts +++ b/lib/__tests__/resolve-account.test.ts @@ -14,6 +14,7 @@ function makeCashAccount(overrides: Partial = {}): CashAccount { currency: 'SEK', ledger_account: '1930', balance: null, + available_balance: null, balance_updated_at: null, enabled: true, is_primary: true, diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index 08047d11..1bb22f03 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `142`; +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `143`; exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` [ @@ -19,6 +19,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "GET /api/v1/companies", "GET /api/v1/companies/:companyId/accounts", "GET /api/v1/companies/:companyId/articles", + "GET /api/v1/companies/:companyId/cash-accounts", "GET /api/v1/companies/:companyId/compliance/check", "GET /api/v1/companies/:companyId/customers", "GET /api/v1/companies/:companyId/customers/:id", diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 8b1c7844..1040fbee 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -71,6 +71,7 @@ import '@/app/api/v1/companies/[companyId]/transactions/ingest/route' import '@/app/api/v1/companies/[companyId]/transactions/batch-categorize/route' import '@/app/api/v1/companies/[companyId]/reconciliation/bank/run/route' import '@/app/api/v1/companies/[companyId]/reconciliation/bank/status/route' +import '@/app/api/v1/companies/[companyId]/cash-accounts/route' // Phase 4 PR-1: AP world: suppliers + supplier-invoices verticals. import '@/app/api/v1/companies/[companyId]/suppliers/route' diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index dbbe5894..00af246d 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -145,6 +145,9 @@ export const V1_ENDPOINT_SCOPES: Record = { // Writes: bulk 'POST /api/v1/companies/:companyId/transactions/ingest': 'transactions:write', 'POST /api/v1/companies/:companyId/transactions/batch-categorize': 'transactions:write', + // Cash accounts: the bank/kassa register incl. the bank-reported balance + // (booked + available + balance_updated_at) from the PSD2 sync. + 'GET /api/v1/companies/:companyId/cash-accounts': 'transactions:read', // Reconciliation (legacy bank-only routes; kept as aliases of the // account-keyed routes below, with their original scopes) 'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write', diff --git a/lib/cash-accounts/__tests__/service.test.ts b/lib/cash-accounts/__tests__/service.test.ts index feb8731d..66c2c0a1 100644 --- a/lib/cash-accounts/__tests__/service.test.ts +++ b/lib/cash-accounts/__tests__/service.test.ts @@ -18,6 +18,7 @@ import { defaultLedgerForCurrency, getRevokedConnectionIds, upsertFromPsd2, + updateBalancesFromSync, ensureManualCashAccount, } from '../service' @@ -1210,3 +1211,106 @@ describe('ensureManualCashAccount', () => { ).rejects.toThrow(/boom/) }) }) + +// --------------------------------------------------------------------------- +// updateBalancesFromSync: balance mirror from the PSD2 sync loop +// --------------------------------------------------------------------------- +describe('updateBalancesFromSync', () => { + interface BalanceUpdate { + payload: Record + 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) => { + 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() + }) +}) diff --git a/lib/cash-accounts/service.ts b/lib/cash-accounts/service.ts index 3d1ad3c4..b7c5af8e 100644 --- a/lib/cash-accounts/service.ts +++ b/lib/cash-accounts/service.ts @@ -45,6 +45,7 @@ export interface UpsertFromPsd2Input { iban?: string | null name?: string | null balance?: number | null + available_balance?: number | null balance_updated_at?: string | null enabled?: boolean /** @@ -1088,6 +1089,76 @@ async function rebindMovableTransactions( return moved } +/** One account's refreshed balance snapshot, as the sync loop stores it. */ +export interface SyncedBalanceInput { + external_uid: string + balance?: number | null + available_balance?: number | null + balance_updated_at?: string | null +} + +/** + * Mirror freshly-synced balances from bank_connections.accounts_data into + * cash_accounts. Before this, cash_accounts.balance was written only at + * connect/selection-save time and then drifted: the transactions-page source + * picker (which reads cash_accounts) showed a connect-time snapshot as if it + * were current. + * + * Balance-only by design: routing fields (ledger_account, enabled, name) are + * owned by the picker-save and callback paths via upsertFromPsd2. Rows are + * matched on (company_id, bank_connection_id, external_uid); accounts without + * a timestamped balance are skipped (never null out a stored balance because + * one refresh was skipped or failed). Mirror failures are logged, not thrown: + * a failed mirror must not fail the sync that produced the data. + */ +export async function updateBalancesFromSync( + supabase: SupabaseClient, + companyId: string, + bankConnectionId: string, + accounts: SyncedBalanceInput[], +): Promise { + for (const account of accounts) { + if (account.balance == null || !account.balance_updated_at) continue + // Manual sync and cron are not serialized per connection: an older run + // finishing later must not overwrite a newer mirror (the timestamp would + // visibly move backwards). Only rows with an older-or-missing timestamp + // accept the write. Two literal predicates instead of one .or(), and the + // payload inlined twice: the schema guard cannot resolve dynamically-built + // logical expressions or payload variables. + const { error: staleError } = await supabase + .from('cash_accounts') + .update({ + balance: account.balance, + available_balance: account.available_balance ?? null, + balance_updated_at: account.balance_updated_at, + }) + .eq('company_id', companyId) + .eq('bank_connection_id', bankConnectionId) + .eq('external_uid', account.external_uid) + .lt('balance_updated_at', account.balance_updated_at) + const { error: nullError } = await supabase + .from('cash_accounts') + .update({ + balance: account.balance, + available_balance: account.available_balance ?? null, + balance_updated_at: account.balance_updated_at, + }) + .eq('company_id', companyId) + .eq('bank_connection_id', bankConnectionId) + .eq('external_uid', account.external_uid) + .is('balance_updated_at', null) + const error = staleError ?? nullError + if (error) { + log.error('updateBalancesFromSync failed', { + companyId, + bankConnectionId, + externalUid: account.external_uid, + error: error.message, + }) + } + } +} + /** * Upsert a PSD2-sourced cash account during connection callback / sync. Keyed on * (company_id, bank_connection_id, external_uid). When the row exists, balance @@ -1110,6 +1181,7 @@ export async function upsertFromPsd2( currency: input.currency.toUpperCase(), ledger_account: input.ledger_account, balance: input.balance ?? null, + available_balance: input.available_balance ?? null, balance_updated_at: input.balance_updated_at ?? null, enabled: input.enabled ?? true, source: 'enable_banking' as CashAccountSource, diff --git a/lib/reconciliation/__tests__/service.test.ts b/lib/reconciliation/__tests__/service.test.ts index 7fe03d5b..225799c2 100644 --- a/lib/reconciliation/__tests__/service.test.ts +++ b/lib/reconciliation/__tests__/service.test.ts @@ -374,4 +374,63 @@ describe('getAccountStatus', () => { expect(s.unexplained_difference).toBe(0) expect(s.bank).toMatchObject({ bank_transaction_total: 122288 }) }) + + it('exposes the bank-reported balance from cash_accounts on the bank kind (F7)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: cashAccount(ID_A, { + balance: 125430.5, + available_balance: 123930.5, + balance_updated_at: '2026-08-20T05:12:00Z', + }), + }) + bankStatusMock.mockResolvedValue(bankStatus({ difference: -46, unexplained_difference: 0 })) + enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) + + const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' }) + if (!s) throw new Error('expected status') + // external_balance stays null for bank: sign-off persists it into + // account_reconciliations and bokslutsbilagor computes closing - external + // from that row, so a today-balance on a balansdag sign-off would print a + // phantom differens. The reported balance lives only in the bank block. + expect(s.external_balance).toBeNull() + expect(s.difference).toBe(-46) + expect(s.unexplained_difference).toBe(0) + expect(s.bank).toMatchObject({ + bank_reported_balance: 125430.5, + bank_reported_available_balance: 123930.5, + bank_balance_updated_at: '2026-08-20T05:12:00Z', + }) + }) + + it('leaves external_balance null on the bank kind when no balance was ever synced', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: cashAccount(ID_A, { balance: null, available_balance: null, balance_updated_at: null }) }) + bankStatusMock.mockResolvedValue(bankStatus()) + enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) + + const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' }) + if (!s) throw new Error('expected status') + expect(s.external_balance).toBeNull() + expect(s.bank).toMatchObject({ + bank_reported_balance: null, + bank_reported_available_balance: null, + bank_balance_updated_at: null, + }) + }) + + it('suppresses a stored balance that has no timestamp (age unknown = unusable)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: cashAccount(ID_A, { balance: 500, available_balance: 400, balance_updated_at: null }) }) + bankStatusMock.mockResolvedValue(bankStatus()) + enqueue({ data: { created_at: '2026-08-20T06:00:00Z' } }) + + const s = await getAccountStatus(supabase as never, COMPANY, bankAccountKey(ID_A), { today: '2026-08-20' }) + if (!s) throw new Error('expected status') + expect(s.bank).toMatchObject({ + bank_reported_balance: null, + bank_reported_available_balance: null, + bank_balance_updated_at: null, + }) + }) }) diff --git a/lib/reconciliation/service.ts b/lib/reconciliation/service.ts index 9d5e85c5..c751805e 100644 --- a/lib/reconciliation/service.ts +++ b/lib/reconciliation/service.ts @@ -53,6 +53,9 @@ interface CashAccountRow { is_primary: boolean | null source: string | null bank_connection_id: string | null + balance: number | null + available_balance: number | null + balance_updated_at: string | null updated_at: string | null } @@ -156,6 +159,24 @@ async function bankStatus( ) const syncedAt = await latestBankSyncAt(supabase, companyId, account.id) const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS + // The bank-reported (booked) balance, mirrored from the last PSD2 balance + // refresh. Point-in-time and dated by balance_updated_at, NOT by any + // through-date a caller asks for. It therefore lives ONLY in the bank block + // below, never in external_balance: sign-off persists external_balance into + // account_reconciliations and bokslutsbilagor computes closing - external + // from that row, so a today-balance stored on a balansdag sign-off would + // print a phantom differens in the year-end appendix (skeptic finding, + // PR #2118). difference/unexplained stay transaction-based for the same + // reason. A balance without its timestamp is unusable (age unknown), so + // both fields are exposed only as a pair. + const reportedBalance = + account.balance == null || account.balance_updated_at == null + ? null + : Number(account.balance) + const reportedAvailable = + reportedBalance == null || account.available_balance == null + ? null + : Number(account.available_balance) return { account_key: bankAccountKey(account.id), kind: 'bank', @@ -178,7 +199,14 @@ async function bankStatus( ignored: raw.ignored_transaction_count, }, skattekonto: null, - bank: raw as unknown as Record, + bank: { + ...(raw as unknown as Record), + // What the bank itself reports for the account (F7): booked + + // available + when it was fetched. Distinct from the movement fields. + bank_reported_balance: reportedBalance, + bank_reported_available_balance: reportedAvailable, + bank_balance_updated_at: reportedBalance == null ? null : account.balance_updated_at, + }, } } @@ -221,7 +249,7 @@ export async function listReconciliationAccounts( const { data, error } = await supabase .from('cash_accounts') - .select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, updated_at') + .select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, balance, available_balance, balance_updated_at, updated_at') .eq('company_id', companyId) .eq('enabled', true) .order('is_primary', { ascending: false }) @@ -392,7 +420,7 @@ export async function getAccountStatus( if (parsed.kind === 'bank') { const { data, error } = await supabase .from('cash_accounts') - .select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, updated_at') + .select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, balance, available_balance, balance_updated_at, updated_at') .eq('company_id', companyId) .eq('id', parsed.cashAccountId) .maybeSingle() diff --git a/messages/en.json b/messages/en.json index 35d13e48..3693d058 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8242,6 +8242,8 @@ "load_failed": "Could not load the reconciliation. Try again in a moment.", "tile_external_skv": "Balance at Skatteverket", "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_ledger": "Booked on {account}", "tile_ledger_bank": "Booked on {account} in the period", diff --git a/messages/sv.json b/messages/sv.json index 8f6cf906..102c1cab 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -8242,6 +8242,8 @@ "load_failed": "Kunde inte hämta avstämningen. Försök igen om en stund.", "tile_external_skv": "Saldo hos Skatteverket", "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_ledger": "Bokfört på {account}", "tile_ledger_bank": "Bokfört på {account} i perioden", diff --git a/scripts/api-skill/generate.ts b/scripts/api-skill/generate.ts index dedfefea..3e760905 100644 --- a/scripts/api-skill/generate.ts +++ b/scripts/api-skill/generate.ts @@ -109,10 +109,10 @@ const GROUPS: Array<{ file: string; title: string; members: string[]; blurb: str { file: 'banking.md', title: 'Banking', - members: ['transactions', 'reconciliation', 'imports'], + members: ['transactions', 'cash-accounts', 'reconciliation', 'imports'], blurb: - '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).', }, { file: 'employees.md', diff --git a/skills/accounted-api/SKILL.md b/skills/accounted-api/SKILL.md index 6a7d5e67..99545376 100644 --- a/skills/accounted-api/SKILL.md +++ b/skills/accounted-api/SKILL.md @@ -8,7 +8,7 @@ description: >- transactions and reconciliation, payroll (lön), VAT/moms and financial reports, SIE import/export, documents, webhooks. Covers auth with gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor - pagination, scopes), and all 142 endpoints. + pagination, scopes), and all 143 endpoints. --- @@ -142,7 +142,7 @@ call can undo it, e.g. invoice credit). ## 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`). ### 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] ``` -### Banking (24) +### Banking (25) Full detail: [references/banking.md](references/banking.md) ```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/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] diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md index 3ff299f5..e833522b 100644 --- a/skills/accounted-api/references/banking.md +++ b/skills/accounted-api/references/banking.md @@ -2,11 +2,75 @@ # 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) 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` **Import a bank-file (CSV / XML / CAMT053).** diff --git a/supabase/migrations/20260901150000_cash_accounts_available_balance.sql b/supabase/migrations/20260901150000_cash_accounts_available_balance.sql new file mode 100644 index 00000000..a0ba2c4d --- /dev/null +++ b/supabase/migrations/20260901150000_cash_accounts_available_balance.sql @@ -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'; diff --git a/types/index.ts b/types/index.ts index 10dc51f4..1048e99a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -685,6 +685,7 @@ export interface CashAccount { // tolerate future currencies without DB-driven enum drift ledger_account: string balance: number | null + available_balance: number | null balance_updated_at: string | null enabled: boolean is_primary: boolean