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
@@ -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));
+1 -1
View File
@@ -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
+137 -108
View File
@@ -8,6 +8,7 @@ using Purrse.Core.Interfaces.Services;
using Purrse.Core.Models;
using Purrse.Data;
using Purrse.Plugins.BankSync;
using Purrse.Plugins.BankSync.Models;
namespace Purrse.Api.Services;
@@ -42,6 +43,42 @@ public class BankSyncService : IBankSyncService
_logger = logger;
}
// --- Provider Credentials ---
public async Task<PlaidCredentialsResponse> GetPlaidCredentialsAsync(Guid userId)
{
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId))
return new PlaidCredentialsResponse(false, "sandbox");
return new PlaidCredentialsResponse(true, settings.PlaidEnvironment);
}
public async Task<PlaidCredentialsResponse> SavePlaidCredentialsAsync(Guid userId, SavePlaidCredentialsRequest request)
{
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
if (settings == null)
{
settings = new BankSyncSettings
{
Id = Guid.NewGuid(),
UserId = userId
};
_db.BankSyncSettings.Add(settings);
}
settings.EncryptedPlaidClientId = _encryption.Encrypt(request.ClientId);
settings.EncryptedPlaidSecret = _encryption.Encrypt(request.Secret);
settings.PlaidEnvironment = request.Environment;
settings.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return new PlaidCredentialsResponse(true, settings.PlaidEnvironment);
}
// --- Connection CRUD ---
public async Task<List<SyncConnectionResponse>> GetConnectionsAsync(Guid userId)
{
var connections = await _db.SyncConnections
@@ -88,15 +125,19 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync();
}
// --- Plaid Flow ---
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
{
var result = await _plaid.CreateLinkTokenAsync(userId.ToString());
var credentials = await GetPlaidCredentialsInternal(userId);
var result = await _plaid.CreateLinkTokenAsync(userId.ToString(), credentials);
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
}
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
{
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken);
var credentials = await GetPlaidCredentialsInternal(userId);
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken, credentials);
var connection = new SyncConnection
{
@@ -113,32 +154,9 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync();
// Discover accounts
await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken);
var config = BuildProviderConfig(connection, exchangeResult.AccessToken, credentials);
var accounts = await _plaid.GetAccountsAsync(config);
return await GetConnectionAsync(userId, connection.Id);
}
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
{
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
// Get accounts to determine institution name
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
var accounts = await _simpleFin.GetAccountsAsync(config);
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.SimpleFIN,
InstitutionName = institutionName,
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
};
_db.SyncConnections.Add(connection);
// Create linked accounts from discovered accounts
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
@@ -157,6 +175,47 @@ public class BankSyncService : IBankSyncService
return await GetConnectionAsync(userId, connection.Id);
}
// --- SimpleFIN Flow ---
public async Task<SyncConnectionResponse> CreateSimpleFinConnectionAsync(Guid userId, CreateSimpleFinConnectionRequest request)
{
var accessUrl = await _simpleFin.ExchangeSetupTokenAsync(request.SetupToken);
var config = new Dictionary<string, string> { ["AccessUrl"] = accessUrl };
var accounts = await _simpleFin.GetAccountsAsync(config);
var institutionName = accounts.FirstOrDefault()?.Institution ?? "SimpleFIN Connection";
var connection = new SyncConnection
{
Id = Guid.NewGuid(),
UserId = userId,
Provider = SyncProvider.SimpleFIN,
InstitutionName = institutionName,
EncryptedAccessToken = _encryption.Encrypt(accessUrl)
};
_db.SyncConnections.Add(connection);
foreach (var account in accounts)
{
_db.LinkedAccounts.Add(new LinkedAccount
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
}
await _db.SaveChangesAsync();
return await GetConnectionAsync(userId, connection.Id);
}
// --- Account Linking ---
public async Task<List<DiscoveredAccountResponse>> DiscoverAccountsAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
@@ -164,19 +223,16 @@ public class BankSyncService : IBankSyncService
?? throw new KeyNotFoundException("Connection not found");
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid
? await GetPlaidCredentialsInternal(userId) : null;
var config = BuildProviderConfig(connection, accessToken, plaidCreds);
var accounts = connection.Provider == SyncProvider.Plaid
? await _plaid.GetAccountsAsync(config)
: await _simpleFin.GetAccountsAsync(config);
return accounts.Select(a => new DiscoveredAccountResponse(
a.ExternalId,
a.Name,
a.AccountType,
null,
a.Balance,
a.Currency
a.ExternalId, a.Name, a.AccountType, null, a.Balance, a.Currency
)).ToList();
}
@@ -193,7 +249,6 @@ public class BankSyncService : IBankSyncService
if (request.AutoCreate && request.AccountId == null)
{
// Auto-create a Purrse account
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
? at : AccountType.Checking;
@@ -216,7 +271,6 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync();
// Reload with Account nav
linkedAccount = await _db.LinkedAccounts
.Include(la => la.Account)
.FirstAsync(la => la.Id == linkedAccount.Id);
@@ -239,6 +293,8 @@ public class BankSyncService : IBankSyncService
return MapLinkedAccountResponse(linkedAccount);
}
// --- Sync ---
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
{
var connection = await _db.SyncConnections
@@ -269,15 +325,11 @@ public class BankSyncService : IBankSyncService
totalSynced += result.AccountsSynced;
}
return new SyncResultResponse(
allResults.Count,
totalSynced,
totalFetched,
totalImported,
totalSkipped,
allResults);
return new SyncResultResponse(allResults.Count, totalSynced, totalFetched, totalImported, totalSkipped, allResults);
}
// --- Logs ---
public async Task<List<SyncLogResponse>> GetSyncLogsAsync(Guid userId, Guid? connectionId = null, int limit = 50)
{
var query = _db.SyncLogs
@@ -292,26 +344,37 @@ public class BankSyncService : IBankSyncService
.OrderByDescending(l => l.StartedAt)
.Take(limit)
.Select(l => new SyncLogResponse(
l.Id,
l.SyncConnectionId,
l.SyncConnection.InstitutionName,
l.Id, l.SyncConnectionId, l.SyncConnection.InstitutionName,
l.LinkedAccountId,
l.LinkedAccount != null ? l.LinkedAccount.ExternalAccountName : null,
l.Status,
l.TransactionsFetched,
l.TransactionsImported,
l.TransactionsSkipped,
l.ErrorMessage,
l.StartedAt,
l.CompletedAt,
l.Duration))
l.Status, l.TransactionsFetched, l.TransactionsImported, l.TransactionsSkipped,
l.ErrorMessage, l.StartedAt, l.CompletedAt, l.Duration))
.ToListAsync();
}
// --- Internal Helpers ---
private async Task<PlaidConfig> GetPlaidCredentialsInternal(Guid userId)
{
var settings = await _db.BankSyncSettings.FirstOrDefaultAsync(s => s.UserId == userId);
if (settings == null || string.IsNullOrEmpty(settings.EncryptedPlaidClientId) || string.IsNullOrEmpty(settings.EncryptedPlaidSecret))
throw new InvalidOperationException("Plaid credentials are not configured. Go to Settings to enter your Plaid API keys.");
return new PlaidConfig
{
ClientId = _encryption.Decrypt(settings.EncryptedPlaidClientId),
Secret = _encryption.Decrypt(settings.EncryptedPlaidSecret),
Environment = settings.PlaidEnvironment
};
}
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
{
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
var config = BuildProviderConfig(connection, accessToken);
PlaidConfig? plaidCreds = connection.Provider == SyncProvider.Plaid
? await GetPlaidCredentialsInternal(userId) : null;
var config = BuildProviderConfig(connection, accessToken, plaidCreds);
var enabledAccounts = connection.LinkedAccounts.Where(la => la.IsEnabled && la.AccountId != null).ToList();
var accountResults = new List<SyncAccountResult>();
@@ -349,10 +412,8 @@ public class BankSyncService : IBankSyncService
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
0, 0, 0,
parseResult.ErrorMessage));
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
0, 0, 0, parseResult.ErrorMessage));
continue;
}
@@ -399,7 +460,6 @@ public class BankSyncService : IBankSyncService
imported++;
}
// Update account balance
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
{
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
@@ -421,8 +481,7 @@ public class BankSyncService : IBankSyncService
totalSkipped += skipped;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
fetched, imported, skipped, null));
}
catch (Exception ex)
@@ -436,8 +495,7 @@ public class BankSyncService : IBankSyncService
log.Duration = log.CompletedAt - log.StartedAt;
accountResults.Add(new SyncAccountResult(
linkedAccount.ExternalAccountName,
linkedAccount.Account?.Name,
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
0, 0, 0, ex.Message));
}
}
@@ -450,7 +508,6 @@ public class BankSyncService : IBankSyncService
await _db.SaveChangesAsync();
// Send SignalR notification
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
{
connectionId = connection.Id,
@@ -463,67 +520,39 @@ public class BankSyncService : IBankSyncService
return new SyncResultResponse(
enabledAccounts.Count,
accountResults.Count(r => r.Error == null),
totalFetched,
totalImported,
totalSkipped,
totalFetched, totalImported, totalSkipped,
accountResults);
}
private async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken)
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials)
{
var config = BuildProviderConfig(connection, accessToken);
var accounts = await _plaid.GetAccountsAsync(config);
foreach (var account in accounts)
if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
{
_db.LinkedAccounts.Add(new LinkedAccount
return new Dictionary<string, string>
{
Id = Guid.NewGuid(),
SyncConnectionId = connection.Id,
ExternalAccountId = account.ExternalId,
ExternalAccountName = account.Name,
ExternalAccountType = account.AccountType,
LastKnownBalance = account.Balance
});
["AccessToken"] = accessToken,
["PlaidClientId"] = plaidCredentials.ClientId,
["PlaidSecret"] = plaidCredentials.Secret,
["PlaidEnvironment"] = plaidCredentials.Environment
};
}
await _db.SaveChangesAsync();
}
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken)
{
return connection.Provider == SyncProvider.Plaid
? new Dictionary<string, string> { ["AccessToken"] = accessToken }
: new Dictionary<string, string> { ["AccessUrl"] = accessToken };
return new Dictionary<string, string> { ["AccessUrl"] = accessToken };
}
private static SyncConnectionResponse MapConnectionResponse(SyncConnection c)
{
return new SyncConnectionResponse(
c.Id,
c.Provider,
c.InstitutionName,
c.InstitutionId,
c.IsActive,
c.SyncIntervalMinutes,
c.LastSyncAt,
c.LastSyncError,
c.CreatedAt,
c.Id, c.Provider, c.InstitutionName, c.InstitutionId,
c.IsActive, c.SyncIntervalMinutes, c.LastSyncAt, c.LastSyncError, c.CreatedAt,
c.LinkedAccounts.Select(MapLinkedAccountResponse).ToList());
}
private static LinkedAccountResponse MapLinkedAccountResponse(LinkedAccount la)
{
return new LinkedAccountResponse(
la.Id,
la.ExternalAccountId,
la.ExternalAccountName,
la.ExternalAccountType,
la.ExternalAccountMask,
la.LastKnownBalance,
la.IsEnabled,
la.AccountId,
la.Account?.Name,
la.LastSyncAt);
la.Id, la.ExternalAccountId, la.ExternalAccountName,
la.ExternalAccountType, la.ExternalAccountMask, la.LastKnownBalance,
la.IsEnabled, la.AccountId, la.Account?.Name, la.LastSyncAt);
}
}
-5
View File
@@ -18,11 +18,6 @@
},
"BankSync": {
"EncryptionKey": "CHANGE_ME_32_CHAR_KEY_FOR_AES256!",
"Plaid": {
"ClientId": "",
"Secret": "",
"Environment": "sandbox"
},
"DefaultSyncIntervalMinutes": 360,
"BackgroundCheckIntervalMinutes": 15
},
+4
View File
@@ -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!;
}
+1
View File
@@ -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
+1
View File
@@ -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