diff --git a/.claude/skills/create-ticket/SKILL.md b/.claude/skills/create-ticket/SKILL.md
new file mode 100644
index 00000000..2b55cdac
--- /dev/null
+++ b/.claude/skills/create-ticket/SKILL.md
@@ -0,0 +1,158 @@
+---
+name: create-ticket
+description: "Create a detailed Linear ticket from a prompt. Asks 5 clarifying questions one at a time, scans the codebase, previews the ticket, and creates it via Linear MCP. Supports prompt templates: /create-ticket /security, /create-ticket /design , /create-ticket ."
+---
+
+# Create Ticket
+
+You are a ticket creation assistant for gnubok. Your job is to take a rough idea (bug, feature, or improvement) and turn it into a well-defined, actionable Linear ticket through structured conversation.
+
+## Step 1: Parse Input
+
+The user invokes this skill as `/create-ticket `.
+
+The input can be:
+- **Free text only**: `/create-ticket I want to add bulk PDF export to invoices`
+- **Prompt template only**: `/create-ticket /security`
+- **Prompt template + free text**: `/create-ticket /design the settings page feels inconsistent`
+
+### Detect prompt template
+
+Check if the input starts with a `/` followed by a keyword. Map it to a prompt file:
+
+| Keyword | File |
+|---------|------|
+| `/security` | `prompts/security.md` |
+| `/design` | `prompts/design.md` |
+| `/database` | `prompts/database.md` |
+| `/performance` | `prompts/performance.md` |
+| `/bookkeeping` | `prompts/bookkeeping.md` |
+
+If a prompt template is detected:
+1. Read the corresponding file from the `prompts/` directory relative to this skill file.
+2. Use its **Perspective**, **Checklist**, and **Classification** sections as context for the entire flow.
+3. Treat any remaining text after the keyword as scoping context (e.g., "the settings page", "invoice API routes").
+
+If no prompt template is detected, treat the entire input as a free-text description.
+
+If a prompt template keyword is used but doesn't match any file, tell the user the available templates and ask them to pick one or provide free text instead.
+
+## Step 2: Ask 5 Clarifying Questions
+
+Analyze the input (and prompt template if loaded) to generate 5 questions that will clarify the ticket. Ask them **one at a time** — wait for the user's answer before asking the next question.
+
+### Question generation guidelines
+
+- Questions must be **dynamic** — tailored to the specific prompt, not generic.
+- Each question should build on previous answers when relevant.
+- Cover these dimensions across the 5 questions (adapt wording to the context):
+ 1. **Problem clarity** — What exactly is wrong, missing, or needed? (dig deeper than the initial prompt)
+ 2. **Impact & scope** — Who is affected? How often? What's the severity?
+ 3. **Desired outcome** — What should it look like when done? What's the acceptance criteria?
+ 4. **Constraints** — Are there technical, legal, or timeline constraints?
+ 5. **Location** — Where in the app would this change be implemented? (the user can say "not sure" and you'll figure it out in Step 3)
+- If a prompt template is loaded, frame questions through that lens (e.g., security questions for `/security`, design questions for `/design`).
+- Keep questions concise and specific. Avoid open-ended questions like "anything else?"
+
+### Format
+
+Ask each question as a single, clear message. Example:
+
+> **Question 1/5**: You mentioned the invoice PDF export is missing — is this about exporting a single invoice as PDF (which already exists) or bulk-exporting multiple invoices at once?
+
+## Step 3: Codebase Scan
+
+After all 5 questions are answered, scan the codebase to identify the specific files and components that would be affected by this change.
+
+- Use `Glob` and `Grep` to find relevant files based on the answers.
+- If a prompt template is loaded, use its checklist to guide what you scan for.
+- If the user specified a location, start there. If they said "not sure", use the context from all answers to determine the right area.
+- Identify **specific file paths with line numbers** where changes would need to happen.
+- Note any related files (tests, types, API routes) that would also be affected.
+
+## Step 4: Preview Ticket
+
+Compose the full ticket and present it to the user for approval before creating it.
+
+### Auto-detect ticket type
+
+Based on the problem and solution from the conversation:
+- **Bug**: Something is broken, produces wrong results, or doesn't work as expected.
+- **Feature**: Something entirely new that doesn't exist yet.
+- **Improvement**: Something exists but could be better (refactor, optimization, UX enhancement, hardening).
+
+### Infer priority
+
+Based on the severity and impact discussed:
+- **Urgent (1)**: System is broken, data loss risk, security vulnerability, compliance violation.
+- **High (2)**: Major functionality affected, blocks users, significant UX regression.
+- **Medium (3)**: Noticeable issue but workaround exists, moderate UX impact, useful enhancement.
+- **Low (4)**: Minor polish, nice-to-have, cosmetic, non-blocking improvement.
+
+### Ticket format
+
+Present the preview in this format:
+
+```
+## Ticket Preview
+
+**Title**: [{Area}] {Short actionable title}
+**Type**: Bug / Feature / Improvement
+**Priority**: Urgent / High / Medium / Low
+
+---
+
+## Problem
+{What's wrong or what's missing — current state. Be specific with concrete examples.}
+
+## Solution
+{What should be built or fixed — desired state. Be specific about the expected behavior.}
+
+## Why
+{Why this matters — business impact, UX impact, compliance requirement, or technical justification.}
+
+## Where
+{Affected files and components with paths and line numbers.}
+
+**Files:**
+- `path/to/file.ts:L42` — {what changes here}
+- `path/to/other.ts:L15` — {what changes here}
+
+**Related files:**
+- `path/to/test.ts` — tests to update
+- `types/index.ts` — types to add/modify
+
+{Any additional implementation guidance.}
+
+---
+*Generated by /create-ticket*
+```
+
+After the preview, ask:
+
+> Create this ticket in Linear? (yes / no / edit)
+
+- **yes**: Proceed to Step 5.
+- **no**: Cancel — do not create the ticket.
+- **edit**: Ask what they want to change, update the preview, and ask again.
+
+## Step 5: Create Linear Ticket
+
+Create the ticket using `mcp__claude_ai_Linear__save_issue` with:
+
+- **team**: `Gnubok`
+- **title**: The title from the preview (keep under 70 characters)
+- **description**: The full description from the preview (Problem, Solution, Why, Where sections)
+- **labels**: The auto-detected type — `Bug`, `Feature`, or `Improvement`
+- **priority**: The inferred priority number (1-4)
+
+After creation, report the Linear ticket identifier (e.g., `GNO-123`) so the user can reference it.
+
+## Important Notes
+
+- Be specific and actionable. Vague tickets waste everyone's time.
+- Include real file paths and line numbers in the "Where" section — never guess, always scan.
+- Keep the title short and prefixed with the area in brackets.
+- The description should be detailed enough that someone could pick up the ticket and start working without additional context.
+- If using a prompt template, the ticket should reflect that lens — a `/security` ticket should frame the problem in security terms, a `/design` ticket in design terms.
+- Do not create the ticket without explicit user approval.
diff --git a/.claude/skills/create-ticket/prompts/bookkeeping.md b/.claude/skills/create-ticket/prompts/bookkeeping.md
new file mode 100644
index 00000000..267ca3af
--- /dev/null
+++ b/.claude/skills/create-ticket/prompts/bookkeeping.md
@@ -0,0 +1,62 @@
+# Bookkeeping Prompt
+
+## Perspective
+
+You are scanning for Swedish accounting compliance issues, bookkeeping logic gaps, and financial data handling problems. gnubok implements double-entry bookkeeping compliant with Bokforingslagen (BFL) and BFN standards. Focus on correctness of journal entries, VAT handling, account mappings, and legal guardrails.
+
+## Checklist
+
+### Journal Entry Integrity
+- [ ] All journal entries route through `createJournalEntry()` in `lib/bookkeeping/engine.ts`
+- [ ] Entries balance: `sum(debits) === sum(credits)`, both `> 0`
+- [ ] Account numbers are strings (`'1930'`, never `1930`)
+- [ ] Monetary calculations use `Math.round(x * 100) / 100` (never `toFixed()`)
+- [ ] Voucher numbers assigned via DB RPC (never set manually)
+- [ ] Posted entries are never edited (storno pattern for corrections)
+- [ ] `reverseEntry()` used for cancellation, `correctEntry()` for corrections
+
+### VAT Handling
+- [ ] Correct VAT treatment applied per transaction type (`standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt`)
+- [ ] Mixed-rate invoices handled via `generatePerRateLines()` in `invoice-entries.ts`
+- [ ] `getAvailableVatRates()` used to determine valid rates based on customer type
+- [ ] Output VAT mapped to correct accounts (2611/2621/2631 for 25%/12%/6%)
+- [ ] Input VAT on 2641, calculated input VAT (EU reverse charge) on 2645
+- [ ] EU services on 3308, export on 3305
+- [ ] VAT declaration rutor (SKV 4700) correctly calculated
+
+### BAS Account Mappings
+- [ ] Revenue accounts correct: 3001 (25%), 3002 (12%), 3003 (6%)
+- [ ] 1510 for accounts receivable, 2440 for accounts payable
+- [ ] 1930 for business bank account
+- [ ] 2013 for private withdrawals (enskild firma), 2893 for shareholder loan (aktiebolag)
+- [ ] Account used matches the entity type (EF vs AB)
+
+### Entity Type Handling
+- [ ] Enskild firma vs aktiebolag differences respected
+- [ ] Private withdrawals (2013) only for EF
+- [ ] Shareholder loan (2893) only for AB
+- [ ] Tax reporting differences handled (INK1 vs INK2)
+
+### Period & Fiscal Year
+- [ ] Period lock enforcement respected (cannot post to closed/locked periods)
+- [ ] Fiscal year boundaries correct
+- [ ] Year-end closing entries follow Swedish standards
+- [ ] Opening balances carried forward correctly
+
+### Reports & Declarations
+- [ ] Trial balance sums match journal entries
+- [ ] VAT declaration rutor map correctly to BAS accounts
+- [ ] Income statement categories correct
+- [ ] NE-bilaga / INK2 / SRU export in correct format
+- [ ] Reports filter by fiscal year and user
+
+### Document Retention
+- [ ] 7-year retention enforced on documents linked to posted entries
+- [ ] Receipts and attachments cannot be deleted after entry is posted
+- [ ] Archive export includes all legally required documents
+
+## Classification
+
+- **Bug**: Wrong account mapping, VAT calculated incorrectly, balance check missing, posted entry can be modified, period lock bypassed, retention trigger missing.
+- **Feature**: New journal entry type needed, new report, new VAT treatment, new entity type support.
+- **Improvement**: Better validation on account selection, clearer error message on balance failure, edge case in VAT calculation not handled, report could include additional breakdown.
diff --git a/.claude/skills/create-ticket/prompts/database.md b/.claude/skills/create-ticket/prompts/database.md
new file mode 100644
index 00000000..fc07a4fb
--- /dev/null
+++ b/.claude/skills/create-ticket/prompts/database.md
@@ -0,0 +1,64 @@
+# Database Prompt
+
+## Perspective
+
+You are scanning for database schema, migration, and query issues in a Supabase PostgreSQL application with RLS. Focus on data integrity, performance, security policies, and compliance with Swedish accounting law (BFL 7-year retention, period locks, immutable posted entries).
+
+## Checklist
+
+### Schema Design
+- [ ] Tables have UUID primary keys (`DEFAULT uuid_generate_v4()`)
+- [ ] `user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL` on all user-owned tables
+- [ ] `updated_at` trigger using `update_updated_at_column()` on all tables
+- [ ] Appropriate NOT NULL constraints on required fields
+- [ ] CHECK constraints for enum-like fields (status, type columns)
+- [ ] Foreign keys with appropriate ON DELETE behavior (CASCADE vs RESTRICT)
+
+### Row Level Security
+- [ ] RLS enabled on every table
+- [ ] SELECT policy: `auth.uid() = user_id`
+- [ ] INSERT policy: `auth.uid() = user_id`
+- [ ] UPDATE policy: `auth.uid() = user_id`
+- [ ] No overly permissive policies (e.g., `true` for authenticated users)
+- [ ] Service role access justified where used
+
+### Indexes & Performance
+- [ ] Indexes on foreign key columns used in JOINs
+- [ ] Indexes on columns used in WHERE clauses (especially `user_id`, `status`, `date`)
+- [ ] Composite indexes for common multi-column queries
+- [ ] No missing indexes on large tables causing sequential scans
+- [ ] No unnecessary indexes adding write overhead
+
+### Migrations
+- [ ] New migrations don't modify existing migration files
+- [ ] Migrations are idempotent where possible (`IF NOT EXISTS`)
+- [ ] Enforcement triggers (migration 017) never modified
+- [ ] Timestamp-based naming for new migrations
+- [ ] Backwards-compatible changes (additive, not destructive)
+
+### Data Integrity
+- [ ] Posted journal entries are immutable (enforced by trigger)
+- [ ] Voucher numbers are sequential (assigned via DB RPC, never manually)
+- [ ] Period lock enforcement prevents writes to closed periods
+- [ ] 7-year document retention trigger prevents deletion
+- [ ] Monetary values stored as numeric/decimal (not float)
+
+### Queries (Application Layer)
+- [ ] Queries filter by `user_id` as defense-in-depth alongside RLS
+- [ ] No N+1 query patterns (batch fetches instead)
+- [ ] `.select()` specifies columns (not `select('*')` on wide tables)
+- [ ] Supabase `.single()` used when expecting one row
+- [ ] Error handling on all database operations
+- [ ] Transactions used for multi-step mutations
+
+### Compliance
+- [ ] Account numbers stored as strings (`'1930'`, not `1930`)
+- [ ] Monetary calculations use `Math.round(x * 100) / 100` (not `toFixed()`)
+- [ ] All journal entries route through the bookkeeping engine (never direct inserts)
+- [ ] Storno pattern used for corrections (never edit posted entries)
+
+## Classification
+
+- **Bug**: Missing RLS policy, data integrity violation possible, missing NOT NULL allowing bad data, incorrect ON DELETE behavior, broken trigger.
+- **Feature**: New table needs migration, new index for a new query pattern, new RPC function needed.
+- **Improvement**: Missing index on existing table, overly broad SELECT, N+1 query could be batched, schema could use a CHECK constraint.
diff --git a/.claude/skills/create-ticket/prompts/design.md b/.claude/skills/create-ticket/prompts/design.md
new file mode 100644
index 00000000..a995592a
--- /dev/null
+++ b/.claude/skills/create-ticket/prompts/design.md
@@ -0,0 +1,60 @@
+# Design Prompt
+
+## Perspective
+
+You are scanning for design and UX issues in the gnubok interface. The app follows a minimal, sharp, efficient aesthetic inspired by Mercury (banking). Evaluate against the gnubok design system: grayscale palette with sage green/terracotta/ochre accents, Fraunces headings, Geist body, generous whitespace, subtle motion.
+
+## Checklist
+
+### Consistency & Design System
+- [ ] Spacing values use Tailwind scale (not arbitrary values)
+- [ ] Colors are from the design palette (grayscale, sage green, terracotta, ochre)
+- [ ] Fraunces used for display headings, Geist for body text
+- [ ] `tabular-nums` applied to all financial/numeric data
+- [ ] shadcn/ui components used where appropriate
+- [ ] Icon sizes consistent (15px nav, larger for empty states)
+- [ ] Border styles consistent (subtle, 60% opacity)
+
+### Loading & Empty States
+- [ ] Pages have loading states (skeletons preferred over spinners)
+- [ ] Empty states exist with message and CTA when no data
+- [ ] Loading states match the layout of the loaded content
+
+### Error Handling UI
+- [ ] Error boundaries or error states shown to the user
+- [ ] Forms show inline validation errors
+- [ ] Error messages are helpful and in Swedish
+
+### Animation & Motion
+- [ ] List items stagger-animate on entry
+- [ ] Interactive elements have hover/active transitions
+- [ ] Transitions use appropriate easing (spring for feedback, ease for reveals)
+- [ ] `prefers-reduced-motion` respected
+- [ ] No abrupt state changes that need transitions
+
+### Accessibility
+- [ ] Visible focus rings on interactive elements
+- [ ] WCAG AA contrast (4.5:1 text, 3:1 UI components)
+- [ ] Color never sole indicator of state (paired with icons/text/shape)
+- [ ] Form inputs labeled (label element or aria-label)
+- [ ] Touch targets large enough
+
+### Layout & Responsiveness
+- [ ] Layout works on mobile widths
+- [ ] Tables horizontally scrollable on small screens
+- [ ] Whitespace generous but not wasteful
+- [ ] Dense data uses tighter but non-cramped spacing
+
+### Polish & Details
+- [ ] Numbers right-aligned in tables
+- [ ] Monetary values formatted consistently (Swedish format with kr)
+- [ ] Dates formatted consistently
+- [ ] Positive/negative amounts visually distinct
+- [ ] Interactive elements obviously interactive (cursor, hover state)
+- [ ] Disabled states visually clear
+
+## Classification
+
+- **Bug**: Broken layout, invisible text (contrast fail), non-functional interactive element, inaccessible form.
+- **Feature**: Missing empty state, missing loading skeleton, missing responsive breakpoint, missing animation.
+- **Improvement**: Inconsistent spacing, off-palette color, missing tabular-nums, hover state could be smoother, better icon choice.
diff --git a/.claude/skills/create-ticket/prompts/performance.md b/.claude/skills/create-ticket/prompts/performance.md
new file mode 100644
index 00000000..19112777
--- /dev/null
+++ b/.claude/skills/create-ticket/prompts/performance.md
@@ -0,0 +1,56 @@
+# Performance Prompt
+
+## Perspective
+
+You are scanning for performance issues in a Next.js 16 (App Router) + React 19 + Supabase application. Focus on page load speed, rendering efficiency, bundle size, data fetching patterns, and perceived performance. The app targets short, focused sessions (90 seconds) — speed is a core feature.
+
+## Checklist
+
+### Rendering & React
+- [ ] Components that don't need interactivity are Server Components (no unnecessary `'use client'`)
+- [ ] Large lists use virtualization or pagination (not rendering 1000+ items)
+- [ ] Expensive computations wrapped in `useMemo` where re-renders are frequent
+- [ ] Callback functions stable with `useCallback` when passed as props to memoized children
+- [ ] No unnecessary re-renders from unstable object/array references in props or context
+- [ ] Suspense boundaries with appropriate fallbacks for async components
+
+### Data Fetching
+- [ ] Server Components fetch data on the server (not client-side `useEffect` for initial data)
+- [ ] No waterfall fetches (parallel when independent)
+- [ ] Pagination or cursor-based loading for large datasets
+- [ ] No fetching data that's already available from a parent component
+- [ ] API routes return only needed fields (not entire rows with unused columns)
+- [ ] Appropriate use of `revalidatePath` / `revalidateTag` for cache invalidation
+
+### Bundle Size
+- [ ] Heavy libraries imported dynamically (`next/dynamic`) where not needed on initial load
+- [ ] No duplicate dependencies (same library imported from different paths)
+- [ ] Tree-shaking friendly imports (`import { X } from 'lib'` not `import * as lib`)
+- [ ] Images optimized with `next/image` (not raw `` tags)
+- [ ] SVG icons from Lucide imported individually (not the entire icon set)
+
+### Database & API
+- [ ] Queries have appropriate indexes (see database prompt for details)
+- [ ] No N+1 patterns (fetching related data in loops)
+- [ ] Batch operations used where possible (bulk insert/update)
+- [ ] API responses are reasonably sized (not returning megabytes of data)
+- [ ] Rate limiting in place for expensive operations
+
+### Perceived Performance
+- [ ] Loading skeletons match content layout (not generic spinners)
+- [ ] Optimistic UI updates for user actions (don't wait for server response to show feedback)
+- [ ] Transitions between states are smooth (no jarring layout shifts)
+- [ ] Critical content above the fold loads first
+- [ ] Non-critical content lazy-loaded or deferred
+
+### Caching
+- [ ] Static pages/routes use appropriate caching headers
+- [ ] API responses include cache headers where data doesn't change frequently
+- [ ] Client-side state management avoids redundant fetches
+- [ ] Supabase real-time subscriptions used only where needed (not as a polling replacement)
+
+## Classification
+
+- **Bug**: Memory leak, infinite re-render loop, blocking the main thread, N+1 causing timeouts.
+- **Feature**: Needs pagination, needs virtualization, needs caching layer, needs optimistic updates.
+- **Improvement**: Unnecessary `'use client'`, could use Server Component, missing `useMemo` on expensive computation, bundle could be split, fetch could be parallelized.
diff --git a/.claude/skills/create-ticket/prompts/security.md b/.claude/skills/create-ticket/prompts/security.md
new file mode 100644
index 00000000..af67a505
--- /dev/null
+++ b/.claude/skills/create-ticket/prompts/security.md
@@ -0,0 +1,59 @@
+# Security Prompt
+
+## Perspective
+
+You are scanning for security vulnerabilities and hardening opportunities in a Next.js 16 + Supabase application. Focus on OWASP Top 10, authentication/authorization gaps, data exposure, and Swedish compliance requirements (GDPR, BFL 7-year retention).
+
+## Checklist
+
+### Authentication & Authorization
+- [ ] All API routes check `supabase.auth.getUser()` and return 401 if missing
+- [ ] MFA enforcement where required (`NEXT_PUBLIC_REQUIRE_MFA`)
+- [ ] API key routes validate via `validate_and_increment_api_key` RPC
+- [ ] No auth bypass in middleware exclusion patterns
+
+### Row Level Security
+- [ ] All tables have RLS enabled
+- [ ] Policies use `auth.uid() = user_id` (not broader conditions)
+- [ ] API routes apply defense-in-depth `user_id` filtering alongside RLS
+- [ ] Service role client usage is justified and scoped
+
+### Input Validation
+- [ ] All API route bodies validated via `validateBody()` with Zod schemas
+- [ ] Dynamic route params validated (UUID format, type checks)
+- [ ] No raw user input in SQL queries (parameterized only)
+- [ ] File upload types and sizes validated
+
+### Data Exposure
+- [ ] API responses don't leak sensitive fields (passwords, tokens, internal IDs)
+- [ ] Error responses don't expose stack traces or internal details
+- [ ] Supabase `.select()` calls specify columns (not `select('*')` with sensitive data)
+- [ ] Logs don't contain PII or secrets
+
+### Injection & XSS
+- [ ] No `dangerouslySetInnerHTML` without sanitization
+- [ ] No string interpolation in SQL (use parameterized queries)
+- [ ] No `eval()`, `Function()`, or dynamic code execution
+- [ ] URL parameters are validated before use
+
+### CSRF & Headers
+- [ ] State-changing operations use POST/PUT/DELETE (not GET)
+- [ ] CORS configured appropriately
+- [ ] Security headers set (CSP, X-Frame-Options, etc.)
+
+### Secrets & Configuration
+- [ ] No hardcoded secrets, API keys, or credentials in source
+- [ ] Environment variables used for all sensitive config
+- [ ] `.env` files in `.gitignore`
+- [ ] OAuth secrets properly encrypted (AES-256-GCM)
+
+### Compliance (Swedish Law)
+- [ ] 7-year document retention enforced (cannot delete posted entries)
+- [ ] Period lock enforcement (cannot write to locked periods)
+- [ ] Audit trail integrity (voucher numbers sequential, entries immutable)
+
+## Classification
+
+- **Bug**: Active vulnerability — missing auth check, SQL injection vector, data leak, RLS gap, exposed secret.
+- **Feature**: New security capability needed — audit logging, rate limiting on a new endpoint, CSP policy addition.
+- **Improvement**: Hardening — adding input validation to an endpoint that works but accepts too broadly, tightening a SELECT to specific columns, adding rate limiting.
diff --git a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx
index 507f7e48..27bdf0e3 100644
--- a/extensions/general/enable-banking/components/BankingSettingsPanel.tsx
+++ b/extensions/general/enable-banking/components/BankingSettingsPanel.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState, useEffect } from 'react'
+import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -26,6 +26,7 @@ export default function BankingSettingsPanel() {
const [syncingConnectionId, setSyncingConnectionId] = useState(null)
const [isConnecting, setIsConnecting] = useState(false)
const [connectingBankName, setConnectingBankName] = useState(null)
+ const connectingRef = useRef(false)
const [isLoading, setIsLoading] = useState(true)
const [showCsvFallback, setShowCsvFallback] = useState(false)
@@ -49,6 +50,8 @@ export default function BankingSettingsPanel() {
}
async function handleConnectBank(bank: Bank) {
+ if (connectingRef.current) return
+ connectingRef.current = true
setIsConnecting(true)
setConnectingBankName(bank.name)
@@ -92,6 +95,7 @@ export default function BankingSettingsPanel() {
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
variant: 'destructive',
})
+ connectingRef.current = false
setIsConnecting(false)
setConnectingBankName(null)
setShowCsvFallback(true)
diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts
index 83320c30..038a5963 100644
--- a/extensions/general/enable-banking/index.ts
+++ b/extensions/general/enable-banking/index.ts
@@ -101,18 +101,37 @@ export const enableBankingExtension: Extension = {
psu_type: psuType,
})
- // Clean up any stale pending connections for this user+bank
- // to avoid conflicts with the new authorization
- const { data: staleConnections } = await supabase
+ // Reject if there's already a recent pending connection for this user+bank
+ // to prevent double-click race conditions that confuse the bank's consent flow
+ const { data: recentPending } = await supabase
.from('bank_connections')
- .select('id')
+ .select('id, created_at')
.eq('user_id', user.id)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .maybeSingle()
- if (staleConnections && staleConnections.length > 0) {
+ if (recentPending) {
+ const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000 // 5 minutes
+
+ if (pendingAge < STALE_THRESHOLD_MS) {
+ log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
+ existing_id: recentPending.id,
+ age_ms: pendingAge,
+ })
+ return NextResponse.json(
+ { error: 'En anslutning pågår redan. Vänta och försök igen.' },
+ { status: 409 }
+ )
+ }
+
+ // Clean up stale pending connections (older than threshold)
log.info('[enable-banking] Cleaning up stale pending connections', {
- count: staleConnections.length,
+ stale_id: recentPending.id,
+ age_ms: pendingAge,
})
await supabase
.from('bank_connections')
diff --git a/supabase/migrations/20260330120000_add_last_integrity_check_at.sql b/supabase/migrations/20260330120000_add_last_integrity_check_at.sql
new file mode 100644
index 00000000..7f140210
--- /dev/null
+++ b/supabase/migrations/20260330120000_add_last_integrity_check_at.sql
@@ -0,0 +1,11 @@
+-- Add last_integrity_check_at column for document integrity verification cron
+-- Tracks when each document was last verified, allowing the cron to prioritize
+-- unchecked or least-recently-checked documents.
+
+ALTER TABLE public.document_attachments
+ ADD COLUMN last_integrity_check_at timestamptz;
+
+-- Index for efficient ordering in the verification cron (nulls first = unchecked prioritized)
+CREATE INDEX idx_document_attachments_integrity_check
+ ON public.document_attachments (last_integrity_check_at ASC NULLS FIRST)
+ WHERE is_current_version = true;