From ec9cab24ccfae63949ae3b374b4d06f1c237e1ae Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 26 Aug 2026 14:31:45 +0200 Subject: [PATCH] feat(mcp): ask the bank first: connect link deep-starts the named bank's consent (#1951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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=. 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../__tests__/bank-match.test.ts | 45 +++++++++++++++++++ .../components/BankSelector.tsx | 36 +++++++++++++++ .../general/enable-banking/lib/bank-match.ts | 32 +++++++++++++ .../__tests__/connect-links.test.ts | 22 +++++++++ extensions/general/mcp-server/server.ts | 22 ++++++--- .../general/mcp-server/skills/onboarding.ts | 16 ++++--- lib/company/first-year-defaults.ts | 6 +++ 7 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 extensions/general/enable-banking/__tests__/bank-match.test.ts create mode 100644 extensions/general/enable-banking/lib/bank-match.ts diff --git a/extensions/general/enable-banking/__tests__/bank-match.test.ts b/extensions/general/enable-banking/__tests__/bank-match.test.ts new file mode 100644 index 00000000..d1788f05 --- /dev/null +++ b/extensions/general/enable-banking/__tests__/bank-match.test.ts @@ -0,0 +1,45 @@ +/** + * matchBankByName: the deep-link resolver behind /import?mode=psd2&bank=. + * 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() + }) +}) diff --git a/extensions/general/enable-banking/components/BankSelector.tsx b/extensions/general/enable-banking/components/BankSelector.tsx index eefe8f85..9f2ed471 100644 --- a/extensions/general/enable-banking/components/BankSelector.tsx +++ b/extensions/general/enable-banking/components/BankSelector.tsx @@ -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(null) const [searchQuery, setSearchQuery] = useState('') const searchRef = useRef(null) + const autoStartHandled = useRef(false) + + // Deep-link preselect (?bank=, 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() { diff --git a/extensions/general/enable-banking/lib/bank-match.ts b/extensions/general/enable-banking/lib/bank-match.ts new file mode 100644 index 00000000..ae6792ec --- /dev/null +++ b/extensions/general/enable-banking/lib/bank-match.ts @@ -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( + 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 +} diff --git a/extensions/general/mcp-server/__tests__/connect-links.test.ts b/extensions/general/mcp-server/__tests__/connect-links.test.ts index a5baff59..059f5f8c 100644 --- a/extensions/general/mcp-server/__tests__/connect-links.test.ts +++ b/extensions/general/mcp-server/__tests__/connect-links.test.ts @@ -52,6 +52,28 @@ describe('onboarding connect-link tools', () => { expect(chain.eq).toHaveBeenCalledWith('company_id', COMPANY_ID) }) + it('bank: a named bank deep-links straight into that bank\'s consent', async () => { + const { from } = listClient([]) + const result = (await bankTool.execute( + { bank: 'Danske Bank' }, + COMPANY_ID, + 'user-1', + { from } as never + )) as Record + expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2&bank=Danske%20Bank') + }) + + it('bank: a blank bank argument falls back to the plain picker link', async () => { + const { from } = listClient([]) + const result = (await bankTool.execute( + { bank: ' ' }, + COMPANY_ID, + 'user-1', + { from } as never + )) as Record + expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2') + }) + it('bank: reports an active connection', async () => { const { from } = listClient([ { id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' }, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 44f54f50..e96c470e 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -3144,7 +3144,7 @@ export const tools: McpTool[] = [ ) } else if (firstYear.isFirstFiscalYear) { stillToAsk.push( - `fiscal year: no closed period in the registry, so this is the FIRST räkenskapsår; suggest first_fiscal_year start ${registrationIso ?? firstYear.firstYearStart} (registration date) and end 31 December (max 18 months from start; an enskild firma's first year must end 31 December), ask only "stämmer det?"` + `fiscal year: no closed period in the registry, so this is likely the FIRST räkenskapsår; suggest first_fiscal_year start ${registrationIso ?? firstYear.firstYearStart} (registration date). End: an enskild firma MUST end 31 December; an AB may pick ANY end within 18 months of start (BFL 3 kap 3 §), 31 December is merely the common default. Ask "stämmer det?" with the choice visible` ) } else { stillToAsk.push('fiscal year: calendar year or broken year (no registry data)') @@ -3307,11 +3307,16 @@ export const tools: McpTool[] = [ name: 'gnubok_connect_bank', title: 'Connect Bank', description: - 'Bank connection status plus the browser link where the user connects a bank (PSD2, BankID consent; must be logged in to Accounted there). Use after gnubok_create_company or when transactions are missing because no bank is connected.', + 'Bank connection status plus the browser link where the user connects a bank (PSD2, BankID consent; must be logged in to Accounted there). Ask WHICH bank they use first and pass it as bank: the link then starts that bank\'s consent directly instead of a picker.', inputSchema: { type: 'object', additionalProperties: false, - properties: {}, + properties: { + bank: { + type: 'string', + description: "The bank's name as the user said it (e.g. 'Swedbank', 'SEB'); the link auto-starts that bank's consent. Omit to show the picker.", + }, + }, }, outputSchema: { type: 'object', @@ -3330,7 +3335,7 @@ export const tools: McpTool[] = [ idempotentHint: true, openWorldHint: false, }, - async execute(_args, companyId, _userId, supabase) { + async execute(args, companyId, _userId, supabase) { const { data, error } = await supabase .from('bank_connections') .select('id, bank_name, status, created_at') @@ -3340,7 +3345,12 @@ export const tools: McpTool[] = [ if (error) throw error const connections = (data ?? []) as Array<{ id: string; bank_name: string | null; status: string; created_at: string }> const active = connections.filter((c) => c.status === 'active') - const connectUrl = `${connectLinkBaseUrl()}/import?mode=psd2` + // A named bank deep-links straight into that bank's consent (the page + // auto-starts it; unknown names fall back to the prefilled picker). + const requestedBank = typeof args.bank === 'string' ? args.bank.trim() : '' + const connectUrl = requestedBank + ? `${connectLinkBaseUrl()}/import?mode=psd2&bank=${encodeURIComponent(requestedBank)}` + : `${connectLinkBaseUrl()}/import?mode=psd2` return { connected: active.length > 0, connections: connections.map((c) => ({ @@ -3353,7 +3363,7 @@ export const tools: McpTool[] = [ instructions: active.length > 0 ? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.' - : 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there, pick their bank, approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.', + : 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there. With bank passed, the link starts that bank\'s consent directly; otherwise they pick the bank first. They approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.', } }, }, diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts index ad258963..ab83dbfd 100644 --- a/extensions/general/mcp-server/skills/onboarding.ts +++ b/extensions/general/mcp-server/skills/onboarding.ts @@ -32,14 +32,16 @@ inget konto skapar du det där (BankID eller e-post), det tar en minut." The call is retried automatically once connected. Do not send the user to the web app to sign up first. -## Step 1: TWO opening questions, then look up +## Step 1: THREE opening questions, then look up -Open with exactly two questions, together: +Open with exactly three questions, together: 1. **Organisationsnummer?** (10 digits; an enskild firma's org number is the owner's personnummer, fine to use here) 2. **Har du bokfört i ett annat system tidigare?** (Fortnox, Visma, Bokio, Björn Lundén, Briox, Wint, annat system, eller helt nytt bolag) +3. **Vilken bank har företaget?** (so the bank connect link later opens that + bank's consent directly instead of a picker) Then call \`gnubok_lookup_company\` with the org number. The registry answers most of the form; present the facts as a SHORT summary to confirm ("Jag @@ -58,8 +60,11 @@ Rules baked into that split (same as the web onboarding): - **Enskild firma name**: verksamhetsnamnet is freely choosable; suggest the registered name but let the user pick. An AB's registered name is a fact. - **Fiscal year**: registry data becomes a confirm question, never an open - one. No closed period in the registry = FIRST räkenskapsår: suggest - registration date to 31 December (up to 18 months, BFL 3 kap 3 §). + one. No closed period in the registry = FIRST räkenskapsår: suggest a + start at the registration date. The end differs by form: an enskild + firma MUST end 31 December; an AB may pick any end within 18 months of + the start (BFL 3 kap 3 §), with 31 December as the common default, so + present the AB's choice rather than assuming it. \`not_found\`/\`unavailable\`: fall back to asking the \`still_to_ask\` list and continue. Only \`aktiebolag\` and \`enskild_firma\` are supported today. @@ -101,7 +106,8 @@ reaches far enough back anyway. ## Step 4: connect bank and Skatteverket (together, no pause) -Call \`gnubok_connect_bank\` AND \`gnubok_connect_skatteverket\` in the same +Call \`gnubok_connect_bank\` (pass \`bank\` from step 1 so the link opens that +bank's consent directly) AND \`gnubok_connect_skatteverket\` in the same turn; on claude.ai/Desktop both render connect cards with buttons. - Bank: BankID + PSD2 consent, then an **account selection dialog** in the diff --git a/lib/company/first-year-defaults.ts b/lib/company/first-year-defaults.ts index 12f2a722..51b002c4 100644 --- a/lib/company/first-year-defaults.ts +++ b/lib/company/first-year-defaults.ts @@ -28,6 +28,12 @@ export function parseStartMonthDay(value: string | null | undefined): number | n * first year to run up to 18 months (no minimum). The 12-month floor alone * missed exactly the extended-first-year companies the signal exists for * (Arcim, registered 13 months before onboarding, first year to 31 Dec). + * + * `noClosedPeriod` comes from TIC's `fiscalYear` being null, which is + * derived from `mostRecentFinancialSummary` (the latest filed report): a + * strong but not perfect signal (a filed report can lag in the data). + * Acceptable because every consumer only feeds a confirm-question + * suggestion; never auto-set first_fiscal_year from this. * Returns both the toggle state and a seeded `first_year_start` (always the * 1st of the registration month, the format the date inputs expect). *