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
@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Purrse.Core.DTOs;
using Purrse.Core.Interfaces.Services;
using System.Security.Claims;
namespace Purrse.Api.Controllers;
[Authorize]
[ApiController]
[Route("api/bank-sync")]
public class BankSyncController : ControllerBase
{
private readonly IBankSyncService _bankSyncService;
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public BankSyncController(IBankSyncService bankSyncService) => _bankSyncService = bankSyncService;
[HttpGet("connections")]
public async Task<ActionResult<List<SyncConnectionResponse>>> GetConnections()
=> Ok(await _bankSyncService.GetConnectionsAsync(UserId));
[HttpGet("connections/{id}")]
public async Task<ActionResult<SyncConnectionResponse>> GetConnection(Guid id)
=> Ok(await _bankSyncService.GetConnectionAsync(UserId, id));
[HttpPut("connections/{id}")]
public async Task<ActionResult<SyncConnectionResponse>> UpdateConnection(Guid id, UpdateConnectionRequest request)
=> Ok(await _bankSyncService.UpdateConnectionAsync(UserId, id, request));
[HttpDelete("connections/{id}")]
public async Task<IActionResult> DeleteConnection(Guid id)
{
await _bankSyncService.DeleteConnectionAsync(UserId, id);
return NoContent();
}
[HttpPost("plaid/create-link-token")]
public async Task<ActionResult<CreateLinkTokenResponse>> CreatePlaidLinkToken()
=> Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId));
[HttpPost("plaid/complete-link")]
public async Task<ActionResult<SyncConnectionResponse>> CompletePlaidLink(CreatePlaidConnectionRequest request)
=> Ok(await _bankSyncService.CompletePlaidLinkAsync(UserId, request));
[HttpPost("simplefin/connect")]
public async Task<ActionResult<SyncConnectionResponse>> ConnectSimpleFin(CreateSimpleFinConnectionRequest request)
=> Ok(await _bankSyncService.CreateSimpleFinConnectionAsync(UserId, request));
[HttpGet("connections/{id}/discover-accounts")]
public async Task<ActionResult<List<DiscoveredAccountResponse>>> DiscoverAccounts(Guid id)
=> Ok(await _bankSyncService.DiscoverAccountsAsync(UserId, id));
[HttpPost("connections/{id}/link-account")]
public async Task<ActionResult<LinkedAccountResponse>> LinkAccount(Guid id, LinkAccountRequest request)
=> Ok(await _bankSyncService.LinkAccountAsync(UserId, id, request));
[HttpPut("linked-accounts/{id}")]
public async Task<ActionResult<LinkedAccountResponse>> UpdateLinkedAccount(Guid id, UpdateLinkedAccountRequest request)
=> Ok(await _bankSyncService.UpdateLinkedAccountAsync(UserId, id, request));
[HttpPost("connections/{id}/sync-now")]
public async Task<ActionResult<SyncResultResponse>> SyncNow(Guid id)
=> Ok(await _bankSyncService.SyncNowAsync(UserId, id));
[HttpPost("sync-all")]
public async Task<ActionResult<SyncResultResponse>> SyncAll()
=> Ok(await _bankSyncService.SyncAllAsync(UserId));
[HttpGet("sync-logs")]
public async Task<ActionResult<List<SyncLogResponse>>> GetSyncLogs([FromQuery] Guid? connectionId, [FromQuery] int limit = 50)
=> Ok(await _bankSyncService.GetSyncLogsAsync(UserId, connectionId, limit));
}
+8
View File
@@ -8,6 +8,7 @@ using Purrse.Api.Services;
using Purrse.Core.Interfaces.Services;
using Purrse.Data;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.BankSync;
using Purrse.Plugins.CSV;
using Purrse.Plugins.OFX;
using Purrse.Plugins.QIF;
@@ -76,18 +77,25 @@ builder.Services.AddScoped<ILoanService, LoanService>();
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
// Built-in file parsers
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
// Bank sync providers
builder.Services.AddSingleton<PlaidSyncProvider>();
builder.Services.AddHttpClient<SimpleFinSyncProvider>();
// Plugin system
builder.Services.AddSingleton<PluginService>();
// Background services
builder.Services.AddHostedService<ScheduledTransactionProcessor>();
builder.Services.AddHostedService<FileWatcherService>();
builder.Services.AddHostedService<BankSyncBackgroundService>();
// SignalR
builder.Services.AddSignalR();
+1
View File
@@ -22,6 +22,7 @@
<ProjectReference Include="..\Purrse.Plugins.OFX\Purrse.Plugins.OFX.csproj" />
<ProjectReference Include="..\Purrse.Plugins.CSV\Purrse.Plugins.CSV.csproj" />
<ProjectReference Include="..\Purrse.Plugins.QIF\Purrse.Plugins.QIF.csproj" />
<ProjectReference Include="..\Purrse.Plugins.BankSync\Purrse.Plugins.BankSync.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore;
using Purrse.Core.Interfaces.Services;
using Purrse.Data;
namespace Purrse.Api.Services;
public class BankSyncBackgroundService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<BankSyncBackgroundService> _logger;
private readonly TimeSpan _checkInterval;
public BankSyncBackgroundService(IServiceProvider services, IConfiguration configuration, ILogger<BankSyncBackgroundService> logger)
{
_services = services;
_logger = logger;
var minutes = configuration.GetValue("BankSync:BackgroundCheckIntervalMinutes", 15);
_checkInterval = TimeSpan.FromMinutes(minutes);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Bank sync background service started with {Interval} min interval", _checkInterval.TotalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(_checkInterval, stoppingToken);
try
{
await ProcessDueSyncs(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in bank sync background service");
}
}
}
private async Task ProcessDueSyncs(CancellationToken stoppingToken)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
var bankSyncService = scope.ServiceProvider.GetRequiredService<IBankSyncService>();
var now = DateTime.UtcNow;
var dueConnections = await db.SyncConnections
.Where(c => c.IsActive)
.Where(c => c.LastSyncAt == null || c.LastSyncAt.Value.AddMinutes(c.SyncIntervalMinutes) < now)
.Select(c => new { c.Id, c.UserId, c.InstitutionName })
.ToListAsync(stoppingToken);
_logger.LogInformation("Found {Count} connections due for sync", dueConnections.Count);
foreach (var conn in dueConnections)
{
if (stoppingToken.IsCancellationRequested) break;
try
{
_logger.LogInformation("Auto-syncing connection {Id} ({Name})", conn.Id, conn.InstitutionName);
await bankSyncService.SyncNowAsync(conn.UserId, conn.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to auto-sync connection {Id} ({Name})", conn.Id, conn.InstitutionName);
}
}
}
}
+529
View File
@@ -0,0 +1,529 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Purrse.Api.Hubs;
using Purrse.Core.DTOs;
using Purrse.Core.Enums;
using Purrse.Core.Helpers;
using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models;
using Purrse.Data;
using Purrse.Plugins.BankSync;
namespace Purrse.Api.Services;
public class BankSyncService : IBankSyncService
{
private readonly PurrseDbContext _db;
private readonly IEncryptionService _encryption;
private readonly PlaidSyncProvider _plaid;
private readonly SimpleFinSyncProvider _simpleFin;
private readonly IDuplicateDetectionService _duplicateService;
private readonly IPayeeService _payeeService;
private readonly IHubContext<NotificationHub> _hub;
private readonly ILogger<BankSyncService> _logger;
public BankSyncService(
PurrseDbContext db,
IEncryptionService encryption,
PlaidSyncProvider plaid,
SimpleFinSyncProvider simpleFin,
IDuplicateDetectionService duplicateService,
IPayeeService payeeService,
IHubContext<NotificationHub> hub,
ILogger<BankSyncService> logger)
{
_db = db;
_encryption = encryption;
_plaid = plaid;
_simpleFin = simpleFin;
_duplicateService = duplicateService;
_payeeService = payeeService;
_hub = hub;
_logger = logger;
}
public async Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId)
{
var connections = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
return connections.Select(MapConnectionResponse).ToList();
}
public async Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
return MapConnectionResponse(connection);
}
public async Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
connection.IsActive = request.IsActive;
connection.SyncIntervalMinutes = request.SyncIntervalMinutes;
connection.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return MapConnectionResponse(connection);
}
public async Task DeleteConnectionAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
_db.SyncConnections.Remove(connection);
await _db.SaveChangesAsync();
}
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
{
var result = await _plaid.CreateLinkTokenAsync(userId.ToString());
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
}
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
{
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken);
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.Plaid,
InstitutionName = exchangeResult.InstitutionName ?? "Unknown Institution",
InstitutionId = exchangeResult.InstitutionId,
EncryptedAccessToken = _encryption.Encrypt(exchangeResult.AccessToken),
PlaidItemId = exchangeResult.ItemId
};
_db.SyncConnections.Add(connection);
await _db.SaveChangesAsync();
// Discover accounts
await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken);
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
{
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
// Get accounts to determine institution name
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
var accounts = await _simpleFin.GetAccountsAsync(config);
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.SimpleFIN,
InstitutionName = institutionName,
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
};
_db.SyncConnections.Add(connection);
// Create linked accounts from discovered accounts
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
var accounts = connection.Provider == SyncProvider.Plaid
? await _plaid.GetAccountsAsync(config)
: await _simpleFin.GetAccountsAsync(config);
return accounts.Select(a => new DiscoveredAccountResponse(
a.ExternalId,
a.Name,
a.AccountType,
null,
a.Balance,
a.Currency
)).ToList();
}
public async Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request)
{
var connection = await _db.SyncConnections
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
var linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstOrDefaultAsync(la => la.Id == request.LinkedAccountId && la.SyncConnectionId == connectionId)
?? throw new KeyNotFoundException("Linked account not found");
if (request.AutoCreate && request.AccountId == null)
{
// Auto-create a Purrse account
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
? at : AccountType.Checking;
var newAccount = new Account
{
Id = Guid.NewGuid(),
UserId = userId,
Name = linkedAccount.ExternalAccountName,
Type = accountType,
Institution = connection.InstitutionName,
Balance = linkedAccount.LastKnownBalance ?? 0
};
_db.Accounts.Add(newAccount);
linkedAccount.AccountId = newAccount.Id;
}
else
{
linkedAccount.AccountId = request.AccountId;
}
await _db.SaveChangesAsync();
// Reload with Account nav
linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstAsync(la => la.Id == linkedAccount.Id);
return MapLinkedAccountResponse(linkedAccount);
}
public async Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request)
{
var linkedAccount = await _db.LinkedAccounts
.Include(la => la.SyncConnection)
.Include(la => la.Account)
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
?? throw new KeyNotFoundException("Linked account not found");
linkedAccount.AccountId = request.AccountId;
linkedAccount.IsEnabled = request.IsEnabled;
await _db.SaveChangesAsync();
return MapLinkedAccountResponse(linkedAccount);
}
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
?? throw new KeyNotFoundException("Connection not found");
return await SyncConnectionInternal(userId, connection);
}
public async Task<SyncResultResponse> SyncAllAsync(Guid userId)
{
var connections = await _db.SyncConnections
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
.Where(c => c.UserId == userId && c.IsActive)
.ToListAsync();
var allResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0, totalSynced = 0;
foreach (var connection in connections)
{
var result = await SyncConnectionInternal(userId, connection);
allResults.AddRange(result.AccountResults);
totalFetched += result.TransactionsFetched;
totalImported += result.TransactionsImported;
totalSkipped += result.TransactionsSkipped;
totalSynced += result.AccountsSynced;
}
return new SyncResultResponse(
allResults.Count,
totalSynced,
totalFetched,
totalImported,
totalSkipped,
allResults);
}
public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50)
{
var query = _db.SyncLogs
.Include(l => l.SyncConnection)
.Include(l => l.LinkedAccount)
.Where(l => l.SyncConnection.UserId == userId);
if (connectionId.HasValue)
query = query.Where(l => l.SyncConnectionId == connectionId.Value);
return await query
.OrderByDescending(l => l.StartedAt)
.Take(limit)
.Select(l => new SyncLogResponse(
l.Id,
l.SyncConnectionId,
l.SyncConnection.InstitutionName,
l.LinkedAccountId,
l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null,
l.Status,
l.TransactionsFetched,
l.TransactionsImported,
l.TransactionsSkipped,
l.ErrorMessage,
l.StartedAt,
l.CompletedAt,
l.Duration))
.ToListAsync();
}
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
{
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
var enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList();
var accountResults = new List<SyncAccountResult>();
int totalFetched = 0, totalImported = 0, totalSkipped = 0;
foreach (var linkedAccount in enabledAccounts)
{
var log = new SyncLog
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
LinkedAccountId = linkedAccount.Id,
Status = SyncStatus.Running,
StartedAt = DateTime.UtcNow
};
_db.SyncLogs.Add(log);
try
{
var endDate = DateTime.UtcNow;
var startDate = linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
var provider = connection.Provider == SyncProvider.Plaid
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
: _simpleFin;
var parseResult = await provider.SyncTransactionsAsync(
linkedAccount.ExternalAccountId, startDate, endDate, config);
if (!parseResult.Success)
{
log.Status = SyncStatus.Failed;
log.ErrorMessage = parseResult.ErrorMessage;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0,
parseResult.ErrorMessage));
continue;
}
int fetched = parseResult.Transactions.Count;
int imported = 0;
int skipped = 0;
foreach (var parsed in parseResult.Transactions)
{
var duplicate = await _duplicateService.FindDuplicateAsync(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount,
parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
if (duplicate != null)
{
skipped++;
continue;
}
var fingerprint = TransactionFingerprint.Generate(
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount, parsed.FitId);
var matchedPayee = await _payeeService.MatchPayeeAsync(userId, parsed.PayeeName ?? string.Empty);
var transaction = new Transaction
{
Id = Guid.NewGuid(),
AccountId = linkedAccount.AccountId!.Value,
Date = parsed.Date,
Amount = parsed.Amount,
PayeeId = matchedPayee?.Id,
PayeeName = parsed.PayeeName,
CategoryId = matchedPayee?.DefaultCategoryId,
Memo = parsed.Memo,
ReferenceNumber = parsed.ReferenceNumber,
CheckNumber = parsed.CheckNumber,
FitId = parsed.FitId,
Fingerprint = fingerprint,
Type = parsed.Amount >= 0 ? TransactionType.Credit : TransactionType.Debit,
Status = TransactionStatus.Uncleared
};
_db.Transactions.Add(transaction);
imported++;
}
// Update account balance
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
{
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
linkedAccount.Account.UpdatedAt = DateTime.UtcNow;
}
linkedAccount.LastKnownBalance = parseResult.LedgerBalance ?? linkedAccount.LastKnownBalance;
linkedAccount.LastSyncAt = DateTime.UtcNow;
log.Status = SyncStatus.Success;
log.TransactionsFetched = fetched;
log.TransactionsImported = imported;
log.TransactionsSkipped = skipped;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
totalFetched += fetched;
totalImported += imported;
totalSkipped += skipped;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
fetched, imported, skipped, null));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to sync account {AccountId} for connection {ConnectionId}",
linkedAccount.ExternalAccountId, connection.Id);
log.Status = SyncStatus.Failed;
log.ErrorMessage = ex.Message;
log.CompletedAt = DateTime.UtcNow;
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0, ex.Message));
}
}
connection.LastSyncAt = DateTime.UtcNow;
connection.LastSyncError = accountResults.Any(r => r.Error != null)
? accountResults.First(r => r.Error != null).Error
: null;
connection.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Send SignalR notification
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
{
connectionId = connection.Id,
institutionName = connection.InstitutionName,
transactionsImported = totalImported,
transactionsSkipped = totalSkipped,
status = accountResults.All(r => r.Error == null) ? "Success" : "PartialSuccess"
});
return new SyncResultResponse(
enabledAccounts.Count,
accountResults.Count(r => r.Error == null),
totalFetched,
totalImported,
totalSkipped,
accountResults);
}
private async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken)
{
var config = BuildProviderConfig(connection, accessToken);
var accounts = await _plaid.GetAccountsAsync(config);
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
}
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken)
{
return connection.Provider == SyncProvider.Plaid
? new Dictionary<string, string> { ["AccessToken"] = accessToken }
: new Dictionary<string, string> { ["AccessUrl"] = accessToken };
}
private static SyncConnectionResponse MapConnectionResponse(SyncConnection c)
{
return new SyncConnectionResponse(
c.Id,
c.Provider,
c.InstitutionName,
c.InstitutionId,
c.IsActive,
c.SyncIntervalMinutes,
c.LastSyncAt,
c.LastSyncError,
c.CreatedAt,
c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList());
}
private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la)
{
return new LinkedAccountResponse(
la.Id,
la.ExternalAccountId,
la.ExternalAccountName,
la.ExternalAccountType,
la.ExternalAccountMask,
la.LastKnownBalance,
la.IsEnabled,
la.AccountId,
la.Account?.Name,
la.LastSyncAt);
}
}
@@ -0,0 +1,54 @@
using System.Security.Cryptography;
using System.Text;
using Purrse.Core.Interfaces.Services;
namespace Purrse.Api.Services;
public class EncryptionService : IEncryptionService
{
private readonly byte[] _key;
public EncryptionService(IConfiguration configuration)
{
var keyString = configuration["BankSync:EncryptionKey"]
?? throw new InvalidOperationException("BankSync:EncryptionKey is not configured");
_key = SHA256.HashData(Encoding.UTF8.GetBytes(keyString));
}
public string Encrypt(string plainText)
{
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV();
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plainText);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
// Prepend IV to ciphertext
var result = new byte[aes.IV.Length + cipherBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
return Convert.ToBase64String(result);
}
public string Decrypt(string cipherText)
{
var fullCipher = Convert.FromBase64String(cipherText);
using var aes = Aes.Create();
aes.Key = _key;
var iv = new byte[aes.BlockSize / 8];
var cipher = new byte[fullCipher.Length - iv.Length];
Buffer.BlockCopy(fullCipher, 0, iv, 0, iv.Length);
Buffer.BlockCopy(fullCipher, iv.Length, cipher, 0, cipher.Length);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
return Encoding.UTF8.GetString(plainBytes);
}
}
+10
View File
@@ -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",
+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>();
}
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Purrse.Core.Models;
namespace Purrse.Data.Configurations;
public class SyncConnectionConfiguration : IEntityTypeConfiguration<SyncConnection>
{
public void Configure(EntityTypeBuilder<SyncConnection> builder)
{
builder.ToTable("sync_connections");
builder.HasKey(c => c.Id);
builder.Property(c => c.InstitutionName).HasMaxLength(200).IsRequired();
builder.Property(c => c.InstitutionId).HasMaxLength(100);
builder.Property(c => c.EncryptedAccessToken).HasColumnType("text").IsRequired();
builder.Property(c => c.PlaidItemId).HasMaxLength(200);
builder.Property(c => c.LastSyncError).HasMaxLength(2000);
builder.HasOne(c => c.User)
.WithMany(u => u.SyncConnections)
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(c => c.UserId);
builder.HasIndex(c => c.PlaidItemId);
}
}
public class LinkedAccountConfiguration : IEntityTypeConfiguration<LinkedAccount>
{
public void Configure(EntityTypeBuilder<LinkedAccount> builder)
{
builder.ToTable("linked_accounts");
builder.HasKey(a => a.Id);
builder.Property(a => a.ExternalAccountId).HasMaxLength(200).IsRequired();
builder.Property(a => a.ExternalAccountName).HasMaxLength(200).IsRequired();
builder.Property(a => a.ExternalAccountType).HasMaxLength(50);
builder.Property(a => a.ExternalAccountMask).HasMaxLength(20);
builder.Property(a => a.LastKnownBalance).HasPrecision(18, 2);
builder.HasOne(a => a.SyncConnection)
.WithMany(c => c.LinkedAccounts)
.HasForeignKey(a => a.SyncConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(a => a.Account)
.WithMany(acc => acc.LinkedAccounts)
.HasForeignKey(a => a.AccountId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(a => new { a.SyncConnectionId, a.ExternalAccountId }).IsUnique();
builder.HasIndex(a => a.AccountId);
}
}
public class SyncLogConfiguration : IEntityTypeConfiguration<SyncLog>
{
public void Configure(EntityTypeBuilder<SyncLog> builder)
{
builder.ToTable("sync_logs");
builder.HasKey(l => l.Id);
builder.Property(l => l.ErrorMessage).HasMaxLength(2000);
builder.HasOne(l => l.SyncConnection)
.WithMany(c => c.SyncLogs)
.HasForeignKey(l => l.SyncConnectionId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(l => l.LinkedAccount)
.WithMany()
.HasForeignKey(l => l.LinkedAccountId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasIndex(l => l.SyncConnectionId);
builder.HasIndex(l => l.StartedAt);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Purrse.Data.Migrations
{
/// <inheritdoc />
public partial class AddBankSync : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sync_connections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Provider = table.Column<int>(type: "integer", nullable: false),
InstitutionName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
InstitutionId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
EncryptedAccessToken = table.Column<string>(type: "text", nullable: false),
PlaidItemId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
SyncIntervalMinutes = table.Column<int>(type: "integer", nullable: false),
LastSyncAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
LastSyncError = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_sync_connections", x => x.Id);
table.ForeignKey(
name: "FK_sync_connections_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "linked_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SyncConnectionId = table.Column<Guid>(type: "uuid", nullable: false),
AccountId = table.Column<Guid>(type: "uuid", nullable: true),
ExternalAccountId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalAccountName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
ExternalAccountType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
ExternalAccountMask = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
LastKnownBalance = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
LastSyncAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_linked_accounts", x => x.Id);
table.ForeignKey(
name: "FK_linked_accounts_accounts_AccountId",
column: x => x.AccountId,
principalTable: "accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_linked_accounts_sync_connections_SyncConnectionId",
column: x => x.SyncConnectionId,
principalTable: "sync_connections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "sync_logs",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SyncConnectionId = table.Column<Guid>(type: "uuid", nullable: false),
LinkedAccountId = table.Column<Guid>(type: "uuid", nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
TransactionsFetched = table.Column<int>(type: "integer", nullable: false),
TransactionsImported = table.Column<int>(type: "integer", nullable: false),
TransactionsSkipped = table.Column<int>(type: "integer", nullable: false),
ErrorMessage = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_sync_logs", x => x.Id);
table.ForeignKey(
name: "FK_sync_logs_linked_accounts_LinkedAccountId",
column: x => x.LinkedAccountId,
principalTable: "linked_accounts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_sync_logs_sync_connections_SyncConnectionId",
column: x => x.SyncConnectionId,
principalTable: "sync_connections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_linked_accounts_AccountId",
table: "linked_accounts",
column: "AccountId");
migrationBuilder.CreateIndex(
name: "IX_linked_accounts_SyncConnectionId_ExternalAccountId",
table: "linked_accounts",
columns: new[] { "SyncConnectionId", "ExternalAccountId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_sync_connections_PlaidItemId",
table: "sync_connections",
column: "PlaidItemId");
migrationBuilder.CreateIndex(
name: "IX_sync_connections_UserId",
table: "sync_connections",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_LinkedAccountId",
table: "sync_logs",
column: "LinkedAccountId");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_StartedAt",
table: "sync_logs",
column: "StartedAt");
migrationBuilder.CreateIndex(
name: "IX_sync_logs_SyncConnectionId",
table: "sync_logs",
column: "SyncConnectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "sync_logs");
migrationBuilder.DropTable(
name: "linked_accounts");
migrationBuilder.DropTable(
name: "sync_connections");
}
}
}
@@ -305,6 +305,59 @@ namespace Purrse.Data.Migrations
b.ToTable("investment_holdings", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AccountId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalAccountId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExternalAccountMask")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalAccountName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ExternalAccountType")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<decimal?>("LastKnownBalance")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("SyncConnectionId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("SyncConnectionId", "ExternalAccountId")
.IsUnique();
b.ToTable("linked_accounts", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b =>
{
b.Property<Guid>("Id")
@@ -627,6 +680,111 @@ namespace Purrse.Data.Migrations
b.ToTable("security_prices", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("InstitutionId")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("InstitutionName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastSyncError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("PlaidItemId")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("Provider")
.HasColumnType("integer");
b.Property<int>("SyncIntervalMinutes")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PlaidItemId");
b.HasIndex("UserId");
b.ToTable("sync_connections", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.SyncLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<TimeSpan?>("Duration")
.HasColumnType("interval");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<Guid?>("LinkedAccountId")
.HasColumnType("uuid");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("SyncConnectionId")
.HasColumnType("uuid");
b.Property<int>("TransactionsFetched")
.HasColumnType("integer");
b.Property<int>("TransactionsImported")
.HasColumnType("integer");
b.Property<int>("TransactionsSkipped")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("LinkedAccountId");
b.HasIndex("StartedAt");
b.HasIndex("SyncConnectionId");
b.ToTable("sync_logs", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{
b.Property<Guid>("Id")
@@ -902,6 +1060,24 @@ namespace Purrse.Data.Migrations
b.Navigation("Security");
});
modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
.WithMany("LinkedAccounts")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection")
.WithMany("LinkedAccounts")
.HasForeignKey("SyncConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("SyncConnection");
});
modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
@@ -1008,6 +1184,35 @@ namespace Purrse.Data.Migrations
b.Navigation("Security");
});
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.HasOne("Purrse.Core.Models.User", "User")
.WithMany("SyncConnections")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Purrse.Core.Models.SyncLog", b =>
{
b.HasOne("Purrse.Core.Models.LinkedAccount", "LinkedAccount")
.WithMany()
.HasForeignKey("LinkedAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection")
.WithMany("SyncLogs")
.HasForeignKey("SyncConnectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LinkedAccount");
b.Navigation("SyncConnection");
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{
b.HasOne("Purrse.Core.Models.Account", "Account")
@@ -1076,6 +1281,8 @@ namespace Purrse.Data.Migrations
{
b.Navigation("InvestmentHoldings");
b.Navigation("LinkedAccounts");
b.Navigation("LoanDetail");
b.Navigation("Reconciliations");
@@ -1131,6 +1338,13 @@ namespace Purrse.Data.Migrations
b.Navigation("Prices");
});
modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b =>
{
b.Navigation("LinkedAccounts");
b.Navigation("SyncLogs");
});
modelBuilder.Entity("Purrse.Core.Models.Transaction", b =>
{
b.Navigation("Splits");
@@ -1139,6 +1353,8 @@ namespace Purrse.Data.Migrations
modelBuilder.Entity("Purrse.Core.Models.User", b =>
{
b.Navigation("Accounts");
b.Navigation("SyncConnections");
});
#pragma warning restore 612, 618
}
+3
View File
@@ -26,6 +26,9 @@ public class PurrseDbContext : DbContext
public DbSet<Reconciliation> Reconciliations => Set<Reconciliation>();
public DbSet<PluginRegistration> PluginRegistrations => Set<PluginRegistration>();
public DbSet<PluginConfiguration> PluginConfigurations => Set<PluginConfiguration>();
public DbSet<SyncConnection> SyncConnections => Set<SyncConnection>();
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -0,0 +1,22 @@
namespace Purrse.Plugins.BankSync.Models;
public class PlaidConfig
{
public string ClientId { get; set; } = string.Empty;
public string Secret { get; set; } = string.Empty;
public string Environment { get; set; } = "sandbox";
}
public class PlaidLinkTokenResult
{
public string LinkToken { get; set; } = string.Empty;
public string Expiration { get; set; } = string.Empty;
}
public class PlaidExchangeResult
{
public string AccessToken { get; set; } = string.Empty;
public string ItemId { get; set; } = string.Empty;
public string? InstitutionId { get; set; }
public string? InstitutionName { get; set; }
}
@@ -0,0 +1,78 @@
using System.Text.Json.Serialization;
namespace Purrse.Plugins.BankSync.Models;
public class SimpleFinAccountSet
{
[JsonPropertyName("errors")]
public List<string> Errors { get; set; } = new();
[JsonPropertyName("accounts")]
public List<SimpleFinAccount> Accounts { get; set; } = new();
}
public class SimpleFinAccount
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("currency")]
public string Currency { get; set; } = "USD";
[JsonPropertyName("balance")]
public string Balance { get; set; } = "0";
[JsonPropertyName("available-balance")]
public string? AvailableBalance { get; set; }
[JsonPropertyName("balance-date")]
public long? BalanceDate { get; set; }
[JsonPropertyName("transactions")]
public List<SimpleFinTransaction> Transactions { get; set; } = new();
[JsonPropertyName("org")]
public SimpleFinOrg? Org { get; set; }
}
public class SimpleFinTransaction
{
[JsonPropertyName("id")]
public string Id { get; set; } = string.Empty;
[JsonPropertyName("posted")]
public long Posted { get; set; }
[JsonPropertyName("amount")]
public string Amount { get; set; } = "0";
[JsonPropertyName("description")]
public string Description { get; set; } = string.Empty;
[JsonPropertyName("payee")]
public string? Payee { get; set; }
[JsonPropertyName("memo")]
public string? Memo { get; set; }
[JsonPropertyName("transacted_at")]
public long? TransactedAt { get; set; }
[JsonPropertyName("pending")]
public bool Pending { get; set; }
}
public class SimpleFinOrg
{
[JsonPropertyName("domain")]
public string? Domain { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("sfin-url")]
public string? SfinUrl { get; set; }
}
@@ -0,0 +1,205 @@
using Going.Plaid;
using Going.Plaid.Entity;
using Going.Plaid.Item;
using Going.Plaid.Link;
using Microsoft.Extensions.Configuration;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models;
using Purrse.Plugins.BankSync.Models;
namespace Purrse.Plugins.BankSync;
public class PlaidSyncProvider : IAccountSyncProvider
{
private readonly PlaidClient _client;
private readonly PlaidConfig _config;
public string Id => "plaid-sync";
public string Name => "Plaid Bank Sync";
public string Version => "1.0.0";
public string Description => "Sync transactions from banks via Plaid";
public PlaidSyncProvider(IConfiguration configuration)
{
_config = new PlaidConfig
{
ClientId = configuration["BankSync:Plaid:ClientId"] ?? string.Empty,
Secret = configuration["BankSync:Plaid:Secret"] ?? string.Empty,
Environment = configuration["BankSync:Plaid:Environment"] ?? "sandbox"
};
var env = _config.Environment.ToLowerInvariant() switch
{
"production" => Going.Plaid.Environment.Production,
"development" => Going.Plaid.Environment.Development,
_ => Going.Plaid.Environment.Sandbox
};
_client = new PlaidClient(env);
}
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
public Task ShutdownAsync() => Task.CompletedTask;
public async Task<PlaidLinkTokenResult> CreateLinkTokenAsync(string userId)
{
var response = await _client.LinkTokenCreateAsync(new LinkTokenCreateRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
User = new LinkTokenCreateRequestUser { ClientUserId = userId },
ClientName = "Purrse",
Products = new[] { Products.Transactions },
CountryCodes = new[] { CountryCode.Us },
Language = Language.English
});
return new PlaidLinkTokenResult
{
LinkToken = response.LinkToken,
Expiration = response.Expiration.ToString("o")
};
}
public async Task<PlaidExchangeResult> ExchangePublicTokenAsync(string publicToken)
{
var response = await _client.ItemPublicTokenExchangeAsync(new ItemPublicTokenExchangeRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
PublicToken = publicToken
});
string? institutionName = null;
string? institutionId = null;
try
{
var itemResponse = await _client.ItemGetAsync(new ItemGetRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
AccessToken = response.AccessToken
});
institutionId = itemResponse.Item.InstitutionId;
if (institutionId != null)
{
var instResponse = await _client.InstitutionsGetByIdAsync(
new Going.Plaid.Institutions.InstitutionsGetByIdRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
InstitutionId = institutionId,
CountryCodes = new[] { CountryCode.Us }
});
institutionName = instResponse.Institution.Name;
}
}
catch
{
// Non-critical; we can proceed without institution name
}
return new PlaidExchangeResult
{
AccessToken = response.AccessToken,
ItemId = response.ItemId,
InstitutionId = institutionId,
InstitutionName = institutionName
};
}
public async Task<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration)
{
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var response = await _client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
AccessToken = accessToken
});
return response.Accounts.Select(a => new SyncAccount
{
ExternalId = a.AccountId,
Name = a.Name ?? a.OfficialName ?? "Unknown",
AccountType = MapAccountType(a.Type, a.Subtype),
Balance = (decimal?)(a.Balances?.Current ?? a.Balances?.Available),
Currency = a.Balances?.IsoCurrencyCode ?? "USD"
}).ToList();
}
public async Task<ParseResult> SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary<string, string> configuration)
{
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var allTransactions = new List<Going.Plaid.Entity.Transaction>();
var offset = 0;
const int batchSize = 500;
while (true)
{
var response = await _client.TransactionsGetAsync(new Going.Plaid.Transactions.TransactionsGetRequest
{
ClientId = _config.ClientId,
Secret = _config.Secret,
AccessToken = accessToken,
StartDate = DateOnly.FromDateTime(startDate),
EndDate = DateOnly.FromDateTime(endDate),
Options = new TransactionsGetRequestOptions
{
AccountIds = new[] { accountId },
Count = batchSize,
Offset = offset
}
});
allTransactions.AddRange(response.Transactions);
offset += response.Transactions.Count;
if (offset >= response.TotalTransactions)
break;
}
var result = new ParseResult
{
Success = true,
Transactions = allTransactions.Select(t => new ParsedTransaction
{
Date = t.Date?.ToDateTime(TimeOnly.MinValue) ?? DateTime.UtcNow,
Amount = (decimal)(t.Amount ?? 0) * -1, // Plaid uses positive for debits
PayeeName = t.MerchantName ?? t.Name,
Memo = t.Name,
FitId = t.TransactionId,
TransactionType = (t.Amount ?? 0) < 0 ? "credit" : "debit"
}).ToList()
};
return result;
}
private static string MapAccountType(AccountType? type, AccountSubtype? subtype)
{
return type switch
{
AccountType.Depository => subtype switch
{
AccountSubtype.Checking => "Checking",
AccountSubtype.Savings => "Savings",
_ => "Checking"
},
AccountType.Credit => "CreditCard",
AccountType.Loan => subtype switch
{
AccountSubtype.Auto => "AutoLoan",
AccountSubtype.Mortgage => "Mortgage",
AccountSubtype.Student => "PersonalLoan",
_ => "PersonalLoan"
},
AccountType.Investment => "Brokerage",
_ => "Checking"
};
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Going.Plaid" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Purrse.Plugins.Abstractions\Purrse.Plugins.Abstractions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,106 @@
using System.Net.Http.Json;
using System.Text;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models;
using Purrse.Plugins.BankSync.Models;
namespace Purrse.Plugins.BankSync;
public class SimpleFinSyncProvider : IAccountSyncProvider
{
private readonly HttpClient _httpClient;
public string Id => "simplefin-sync";
public string Name => "SimpleFIN Bank Sync";
public string Version => "1.0.0";
public string Description => "Sync transactions from banks via SimpleFIN Bridge";
public SimpleFinSyncProvider(HttpClient httpClient)
{
_httpClient = httpClient;
}
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
public Task ShutdownAsync() => Task.CompletedTask;
public async Task<string> ExchangeSetupTokenAsync(string setupToken)
{
// Decode base64 setup token to get the claim URL
var claimUrl = Encoding.UTF8.GetString(Convert.FromBase64String(setupToken));
var response = await _httpClient.PostAsync(claimUrl, null);
response.EnsureSuccessStatusCode();
var accessUrl = await response.Content.ReadAsStringAsync();
return accessUrl.Trim();
}
public async Task<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration)
{
var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty;
var response = await _httpClient.GetFromJsonAsync<SimpleFinAccountSet>($"{accessUrl}/accounts");
if (response == null)
return new List<SyncAccount>();
return response.Accounts.Select(a => new SyncAccount
{
ExternalId = a.Id,
Name = a.Name,
Institution = a.Org?.Name,
AccountType = GuessAccountType(a.Name),
Balance = decimal.TryParse(a.Balance, out var bal) ? bal : null,
Currency = a.Currency
}).ToList();
}
public async Task<ParseResult> SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary<string, string> configuration)
{
var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty;
var startUnix = new DateTimeOffset(startDate).ToUnixTimeSeconds();
var endUnix = new DateTimeOffset(endDate).ToUnixTimeSeconds();
var response = await _httpClient.GetFromJsonAsync<SimpleFinAccountSet>(
$"{accessUrl}/accounts?start-date={startUnix}&end-date={endUnix}");
if (response == null)
return new ParseResult { Success = false, ErrorMessage = "No response from SimpleFIN" };
var account = response.Accounts.FirstOrDefault(a => a.Id == accountId);
if (account == null)
return new ParseResult { Success = true, Transactions = new List<ParsedTransaction>() };
var result = new ParseResult
{
Success = true,
LedgerBalance = decimal.TryParse(account.Balance, out var bal) ? bal : null,
Transactions = account.Transactions
.Where(t => !t.Pending)
.Select(t => new ParsedTransaction
{
Date = DateTimeOffset.FromUnixTimeSeconds(t.Posted).UtcDateTime,
Amount = decimal.TryParse(t.Amount, out var amt) ? amt : 0,
PayeeName = t.Payee ?? t.Description,
Memo = t.Memo ?? t.Description,
FitId = t.Id,
TransactionType = decimal.TryParse(t.Amount, out var a) && a >= 0 ? "credit" : "debit"
}).ToList()
};
return result;
}
private static string GuessAccountType(string name)
{
var lower = name.ToLowerInvariant();
if (lower.Contains("checking")) return "Checking";
if (lower.Contains("savings")) return "Savings";
if (lower.Contains("credit")) return "CreditCard";
if (lower.Contains("loan")) return "PersonalLoan";
if (lower.Contains("mortgage")) return "Mortgage";
if (lower.Contains("401k") || lower.Contains("retirement")) return "Retirement401k";
if (lower.Contains("ira")) return "IRA";
if (lower.Contains("brokerage") || lower.Contains("invest")) return "Brokerage";
return "Checking";
}
}