757fcec621
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>
129 lines
5.3 KiB
C#
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";
|
|
}
|
|
}
|