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,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}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user