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:
+15
@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Plugins.QIF", "src\P
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Purrse.Tests", "src\Purrse.Tests\Purrse.Tests.csproj", "{8A090385-C08B-4BCB-A1C9-61C2D23E9C49}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -140,5 +154,6 @@ Global
|
||||
{C93480E6-AEF4-4665-9F8C-032F36DC6AD3} = {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}
|
||||
{19EC4D6B-54CF-414C-B7C8-569C0A488F8A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -75,10 +75,18 @@
|
||||
:class="{ 'bg-blue-lighten-5': !n.read }"
|
||||
@click="handleNotificationClick(n)"
|
||||
>
|
||||
<v-list-item-title>{{ n.data.fileName }}</v-list-item-title>
|
||||
<template v-if="n.type === 'FileImportReady'">
|
||||
<v-list-item-title>{{ (n.data as any).fileName }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ n.data.newTransactions }} new, {{ n.data.possibleDuplicates }} duplicates
|
||||
{{ (n.data as any).newTransactions }} new, {{ (n.data as any).possibleDuplicates }} duplicates
|
||||
</v-list-item-subtitle>
|
||||
</template>
|
||||
<template v-else-if="n.type === 'BankSyncComplete'">
|
||||
<v-list-item-title>{{ (n.data as any).institutionName }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ (n.data as any).transactionsImported }} imported, {{ (n.data as any).transactionsSkipped }} skipped
|
||||
</v-list-item-subtitle>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
@@ -94,6 +102,7 @@
|
||||
<v-list density="compact">
|
||||
<v-list-item prepend-icon="mdi-account" :title="authStore.username || 'User'" />
|
||||
<v-divider />
|
||||
<v-list-item prepend-icon="mdi-cog" title="Settings" @click="router.push('/settings')" />
|
||||
<v-list-item prepend-icon="mdi-logout" title="Logout" @click="handleLogout" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
@@ -140,6 +149,7 @@ const navItems = [
|
||||
{ title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' },
|
||||
{ title: 'Investments', icon: 'mdi-finance', route: '/investments' },
|
||||
{ title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' },
|
||||
{ title: 'Settings', icon: 'mdi-cog', route: '/settings' },
|
||||
]
|
||||
|
||||
const currentPageTitle = computed(() => {
|
||||
@@ -157,8 +167,12 @@ function toggleTheme() {
|
||||
|
||||
function handleNotificationClick(n: AppNotification) {
|
||||
markAsRead(n.id)
|
||||
if (n.type === 'BankSyncComplete') {
|
||||
router.push('/settings')
|
||||
} else {
|
||||
router.push('/imports')
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { AppNotification, FileImportNotification } from '@/types'
|
||||
import type { AppNotification, FileImportNotification, BankSyncNotification } from '@/types'
|
||||
|
||||
const notifications = ref<AppNotification[]>([])
|
||||
const isConnected = ref(false)
|
||||
@@ -32,6 +32,16 @@ function connect() {
|
||||
})
|
||||
})
|
||||
|
||||
connection.on('BankSyncComplete', (data: BankSyncNotification) => {
|
||||
notifications.value.unshift({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'BankSyncComplete',
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: false
|
||||
})
|
||||
})
|
||||
|
||||
connection.onclose(() => {
|
||||
isConnected.value = false
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ const routes = [
|
||||
{ path: '/reports', name: 'reports', component: () => import('@/views/reports/ReportsView.vue'), meta: { auth: true } },
|
||||
{ path: '/investments', name: 'investments', component: () => import('@/views/investments/InvestmentsView.vue'), meta: { auth: true } },
|
||||
{ path: '/plugins', name: 'plugins', component: () => import('@/views/plugins/PluginsView.vue'), meta: { auth: true } },
|
||||
{ path: '/settings', name: 'settings', component: () => import('@/views/settings/SettingsView.vue'), meta: { auth: true } },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import api from './api'
|
||||
import type {
|
||||
SyncConnection,
|
||||
LinkedAccountInfo,
|
||||
DiscoveredAccount,
|
||||
SyncResult,
|
||||
SyncLogEntry,
|
||||
CreateLinkTokenResponse
|
||||
} from '@/types'
|
||||
|
||||
export const bankSyncApi = {
|
||||
getConnections: () =>
|
||||
api.get<SyncConnection[]>('/bank-sync/connections'),
|
||||
|
||||
getConnection: (id: string) =>
|
||||
api.get<SyncConnection>(`/bank-sync/connections/${id}`),
|
||||
|
||||
updateConnection: (id: string, data: { isActive: boolean; syncIntervalMinutes: number }) =>
|
||||
api.put<SyncConnection>(`/bank-sync/connections/${id}`, data),
|
||||
|
||||
deleteConnection: (id: string) =>
|
||||
api.delete(`/bank-sync/connections/${id}`),
|
||||
|
||||
createLinkToken: () =>
|
||||
api.post<CreateLinkTokenResponse>('/bank-sync/plaid/create-link-token'),
|
||||
|
||||
completePlaidLink: (publicToken: string) =>
|
||||
api.post<SyncConnection>('/bank-sync/plaid/complete-link', { publicToken }),
|
||||
|
||||
connectSimpleFin: (setupToken: string) =>
|
||||
api.post<SyncConnection>('/bank-sync/simplefin/connect', { setupToken }),
|
||||
|
||||
discoverAccounts: (connectionId: string) =>
|
||||
api.get<DiscoveredAccount[]>(`/bank-sync/connections/${connectionId}/discover-accounts`),
|
||||
|
||||
linkAccount: (connectionId: string, data: { linkedAccountId: string; accountId: string | null; autoCreate: boolean }) =>
|
||||
api.post<LinkedAccountInfo>(`/bank-sync/connections/${connectionId}/link-account`, data),
|
||||
|
||||
updateLinkedAccount: (linkedAccountId: string, data: { accountId: string | null; isEnabled: boolean }) =>
|
||||
api.put<LinkedAccountInfo>(`/bank-sync/linked-accounts/${linkedAccountId}`, data),
|
||||
|
||||
syncNow: (connectionId: string) =>
|
||||
api.post<SyncResult>(`/bank-sync/connections/${connectionId}/sync-now`),
|
||||
|
||||
syncAll: () =>
|
||||
api.post<SyncResult>('/bank-sync/sync-all'),
|
||||
|
||||
getSyncLogs: (connectionId?: string, limit = 50) =>
|
||||
api.get<SyncLogEntry[]>('/bank-sync/sync-logs', {
|
||||
params: { connectionId, limit }
|
||||
}),
|
||||
}
|
||||
@@ -387,14 +387,99 @@ export interface FileImportNotification {
|
||||
possibleDuplicates: number
|
||||
}
|
||||
|
||||
export interface BankSyncNotification {
|
||||
connectionId: string
|
||||
institutionName: string
|
||||
transactionsImported: number
|
||||
transactionsSkipped: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
type: 'FileImportReady'
|
||||
data: FileImportNotification
|
||||
type: 'FileImportReady' | 'BankSyncComplete'
|
||||
data: FileImportNotification | BankSyncNotification
|
||||
timestamp: string
|
||||
read: boolean
|
||||
}
|
||||
|
||||
// Bank Sync
|
||||
export type SyncProvider = 'Plaid' | 'SimpleFIN'
|
||||
|
||||
export interface SyncConnection {
|
||||
id: string
|
||||
provider: SyncProvider
|
||||
institutionName: string
|
||||
institutionId: string | null
|
||||
isActive: boolean
|
||||
syncIntervalMinutes: number
|
||||
lastSyncAt: string | null
|
||||
lastSyncError: string | null
|
||||
createdAt: string
|
||||
linkedAccounts: LinkedAccountInfo[]
|
||||
}
|
||||
|
||||
export interface LinkedAccountInfo {
|
||||
id: string
|
||||
externalAccountId: string
|
||||
externalAccountName: string
|
||||
externalAccountType: string | null
|
||||
externalAccountMask: string | null
|
||||
lastKnownBalance: number | null
|
||||
isEnabled: boolean
|
||||
accountId: string | null
|
||||
accountName: string | null
|
||||
lastSyncAt: string | null
|
||||
}
|
||||
|
||||
export interface DiscoveredAccount {
|
||||
externalAccountId: string
|
||||
name: string
|
||||
type: string | null
|
||||
mask: string | null
|
||||
balance: number | null
|
||||
currency: string | null
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
totalAccounts: number
|
||||
accountsSynced: number
|
||||
transactionsFetched: number
|
||||
transactionsImported: number
|
||||
transactionsSkipped: number
|
||||
accountResults: SyncAccountResult[]
|
||||
}
|
||||
|
||||
export interface SyncAccountResult {
|
||||
externalAccountName: string
|
||||
purrseAccountName: string | null
|
||||
fetched: number
|
||||
imported: number
|
||||
skipped: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface SyncLogEntry {
|
||||
id: string
|
||||
connectionId: string
|
||||
institutionName: string
|
||||
linkedAccountId: string | null
|
||||
linkedAccountName: string | null
|
||||
status: 'Success' | 'PartialSuccess' | 'Failed' | 'Running'
|
||||
transactionsFetched: number
|
||||
transactionsImported: number
|
||||
transactionsSkipped: number
|
||||
errorMessage: string | null
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
duration: string | null
|
||||
}
|
||||
|
||||
export interface CreateLinkTokenResponse {
|
||||
linkToken: string
|
||||
expiration: string
|
||||
}
|
||||
|
||||
// Split form helper
|
||||
export interface SplitFormItem {
|
||||
categoryId: string | null
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<v-tabs v-model="activeTab" color="primary">
|
||||
<v-tab value="profile">Profile</v-tab>
|
||||
<v-tab value="connections">Bank Connections</v-tab>
|
||||
<v-tab value="history">Sync History</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-tabs-window v-model="activeTab" class="mt-4">
|
||||
<!-- Tab 1: Profile -->
|
||||
<v-tabs-window-item value="profile">
|
||||
<v-card>
|
||||
<v-card-title>Profile</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
:model-value="authStore.username"
|
||||
label="Username"
|
||||
readonly
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- Tab 2: Bank Connections -->
|
||||
<v-tabs-window-item value="connections">
|
||||
<div class="d-flex justify-end mb-4 ga-2">
|
||||
<v-btn color="primary" prepend-icon="mdi-bank-plus" @click="startPlaidLink" :loading="plaidLoading">
|
||||
Connect via Plaid
|
||||
</v-btn>
|
||||
<v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true">
|
||||
Connect via SimpleFIN
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="connections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
|
||||
No bank connections yet. Click a button above to connect your bank.
|
||||
</v-alert>
|
||||
|
||||
<v-card v-for="conn in connections" :key="conn.id" class="mb-4">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">{{ conn.provider === 'Plaid' ? 'mdi-bank' : 'mdi-link-variant' }}</v-icon>
|
||||
{{ conn.institutionName }}
|
||||
<v-chip :color="conn.provider === 'Plaid' ? 'blue' : 'green'" size="small" class="ml-2">
|
||||
{{ conn.provider }}
|
||||
</v-chip>
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
:color="conn.isActive ? 'success' : 'grey'"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ conn.isActive ? 'Active' : 'Inactive' }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-2">
|
||||
Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }}
|
||||
<span v-if="conn.lastSyncError" class="text-error ml-2">{{ conn.lastSyncError }}</span>
|
||||
</div>
|
||||
|
||||
<v-table density="compact" v-if="conn.linkedAccounts.length > 0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>External Account</th>
|
||||
<th>Type</th>
|
||||
<th>Balance</th>
|
||||
<th>Purrse Account</th>
|
||||
<th>Enabled</th>
|
||||
<th>Last Synced</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="la in conn.linkedAccounts" :key="la.id">
|
||||
<td>{{ la.externalAccountName }}</td>
|
||||
<td>{{ la.externalAccountType }}</td>
|
||||
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
|
||||
<td>
|
||||
<v-select
|
||||
:model-value="la.accountId"
|
||||
:items="accountOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
placeholder="Select account..."
|
||||
@update:model-value="(val: string | null) => updateLinkedAccount(la, val)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<v-switch
|
||||
:model-value="la.isEnabled"
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
@update:model-value="(val: boolean | null) => toggleLinkedAccount(la, val ?? false)"
|
||||
/>
|
||||
</td>
|
||||
<td class="text-body-2">{{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<div class="d-flex align-center mt-3 ga-2">
|
||||
<v-select
|
||||
v-model="syncIntervals[conn.id]"
|
||||
:items="intervalOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
label="Sync Interval"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
style="max-width: 200px"
|
||||
@update:model-value="() => updateConnectionInterval(conn)"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-sync"
|
||||
:loading="syncingConnections[conn.id]"
|
||||
@click="syncNow(conn)"
|
||||
>
|
||||
Sync Now
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
:color="conn.isActive ? 'warning' : 'success'"
|
||||
@click="toggleConnection(conn)"
|
||||
>
|
||||
{{ conn.isActive ? 'Disable' : 'Enable' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
@click="confirmDeleteConnection(conn)"
|
||||
>
|
||||
Delete
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Account Mapping Dialog -->
|
||||
<v-dialog v-model="showMappingDialog" max-width="700" persistent>
|
||||
<v-card>
|
||||
<v-card-title>Map Bank Accounts</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-4">Map your external bank accounts to Purrse accounts, or auto-create new ones.</p>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>External Account</th>
|
||||
<th>Type</th>
|
||||
<th>Balance</th>
|
||||
<th>Purrse Account</th>
|
||||
<th>Auto-create</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="la in newLinkedAccounts" :key="la.id">
|
||||
<td>{{ la.externalAccountName }}</td>
|
||||
<td>{{ la.externalAccountType }}</td>
|
||||
<td>{{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }}</td>
|
||||
<td>
|
||||
<v-select
|
||||
v-model="mappingSelections[la.id]"
|
||||
:items="accountOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
:disabled="autoCreateSelections[la.id]"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<v-checkbox
|
||||
v-model="autoCreateSelections[la.id]"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showMappingDialog = false">Skip</v-btn>
|
||||
<v-btn color="primary" @click="saveMappings" :loading="mappingSaving">Save Mappings</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- SimpleFIN Dialog -->
|
||||
<v-dialog v-model="showSimpleFinDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Connect via SimpleFIN</v-card-title>
|
||||
<v-card-text>
|
||||
<p class="mb-4">
|
||||
Visit <strong>simplefin.org/bridge</strong> to get a setup token for your bank, then paste it below.
|
||||
</p>
|
||||
<v-text-field
|
||||
v-model="simpleFinToken"
|
||||
label="Setup Token"
|
||||
variant="outlined"
|
||||
placeholder="Paste your SimpleFIN setup token..."
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showSimpleFinDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" @click="connectSimpleFin" :loading="simpleFinLoading" :disabled="!simpleFinToken">
|
||||
Connect
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title>Delete Connection</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete the connection to <strong>{{ deleteTarget?.institutionName }}</strong>?
|
||||
This will remove all linked account mappings and sync history.
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDeleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="error" @click="deleteConnection" :loading="deleteLoading">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Sync Results Snackbar -->
|
||||
<v-snackbar v-model="showSyncResult" :timeout="5000" color="success">
|
||||
{{ syncResultMessage }}
|
||||
</v-snackbar>
|
||||
</v-tabs-window-item>
|
||||
|
||||
<!-- Tab 3: Sync History -->
|
||||
<v-tabs-window-item value="history">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
Sync History
|
||||
<v-spacer />
|
||||
<v-select
|
||||
v-model="logFilterConnectionId"
|
||||
:items="connectionFilterOptions"
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
label="Filter by connection"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
style="max-width: 300px"
|
||||
@update:model-value="loadSyncLogs"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-data-table
|
||||
:headers="logHeaders"
|
||||
:items="syncLogs"
|
||||
:loading="logsLoading"
|
||||
density="compact"
|
||||
items-per-page="25"
|
||||
>
|
||||
<template v-slot:item.startedAt="{ item }">
|
||||
{{ new Date(item.startedAt).toLocaleString() }}
|
||||
</template>
|
||||
<template v-slot:item.status="{ item }">
|
||||
<v-chip
|
||||
:color="statusColor(item.status)"
|
||||
size="small"
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.duration="{ item }">
|
||||
{{ item.duration || '-' }}
|
||||
</template>
|
||||
<template v-slot:item.errorMessage="{ item }">
|
||||
<span v-if="item.errorMessage" class="text-error text-body-2">{{ item.errorMessage }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { bankSyncApi } from '@/services/bankSync'
|
||||
import { accountsApi } from '@/services/accounts'
|
||||
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activeTab = ref('connections')
|
||||
const loading = ref(false)
|
||||
const connections = ref<SyncConnection[]>([])
|
||||
const accounts = ref<Account[]>([])
|
||||
const syncIntervals = reactive<Record<string, number>>({})
|
||||
const syncingConnections = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Plaid
|
||||
const plaidLoading = ref(false)
|
||||
|
||||
// SimpleFIN
|
||||
const showSimpleFinDialog = ref(false)
|
||||
const simpleFinToken = ref('')
|
||||
const simpleFinLoading = ref(false)
|
||||
|
||||
// Mapping dialog
|
||||
const showMappingDialog = ref(false)
|
||||
const mappingSaving = ref(false)
|
||||
const newConnectionId = ref('')
|
||||
const newLinkedAccounts = ref<LinkedAccountInfo[]>([])
|
||||
const mappingSelections = reactive<Record<string, string | null>>({})
|
||||
const autoCreateSelections = reactive<Record<string, boolean>>({})
|
||||
|
||||
// Delete
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<SyncConnection | null>(null)
|
||||
const deleteLoading = ref(false)
|
||||
|
||||
// Sync result
|
||||
const showSyncResult = ref(false)
|
||||
const syncResultMessage = ref('')
|
||||
|
||||
// Sync logs
|
||||
const syncLogs = ref<SyncLogEntry[]>([])
|
||||
const logsLoading = ref(false)
|
||||
const logFilterConnectionId = ref<string | null>(null)
|
||||
|
||||
const accountOptions = computed(() =>
|
||||
accounts.value
|
||||
.filter(a => a.isActive && !a.isClosed)
|
||||
.map(a => ({ text: `${a.name} (${a.type})`, value: a.id }))
|
||||
)
|
||||
|
||||
const connectionFilterOptions = computed(() =>
|
||||
connections.value.map(c => ({ text: c.institutionName, value: c.id }))
|
||||
)
|
||||
|
||||
const intervalOptions = [
|
||||
{ text: '1 hour', value: 60 },
|
||||
{ text: '3 hours', value: 180 },
|
||||
{ text: '6 hours', value: 360 },
|
||||
{ text: '12 hours', value: 720 },
|
||||
{ text: '24 hours', value: 1440 },
|
||||
]
|
||||
|
||||
const logHeaders = [
|
||||
{ title: 'Time', key: 'startedAt', sortable: true },
|
||||
{ title: 'Institution', key: 'institutionName', sortable: true },
|
||||
{ title: 'Account', key: 'linkedAccountName', sortable: false },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: 'Imported', key: 'transactionsImported', sortable: false },
|
||||
{ title: 'Skipped', key: 'transactionsSkipped', sortable: false },
|
||||
{ title: 'Duration', key: 'duration', sortable: false },
|
||||
{ title: 'Error', key: 'errorMessage', sortable: false },
|
||||
]
|
||||
|
||||
function statusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'Success': return 'success'
|
||||
case 'PartialSuccess': return 'warning'
|
||||
case 'Failed': return 'error'
|
||||
case 'Running': return 'info'
|
||||
default: return 'grey'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
|
||||
}
|
||||
|
||||
async function loadConnections() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await bankSyncApi.getConnections()
|
||||
connections.value = data
|
||||
data.forEach(c => { syncIntervals[c.id] = c.syncIntervalMinutes })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const { data } = await accountsApi.getAll()
|
||||
accounts.value = data
|
||||
}
|
||||
|
||||
async function loadSyncLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const { data } = await bankSyncApi.getSyncLogs(logFilterConnectionId.value || undefined)
|
||||
syncLogs.value = data
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startPlaidLink() {
|
||||
plaidLoading.value = true
|
||||
try {
|
||||
const { data: linkData } = await bankSyncApi.createLinkToken()
|
||||
|
||||
// Dynamically load Plaid Link script
|
||||
await loadPlaidScript()
|
||||
|
||||
const plaid = (window as any).Plaid.create({
|
||||
token: linkData.linkToken,
|
||||
onSuccess: async (publicToken: string) => {
|
||||
try {
|
||||
const { data: conn } = await bankSyncApi.completePlaidLink(publicToken)
|
||||
connections.value.unshift(conn)
|
||||
syncIntervals[conn.id] = conn.syncIntervalMinutes
|
||||
openMappingDialog(conn)
|
||||
} catch (err) {
|
||||
console.error('Failed to complete Plaid link:', err)
|
||||
}
|
||||
},
|
||||
onExit: () => {
|
||||
plaidLoading.value = false
|
||||
}
|
||||
})
|
||||
plaid.open()
|
||||
} catch (err) {
|
||||
console.error('Failed to create link token:', err)
|
||||
plaidLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadPlaidScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ((window as any).Plaid) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('Failed to load Plaid Link script'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
async function connectSimpleFin() {
|
||||
simpleFinLoading.value = true
|
||||
try {
|
||||
const { data: conn } = await bankSyncApi.connectSimpleFin(simpleFinToken.value)
|
||||
connections.value.unshift(conn)
|
||||
syncIntervals[conn.id] = conn.syncIntervalMinutes
|
||||
showSimpleFinDialog.value = false
|
||||
simpleFinToken.value = ''
|
||||
openMappingDialog(conn)
|
||||
} catch (err) {
|
||||
console.error('Failed to connect SimpleFIN:', err)
|
||||
} finally {
|
||||
simpleFinLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openMappingDialog(conn: SyncConnection) {
|
||||
newConnectionId.value = conn.id
|
||||
newLinkedAccounts.value = conn.linkedAccounts
|
||||
conn.linkedAccounts.forEach(la => {
|
||||
mappingSelections[la.id] = null
|
||||
autoCreateSelections[la.id] = false
|
||||
})
|
||||
showMappingDialog.value = true
|
||||
}
|
||||
|
||||
async function saveMappings() {
|
||||
mappingSaving.value = true
|
||||
try {
|
||||
for (const la of newLinkedAccounts.value) {
|
||||
const accountId = mappingSelections[la.id]
|
||||
const autoCreate = autoCreateSelections[la.id]
|
||||
if (accountId || autoCreate) {
|
||||
await bankSyncApi.linkAccount(newConnectionId.value, {
|
||||
linkedAccountId: la.id,
|
||||
accountId: autoCreate ? null : accountId,
|
||||
autoCreate
|
||||
})
|
||||
}
|
||||
}
|
||||
showMappingDialog.value = false
|
||||
await loadConnections()
|
||||
await loadAccounts()
|
||||
} finally {
|
||||
mappingSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLinkedAccount(la: LinkedAccountInfo, accountId: string | null) {
|
||||
await bankSyncApi.updateLinkedAccount(la.id, { accountId, isEnabled: la.isEnabled })
|
||||
await loadConnections()
|
||||
}
|
||||
|
||||
async function toggleLinkedAccount(la: LinkedAccountInfo, enabled: boolean) {
|
||||
await bankSyncApi.updateLinkedAccount(la.id, { accountId: la.accountId, isEnabled: enabled })
|
||||
await loadConnections()
|
||||
}
|
||||
|
||||
async function updateConnectionInterval(conn: SyncConnection) {
|
||||
await bankSyncApi.updateConnection(conn.id, {
|
||||
isActive: conn.isActive,
|
||||
syncIntervalMinutes: syncIntervals[conn.id]
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleConnection(conn: SyncConnection) {
|
||||
await bankSyncApi.updateConnection(conn.id, {
|
||||
isActive: !conn.isActive,
|
||||
syncIntervalMinutes: conn.syncIntervalMinutes
|
||||
})
|
||||
await loadConnections()
|
||||
}
|
||||
|
||||
async function syncNow(conn: SyncConnection) {
|
||||
syncingConnections[conn.id] = true
|
||||
try {
|
||||
const { data } = await bankSyncApi.syncNow(conn.id)
|
||||
syncResultMessage.value = `Synced ${conn.institutionName}: ${data.transactionsImported} imported, ${data.transactionsSkipped} skipped`
|
||||
showSyncResult.value = true
|
||||
await loadConnections()
|
||||
} catch (err) {
|
||||
console.error('Sync failed:', err)
|
||||
} finally {
|
||||
syncingConnections[conn.id] = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteConnection(conn: SyncConnection) {
|
||||
deleteTarget.value = conn
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function deleteConnection() {
|
||||
if (!deleteTarget.value) return
|
||||
deleteLoading.value = true
|
||||
try {
|
||||
await bankSyncApi.deleteConnection(deleteTarget.value.id)
|
||||
connections.value = connections.value.filter(c => c.id !== deleteTarget.value!.id)
|
||||
showDeleteDialog.value = false
|
||||
} finally {
|
||||
deleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadConnections(), loadAccounts(), loadSyncLogs()])
|
||||
})
|
||||
</script>
|
||||
@@ -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,6 +8,7 @@ using Purrse.Api.Services;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using Purrse.Data;
|
||||
using Purrse.Plugins.Abstractions;
|
||||
using Purrse.Plugins.BankSync;
|
||||
using Purrse.Plugins.CSV;
|
||||
using Purrse.Plugins.OFX;
|
||||
using Purrse.Plugins.QIF;
|
||||
@@ -76,18 +77,25 @@ builder.Services.AddScoped<ILoanService, LoanService>();
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
builder.Services.AddScoped<IReportService, ReportService>();
|
||||
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
|
||||
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
||||
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
||||
|
||||
// Built-in file parsers
|
||||
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
|
||||
builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
|
||||
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
|
||||
|
||||
// Bank sync providers
|
||||
builder.Services.AddSingleton<PlaidSyncProvider>();
|
||||
builder.Services.AddHttpClient<SimpleFinSyncProvider>();
|
||||
|
||||
// Plugin system
|
||||
builder.Services.AddSingleton<PluginService>();
|
||||
|
||||
// Background services
|
||||
builder.Services.AddHostedService<ScheduledTransactionProcessor>();
|
||||
builder.Services.AddHostedService<FileWatcherService>();
|
||||
builder.Services.AddHostedService<BankSyncBackgroundService>();
|
||||
|
||||
// SignalR
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<ProjectReference Include="..\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj" />
|
||||
<ProjectReference Include="..\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj" />
|
||||
<ProjectReference Include="..\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj" />
|
||||
<ProjectReference Include="..\Purrse.Plugins.BankSync\Purrse.Plugins.BankSync.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,16 @@
|
||||
"Imports": {
|
||||
"WatchPath": "imports"
|
||||
},
|
||||
"BankSync": {
|
||||
"EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!",
|
||||
"Plaid": {
|
||||
"ClientId": "",
|
||||
"Secret": "",
|
||||
"Environment": "sandbox"
|
||||
},
|
||||
"DefaultSyncIntervalMinutes": 360,
|
||||
"BackgroundCheckIntervalMinutes": 15
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Purrse.Core.Enums;
|
||||
|
||||
public enum SyncProvider
|
||||
{
|
||||
Plaid,
|
||||
SimpleFIN
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -25,4 +25,5 @@ public class Account
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<InvestmentHolding> InvestmentHoldings { get; set; } = new List<InvestmentHolding>();
|
||||
public ICollection<Reconciliation> Reconciliations { get; set; } = new List<Reconciliation>();
|
||||
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -12,4 +12,5 @@ public class User
|
||||
public DateTime? RefreshTokenExpiresAt { get; set; }
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -627,6 +680,111 @@ namespace Purrse.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -902,6 +1060,24 @@ namespace Purrse.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Purrse.Core.Models.Account", "Account")
|
||||
@@ -1008,6 +1184,35 @@ namespace Purrse.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Purrse.Core.Models.Account", "Account")
|
||||
@@ -1076,6 +1281,8 @@ namespace Purrse.Data.Migrations
|
||||
{
|
||||
b.Navigation("InvestmentHoldings");
|
||||
|
||||
b.Navigation("LinkedAccounts");
|
||||
|
||||
b.Navigation("LoanDetail");
|
||||
|
||||
b.Navigation("Reconciliations");
|
||||
@@ -1131,6 +1338,13 @@ namespace Purrse.Data.Migrations
|
||||
b.Navigation("Prices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
|
||||
{
|
||||
b.Navigation("LinkedAccounts");
|
||||
|
||||
b.Navigation("SyncLogs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
|
||||
{
|
||||
b.Navigation("Splits");
|
||||
@@ -1139,6 +1353,8 @@ namespace Purrse.Data.Migrations
|
||||
modelBuilder.Entity("Purrse.Core.Models.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("SyncConnections");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ public class PurrseDbContext : DbContext
|
||||
public DbSet<Reconciliation> Reconciliations => Set<Reconciliation>();
|
||||
public DbSet<PluginRegistration> PluginRegistrations => Set<PluginRegistration>();
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user