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
+8 -1
View File
@@ -5,10 +5,17 @@ import type {
DiscoveredAccount, DiscoveredAccount,
SyncResult, SyncResult,
SyncLogEntry, SyncLogEntry,
CreateLinkTokenResponse CreateLinkTokenResponse,
PlaidCredentials
} from '@/types' } from '@/types'
export const bankSyncApi = { export const bankSyncApi = {
getPlaidCredentials: () =>
api.get<PlaidCredentials>('/bank-sync/plaid/credentials'),
savePlaidCredentials: (data: { clientId: string; secret: string; environment: string }) =>
api.post<PlaidCredentials>('/bank-sync/plaid/credentials', data),
getConnections: () => getConnections: () =>
api.get<SyncConnection[]>('/bank-sync/connections'), api.get<SyncConnection[]>('/bank-sync/connections'),
+5
View File
@@ -404,6 +404,11 @@ export interface AppNotification {
} }
// Bank Sync // Bank Sync
export interface PlaidCredentials {
isConfigured: boolean
environment: string
}
export type SyncProvider = 'Plaid' | 'SimpleFIN' export type SyncProvider = 'Plaid' | 'SimpleFIN'
export interface SyncConnection { export interface SyncConnection {
+97 -5
View File
@@ -25,8 +25,63 @@
<!-- Tab 2: Bank Connections --> <!-- Tab 2: Bank Connections -->
<v-tabs-window-item value="connections"> <v-tabs-window-item value="connections">
<!-- Plaid API Credentials -->
<v-card class="mb-4">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-key</v-icon>
Plaid API Credentials
<v-chip v-if="plaidCredentials.isConfigured" color="success" size="small" class="ml-2">Configured</v-chip>
<v-chip v-else color="warning" size="small" class="ml-2">Not configured</v-chip>
</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
Enter your Plaid developer credentials. Get them from your Plaid dashboard.
</p>
<v-row dense>
<v-col cols="12" sm="4">
<v-text-field
v-model="plaidForm.clientId"
label="Client ID"
variant="outlined"
density="compact"
:placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
/>
</v-col>
<v-col cols="12" sm="4">
<v-text-field
v-model="plaidForm.secret"
label="Secret"
variant="outlined"
density="compact"
type="password"
:placeholder="plaidCredentials.isConfigured ? '(saved)' : ''"
/>
</v-col>
<v-col cols="12" sm="2">
<v-select
v-model="plaidForm.environment"
:items="plaidEnvironments"
label="Environment"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" sm="2" class="d-flex align-center">
<v-btn
color="primary"
:loading="plaidCredsSaving"
:disabled="!plaidForm.clientId || !plaidForm.secret"
@click="savePlaidCredentials"
>
Save
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<div class="d-flex justify-end mb-4 ga-2"> <div class="d-flex justify-end mb-4 ga-2">
<v-btn color="primary" prepend-icon="mdi-bank-plus" @click="startPlaidLink" :loading="plaidLoading"> <v-btn color="primary" prepend-icon="mdi-bank-plus" @click="startPlaidLink" :loading="plaidLoading" :disabled="!plaidCredentials.isConfigured">
Connect via Plaid Connect via Plaid
</v-btn> </v-btn>
<v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true"> <v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true">
@@ -35,7 +90,7 @@
</div> </div>
<v-alert v-if="connections.length === 0 && !loading" type="info" variant="tonal" class="mb-4"> <v-alert v-if="connections.length === 0 && !loading" type="info" variant="tonal" class="mb-4">
No bank connections yet. Click a button above to connect your bank. No bank connections yet. Configure your API credentials above, then click a button to connect your bank.
</v-alert> </v-alert>
<v-card v-for="conn in connections" :key="conn.id" class="mb-4"> <v-card v-for="conn in connections" :key="conn.id" class="mb-4">
@@ -308,7 +363,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { bankSyncApi } from '@/services/bankSync' import { bankSyncApi } from '@/services/bankSync'
import { accountsApi } from '@/services/accounts' import { accountsApi } from '@/services/accounts'
import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry } from '@/types' import type { SyncConnection, LinkedAccountInfo, Account, SyncLogEntry, PlaidCredentials } from '@/types'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -319,7 +374,13 @@ const accounts = ref<Account[]>([])
const syncIntervals = reactive<Record<string, number>>({}) const syncIntervals = reactive<Record<string, number>>({})
const syncingConnections = reactive<Record<string, boolean>>({}) const syncingConnections = reactive<Record<string, boolean>>({})
// Plaid // Plaid credentials
const plaidCredentials = ref<PlaidCredentials>({ isConfigured: false, environment: 'sandbox' })
const plaidForm = reactive({ clientId: '', secret: '', environment: 'sandbox' })
const plaidCredsSaving = ref(false)
const plaidEnvironments = ['sandbox', 'development', 'production']
// Plaid link
const plaidLoading = ref(false) const plaidLoading = ref(false)
// SimpleFIN // SimpleFIN
@@ -396,6 +457,37 @@ function formatCurrency(value: number) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value) return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value)
} }
async function loadPlaidCredentials() {
try {
const { data } = await bankSyncApi.getPlaidCredentials()
plaidCredentials.value = data
plaidForm.environment = data.environment
} catch {
// Not configured yet
}
}
async function savePlaidCredentials() {
plaidCredsSaving.value = true
try {
const { data } = await bankSyncApi.savePlaidCredentials({
clientId: plaidForm.clientId,
secret: plaidForm.secret,
environment: plaidForm.environment
})
plaidCredentials.value = data
plaidForm.clientId = ''
plaidForm.secret = ''
syncResultMessage.value = 'Plaid credentials saved'
showSyncResult.value = true
} catch (err: any) {
errorMessage.value = err.response?.data?.error || 'Failed to save Plaid credentials'
showError.value = true
} finally {
plaidCredsSaving.value = false
}
}
async function loadConnections() { async function loadConnections() {
loading.value = true loading.value = true
try { try {
@@ -583,6 +675,6 @@ async function deleteConnection() {
} }
onMounted(async () => { onMounted(async () => {
await Promise.all([loadConnections(), loadAccounts(), loadSyncLogs()]) await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs()])
}) })
</script> </script>
@@ -35,6 +35,14 @@ public class BankSyncController : ControllerBase
return NoContent(); return NoContent();
} }
[HttpGet("plaid/credentials")]
public async Task<ActionResult<PlaidCredentialsResponse>> GetPlaidCredentials()
=> Ok(await _bankSyncService.GetPlaidCredentialsAsync(UserId));
[HttpPost("plaid/credentials")]
public async Task<ActionResult<PlaidCredentialsResponse>> SavePlaidCredentials(SavePlaidCredentialsRequest request)
=> Ok(await _bankSyncService.SavePlaidCredentialsAsync(UserId, request));
[HttpPost("plaid/create-link-token")] [HttpPost("plaid/create-link-token")]
public async Task<ActionResult<CreateLinkTokenResponse>> CreatePlaidLinkToken() public async Task<ActionResult<CreateLinkTokenResponse>> CreatePlaidLinkToken()
=> Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId)); => Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId));
+1 -1
View File
@@ -86,7 +86,7 @@ builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>(); builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
// Bank sync providers // Bank sync providers
builder.Services.AddSingleton<PlaidSyncProvider>(); builder.Services.AddSingleton<PlaidSyncProvider>(); // Stateless — credentials passed per-call
builder.Services.AddHttpClient<SimpleFinSyncProvider>(); builder.Services.AddHttpClient<SimpleFinSyncProvider>();
// Plugin system // Plugin system
+137 -108
View File
@@ -8,6 +8,7 @@ using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models; using Purrse.Core.Models;
using Purrse.Data; using Purrse.Data;
using Purrse.Plugins.BankSync; using Purrse.Plugins.BankSync;
using Purrse.Plugins.BankSync.Models;
namespace Purrse.Api.Services; namespace Purrse.Api.Services;
@@ -42,6 +43,42 @@ public class BankSyncService : IBankSyncService
_logger = logger; _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) public async Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId)
{ {
var connections = await _db.SyncConnections var connections = await _db.SyncConnections
@@ -88,15 +125,19 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
// --- Plaid Flow ---
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId) 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); return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
} }
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request) 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 var connection = new SyncConnection
{ {
@@ -113,32 +154,9 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Discover accounts // 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) foreach (var account in accounts)
{ {
_db.LinkedAccounts.Add(new LinkedAccount _db.LinkedAccounts.Add(new LinkedAccount
@@ -157,6 +175,47 @@ public class BankSyncService : IBankSyncService
return await GetConnectionAsync(userId, connection.Id); 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) public async Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId)
{ {
var connection = await _db.SyncConnections var connection = await _db.SyncConnections
@@ -164,19 +223,16 @@ public class BankSyncService : IBankSyncService
?? throw new KeyNotFoundException("Connection not found"); ?? throw new KeyNotFoundException("Connection not found");
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken); 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 var accounts = connection.Provider == SyncProvider.Plaid
? await _plaid.GetAccountsAsync(config) ? await _plaid.GetAccountsAsync(config)
: await _simpleFin.GetAccountsAsync(config); : await _simpleFin.GetAccountsAsync(config);
return accounts.Select(a => new DiscoveredAccountResponse( return accounts.Select(a => new DiscoveredAccountResponse(
a.ExternalId, a.ExternalId, a.Name, a.AccountType, null, a.Balance, a.Currency
a.Name,
a.AccountType,
null,
a.Balance,
a.Currency
)).ToList(); )).ToList();
} }
@@ -193,7 +249,6 @@ public class BankSyncService : IBankSyncService
if (request.AutoCreate && request.AccountId == null) if (request.AutoCreate && request.AccountId == null)
{ {
// Auto-create a Purrse account
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at) var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
? at : AccountType.Checking; ? at : AccountType.Checking;
@@ -216,7 +271,6 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Reload with Account nav
linkedAccount = await _db.LinkedAccounts linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account) .Include(la => la.Account)
.FirstAsync(la => la.Id == linkedAccount.Id); .FirstAsync(la => la.Id == linkedAccount.Id);
@@ -239,6 +293,8 @@ public class BankSyncService : IBankSyncService
return MapLinkedAccountResponse(linkedAccount); return MapLinkedAccountResponse(linkedAccount);
} }
// --- Sync ---
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId) public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
{ {
var connection = await _db.SyncConnections var connection = await _db.SyncConnections
@@ -269,15 +325,11 @@ public class BankSyncService : IBankSyncService
totalSynced += result.AccountsSynced; totalSynced += result.AccountsSynced;
} }
return new SyncResultResponse( return new SyncResultResponse(allResults.Count, totalSynced, totalFetched, totalImported, totalSkipped, allResults);
allResults.Count,
totalSynced,
totalFetched,
totalImported,
totalSkipped,
allResults);
} }
// --- Logs ---
public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50) public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50)
{ {
var query = _db.SyncLogs var query = _db.SyncLogs
@@ -292,26 +344,37 @@ public class BankSyncService : IBankSyncService
.OrderByDescending(l => l.StartedAt) .OrderByDescending(l => l.StartedAt)
.Take(limit) .Take(limit)
.Select(l => new SyncLogResponse( .Select(l => new SyncLogResponse(
l.Id, l.Id, l.SyncConnectionId, l.SyncConnection.InstitutionName,
l.SyncConnectionId,
l.SyncConnection.InstitutionName,
l.LinkedAccountId, l.LinkedAccountId,
l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null, l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null,
l.Status, l.Status, l.TransactionsFetched, l.TransactionsImported, l.TransactionsSkipped,
l.TransactionsFetched, l.ErrorMessage, l.StartedAt, l.CompletedAt, l.Duration))
l.TransactionsImported,
l.TransactionsSkipped,
l.ErrorMessage,
l.StartedAt,
l.CompletedAt,
l.Duration))
.ToListAsync(); .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) private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
{ {
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken); 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 enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList();
var accountResults = new List<SyncAccountResult>(); var accountResults = new List<SyncAccountResult>();
@@ -349,10 +412,8 @@ public class BankSyncService : IBankSyncService
log.Duration = log.CompletedAt - log.StartedAt; log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult( accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName, linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
linkedAccount.Account?.Name, 0, 0, 0, parseResult.ErrorMessage));
0, 0, 0,
parseResult.ErrorMessage));
continue; continue;
} }
@@ -399,7 +460,6 @@ public class BankSyncService : IBankSyncService
imported++; imported++;
} }
// Update account balance
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue) if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
{ {
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value; linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
@@ -421,8 +481,7 @@ public class BankSyncService : IBankSyncService
totalSkipped += skipped; totalSkipped += skipped;
accountResults.Add(new SyncAccountResult( accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName, linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
linkedAccount.Account?.Name,
fetched, imported, skipped, null)); fetched, imported, skipped, null));
} }
catch (Exception ex) catch (Exception ex)
@@ -436,8 +495,7 @@ public class BankSyncService : IBankSyncService
log.Duration = log.CompletedAt - log.StartedAt; log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult( accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName, linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
linkedAccount.Account?.Name,
0, 0, 0, ex.Message)); 0, 0, 0, ex.Message));
} }
} }
@@ -450,7 +508,6 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Send SignalR notification
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
{ {
connectionId = connection.Id, connectionId = connection.Id,
@@ -463,67 +520,39 @@ public class BankSyncService : IBankSyncService
return new SyncResultResponse( return new SyncResultResponse(
enabledAccounts.Count, enabledAccounts.Count,
accountResults.Count(r => r.Error == null), accountResults.Count(r => r.Error == null),
totalFetched, totalFetched, totalImported, totalSkipped,
totalImported,
totalSkipped,
accountResults); 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); if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
var accounts = await _plaid.GetAccountsAsync(config);
foreach (var account in accounts)
{ {
_db.LinkedAccounts.Add(new LinkedAccount return new Dictionary<string, string>
{ {
Id = Guid.NewGuid(), ["AccessToken"] = accessToken,
SyncConnectionId = connection.Id, ["PlaidClientId"] = plaidCredentials.ClientId,
ExternalAccountId = account.ExternalId, ["PlaidSecret"] = plaidCredentials.Secret,
ExternalAccountName = account.Name, ["PlaidEnvironment"] = plaidCredentials.Environment
ExternalAccountType = account.AccountType, };
LastKnownBalance = account.Balance
});
} }
await _db.SaveChangesAsync(); return new Dictionary<string, string> { ["AccessUrl"] = accessToken };
}
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 };
} }
private static SyncConnectionResponse MapConnectionResponse(SyncConnection c) private static SyncConnectionResponse MapConnectionResponse(SyncConnection c)
{ {
return new SyncConnectionResponse( return new SyncConnectionResponse(
c.Id, c.Id, c.Provider, c.InstitutionName, c.InstitutionId,
c.Provider, c.IsActive, c.SyncIntervalMinutes, c.LastSyncAt, c.LastSyncError, c.CreatedAt,
c.InstitutionName,
c.InstitutionId,
c.IsActive,
c.SyncIntervalMinutes,
c.LastSyncAt,
c.LastSyncError,
c.CreatedAt,
c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList()); c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList());
} }
private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la) private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la)
{ {
return new LinkedAccountResponse( return new LinkedAccountResponse(
la.Id, la.Id, la.ExternalAccountId, la.ExternalAccountName,
la.ExternalAccountId, la.ExternalAccountType, la.ExternalAccountMask, la.LastKnownBalance,
la.ExternalAccountName, la.IsEnabled, la.AccountId, la.Account?.Name, la.LastSyncAt);
la.ExternalAccountType,
la.ExternalAccountMask,
la.LastKnownBalance,
la.IsEnabled,
la.AccountId,
la.Account?.Name,
la.LastSyncAt);
} }
} }
-5
View File
@@ -18,11 +18,6 @@
}, },
"BankSync": { "BankSync": {
"EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!", "EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!",
"Plaid": {
"ClientId": "",
"Secret": "",
"Environment": "sandbox"
},
"DefaultSyncIntervalMinutes": 360, "DefaultSyncIntervalMinutes": 360,
"BackgroundCheckIntervalMinutes": 15 "BackgroundCheckIntervalMinutes": 15
}, },
+4
View File
@@ -2,6 +2,10 @@ using Purrse.Core.Enums;
namespace Purrse.Core.DTOs; namespace Purrse.Core.DTOs;
// Provider credentials
public record SavePlaidCredentialsRequest(string ClientId, string Secret, string Environment);
public record PlaidCredentialsResponse(bool IsConfigured, string Environment);
// Plaid // Plaid
public record CreatePlaidConnectionRequest(string PublicToken); public record CreatePlaidConnectionRequest(string PublicToken);
public record CreateLinkTokenResponse(string LinkToken, string Expiration); public record CreateLinkTokenResponse(string LinkToken, string Expiration);
@@ -10,6 +10,10 @@ public interface IBankSyncService
Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request); Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request);
Task DeleteConnectionAsync(Guid userId, Guid connectionId); Task DeleteConnectionAsync(Guid userId, Guid connectionId);
// Provider credentials
Task<PlaidCredentialsResponse> GetPlaidCredentialsAsync(Guid userId);
Task<PlaidCredentialsResponse> SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request);
// Plaid flow // Plaid flow
Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId); Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId);
Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request); Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request);
@@ -0,0 +1,13 @@
namespace Purrse.Core.Models;
public class BankSyncSettings
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string? EncryptedPlaidClientId { get; set; }
public string? EncryptedPlaidSecret { get; set; }
public string PlaidEnvironment { get; set; } = "sandbox";
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;
}
+1
View File
@@ -13,4 +13,5 @@ public class User
public ICollection<Account> Accounts { get; set; } = new List<Account>(); public ICollection<Account> Accounts { get; set; } = new List<Account>();
public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>(); public ICollection<SyncConnection> SyncConnections { get; set; } = new List<SyncConnection>();
public BankSyncSettings? BankSyncSettings { get; set; }
} }
@@ -53,6 +53,25 @@ public class LinkedAccountConfiguration : IEntityTypeConfiguration<LinkedAccount
} }
} }
public class BankSyncSettingsConfiguration : IEntityTypeConfiguration<BankSyncSettings>
{
public void Configure(EntityTypeBuilder<BankSyncSettings> builder)
{
builder.ToTable("bank_sync_settings");
builder.HasKey(s => s.Id);
builder.Property(s => s.EncryptedPlaidClientId).HasColumnType("text");
builder.Property(s => s.EncryptedPlaidSecret).HasColumnType("text");
builder.Property(s => s.PlaidEnvironment).HasMaxLength(20);
builder.HasOne(s => s.User)
.WithOne(u => u.BankSyncSettings)
.HasForeignKey<BankSyncSettings>(s => s.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(s => s.UserId).IsUnique();
}
}
public class SyncLogConfiguration : IEntityTypeConfiguration<SyncLog> public class SyncLogConfiguration : IEntityTypeConfiguration<SyncLog>
{ {
public void Configure(EntityTypeBuilder<SyncLog> builder) public void Configure(EntityTypeBuilder<SyncLog> builder)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Purrse.Data.Migrations
{
/// <inheritdoc />
public partial class AddBankSyncSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "bank_sync_settings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
EncryptedPlaidClientId = table.Column<string>(type: "text", nullable: true),
EncryptedPlaidSecret = table.Column<string>(type: "text", nullable: true),
PlaidEnvironment = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_bank_sync_settings", x => x.Id);
table.ForeignKey(
name: "FK_bank_sync_settings_users_UserId",
column: x => x.UserId,
principalTable: "users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_bank_sync_settings_UserId",
table: "bank_sync_settings",
column: "UserId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "bank_sync_settings");
}
}
}
@@ -127,6 +127,37 @@ namespace Purrse.Data.Migrations
b.ToTable("amortization_entries", (string)null); b.ToTable("amortization_entries", (string)null);
}); });
modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("EncryptedPlaidClientId")
.HasColumnType("text");
b.Property<string>("EncryptedPlaidSecret")
.HasColumnType("text");
b.Property<string>("PlaidEnvironment")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("bank_sync_settings", (string)null);
});
modelBuilder.Entity("Purrse.Core.Models.Budget", b => modelBuilder.Entity("Purrse.Core.Models.Budget", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -974,6 +1005,17 @@ namespace Purrse.Data.Migrations
b.Navigation("LoanDetail"); b.Navigation("LoanDetail");
}); });
modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b =>
{
b.HasOne("Purrse.Core.Models.User", "User")
.WithOne("BankSyncSettings")
.HasForeignKey("Purrse.Core.Models.BankSyncSettings", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Purrse.Core.Models.Budget", b => modelBuilder.Entity("Purrse.Core.Models.Budget", b =>
{ {
b.HasOne("Purrse.Core.Models.User", "User") b.HasOne("Purrse.Core.Models.User", "User")
@@ -1354,6 +1396,8 @@ namespace Purrse.Data.Migrations
{ {
b.Navigation("Accounts"); b.Navigation("Accounts");
b.Navigation("BankSyncSettings");
b.Navigation("SyncConnections"); b.Navigation("SyncConnections");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
+1
View File
@@ -29,6 +29,7 @@ public class PurrseDbContext : DbContext
public DbSet<SyncConnection> SyncConnections => Set<SyncConnection>(); public DbSet<SyncConnection> SyncConnections => Set<SyncConnection>();
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>(); public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
public DbSet<SyncLog> SyncLogs => Set<SyncLog>(); public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -2,7 +2,6 @@ using Going.Plaid;
using Going.Plaid.Entity; using Going.Plaid.Entity;
using Going.Plaid.Item; using Going.Plaid.Item;
using Going.Plaid.Link; using Going.Plaid.Link;
using Microsoft.Extensions.Configuration;
using Purrse.Plugins.Abstractions; using Purrse.Plugins.Abstractions;
using Purrse.Plugins.Abstractions.Models; using Purrse.Plugins.Abstractions.Models;
using Purrse.Plugins.BankSync.Models; using Purrse.Plugins.BankSync.Models;
@@ -11,46 +10,37 @@ namespace Purrse.Plugins.BankSync;
public class PlaidSyncProvider : IAccountSyncProvider public class PlaidSyncProvider : IAccountSyncProvider
{ {
private readonly PlaidClient _client;
private readonly PlaidConfig _config;
public string Id => "plaid-sync"; public string Id => "plaid-sync";
public string Name => "Plaid Bank Sync"; public string Name => "Plaid Bank Sync";
public string Version => "1.0.0"; public string Version => "1.0.0";
public string Description => "Sync transactions from banks via Plaid"; public string Description => "Sync transactions from banks via Plaid";
public PlaidSyncProvider(IConfiguration configuration) public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
{ public Task ShutdownAsync() => Task.CompletedTask;
_config = new PlaidConfig
{
ClientId = configuration["BankSync:Plaid:ClientId"] ?? string.Empty,
Secret = configuration["BankSync:Plaid:Secret"] ?? string.Empty,
Environment = configuration["BankSync:Plaid:Environment"] ?? "sandbox"
};
var env = _config.Environment.ToLowerInvariant() switch private static PlaidClient CreateClient(string environment)
{
var env = environment.ToLowerInvariant() switch
{ {
"production" => Going.Plaid.Environment.Production, "production" => Going.Plaid.Environment.Production,
"development" => Going.Plaid.Environment.Development, "development" => Going.Plaid.Environment.Development,
_ => Going.Plaid.Environment.Sandbox _ => Going.Plaid.Environment.Sandbox
}; };
return new PlaidClient(env);
_client = new PlaidClient(env);
} }
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask; public async Task<PlaidLinkTokenResult> CreateLinkTokenAsync(string userId, PlaidConfig credentials)
public Task ShutdownAsync() => Task.CompletedTask;
public async Task<PlaidLinkTokenResult> CreateLinkTokenAsync(string userId)
{ {
if (string.IsNullOrEmpty(_config.ClientId) || string.IsNullOrEmpty(_config.Secret)) if (string.IsNullOrEmpty(credentials.ClientId) || string.IsNullOrEmpty(credentials.Secret))
throw new InvalidOperationException( throw new InvalidOperationException(
"Plaid credentials are not configured. Set BankSync:Plaid:ClientId and BankSync:Plaid:Secret in appsettings.json."); "Plaid credentials are not configured. Go to Settings to enter your Plaid API keys.");
var response = await _client.LinkTokenCreateAsync(new LinkTokenCreateRequest var client = CreateClient(credentials.Environment);
var response = await client.LinkTokenCreateAsync(new LinkTokenCreateRequest
{ {
ClientId = _config.ClientId, ClientId = credentials.ClientId,
Secret = _config.Secret, Secret = credentials.Secret,
User = new LinkTokenCreateRequestUser { ClientUserId = userId }, User = new LinkTokenCreateRequestUser { ClientUserId = userId },
ClientName = "Purrse", ClientName = "Purrse",
Products = new[] { Products.Transactions }, Products = new[] { Products.Transactions },
@@ -65,12 +55,14 @@ public class PlaidSyncProvider : IAccountSyncProvider
}; };
} }
public async Task<PlaidExchangeResult> ExchangePublicTokenAsync(string publicToken) public async Task<PlaidExchangeResult> ExchangePublicTokenAsync(string publicToken, PlaidConfig credentials)
{ {
var response = await _client.ItemPublicTokenExchangeAsync(new ItemPublicTokenExchangeRequest var client = CreateClient(credentials.Environment);
var response = await client.ItemPublicTokenExchangeAsync(new ItemPublicTokenExchangeRequest
{ {
ClientId = _config.ClientId, ClientId = credentials.ClientId,
Secret = _config.Secret, Secret = credentials.Secret,
PublicToken = publicToken PublicToken = publicToken
}); });
@@ -79,10 +71,10 @@ public class PlaidSyncProvider : IAccountSyncProvider
try try
{ {
var itemResponse = await _client.ItemGetAsync(new ItemGetRequest var itemResponse = await client.ItemGetAsync(new ItemGetRequest
{ {
ClientId = _config.ClientId, ClientId = credentials.ClientId,
Secret = _config.Secret, Secret = credentials.Secret,
AccessToken = response.AccessToken AccessToken = response.AccessToken
}); });
@@ -90,11 +82,11 @@ public class PlaidSyncProvider : IAccountSyncProvider
if (institutionId != null) if (institutionId != null)
{ {
var instResponse = await _client.InstitutionsGetByIdAsync( var instResponse = await client.InstitutionsGetByIdAsync(
new Going.Plaid.Institutions.InstitutionsGetByIdRequest new Going.Plaid.Institutions.InstitutionsGetByIdRequest
{ {
ClientId = _config.ClientId, ClientId = credentials.ClientId,
Secret = _config.Secret, Secret = credentials.Secret,
InstitutionId = institutionId, InstitutionId = institutionId,
CountryCodes = new[] { CountryCode.Us } CountryCodes = new[] { CountryCode.Us }
}); });
@@ -117,12 +109,14 @@ public class PlaidSyncProvider : IAccountSyncProvider
public async Task<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration) public async Task<List<SyncAccount>> GetAccountsAsync(Dictionary<string, string> configuration)
{ {
var credentials = ExtractCredentials(configuration);
var client = CreateClient(credentials.Environment);
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty; var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var response = await _client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest var response = await client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest
{ {
ClientId = _config.ClientId, ClientId = credentials.ClientId,
Secret = _config.Secret, Secret = credentials.Secret,
AccessToken = accessToken AccessToken = accessToken
}); });
@@ -138,6 +132,8 @@ public class PlaidSyncProvider : IAccountSyncProvider
public async Task<ParseResult> SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary<string, string> configuration) public async Task<ParseResult> SyncTransactionsAsync(string accountId, DateTime startDate, DateTime endDate, Dictionary<string, string> configuration)
{ {
var credentials = ExtractCredentials(configuration);
var client = CreateClient(credentials.Environment);
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty; var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
var allTransactions = new List<Going.Plaid.Entity.Transaction>(); var allTransactions = new List<Going.Plaid.Entity.Transaction>();
var offset = 0; var offset = 0;
@@ -145,10 +141,10 @@ public class PlaidSyncProvider : IAccountSyncProvider
while (true) while (true)
{ {
var response = await _client.TransactionsGetAsync(new Going.Plaid.Transactions.TransactionsGetRequest var response = await client.TransactionsGetAsync(new Going.Plaid.Transactions.TransactionsGetRequest
{ {
ClientId = _config.ClientId, ClientId = credentials.ClientId,
Secret = _config.Secret, Secret = credentials.Secret,
AccessToken = accessToken, AccessToken = accessToken,
StartDate = DateOnly.FromDateTime(startDate), StartDate = DateOnly.FromDateTime(startDate),
EndDate = DateOnly.FromDateTime(endDate), EndDate = DateOnly.FromDateTime(endDate),
@@ -184,6 +180,16 @@ public class PlaidSyncProvider : IAccountSyncProvider
return result; return result;
} }
private static PlaidConfig ExtractCredentials(Dictionary<string, string> configuration)
{
return new PlaidConfig
{
ClientId = configuration.GetValueOrDefault("PlaidClientId") ?? string.Empty,
Secret = configuration.GetValueOrDefault("PlaidSecret") ?? string.Empty,
Environment = configuration.GetValueOrDefault("PlaidEnvironment") ?? "sandbox"
};
}
private static string MapAccountType(AccountType? type, AccountSubtype? subtype) private static string MapAccountType(AccountType? type, AccountSubtype? subtype)
{ {
return type switch return type switch