fix(enable-banking): one primary action per connection state on /settings/banking (#1727)
Restructure the banking settings page so every connection state has a
clear hierarchy:
- One "Dina bankkopplingar" group sorted by state precedence
(pending_selection, pending, error, expired, expiring soon, active),
replacing the three-way group split. State derivation, sorting and
worst-state selection live in a pure, unit-tested helper
(lib/connection-state.ts).
- Exactly one page-level .attn sentence for the worst state, or none;
the BankSyncStatusChip is removed from this page (it linked to
itself; it stays on /transactions and /import).
- Each row shows one primary action per state (Valj konton, Forsok
igen, Fornya samtycke, Synka nu); everything else moves into a "..."
menu, and details (accounts, IBAN, balances, initial historik) sit
behind a collapsed disclosure. Expired rows never show balances.
- Expiring-soon active rows get a "Fornya samtycke" primary that
reconnects without a psu-type override (the server reuses the stored
psu_type); the explicit account-type choice stays in the menu.
- "Anslut ny bank" collapses behind one outline "Anslut en bank till"
button whenever a non-revoked connection exists; the reuse-session
group only shows while the connect-new surface is visible.
- Fresh connects to an already-connected bank are intercepted with a
renew-instead dialog; "Anslut som ny" proceeds with force_new: true
for the upcoming server-side 409 guard.
- In-flight 'pending' rows render as a spinner row ("Vantar pa banken")
instead of being invisible.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
e1805125af
commit
1ded1af8fe
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
buildPageAttentionSentence,
|
||||
getConnectionUiState,
|
||||
selectPageAttention,
|
||||
sortConnectionsByPrecedence,
|
||||
EXPIRY_WARNING_DAYS,
|
||||
STALE_SYNC_DAYS,
|
||||
} from '../connection-state'
|
||||
|
||||
const NOW = new Date('2026-08-19T12:00:00Z').getTime()
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function iso(offsetDays: number): string {
|
||||
return new Date(NOW + offsetDays * DAY_MS).toISOString()
|
||||
}
|
||||
|
||||
function conn(overrides: {
|
||||
status?: string
|
||||
consent_expires?: string | null
|
||||
last_synced_at?: string | null
|
||||
created_at?: string
|
||||
bank_name?: string
|
||||
}) {
|
||||
return {
|
||||
status: 'active',
|
||||
consent_expires: iso(60),
|
||||
last_synced_at: iso(-1),
|
||||
created_at: iso(-30),
|
||||
bank_name: 'SEB',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('getConnectionUiState', () => {
|
||||
it('maps DB statuses straight through', () => {
|
||||
expect(getConnectionUiState(conn({ status: 'pending_selection' }), NOW)).toBe('pending_selection')
|
||||
expect(getConnectionUiState(conn({ status: 'pending' }), NOW)).toBe('pending')
|
||||
expect(getConnectionUiState(conn({ status: 'error' }), NOW)).toBe('error')
|
||||
expect(getConnectionUiState(conn({ status: 'expired' }), NOW)).toBe('expired')
|
||||
})
|
||||
|
||||
it('classifies a healthy active row as active', () => {
|
||||
expect(getConnectionUiState(conn({}), NOW)).toBe('active')
|
||||
})
|
||||
|
||||
it('flags consent expiring within the warning window', () => {
|
||||
expect(
|
||||
getConnectionUiState(conn({ consent_expires: iso(EXPIRY_WARNING_DAYS - 1) }), NOW),
|
||||
).toBe('expiring')
|
||||
expect(
|
||||
getConnectionUiState(conn({ consent_expires: iso(EXPIRY_WARNING_DAYS + 1) }), NOW),
|
||||
).toBe('active')
|
||||
})
|
||||
|
||||
it('expiring beats stale on the same row', () => {
|
||||
expect(
|
||||
getConnectionUiState(
|
||||
conn({ consent_expires: iso(2), last_synced_at: iso(-10) }),
|
||||
NOW,
|
||||
),
|
||||
).toBe('expiring')
|
||||
})
|
||||
|
||||
it('flags stale and never-synced active rows', () => {
|
||||
expect(
|
||||
getConnectionUiState(conn({ last_synced_at: iso(-STALE_SYNC_DAYS) }), NOW),
|
||||
).toBe('stale')
|
||||
expect(
|
||||
getConnectionUiState(conn({ last_synced_at: iso(-(STALE_SYNC_DAYS - 1)) }), NOW),
|
||||
).toBe('active')
|
||||
expect(getConnectionUiState(conn({ last_synced_at: null }), NOW)).toBe('never_synced')
|
||||
})
|
||||
|
||||
it('handles a row with no consent date', () => {
|
||||
expect(getConnectionUiState(conn({ consent_expires: null }), NOW)).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sortConnectionsByPrecedence', () => {
|
||||
it('orders pending_selection, pending, error, expired, expiring, active', () => {
|
||||
const rows = [
|
||||
conn({ bank_name: 'Healthy' }),
|
||||
conn({ bank_name: 'Expiring', consent_expires: iso(2) }),
|
||||
conn({ bank_name: 'Expired', status: 'expired' }),
|
||||
conn({ bank_name: 'Errored', status: 'error' }),
|
||||
conn({ bank_name: 'InFlight', status: 'pending' }),
|
||||
conn({ bank_name: 'PickAccounts', status: 'pending_selection' }),
|
||||
]
|
||||
expect(sortConnectionsByPrecedence(rows, NOW).map((r) => r.bank_name)).toEqual([
|
||||
'PickAccounts',
|
||||
'InFlight',
|
||||
'Errored',
|
||||
'Expired',
|
||||
'Expiring',
|
||||
'Healthy',
|
||||
])
|
||||
})
|
||||
|
||||
it('breaks ties by newest created_at first and does not mutate the input', () => {
|
||||
const older = conn({ bank_name: 'Older', created_at: iso(-100) })
|
||||
const newer = conn({ bank_name: 'Newer', created_at: iso(-1) })
|
||||
const rows = [older, newer]
|
||||
const sorted = sortConnectionsByPrecedence(rows, NOW)
|
||||
expect(sorted.map((r) => r.bank_name)).toEqual(['Newer', 'Older'])
|
||||
expect(rows[0]).toBe(older)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectPageAttention', () => {
|
||||
it('returns null when every connection is healthy or has no attention state', () => {
|
||||
expect(selectPageAttention([conn({})], NOW)).toBeNull()
|
||||
expect(selectPageAttention([conn({ status: 'pending_selection' })], NOW)).toBeNull()
|
||||
expect(selectPageAttention([], NOW)).toBeNull()
|
||||
})
|
||||
|
||||
it('picks the worst state: error beats expired beats expiring beats stale', () => {
|
||||
const errored = conn({ bank_name: 'Errored', status: 'error' })
|
||||
const expired = conn({ bank_name: 'Expired', status: 'expired' })
|
||||
const expiring = conn({ bank_name: 'Expiring', consent_expires: iso(2) })
|
||||
const stale = conn({ bank_name: 'Stale', last_synced_at: iso(-10) })
|
||||
|
||||
expect(selectPageAttention([stale, expiring, expired, errored], NOW)?.connection.bank_name).toBe('Errored')
|
||||
expect(selectPageAttention([stale, expiring, expired], NOW)?.connection.bank_name).toBe('Expired')
|
||||
expect(selectPageAttention([stale, expiring], NOW)?.connection.bank_name).toBe('Expiring')
|
||||
expect(selectPageAttention([stale], NOW)?.state).toBe('stale')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPageAttentionSentence', () => {
|
||||
it('names the bank and the state', () => {
|
||||
const attention = selectPageAttention([conn({ status: 'expired' })], NOW)!
|
||||
expect(buildPageAttentionSentence(attention, NOW)).toBe(
|
||||
'SEB: PSD2-samtycket har löpt ut. Förnya samtycket för att återuppta synkroniseringen.',
|
||||
)
|
||||
})
|
||||
|
||||
it('counts days for the expiring state with singular/plural', () => {
|
||||
const one = selectPageAttention([conn({ consent_expires: iso(1) })], NOW)!
|
||||
expect(buildPageAttentionSentence(one, NOW)).toContain('går ut om 1 dag.')
|
||||
const five = selectPageAttention([conn({ consent_expires: iso(5) })], NOW)!
|
||||
expect(buildPageAttentionSentence(five, NOW)).toContain('går ut om 5 dagar.')
|
||||
})
|
||||
|
||||
it('counts days since sync for the stale state', () => {
|
||||
const attention = selectPageAttention([conn({ last_synced_at: iso(-10) })], NOW)!
|
||||
expect(buildPageAttentionSentence(attention, NOW)).toContain('ingen synkning på 10 dagar')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* UI state model for /settings/banking.
|
||||
*
|
||||
* The DB status ('pending', 'pending_selection', 'active', 'expired',
|
||||
* 'error', 'revoked') is not the same thing as what the page should say:
|
||||
* an 'active' row whose consent runs out in three days needs renewal, and
|
||||
* an 'active' row that has not synced for days needs a sync check. This
|
||||
* module derives that presentation state once, so the panel's sort order,
|
||||
* the single page-level attention sentence, and the per-row primary action
|
||||
* all agree on which state a connection is in.
|
||||
*
|
||||
* Pure functions only (no React, no fetch): unit-tested in
|
||||
* lib/__tests__/connection-state.test.ts.
|
||||
*/
|
||||
|
||||
/** Days before consent expiry at which renewal becomes the primary action.
|
||||
* Must match isConsentExpiringSoon in api-client.ts. */
|
||||
export const EXPIRY_WARNING_DAYS = 7
|
||||
|
||||
/** Days without a completed sync before an 'active' row is treated as stale.
|
||||
* The nightly cron runs daily, so 3 days is several missed runs. */
|
||||
export const STALE_SYNC_DAYS = 3
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** Presentation state, from most to least urgent (see STATE_PRECEDENCE). */
|
||||
export type ConnectionUiState =
|
||||
| 'pending_selection'
|
||||
| 'pending'
|
||||
| 'error'
|
||||
| 'expired'
|
||||
| 'expiring'
|
||||
| 'stale'
|
||||
| 'never_synced'
|
||||
| 'active'
|
||||
|
||||
/** The fields the state derivation reads; structural so tests and callers
|
||||
* don't have to build full BankConnection rows. */
|
||||
export interface ConnectionStateInput {
|
||||
status: string
|
||||
consent_expires: string | null
|
||||
last_synced_at: string | null
|
||||
}
|
||||
|
||||
export function getConnectionUiState(
|
||||
connection: ConnectionStateInput,
|
||||
now: number = Date.now(),
|
||||
): ConnectionUiState {
|
||||
switch (connection.status) {
|
||||
case 'pending_selection':
|
||||
return 'pending_selection'
|
||||
case 'pending':
|
||||
return 'pending'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'expired':
|
||||
return 'expired'
|
||||
default: {
|
||||
// 'active' (and, defensively, any unknown status): refine by liveness.
|
||||
if (connection.consent_expires) {
|
||||
const expires = new Date(connection.consent_expires).getTime()
|
||||
if (expires <= now + EXPIRY_WARNING_DAYS * DAY_MS) return 'expiring'
|
||||
}
|
||||
if (!connection.last_synced_at) return 'never_synced'
|
||||
const daysSinceSync = Math.floor(
|
||||
(now - new Date(connection.last_synced_at).getTime()) / DAY_MS,
|
||||
)
|
||||
if (daysSinceSync >= STALE_SYNC_DAYS) return 'stale'
|
||||
return 'active'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sort order for the single "Dina bankkopplingar" group: the row that needs
|
||||
* the user first sits first. */
|
||||
const STATE_PRECEDENCE: Record<ConnectionUiState, number> = {
|
||||
pending_selection: 0,
|
||||
pending: 1,
|
||||
error: 2,
|
||||
expired: 3,
|
||||
expiring: 4,
|
||||
stale: 5,
|
||||
never_synced: 6,
|
||||
active: 7,
|
||||
}
|
||||
|
||||
export function sortConnectionsByPrecedence<
|
||||
T extends ConnectionStateInput & { created_at: string },
|
||||
>(connections: T[], now: number = Date.now()): T[] {
|
||||
return [...connections].sort((a, b) => {
|
||||
const diff =
|
||||
STATE_PRECEDENCE[getConnectionUiState(a, now)] -
|
||||
STATE_PRECEDENCE[getConnectionUiState(b, now)]
|
||||
if (diff !== 0) return diff
|
||||
// Within a state, newest first (matches the previous created_at desc order).
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
}
|
||||
|
||||
/** States that earn the page's one .attn sentence (design convention 6:
|
||||
* attention is ONE ochre sentence per page). Worst first. */
|
||||
export type PageAttentionState = 'error' | 'expired' | 'expiring' | 'stale' | 'never_synced'
|
||||
|
||||
const ATTENTION_PRECEDENCE: PageAttentionState[] = [
|
||||
'error',
|
||||
'expired',
|
||||
'expiring',
|
||||
'stale',
|
||||
'never_synced',
|
||||
]
|
||||
|
||||
export interface PageAttention<T> {
|
||||
state: PageAttentionState
|
||||
connection: T
|
||||
}
|
||||
|
||||
/** Pick the single worst-state connection the page should call out, or null
|
||||
* when every connection is healthy (or there are none). */
|
||||
export function selectPageAttention<T extends ConnectionStateInput>(
|
||||
connections: T[],
|
||||
now: number = Date.now(),
|
||||
): PageAttention<T> | null {
|
||||
for (const state of ATTENTION_PRECEDENCE) {
|
||||
const match = connections.find((c) => getConnectionUiState(c, now) === state)
|
||||
if (match) return { state, connection: match }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The one page-level attention sentence. Swedish by the enable-banking
|
||||
* component convention (extension UI is hardcoded Swedish). */
|
||||
export function buildPageAttentionSentence(
|
||||
attention: PageAttention<ConnectionStateInput & { bank_name: string }>,
|
||||
now: number = Date.now(),
|
||||
): string {
|
||||
const bank = attention.connection.bank_name
|
||||
switch (attention.state) {
|
||||
case 'error':
|
||||
return `${bank}: anslutningen har ett fel. Försök igen eller förnya samtycket.`
|
||||
case 'expired':
|
||||
return `${bank}: PSD2-samtycket har löpt ut. Förnya samtycket för att återuppta synkroniseringen.`
|
||||
case 'expiring': {
|
||||
const expires = attention.connection.consent_expires
|
||||
const days = expires
|
||||
? Math.max(0, Math.ceil((new Date(expires).getTime() - now) / DAY_MS))
|
||||
: 0
|
||||
return `${bank}: samtycket går ut om ${days} ${days === 1 ? 'dag' : 'dagar'}. Förnya det för att undvika avbrott i synkroniseringen.`
|
||||
}
|
||||
case 'stale': {
|
||||
const last = attention.connection.last_synced_at
|
||||
const days = last ? Math.floor((now - new Date(last).getTime()) / DAY_MS) : 0
|
||||
return `${bank}: ingen synkning på ${days} dagar. Kör Synka för att kontrollera att anslutningen fortfarande fungerar.`
|
||||
}
|
||||
case 'never_synced':
|
||||
return `${bank}: anslutningen har aldrig synkat. Kör Synka för att hämta transaktioner.`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user