|
|
|
@@ -8,6 +8,7 @@ using Purrse.Core.Interfaces.Services;
|
|
|
|
|
using Purrse.Core.Models;
|
|
|
|
|
using Purrse.Data;
|
|
|
|
|
using Purrse.Plugins.BankSync;
|
|
|
|
|
using Purrse.Plugins.BankSync.Models;
|
|
|
|
|
|
|
|
|
|
namespace Purrse.Api.Services;
|
|
|
|
|
|
|
|
|
@@ -42,6 +43,42 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
_logger = logger;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Provider Credentials ---
|
|
|
|
|
|
|
|
|
|
public async Task<PlaidCredentialsResponse> GetPlaidCredentialsAsync(Guid userId)
|
|
|
|
|
{
|
|
|
|
|
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
|
|
|
|
if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId))
|
|
|
|
|
return new PlaidCredentialsResponse(false, "sandbox");
|
|
|
|
|
|
|
|
|
|
return new PlaidCredentialsResponse(true, settings.PlaidEnvironment);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<PlaidCredentialsResponse> SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request)
|
|
|
|
|
{
|
|
|
|
|
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
|
|
|
|
|
|
|
|
|
if (settings == null)
|
|
|
|
|
{
|
|
|
|
|
settings = new BankSyncSettings
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
UserId = userId
|
|
|
|
|
};
|
|
|
|
|
_db.BankSyncSettings.Add(settings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
settings.EncryptedPlaidClientId = _encryption.Encrypt(request.ClientId);
|
|
|
|
|
settings.EncryptedPlaidSecret = _encryption.Encrypt(request.Secret);
|
|
|
|
|
settings.PlaidEnvironment = request.Environment;
|
|
|
|
|
settings.UpdatedAt = DateTime.UtcNow;
|
|
|
|
|
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
return new PlaidCredentialsResponse(true, settings.PlaidEnvironment);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Connection CRUD ---
|
|
|
|
|
|
|
|
|
|
public async Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId)
|
|
|
|
|
{
|
|
|
|
|
var connections = await _db.SyncConnections
|
|
|
|
@@ -88,15 +125,19 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Plaid Flow ---
|
|
|
|
|
|
|
|
|
|
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
|
|
|
|
|
{
|
|
|
|
|
var result = await _plaid.CreateLinkTokenAsync(userId.ToString());
|
|
|
|
|
var credentials = await GetPlaidCredentialsInternal(userId);
|
|
|
|
|
var result = await _plaid.CreateLinkTokenAsync(userId.ToString(), credentials);
|
|
|
|
|
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
|
|
|
|
|
{
|
|
|
|
|
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken);
|
|
|
|
|
var credentials = await GetPlaidCredentialsInternal(userId);
|
|
|
|
|
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken, credentials);
|
|
|
|
|
|
|
|
|
|
var connection = new SyncConnection
|
|
|
|
|
{
|
|
|
|
@@ -113,32 +154,9 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
// Discover accounts
|
|
|
|
|
await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken);
|
|
|
|
|
var config = BuildProviderConfig(connection, exchangeResult.AccessToken, credentials);
|
|
|
|
|
var accounts = await _plaid.GetAccountsAsync(config);
|
|
|
|
|
|
|
|
|
|
return await GetConnectionAsync(userId, connection.Id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
|
|
|
|
|
{
|
|
|
|
|
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
|
|
|
|
|
|
|
|
|
|
// Get accounts to determine institution name
|
|
|
|
|
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
|
|
|
|
|
var accounts = await _simpleFin.GetAccountsAsync(config);
|
|
|
|
|
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
|
|
|
|
|
|
|
|
|
|
var connection = new SyncConnection
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
UserId = userId,
|
|
|
|
|
Provider = SyncProvider.SimpleFIN,
|
|
|
|
|
InstitutionName = institutionName,
|
|
|
|
|
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_db.SyncConnections.Add(connection);
|
|
|
|
|
|
|
|
|
|
// Create linked accounts from discovered accounts
|
|
|
|
|
foreach (var account in accounts)
|
|
|
|
|
{
|
|
|
|
|
_db.LinkedAccounts.Add(new LinkedAccount
|
|
|
|
@@ -157,6 +175,47 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
return await GetConnectionAsync(userId, connection.Id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- SimpleFIN Flow ---
|
|
|
|
|
|
|
|
|
|
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
|
|
|
|
|
{
|
|
|
|
|
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
|
|
|
|
|
|
|
|
|
|
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
|
|
|
|
|
var accounts = await _simpleFin.GetAccountsAsync(config);
|
|
|
|
|
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
|
|
|
|
|
|
|
|
|
|
var connection = new SyncConnection
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
UserId = userId,
|
|
|
|
|
Provider = SyncProvider.SimpleFIN,
|
|
|
|
|
InstitutionName = institutionName,
|
|
|
|
|
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
_db.SyncConnections.Add(connection);
|
|
|
|
|
|
|
|
|
|
foreach (var account in accounts)
|
|
|
|
|
{
|
|
|
|
|
_db.LinkedAccounts.Add(new LinkedAccount
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
SyncConnectionId = connection.Id,
|
|
|
|
|
ExternalAccountId = account.ExternalId,
|
|
|
|
|
ExternalAccountName = account.Name,
|
|
|
|
|
ExternalAccountType = account.AccountType,
|
|
|
|
|
LastKnownBalance = account.Balance
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
return await GetConnectionAsync(userId, connection.Id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Account Linking ---
|
|
|
|
|
|
|
|
|
|
public async Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId)
|
|
|
|
|
{
|
|
|
|
|
var connection = await _db.SyncConnections
|
|
|
|
@@ -164,19 +223,16 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
|
|
|
|
|
|
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
|
|
|
|
|
var config = BuildProviderConfig(connection, accessToken);
|
|
|
|
|
PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid
|
|
|
|
|
? await GetPlaidCredentialsInternal(userId) : null;
|
|
|
|
|
var config = BuildProviderConfig(connection, accessToken, plaidCreds);
|
|
|
|
|
|
|
|
|
|
var accounts = connection.Provider == SyncProvider.Plaid
|
|
|
|
|
? await _plaid.GetAccountsAsync(config)
|
|
|
|
|
: await _simpleFin.GetAccountsAsync(config);
|
|
|
|
|
|
|
|
|
|
return accounts.Select(a => new DiscoveredAccountResponse(
|
|
|
|
|
a.ExternalId,
|
|
|
|
|
a.Name,
|
|
|
|
|
a.AccountType,
|
|
|
|
|
null,
|
|
|
|
|
a.Balance,
|
|
|
|
|
a.Currency
|
|
|
|
|
a.ExternalId, a.Name, a.AccountType, null, a.Balance, a.Currency
|
|
|
|
|
)).ToList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -193,7 +249,6 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
|
|
|
|
|
if (request.AutoCreate && request.AccountId == null)
|
|
|
|
|
{
|
|
|
|
|
// Auto-create a Purrse account
|
|
|
|
|
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
|
|
|
|
|
? at : AccountType.Checking;
|
|
|
|
|
|
|
|
|
@@ -216,7 +271,6 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
// Reload with Account nav
|
|
|
|
|
linkedAccount = await _db.LinkedAccounts
|
|
|
|
|
.Include(la => la.Account)
|
|
|
|
|
.FirstAsync(la => la.Id == linkedAccount.Id);
|
|
|
|
@@ -239,6 +293,8 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
return MapLinkedAccountResponse(linkedAccount);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Sync ---
|
|
|
|
|
|
|
|
|
|
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
|
|
|
|
|
{
|
|
|
|
|
var connection = await _db.SyncConnections
|
|
|
|
@@ -269,15 +325,11 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
totalSynced += result.AccountsSynced;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new SyncResultResponse(
|
|
|
|
|
allResults.Count,
|
|
|
|
|
totalSynced,
|
|
|
|
|
totalFetched,
|
|
|
|
|
totalImported,
|
|
|
|
|
totalSkipped,
|
|
|
|
|
allResults);
|
|
|
|
|
return new SyncResultResponse(allResults.Count, totalSynced, totalFetched, totalImported, totalSkipped, allResults);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Logs ---
|
|
|
|
|
|
|
|
|
|
public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50)
|
|
|
|
|
{
|
|
|
|
|
var query = _db.SyncLogs
|
|
|
|
@@ -292,26 +344,37 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
.OrderByDescending(l => l.StartedAt)
|
|
|
|
|
.Take(limit)
|
|
|
|
|
.Select(l => new SyncLogResponse(
|
|
|
|
|
l.Id,
|
|
|
|
|
l.SyncConnectionId,
|
|
|
|
|
l.SyncConnection.InstitutionName,
|
|
|
|
|
l.Id, l.SyncConnectionId, l.SyncConnection.InstitutionName,
|
|
|
|
|
l.LinkedAccountId,
|
|
|
|
|
l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null,
|
|
|
|
|
l.Status,
|
|
|
|
|
l.TransactionsFetched,
|
|
|
|
|
l.TransactionsImported,
|
|
|
|
|
l.TransactionsSkipped,
|
|
|
|
|
l.ErrorMessage,
|
|
|
|
|
l.StartedAt,
|
|
|
|
|
l.CompletedAt,
|
|
|
|
|
l.Duration))
|
|
|
|
|
l.Status, l.TransactionsFetched, l.TransactionsImported, l.TransactionsSkipped,
|
|
|
|
|
l.ErrorMessage, l.StartedAt, l.CompletedAt, l.Duration))
|
|
|
|
|
.ToListAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Internal Helpers ---
|
|
|
|
|
|
|
|
|
|
private async Task<PlaidConfig> GetPlaidCredentialsInternal(Guid userId)
|
|
|
|
|
{
|
|
|
|
|
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
|
|
|
|
|
|
|
|
|
|
if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId) || string.IsNullOrEmpty(settings.EncryptedPlaidSecret))
|
|
|
|
|
throw new InvalidOperationException("Plaid credentials are not configured. Go to Settings to enter your Plaid API keys.");
|
|
|
|
|
|
|
|
|
|
return new PlaidConfig
|
|
|
|
|
{
|
|
|
|
|
ClientId = _encryption.Decrypt(settings.EncryptedPlaidClientId),
|
|
|
|
|
Secret = _encryption.Decrypt(settings.EncryptedPlaidSecret),
|
|
|
|
|
Environment = settings.PlaidEnvironment
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
|
|
|
|
|
{
|
|
|
|
|
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
|
|
|
|
|
var config = BuildProviderConfig(connection, accessToken);
|
|
|
|
|
PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid
|
|
|
|
|
? await GetPlaidCredentialsInternal(userId) : null;
|
|
|
|
|
var config = BuildProviderConfig(connection, accessToken, plaidCreds);
|
|
|
|
|
var enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList();
|
|
|
|
|
|
|
|
|
|
var accountResults = new List<SyncAccountResult>();
|
|
|
|
@@ -349,10 +412,8 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
log.Duration = log.CompletedAt - log.StartedAt;
|
|
|
|
|
|
|
|
|
|
accountResults.Add(new SyncAccountResult(
|
|
|
|
|
linkedAccount.ExternalAccountName,
|
|
|
|
|
linkedAccount.Account?.Name,
|
|
|
|
|
0, 0, 0,
|
|
|
|
|
parseResult.ErrorMessage));
|
|
|
|
|
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
|
|
|
|
|
0, 0, 0, parseResult.ErrorMessage));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -399,7 +460,6 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
imported++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update account balance
|
|
|
|
|
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
|
|
|
|
|
{
|
|
|
|
|
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
|
|
|
|
@@ -421,8 +481,7 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
totalSkipped += skipped;
|
|
|
|
|
|
|
|
|
|
accountResults.Add(new SyncAccountResult(
|
|
|
|
|
linkedAccount.ExternalAccountName,
|
|
|
|
|
linkedAccount.Account?.Name,
|
|
|
|
|
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
|
|
|
|
|
fetched, imported, skipped, null));
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
@@ -436,8 +495,7 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
log.Duration = log.CompletedAt - log.StartedAt;
|
|
|
|
|
|
|
|
|
|
accountResults.Add(new SyncAccountResult(
|
|
|
|
|
linkedAccount.ExternalAccountName,
|
|
|
|
|
linkedAccount.Account?.Name,
|
|
|
|
|
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
|
|
|
|
|
0, 0, 0, ex.Message));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -450,7 +508,6 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
|
|
|
|
|
// Send SignalR notification
|
|
|
|
|
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
|
|
|
|
|
{
|
|
|
|
|
connectionId = connection.Id,
|
|
|
|
@@ -463,67 +520,39 @@ public class BankSyncService : IBankSyncService
|
|
|
|
|
return new SyncResultResponse(
|
|
|
|
|
enabledAccounts.Count,
|
|
|
|
|
accountResults.Count(r => r.Error == null),
|
|
|
|
|
totalFetched,
|
|
|
|
|
totalImported,
|
|
|
|
|
totalSkipped,
|
|
|
|
|
totalFetched, totalImported, totalSkipped,
|
|
|
|
|
accountResults);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken)
|
|
|
|
|
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials)
|
|
|
|
|
{
|
|
|
|
|
var config = BuildProviderConfig(connection, accessToken);
|
|
|
|
|
var accounts = await _plaid.GetAccountsAsync(config);
|
|
|
|
|
|
|
|
|
|
foreach (var account in accounts)
|
|
|
|
|
if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
|
|
|
|
|
{
|
|
|
|
|
_db.LinkedAccounts.Add(new LinkedAccount
|
|
|
|
|
return new Dictionary<string, string>
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
SyncConnectionId = connection.Id,
|
|
|
|
|
ExternalAccountId = account.ExternalId,
|
|
|
|
|
ExternalAccountName = account.Name,
|
|
|
|
|
ExternalAccountType = account.AccountType,
|
|
|
|
|
LastKnownBalance = account.Balance
|
|
|
|
|
});
|
|
|
|
|
["AccessToken"] = accessToken,
|
|
|
|
|
["PlaidClientId"] = plaidCredentials.ClientId,
|
|
|
|
|
["PlaidSecret"] = plaidCredentials.Secret,
|
|
|
|
|
["PlaidEnvironment"] = plaidCredentials.Environment
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken)
|
|
|
|
|
{
|
|
|
|
|
return connection.Provider == SyncProvider.Plaid
|
|
|
|
|
? new Dictionary<string, string> { ["AccessToken"] = accessToken }
|
|
|
|
|
: new Dictionary<string, string> { ["AccessUrl"] = accessToken };
|
|
|
|
|
return new Dictionary<string, string> { ["AccessUrl"] = accessToken };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static SyncConnectionResponse MapConnectionResponse(SyncConnection c)
|
|
|
|
|
{
|
|
|
|
|
return new SyncConnectionResponse(
|
|
|
|
|
c.Id,
|
|
|
|
|
c.Provider,
|
|
|
|
|
c.InstitutionName,
|
|
|
|
|
c.InstitutionId,
|
|
|
|
|
c.IsActive,
|
|
|
|
|
c.SyncIntervalMinutes,
|
|
|
|
|
c.LastSyncAt,
|
|
|
|
|
c.LastSyncError,
|
|
|
|
|
c.CreatedAt,
|
|
|
|
|
c.Id, c.Provider, c.InstitutionName, c.InstitutionId,
|
|
|
|
|
c.IsActive, c.SyncIntervalMinutes, c.LastSyncAt, c.LastSyncError, c.CreatedAt,
|
|
|
|
|
c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la)
|
|
|
|
|
{
|
|
|
|
|
return new LinkedAccountResponse(
|
|
|
|
|
la.Id,
|
|
|
|
|
la.ExternalAccountId,
|
|
|
|
|
la.ExternalAccountName,
|
|
|
|
|
la.ExternalAccountType,
|
|
|
|
|
la.ExternalAccountMask,
|
|
|
|
|
la.LastKnownBalance,
|
|
|
|
|
la.IsEnabled,
|
|
|
|
|
la.AccountId,
|
|
|
|
|
la.Account?.Name,
|
|
|
|
|
la.LastSyncAt);
|
|
|
|
|
la.Id, la.ExternalAccountId, la.ExternalAccountName,
|
|
|
|
|
la.ExternalAccountType, la.ExternalAccountMask, la.LastKnownBalance,
|
|
|
|
|
la.IsEnabled, la.AccountId, la.Account?.Name, la.LastSyncAt);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|