From 3e0446c9beebe8e308914f538bd784c8b73006b3 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Sun, 8 Feb 2026 15:08:23 -0500 Subject: [PATCH] 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 --- Purrse.sln | 15 + frontend/src/components/layout/AppLayout.vue | 24 +- frontend/src/composables/useNotifications.ts | 12 +- frontend/src/router/index.ts | 1 + frontend/src/services/bankSync.ts | 52 + frontend/src/types/index.ts | 89 +- frontend/src/views/settings/SettingsView.vue | 571 +++++++ .../Controllers/BankSyncController.cs | 73 + src/Purrse.Api/Program.cs | 8 + src/Purrse.Api/Purrse.Api.csproj | 1 + .../Services/BankSyncBackgroundService.cs | 75 + src/Purrse.Api/Services/BankSyncService.cs | 529 +++++++ src/Purrse.Api/Services/EncryptionService.cs | 54 + src/Purrse.Api/appsettings.json | 10 + src/Purrse.Core/DTOs/BankSyncDtos.cs | 82 + src/Purrse.Core/Enums/SyncProvider.cs | 7 + src/Purrse.Core/Enums/SyncStatus.cs | 9 + .../Interfaces/Services/IBankSyncService.cs | 31 + .../Interfaces/Services/IEncryptionService.cs | 7 + src/Purrse.Core/Models/Account.cs | 1 + src/Purrse.Core/Models/LinkedAccount.cs | 19 + src/Purrse.Core/Models/SyncConnection.cs | 24 + src/Purrse.Core/Models/SyncLog.cs | 21 + src/Purrse.Core/Models/User.cs | 1 + .../SyncConnectionConfiguration.cs | 77 + .../20260208200329_AddBankSync.Designer.cs | 1365 +++++++++++++++++ .../Migrations/20260208200329_AddBankSync.cs | 159 ++ .../PurrseDbContextModelSnapshot.cs | 216 +++ src/Purrse.Data/PurrseDbContext.cs | 3 + .../Models/PlaidModels.cs | 22 + .../Models/SimpleFinModels.cs | 78 + .../PlaidSyncProvider.cs | 205 +++ .../Purrse.Plugins.BankSync.csproj | 17 + .../SimpleFinSyncProvider.cs | 106 ++ 34 files changed, 3956 insertions(+), 8 deletions(-) create mode 100644 frontend/src/services/bankSync.ts create mode 100644 frontend/src/views/settings/SettingsView.vue create mode 100644 src/Purrse.Api/Controllers/BankSyncController.cs create mode 100644 src/Purrse.Api/Services/BankSyncBackgroundService.cs create mode 100644 src/Purrse.Api/Services/BankSyncService.cs create mode 100644 src/Purrse.Api/Services/EncryptionService.cs create mode 100644 src/Purrse.Core/DTOs/BankSyncDtos.cs create mode 100644 src/Purrse.Core/Enums/SyncProvider.cs create mode 100644 src/Purrse.Core/Enums/SyncStatus.cs create mode 100644 src/Purrse.Core/Interfaces/Services/IBankSyncService.cs create mode 100644 src/Purrse.Core/Interfaces/Services/IEncryptionService.cs create mode 100644 src/Purrse.Core/Models/LinkedAccount.cs create mode 100644 src/Purrse.Core/Models/SyncConnection.cs create mode 100644 src/Purrse.Core/Models/SyncLog.cs create mode 100644 src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs create mode 100644 src/Purrse.Data/Migrations/20260208200329_AddBankSync.Designer.cs create mode 100644 src/Purrse.Data/Migrations/20260208200329_AddBankSync.cs create mode 100644 src/Purrse.Plugins.BankSync/Models/PlaidModels.cs create mode 100644 src/Purrse.Plugins.BankSync/Models/SimpleFinModels.cs create mode 100644 src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs create mode 100644 src/Purrse.Plugins.BankSync/Purrse.Plugins.BankSync.csproj create mode 100644 src/Purrse.Plugins.BankSync/SimpleFinSyncProvider.cs diff --git a/Purrse.sln b/Purrse.sln index feee7b1..c9c3f02 100644 --- a/Purrse.sln +++ b/Purrse.sln @@ -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 diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index 803dd97..e2239a4 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -75,10 +75,18 @@ :class="{ 'bg-blue-lighten-5': !n.read }" @click="handleNotificationClick(n)" > - {{ n.data.fileName }} - - {{ n.data.newTransactions }} new, {{ n.data.possibleDuplicates }} duplicates - + + @@ -94,6 +102,7 @@ + @@ -140,6 +149,7 @@ const navItems = [ { title: 'Reports', icon: 'mdi-chart-bar', route: '/reports' }, { title: 'Investments', icon: 'mdi-finance', route: '/investments' }, { title: 'Plugins', icon: 'mdi-puzzle', route: '/plugins' }, + { title: 'Settings', icon: 'mdi-cog', route: '/settings' }, ] const currentPageTitle = computed(() => { @@ -157,7 +167,11 @@ function toggleTheme() { function handleNotificationClick(n: AppNotification) { markAsRead(n.id) - router.push('/imports') + if (n.type === 'BankSyncComplete') { + router.push('/settings') + } else { + router.push('/imports') + } } function handleLogout() { diff --git a/frontend/src/composables/useNotifications.ts b/frontend/src/composables/useNotifications.ts index 4850693..4aa70fa 100644 --- a/frontend/src/composables/useNotifications.ts +++ b/frontend/src/composables/useNotifications.ts @@ -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([]) 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 }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index f9cda82..f2afd5a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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({ diff --git a/frontend/src/services/bankSync.ts b/frontend/src/services/bankSync.ts new file mode 100644 index 0000000..484550b --- /dev/null +++ b/frontend/src/services/bankSync.ts @@ -0,0 +1,52 @@ +import api from './api' +import type { + SyncConnection, + LinkedAccountInfo, + DiscoveredAccount, + SyncResult, + SyncLogEntry, + CreateLinkTokenResponse +} from '@/types' + +export const bankSyncApi = { + getConnections: () => + api.get('/bank-sync/connections'), + + getConnection: (id: string) => + api.get(`/bank-sync/connections/${id}`), + + updateConnection: (id: string, data: { isActive: boolean; syncIntervalMinutes: number }) => + api.put(`/bank-sync/connections/${id}`, data), + + deleteConnection: (id: string) => + api.delete(`/bank-sync/connections/${id}`), + + createLinkToken: () => + api.post('/bank-sync/plaid/create-link-token'), + + completePlaidLink: (publicToken: string) => + api.post('/bank-sync/plaid/complete-link', { publicToken }), + + connectSimpleFin: (setupToken: string) => + api.post('/bank-sync/simplefin/connect', { setupToken }), + + discoverAccounts: (connectionId: string) => + api.get(`/bank-sync/connections/${connectionId}/discover-accounts`), + + linkAccount: (connectionId: string, data: { linkedAccountId: string; accountId: string | null; autoCreate: boolean }) => + api.post(`/bank-sync/connections/${connectionId}/link-account`, data), + + updateLinkedAccount: (linkedAccountId: string, data: { accountId: string | null; isEnabled: boolean }) => + api.put(`/bank-sync/linked-accounts/${linkedAccountId}`, data), + + syncNow: (connectionId: string) => + api.post(`/bank-sync/connections/${connectionId}/sync-now`), + + syncAll: () => + api.post('/bank-sync/sync-all'), + + getSyncLogs: (connectionId?: string, limit = 50) => + api.get('/bank-sync/sync-logs', { + params: { connectionId, limit } + }), +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index dc6d2d8..6efe145 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue new file mode 100644 index 0000000..b5d42f1 --- /dev/null +++ b/frontend/src/views/settings/SettingsView.vue @@ -0,0 +1,571 @@ + + + diff --git a/src/Purrse.Api/Controllers/BankSyncController.cs b/src/Purrse.Api/Controllers/BankSyncController.cs new file mode 100644 index 0000000..989b516 --- /dev/null +++ b/src/Purrse.Api/Controllers/BankSyncController.cs @@ -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>> GetConnections() + => Ok(await _bankSyncService.GetConnectionsAsync(UserId)); + + [HttpGet("connections/{id}")] + public async Task> GetConnection(Guid id) + => Ok(await _bankSyncService.GetConnectionAsync(UserId, id)); + + [HttpPut("connections/{id}")] + public async Task> UpdateConnection(Guid id, UpdateConnectionRequest request) + => Ok(await _bankSyncService.UpdateConnectionAsync(UserId, id, request)); + + [HttpDelete("connections/{id}")] + public async Task DeleteConnection(Guid id) + { + await _bankSyncService.DeleteConnectionAsync(UserId, id); + return NoContent(); + } + + [HttpPost("plaid/create-link-token")] + public async Task> CreatePlaidLinkToken() + => Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId)); + + [HttpPost("plaid/complete-link")] + public async Task> CompletePlaidLink(CreatePlaidConnectionRequest request) + => Ok(await _bankSyncService.CompletePlaidLinkAsync(UserId, request)); + + [HttpPost("simplefin/connect")] + public async Task> ConnectSimpleFin(CreateSimpleFinConnectionRequest request) + => Ok(await _bankSyncService.CreateSimpleFinConnectionAsync(UserId, request)); + + [HttpGet("connections/{id}/discover-accounts")] + public async Task>> DiscoverAccounts(Guid id) + => Ok(await _bankSyncService.DiscoverAccountsAsync(UserId, id)); + + [HttpPost("connections/{id}/link-account")] + public async Task> LinkAccount(Guid id, LinkAccountRequest request) + => Ok(await _bankSyncService.LinkAccountAsync(UserId, id, request)); + + [HttpPut("linked-accounts/{id}")] + public async Task> UpdateLinkedAccount(Guid id, UpdateLinkedAccountRequest request) + => Ok(await _bankSyncService.UpdateLinkedAccountAsync(UserId, id, request)); + + [HttpPost("connections/{id}/sync-now")] + public async Task> SyncNow(Guid id) + => Ok(await _bankSyncService.SyncNowAsync(UserId, id)); + + [HttpPost("sync-all")] + public async Task> SyncAll() + => Ok(await _bankSyncService.SyncAllAsync(UserId)); + + [HttpGet("sync-logs")] + public async Task>> GetSyncLogs([FromQuery] Guid? connectionId, [FromQuery] int limit = 50) + => Ok(await _bankSyncService.GetSyncLogsAsync(UserId, connectionId, limit)); +} diff --git a/src/Purrse.Api/Program.cs b/src/Purrse.Api/Program.cs index 2f4b6cb..5ec87fa 100644 --- a/src/Purrse.Api/Program.cs +++ b/src/Purrse.Api/Program.cs @@ -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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Built-in file parsers builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +// Bank sync providers +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); + // Plugin system builder.Services.AddSingleton(); // Background services builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // SignalR builder.Services.AddSignalR(); diff --git a/src/Purrse.Api/Purrse.Api.csproj b/src/Purrse.Api/Purrse.Api.csproj index 060451b..f69ea5a 100644 --- a/src/Purrse.Api/Purrse.Api.csproj +++ b/src/Purrse.Api/Purrse.Api.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Purrse.Api/Services/BankSyncBackgroundService.cs b/src/Purrse.Api/Services/BankSyncBackgroundService.cs new file mode 100644 index 0000000..b7c4746 --- /dev/null +++ b/src/Purrse.Api/Services/BankSyncBackgroundService.cs @@ -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 _logger; + private readonly TimeSpan _checkInterval; + + public BankSyncBackgroundService(IServiceProvider services, IConfiguration configuration, ILogger 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(); + var bankSyncService = scope.ServiceProvider.GetRequiredService(); + + 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); + } + } + } +} diff --git a/src/Purrse.Api/Services/BankSyncService.cs b/src/Purrse.Api/Services/BankSyncService.cs new file mode 100644 index 0000000..1e2ddc1 --- /dev/null +++ b/src/Purrse.Api/Services/BankSyncService.cs @@ -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 _hub; + private readonly ILogger _logger; + + public BankSyncService( + PurrseDbContext db, + IEncryptionService encryption, + PlaidSyncProvider plaid, + SimpleFinSyncProvider simpleFin, + IDuplicateDetectionService duplicateService, + IPayeeService payeeService, + IHubContext hub, + ILogger logger) + { + _db = db; + _encryption = encryption; + _plaid = plaid; + _simpleFin = simpleFin; + _duplicateService = duplicateService; + _payeeService = payeeService; + _hub = hub; + _logger = logger; + } + + public async Task> 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 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 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 CreatePlaidLinkTokenAsync(Guid userId) + { + var result = await _plaid.CreateLinkTokenAsync(userId.ToString()); + return new CreateLinkTokenResponse(result.LinkToken, result.Expiration); + } + + public async Task 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 CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request) + { + var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken); + + // Get accounts to determine institution name + var config = new Dictionary { ["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> 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 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(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 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 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 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(); + 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> 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 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(); + 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 BuildProviderConfig(SyncConnection connection, string accessToken) + { + return connection.Provider == SyncProvider.Plaid + ? new Dictionary { ["AccessToken"] = accessToken } + : new Dictionary { ["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); + } +} diff --git a/src/Purrse.Api/Services/EncryptionService.cs b/src/Purrse.Api/Services/EncryptionService.cs new file mode 100644 index 0000000..dc7c68d --- /dev/null +++ b/src/Purrse.Api/Services/EncryptionService.cs @@ -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); + } +} diff --git a/src/Purrse.Api/appsettings.json b/src/Purrse.Api/appsettings.json index 51ba172..514cefb 100644 --- a/src/Purrse.Api/appsettings.json +++ b/src/Purrse.Api/appsettings.json @@ -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", diff --git a/src/Purrse.Core/DTOs/BankSyncDtos.cs b/src/Purrse.Core/DTOs/BankSyncDtos.cs new file mode 100644 index 0000000..94c4df6 --- /dev/null +++ b/src/Purrse.Core/DTOs/BankSyncDtos.cs @@ -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 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 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); diff --git a/src/Purrse.Core/Enums/SyncProvider.cs b/src/Purrse.Core/Enums/SyncProvider.cs new file mode 100644 index 0000000..6afbb6c --- /dev/null +++ b/src/Purrse.Core/Enums/SyncProvider.cs @@ -0,0 +1,7 @@ +namespace Purrse.Core.Enums; + +public enum SyncProvider +{ + Plaid, + SimpleFIN +} diff --git a/src/Purrse.Core/Enums/SyncStatus.cs b/src/Purrse.Core/Enums/SyncStatus.cs new file mode 100644 index 0000000..c7884a7 --- /dev/null +++ b/src/Purrse.Core/Enums/SyncStatus.cs @@ -0,0 +1,9 @@ +namespace Purrse.Core.Enums; + +public enum SyncStatus +{ + Success, + PartialSuccess, + Failed, + Running +} diff --git a/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs b/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs new file mode 100644 index 0000000..d85ee18 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IBankSyncService.cs @@ -0,0 +1,31 @@ +using Purrse.Core.DTOs; + +namespace Purrse.Core.Interfaces.Services; + +public interface IBankSyncService +{ + // Connection CRUD + Task> GetConnectionsAsync(Guid userId); + Task GetConnectionAsync(Guid userId, Guid connectionId); + Task UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request); + Task DeleteConnectionAsync(Guid userId, Guid connectionId); + + // Plaid flow + Task CreatePlaidLinkTokenAsync(Guid userId); + Task CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request); + + // SimpleFIN flow + Task CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request); + + // Account linking + Task> DiscoverAccountsAsync(Guid userId, Guid connectionId); + Task LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request); + Task UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request); + + // Sync + Task SyncNowAsync(Guid userId, Guid connectionId); + Task SyncAllAsync(Guid userId); + + // Logs + Task> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50); +} diff --git a/src/Purrse.Core/Interfaces/Services/IEncryptionService.cs b/src/Purrse.Core/Interfaces/Services/IEncryptionService.cs new file mode 100644 index 0000000..5bcc565 --- /dev/null +++ b/src/Purrse.Core/Interfaces/Services/IEncryptionService.cs @@ -0,0 +1,7 @@ +namespace Purrse.Core.Interfaces.Services; + +public interface IEncryptionService +{ + string Encrypt(string plainText); + string Decrypt(string cipherText); +} diff --git a/src/Purrse.Core/Models/Account.cs b/src/Purrse.Core/Models/Account.cs index 288005f..8067ce8 100644 --- a/src/Purrse.Core/Models/Account.cs +++ b/src/Purrse.Core/Models/Account.cs @@ -25,4 +25,5 @@ public class Account public ICollection Transactions { get; set; } = new List(); public ICollection InvestmentHoldings { get; set; } = new List(); public ICollection Reconciliations { get; set; } = new List(); + public ICollection LinkedAccounts { get; set; } = new List(); } diff --git a/src/Purrse.Core/Models/LinkedAccount.cs b/src/Purrse.Core/Models/LinkedAccount.cs new file mode 100644 index 0000000..a4f8c25 --- /dev/null +++ b/src/Purrse.Core/Models/LinkedAccount.cs @@ -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; } +} diff --git a/src/Purrse.Core/Models/SyncConnection.cs b/src/Purrse.Core/Models/SyncConnection.cs new file mode 100644 index 0000000..c6b5f5e --- /dev/null +++ b/src/Purrse.Core/Models/SyncConnection.cs @@ -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 LinkedAccounts { get; set; } = new List(); + public ICollection SyncLogs { get; set; } = new List(); +} diff --git a/src/Purrse.Core/Models/SyncLog.cs b/src/Purrse.Core/Models/SyncLog.cs new file mode 100644 index 0000000..d402bc8 --- /dev/null +++ b/src/Purrse.Core/Models/SyncLog.cs @@ -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; } +} diff --git a/src/Purrse.Core/Models/User.cs b/src/Purrse.Core/Models/User.cs index 769d87c..61baee9 100644 --- a/src/Purrse.Core/Models/User.cs +++ b/src/Purrse.Core/Models/User.cs @@ -12,4 +12,5 @@ public class User public DateTime? RefreshTokenExpiresAt { get; set; } public ICollection Accounts { get; set; } = new List(); + public ICollection SyncConnections { get; set; } = new List(); } diff --git a/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs b/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs new file mode 100644 index 0000000..aa71ca7 --- /dev/null +++ b/src/Purrse.Data/Configurations/SyncConnectionConfiguration.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Purrse.Core.Models; + +namespace Purrse.Data.Configurations; + +public class SyncConnectionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder 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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/src/Purrse.Data/Migrations/20260208200329_AddBankSync.Designer.cs b/src/Purrse.Data/Migrations/20260208200329_AddBankSync.Designer.cs new file mode 100644 index 0000000..f95767e --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208200329_AddBankSync.Designer.cs @@ -0,0 +1,1365 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Purrse.Data; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + [DbContext(typeof(PurrseDbContext))] + [Migration("20260208200329_AddBankSync")] + partial class AddBankSync + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Balance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditLimit") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Institution") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LoanDetailId") + .HasColumnType("uuid"); + + b.Property("PaymentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentNumber") + .HasColumnType("integer"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RemainingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("LoanDetailId"); + + b.ToTable("amortization_entries", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique(); + + b.ToTable("budgets", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("BudgetedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("budget_items", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("UserId", "Name", "ParentId") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DuplicateCount") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedCount") + .HasColumnType("integer"); + + b.Property("ParsedDataJson") + .HasColumnType("text"); + + b.Property("SkippedCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalCount") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("UserId"); + + b.ToTable("import_batches", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AsOfDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CostBasis") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.Property("Shares") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SecurityId"); + + b.ToTable("investment_holdings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalAccountId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountMask") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastKnownBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExtraPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("MaturityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MonthlyPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginalBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TermMonths") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("loan_details", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultCategoryId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCategoryId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("payees", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Alias"); + + b.HasIndex("PayeeId"); + + b.ToTable("payee_aliases", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginRegistrationId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("PluginRegistrationId", "Key") + .IsUnique(); + + b.ToTable("plugin_configurations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryAssembly") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PluginId") + .IsUnique(); + + b.ToTable("plugin_registrations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("StatementBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("StatementDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("reconciliations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AutoPost") + .HasColumnType("boolean"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NextDueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReminderDaysBefore") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("UserId"); + + b.ToTable("scheduled_transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol") + .IsUnique(); + + b.ToTable("securities", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SecurityId", "Date") + .IsUnique(); + + b.ToTable("security_prices", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstitutionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstitutionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PlaidItemId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LinkedAccountId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.Property("TransactionsFetched") + .HasColumnType("integer"); + + b.Property("TransactionsImported") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CheckNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FitId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ImportBatchId") + .HasColumnType("uuid"); + + b.Property("IsVoid") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciliationId") + .HasColumnType("uuid"); + + b.Property("ReferenceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TransferTransactionId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Date"); + + b.HasIndex("Fingerprint"); + + b.HasIndex("FitId"); + + b.HasIndex("ImportBatchId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("ReconciliationId"); + + b.HasIndex("TransferTransactionId") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("TransactionId"); + + b.ToTable("transaction_splits", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail") + .WithMany("AmortizationEntries") + .HasForeignKey("LoanDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoanDetail"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.HasOne("Purrse.Core.Models.Budget", "Budget") + .WithMany("Items") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("BudgetItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.HasOne("Purrse.Core.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("InvestmentHoldings") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Holdings") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + 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") + .WithOne("LoanDetail") + .HasForeignKey("Purrse.Core.Models.LoanDetail", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.HasOne("Purrse.Core.Models.Category", "DefaultCategory") + .WithMany() + .HasForeignKey("DefaultCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DefaultCategory"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Aliases") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payee"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.HasOne("Purrse.Core.Models.PluginRegistration", "PluginRegistration") + .WithMany("Configurations") + .HasForeignKey("PluginRegistrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PluginRegistration"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Reconciliations") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("Payee"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Prices") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.ImportBatch", "ImportBatch") + .WithMany("Transactions") + .HasForeignKey("ImportBatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Transactions") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Reconciliation", "Reconciliation") + .WithMany("Transactions") + .HasForeignKey("ReconciliationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "TransferTransaction") + .WithOne() + .HasForeignKey("Purrse.Core.Models.Transaction", "TransferTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ImportBatch"); + + b.Navigation("Payee"); + + b.Navigation("Reconciliation"); + + b.Navigation("TransferTransaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "Transaction") + .WithMany("Splits") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Navigation("InvestmentHoldings"); + + b.Navigation("LinkedAccounts"); + + b.Navigation("LoanDetail"); + + b.Navigation("Reconciliations"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Navigation("BudgetItems"); + + b.Navigation("Children"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Navigation("AmortizationEntries"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Navigation("Aliases"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Navigation("Holdings"); + + 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"); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("SyncConnections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Purrse.Data/Migrations/20260208200329_AddBankSync.cs b/src/Purrse.Data/Migrations/20260208200329_AddBankSync.cs new file mode 100644 index 0000000..119a5b1 --- /dev/null +++ b/src/Purrse.Data/Migrations/20260208200329_AddBankSync.cs @@ -0,0 +1,159 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + /// + public partial class AddBankSync : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "sync_connections", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Provider = table.Column(type: "integer", nullable: false), + InstitutionName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + InstitutionId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + EncryptedAccessToken = table.Column(type: "text", nullable: false), + PlaidItemId = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + SyncIntervalMinutes = table.Column(type: "integer", nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + LastSyncError = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(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(type: "uuid", nullable: false), + SyncConnectionId = table.Column(type: "uuid", nullable: false), + AccountId = table.Column(type: "uuid", nullable: true), + ExternalAccountId = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ExternalAccountName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ExternalAccountType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ExternalAccountMask = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + LastKnownBalance = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true), + IsEnabled = table.Column(type: "boolean", nullable: false), + LastSyncAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(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(type: "uuid", nullable: false), + SyncConnectionId = table.Column(type: "uuid", nullable: false), + LinkedAccountId = table.Column(type: "uuid", nullable: true), + Status = table.Column(type: "integer", nullable: false), + TransactionsFetched = table.Column(type: "integer", nullable: false), + TransactionsImported = table.Column(type: "integer", nullable: false), + TransactionsSkipped = table.Column(type: "integer", nullable: false), + ErrorMessage = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + CompletedAt = table.Column(type: "timestamp with time zone", nullable: true), + Duration = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "sync_logs"); + + migrationBuilder.DropTable( + name: "linked_accounts"); + + migrationBuilder.DropTable( + name: "sync_connections"); + } + } +} diff --git a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs index dd0eb0f..c28bc3c 100644 --- a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs +++ b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs @@ -305,6 +305,59 @@ namespace Purrse.Data.Migrations b.ToTable("investment_holdings", (string)null); }); + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalAccountId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountMask") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastKnownBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") @@ -627,6 +680,111 @@ namespace Purrse.Data.Migrations b.ToTable("security_prices", (string)null); }); + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstitutionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstitutionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PlaidItemId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LinkedAccountId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.Property("TransactionsFetched") + .HasColumnType("integer"); + + b.Property("TransactionsImported") + .HasColumnType("integer"); + + b.Property("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("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 } diff --git a/src/Purrse.Data/PurrseDbContext.cs b/src/Purrse.Data/PurrseDbContext.cs index 2bd2402..436c2d2 100644 --- a/src/Purrse.Data/PurrseDbContext.cs +++ b/src/Purrse.Data/PurrseDbContext.cs @@ -26,6 +26,9 @@ public class PurrseDbContext : DbContext public DbSet Reconciliations => Set(); public DbSet PluginRegistrations => Set(); public DbSet PluginConfigurations => Set(); + public DbSet SyncConnections => Set(); + public DbSet LinkedAccounts => Set(); + public DbSet SyncLogs => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Purrse.Plugins.BankSync/Models/PlaidModels.cs b/src/Purrse.Plugins.BankSync/Models/PlaidModels.cs new file mode 100644 index 0000000..971f461 --- /dev/null +++ b/src/Purrse.Plugins.BankSync/Models/PlaidModels.cs @@ -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; } +} diff --git a/src/Purrse.Plugins.BankSync/Models/SimpleFinModels.cs b/src/Purrse.Plugins.BankSync/Models/SimpleFinModels.cs new file mode 100644 index 0000000..eb93468 --- /dev/null +++ b/src/Purrse.Plugins.BankSync/Models/SimpleFinModels.cs @@ -0,0 +1,78 @@ +using System.Text.Json.Serialization; + +namespace Purrse.Plugins.BankSync.Models; + +public class SimpleFinAccountSet +{ + [JsonPropertyName("errors")] + public List Errors { get; set; } = new(); + + [JsonPropertyName("accounts")] + public List 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 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; } +} diff --git a/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs b/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs new file mode 100644 index 0000000..c3e941d --- /dev/null +++ b/src/Purrse.Plugins.BankSync/PlaidSyncProvider.cs @@ -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 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 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> GetAccountsAsync(Dictionary 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 SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary configuration) + { + var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty; + var allTransactions = new List(); + 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" + }; + } +} diff --git a/src/Purrse.Plugins.BankSync/Purrse.Plugins.BankSync.csproj b/src/Purrse.Plugins.BankSync/Purrse.Plugins.BankSync.csproj new file mode 100644 index 0000000..2ef6cb7 --- /dev/null +++ b/src/Purrse.Plugins.BankSync/Purrse.Plugins.BankSync.csproj @@ -0,0 +1,17 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + diff --git a/src/Purrse.Plugins.BankSync/SimpleFinSyncProvider.cs b/src/Purrse.Plugins.BankSync/SimpleFinSyncProvider.cs new file mode 100644 index 0000000..80e8123 --- /dev/null +++ b/src/Purrse.Plugins.BankSync/SimpleFinSyncProvider.cs @@ -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 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> GetAccountsAsync(Dictionary configuration) + { + var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty; + + var response = await _httpClient.GetFromJsonAsync($"{accessUrl}/accounts"); + if (response == null) + return new List(); + + 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 SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary 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( + $"{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() }; + + 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"; + } +}