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
+15
View File
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.QIF", "src\P
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Tests", "src\Purrse.Tests\Purrse.Tests.csproj", "{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Tests", "src\Purrse.Tests\Purrse.Tests.csproj", "{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.BankSync", "src\Purrse.Plugins.BankSync\Purrse.Plugins.BankSync.csproj", "{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -127,6 +129,18 @@ Global
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x64.Build.0 = Release|Any CPU {8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x64.Build.0 = Release|Any CPU
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x86.ActiveCfg = Release|Any CPU {8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x86.ActiveCfg = Release|Any CPU
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x86.Build.0 = Release|Any CPU {8A090385-C08B-4BCB-A1C9-61C2D23E9C49}.Release|x86.Build.0 = Release|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Debug|x64.ActiveCfg = Debug|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Debug|x64.Build.0 = Debug|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Debug|x86.ActiveCfg = Debug|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Debug|x86.Build.0 = Debug|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Release|Any CPU.Build.0 = Release|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Release|x64.ActiveCfg = Release|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Release|x64.Build.0 = Release|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Release|x86.ActiveCfg = Release|Any CPU
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -140,5 +154,6 @@ Global
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C93480E6-AEF4-4665-9F8C-032F36DC6AD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {3CA0D3FF-F269-42D8-A8D4-AB77A34CBF0B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{8A090385-C08B-4BCB-A1C9-61C2D23E9C49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8A090385-C08B-4BCB-A1C9-61C2D23E9C49} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
+19 -5
View File
@@ -75,10 +75,18 @@
:class="{ 'bg-blue-lighten-5': !n.read }" :class="{ 'bg-blue-lighten-5': !n.read }"
@click="handleNotificationClick(n)" @click="handleNotificationClick(n)"
> >
<v-list-item-title>{{ n.data.fileName }}</v-list-item-title> <template v-if="n.type === 'FileImportReady'">
<v-list-item-subtitle> <v-list-item-title>{{ (n.data as any).fileName }}</v-list-item-title>
{{ n.data.newTransactions }} new, {{ n.data.possibleDuplicates }} duplicates <v-list-item-subtitle>
</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> </v-list-item>
</template> </template>
</v-list> </v-list>
@@ -94,6 +102,7 @@
<v-list density="compact"> <v-list density="compact">
<v-list-item prepend-icon="mdi-account" :title="authStore.username || 'User'" /> <v-list-item prepend-icon="mdi-account" :title="authStore.username || 'User'" />
<v-divider /> <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-item prepend-icon="mdi-logout" title="Logout" @click="handleLogout" />
</v-list> </v-list>
</v-menu> </v-menu>
@@ -140,6 +149,7 @@ const navItems = [
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' }, { title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' }, { title: 'Investments', icon: 'mdi-finance', route: '/investments' },
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' }, { title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
{ title: 'Settings', icon: 'mdi-cog', route: '/settings' },
] ]
const currentPageTitle = computed(() => { const currentPageTitle = computed(() => {
@@ -157,7 +167,11 @@ function toggleTheme() {
function handleNotificationClick(n: AppNotification) { function handleNotificationClick(n: AppNotification) {
markAsRead(n.id) markAsRead(n.id)
router.push('/imports') if (n.type === 'BankSyncComplete') {
router.push('/settings')
} else {
router.push('/imports')
}
} }
function handleLogout() { function handleLogout() {
+11 -1
View File
@@ -1,7 +1,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr' import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { AppNotification, FileImportNotification } from '@/types' import type { AppNotification, FileImportNotification, BankSyncNotification } from '@/types'
const notifications = ref<AppNotification[]>([]) const notifications = ref<AppNotification[]>([])
const isConnected = ref(false) 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(() => { connection.onclose(() => {
isConnected.value = false 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: '/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: '/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: '/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({ 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 possibleDuplicates: number
} }
export interface BankSyncNotification {
connectionId: string
institutionName: string
transactionsImported: number
transactionsSkipped: number
status: string
}
export interface AppNotification { export interface AppNotification {
id: string id: string
type: 'FileImportReady' type: 'FileImportReady' | 'BankSyncComplete'
data: FileImportNotification data: FileImportNotification | BankSyncNotification
timestamp: string timestamp: string
read: boolean 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 // Split form helper
export interface SplitFormItem { export interface SplitFormItem {
categoryId: string | null 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>
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/bank-sync")]
public class BankSyncController : ControllerBase
{
private readonly IBankSyncService _bankSyncService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public BankSyncController(IBankSyncService bankSyncService) => _bankSyncService = bankSyncService;
[HttpGet("connections")]
public async Task<ActionResult<List<SyncConnectionResponse>>> GetConnections()
=> Ok(await _bankSyncService.GetConnectionsAsync(UserId));
[HttpGet("connections/{id}")]
public async Task<ActionResult<SyncConnectionResponse>> GetConnection(Guid id)
=> Ok(await _bankSyncService.GetConnectionAsync(UserId, id));
[HttpPut("connections/{id}")]
public async Task<ActionResult<SyncConnectionResponse>> UpdateConnection(Guid id, UpdateConnectionRequest request)
=> Ok(await _bankSyncService.UpdateConnectionAsync(UserId, id, request));
[HttpDelete("connections/{id}")]
public async Task<IActionResult> DeleteConnection(Guid id)
{
await _bankSyncService.DeleteConnectionAsync(UserId, id);
return NoContent();
}
[HttpPost("plaid/create-link-token")]
public async Task<ActionResult<CreateLinkTokenResponse>> CreatePlaidLinkToken()
=> Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId));
[HttpPost("plaid/complete-link")]
public async Task<ActionResult<SyncConnectionResponse>> CompletePlaidLink(CreatePlaidConnectionRequest request)
=> Ok(await _bankSyncService.CompletePlaidLinkAsync(UserId, request));
[HttpPost("simplefin/connect")]
public async Task<ActionResult<SyncConnectionResponse>> ConnectSimpleFin(CreateSimpleFinConnectionRequest request)
=> Ok(await _bankSyncService.CreateSimpleFinConnectionAsync(UserId, request));
[HttpGet("connections/{id}/discover-accounts")]
public async Task<ActionResult<List<DiscoveredAccountResponse>>> DiscoverAccounts(Guid id)
=> Ok(await _bankSyncService.DiscoverAccountsAsync(UserId, id));
[HttpPost("connections/{id}/link-account")]
public async Task<ActionResult<LinkedAccountResponse>> LinkAccount(Guid id, LinkAccountRequest request)
=> Ok(await _bankSyncService.LinkAccountAsync(UserId, id, request));
[HttpPut("linked-accounts/{id}")]
public async Task<ActionResult<LinkedAccountResponse>> UpdateLinkedAccount(Guid id, UpdateLinkedAccountRequest request)
=> Ok(await _bankSyncService.UpdateLinkedAccountAsync(UserId, id, request));
[HttpPost("connections/{id}/sync-now")]
public async Task<ActionResult<SyncResultResponse>> SyncNow(Guid id)
=> Ok(await _bankSyncService.SyncNowAsync(UserId, id));
[HttpPost("sync-all")]
public async Task<ActionResult<SyncResultResponse>> SyncAll()
=> Ok(await _bankSyncService.SyncAllAsync(UserId));
[HttpGet("sync-logs")]
public async Task<ActionResult<List<SyncLogResponse>>> GetSyncLogs([FromQuery] Guid? connectionId, [FromQuery] int limit = 50)
=> Ok(await _bankSyncService.GetSyncLogsAsync(UserId, connectionId, limit));
}
+8
View File
@@ -8,6 +8,7 @@ using Purrse.Api.Services;
using Purrse.Core.Interfaces.Services; using Purrse.Core.Interfaces.Services;
using Purrse.Data; using Purrse.Data;
using Purrse.Plugins.Abstractions; using Purrse.Plugins.Abstractions;
using Purrse.Plugins.BankSync;
using Purrse.Plugins.CSV; using Purrse.Plugins.CSV;
using Purrse.Plugins.OFX; using Purrse.Plugins.OFX;
using Purrse.Plugins.QIF; using Purrse.Plugins.QIF;
@@ -76,18 +77,25 @@ builder.Services.AddScoped<ILoanService, LoanService>();
builder.Services.AddScoped<IDashboardService, DashboardService>(); builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IReportService, ReportService>(); builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>(); builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
// Built-in file parsers // Built-in file parsers
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>(); builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
builder.Services.AddSingleton<IFileParser, CSVParserPlugin>(); builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>(); builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
// Bank sync providers
builder.Services.AddSingleton<PlaidSyncProvider>();
builder.Services.AddHttpClient<SimpleFinSyncProvider>();
// Plugin system // Plugin system
builder.Services.AddSingleton<PluginService>(); builder.Services.AddSingleton<PluginService>();
// Background services // Background services
builder.Services.AddHostedService<ScheduledTransactionProcessor>(); builder.Services.AddHostedService<ScheduledTransactionProcessor>();
builder.Services.AddHostedService<FileWatcherService>(); builder.Services.AddHostedService<FileWatcherService>();
builder.Services.AddHostedService<BankSyncBackgroundService>();
// SignalR // SignalR
builder.Services.AddSignalR(); builder.Services.AddSignalR();
+1
View File
@@ -22,6 +22,7 @@
<ProjectReference Include="..\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj" /> <ProjectReference Include="..\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj" />
<ProjectReference Include="..\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj" /> <ProjectReference Include="..\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj" />
<ProjectReference Include="..\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj" /> <ProjectReference Include="..\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj" />
<ProjectReference Include="..\Purrse.Plugins.BankSync\Purrse.Plugins.BankSync.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore;
using Purrse.Core.Interfaces.Services;
using Purrse.Data;
namespace Purrse.Api.Services;
public class BankSyncBackgroundService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<BankSyncBackgroundService> _logger;
private readonly TimeSpan _checkInterval;
public BankSyncBackgroundService(IServiceProvider services, IConfiguration configuration, ILogger<BankSyncBackgroundService> logger)
{
_services = services;
_logger = logger;
var minutes = configuration.GetValue("BankSync:BackgroundCheckIntervalMinutes", 15);
_checkInterval = TimeSpan.FromMinutes(minutes);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Bank sync background service started with {Interval} min interval", _checkInterval.TotalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(_checkInterval, stoppingToken);
try
{
await ProcessDueSyncs(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in bank sync background service");
}
}
}
private async Task ProcessDueSyncs(CancellationToken stoppingToken)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
var bankSyncService = scope.ServiceProvider.GetRequiredService<IBankSyncService>();
var now = DateTime.UtcNow;
var dueConnections = await db.SyncConnections
.Where(c => c.IsActive)
.Where(c => c.LastSyncAt == null || c.LastSyncAt.Value.AddMinutes(c.SyncIntervalMinutes) < now)
.Select(c => new { c.Id, c.UserId, c.InstitutionName })
.ToListAsync(stoppingToken);
_logger.LogInformation("Found {Count} connections due for sync", dueConnections.Count);
foreach (var conn in dueConnections)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
_logger.LogInformation("Auto-syncing connection {Id} ({Name})", conn.Id, conn.InstitutionName);
await bankSyncService.SyncNowAsync(conn.UserId, conn.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to auto-sync connection {Id} ({Name})", conn.Id, conn.InstitutionName);
}
}
}
}
+529
View File
@@ -0,0 +1,529 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Purrse.Api.Hubs;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Helpers;
using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models;
using Purrse.Data;
using Purrse.Plugins.BankSync;
namespace Purrse.Api.Services;
public class BankSyncService : IBankSyncService
{
private readonly PurrseDbContext _db;
private readonly IEncryptionService _encryption;
private readonly PlaidSyncProvider _plaid;
private readonly SimpleFinSyncProvider _simpleFin;
private readonly IDuplicateDetectionService _duplicateService;
private readonly IPayeeService _payeeService;
private readonly IHubContext<NotificationHub> _hub;
private readonly ILogger<BankSyncService> _logger;
public BankSyncService(
PurrseDbContext db,
IEncryptionService encryption,
PlaidSyncProvider plaid,
SimpleFinSyncProvider simpleFin,
IDuplicateDetectionService duplicateService,
IPayeeService payeeService,
IHubContext<NotificationHub> hub,
ILogger<BankSyncService> logger)
{
_db = db;
_encryption = encryption;
_plaid = plaid;
_simpleFin = simpleFin;
_duplicateService = duplicateService;
_payeeService = payeeService;
_hub = hub;
_logger = logger;
}
public async Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId)
{
var connections = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
return connections.Select(MapConnectionResponse).ToList();
}
public async Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
return MapConnectionResponse(connection);
}
public async Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
connection.IsActive = request.IsActive;
connection.SyncIntervalMinutes = request.SyncIntervalMinutes;
connection.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return MapConnectionResponse(connection);
}
public async Task DeleteConnectionAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
_db.SyncConnections.Remove(connection);
await _db.SaveChangesAsync();
}
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
{
var result = await _plaid.CreateLinkTokenAsync(userId.ToString());
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
}
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
{
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken);
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.Plaid,
InstitutionName = exchangeResult.InstitutionName ?? "Unknown Institution",
InstitutionId = exchangeResult.InstitutionId,
EncryptedAccessToken = _encryption.Encrypt(exchangeResult.AccessToken),
PlaidItemId = exchangeResult.ItemId
};
_db.SyncConnections.Add(connection);
await _db.SaveChangesAsync();
// Discover accounts
await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken);
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
{
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
// Get accounts to determine institution name
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
var accounts = await _simpleFin.GetAccountsAsync(config);
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.SimpleFIN,
InstitutionName = institutionName,
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
};
_db.SyncConnections.Add(connection);
// Create linked accounts from discovered accounts
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
var accounts = connection.Provider == SyncProvider.Plaid
? await _plaid.GetAccountsAsync(config)
: await _simpleFin.GetAccountsAsync(config);
return accounts.Select(a => new DiscoveredAccountResponse(
a.ExternalId,
a.Name,
a.AccountType,
null,
a.Balance,
a.Currency
)).ToList();
}
public async Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
var linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstOrDefaultAsync(la => la.Id == request.LinkedAccountId && la.SyncConnectionId == connectionId)
?? throw new KeyNotFoundException("Linked account not found");
if (request.AutoCreate && request.AccountId == null)
{
// Auto-create a Purrse account
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
? at : AccountType.Checking;
var newAccount = new Account
{
Id = Guid.NewGuid(),
UserId = userId,
Name = linkedAccount.ExternalAccountName,
Type = accountType,
Institution = connection.InstitutionName,
Balance = linkedAccount.LastKnownBalance ?? 0
};
_db.Accounts.Add(newAccount);
linkedAccount.AccountId = newAccount.Id;
}
else
{
linkedAccount.AccountId = request.AccountId;
}
await _db.SaveChangesAsync();
// Reload with Account nav
linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstAsync(la => la.Id == linkedAccount.Id);
return MapLinkedAccountResponse(linkedAccount);
}
public async Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request)
{
var linkedAccount = await _db.LinkedAccounts
.Include(la => la.SyncConnection)
.Include(la => la.Account)
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
?? throw new KeyNotFoundException("Linked account not found");
linkedAccount.AccountId = request.AccountId;
linkedAccount.IsEnabled = request.IsEnabled;
await _db.SaveChangesAsync();
return MapLinkedAccountResponse(linkedAccount);
}
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
return await SyncConnectionInternal(userId, connection);
}
public async Task<SyncResultResponse> SyncAllAsync(Guid userId)
{
var connections = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.Where(c => c.UserId == userId && c.IsActive)
.ToListAsync();
var allResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0, totalSynced = 0;
foreach (var connection in connections)
{
var result = await SyncConnectionInternal(userId, connection);
allResults.AddRange(result.AccountResults);
totalFetched += result.TransactionsFetched;
totalImported += result.TransactionsImported;
totalSkipped += result.TransactionsSkipped;
totalSynced += result.AccountsSynced;
}
return new SyncResultResponse(
allResults.Count,
totalSynced,
totalFetched,
totalImported,
totalSkipped,
allResults);
}
public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50)
{
var query = _db.SyncLogs
.Include(l => l.SyncConnection)
.Include(l => l.LinkedAccount)
.Where(l => l.SyncConnection.UserId == userId);
if (connectionId.HasValue)
query = query.Where(l => l.SyncConnectionId == connectionId.Value);
return await query
.OrderByDescending(l => l.StartedAt)
.Take(limit)
.Select(l => new SyncLogResponse(
l.Id,
l.SyncConnectionId,
l.SyncConnection.InstitutionName,
l.LinkedAccountId,
l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null,
l.Status,
l.TransactionsFetched,
l.TransactionsImported,
l.TransactionsSkipped,
l.ErrorMessage,
l.StartedAt,
l.CompletedAt,
l.Duration))
.ToListAsync();
}
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
{
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
var enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList();
var accountResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0;
foreach (var linkedAccount in enabledAccounts)
{
var log = new SyncLog
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
LinkedAccountId = linkedAccount.Id,
Status = SyncStatus.Running,
StartedAt = DateTime.UtcNow
};
_db.SyncLogs.Add(log);
try
{
var endDate = DateTime.UtcNow;
var startDate = linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
var provider = connection.Provider == SyncProvider.Plaid
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
: _simpleFin;
var parseResult = await provider.SyncTransactionsAsync(
linkedAccount.ExternalAccountId, startDate, endDate, config);
if (!parseResult.Success)
{
log.Status = SyncStatus.Failed;
log.ErrorMessage = parseResult.ErrorMessage;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0,
parseResult.ErrorMessage));
continue;
}
int fetched = parseResult.Transactions.Count;
int imported = 0;
int skipped = 0;
foreach (var parsed in parseResult.Transactions)
{
var duplicate = await _duplicateService.FindDuplicateAsync(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount,
parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
if (duplicate != null)
{
skipped++;
continue;
}
var fingerprint = TransactionFingerprint.Generate(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount, parsed.FitId);
var matchedPayee = await _payeeService.MatchPayeeAsync(userId, parsed.PayeeName ?? string.Empty);
var transaction = new Transaction
{
Id = Guid.NewGuid(),
AccountId = linkedAccount.AccountId!.Value,
Date = parsed.Date,
Amount = parsed.Amount,
PayeeId = matchedPayee?.Id,
PayeeName = parsed.PayeeName,
CategoryId = matchedPayee?.DefaultCategoryId,
Memo = parsed.Memo,
ReferenceNumber = parsed.ReferenceNumber,
CheckNumber = parsed.CheckNumber,
FitId = parsed.FitId,
Fingerprint = fingerprint,
Type = parsed.Amount >= 0 ? TransactionType.Credit : TransactionType.Debit,
Status = TransactionStatus.Uncleared
};
_db.Transactions.Add(transaction);
imported++;
}
// Update account balance
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
{
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
linkedAccount.Account.UpdatedAt = DateTime.UtcNow;
}
linkedAccount.LastKnownBalance = parseResult.LedgerBalance ?? linkedAccount.LastKnownBalance;
linkedAccount.LastSyncAt = DateTime.UtcNow;
log.Status = SyncStatus.Success;
log.TransactionsFetched = fetched;
log.TransactionsImported = imported;
log.TransactionsSkipped = skipped;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
totalFetched += fetched;
totalImported += imported;
totalSkipped += skipped;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
fetched, imported, skipped, null));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync account {AccountId} for connection {ConnectionId}",
linkedAccount.ExternalAccountId, connection.Id);
log.Status = SyncStatus.Failed;
log.ErrorMessage = ex.Message;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0, ex.Message));
}
}
connection.LastSyncAt = DateTime.UtcNow;
connection.LastSyncError = accountResults.Any(r => r.Error != null)
? accountResults.First(r => r.Error != null).Error
: null;
connection.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Send SignalR notification
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
{
connectionId = connection.Id,
institutionName = connection.InstitutionName,
transactionsImported = totalImported,
transactionsSkipped = totalSkipped,
status = accountResults.All(r => r.Error == null) ? "Success" : "PartialSuccess"
});
return new SyncResultResponse(
enabledAccounts.Count,
accountResults.Count(r => r.Error == null),
totalFetched,
totalImported,
totalSkipped,
accountResults);
}
private async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken)
{
var config = BuildProviderConfig(connection, accessToken);
var accounts = await _plaid.GetAccountsAsync(config);
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
}
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken)
{
return connection.Provider == SyncProvider.Plaid
? new Dictionary<string, string> { ["AccessToken"] = accessToken }
: new Dictionary<string, string> { ["AccessUrl"] = accessToken };
}
private static SyncConnectionResponse MapConnectionResponse(SyncConnection c)
{
return new SyncConnectionResponse(
c.Id,
c.Provider,
c.InstitutionName,
c.InstitutionId,
c.IsActive,
c.SyncIntervalMinutes,
c.LastSyncAt,
c.LastSyncError,
c.CreatedAt,
c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList());
}
private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la)
{
return new LinkedAccountResponse(
la.Id,
la.ExternalAccountId,
la.ExternalAccountName,
la.ExternalAccountType,
la.ExternalAccountMask,
la.LastKnownBalance,
la.IsEnabled,
la.AccountId,
la.Account?.Name,
la.LastSyncAt);
}
}
@@ -0,0 +1,54 @@
using System.Security.Cryptography;
using System.Text;
using Purrse.Core.Interfaces.Services;
namespace Purrse.Api.Services;
public class EncryptionService : IEncryptionService
{
private readonly byte[] _key;
public EncryptionService(IConfiguration configuration)
{
var keyString = configuration["BankSync:EncryptionKey"]
?? throw new InvalidOperationException("BankSync:EncryptionKey is not configured");
_key = SHA256.HashData(Encoding.UTF8.GetBytes(keyString));
}
public string Encrypt(string plainText)
{
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// Prepend IV to ciphertext
var result = new byte[aes.IV.Length + cipherBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
return Convert.ToBase64String(result);
}
public string Decrypt(string cipherText)
{
var fullCipher = Convert.FromBase64String(cipherText);
using var aes = Aes.Create();
aes.Key = _key;
var iv = new byte[aes.BlockSize / 8];
var cipher = new byte[fullCipher.Length - iv.Length];
Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length);
Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
return Encoding.UTF8.GetString(plainBytes);
}
}
+10
View File
@@ -16,6 +16,16 @@
"Imports": { "Imports": {
"WatchPath": "imports" "WatchPath": "imports"
}, },
"BankSync": {
"EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!",
"Plaid": {
"ClientId": "",
"Secret": "",
"Environment": "sandbox"
},
"DefaultSyncIntervalMinutes": 360,
"BackgroundCheckIntervalMinutes": 15
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
+82
View File
@@ -0,0 +1,82 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
// Plaid
public record CreatePlaidConnectionRequest(string PublicToken);
public record CreateLinkTokenResponse(string LinkToken, string Expiration);
// SimpleFIN
public record CreateSimpleFinConnectionRequest(string SetupToken);
// Connection management
public record SyncConnectionResponse(
Guid Id,
SyncProvider Provider,
string InstitutionName,
string? InstitutionId,
bool IsActive,
int SyncIntervalMinutes,
DateTime? LastSyncAt,
string? LastSyncError,
DateTime CreatedAt,
List<LinkedAccountResponse> LinkedAccounts);
public record LinkedAccountResponse(
Guid Id,
string ExternalAccountId,
string ExternalAccountName,
string? ExternalAccountType,
string? ExternalAccountMask,
decimal? LastKnownBalance,
bool IsEnabled,
Guid? AccountId,
string? AccountName,
DateTime? LastSyncAt);
public record UpdateConnectionRequest(bool IsActive, int SyncIntervalMinutes);
// Account linking
public record LinkAccountRequest(Guid LinkedAccountId, Guid? AccountId, bool AutoCreate);
public record UpdateLinkedAccountRequest(Guid? AccountId, bool IsEnabled);
public record DiscoveredAccountResponse(
string ExternalAccountId,
string Name,
string? Type,
string? Mask,
decimal? Balance,
string? Currency);
// Sync results
public record SyncResultResponse(
int TotalAccounts,
int AccountsSynced,
int TransactionsFetched,
int TransactionsImported,
int TransactionsSkipped,
List<SyncAccountResult> AccountResults);
public record SyncAccountResult(
string ExternalAccountName,
string? PurrseAccountName,
int Fetched,
int Imported,
int Skipped,
string? Error);
// Sync logs
public record SyncLogResponse(
Guid Id,
Guid ConnectionId,
string InstitutionName,
Guid? LinkedAccountId,
string? LinkedAccountName,
SyncStatus Status,
int TransactionsFetched,
int TransactionsImported,
int TransactionsSkipped,
string? ErrorMessage,
DateTime StartedAt,
DateTime? CompletedAt,
TimeSpan? Duration);
+7
View File
@@ -0,0 +1,7 @@
namespace Purrse.Core.Enums;
public enum SyncProvider
{
Plaid,
SimpleFIN
}
+9
View File
@@ -0,0 +1,9 @@
namespace Purrse.Core.Enums;
public enum SyncStatus
{
Success,
PartialSuccess,
Failed,
Running
}
@@ -0,0 +1,31 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IBankSyncService
{
// Connection CRUD
Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId);
Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId);
Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request);
Task DeleteConnectionAsync(Guid userId, Guid connectionId);
// Plaid flow
Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId);
Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request);
// SimpleFIN flow
Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request);
// Account linking
Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId);
Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request);
Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request);
// Sync
Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId);
Task<SyncResultResponse> SyncAllAsync(Guid userId);
// Logs
Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50);
}
@@ -0,0 +1,7 @@
namespace Purrse.Core.Interfaces.Services;
public interface IEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string cipherText);
}
+1
View File
@@ -25,4 +25,5 @@ public class Account
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>(); public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<InvestmentHolding> InvestmentHoldings { get; set; } = new List<InvestmentHolding>(); public ICollection<InvestmentHolding> InvestmentHoldings { get; set; } = new List<InvestmentHolding>();
public ICollection<Reconciliation> Reconciliations { get; set; } = new List<Reconciliation>(); public ICollection<Reconciliation> Reconciliations { get; set; } = new List<Reconciliation>();
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
} }
+19
View File
@@ -0,0 +1,19 @@
namespace Purrse.Core.Models;
public class LinkedAccount
{
public Guid Id { get; set; }
public Guid SyncConnectionId { get; set; }
public Guid? AccountId { get; set; }
public string ExternalAccountId { get; set; } = string.Empty;
public string ExternalAccountName { get; set; } = string.Empty;
public string? ExternalAccountType { get; set; }
public string? ExternalAccountMask { get; set; }
public decimal? LastKnownBalance { get; set; }
public bool IsEnabled { get; set; } = true;
public DateTime? LastSyncAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public SyncConnection SyncConnection { get; set; } = null!;
public Account? Account { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class SyncConnection
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public SyncProvider Provider { get; set; }
public string InstitutionName { get; set; } = string.Empty;
public string? InstitutionId { get; set; }
public string EncryptedAccessToken { get; set; } = string.Empty;
public string? PlaidItemId { get; set; }
public bool IsActive { get; set; } = true;
public int SyncIntervalMinutes { get; set; } = 360;
public DateTime? LastSyncAt { get; set; }
public string? LastSyncError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
public ICollection<SyncLog> SyncLogs { get; set; } = new List<SyncLog>();
}
+21
View File
@@ -0,0 +1,21 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class SyncLog
{
public Guid Id { get; set; }
public Guid SyncConnectionId { get; set; }
public Guid? LinkedAccountId { get; set; }
public SyncStatus Status { get; set; }
public int TransactionsFetched { get; set; }
public int TransactionsImported { get; set; }
public int TransactionsSkipped { get; set; }
public string? ErrorMessage { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public TimeSpan? Duration { get; set; }
public SyncConnection SyncConnection { get; set; } = null!;
public LinkedAccount? LinkedAccount { get; set; }
}
+1
View File
@@ -12,4 +12,5 @@ public class User
public DateTime? RefreshTokenExpiresAt { get; set; } public DateTime? RefreshTokenExpiresAt { get; set; }
public ICollection<Account> Accounts { get; set; } = new List<Account>(); public ICollection<Account> Accounts { get; set; } = new List<Account>();
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
} }
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class SyncConnectionConfiguration : IEntityTypeConfiguration<SyncConnection>
{
public void Configure(EntityTypeBuilder<SyncConnection> builder)
{
builder.ToTable("sync_connections");
builder.HasKey(c => c.Id);
builder.Property(c => c.InstitutionName).HasMaxLength(200).IsRequired();
builder.Property(c => c.InstitutionId).HasMaxLength(100);
builder.Property(c => c.EncryptedAccessToken).HasColumnType("text").IsRequired();
builder.Property(c => c.PlaidItemId).HasMaxLength(200);
builder.Property(c => c.LastSyncError).HasMaxLength(2000);
builder.HasOne(c => c.User)
.WithMany(u => u.SyncConnections)
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(c => c.UserId);
builder.HasIndex(c => c.PlaidItemId);
}
}
public class LinkedAccountConfiguration : IEntityTypeConfiguration<LinkedAccount>
{
public void Configure(EntityTypeBuilder<LinkedAccount> builder)
{
builder.ToTable("linked_accounts");
builder.HasKey(a => a.Id);
builder.Property(a => a.ExternalAccountId).HasMaxLength(200).IsRequired();
builder.Property(a => a.ExternalAccountName).HasMaxLength(200).IsRequired();
builder.Property(a => a.ExternalAccountType).HasMaxLength(50);
builder.Property(a => a.ExternalAccountMask).HasMaxLength(20);
builder.Property(a => a.LastKnownBalance).HasPrecision(18, 2);
builder.HasOne(a => a.SyncConnection)
.WithMany(c => c.LinkedAccounts)
.HasForeignKey(a => a.SyncConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(a => a.Account)
.WithMany(acc => acc.LinkedAccounts)
.HasForeignKey(a => a.AccountId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(a => new { a.SyncConnectionId, a.ExternalAccountId }).IsUnique();
builder.HasIndex(a => a.AccountId);
}
}
public class SyncLogConfiguration : IEntityTypeConfiguration<SyncLog>
{
public void Configure(EntityTypeBuilder<SyncLog> builder)
{
builder.ToTable("sync_logs");
builder.HasKey(l => l.Id);
builder.Property(l => l.ErrorMessage).HasMaxLength(2000);
builder.HasOne(l => l.SyncConnection)
.WithMany(c => c.SyncLogs)
.HasForeignKey(l => l.SyncConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.LinkedAccount)
.WithMany()
.HasForeignKey(l => l.LinkedAccountId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(l => l.SyncConnectionId);
builder.HasIndex(l => l.StartedAt);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Purrse.Data.Migrations
{
/// <inheritdoc />
public partial class AddBankSync : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sync_connections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Provider = table.Column<int>(type: "integer", nullable: false),
InstitutionName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
InstitutionId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
EncryptedAccessToken = table.Column<string>(type: "text", nullable: false),
PlaidItemId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
SyncIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
LastSyncAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSyncError = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sync_connections", x => x.Id);
table.ForeignKey(
name: "FK_sync_connections_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "linked_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SyncConnectionId = table.Column<Guid>(type: "uuid", nullable: false),
AccountId = table.Column<Guid>(type: "uuid", nullable: true),
ExternalAccountId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalAccountType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ExternalAccountMask = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
LastKnownBalance = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastSyncAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_linked_accounts", x => x.Id);
table.ForeignKey(
name: "FK_linked_accounts_accounts_AccountId",
column: x => x.AccountId,
principalTable: "accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_linked_accounts_sync_connections_SyncConnectionId",
column: x => x.SyncConnectionId,
principalTable: "sync_connections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sync_logs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SyncConnectionId = table.Column<Guid>(type: "uuid", nullable: false),
LinkedAccountId = table.Column<Guid>(type: "uuid", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
TransactionsFetched = table.Column<int>(type: "integer", nullable: false),
TransactionsImported = table.Column<int>(type: "integer", nullable: false),
TransactionsSkipped = table.Column<int>(type: "integer", nullable: false),
ErrorMessage = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_sync_logs", x => x.Id);
table.ForeignKey(
name: "FK_sync_logs_linked_accounts_LinkedAccountId",
column: x => x.LinkedAccountId,
principalTable: "linked_accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_sync_logs_sync_connections_SyncConnectionId",
column: x => x.SyncConnectionId,
principalTable: "sync_connections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_linked_accounts_AccountId",
table: "linked_accounts",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_linked_accounts_SyncConnectionId_ExternalAccountId",
table: "linked_accounts",
columns: new[] { "SyncConnectionId", "ExternalAccountId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sync_connections_PlaidItemId",
table: "sync_connections",
column: "PlaidItemId");
migrationBuilder.CreateIndex(
name: "IX_sync_connections_UserId",
table: "sync_connections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_LinkedAccountId",
table: "sync_logs",
column: "LinkedAccountId");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_StartedAt",
table: "sync_logs",
column: "StartedAt");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_SyncConnectionId",
table: "sync_logs",
column: "SyncConnectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sync_logs");
migrationBuilder.DropTable(
name: "linked_accounts");
migrationBuilder.DropTable(
name: "sync_connections");
}
}
}
@@ -305,6 +305,59 @@ namespace Purrse.Data.Migrations
b.ToTable("investment_holdings", (string)null); b.ToTable("investment_holdings", (string)null);
}); });
modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalAccountId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExternalAccountMask")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExternalAccountType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<decimal?>("LastKnownBalance")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SyncConnectionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("SyncConnectionId", "ExternalAccountId")
.IsUnique();
b.ToTable("linked_accounts", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -627,6 +680,111 @@ namespace Purrse.Data.Migrations
b.ToTable("security_prices", (string)null); b.ToTable("security_prices", (string)null);
}); });
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("InstitutionId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("InstitutionName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastSyncError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("PlaidItemId")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Provider")
.HasColumnType("integer");
b.Property<int>("SyncIntervalMinutes")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlaidItemId");
b.HasIndex("UserId");
b.ToTable("sync_connections", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.SyncLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid?>("LinkedAccountId")
.HasColumnType("uuid");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("SyncConnectionId")
.HasColumnType("uuid");
b.Property<int>("TransactionsFetched")
.HasColumnType("integer");
b.Property<int>("TransactionsImported")
.HasColumnType("integer");
b.Property<int>("TransactionsSkipped")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("LinkedAccountId");
b.HasIndex("StartedAt");
b.HasIndex("SyncConnectionId");
b.ToTable("sync_logs", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b => modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -902,6 +1060,24 @@ namespace Purrse.Data.Migrations
b.Navigation("Security"); b.Navigation("Security");
}); });
modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
.WithMany("LinkedAccounts")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection")
.WithMany("LinkedAccounts")
.HasForeignKey("SyncConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("SyncConnection");
});
modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b =>
{ {
b.HasOne("Purrse.Core.Models.Account", "Account") b.HasOne("Purrse.Core.Models.Account", "Account")
@@ -1008,6 +1184,35 @@ namespace Purrse.Data.Migrations
b.Navigation("Security"); b.Navigation("Security");
}); });
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.HasOne("Purrse.Core.Models.User", "User")
.WithMany("SyncConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Purrse.Core.Models.SyncLog", b =>
{
b.HasOne("Purrse.Core.Models.LinkedAccount", "LinkedAccount")
.WithMany()
.HasForeignKey("LinkedAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection")
.WithMany("SyncLogs")
.HasForeignKey("SyncConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LinkedAccount");
b.Navigation("SyncConnection");
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b => modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{ {
b.HasOne("Purrse.Core.Models.Account", "Account") b.HasOne("Purrse.Core.Models.Account", "Account")
@@ -1076,6 +1281,8 @@ namespace Purrse.Data.Migrations
{ {
b.Navigation("InvestmentHoldings"); b.Navigation("InvestmentHoldings");
b.Navigation("LinkedAccounts");
b.Navigation("LoanDetail"); b.Navigation("LoanDetail");
b.Navigation("Reconciliations"); b.Navigation("Reconciliations");
@@ -1131,6 +1338,13 @@ namespace Purrse.Data.Migrations
b.Navigation("Prices"); b.Navigation("Prices");
}); });
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.Navigation("LinkedAccounts");
b.Navigation("SyncLogs");
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b => modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{ {
b.Navigation("Splits"); b.Navigation("Splits");
@@ -1139,6 +1353,8 @@ namespace Purrse.Data.Migrations
modelBuilder.Entity("Purrse.Core.Models.User", b => modelBuilder.Entity("Purrse.Core.Models.User", b =>
{ {
b.Navigation("Accounts"); b.Navigation("Accounts");
b.Navigation("SyncConnections");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
+3
View File
@@ -26,6 +26,9 @@ public class PurrseDbContext : DbContext
public DbSet<Reconciliation> Reconciliations => Set<Reconciliation>(); public DbSet<Reconciliation> Reconciliations => Set<Reconciliation>();
public DbSet<PluginRegistration> PluginRegistrations => Set<PluginRegistration>(); public DbSet<PluginRegistration> PluginRegistrations => Set<PluginRegistration>();
public DbSet<PluginConfiguration> PluginConfigurations => Set<PluginConfiguration>(); public DbSet<PluginConfiguration> PluginConfigurations => Set<PluginConfiguration>();
public DbSet<SyncConnection> SyncConnections => Set<SyncConnection>();
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -0,0 +1,22 @@
namespace Purrse.Plugins.BankSync.Models;
public class PlaidConfig
{
public string ClientId { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public string Environment { get; set; } = "sandbox";
}
public class PlaidLinkTokenResult
{
public string LinkToken { get; set; } = string.Empty;
public string Expiration { get; set; } = string.Empty;
}
public class PlaidExchangeResult
{
public string AccessToken { get; set; } = string.Empty;
public string ItemId { get; set; } = string.Empty;
public string? InstitutionId { get; set; }
public string? InstitutionName { get; set; }
}
@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
namespace Purrse.Plugins.BankSync.Models;
public class SimpleFinAccountSet
{
[JsonPropertyName("errors")]
public List<string> Errors { get; set; } = new();
[JsonPropertyName("accounts")]
public List<SimpleFinAccount> Accounts { get; set; } = new();
}
public class SimpleFinAccount
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("currency")]
public string Currency { get; set; } = "USD";
[JsonPropertyName("balance")]
public string Balance { get; set; } = "0";
[JsonPropertyName("available-balance")]
public string? AvailableBalance { get; set; }
[JsonPropertyName("balance-date")]
public long? BalanceDate { get; set; }
[JsonPropertyName("transactions")]
public List<SimpleFinTransaction> Transactions { get; set; } = new();
[JsonPropertyName("org")]
public SimpleFinOrg? Org { get; set; }
}
public class SimpleFinTransaction
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("posted")]
public long Posted { get; set; }
[JsonPropertyName("amount")]
public string Amount { get; set; } = "0";
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
[JsonPropertyName("payee")]
public string? Payee { get; set; }
[JsonPropertyName("memo")]
public string? Memo { get; set; }
[JsonPropertyName("transacted_at")]
public long? TransactedAt { get; set; }
[JsonPropertyName("pending")]
public bool Pending { get; set; }
}
public class SimpleFinOrg
{
[JsonPropertyName("domain")]
public string? Domain { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("sfin-url")]
public string? SfinUrl { get; set; }
}
@@ -0,0 +1,205 @@
using Going.Plaid;
using Going.Plaid.Entity;
using Going.Plaid.Item;
using Going.Plaid.Link;
using Microsoft.Extensions.Configuration;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models;
using Purrse.Plugins.BankSync.Models;
namespace Purrse.Plugins.BankSync;
public class PlaidSyncProvider : IAccountSyncProvider
{
private readonly PlaidClient _client;
private readonly PlaidConfig _config;
public string Id => "plaid-sync";
public string Name => "Plaid Bank Sync";
public string Version => "1.0.0";
public string Description => "Sync transactions from banks via Plaid";
public PlaidSyncProvider(IConfiguration configuration)
{
_config = new PlaidConfig
{
ClientId = configuration["BankSync:Plaid:ClientId"] ?? string.Empty,
Secret = configuration["BankSync:Plaid:Secret"] ?? string.Empty,
Environment = configuration["BankSync:Plaid:Environment"] ?? "sandbox"
};
var env = _config.Environment.ToLowerInvariant() switch
{
"production" => Going.Plaid.Environment.Production,
"development" => Going.Plaid.Environment.Development,
_ => Going.Plaid.Environment.Sandbox
};
_client = new PlaidClient(env);
}
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
public Task ShutdownAsync() => Task.CompletedTask;
public async Task<PlaidLinkTokenResult> CreateLinkTokenAsync(string userId)
{
var response = await _client.LinkTokenCreateAsync(new LinkTokenCreateRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
User = new LinkTokenCreateRequestUser { ClientUserId = userId },
ClientName = "Purrse",
Products = new[] { Products.Transactions },
CountryCodes = new[] { CountryCode.Us },
Language = Language.English
});
return new PlaidLinkTokenResult
{
LinkToken = response.LinkToken,
Expiration = response.Expiration.ToString("o")
};
}
public async Task<PlaidExchangeResult> ExchangePublicTokenAsync(string publicToken)
{
var response = await _client.ItemPublicTokenExchangeAsync(new ItemPublicTokenExchangeRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
PublicToken = publicToken
});
string? institutionName = null;
string? institutionId = null;
try
{
var itemResponse = await _client.ItemGetAsync(new ItemGetRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
AccessToken = response.AccessToken
});
institutionId = itemResponse.Item.InstitutionId;
if (institutionId != null)
{
var instResponse = await _client.InstitutionsGetByIdAsync(
new Going.Plaid.Institutions.InstitutionsGetByIdRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
InstitutionId = institutionId,
CountryCodes = new[] { CountryCode.Us }
});
institutionName = instResponse.Institution.Name;
}
}
catch
{
// Non-critical; we can proceed without institution name
}
return new PlaidExchangeResult
{
AccessToken = response.AccessToken,
ItemId = response.ItemId,
InstitutionId = institutionId,
InstitutionName = institutionName
};
}
public async Task<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration)
{
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var response = await _client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
AccessToken = accessToken
});
return response.Accounts.Select(a => new SyncAccount
{
ExternalId = a.AccountId,
Name = a.Name ?? a.OfficialName ?? "Unknown",
AccountType = MapAccountType(a.Type, a.Subtype),
Balance = (decimal?)(a.Balances?.Current ?? a.Balances?.Available),
Currency = a.Balances?.IsoCurrencyCode ?? "USD"
}).ToList();
}
public async Task<ParseResult> SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary<string, string> configuration)
{
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var allTransactions = new List<Going.Plaid.Entity.Transaction>();
var offset = 0;
const int batchSize = 500;
while (true)
{
var response = await _client.TransactionsGetAsync(new Going.Plaid.Transactions.TransactionsGetRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
AccessToken = accessToken,
StartDate = DateOnly.FromDateTime(startDate),
EndDate = DateOnly.FromDateTime(endDate),
Options = new TransactionsGetRequestOptions
{
AccountIds = new[] { accountId },
Count = batchSize,
Offset = offset
}
});
allTransactions.AddRange(response.Transactions);
offset += response.Transactions.Count;
if (offset >= response.TotalTransactions)
break;
}
var result = new ParseResult
{
Success = true,
Transactions = allTransactions.Select(t => new ParsedTransaction
{
Date = t.Date?.ToDateTime(TimeOnly.MinValue) ?? DateTime.UtcNow,
Amount = (decimal)(t.Amount ?? 0) * -1, // Plaid uses positive for debits
PayeeName = t.MerchantName ?? t.Name,
Memo = t.Name,
FitId = t.TransactionId,
TransactionType = (t.Amount ?? 0) < 0 ? "credit" : "debit"
}).ToList()
};
return result;
}
private static string MapAccountType(AccountType? type, AccountSubtype? subtype)
{
return type switch
{
AccountType.Depository => subtype switch
{
AccountSubtype.Checking => "Checking",
AccountSubtype.Savings => "Savings",
_ => "Checking"
},
AccountType.Credit => "CreditCard",
AccountType.Loan => subtype switch
{
AccountSubtype.Auto => "AutoLoan",
AccountSubtype.Mortgage => "Mortgage",
AccountSubtype.Student => "PersonalLoan",
_ => "PersonalLoan"
},
AccountType.Investment => "Brokerage",
_ => "Checking"
};
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Going.Plaid" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,106 @@
using System.Net.Http.Json;
using System.Text;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models;
using Purrse.Plugins.BankSync.Models;
namespace Purrse.Plugins.BankSync;
public class SimpleFinSyncProvider : IAccountSyncProvider
{
private readonly HttpClient _httpClient;
public string Id => "simplefin-sync";
public string Name => "SimpleFIN Bank Sync";
public string Version => "1.0.0";
public string Description => "Sync transactions from banks via SimpleFIN Bridge";
public SimpleFinSyncProvider(HttpClient httpClient)
{
_httpClient = httpClient;
}
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
public Task ShutdownAsync() => Task.CompletedTask;
public async Task<string> ExchangeSetupTokenAsync(string setupToken)
{
// Decode base64 setup token to get the claim URL
var claimUrl = Encoding.UTF8.GetString(Convert.FromBase64String(setupToken));
var response = await _httpClient.PostAsync(claimUrl, null);
response.EnsureSuccessStatusCode();
var accessUrl = await response.Content.ReadAsStringAsync();
return accessUrl.Trim();
}
public async Task<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration)
{
var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty;
var response = await _httpClient.GetFromJsonAsync<SimpleFinAccountSet>($"{accessUrl}/accounts");
if (response == null)
return new List<SyncAccount>();
return response.Accounts.Select(a => new SyncAccount
{
ExternalId = a.Id,
Name = a.Name,
Institution = a.Org?.Name,
AccountType = GuessAccountType(a.Name),
Balance = decimal.TryParse(a.Balance, out var bal) ? bal : null,
Currency = a.Currency
}).ToList();
}
public async Task<ParseResult> SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary<string, string> configuration)
{
var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty;
var startUnix = new DateTimeOffset(startDate).ToUnixTimeSeconds();
var endUnix = new DateTimeOffset(endDate).ToUnixTimeSeconds();
var response = await _httpClient.GetFromJsonAsync<SimpleFinAccountSet>(
$"{accessUrl}/accounts?start-date={startUnix}&end-date={endUnix}");
if (response == null)
return new ParseResult { Success = false, ErrorMessage = "No response from SimpleFIN" };
var account = response.Accounts.FirstOrDefault(a => a.Id == accountId);
if (account == null)
return new ParseResult { Success = true, Transactions = new List<ParsedTransaction>() };
var result = new ParseResult
{
Success = true,
LedgerBalance = decimal.TryParse(account.Balance, out var bal) ? bal : null,
Transactions = account.Transactions
.Where(t => !t.Pending)
.Select(t => new ParsedTransaction
{
Date = DateTimeOffset.FromUnixTimeSeconds(t.Posted).UtcDateTime,
Amount = decimal.TryParse(t.Amount, out var amt) ? amt : 0,
PayeeName = t.Payee ?? t.Description,
Memo = t.Memo ?? t.Description,
FitId = t.Id,
TransactionType = decimal.TryParse(t.Amount, out var a) && a >= 0 ? "credit" : "debit"
}).ToList()
};
return result;
}
private static string GuessAccountType(string name)
{
var lower = name.ToLowerInvariant();
if (lower.Contains("checking")) return "Checking";
if (lower.Contains("savings")) return "Savings";
if (lower.Contains("credit")) return "CreditCard";
if (lower.Contains("loan")) return "PersonalLoan";
if (lower.Contains("mortgage")) return "Mortgage";
if (lower.Contains("401k") || lower.Contains("retirement")) return "Retirement401k";
if (lower.Contains("ira")) return "IRA";
if (lower.Contains("brokerage") || lower.Contains("invest")) return "Brokerage";
return "Checking";
}
}