cd40127f0e
* 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>
731 lines
28 KiB
TypeScript
731 lines
28 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// Mock api-client
|
|
const mockGetAllTransactionsWithRaw = vi.fn()
|
|
const mockConvertTransaction = vi.fn()
|
|
const mockGetAccountBalance = vi.fn()
|
|
vi.mock('../api-client', () => ({
|
|
getAllTransactionsWithRaw: (...args: unknown[]) => mockGetAllTransactionsWithRaw(...args),
|
|
convertTransaction: (...args: unknown[]) => mockConvertTransaction(...args),
|
|
getAccountBalance: (...args: unknown[]) => mockGetAccountBalance(...args),
|
|
}))
|
|
|
|
// Mock document service
|
|
const mockUploadDocument = vi.fn()
|
|
vi.mock('@/lib/core/documents/document-service', () => ({
|
|
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
|
}))
|
|
|
|
// Mock ingest
|
|
const mockIngest = vi.fn()
|
|
|
|
import { syncAccountTransactions } from '../sync'
|
|
import type { StoredAccount } from '../../types'
|
|
|
|
const USER_ID = 'user-1'
|
|
const COMPANY_ID = 'company-1'
|
|
const CONNECTION_ID = 'conn-1'
|
|
|
|
function makeAccount(overrides: Partial<StoredAccount> = {}): StoredAccount {
|
|
return {
|
|
uid: 'acc-uid-1',
|
|
currency: 'SEK',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('syncAccountTransactions', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockGetAccountBalance.mockRejectedValue(new Error('skip'))
|
|
mockIngest.mockResolvedValue({ imported: 1, duplicates: 0, errors: 0, reconciled: 0, auto_categorized: 0, auto_matched_invoices: 0, transaction_ids: ['tx-1'] })
|
|
})
|
|
|
|
it('calls uploadDocument for each raw page with correct filename pattern', async () => {
|
|
const rawPage1 = JSON.stringify({ transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' } }] })
|
|
const rawPage2 = JSON.stringify({ transactions: [{ transaction_amount: { amount: '200', currency: 'SEK' } }] })
|
|
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [
|
|
{ transaction_amount: { amount: '100', currency: 'SEK' } },
|
|
{ transaction_amount: { amount: '200', currency: 'SEK' } },
|
|
],
|
|
rawPages: [rawPage1, rawPage2],
|
|
})
|
|
|
|
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string } }) => ({
|
|
id: `tx-${tx.transaction_amount.amount}`,
|
|
date: '2024-06-15',
|
|
booking_date: '2024-06-15',
|
|
amount: parseFloat(tx.transaction_amount.amount),
|
|
currency: 'SEK',
|
|
description: 'Test',
|
|
}))
|
|
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
const account = makeAccount()
|
|
await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
mockIngest
|
|
)
|
|
|
|
expect(mockUploadDocument).toHaveBeenCalledTimes(2)
|
|
|
|
// Verify filename pattern
|
|
const firstCall = mockUploadDocument.mock.calls[0]
|
|
expect(firstCall[3].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p1\.json$/)
|
|
expect(firstCall[3].type).toBe('application/json')
|
|
expect(firstCall[4]).toEqual({ upload_source: 'api' })
|
|
|
|
const secondCall = mockUploadDocument.mock.calls[1]
|
|
expect(secondCall[3].name).toMatch(/^psd2-response_conn-1_acc-uid-1_.*_p2\.json$/)
|
|
})
|
|
|
|
it('completes sync even if uploadDocument throws', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' } }],
|
|
rawPages: ['{"transactions":[]}'],
|
|
})
|
|
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1',
|
|
date: '2024-06-15',
|
|
booking_date: '2024-06-15',
|
|
amount: 100,
|
|
currency: 'SEK',
|
|
description: 'Test',
|
|
})
|
|
|
|
mockUploadDocument.mockRejectedValue(new Error('Storage error'))
|
|
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
|
|
const account = makeAccount()
|
|
const result = await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
mockIngest
|
|
)
|
|
|
|
expect(result.imported).toBe(1)
|
|
expect(result.errors).toBe(0)
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('Failed to archive raw response'),
|
|
expect.any(Error)
|
|
)
|
|
|
|
errorSpy.mockRestore()
|
|
})
|
|
|
|
it('forwards strategy from sync options to getAllTransactionsWithRaw', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
makeAccount(),
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
mockIngest,
|
|
{ strategy: 'longest' }
|
|
)
|
|
|
|
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
|
|
'acc-uid-1',
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
'longest'
|
|
)
|
|
})
|
|
|
|
it('omits strategy when sync options do not include it', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
makeAccount(),
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
mockIngest
|
|
)
|
|
|
|
expect(mockGetAllTransactionsWithRaw).toHaveBeenCalledWith(
|
|
'acc-uid-1',
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
undefined
|
|
)
|
|
})
|
|
|
|
it('passes raw transactions to ingest function', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '500', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-500',
|
|
date: '2024-06-15',
|
|
booking_date: '2024-06-15',
|
|
amount: -500,
|
|
currency: 'SEK',
|
|
description: 'Purchase',
|
|
counterparty_name: 'Store',
|
|
merchant_category_code: '5411',
|
|
})
|
|
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
const account = makeAccount()
|
|
await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2024-01-01',
|
|
'2024-12-31',
|
|
mockIngest
|
|
)
|
|
|
|
expect(mockIngest).toHaveBeenCalledTimes(1)
|
|
const rawTxns = mockIngest.mock.calls[0][3]
|
|
expect(rawTxns).toHaveLength(1)
|
|
// Content-derived external_id: eb_{accountScope}_{date}_{öre}_{occurrence}.
|
|
// Deliberately NOT keyed off the bank's (unstable) tx id.
|
|
expect(rawTxns[0].external_id).toBe('eb_acc-uid-1_2024-06-15_-50000_0')
|
|
expect(rawTxns[0].import_source).toBe('enable_banking')
|
|
})
|
|
|
|
it('skips pending entries (no booking_date) and ingests only booked ones', async () => {
|
|
// A pending PSD2 entry has no booking_date: only a value_date. Importing it
|
|
// is what produced the production duplicates: its date (hence its dedup id)
|
|
// differs from the same transaction's booked representation. Pending entries
|
|
// must be skipped; booked ones import as usual, keyed on booking_date.
|
|
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
|
|
id: 'x',
|
|
date: tx.booking_date || tx.value_date,
|
|
booking_date: tx.booking_date || tx.value_date,
|
|
amount: -parseFloat(tx.transaction_amount.amount),
|
|
currency: 'SEK',
|
|
description: 'T',
|
|
}))
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [
|
|
{ transaction_amount: { amount: '50', currency: 'SEK' }, value_date: '2026-02-15' }, // pending → skipped
|
|
{ transaction_amount: { amount: '75', currency: 'SEK' }, booking_date: '2026-03-01' }, // booked → kept
|
|
],
|
|
rawPages: ['{}'],
|
|
})
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
|
|
const batch = mockIngest.mock.calls[0][3]
|
|
expect(batch).toHaveLength(1)
|
|
expect(batch[0].date).toBe('2026-03-01')
|
|
expect(batch[0].external_id).toBe('eb_acc-uid-1_2026-03-01_-7500_0')
|
|
})
|
|
|
|
it('does not re-import a booked transaction when a later sync returns it pending (no booking_date)', async () => {
|
|
// Regression for the 45→90 duplication. The same transaction is returned
|
|
// booked in sync 1 (booking_date 2026-04-21) and pending in sync 2 (only
|
|
// value_date 2026-02-15). Previously sync 2 ingested the pending copy with a
|
|
// value_date-derived id (different date → new id → duplicate). It must now
|
|
// be skipped, so sync 2 ingests nothing for it.
|
|
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date?: string, value_date?: string }) => ({
|
|
id: 'x',
|
|
date: tx.booking_date || tx.value_date,
|
|
booking_date: tx.booking_date || tx.value_date,
|
|
amount: -parseFloat(tx.transaction_amount.amount),
|
|
currency: 'SEK',
|
|
description: 'AVI ÖVERDRAG',
|
|
}))
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
// Sync 1: booked
|
|
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-21', value_date: '2026-02-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
const firstBatch = mockIngest.mock.calls[0][3]
|
|
expect(firstBatch).toHaveLength(1)
|
|
expect(firstBatch[0].date).toBe('2026-04-21')
|
|
expect(firstBatch[0].external_id).toBe('eb_acc-uid-1_2026-04-21_-10000_0')
|
|
|
|
// Sync 2: same transaction now returned pending (booking_date dropped)
|
|
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, value_date: '2026-02-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
const secondBatch = mockIngest.mock.calls[1][3]
|
|
expect(secondBatch).toHaveLength(0)
|
|
})
|
|
|
|
it('gives identical same-day same-amount transactions distinct, stable external_ids', async () => {
|
|
// Two genuinely distinct transactions that share date + amount must both be
|
|
// kept (distinct ids), and re-running the sync must reproduce the SAME set
|
|
// of ids so the second sync dedupes instead of duplicating.
|
|
const apiTxns = [
|
|
{ transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
|
|
{ transaction_amount: { amount: '250', currency: 'SEK' }, booking_date: '2024-06-15' },
|
|
]
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: apiTxns, rawPages: ['{}'] })
|
|
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
|
|
id: `bank-id-${Math.random()}`, // unstable bank id: must NOT influence external_id
|
|
date: tx.booking_date,
|
|
booking_date: tx.booking_date,
|
|
amount: -parseFloat(tx.transaction_amount.amount),
|
|
currency: 'SEK',
|
|
description: 'Kaffe',
|
|
}))
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
|
|
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
|
|
expect(ids).toEqual([
|
|
'eb_acc-uid-1_2024-06-15_-25000_0',
|
|
'eb_acc-uid-1_2024-06-15_-25000_1',
|
|
])
|
|
})
|
|
|
|
it('prefers IBAN over uid for the external_id account scope', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
|
|
makeAccount({ iban: 'SE4550000000058398257466' }),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
|
|
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
|
|
expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
|
|
})
|
|
|
|
it('normalizes IBAN whitespace/case so the account scope is stable across syncs', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
// ASPSP returns the IBAN in grouped, lowercased display form.
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
|
|
makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' }),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
|
|
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
|
|
// Same scope as the spaced/cased variant above.
|
|
expect(ids).toEqual(['eb_SE4550000000058398257466_2024-06-15_10000_0'])
|
|
})
|
|
|
|
it('keeps external_ids identical across a uid change when dedup_scope is preserved', async () => {
|
|
// The renewal case for accounts WITHOUT an IBAN: many ASPSPs mint a fresh
|
|
// uid on re-authorization. dedup_scope pins the scope of the first ingest,
|
|
// so the re-synced ids must be byte-identical and collide on
|
|
// (company_id, external_id) instead of re-importing the history.
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
|
|
makeAccount({ uid: 'uid-before-renewal', dedup_scope: 'uid-before-renewal' }),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
|
|
|
|
// Renewed consent: the ASPSP minted a NEW uid, the carried scope survives.
|
|
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
|
|
makeAccount({ uid: 'uid-after-renewal', dedup_scope: 'uid-before-renewal' }),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id)
|
|
|
|
expect(firstIds).toEqual(['eb_uid-before-renewal_2024-06-15_10000_0'])
|
|
expect(secondIds).toEqual(firstIds)
|
|
})
|
|
|
|
it('prefers dedup_scope over both IBAN and uid for the external_id account scope', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID,
|
|
makeAccount({ iban: 'SE4550000000058398257466', dedup_scope: 'pinned-scope' }),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
|
|
const ids = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
|
|
expect(ids).toEqual(['eb_pinned-scope_2024-06-15_10000_0'])
|
|
})
|
|
|
|
it('stamps dedup_scope with the scope used, so the accounts_data write-back persists it', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2024-06-15' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1', date: '2024-06-15', booking_date: '2024-06-15', amount: 100, currency: 'SEK', description: 'Test',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
const withIban = makeAccount({ iban: 'se45 5000 0000 0583 9825 7466' })
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, withIban,
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
expect(withIban.dedup_scope).toBe('SE4550000000058398257466')
|
|
|
|
const withoutIban = makeAccount()
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, withoutIban,
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
expect(withoutIban.dedup_scope).toBe('acc-uid-1')
|
|
})
|
|
|
|
it('reproduces the same SET of external_ids when a re-sync returns transactions in a different order', async () => {
|
|
// Two genuinely distinct same-day/same-amount transactions. A later sync may
|
|
// return them in any order; the dedupe guarantee is that the id SET is
|
|
// identical, so the re-sync collides on (company_id, external_id).
|
|
const mk = (booking_date: string, amount: string) => ({ transaction_amount: { amount, currency: 'SEK' }, booking_date })
|
|
const convert = (tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
|
|
id: `bank-${Math.random()}`, // unstable bank id: irrelevant to external_id
|
|
date: tx.booking_date,
|
|
booking_date: tx.booking_date,
|
|
amount: -parseFloat(tx.transaction_amount.amount),
|
|
currency: 'SEK',
|
|
description: 'Lunch',
|
|
})
|
|
mockConvertTransaction.mockImplementation(convert)
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
// First sync order.
|
|
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
|
|
transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
|
|
rawPages: ['{}'],
|
|
})
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
|
|
'2024-06-01', '2024-06-30', mockIngest
|
|
)
|
|
const firstIds = mockIngest.mock.calls[0][3].map((t: { external_id: string }) => t.external_id)
|
|
|
|
// Re-sync, reversed order (and different lookback window does not matter).
|
|
mockGetAllTransactionsWithRaw.mockResolvedValueOnce({
|
|
transactions: [mk('2024-06-15', '250'), mk('2024-06-15', '250')],
|
|
rawPages: ['{}'],
|
|
})
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, makeAccount(),
|
|
'2024-03-01', '2024-06-30', mockIngest
|
|
)
|
|
const secondIds = mockIngest.mock.calls[1][3].map((t: { external_id: string }) => t.external_id)
|
|
|
|
expect(new Set(firstIds)).toEqual(new Set(secondIds))
|
|
expect(firstIds).toHaveLength(2)
|
|
})
|
|
|
|
it('returns the min/max booking date the ASPSP returned for the activation UI', async () => {
|
|
// The min/max loop reads booking_date from the *raw* transactions (sync.ts:75-82),
|
|
// before convertTransaction runs: so the dates need to be set here.
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [
|
|
{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-15' },
|
|
{ transaction_amount: { amount: '200', currency: 'SEK' }, booking_date: '2026-02-20' },
|
|
{ transaction_amount: { amount: '300', currency: 'SEK' }, booking_date: '2026-05-10' },
|
|
],
|
|
rawPages: ['{}'],
|
|
})
|
|
|
|
mockConvertTransaction.mockImplementation((tx: { transaction_amount: { amount: string }, booking_date: string }) => ({
|
|
id: `tx-${tx.transaction_amount.amount}`,
|
|
date: tx.booking_date,
|
|
booking_date: tx.booking_date,
|
|
amount: parseFloat(tx.transaction_amount.amount),
|
|
currency: 'SEK',
|
|
description: 'Test',
|
|
}))
|
|
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
const account = makeAccount()
|
|
const result = await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2026-02-13',
|
|
'2026-05-13',
|
|
mockIngest
|
|
)
|
|
|
|
expect(result.returnedMinBookingDate).toBe('2026-02-20')
|
|
expect(result.returnedMaxBookingDate).toBe('2026-05-10')
|
|
})
|
|
|
|
it('returns undefined min/max when no transactions came back', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [],
|
|
rawPages: [],
|
|
})
|
|
|
|
const account = makeAccount()
|
|
const result = await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2026-02-13',
|
|
'2026-05-13',
|
|
mockIngest
|
|
)
|
|
|
|
expect(result.returnedMinBookingDate).toBeUndefined()
|
|
expect(result.returnedMaxBookingDate).toBeUndefined()
|
|
})
|
|
|
|
it('passes account.ledger_account as IngestOptions.settlementAccount when set', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'EUR' }, booking_date: '2026-04-01' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1',
|
|
date: '2026-04-01',
|
|
booking_date: '2026-04-01',
|
|
amount: 100,
|
|
currency: 'EUR',
|
|
description: 'EUR purchase',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
const account = makeAccount({ uid: 'eur-acc', currency: 'EUR', ledger_account: '1932' })
|
|
await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2026-02-13',
|
|
'2026-05-13',
|
|
mockIngest
|
|
)
|
|
|
|
const ingestOptions = mockIngest.mock.calls[0][4]
|
|
expect(ingestOptions).toMatchObject({ settlementAccount: '1932' })
|
|
})
|
|
|
|
it('skips the balance call when the stored balance is fresher than 12 hours', async () => {
|
|
// PSD2 unattended consents allow only 4 BALANCES calls per account per
|
|
// day; a fresh stored balance must not burn the quota on every manual sync.
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
|
|
|
const freshAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
|
const account = makeAccount({ balance: 500, balance_updated_at: freshAt })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
|
|
expect(mockGetAccountBalance).not.toHaveBeenCalled()
|
|
expect(account.balance).toBe(500)
|
|
expect(account.balance_updated_at).toBe(freshAt)
|
|
})
|
|
|
|
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', available: 1100.5 })
|
|
|
|
const staleAt = new Date(Date.now() - 13 * 60 * 60 * 1000).toISOString()
|
|
const account = makeAccount({ balance: 500, balance_updated_at: staleAt })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
|
|
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.
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
|
mockGetAccountBalance.mockResolvedValue({ amount: 1234.56, date: '2026-06-01' })
|
|
|
|
const futureAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
|
const account = makeAccount({ balance: 500, balance_updated_at: futureAt })
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
|
|
expect(mockGetAccountBalance).toHaveBeenCalledWith('acc-uid-1')
|
|
expect(account.balance).toBe(1234.56)
|
|
expect(account.balance_updated_at).not.toBe(futureAt)
|
|
})
|
|
|
|
it('attempts a balance refresh when no timestamp is stored', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({ transactions: [], rawPages: [] })
|
|
|
|
const account = makeAccount() // no balance_updated_at
|
|
|
|
await syncAccountTransactions(
|
|
{} as never, COMPANY_ID, USER_ID, CONNECTION_ID, account,
|
|
'2026-01-01', '2026-06-01', mockIngest
|
|
)
|
|
|
|
// The beforeEach default rejects the call: the sync must survive that and
|
|
// leave the timestamp unset (so the next sync tries again).
|
|
expect(mockGetAccountBalance).toHaveBeenCalledTimes(1)
|
|
expect(account.balance_updated_at).toBeUndefined()
|
|
})
|
|
|
|
it('omits settlementAccount when account.ledger_account is unset (mapping engine defaults to 1930)', async () => {
|
|
mockGetAllTransactionsWithRaw.mockResolvedValue({
|
|
transactions: [{ transaction_amount: { amount: '100', currency: 'SEK' }, booking_date: '2026-04-01' }],
|
|
rawPages: ['{}'],
|
|
})
|
|
mockConvertTransaction.mockReturnValue({
|
|
id: 'tx-1',
|
|
date: '2026-04-01',
|
|
booking_date: '2026-04-01',
|
|
amount: 100,
|
|
currency: 'SEK',
|
|
description: 'SEK purchase',
|
|
})
|
|
mockUploadDocument.mockResolvedValue({ id: 'doc-1' })
|
|
|
|
const account = makeAccount() // No ledger_account
|
|
await syncAccountTransactions(
|
|
{} as never,
|
|
COMPANY_ID,
|
|
USER_ID,
|
|
CONNECTION_ID,
|
|
account,
|
|
'2026-02-13',
|
|
'2026-05-13',
|
|
mockIngest
|
|
)
|
|
|
|
const ingestOptions = mockIngest.mock.calls[0][4]
|
|
expect(ingestOptions.settlementAccount).toBeUndefined()
|
|
})
|
|
})
|