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:
co-authored by
Claude Opus 4.7
parent
d0fbc2b616
commit
a53a119a2e
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user