fix(bank): 90-day lookback is the default: long PSD2 requests make some banks kill the session (#1964)

E2E #11: the account picker's fiscal-year default sent a 365-day request
for a 405-day-old fiscal year; Swedbank answered by TERMINATING the
session: zero transactions, connection expired, no error surfaced
(initial_sync_requested_from 2025-08-26, returned min/max null). 90
days worked.

- Default lookback mode is now 'fast' (90 days), labeled rekommenderas
  on a first connect; the fiscal-year option stays but carries an inline
  'vissa banker avbryter kopplingen' note when its span exceeds 90 days,
  and the long-range helper names the real failure mode + the SIE/CSV
  path for older history.
- SIE drop card: stage + arm in ONE click (three clicks was one too
  many): after the verdict, the single button reads 'Bokför:
  oåterkalleligt (BFL 5 kap 5 §)' and the deliberate click commits with
  confirmed=true.
- Skill: never re-ask an answered question; when a connect card
  rendered, do not paste the URL as text too.

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:
Jakob Wennberg
2026-08-26 22:36:06 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 53c4d0d194
commit c2f055b903
4 changed files with 35 additions and 22 deletions
@@ -133,7 +133,12 @@ export function AccountPickerDialog({
const chartError = Boolean(chartLoadError)
const [ledgerByUid, setLedgerByUid] = useState<Record<string, string>>({})
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fiscal-year')
// Default 'fast' (90 days): the known-good PSD2 window. The fiscal-year
// default used to send 365+-day requests that some banks (Swedbank) answer
// by TERMINATING the session: zero transactions, connection expired, no
// error surfaced (E2E 2026-08-26). Longer ranges stay available as explicit
// choices with the risk spelled out.
const [lookbackMode, setLookbackMode] = useState<LookbackMode>('fast')
const [customSubMode, setCustomSubMode] = useState<CustomSubMode>('date')
const [customDate, setCustomDate] = useState<string>('')
// Newest transaction date this CONNECTION has already imported (date null on
@@ -161,7 +166,7 @@ export function AccountPickerDialog({
)
setSelected(initial)
setSaveError(null)
setLookbackMode('fiscal-year')
setLookbackMode('fast')
setCustomSubMode('date')
setCustomDate('')
lookbackTouched.current = false
@@ -716,7 +721,10 @@ export function AccountPickerDialog({
className="mt-1"
/>
<span>
<span className="block">Senaste 90 dagar <span className="text-muted-foreground">(snabbt)</span></span>
<span className="block">Senaste 90 dagar{!gapFill && <span className="text-muted-foreground"> (rekommenderas)</span>}</span>
<span className="text-xs text-muted-foreground">
det längsta de flesta banker lämnar ut utan extra godkännande
</span>
</span>
</label>
@@ -734,6 +742,7 @@ export function AccountPickerDialog({
<span className="block">Sedan räkenskapsårets början</span>
<span className="text-xs text-muted-foreground tabular-nums">
från {settingsLoaded ? fiscalYearStart : '…'}
{settingsLoaded && daysBetween(fiscalYearStart) > 90 && ': vissa banker avbryter kopplingen vid så långa förfrågningar'}
</span>
</span>
</label>
@@ -793,13 +802,16 @@ export function AccountPickerDialog({
)}
{showLongRangeHelper && (
<p className="text-xs text-muted-foreground">
Din bank returnerar oftast max 90 dagar. Behöver du äldre transaktioner kan du{' '}
<p className="attn text-[12.5px]">
De flesta banker lämnar bara ut cirka 90 dagar utan extra godkännande, och vissa
(till exempel Swedbank) avbryter hela kopplingen vid längre förfrågningar: då hämtas
inget alls och banken måste kopplas om. Säkrast är 90 dagar här och äldre historik
via{' '}
<Link
href="/import?mode=sie"
className="text-foreground underline underline-offset-2"
>
importera via SIE eller bankfil
SIE eller kontoutdrag (CSV)
</Link>
. Vi visar exakt vad banken returnerade efter sparat val.
</p>
@@ -18,13 +18,13 @@ describe('SIE drop widget', () => {
expect(widget?.html).toContain("addEventListener('drop'")
})
it('carries the approval in-card: two-click BFL confirm on the staged import', () => {
it('carries the approval in-card: stage+arm in one click, BFL text on the commit button', () => {
const html = widget!.html
expect(html).toContain("callTool('gnubok_approve_pending_operation'")
// High-risk approve arms confirmed=true only on the second, deliberate
// click: the human acknowledgment, never a default.
// confirmed=true rides the deliberate click on a button whose label IS
// the irreversibility statement: the human acknowledgment, no default.
expect(html).toContain('args.confirmed = true')
expect(html).toContain('BFL 5 kap 5')
expect(html).toContain('Bokför: oåterkalleligt (BFL 5 kap 5 §)')
})
it('passes exact bytes through tools/call with a sha256, never retyped or fetched', () => {
@@ -58,6 +58,9 @@ Open with exactly three questions, together:
3. **Vilken bank har företaget?** (so the bank connect link later opens that
bank's consent directly instead of a picker)
Never re-ask a question the user already answered in this conversation
(the opening round included): reuse the answer.
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
hittade Example AB, Storgatan 1 i Stockholm, godkänd för F-skatt och
@@ -151,7 +154,9 @@ reaches far enough back anyway.
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.
turn; on claude.ai/Desktop both render connect cards with buttons. When a
card rendered, do NOT paste the URL as text too: the card button IS the
link, and duplicate raw URLs read as clutter.
- Bank: BankID + PSD2 consent, then an **account selection dialog** in the
browser: transactions start syncing when the user saves it. Banks cap
@@ -270,10 +270,13 @@ export const SIE_DROP_HTML = `<!DOCTYPE html>
}).then(function(res) {
const sc = parseResult(res);
if (sc && sc.operation_id) {
stagedOp = { id: sc.operation_id, risk: sc.risk_level || 'high', armed: false };
// Stage + arm in ONE step: the next click is already the deliberate
// BFL acknowledgment (three clicks was one too many, E2E #11). The
// irreversibility text on the button IS the surfacing.
stagedOp = { id: sc.operation_id, risk: sc.risk_level || 'high', armed: true };
el('import').disabled = false;
el('import').textContent = 'Godkänn bokföringen';
note('Importen är förberedd. Godkänn här i kortet så bokförs verifikationerna, eller säg till i chatten.');
el('import').textContent = 'Bokför: oåterkalleligt (BFL 5 kap 5 §)';
note('Importen är förberedd. Bokförda verifikat kan inte raderas, endast rättas med storno. Klicka för att bokföra, eller säg till i chatten.');
} else {
el('import').textContent = 'Import förberedd';
note('Importen är förberedd och väntar på godkännande i chatten eller i Accounted.');
@@ -289,12 +292,6 @@ export const SIE_DROP_HTML = `<!DOCTYPE html>
});
function approveStaged() {
if (stagedOp.risk === 'high' && !stagedOp.armed) {
stagedOp.armed = true;
el('import').textContent = 'Bekräfta: oåterkalleligt (BFL 5 kap 5 §)';
note('Bokförda verifikat kan inte raderas, endast rättas med storno. Klicka igen för att bokföra.');
return;
}
el('import').disabled = true;
el('import').textContent = 'Bokför…';
const args = { operation_id: stagedOp.id };
@@ -307,8 +304,7 @@ export const SIE_DROP_HTML = `<!DOCTYPE html>
});
}).catch(function(err) {
el('import').disabled = false;
stagedOp.armed = false;
el('import').textContent = 'Godkänn bokföringen';
el('import').textContent = 'Bokför: oåterkalleligt (BFL 5 kap 5 §)';
note('Godkännandet misslyckades: ' + (err && err.message ? err.message : 'okänt fel'));
});
}