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 - + + {{ (n.data as any).fileName }} + + {{ (n.data as any).newTransactions }} new, {{ (n.data as any).possibleDuplicates }} duplicates + + + + {{ (n.data as any).institutionName }} + + {{ (n.data as any).transactionsImported }} imported, {{ (n.data as any).transactionsSkipped }} skipped + + @@ -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 @@ + + + + Profile + Bank Connections + Sync History + + + + + + + Profile + + + + + + + + + + + Connect via Plaid + + + Connect via SimpleFIN + + + + + No bank connections yet. Click a button above to connect your bank. + + + + + {{ conn.provider === 'Plaid' ? 'mdi-bank' : 'mdi-link-variant' }} + {{ conn.institutionName }} + + {{ conn.provider }} + + + + {{ conn.isActive ? 'Active' : 'Inactive' }} + + + + + + Last synced: {{ conn.lastSyncAt ? new Date(conn.lastSyncAt).toLocaleString() : 'Never' }} + {{ conn.lastSyncError }} + + + + + + External Account + Type + Balance + Purrse Account + Enabled + Last Synced + + + + + {{ la.externalAccountName }} + {{ la.externalAccountType }} + {{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }} + + updateLinkedAccount(la, val)" + /> + + + toggleLinkedAccount(la, val ?? false)" + /> + + {{ la.lastSyncAt ? new Date(la.lastSyncAt).toLocaleString() : 'Never' }} + + + + + + updateConnectionInterval(conn)" + /> + + + Sync Now + + + {{ conn.isActive ? 'Disable' : 'Enable' }} + + + Delete + + + + + + + + + Map Bank Accounts + + Map your external bank accounts to Purrse accounts, or auto-create new ones. + + + + External Account + Type + Balance + Purrse Account + Auto-create + + + + + {{ la.externalAccountName }} + {{ la.externalAccountType }} + {{ la.lastKnownBalance != null ? formatCurrency(la.lastKnownBalance) : '-' }} + + + + + + + + + + + + + Skip + Save Mappings + + + + + + + + Connect via SimpleFIN + + + Visit simplefin.org/bridge to get a setup token for your bank, then paste it below. + + + + + + Cancel + + Connect + + + + + + + + + Delete Connection + + Are you sure you want to delete the connection to {{ deleteTarget?.institutionName }}? + This will remove all linked account mappings and sync history. + + + + Cancel + Delete + + + + + + + {{ syncResultMessage }} + + + + + + + + Sync History + + + + + + {{ new Date(item.startedAt).toLocaleString() }} + + + + {{ item.status }} + + + + {{ item.duration || '-' }} + + + {{ item.errorMessage }} + - + + + + + + + + + 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
Map your external bank accounts to Purrse accounts, or auto-create new ones.
+ Visit simplefin.org/bridge to get a setup token for your bank, then paste it below. +