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,
|
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'),
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user