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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user