feat(mcp): ask the bank first: connect link deep-starts the named bank's consent (#1951)
* feat(mcp): ask the bank first: connect link deep-starts the named bank's consent The connect card used to open the generic picker page; the user then chose the bank there. The agent now asks 'vilken bank har företaget?' among the opening questions and passes it to gnubok_connect_bank, whose connect_url becomes /import?mode=psd2&bank=<name>. BankSelector resolves the name (exact, then unique prefix, then unique substring: ambiguous names fall back to the prefilled picker rather than guessing an institution) and auto-starts that bank's consent through the same onConnect handler, so the duplicate-pending and renew-instead guards stay fully interactive. The param is stripped via history.replaceState after the one-shot so an aborted bank flow plus back-navigation does not silently relaunch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): first-year suggestion keeps the AB vs enskild firma distinction Compliance-review finding on #1949: the suggestion text collapsed both forms onto a 31 December end. Only an enskild firma's first year MUST end 31 December; an AB may pick any end within BFL 3 kap 3 §'s 18-month cap, with 31 December as the common default. The lookup tool's still_to_ask line and the skill now say so explicitly, and first-year-defaults documents that fiscalYear-null is a strong-not-perfect filed-report signal that must only ever feed confirm-question suggestions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- 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
Claude Fable 5
Jakob Wennberg
parent
9a56b7aff9
commit
ec9cab24cc
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* matchBankByName: the deep-link resolver behind /import?mode=psd2&bank=<name>.
|
||||
* Conservative on purpose: starting a consent at the wrong institution is
|
||||
* worse than falling back to the prefilled picker.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { matchBankByName } from '../lib/bank-match'
|
||||
|
||||
const BANKS = [
|
||||
{ name: 'Swedbank' },
|
||||
{ name: 'SEB' },
|
||||
{ name: 'Nordea' },
|
||||
{ name: 'Handelsbanken' },
|
||||
{ name: 'Danske Bank' },
|
||||
{ name: 'Länsförsäkringar Bank' },
|
||||
{ name: 'Länsförsäkringar Skåne' },
|
||||
{ name: 'ICA Banken' },
|
||||
]
|
||||
|
||||
describe('matchBankByName', () => {
|
||||
it('matches exact names case-insensitively', () => {
|
||||
expect(matchBankByName(BANKS, 'swedbank')?.name).toBe('Swedbank')
|
||||
expect(matchBankByName(BANKS, 'SEB')?.name).toBe('SEB')
|
||||
expect(matchBankByName(BANKS, ' handelsbanken ')?.name).toBe('Handelsbanken')
|
||||
})
|
||||
|
||||
it('matches a unique prefix', () => {
|
||||
expect(matchBankByName(BANKS, 'nord')?.name).toBe('Nordea')
|
||||
expect(matchBankByName(BANKS, 'danske')?.name).toBe('Danske Bank')
|
||||
})
|
||||
|
||||
it('matches a unique substring', () => {
|
||||
expect(matchBankByName(BANKS, 'ica')?.name).toBe('ICA Banken')
|
||||
})
|
||||
|
||||
it('returns null for an ambiguous name instead of guessing an institution', () => {
|
||||
expect(matchBankByName(BANKS, 'länsförsäkringar')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unknown or empty input', () => {
|
||||
expect(matchBankByName(BANKS, 'Monopolbanken')).toBeNull()
|
||||
expect(matchBankByName(BANKS, '')).toBeNull()
|
||||
expect(matchBankByName(BANKS, ' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronRight, Landmark, Loader2, Search } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { matchBankByName } from '../lib/bank-match'
|
||||
|
||||
export interface Bank {
|
||||
name: string
|
||||
@@ -128,6 +129,41 @@ export function BankSelector({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const autoStartHandled = useRef(false)
|
||||
|
||||
// Deep-link preselect (?bank=<name>, set by the MCP connect card when the
|
||||
// user already named their bank in chat): auto-start that bank's consent
|
||||
// the way clicking its row would, so the flow feels like Skatteverket's
|
||||
// one-click authorize. The param is stripped immediately so an abort at
|
||||
// the bank followed by back-navigation does not silently re-launch; an
|
||||
// unknown or ambiguous name just prefills the search instead of guessing.
|
||||
// Guards further down (duplicate pending, renew-instead-409) stay fully
|
||||
// interactive because this runs through the same onConnect handler.
|
||||
useEffect(() => {
|
||||
if (autoStartHandled.current || isLoading || banks.length === 0 || isConnecting) return
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const requested = params.get('bank')
|
||||
if (!requested) {
|
||||
autoStartHandled.current = true
|
||||
return
|
||||
}
|
||||
autoStartHandled.current = true
|
||||
params.delete('bank')
|
||||
const query = params.toString()
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`
|
||||
)
|
||||
const match = matchBankByName(banks, requested)
|
||||
if (match) {
|
||||
onConnect(match)
|
||||
} else {
|
||||
setSearchQuery(requested)
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot after the bank list arrives
|
||||
}, [isLoading, banks, isConnecting])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBanks() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Resolve a user-stated bank name ("swedbank", "SEB", "handelsbanken") to
|
||||
* one ASPSP from the Enable Banking list, so a deep link can start that
|
||||
* bank's consent directly instead of showing the picker.
|
||||
*
|
||||
* Matching is deliberately conservative: exact (case-insensitive) first,
|
||||
* then a UNIQUE prefix match, then a UNIQUE substring match. An ambiguous
|
||||
* or unknown name returns null and the caller falls back to the picker
|
||||
* with the query prefilled: guessing between "Länsförsäkringar Bank" and a
|
||||
* regional "Länsförsäkringar Skåne" would start a consent at the wrong
|
||||
* institution.
|
||||
*/
|
||||
export function matchBankByName<T extends { name: string }>(
|
||||
banks: T[],
|
||||
query: string
|
||||
): T | null {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return null
|
||||
|
||||
const exact = banks.filter((b) => b.name.toLowerCase() === q)
|
||||
if (exact.length === 1) return exact[0]
|
||||
if (exact.length > 1) return null
|
||||
|
||||
const prefix = banks.filter((b) => b.name.toLowerCase().startsWith(q))
|
||||
if (prefix.length === 1) return prefix[0]
|
||||
if (prefix.length > 1) return null
|
||||
|
||||
const substring = banks.filter((b) => b.name.toLowerCase().includes(q))
|
||||
if (substring.length === 1) return substring[0]
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user