Fix/vat parent accounts (#438)

* feat(enable-banking): add support for account selection and syncing

- Updated StoredAccount interface to include an 'enabled' flag for account syncing preferences.
- Enhanced ensureFiscalPeriod function to handle overlapping fiscal periods with posted entries and opening balances.
- Added tests for fiscal period validation and account syncing logic.
- Implemented AccountPickerDialog component for user account selection.
- Created API routes for PATCH /accounts and POST /sync to manage account syncing.
- Introduced 'pending_selection' status for bank connections to allow user account selection before syncing.
- Updated database migration to support new connection status and backfill existing accounts with enabled=true.

* feat(enable-banking): implement account selection and consent event logging

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-11 17:12:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d0fbc2b616
commit a53a119a2e
20 changed files with 1761 additions and 112 deletions
@@ -0,0 +1,335 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { enableBankingExtension } from '../index'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
// Locate the PATCH /accounts handler once — schema doesn't change at runtime.
const accountsRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'PATCH' && r.path === '/accounts'
)
if (!accountsRoute) {
throw new Error('PATCH /accounts route not registered on enable-banking extension')
}
interface SupabaseStub {
authUser: { id: string } | null
connectionRow: {
id: string
status: string
accounts_data: StoredAccount[]
} | null
connectionError?: { message: string } | null
updateError?: { message: string } | null
capturedUpdate?: Record<string, unknown>
}
function buildSupabase(stub: SupabaseStub) {
return {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: stub.authUser }, error: null }),
},
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: stub.connectionRow,
error: stub.connectionError ?? null,
}),
update: vi.fn((payload: Record<string, unknown>) => {
stub.capturedUpdate = payload
return {
eq: vi.fn().mockResolvedValue({ error: stub.updateError ?? null }),
}
}),
})),
}
}
function makeContext(supabase: ReturnType<typeof buildSupabase>): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as never,
services: {} as never,
}
}
function makeRequest(body: unknown): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/accounts', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('PATCH /accounts (enable-banking)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when unauthenticated', async () => {
const supabase = buildSupabase({ authUser: null, connectionRow: null })
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(401)
})
it('returns 400 when connection_id missing', async () => {
const supabase = buildSupabase({ authUser: { id: 'user-1' }, connectionRow: null })
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(400)
})
it('returns 400 when enabled_uids is empty', async () => {
const supabase = buildSupabase({ authUser: { id: 'user-1' }, connectionRow: null })
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: [] }),
ctx
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Välj minst ett konto/i)
})
it('returns 400 when enabled_uids contains unknown uid', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: true },
],
},
})
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1', 'acc-bogus'] }),
ctx
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.unknown_uids).toEqual(['acc-bogus'])
})
it('returns 404 when connection not found', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: null,
connectionError: { message: 'not found' },
})
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(404)
})
it('returns 400 when connection is in an invalid status (e.g. expired)', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'expired',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
},
})
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(400)
})
it('flips status to active and writes per-account enabled flags', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true, name: 'Företag' },
{ uid: 'acc-2', currency: 'SEK', enabled: true, name: 'Privat' },
{ uid: 'acc-3', currency: 'SEK', enabled: true, name: 'Spar' },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1', 'acc-3'] }),
ctx
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toMatchObject({ success: true, enabled_count: 2, total_count: 3 })
expect(stub.capturedUpdate).toBeDefined()
expect(stub.capturedUpdate?.status).toBe('active')
const written = stub.capturedUpdate?.accounts_data as StoredAccount[]
expect(written).toHaveLength(3)
expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(true)
expect(written.find(a => a.uid === 'acc-2')?.enabled).toBe(false)
expect(written.find(a => a.uid === 'acc-3')?.enabled).toBe(true)
// Disabled accounts are kept in the row so the user can re-enable later.
expect(written.find(a => a.uid === 'acc-2')?.name).toBe('Privat')
})
it('allows re-selection on an already-active connection', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: false },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-2'] }),
ctx
)
expect(res.status).toBe(200)
const written = stub.capturedUpdate?.accounts_data as StoredAccount[]
expect(written.find(a => a.uid === 'acc-1')?.enabled).toBe(false)
expect(written.find(a => a.uid === 'acc-2')?.enabled).toBe(true)
})
it('omits status from update payload when connection is already active (state machine)', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: false },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-2'] }),
ctx
)
expect(res.status).toBe(200)
// Status field is NOT present in the update — already-active connections
// don't re-assert the transition, which keeps the state machine explicit.
expect(stub.capturedUpdate).toBeDefined()
expect('status' in (stub.capturedUpdate ?? {})).toBe(false)
})
it('returns 400 when ctx.companyId is absent (no user.id fallback)', async () => {
const supabase = buildSupabase({ authUser: { id: 'user-1' }, connectionRow: null })
const ctx = makeContext(supabase)
// Simulate a missing company context — should not fall back to user.id.
const ctxWithoutCompany = { ...ctx, companyId: undefined as unknown as string }
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctxWithoutCompany
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Company context required/i)
})
it('returns 400 when enabled_uids exceeds the per-connection cap', async () => {
const supabase = buildSupabase({
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [],
},
})
const ctx = makeContext(supabase)
const tooMany = Array.from({ length: 51 }, (_, i) => `acc-${i}`)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: tooMany }),
ctx
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Max 50 konton/i)
})
it('emits bank_connection.account_selection_changed after a successful update', async () => {
const stub: SupabaseStub = {
authUser: { id: 'user-1' },
connectionRow: {
id: 'conn-1',
status: 'pending_selection',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: true },
{ uid: 'acc-2', currency: 'SEK', enabled: true },
],
},
}
const supabase = buildSupabase(stub)
const ctx = makeContext(supabase)
const res = await accountsRoute.handler(
makeRequest({ connection_id: 'conn-1', enabled_uids: ['acc-1'] }),
ctx
)
expect(res.status).toBe(200)
expect(ctx.emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'bank_connection.account_selection_changed',
payload: expect.objectContaining({
connectionId: 'conn-1',
previousStatus: 'pending_selection',
newStatus: 'active',
enabledCount: 1,
totalCount: 2,
userId: 'user-1',
companyId: 'company-1',
}),
})
)
})
})
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { enableBankingExtension } from '../index'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { StoredAccount } from '../types'
const syncRoute = enableBankingExtension.apiRoutes?.find(
r => r.method === 'POST' && r.path === '/sync'
)
if (!syncRoute) {
throw new Error('POST /sync route not registered on enable-banking extension')
}
function makeContext(connectionRow: {
status: string
accounts_data: StoredAccount[]
}): ExtensionContext {
const supabase = {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(() => ({
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { id: 'conn-1', company_id: 'company-1', ...connectionRow },
error: null,
}),
maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
})),
}
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: supabase as any,
emit: vi.fn().mockResolvedValue(undefined),
settings: { get: vi.fn(), set: vi.fn(), getAll: vi.fn() } as never,
storage: {} as never,
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as never,
services: {} as never,
}
}
function makeRequest(): Request {
return new Request('http://localhost/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: 'conn-1' }),
})
}
describe('POST /sync (enable-banking) — account filtering', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 400 when status is pending_selection', async () => {
const ctx = makeContext({
status: 'pending_selection',
accounts_data: [{ uid: 'acc-1', currency: 'SEK', enabled: true }],
})
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/not active/i)
})
it('returns 400 when every account is disabled', async () => {
const ctx = makeContext({
status: 'active',
accounts_data: [
{ uid: 'acc-1', currency: 'SEK', enabled: false },
{ uid: 'acc-2', currency: 'SEK', enabled: false },
],
})
const res = await syncRoute.handler(makeRequest(), ctx)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toMatch(/Inga konton är valda/i)
})
})
@@ -0,0 +1,220 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import type { StoredAccount } from '../types'
interface AccountPickerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
connectionId: string
bankName: string
accounts: StoredAccount[]
// True when the connection is still in pending_selection — closing without
// saving is allowed but the user is reminded that no sync runs until they
// confirm.
isInitialSelection: boolean
onSaved: () => void
}
export function AccountPickerDialog({
open,
onOpenChange,
connectionId,
bankName,
accounts,
isInitialSelection,
onSaved,
}: AccountPickerDialogProps) {
const { toast } = useToast()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [isSaving, setIsSaving] = useState(false)
useEffect(() => {
if (open) {
// Start the dialog reflecting the current state. Accounts without an
// explicit enabled flag are treated as enabled (back-compat).
const initial = new Set<string>(
accounts.filter(a => a.enabled !== false).map(a => a.uid)
)
setSelected(initial)
}
}, [open, accounts])
const allSelected = accounts.length > 0 && selected.size === accounts.length
const noneSelected = selected.size === 0
const sortedAccounts = useMemo(
() => [...accounts].sort((a, b) => (a.name || a.iban || '').localeCompare(b.name || b.iban || '')),
[accounts]
)
function toggle(uid: string) {
setSelected(prev => {
const next = new Set(prev)
if (next.has(uid)) next.delete(uid)
else next.add(uid)
return next
})
}
function selectAll() {
setSelected(new Set(accounts.map(a => a.uid)))
}
function selectNone() {
setSelected(new Set())
}
async function handleSave() {
if (noneSelected) {
toast({
title: 'Välj minst ett konto',
description: 'Avmarkera alla konton och koppla bort banken istället om inga konton ska synkas.',
variant: 'destructive',
})
return
}
setIsSaving(true)
try {
const response = await fetch('/api/extensions/ext/enable-banking/accounts', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: connectionId,
enabled_uids: Array.from(selected),
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte spara kontoval')
}
toast({
title: 'Kontoval sparat',
description: `${data.enabled_count} av ${data.total_count} konton kommer synkas.`,
})
onOpenChange(false)
onSaved()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte spara kontoval',
variant: 'destructive',
})
} finally {
setIsSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Välj konton att synka — {bankName}</DialogTitle>
<DialogDescription>
{isInitialSelection
? 'Banken har gett åtkomst till följande konton. Avmarkera de konton du inte vill synka transaktioner från. Inga transaktioner hämtas innan du sparar.'
: 'Justera vilka konton som ska synkas. Konton du avmarkerar slutar synkas från nästa körning; redan importerade transaktioner ligger kvar.'}
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{selected.size} av {accounts.length} valda
</span>
<div className="flex gap-2">
<button
type="button"
onClick={selectAll}
disabled={allSelected || isSaving}
className="underline-offset-2 hover:underline disabled:opacity-50 disabled:no-underline"
>
Markera alla
</button>
<span aria-hidden>·</span>
<button
type="button"
onClick={selectNone}
disabled={noneSelected || isSaving}
className="underline-offset-2 hover:underline disabled:opacity-50 disabled:no-underline"
>
Avmarkera alla
</button>
</div>
</div>
<div className="max-h-[50vh] overflow-y-auto rounded-lg border border-border divide-y divide-border">
{sortedAccounts.map(account => {
const isChecked = selected.has(account.uid)
return (
<label
key={account.uid}
className="flex cursor-pointer items-center gap-3 p-3 hover:bg-muted/50"
>
<Checkbox
checked={isChecked}
onCheckedChange={() => toggle(account.uid)}
disabled={isSaving}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{account.name || account.iban || 'Okänt konto'}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground tabular-nums">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
{account.balance !== undefined && (
<p className="text-sm font-medium tabular-nums shrink-0">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
)}
</label>
)
})}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Avbryt
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving || noneSelected}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sparar…
</>
) : (
'Spara val'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -9,6 +9,7 @@ import {
CreditCard,
AlertTriangle,
RefreshCw,
Settings,
Trash2,
Loader2,
CheckCircle,
@@ -22,6 +23,7 @@ interface BankConnectionStatusProps {
onSync: (connectionId: string) => void
onDisconnect: (connectionId: string) => void
onReconnect?: (bank: { name: string; country: string }) => void
onManageAccounts?: (connectionId: string) => void
isSyncing?: boolean
}
@@ -30,6 +32,7 @@ export function BankConnectionStatus({
onSync,
onDisconnect,
onReconnect,
onManageAccounts,
isSyncing = false,
}: BankConnectionStatusProps) {
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
@@ -86,8 +89,11 @@ export function BankConnectionStatus({
currency: string
balance?: number
balance_updated_at?: string
enabled?: boolean
}>) || []
const enabledCount = accounts.filter((a) => a.enabled !== false).length
const [now] = useState(() => Date.now())
function formatBalanceAge(updatedAt: string): string {
@@ -168,6 +174,16 @@ export function BankConnectionStatus({
)}
</Button>
)}
{onManageAccounts && (
<Button
variant="ghost"
size="sm"
onClick={() => onManageAccounts(connection.id)}
title="Hantera konton"
>
<Settings className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -228,40 +244,55 @@ export function BankConnectionStatus({
{/* Accounts list */}
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Konton</p>
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">Konton</p>
<p className="text-xs text-muted-foreground tabular-nums">
{enabledCount} av {accounts.length} synkas
</p>
</div>
<div className="space-y-2">
{accounts.map((account) => (
<div
key={account.uid}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="text-sm font-medium">
{account.name || account.iban || 'Okänt konto'}
</p>
{account.iban && (
<p className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
{account.balance !== undefined && (
<div className="text-right">
<p className="text-sm font-medium">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
{account.balance_updated_at && (
<p className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
{accounts.map((account) => {
const isDisabled = account.enabled === false
return (
<div
key={account.uid}
className={`flex items-center justify-between p-3 rounded-lg ${isDisabled ? 'bg-muted/20 opacity-60' : 'bg-muted/50'}`}
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{account.name || account.iban || 'Okänt konto'}
</p>
{isDisabled && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1.5 py-0.5">
Synkas ej
</span>
)}
</div>
{account.iban && (
<p className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
)}
</div>
)}
</div>
))}
{account.balance !== undefined && (
<div className="text-right">
<p className="text-sm font-medium tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
{account.balance_updated_at && (
<p className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</p>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
@@ -6,13 +6,15 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { Loader2, Upload } from 'lucide-react'
import { AlertTriangle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus'
import { AccountPickerDialog } from './AccountPickerDialog'
import type { BankConnection } from '@/types'
import type { StoredAccount } from '../types'
/**
* Self-contained banking settings panel for the enable-banking extension.
@@ -34,6 +36,7 @@ export default function BankingSettingsPanel() {
const [isLoading, setIsLoading] = useState(true)
const [showCsvFallback, setShowCsvFallback] = useState(false)
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
const [pickerConnectionId, setPickerConnectionId] = useState<string | null>(null)
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
const PENDING_LOCK_MS = 30 * 1000
@@ -45,6 +48,28 @@ export default function BankingSettingsPanel() {
}
}, [])
// Auto-open the picker when the user lands here from the OAuth callback
// (URL: /settings/banking?select_accounts=<id>). The query param is stripped
// afterwards so a refresh doesn't keep reopening it.
useEffect(() => {
if (isLoading) return
if (typeof window === 'undefined') return
const params = new URLSearchParams(window.location.search)
const targetId = params.get('select_accounts')
if (!targetId) return
const match = bankConnections.find(c => c.id === targetId)
if (match) {
setPickerConnectionId(targetId)
}
params.delete('select_accounts')
const newQuery = params.toString()
const newUrl = `${window.location.pathname}${newQuery ? `?${newQuery}` : ''}`
window.history.replaceState({}, '', newUrl)
}, [isLoading, bankConnections])
function releaseConnectingLock() {
connectingRef.current = false
setIsConnecting(false)
@@ -252,12 +277,34 @@ export default function BankingSettingsPanel() {
}
const activeConnections = bankConnections.filter((c) => c.status === 'active')
const pendingSelectionConnections = bankConnections.filter((c) => c.status === 'pending_selection')
const actionRequiredConnections = bankConnections.filter((c) => ['expired', 'error'].includes(c.status))
const pickerConnection = pickerConnectionId
? bankConnections.find(c => c.id === pickerConnectionId)
: null
const pickerAccounts = pickerConnection
? ((pickerConnection.accounts_data as StoredAccount[] | null) || [])
: []
return (
<div className="space-y-6">
<DestructiveConfirmDialog {...dialogProps} />
{pickerConnection && (
<AccountPickerDialog
open={!!pickerConnection}
onOpenChange={(open) => {
if (!open) setPickerConnectionId(null)
}}
connectionId={pickerConnection.id}
bankName={pickerConnection.bank_name}
accounts={pickerAccounts}
isInitialSelection={pickerConnection.status === 'pending_selection'}
onSaved={() => fetchConnections()}
/>
)}
{/* Persistent CSV fallback after connection/sync failure */}
{showCsvFallback && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
@@ -271,6 +318,54 @@ export default function BankingSettingsPanel() {
</div>
)}
{/* Pending account selection — new connections waiting for the user to pick accounts */}
{pendingSelectionConnections.length > 0 && (
<Card className="border-warning/30">
<CardHeader>
<CardTitle>Välj konton att synka</CardTitle>
<CardDescription>
Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingSelectionConnections.map((connection) => {
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
return (
<div
key={connection.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 shrink-0 text-warning" />
<div>
<p className="font-medium">{connection.bank_name}</p>
<p className="text-sm text-muted-foreground">
{accountsList.length} konton tillgängliga — inga transaktioner synkas ännu
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setPickerConnectionId(connection.id)}
>
Välj konton
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnectBank(connection.id)}
>
Avbryt
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
)}
{/* Action required — expired/error connections */}
{actionRequiredConnections.length > 0 && (
<Card className="border-warning/30">
@@ -288,6 +383,7 @@ export default function BankingSettingsPanel() {
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onReconnect={handleConnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
@@ -308,6 +404,7 @@ export default function BankingSettingsPanel() {
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
+238 -10
View File
@@ -9,9 +9,19 @@ import {
} from './lib/api-client'
import { syncAccountTransactions } from './lib/sync'
import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation'
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
import type { StoredAccount } from './types'
import type { Transaction } from '@/types'
// Per-user limits keep one tenant from spamming any single bank handler.
// Sliding 60s windows — generous enough for legitimate retry, tight enough
// to prevent UUID probing or status-machine abuse.
const RATE_LIMIT_ACCOUNTS = { maxRequests: 20, windowMs: 60_000 }
const RATE_LIMIT_SYNC = { maxRequests: 10, windowMs: 60_000 }
const RATE_LIMIT_DISCONNECT = { maxRequests: 10, windowMs: 60_000 }
const MAX_ENABLED_UIDS = 50
/**
* Enable Banking (PSD2) extension
*
@@ -87,6 +97,11 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const { aspsp_name, aspsp_country, psu_type: explicitPsuType } = await request.json()
if (!aspsp_name || !aspsp_country) {
@@ -102,7 +117,6 @@ export const enableBankingExtension: Extension = {
if (explicitPsuType === 'personal' || explicitPsuType === 'business') {
psuType = explicitPsuType
} else {
const companyId = ctx?.companyId ?? user.id
const { data: company } = await supabase
.from('companies')
.select('entity_type')
@@ -125,7 +139,7 @@ export const enableBankingExtension: Extension = {
const { data: recentPending } = await supabase
.from('bank_connections')
.select('id, created_at')
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
.order('created_at', { ascending: false })
@@ -155,7 +169,7 @@ export const enableBankingExtension: Extension = {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.eq('bank_name', aspsp_name)
.eq('status', 'pending')
}
@@ -176,7 +190,7 @@ export const enableBankingExtension: Extension = {
const { data: connection, error } = await supabase
.from('bank_connections')
.insert({
company_id: ctx?.companyId ?? user.id,
company_id: companyId,
user_id: user.id,
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
bank_name: aspsp_name,
@@ -230,6 +244,18 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'enable-banking:sync',
identifier: user.id,
...RATE_LIMIT_SYNC,
})
if (!rl.ok) return rl.response!
const { connection_id, days_back: rawDaysBack = 30 } = await request.json()
const days_back = Math.min(Math.max(1, rawDaysBack), 365)
@@ -237,7 +263,7 @@ export const enableBankingExtension: Extension = {
.from('bank_connections')
.select('*')
.eq('id', connection_id)
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.single()
if (connectionError || !connection) {
@@ -249,7 +275,18 @@ export const enableBankingExtension: Extension = {
}
try {
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
// Keep the full list for write-back; sync only the enabled subset.
// undefined enabled === true for back-compat with rows that predate
// the per-account toggle.
const allAccounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const accounts = allAccounts.filter(a => a.enabled !== false)
if (accounts.length === 0) {
return NextResponse.json(
{ error: 'Inga konton är valda för synkning. Öppna "Hantera konton" för att aktivera minst ett.' },
{ status: 400 }
)
}
const toDate = new Date().toISOString().split('T')[0]
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
@@ -259,7 +296,6 @@ export const enableBankingExtension: Extension = {
// Use ctx.services.ingestTransactions when available
const ingestFn = ctx?.services.ingestTransactions
const companyId = ctx?.companyId ?? user.id
// Detect SIE overlap — skip auto-categorization if the sync range
// overlaps with a completed SIE import to prevent double-booking.
@@ -341,7 +377,7 @@ export const enableBankingExtension: Extension = {
await supabase
.from('bank_connections')
.update({
accounts_data: accounts,
accounts_data: allAccounts,
last_synced_at: syncedAt,
})
.eq('id', connection.id)
@@ -387,6 +423,158 @@ export const enableBankingExtension: Extension = {
}
},
},
{
method: 'PATCH',
path: '/accounts',
handler: async (request: Request, ctx?: ExtensionContext) => {
const log = ctx?.log ?? console
const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// company_id must come from the verified extension context, never fall
// back to user.id (which is a different identifier dimension and would
// silently mis-scope queries in multi-tenant deployments).
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'enable-banking:accounts',
identifier: user.id,
...RATE_LIMIT_ACCOUNTS,
})
if (!rl.ok) return rl.response!
const body = await request.json().catch(() => null)
const connection_id = body?.connection_id
const enabled_uids = body?.enabled_uids
if (typeof connection_id !== 'string' || !connection_id) {
return NextResponse.json({ error: 'connection_id krävs' }, { status: 400 })
}
if (!Array.isArray(enabled_uids) || !enabled_uids.every(u => typeof u === 'string')) {
return NextResponse.json({ error: 'enabled_uids måste vara en lista av strängar' }, { status: 400 })
}
if (enabled_uids.length === 0) {
return NextResponse.json(
{ error: 'Välj minst ett konto, eller koppla bort banken om inga konton ska synkas.' },
{ status: 400 }
)
}
if (enabled_uids.length > MAX_ENABLED_UIDS) {
return NextResponse.json(
{ error: `Max ${MAX_ENABLED_UIDS} konton per anslutning.` },
{ status: 400 }
)
}
const { data: connection, error: connectionError } = await supabase
.from('bank_connections')
.select('id, status, accounts_data, bank_name')
.eq('id', connection_id)
.eq('company_id', companyId)
.single()
if (connectionError || !connection) {
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
}
if (connection.status !== 'pending_selection' && connection.status !== 'active') {
return NextResponse.json(
{ error: 'Anslutningen kan inte konfigureras i nuvarande status.' },
{ status: 400 }
)
}
const existing = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
const knownUids = new Set(existing.map(a => a.uid))
const unknownUids = enabled_uids.filter(uid => !knownUids.has(uid))
if (unknownUids.length > 0) {
return NextResponse.json(
{ error: 'Ett eller flera konton kunde inte hittas.', unknown_uids: unknownUids },
{ status: 400 }
)
}
const enabledSet = new Set(enabled_uids)
const updatedAccounts: StoredAccount[] = existing.map(a => ({
...a,
enabled: enabledSet.has(a.uid),
}))
// State machine: only transition pending_selection → active. Once
// active, the status field is omitted from the update so the same
// endpoint can be reused to change account selection without
// re-asserting a transition that has already happened.
const updatePayload: { accounts_data: StoredAccount[]; status?: 'active' } = {
accounts_data: updatedAccounts,
}
if (connection.status === 'pending_selection') {
updatePayload.status = 'active'
}
const { error: updateError } = await supabase
.from('bank_connections')
.update(updatePayload)
.eq('id', connection.id)
if (updateError) {
log.error('[enable-banking] Failed to update account selection', {
errorMessage: updateError.message,
connectionId: connection.id,
userId: user.id,
companyId,
})
return NextResponse.json({ error: 'Kunde inte spara kontoval' }, { status: 500 })
}
const newStatus = updatePayload.status ?? connection.status
log.info('[enable-banking] Account selection saved', {
connectionId: connection.id,
enabledCount: enabled_uids.length,
totalCount: existing.length,
previousStatus: connection.status,
newStatus,
userId: user.id,
companyId,
})
try {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'bank_connection.account_selection_changed',
payload: {
connectionId: connection.id,
bankName: (connection as { bank_name?: string | null }).bank_name ?? null,
previousStatus: connection.status,
newStatus,
enabledCount: enabled_uids.length,
totalCount: existing.length,
userId: user.id,
companyId,
},
})
} catch (emitError) {
log.error('[enable-banking] Failed to emit account selection event', {
errorMessage: emitError instanceof Error ? emitError.message : String(emitError),
connectionId: connection.id,
userId: user.id,
companyId,
})
}
return NextResponse.json({
success: true,
enabled_count: enabled_uids.length,
total_count: existing.length,
})
},
},
{
method: 'DELETE',
path: '/disconnect',
@@ -399,6 +587,18 @@ export const enableBankingExtension: Extension = {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!ctx?.companyId) {
return NextResponse.json({ error: 'Company context required' }, { status: 400 })
}
const companyId = ctx.companyId
const rl = await checkRateLimit({
prefix: 'enable-banking:disconnect',
identifier: user.id,
...RATE_LIMIT_DISCONNECT,
})
if (!rl.ok) return rl.response!
const { connection_id } = await request.json()
if (!connection_id) {
@@ -407,9 +607,9 @@ export const enableBankingExtension: Extension = {
const { data: connection, error: findError } = await supabase
.from('bank_connections')
.select('id, session_id, status')
.select('id, session_id, status, bank_name')
.eq('id', connection_id)
.eq('company_id', ctx?.companyId ?? user.id)
.eq('company_id', companyId)
.single()
if (findError || !connection) {
@@ -426,6 +626,8 @@ export const enableBankingExtension: Extension = {
sessionId: connection.session_id,
connectionId: connection_id,
connectionStatus: connection.status,
userId: user.id,
companyId,
})
}
}
@@ -436,9 +638,35 @@ export const enableBankingExtension: Extension = {
.eq('id', connection.id)
if (updateError) {
log.error('[enable-banking] Failed to mark connection revoked', {
errorMessage: updateError.message,
connectionId: connection.id,
userId: user.id,
companyId,
})
return NextResponse.json({ error: 'Failed to disconnect' }, { status: 500 })
}
try {
const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus)
await emit({
type: 'bank_connection.revoked',
payload: {
connectionId: connection.id,
bankName: (connection as { bank_name?: string | null }).bank_name ?? null,
userId: user.id,
companyId,
},
})
} catch (emitError) {
log.error('[enable-banking] Failed to emit revoke event', {
errorMessage: emitError instanceof Error ? emitError.message : String(emitError),
connectionId: connection.id,
userId: user.id,
companyId,
})
}
return NextResponse.json({ success: true })
},
},
@@ -7,6 +7,10 @@ export interface StoredAccount {
currency: string
balance?: number
balance_updated_at?: string
// When false, the account is part of the PSD2 consent but the user has
// chosen not to sync transactions from it. Treated as true if missing
// (back-compat with rows that predate the per-account toggle).
enabled?: boolean
}
// Re-export API types from the client