Private
Public Access
1
0

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:
Catherine Renelle
2026-02-08 16:50:48 -05:00
parent 849a392d14
commit 9d6a006045
17 changed files with 1847 additions and 160 deletions
+137 -108
View File
@@ -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);
}
}