9d6a006045
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>
559 lines
22 KiB
C#
559 lines
22 KiB
C#
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Purrse.Api.Hubs;
|
|
using Purrse.Core.DTOs;
|
|
using Purrse.Core.Enums;
|
|
using Purrse.Core.Helpers;
|
|
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;
|
|
|
|
public class BankSyncService : IBankSyncService
|
|
{
|
|
private readonly PurrseDbContext _db;
|
|
private readonly IEncryptionService _encryption;
|
|
private readonly PlaidSyncProvider _plaid;
|
|
private readonly SimpleFinSyncProvider _simpleFin;
|
|
private readonly IDuplicateDetectionService _duplicateService;
|
|
private readonly IPayeeService _payeeService;
|
|
private readonly IHubContext<NotificationHub> _hub;
|
|
private readonly ILogger<BankSyncService> _logger;
|
|
|
|
public BankSyncService(
|
|
PurrseDbContext db,
|
|
IEncryptionService encryption,
|
|
PlaidSyncProvider plaid,
|
|
SimpleFinSyncProvider simpleFin,
|
|
IDuplicateDetectionService duplicateService,
|
|
IPayeeService payeeService,
|
|
IHubContext<NotificationHub> hub,
|
|
ILogger<BankSyncService> logger)
|
|
{
|
|
_db = db;
|
|
_encryption = encryption;
|
|
_plaid = plaid;
|
|
_simpleFin = simpleFin;
|
|
_duplicateService = duplicateService;
|
|
_payeeService = payeeService;
|
|
_hub = hub;
|
|
_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
|
|
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
|
|
.Where(c => c.UserId == userId)
|
|
.OrderByDescending(c => c.CreatedAt)
|
|
.ToListAsync();
|
|
|
|
return connections.Select(MapConnectionResponse).ToList();
|
|
}
|
|
|
|
public async Task<SyncConnectionResponse> GetConnectionAsync(Guid userId, Guid connectionId)
|
|
{
|
|
var connection = await _db.SyncConnections
|
|
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
|
|
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
return MapConnectionResponse(connection);
|
|
}
|
|
|
|
public async Task<SyncConnectionResponse> UpdateConnectionAsync(Guid userId, Guid connectionId, UpdateConnectionRequest request)
|
|
{
|
|
var connection = await _db.SyncConnections
|
|
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
|
|
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
connection.IsActive = request.IsActive;
|
|
connection.SyncIntervalMinutes = request.SyncIntervalMinutes;
|
|
connection.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _db.SaveChangesAsync();
|
|
return MapConnectionResponse(connection);
|
|
}
|
|
|
|
public async Task DeleteConnectionAsync(Guid userId, Guid connectionId)
|
|
{
|
|
var connection = await _db.SyncConnections
|
|
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
_db.SyncConnections.Remove(connection);
|
|
await _db.SaveChangesAsync();
|
|
}
|
|
|
|
// --- Plaid Flow ---
|
|
|
|
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
|
|
{
|
|
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 credentials = await GetPlaidCredentialsInternal(userId);
|
|
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken, credentials);
|
|
|
|
var connection = new SyncConnection
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = userId,
|
|
Provider = SyncProvider.Plaid,
|
|
InstitutionName = exchangeResult.InstitutionName ?? "Unknown Institution",
|
|
InstitutionId = exchangeResult.InstitutionId,
|
|
EncryptedAccessToken = _encryption.Encrypt(exchangeResult.AccessToken),
|
|
PlaidItemId = exchangeResult.ItemId
|
|
};
|
|
|
|
_db.SyncConnections.Add(connection);
|
|
await _db.SaveChangesAsync();
|
|
|
|
// Discover accounts
|
|
var config = BuildProviderConfig(connection, exchangeResult.AccessToken, credentials);
|
|
var accounts = await _plaid.GetAccountsAsync(config);
|
|
|
|
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);
|
|
}
|
|
|
|
// --- 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
|
|
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
|
|
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
|
|
)).ToList();
|
|
}
|
|
|
|
public async Task<LinkedAccountResponse> LinkAccountAsync(Guid userId, Guid connectionId, LinkAccountRequest request)
|
|
{
|
|
var connection = await _db.SyncConnections
|
|
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
var linkedAccount = await _db.LinkedAccounts
|
|
.Include(la => la.Account)
|
|
.FirstOrDefaultAsync(la => la.Id == request.LinkedAccountId && la.SyncConnectionId == connectionId)
|
|
?? throw new KeyNotFoundException("Linked account not found");
|
|
|
|
if (request.AutoCreate && request.AccountId == null)
|
|
{
|
|
var accountType = Enum.TryParse<AccountType>(linkedAccount.ExternalAccountType, true, out var at)
|
|
? at : AccountType.Checking;
|
|
|
|
var newAccount = new Account
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserId = userId,
|
|
Name = linkedAccount.ExternalAccountName,
|
|
Type = accountType,
|
|
Institution = connection.InstitutionName,
|
|
Balance = linkedAccount.LastKnownBalance ?? 0
|
|
};
|
|
_db.Accounts.Add(newAccount);
|
|
linkedAccount.AccountId = newAccount.Id;
|
|
}
|
|
else
|
|
{
|
|
linkedAccount.AccountId = request.AccountId;
|
|
}
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
linkedAccount = await _db.LinkedAccounts
|
|
.Include(la => la.Account)
|
|
.FirstAsync(la => la.Id == linkedAccount.Id);
|
|
|
|
return MapLinkedAccountResponse(linkedAccount);
|
|
}
|
|
|
|
public async Task<LinkedAccountResponse> UpdateLinkedAccountAsync(Guid userId, Guid linkedAccountId, UpdateLinkedAccountRequest request)
|
|
{
|
|
var linkedAccount = await _db.LinkedAccounts
|
|
.Include(la => la.SyncConnection)
|
|
.Include(la => la.Account)
|
|
.FirstOrDefaultAsync(la => la.Id == linkedAccountId && la.SyncConnection.UserId == userId)
|
|
?? throw new KeyNotFoundException("Linked account not found");
|
|
|
|
linkedAccount.AccountId = request.AccountId;
|
|
linkedAccount.IsEnabled = request.IsEnabled;
|
|
|
|
await _db.SaveChangesAsync();
|
|
return MapLinkedAccountResponse(linkedAccount);
|
|
}
|
|
|
|
// --- Sync ---
|
|
|
|
public async Task<SyncResultResponse> SyncNowAsync(Guid userId, Guid connectionId)
|
|
{
|
|
var connection = await _db.SyncConnections
|
|
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
|
|
.FirstOrDefaultAsync(c => c.Id == connectionId && c.UserId == userId)
|
|
?? throw new KeyNotFoundException("Connection not found");
|
|
|
|
return await SyncConnectionInternal(userId, connection);
|
|
}
|
|
|
|
public async Task<SyncResultResponse> SyncAllAsync(Guid userId)
|
|
{
|
|
var connections = await _db.SyncConnections
|
|
.Include(c => c.LinkedAccounts).ThenInclude(la => la.Account)
|
|
.Where(c => c.UserId == userId && c.IsActive)
|
|
.ToListAsync();
|
|
|
|
var allResults = new List<SyncAccountResult>();
|
|
int totalFetched = 0, totalImported = 0, totalSkipped = 0, totalSynced = 0;
|
|
|
|
foreach (var connection in connections)
|
|
{
|
|
var result = await SyncConnectionInternal(userId, connection);
|
|
allResults.AddRange(result.AccountResults);
|
|
totalFetched += result.TransactionsFetched;
|
|
totalImported += result.TransactionsImported;
|
|
totalSkipped += result.TransactionsSkipped;
|
|
totalSynced += result.AccountsSynced;
|
|
}
|
|
|
|
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
|
|
.Include(l => l.SyncConnection)
|
|
.Include(l => l.LinkedAccount)
|
|
.Where(l => l.SyncConnection.UserId == userId);
|
|
|
|
if (connectionId.HasValue)
|
|
query = query.Where(l => l.SyncConnectionId == connectionId.Value);
|
|
|
|
return await query
|
|
.OrderByDescending(l => l.StartedAt)
|
|
.Take(limit)
|
|
.Select(l => new SyncLogResponse(
|
|
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))
|
|
.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);
|
|
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>();
|
|
int totalFetched = 0, totalImported = 0, totalSkipped = 0;
|
|
|
|
foreach (var linkedAccount in enabledAccounts)
|
|
{
|
|
var log = new SyncLog
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
SyncConnectionId = connection.Id,
|
|
LinkedAccountId = linkedAccount.Id,
|
|
Status = SyncStatus.Running,
|
|
StartedAt = DateTime.UtcNow
|
|
};
|
|
_db.SyncLogs.Add(log);
|
|
|
|
try
|
|
{
|
|
var endDate = DateTime.UtcNow;
|
|
var startDate = linkedAccount.LastSyncAt ?? endDate.AddDays(-90);
|
|
|
|
var provider = connection.Provider == SyncProvider.Plaid
|
|
? (Purrse.Plugins.Abstractions.IAccountSyncProvider)_plaid
|
|
: _simpleFin;
|
|
|
|
var parseResult = await provider.SyncTransactionsAsync(
|
|
linkedAccount.ExternalAccountId, startDate, endDate, config);
|
|
|
|
if (!parseResult.Success)
|
|
{
|
|
log.Status = SyncStatus.Failed;
|
|
log.ErrorMessage = parseResult.ErrorMessage;
|
|
log.CompletedAt = DateTime.UtcNow;
|
|
log.Duration = log.CompletedAt - log.StartedAt;
|
|
|
|
accountResults.Add(new SyncAccountResult(
|
|
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
|
|
0, 0, 0, parseResult.ErrorMessage));
|
|
continue;
|
|
}
|
|
|
|
int fetched = parseResult.Transactions.Count;
|
|
int imported = 0;
|
|
int skipped = 0;
|
|
|
|
foreach (var parsed in parseResult.Transactions)
|
|
{
|
|
var duplicate = await _duplicateService.FindDuplicateAsync(
|
|
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount,
|
|
parsed.PayeeName, parsed.FitId, parsed.CheckNumber);
|
|
|
|
if (duplicate != null)
|
|
{
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
var fingerprint = TransactionFingerprint.Generate(
|
|
linkedAccount.AccountId!.Value, parsed.Date, parsed.Amount, parsed.FitId);
|
|
|
|
var matchedPayee = await _payeeService.MatchPayeeAsync(userId, parsed.PayeeName ?? string.Empty);
|
|
|
|
var transaction = new Transaction
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
AccountId = linkedAccount.AccountId!.Value,
|
|
Date = parsed.Date,
|
|
Amount = parsed.Amount,
|
|
PayeeId = matchedPayee?.Id,
|
|
PayeeName = parsed.PayeeName,
|
|
CategoryId = matchedPayee?.DefaultCategoryId,
|
|
Memo = parsed.Memo,
|
|
ReferenceNumber = parsed.ReferenceNumber,
|
|
CheckNumber = parsed.CheckNumber,
|
|
FitId = parsed.FitId,
|
|
Fingerprint = fingerprint,
|
|
Type = parsed.Amount >= 0 ? TransactionType.Credit : TransactionType.Debit,
|
|
Status = TransactionStatus.Uncleared
|
|
};
|
|
|
|
_db.Transactions.Add(transaction);
|
|
imported++;
|
|
}
|
|
|
|
if (linkedAccount.Account != null && parseResult.LedgerBalance.HasValue)
|
|
{
|
|
linkedAccount.Account.Balance = parseResult.LedgerBalance.Value;
|
|
linkedAccount.Account.UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
|
|
linkedAccount.LastKnownBalance = parseResult.LedgerBalance ?? linkedAccount.LastKnownBalance;
|
|
linkedAccount.LastSyncAt = DateTime.UtcNow;
|
|
|
|
log.Status = SyncStatus.Success;
|
|
log.TransactionsFetched = fetched;
|
|
log.TransactionsImported = imported;
|
|
log.TransactionsSkipped = skipped;
|
|
log.CompletedAt = DateTime.UtcNow;
|
|
log.Duration = log.CompletedAt - log.StartedAt;
|
|
|
|
totalFetched += fetched;
|
|
totalImported += imported;
|
|
totalSkipped += skipped;
|
|
|
|
accountResults.Add(new SyncAccountResult(
|
|
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
|
|
fetched, imported, skipped, null));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to sync account {AccountId} for connection {ConnectionId}",
|
|
linkedAccount.ExternalAccountId, connection.Id);
|
|
|
|
log.Status = SyncStatus.Failed;
|
|
log.ErrorMessage = ex.Message;
|
|
log.CompletedAt = DateTime.UtcNow;
|
|
log.Duration = log.CompletedAt - log.StartedAt;
|
|
|
|
accountResults.Add(new SyncAccountResult(
|
|
linkedAccount.ExternalAccountName, linkedAccount.Account?.Name,
|
|
0, 0, 0, ex.Message));
|
|
}
|
|
}
|
|
|
|
connection.LastSyncAt = DateTime.UtcNow;
|
|
connection.LastSyncError = accountResults.Any(r => r.Error != null)
|
|
? accountResults.First(r => r.Error != null).Error
|
|
: null;
|
|
connection.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await _db.SaveChangesAsync();
|
|
|
|
await _hub.Clients.Group(userId.ToString()).SendAsync("BankSyncComplete", new
|
|
{
|
|
connectionId = connection.Id,
|
|
institutionName = connection.InstitutionName,
|
|
transactionsImported = totalImported,
|
|
transactionsSkipped = totalSkipped,
|
|
status = accountResults.All(r => r.Error == null) ? "Success" : "PartialSuccess"
|
|
});
|
|
|
|
return new SyncResultResponse(
|
|
enabledAccounts.Count,
|
|
accountResults.Count(r => r.Error == null),
|
|
totalFetched, totalImported, totalSkipped,
|
|
accountResults);
|
|
}
|
|
|
|
private static Dictionary<string, string> BuildProviderConfig(SyncConnection connection, string accessToken, PlaidConfig? plaidCredentials)
|
|
{
|
|
if (connection.Provider == SyncProvider.Plaid && plaidCredentials != null)
|
|
{
|
|
return new Dictionary<string, string>
|
|
{
|
|
["AccessToken"] = accessToken,
|
|
["PlaidClientId"] = plaidCredentials.ClientId,
|
|
["PlaidSecret"] = plaidCredentials.Secret,
|
|
["PlaidEnvironment"] = plaidCredentials.Environment
|
|
};
|
|
}
|
|
|
|
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.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);
|
|
}
|
|
}
|