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:
@@ -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