Private
Public Access
1
0

Implement Phase 6: automated bank sync (Plaid + SimpleFIN) & settings

Add automated transaction syncing via Plaid and SimpleFIN providers,
a Settings page for managing connections and account mappings,
background scheduled syncing, and sync history logging.

New backend: BankSync plugin project with PlaidSyncProvider and
SimpleFinSyncProvider, SyncConnection/LinkedAccount/SyncLog models,
AES-256 encryption service, BankSyncService orchestration,
BankSyncBackgroundService (15-min tick), and BankSyncController
(13 endpoints). EF migration creates sync_connections,
linked_accounts, and sync_logs tables.

New frontend: Settings view with 3 tabs (Profile, Bank Connections,
Sync History), bankSync API service, BankSyncComplete SignalR
notifications, and Settings nav item in sidebar + avatar dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-08 15:08:23 -05:00
parent eb49147080
commit 3e0446c9be
34 changed files with 3956 additions and 8 deletions
+82
View File
@@ -0,0 +1,82 @@
using Purrse.Core.Enums;
namespace Purrse.Core.DTOs;
// Plaid
public record CreatePlaidConnectionRequest(string PublicToken);
public record CreateLinkTokenResponse(string LinkToken, string Expiration);
// SimpleFIN
public record CreateSimpleFinConnectionRequest(string SetupToken);
// Connection management
public record SyncConnectionResponse(
Guid Id,
SyncProvider Provider,
string InstitutionName,
string? InstitutionId,
bool IsActive,
int SyncIntervalMinutes,
DateTime? LastSyncAt,
string? LastSyncError,
DateTime CreatedAt,
List<LinkedAccountResponse> LinkedAccounts);
public record LinkedAccountResponse(
Guid Id,
string ExternalAccountId,
string ExternalAccountName,
string? ExternalAccountType,
string? ExternalAccountMask,
decimal? LastKnownBalance,
bool IsEnabled,
Guid? AccountId,
string? AccountName,
DateTime? LastSyncAt);
public record UpdateConnectionRequest(bool IsActive, int SyncIntervalMinutes);
// Account linking
public record LinkAccountRequest(Guid LinkedAccountId, Guid? AccountId, bool AutoCreate);
public record UpdateLinkedAccountRequest(Guid? AccountId, bool IsEnabled);
public record DiscoveredAccountResponse(
string ExternalAccountId,
string Name,
string? Type,
string? Mask,
decimal? Balance,
string? Currency);
// Sync results
public record SyncResultResponse(
int TotalAccounts,
int AccountsSynced,
int TransactionsFetched,
int TransactionsImported,
int TransactionsSkipped,
List<SyncAccountResult> AccountResults);
public record SyncAccountResult(
string ExternalAccountName,
string? PurrseAccountName,
int Fetched,
int Imported,
int Skipped,
string? Error);
// Sync logs
public record SyncLogResponse(
Guid Id,
Guid ConnectionId,
string InstitutionName,
Guid? LinkedAccountId,
string? LinkedAccountName,
SyncStatus Status,
int TransactionsFetched,
int TransactionsImported,
int TransactionsSkipped,
string? ErrorMessage,
DateTime StartedAt,
DateTime? CompletedAt,
TimeSpan? Duration);
+7
View File
@@ -0,0 +1,7 @@
namespace Purrse.Core.Enums;
public enum SyncProvider
{
Plaid,
SimpleFIN
}
+9
View File
@@ -0,0 +1,9 @@
namespace Purrse.Core.Enums;
public enum SyncStatus
{
Success,
PartialSuccess,
Failed,
Running
}
@@ -0,0 +1,31 @@
using Purrse.Core.DTOs;
namespace Purrse.Core.Interfaces.Services;
public interface IBankSyncService
{
// Connection CRUD
Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId);
Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId);
Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request);
Task DeleteConnectionAsync(Guid userId, Guid connectionId);
// Plaid flow
Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId);
Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request);
// SimpleFIN flow
Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request);
// Account linking
Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId);
Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request);
Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request);
// Sync
Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId);
Task<SyncResultResponse> SyncAllAsync(Guid userId);
// Logs
Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50);
}
@@ -0,0 +1,7 @@
namespace Purrse.Core.Interfaces.Services;
public interface IEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string cipherText);
}
+1
View File
@@ -25,4 +25,5 @@ public class Account
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
public ICollection<InvestmentHolding> InvestmentHoldings { get; set; } = new List<InvestmentHolding>();
public ICollection<Reconciliation> Reconciliations { get; set; } = new List<Reconciliation>();
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
}
+19
View File
@@ -0,0 +1,19 @@
namespace Purrse.Core.Models;
public class LinkedAccount
{
public Guid Id { get; set; }
public Guid SyncConnectionId { get; set; }
public Guid? AccountId { get; set; }
public string ExternalAccountId { get; set; } = string.Empty;
public string ExternalAccountName { get; set; } = string.Empty;
public string? ExternalAccountType { get; set; }
public string? ExternalAccountMask { get; set; }
public decimal? LastKnownBalance { get; set; }
public bool IsEnabled { get; set; } = true;
public DateTime? LastSyncAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public SyncConnection SyncConnection { get; set; } = null!;
public Account? Account { get; set; }
}
+24
View File
@@ -0,0 +1,24 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class SyncConnection
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public SyncProvider Provider { get; set; }
public string InstitutionName { get; set; } = string.Empty;
public string? InstitutionId { get; set; }
public string EncryptedAccessToken { get; set; } = string.Empty;
public string? PlaidItemId { get; set; }
public bool IsActive { get; set; } = true;
public int SyncIntervalMinutes { get; set; } = 360;
public DateTime? LastSyncAt { get; set; }
public string? LastSyncError { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
public ICollection<SyncLog> SyncLogs { get; set; } = new List<SyncLog>();
}
+21
View File
@@ -0,0 +1,21 @@
using Purrse.Core.Enums;
namespace Purrse.Core.Models;
public class SyncLog
{
public Guid Id { get; set; }
public Guid SyncConnectionId { get; set; }
public Guid? LinkedAccountId { get; set; }
public SyncStatus Status { get; set; }
public int TransactionsFetched { get; set; }
public int TransactionsImported { get; set; }
public int TransactionsSkipped { get; set; }
public string? ErrorMessage { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public TimeSpan? Duration { get; set; }
public SyncConnection SyncConnection { get; set; } = null!;
public LinkedAccount? LinkedAccount { get; set; }
}
+1
View File
@@ -12,4 +12,5 @@ public class User
public DateTime? RefreshTokenExpiresAt { get; set; }
public ICollection<Account> Accounts { get; set; } = new List<Account>();
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
}