Make Plaid credentials per-user, configurable via Settings UI
Plaid ClientId/Secret are now stored per-user in the database (encrypted) instead of in appsettings.json. Users enter their own Plaid developer credentials in the Settings > Bank Connections tab. - New BankSyncSettings model (1:1 with User) for encrypted credentials - PlaidSyncProvider is now stateless, receives credentials per-call - Settings UI shows a credentials card with save form - Connect via Plaid button disabled until credentials are configured - Removed Plaid section from appsettings.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user