Implement Phase 6: automated bank sync (Plaid + SimpleFIN) & settings
Add automated transaction syncing via Plaid and SimpleFIN providers, a Settings page for managing connections and account mappings, background scheduled syncing, and sync history logging. New backend: BankSync plugin project with PlaidSyncProvider and SimpleFinSyncProvider, SyncConnection/LinkedAccount/SyncLog models, AES-256 encryption service, BankSyncService orchestration, BankSyncBackgroundService (15-min tick), and BankSyncController (13 endpoints). EF migration creates sync_connections, linked_accounts, and sync_logs tables. New frontend: Settings view with 3 tabs (Profile, Bank Connections, Sync History), bankSync API service, BankSyncComplete SignalR notifications, and Settings nav item in sidebar + avatar dropdown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public async Task<CreateLinkTokenResponse> CreatePlaidLinkTokenAsync(Guid userId)
|
||||
{
|
||||
var result = await _plaid.CreateLinkTokenAsync(userId.ToString());
|
||||
return new CreateLinkTokenResponse(result.LinkToken, result.Expiration);
|
||||
}
|
||||
|
||||
public async Task<SyncConnectionResponse> CompletePlaidLinkAsync(Guid userId, CreatePlaidConnectionRequest request)
|
||||
{
|
||||
var exchangeResult = await _plaid.ExchangePublicTokenAsync(request.PublicToken);
|
||||
|
||||
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
|
||||
await DiscoverAndCreateLinkedAccounts(connection, exchangeResult.AccessToken);
|
||||
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
var config = BuildProviderConfig(connection, accessToken);
|
||||
|
||||
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)
|
||||
{
|
||||
// Auto-create a Purrse account
|
||||
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();
|
||||
|
||||
// Reload with Account nav
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private async Task<SyncResultResponse> SyncConnectionInternal(Guid userId, SyncConnection connection)
|
||||
{
|
||||
var accessToken = _encryption.Decrypt(connection.EncryptedAccessToken);
|
||||
var config = BuildProviderConfig(connection, accessToken);
|
||||
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++;
|
||||
}
|
||||
|
||||
// Update account balance
|
||||
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();
|
||||
|
||||
// Send SignalR notification
|
||||
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 async Task DiscoverAndCreateLinkedAccounts(SyncConnection connection, string accessToken)
|
||||
{
|
||||
var config = BuildProviderConfig(connection, accessToken);
|
||||
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();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user