using System.Net.Http.Headers; 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 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, new StringContent(string.Empty)); if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) throw new InvalidOperationException("Setup token has already been claimed or is invalid. Please generate a new token from SimpleFIN Bridge."); response.EnsureSuccessStatusCode(); var accessUrl = await response.Content.ReadAsStringAsync(); return accessUrl.Trim(); } public async Task> GetAccountsAsync(Dictionary configuration) { var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty; var response = await GetWithAuthAsync($"{accessUrl}/accounts"); if (response == null) return new List(); 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 SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary configuration) { var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty; var startUnix = new DateTimeOffset(startDate).ToUnixTimeSeconds(); var endUnix = new DateTimeOffset(endDate).ToUnixTimeSeconds(); var response = await GetWithAuthAsync( $"{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() }; 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.Description, FitId = t.Id, TransactionType = decimal.TryParse(t.Amount, out var a) && a >= 0 ? "credit" : "debit" }).ToList() }; return result; } /// /// HttpClient does not extract Basic Auth credentials from URLs (user:pass@host). /// This method parses the URI, builds the Authorization header, and strips credentials from the request URL. /// private async Task GetWithAuthAsync(string url) { var uri = new Uri(url); var request = new HttpRequestMessage(HttpMethod.Get, new UriBuilder(uri) { UserName = "", Password = "" }.Uri); if (!string.IsNullOrEmpty(uri.UserInfo)) { var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(uri.UserInfo)); request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); } var response = await _httpClient.SendAsync(request); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(); } 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"; } }