Private
Public Access
1
0

Implement Phase 6: automated bank sync (Plaid + SimpleFIN) & settings

Add automated transaction syncing via Plaid and SimpleFIN providers,
a Settings page for managing connections and account mappings,
background scheduled syncing, and sync history logging.

New backend: BankSync plugin project with PlaidSyncProvider and
SimpleFinSyncProvider, SyncConnection/LinkedAccount/SyncLog models,
AES-256 encryption service, BankSyncService orchestration,
BankSyncBackgroundService (15-min tick), and BankSyncController
(13 endpoints). EF migration creates sync_connections,
linked_accounts, and sync_logs tables.

New frontend: Settings view with 3 tabs (Profile, Bank Connections,
Sync History), bankSync API service, BankSyncComplete SignalR
notifications, and Settings nav item in sidebar + avatar dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 15:08:23 -05:00
parent eb49147080
commit 3e0446c9be
34 changed files with 3956 additions and 8 deletions
+19 -5
View File
@@ -75,10 +75,18 @@
:class="{ 'bg-blue-lighten-5': !n.read }"
@click="handleNotificationClick(n)"
>
<v-list-item-title>{{ n.data.fileName }}</v-list-item-title>
<v-list-item-subtitle>
{{ n.data.newTransactions }} new, {{ n.data.possibleDuplicates }} duplicates
</v-list-item-subtitle>
<template v-if="n.type === 'FileImportReady'">
<v-list-item-title>{{ (n.data as any).fileName }}</v-list-item-title>
<v-list-item-subtitle>
{{ (n.data as any).newTransactions }} new, {{ (n.data as any).possibleDuplicates }} duplicates
</v-list-item-subtitle>
</template>
<template v-else-if="n.type === 'BankSyncComplete'">
<v-list-item-title>{{ (n.data as any).institutionName }}</v-list-item-title>
<v-list-item-subtitle>
{{ (n.data as any).transactionsImported }} imported, {{ (n.data as any).transactionsSkipped }} skipped
</v-list-item-subtitle>
</template>
</v-list-item>
</template>
</v-list>
@@ -94,6 +102,7 @@
<v-list density="compact">
<v-list-item prepend-icon="mdi-account" :title="authStore.username || 'User'" />
<v-divider />
<v-list-item prepend-icon="mdi-cog" title="Settings" @click="router.push('/settings')" />
<v-list-item prepend-icon="mdi-logout" title="Logout" @click="handleLogout" />
</v-list>
</v-menu>
@@ -140,6 +149,7 @@ const navItems = [
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
{ title: 'Settings', icon: 'mdi-cog', route: '/settings' },
]
const currentPageTitle = computed(() => {
@@ -157,7 +167,11 @@ function toggleTheme() {
function handleNotificationClick(n: AppNotification) {
markAsRead(n.id)
router.push('/imports')
if (n.type === 'BankSyncComplete') {
router.push('/settings')
} else {
router.push('/imports')
}
}
function handleLogout() {
+11 -1
View File
@@ -1,7 +1,7 @@
import { ref, computed } from 'vue'
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr'
import { useAuthStore } from '@/stores/auth'
import type { AppNotification, FileImportNotification } from '@/types'
import type { AppNotification, FileImportNotification, BankSyncNotification } from '@/types'
const notifications = ref<AppNotification[]>([])
const isConnected = ref(false)
@@ -32,6 +32,16 @@ function connect() {
})
})
connection.on('BankSyncComplete', (data: BankSyncNotification) => {
notifications.value.unshift({
id: crypto.randomUUID(),
type: 'BankSyncComplete',
data,
timestamp: new Date().toISOString(),
read: false
})
})
connection.onclose(() => {
isConnected.value = false
})
+1
View File
@@ -18,6 +18,7 @@ const routes = [
{ path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } },
{ path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } },
{ path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } },
{ path: '/settings', name: 'settings', component: () => import('@/views/settings/SettingsView.vue'), meta: { auth: true } },
]
const router = createRouter({
+52
View File
@@ -0,0 +1,52 @@
import api from './api'
import type {
SyncConnection,
LinkedAccountInfo,
DiscoveredAccount,
SyncResult,
SyncLogEntry,
CreateLinkTokenResponse
} from '@/types'
export const bankSyncApi = {
getConnections: () =>
api.get<SyncConnection[]>('/bank-sync/connections'),
getConnection: (id: string) =>
api.get<SyncConnection>(`/bank-sync/connections/${id}`),
updateConnection: (id: string, data: { isActive: boolean; syncIntervalMinutes: number }) =>
api.put<SyncConnection>(`/bank-sync/connections/${id}`, data),
deleteConnection: (id: string) =>
api.delete(`/bank-sync/connections/${id}`),
createLinkToken: () =>
api.post<CreateLinkTokenResponse>('/bank-sync/plaid/create-link-token'),
completePlaidLink: (publicToken: string) =>
api.post<SyncConnection>('/bank-sync/plaid/complete-link', { publicToken }),
connectSimpleFin: (setupToken: string) =>
api.post<SyncConnection>('/bank-sync/simplefin/connect', { setupToken }),
discoverAccounts: (connectionId: string) =>
api.get<DiscoveredAccount[]>(`/bank-sync/connections/${connectionId}/discover-accounts`),
linkAccount: (connectionId: string, data: { linkedAccountId: string; accountId: string | null; autoCreate: boolean }) =>
api.post<LinkedAccountInfo>(`/bank-sync/connections/${connectionId}/link-account`, data),
updateLinkedAccount: (linkedAccountId: string, data: { accountId: string | null; isEnabled: boolean }) =>
api.put<LinkedAccountInfo>(`/bank-sync/linked-accounts/${linkedAccountId}`, data),
syncNow: (connectionId: string) =>
api.post<SyncResult>(`/bank-sync/connections/${connectionId}/sync-now`),
syncAll: () =>
api.post<SyncResult>('/bank-sync/sync-all'),
getSyncLogs: (connectionId?: string, limit = 50) =>
api.get<SyncLogEntry[]>('/bank-sync/sync-logs', {
params: { connectionId, limit }
}),
}
+87 -2
View File
@@ -387,14 +387,99 @@ export interface FileImportNotification {
possibleDuplicates: number
}
export interface BankSyncNotification {
connectionId: string
institutionName: string
transactionsImported: number
transactionsSkipped: number
status: string
}
export interface AppNotification {
id: string
type: 'FileImportReady'
data: FileImportNotification
type: 'FileImportReady' | 'BankSyncComplete'
data: FileImportNotification | BankSyncNotification
timestamp: string
read: boolean
}
// Bank Sync
export type SyncProvider = 'Plaid' | 'SimpleFIN'
export interface SyncConnection {
id: string
provider: SyncProvider
institutionName: string
institutionId: string | null
isActive: boolean
syncIntervalMinutes: number
lastSyncAt: string | null
lastSyncError: string | null
createdAt: string
linkedAccounts: LinkedAccountInfo[]
}
export interface LinkedAccountInfo {
id: string
externalAccountId: string
externalAccountName: string
externalAccountType: string | null
externalAccountMask: string | null
lastKnownBalance: number | null
isEnabled: boolean
accountId: string | null
accountName: string | null
lastSyncAt: string | null
}
export interface DiscoveredAccount {
externalAccountId: string
name: string
type: string | null
mask: string | null
balance: number | null
currency: string | null
}
export interface SyncResult {
totalAccounts: number
accountsSynced: number
transactionsFetched: number
transactionsImported: number
transactionsSkipped: number
accountResults: SyncAccountResult[]
}
export interface SyncAccountResult {
externalAccountName: string
purrseAccountName: string | null
fetched: number
imported: number
skipped: number
error: string | null
}
export interface SyncLogEntry {
id: string
connectionId: string
institutionName: string
linkedAccountId: string | null
linkedAccountName: string | null
status: 'Success' | 'PartialSuccess' | 'Failed' | 'Running'
transactionsFetched: number
transactionsImported: number
transactionsSkipped: number
errorMessage: string | null
startedAt: string
completedAt: string | null
duration: string | null
}
export interface CreateLinkTokenResponse {
linkToken: string
expiration: string
}
// Split form helper
export interface SplitFormItem {
categoryId: string | null
@@ -0,0 +1,571 @@
<template>
<AppLayout>
<v-tabs v-model="activeTab" color="primary">
<v-tab value="profile">Profile</v-tab>
<v-tab value="connections">Bank Connections</v-tab>
<v-tab value="history">Sync History</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab" class="mt-4">
<!-- Tab 1: Profile -->
<v-tabs-window-item value="profile">
<v-card>
<v-card-title>Profile</v-card-title>
<v-card-text>
<v-text-field
:model-value="authStore.username"
label="Username"
readonly
variant="outlined"
class="mb-4"
/>
</v-card-text>
</v-card>
</v-tabs-window-item>
<!-- Tab 2: Bank Connections -->
<v-tabs-window-item value="connections">
<div class="d-flex justify-end mb-4 ga-2">
<v-btn color="primary" prepend-icon="mdi-bank-plus" @click="startPlaidLink" :loading="plaidLoading">
Connect via Plaid
</v-btn>
<v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true">
Connect via SimpleFIN
</v-btn>
</div>
<v-alert v-if="connections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
No bank connections yet. Click a button above to connect your bank.
</v-alert>
<v-card v-for="conn in connections" :key="conn.id" class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">{{ conn.provider === 'Plaid' ? 'mdi-bank' : 'mdi-link-variant' }}</v-icon>
{{ conn.institutionName }}
<v-chip :color="conn.provider === 'Plaid' ? 'blue' : 'green'" size="small" class="ml-2">
{{ conn.provider }}
</v-chip>
<v-spacer />
<v-chip
:color="conn.isActive ? 'success' : 'grey'"
size="small"
class="mr-2"
>
{{ conn.isActive ? 'Active' : 'Inactive' }}
</v-chip>
</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-2">
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span>
</div>
<v-table density="compact" v-if="conn.linkedAccounts.length > 0">
<thead>
<tr>
<th>External Account</th>
<th>Type</th>
<th>Balance</th>
<th>Purrse Account</th>
<th>Enabled</th>
<th>Last Synced</th>
</tr>
</thead>
<tbody>
<tr v-for="la in conn.linkedAccounts" :key="la.id">
<td>{{ la.externalAccountName }}</td>
<td>{{ la.externalAccountType }}</td>
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
<td>
<v-select
:model-value="la.accountId"
:items="accountOptions"
item-title="text"
item-value="value"
density="compact"
variant="outlined"
hide-details
clearable
placeholder="Select account..."
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)"
/>
</td>
<td>
<v-switch
:model-value="la.isEnabled"
density="compact"
hide-details
color="primary"
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)"
/>
</td>
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td>
</tr>
</tbody>
</v-table>
<div class="d-flex align-center mt-3 ga-2">
<v-select
v-model="syncIntervals[conn.id]"
:items="intervalOptions"
item-title="text"
item-value="value"
label="Sync Interval"
density="compact"
variant="outlined"
hide-details
style="max-width: 200px"
@update:model-value="() => updateConnectionInterval(conn)"
/>
<v-spacer />
<v-btn
variant="tonal"
color="primary"
prepend-icon="mdi-sync"
:loading="syncingConnections[conn.id]"
@click="syncNow(conn)"
>
Sync Now
</v-btn>
<v-btn
variant="tonal"
:color="conn.isActive ? 'warning' : 'success'"
@click="toggleConnection(conn)"
>
{{ conn.isActive ? 'Disable' : 'Enable' }}
</v-btn>
<v-btn
variant="tonal"
color="error"
prepend-icon="mdi-delete"
@click="confirmDeleteConnection(conn)"
>
Delete
</v-btn>
</div>
</v-card-text>
</v-card>
<!-- Account Mapping Dialog -->
<v-dialog v-model="showMappingDialog" max-width="700" persistent>
<v-card>
<v-card-title>Map Bank Accounts</v-card-title>
<v-card-text>
<p class="mb-4">Map your external bank accounts to Purrse accounts, or auto-create new ones.</p>
<v-table density="compact">
<thead>
<tr>
<th>External Account</th>
<th>Type</th>
<th>Balance</th>
<th>Purrse Account</th>
<th>Auto-create</th>
</tr>
</thead>
<tbody>
<tr v-for="la in newLinkedAccounts" :key="la.id">
<td>{{ la.externalAccountName }}</td>
<td>{{ la.externalAccountType }}</td>
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
<td>
<v-select
v-model="mappingSelections[la.id]"
:items="accountOptions"
item-title="text"
item-value="value"
density="compact"
variant="outlined"
hide-details
clearable
:disabled="autoCreateSelections[la.id]"
placeholder="Select..."
/>
</td>
<td>
<v-checkbox
v-model="autoCreateSelections[la.id]"
density="compact"
hide-details
/>
</td>
</tr>
</tbody>
</v-table>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showMappingDialog = false">Skip</v-btn>
<v-btn color="primary" @click="saveMappings" :loading="mappingSaving">Save Mappings</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- SimpleFIN Dialog -->
<v-dialog v-model="showSimpleFinDialog" max-width="500">
<v-card>
<v-card-title>Connect via SimpleFIN</v-card-title>
<v-card-text>
<p class="mb-4">
Visit <strong>simplefin.org/bridge</strong> to get a setup token for your bank, then paste it below.
</p>
<v-text-field
v-model="simpleFinToken"
label="Setup Token"
variant="outlined"
placeholder="Paste your SimpleFIN setup token..."
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showSimpleFinDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="connectSimpleFin" :loading="simpleFinLoading" :disabled="!simpleFinToken">
Connect
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete Confirmation -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title>Delete Connection</v-card-title>
<v-card-text>
Are you sure you want to delete the connection to <strong>{{ deleteTarget?.institutionName }}</strong>?
This will remove all linked account mappings and sync history.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" @click="deleteConnection" :loading="deleteLoading">Delete</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Sync Results Snackbar -->
<v-snackbar v-model="showSyncResult" :timeout="5000" color="success">
{{ syncResultMessage }}
</v-snackbar>
</v-tabs-window-item>
<!-- Tab 3: Sync History -->
<v-tabs-window-item value="history">
<v-card>
<v-card-title class="d-flex align-center">
Sync History
<v-spacer />
<v-select
v-model="logFilterConnectionId"
:items="connectionFilterOptions"
item-title="text"
item-value="value"
label="Filter by connection"
density="compact"
variant="outlined"
hide-details
clearable
style="max-width: 300px"
@update:model-value="loadSyncLogs"
/>
</v-card-title>
<v-data-table
:headers="logHeaders"
:items="syncLogs"
:loading="logsLoading"
density="compact"
items-per-page="25"
>
<template v-slot:item.startedAt="{ item }">
{{ new Date(item.startedAt).toLocaleString() }}
</template>
<template v-slot:item.status="{ item }">
<v-chip
:color="statusColor(item.status)"
size="small"
>
{{ item.status }}
</v-chip>
</template>
<template v-slot:item.duration="{ item }">
{{ item.duration || '-' }}
</template>
<template v-slot:item.errorMessage="{ item }">
<span v-if="item.errorMessage" class="text-error text-body-2">{{ item.errorMessage }}</span>
<span v-else>-</span>
</template>
</v-data-table>
</v-card>
</v-tabs-window-item>
</v-tabs-window>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import { useAuthStore } from '@/stores/auth'
import { bankSyncApi } from '@/services/bankSync'
import { accountsApi } from '@/services/accounts'
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry } from '@/types'
const authStore = useAuthStore()
const activeTab = ref('connections')
const loading = ref(false)
const connections = ref<SyncConnection[]>([])
const accounts = ref<Account[]>([])
const syncIntervals = reactive<Record<string, number>>({})
const syncingConnections = reactive<Record<string, boolean>>({})
// Plaid
const plaidLoading = ref(false)
// SimpleFIN
const showSimpleFinDialog = ref(false)
const simpleFinToken = ref('')
const simpleFinLoading = ref(false)
// Mapping dialog
const showMappingDialog = ref(false)
const mappingSaving = ref(false)
const newConnectionId = ref('')
const newLinkedAccounts = ref<LinkedAccountInfo[]>([])
const mappingSelections = reactive<Record<string, string | null>>({})
const autoCreateSelections = reactive<Record<string, boolean>>({})
// Delete
const showDeleteDialog = ref(false)
const deleteTarget = ref<SyncConnection | null>(null)
const deleteLoading = ref(false)
// Sync result
const showSyncResult = ref(false)
const syncResultMessage = ref('')
// Sync logs
const syncLogs = ref<SyncLogEntry[]>([])
const logsLoading = ref(false)
const logFilterConnectionId = ref<string | null>(null)
const accountOptions = computed(() =>
accounts.value
.filter(a => a.isActive && !a.isClosed)
.map(a => ({ text: `${a.name} (${a.type})`, value: a.id }))
)
const connectionFilterOptions = computed(() =>
connections.value.map(c => ({ text: c.institutionName, value: c.id }))
)
const intervalOptions = [
{ text: '1 hour', value: 60 },
{ text: '3 hours', value: 180 },
{ text: '6 hours', value: 360 },
{ text: '12 hours', value: 720 },
{ text: '24 hours', value: 1440 },
]
const logHeaders = [
{ title: 'Time', key: 'startedAt', sortable: true },
{ title: 'Institution', key: 'institutionName', sortable: true },
{ title: 'Account', key: 'linkedAccountName', sortable: false },
{ title: 'Status', key: 'status', sortable: true },
{ title: 'Imported', key: 'transactionsImported', sortable: false },
{ title: 'Skipped', key: 'transactionsSkipped', sortable: false },
{ title: 'Duration', key: 'duration', sortable: false },
{ title: 'Error', key: 'errorMessage', sortable: false },
]
function statusColor(status: string) {
switch (status) {
case 'Success': return 'success'
case 'PartialSuccess': return 'warning'
case 'Failed': return 'error'
case 'Running': return 'info'
default: return 'grey'
}
}
function formatCurrency(value: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
}
async function loadConnections() {
loading.value = true
try {
const { data } = await bankSyncApi.getConnections()
connections.value = data
data.forEach(c => { syncIntervals[c.id] = c.syncIntervalMinutes })
} finally {
loading.value = false
}
}
async function loadAccounts() {
const { data } = await accountsApi.getAll()
accounts.value = data
}
async function loadSyncLogs() {
logsLoading.value = true
try {
const { data } = await bankSyncApi.getSyncLogs(logFilterConnectionId.value || undefined)
syncLogs.value = data
} finally {
logsLoading.value = false
}
}
async function startPlaidLink() {
plaidLoading.value = true
try {
const { data: linkData } = await bankSyncApi.createLinkToken()
// Dynamically load Plaid Link script
await loadPlaidScript()
const plaid = (window as any).Plaid.create({
token: linkData.linkToken,
onSuccess: async (publicToken: string) => {
try {
const { data: conn } = await bankSyncApi.completePlaidLink(publicToken)
connections.value.unshift(conn)
syncIntervals[conn.id] = conn.syncIntervalMinutes
openMappingDialog(conn)
} catch (err) {
console.error('Failed to complete Plaid link:', err)
}
},
onExit: () => {
plaidLoading.value = false
}
})
plaid.open()
} catch (err) {
console.error('Failed to create link token:', err)
plaidLoading.value = false
}
}
function loadPlaidScript(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).Plaid) {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'
script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load Plaid Link script'))
document.head.appendChild(script)
})
}
async function connectSimpleFin() {
simpleFinLoading.value = true
try {
const { data: conn } = await bankSyncApi.connectSimpleFin(simpleFinToken.value)
connections.value.unshift(conn)
syncIntervals[conn.id] = conn.syncIntervalMinutes
showSimpleFinDialog.value = false
simpleFinToken.value = ''
openMappingDialog(conn)
} catch (err) {
console.error('Failed to connect SimpleFIN:', err)
} finally {
simpleFinLoading.value = false
}
}
function openMappingDialog(conn: SyncConnection) {
newConnectionId.value = conn.id
newLinkedAccounts.value = conn.linkedAccounts
conn.linkedAccounts.forEach(la => {
mappingSelections[la.id] = null
autoCreateSelections[la.id] = false
})
showMappingDialog.value = true
}
async function saveMappings() {
mappingSaving.value = true
try {
for (const la of newLinkedAccounts.value) {
const accountId = mappingSelections[la.id]
const autoCreate = autoCreateSelections[la.id]
if (accountId || autoCreate) {
await bankSyncApi.linkAccount(newConnectionId.value, {
linkedAccountId: la.id,
accountId: autoCreate ? null : accountId,
autoCreate
})
}
}
showMappingDialog.value = false
await loadConnections()
await loadAccounts()
} finally {
mappingSaving.value = false
}
}
async function updateLinkedAccount(la: LinkedAccountInfo, accountId: string | null) {
await bankSyncApi.updateLinkedAccount(la.id, { accountId, isEnabled: la.isEnabled })
await loadConnections()
}
async function toggleLinkedAccount(la: LinkedAccountInfo, enabled: boolean) {
await bankSyncApi.updateLinkedAccount(la.id, { accountId: la.accountId, isEnabled: enabled })
await loadConnections()
}
async function updateConnectionInterval(conn: SyncConnection) {
await bankSyncApi.updateConnection(conn.id, {
isActive: conn.isActive,
syncIntervalMinutes: syncIntervals[conn.id]
})
}
async function toggleConnection(conn: SyncConnection) {
await bankSyncApi.updateConnection(conn.id, {
isActive: !conn.isActive,
syncIntervalMinutes: conn.syncIntervalMinutes
})
await loadConnections()
}
async function syncNow(conn: SyncConnection) {
syncingConnections[conn.id] = true
try {
const { data } = await bankSyncApi.syncNow(conn.id)
syncResultMessage.value = `Synced ${conn.institutionName}: ${data.transactionsImported} imported, ${data.transactionsSkipped} skipped`
showSyncResult.value = true
await loadConnections()
} catch (err) {
console.error('Sync failed:', err)
} finally {
syncingConnections[conn.id] = false
}
}
function confirmDeleteConnection(conn: SyncConnection) {
deleteTarget.value = conn
showDeleteDialog.value = true
}
async function deleteConnection() {
if (!deleteTarget.value) return
deleteLoading.value = true
try {
await bankSyncApi.deleteConnection(deleteTarget.value.id)
connections.value = connections.value.filter(c => c.id !== deleteTarget.value!.id)
showDeleteDialog.value = false
} finally {
deleteLoading.value = false
}
}
onMounted(async () => {
await Promise.all([loadConnections(), loadAccounts(), loadSyncLogs()])
})
</script>