Private
Public Access
1
0
Files
Purrse/src/Purrse.Plugins.BankSync/SimpleFinSyncProvider.cs
T
Catherine Renelle 757fcec621 Always store full bank description in memo for SimpleFIN
Use the raw Description field as memo so the full bank text
(e.g. "Card purchase BUYVM VPS* FRANTECH 11 MY.FRANTECH.C WY
02-08-2026") is always visible. The cleaned Payee field is still
used for the payee name and matching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:04:39 -05:00

129 lines
5.3 KiB
C#

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<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, 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<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration)
{
var accessUrl = configuration.GetValueOrDefault("AccessUrl") ?? string.Empty;
var response = await GetWithAuthAsync($"{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 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<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.Description,
FitId = t.Id,
TransactionType = decimal.TryParse(t.Amount, out var a) && a >= 0 ? "credit" : "debit"
}).ToList()
};
return result;
}
/// <summary>
/// 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.
/// </summary>
private async Task<SimpleFinAccountSet?> 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<SimpleFinAccountSet>();
}
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";
}
}