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:
@@ -5,10 +5,17 @@ import type {
|
||||
DiscoveredAccount,
|
||||
SyncResult,
|
||||
SyncLogEntry,
|
||||
CreateLinkTokenResponse
|
||||
CreateLinkTokenResponse,
|
||||
PlaidCredentials
|
||||
} from '@/types'
|
||||
|
||||
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: () =>
|
||||
api.get<SyncConnection[]>('/bank-sync/connections'),
|
||||
|
||||
|
||||
@@ -404,6 +404,11 @@ export interface AppNotification {
|
||||
}
|
||||
|
||||
// Bank Sync
|
||||
export interface PlaidCredentials {
|
||||
isConfigured: boolean
|
||||
environment: string
|
||||
}
|
||||
|
||||
export type SyncProvider = 'Plaid' | 'SimpleFIN'
|
||||
|
||||
export interface SyncConnection {
|
||||
|
||||
@@ -25,8 +25,63 @@
|
||||
|
||||
<!-- Tab 2: Bank 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">
|
||||
<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
|
||||
</v-btn>
|
||||
<v-btn color="secondary" prepend-icon="mdi-link-plus" @click="showSimpleFinDialog = true">
|
||||
@@ -35,7 +90,7 @@
|
||||
</div>
|
||||
|
||||
<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-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 { bankSyncApi } from '@/services/bankSync'
|
||||
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()
|
||||
|
||||
@@ -319,7 +374,13 @@ const accounts = ref<Account[]>([])
|
||||
const syncIntervals = reactive<Record<string, number>>({})
|
||||
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)
|
||||
|
||||
// SimpleFIN
|
||||
@@ -396,6 +457,37 @@ function formatCurrency(value: number) {
|
||||
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() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -583,6 +675,6 @@ async function deleteConnection() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadConnections(), loadAccounts(), loadSyncLogs()])
|
||||
await Promise.all([loadPlaidCredentials(), loadConnections(), loadAccounts(), loadSyncLogs()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -35,6 +35,14 @@ public class BankSyncController : ControllerBase
|
||||
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")]
|
||||
public async Task<ActionResult<CreateLinkTokenResponse>> CreatePlaidLinkToken()
|
||||
=> Ok(await _bankSyncService.CreatePlaidLinkTokenAsync(UserId));
|
||||
|
||||
@@ -86,7 +86,7 @@ builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
|
||||
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
|
||||
|
||||
// Bank sync providers
|
||||
builder.Services.AddSingleton<PlaidSyncProvider>();
|
||||
builder.Services.AddSingleton<PlaidSyncProvider>(); // Stateless — credentials passed per-call
|
||||
builder.Services.AddHttpClient<SimpleFinSyncProvider>();
|
||||
|
||||
// Plugin system
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
},
|
||||
"BankSync": {
|
||||
"EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!",
|
||||
"Plaid": {
|
||||
"ClientId": "",
|
||||
"Secret": "",
|
||||
"Environment": "sandbox"
|
||||
},
|
||||
"DefaultSyncIntervalMinutes": 360,
|
||||
"BackgroundCheckIntervalMinutes": 15
|
||||
},
|
||||
|
||||
@@ -2,6 +2,10 @@ using Purrse.Core.Enums;
|
||||
|
||||
namespace Purrse.Core.DTOs;
|
||||
|
||||
// Provider credentials
|
||||
public record SavePlaidCredentialsRequest(string ClientId, string Secret, string Environment);
|
||||
public record PlaidCredentialsResponse(bool IsConfigured, string Environment);
|
||||
|
||||
// Plaid
|
||||
public record CreatePlaidConnectionRequest(string PublicToken);
|
||||
public record CreateLinkTokenResponse(string LinkToken, string Expiration);
|
||||
|
||||
@@ -10,6 +10,10 @@ public interface IBankSyncService
|
||||
Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request);
|
||||
Task DeleteConnectionAsync(Guid userId, Guid connectionId);
|
||||
|
||||
// Provider credentials
|
||||
Task<PlaidCredentialsResponse> GetPlaidCredentialsAsync(Guid userId);
|
||||
Task<PlaidCredentialsResponse> SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request);
|
||||
|
||||
// Plaid flow
|
||||
Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId);
|
||||
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!;
|
||||
}
|
||||
@@ -13,4 +13,5 @@ public class User
|
||||
|
||||
public ICollection<Account> Accounts { get; set; } = new List<Account>();
|
||||
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 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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -974,6 +1005,17 @@ namespace Purrse.Data.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("Purrse.Core.Models.User", "User")
|
||||
@@ -1354,6 +1396,8 @@ namespace Purrse.Data.Migrations
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("BankSyncSettings");
|
||||
|
||||
b.Navigation("SyncConnections");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -29,6 +29,7 @@ public class PurrseDbContext : DbContext
|
||||
public DbSet<SyncConnection> SyncConnections => Set<SyncConnection>();
|
||||
public DbSet<LinkedAccount> LinkedAccounts => Set<LinkedAccount>();
|
||||
public DbSet<SyncLog> SyncLogs => Set<SyncLog>();
|
||||
public DbSet<BankSyncSettings> BankSyncSettings => Set<BankSyncSettings>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ using Going.Plaid;
|
||||
using Going.Plaid.Entity;
|
||||
using Going.Plaid.Item;
|
||||
using Going.Plaid.Link;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Purrse.Plugins.Abstractions;
|
||||
using Purrse.Plugins.Abstractions.Models;
|
||||
using Purrse.Plugins.BankSync.Models;
|
||||
@@ -11,46 +10,37 @@ namespace Purrse.Plugins.BankSync;
|
||||
|
||||
public class PlaidSyncProvider : IAccountSyncProvider
|
||||
{
|
||||
private readonly PlaidClient _client;
|
||||
private readonly PlaidConfig _config;
|
||||
|
||||
public string Id => "plaid-sync";
|
||||
public string Name => "Plaid Bank Sync";
|
||||
public string Version => "1.0.0";
|
||||
public string Description => "Sync transactions from banks via Plaid";
|
||||
|
||||
public PlaidSyncProvider(IConfiguration configuration)
|
||||
{
|
||||
_config = new PlaidConfig
|
||||
{
|
||||
ClientId = configuration["BankSync:Plaid:ClientId"] ?? string.Empty,
|
||||
Secret = configuration["BankSync:Plaid:Secret"] ?? string.Empty,
|
||||
Environment = configuration["BankSync:Plaid:Environment"] ?? "sandbox"
|
||||
};
|
||||
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
|
||||
public Task ShutdownAsync() => Task.CompletedTask;
|
||||
|
||||
var env = _config.Environment.ToLowerInvariant() switch
|
||||
private static PlaidClient CreateClient(string environment)
|
||||
{
|
||||
var env = environment.ToLowerInvariant() switch
|
||||
{
|
||||
"production" => Going.Plaid.Environment.Production,
|
||||
"development" => Going.Plaid.Environment.Development,
|
||||
_ => Going.Plaid.Environment.Sandbox
|
||||
};
|
||||
|
||||
_client = new PlaidClient(env);
|
||||
return new PlaidClient(env);
|
||||
}
|
||||
|
||||
public Task InitializeAsync(IPluginContext context) => Task.CompletedTask;
|
||||
public Task ShutdownAsync() => Task.CompletedTask;
|
||||
|
||||
public async Task<PlaidLinkTokenResult> CreateLinkTokenAsync(string userId)
|
||||
public async Task<PlaidLinkTokenResult> CreateLinkTokenAsync(string userId, PlaidConfig credentials)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_config.ClientId) || string.IsNullOrEmpty(_config.Secret))
|
||||
if (string.IsNullOrEmpty(credentials.ClientId) || string.IsNullOrEmpty(credentials.Secret))
|
||||
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,
|
||||
Secret = _config.Secret,
|
||||
ClientId = credentials.ClientId,
|
||||
Secret = credentials.Secret,
|
||||
User = new LinkTokenCreateRequestUser { ClientUserId = userId },
|
||||
ClientName = "Purrse",
|
||||
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,
|
||||
Secret = _config.Secret,
|
||||
ClientId = credentials.ClientId,
|
||||
Secret = credentials.Secret,
|
||||
PublicToken = publicToken
|
||||
});
|
||||
|
||||
@@ -79,10 +71,10 @@ public class PlaidSyncProvider : IAccountSyncProvider
|
||||
|
||||
try
|
||||
{
|
||||
var itemResponse = await _client.ItemGetAsync(new ItemGetRequest
|
||||
var itemResponse = await client.ItemGetAsync(new ItemGetRequest
|
||||
{
|
||||
ClientId = _config.ClientId,
|
||||
Secret = _config.Secret,
|
||||
ClientId = credentials.ClientId,
|
||||
Secret = credentials.Secret,
|
||||
AccessToken = response.AccessToken
|
||||
});
|
||||
|
||||
@@ -90,11 +82,11 @@ public class PlaidSyncProvider : IAccountSyncProvider
|
||||
|
||||
if (institutionId != null)
|
||||
{
|
||||
var instResponse = await _client.InstitutionsGetByIdAsync(
|
||||
var instResponse = await client.InstitutionsGetByIdAsync(
|
||||
new Going.Plaid.Institutions.InstitutionsGetByIdRequest
|
||||
{
|
||||
ClientId = _config.ClientId,
|
||||
Secret = _config.Secret,
|
||||
ClientId = credentials.ClientId,
|
||||
Secret = credentials.Secret,
|
||||
InstitutionId = institutionId,
|
||||
CountryCodes = new[] { CountryCode.Us }
|
||||
});
|
||||
@@ -117,12 +109,14 @@ public class PlaidSyncProvider : IAccountSyncProvider
|
||||
|
||||
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 response = await _client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest
|
||||
var response = await client.AccountsGetAsync(new Going.Plaid.Accounts.AccountsGetRequest
|
||||
{
|
||||
ClientId = _config.ClientId,
|
||||
Secret = _config.Secret,
|
||||
ClientId = credentials.ClientId,
|
||||
Secret = credentials.Secret,
|
||||
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)
|
||||
{
|
||||
var credentials = ExtractCredentials(configuration);
|
||||
var client = CreateClient(credentials.Environment);
|
||||
var accessToken = configuration.GetValueOrDefault("AccessToken") ?? string.Empty;
|
||||
var allTransactions = new List<Going.Plaid.Entity.Transaction>();
|
||||
var offset = 0;
|
||||
@@ -145,10 +141,10 @@ public class PlaidSyncProvider : IAccountSyncProvider
|
||||
|
||||
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,
|
||||
Secret = _config.Secret,
|
||||
ClientId = credentials.ClientId,
|
||||
Secret = credentials.Secret,
|
||||
AccessToken = accessToken,
|
||||
StartDate = DateOnly.FromDateTime(startDate),
|
||||
EndDate = DateOnly.FromDateTime(endDate),
|
||||
@@ -184,6 +180,16 @@ public class PlaidSyncProvider : IAccountSyncProvider
|
||||
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)
|
||||
{
|
||||
return type switch
|
||||
|
||||
Reference in New Issue
Block a user