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,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";
}
}