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,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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user