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:
@@ -0,0 +1,82 @@
|
||||
using Purrse.Core.Enums;
|
||||
|
||||
namespace Purrse.Core.DTOs;
|
||||
|
||||
// Plaid
|
||||
public record CreatePlaidConnectionRequest(string PublicToken);
|
||||
public record CreateLinkTokenResponse(string LinkToken, string Expiration);
|
||||
|
||||
// SimpleFIN
|
||||
public record CreateSimpleFinConnectionRequest(string SetupToken);
|
||||
|
||||
// Connection management
|
||||
public record SyncConnectionResponse(
|
||||
Guid Id,
|
||||
SyncProvider Provider,
|
||||
string InstitutionName,
|
||||
string? InstitutionId,
|
||||
bool IsActive,
|
||||
int SyncIntervalMinutes,
|
||||
DateTime? LastSyncAt,
|
||||
string? LastSyncError,
|
||||
DateTime CreatedAt,
|
||||
List<LinkedAccountResponse> LinkedAccounts);
|
||||
|
||||
public record LinkedAccountResponse(
|
||||
Guid Id,
|
||||
string ExternalAccountId,
|
||||
string ExternalAccountName,
|
||||
string? ExternalAccountType,
|
||||
string? ExternalAccountMask,
|
||||
decimal? LastKnownBalance,
|
||||
bool IsEnabled,
|
||||
Guid? AccountId,
|
||||
string? AccountName,
|
||||
DateTime? LastSyncAt);
|
||||
|
||||
public record UpdateConnectionRequest(bool IsActive, int SyncIntervalMinutes);
|
||||
|
||||
// Account linking
|
||||
public record LinkAccountRequest(Guid LinkedAccountId, Guid? AccountId, bool AutoCreate);
|
||||
public record UpdateLinkedAccountRequest(Guid? AccountId, bool IsEnabled);
|
||||
|
||||
public record DiscoveredAccountResponse(
|
||||
string ExternalAccountId,
|
||||
string Name,
|
||||
string? Type,
|
||||
string? Mask,
|
||||
decimal? Balance,
|
||||
string? Currency);
|
||||
|
||||
// Sync results
|
||||
public record SyncResultResponse(
|
||||
int TotalAccounts,
|
||||
int AccountsSynced,
|
||||
int TransactionsFetched,
|
||||
int TransactionsImported,
|
||||
int TransactionsSkipped,
|
||||
List<SyncAccountResult> AccountResults);
|
||||
|
||||
public record SyncAccountResult(
|
||||
string ExternalAccountName,
|
||||
string? PurrseAccountName,
|
||||
int Fetched,
|
||||
int Imported,
|
||||
int Skipped,
|
||||
string? Error);
|
||||
|
||||
// Sync logs
|
||||
public record SyncLogResponse(
|
||||
Guid Id,
|
||||
Guid ConnectionId,
|
||||
string InstitutionName,
|
||||
Guid? LinkedAccountId,
|
||||
string? LinkedAccountName,
|
||||
SyncStatus Status,
|
||||
int TransactionsFetched,
|
||||
int TransactionsImported,
|
||||
int TransactionsSkipped,
|
||||
string? ErrorMessage,
|
||||
DateTime StartedAt,
|
||||
DateTime? CompletedAt,
|
||||
TimeSpan? Duration);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Purrse.Core.Enums;
|
||||
|
||||
public enum SyncProvider
|
||||
{
|
||||
Plaid,
|
||||
SimpleFIN
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Purrse.Core.Enums;
|
||||
|
||||
public enum SyncStatus
|
||||
{
|
||||
Success,
|
||||
PartialSuccess,
|
||||
Failed,
|
||||
Running
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Purrse.Core.DTOs;
|
||||
|
||||
namespace Purrse.Core.Interfaces.Services;
|
||||
|
||||
public interface IBankSyncService
|
||||
{
|
||||
// Connection CRUD
|
||||
Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId);
|
||||
Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId);
|
||||
Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request);
|
||||
Task DeleteConnectionAsync(Guid userId, Guid connectionId);
|
||||
|
||||
// Plaid flow
|
||||
Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId);
|
||||
Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request);
|
||||
|
||||
// SimpleFIN flow
|
||||
Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request);
|
||||
|
||||
// Account linking
|
||||
Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId);
|
||||
Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request);
|
||||
Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request);
|
||||
|
||||
// Sync
|
||||
Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId);
|
||||
Task<SyncResultResponse> SyncAllAsync(Guid userId);
|
||||
|
||||
// Logs
|
||||
Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Purrse.Core.Interfaces.Services;
|
||||
|
||||
public interface IEncryptionService
|
||||
{
|
||||
string Encrypt(string plainText);
|
||||
string Decrypt(string cipherText);
|
||||
}
|
||||
@@ -25,4 +25,5 @@ public class Account
|
||||
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
|
||||
public ICollection<InvestmentHolding> InvestmentHoldings { get; set; } = new List<InvestmentHolding>();
|
||||
public ICollection<Reconciliation> Reconciliations { get; set; } = new List<Reconciliation>();
|
||||
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Purrse.Core.Models;
|
||||
|
||||
public class LinkedAccount
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid SyncConnectionId { get; set; }
|
||||
public Guid? AccountId { get; set; }
|
||||
public string ExternalAccountId { get; set; } = string.Empty;
|
||||
public string ExternalAccountName { get; set; } = string.Empty;
|
||||
public string? ExternalAccountType { get; set; }
|
||||
public string? ExternalAccountMask { get; set; }
|
||||
public decimal? LastKnownBalance { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public SyncConnection SyncConnection { get; set; } = null!;
|
||||
public Account? Account { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Purrse.Core.Enums;
|
||||
|
||||
namespace Purrse.Core.Models;
|
||||
|
||||
public class SyncConnection
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public SyncProvider Provider { get; set; }
|
||||
public string InstitutionName { get; set; } = string.Empty;
|
||||
public string? InstitutionId { get; set; }
|
||||
public string EncryptedAccessToken { get; set; } = string.Empty;
|
||||
public string? PlaidItemId { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SyncIntervalMinutes { get; set; } = 360;
|
||||
public DateTime? LastSyncAt { get; set; }
|
||||
public string? LastSyncError { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
public ICollection<LinkedAccount> LinkedAccounts { get; set; } = new List<LinkedAccount>();
|
||||
public ICollection<SyncLog> SyncLogs { get; set; } = new List<SyncLog>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Purrse.Core.Enums;
|
||||
|
||||
namespace Purrse.Core.Models;
|
||||
|
||||
public class SyncLog
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid SyncConnectionId { get; set; }
|
||||
public Guid? LinkedAccountId { get; set; }
|
||||
public SyncStatus Status { get; set; }
|
||||
public int TransactionsFetched { get; set; }
|
||||
public int TransactionsImported { get; set; }
|
||||
public int TransactionsSkipped { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTime StartedAt { get; set; }
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public TimeSpan? Duration { get; set; }
|
||||
|
||||
public SyncConnection SyncConnection { get; set; } = null!;
|
||||
public LinkedAccount? LinkedAccount { get; set; }
|
||||
}
|
||||
@@ -12,4 +12,5 @@ public class User
|
||||
public DateTime? RefreshTokenExpiresAt { get; set; }
|
||||
|
||||
public ICollection<Account> Accounts { get; set; } = new List<Account>();
|
||||
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user