222 lines
8.6 KiB
C#
222 lines
8.6 KiB
C#
|
|
using Books.Api.EventFlow.ReadModels;
|
||
|
|
using Books.Api.EventFlow.Repositories;
|
||
|
|
using Hangfire;
|
||
|
|
|
||
|
|
namespace Books.Api.Banking;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Hangfire job for syncing bank transactions from Enable Banking.
|
||
|
|
/// Runs every 30 minutes to fetch new transactions for all active bank connections.
|
||
|
|
/// </summary>
|
||
|
|
public class BankTransactionSyncJob(
|
||
|
|
IBankConnectionRepository connectionRepository,
|
||
|
|
IBankTransactionRepository transactionRepository,
|
||
|
|
IEnableBankingClient bankingClient,
|
||
|
|
ILogger<BankTransactionSyncJob> logger)
|
||
|
|
{
|
||
|
|
/// <summary>
|
||
|
|
/// Sync all active bank connections for all companies.
|
||
|
|
/// Called by Hangfire recurring job.
|
||
|
|
/// </summary>
|
||
|
|
[DisableConcurrentExecution(timeoutInSeconds: 300)]
|
||
|
|
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [60, 120, 300])]
|
||
|
|
public async Task SyncAllActiveConnectionsAsync(CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
logger.LogInformation("Starting bank transaction sync for all active connections");
|
||
|
|
|
||
|
|
var result = new BankTransactionSyncResult();
|
||
|
|
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Get all companies with active connections
|
||
|
|
// Note: This is a simplified approach - in production you might want to
|
||
|
|
// iterate through companies more efficiently
|
||
|
|
var connections = await GetAllActiveConnectionsAsync(cancellationToken);
|
||
|
|
result.TotalConnections = connections.Count;
|
||
|
|
|
||
|
|
foreach (var connection in connections)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var connectionResult = await SyncConnectionAsync(connection, cancellationToken);
|
||
|
|
result.TotalAccounts += connectionResult.TotalAccounts;
|
||
|
|
result.NewTransactions += connectionResult.NewTransactions;
|
||
|
|
result.SkippedDuplicates += connectionResult.SkippedDuplicates;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id);
|
||
|
|
result.Errors++;
|
||
|
|
result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.LogInformation(
|
||
|
|
"Bank transaction sync completed: {Connections} connections, {Accounts} accounts, " +
|
||
|
|
"{New} new transactions, {Skipped} duplicates, {Errors} errors",
|
||
|
|
result.TotalConnections, result.TotalAccounts, result.NewTransactions,
|
||
|
|
result.SkippedDuplicates, result.Errors);
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
logger.LogError(ex, "Fatal error during bank transaction sync");
|
||
|
|
throw;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Sync transactions for a specific company (manual trigger from UI).
|
||
|
|
/// </summary>
|
||
|
|
public async Task<BankTransactionSyncResult> SyncForCompanyAsync(
|
||
|
|
string companyId,
|
||
|
|
CancellationToken cancellationToken = default)
|
||
|
|
{
|
||
|
|
logger.LogInformation("Starting manual bank transaction sync for company {CompanyId}", companyId);
|
||
|
|
|
||
|
|
var result = new BankTransactionSyncResult();
|
||
|
|
|
||
|
|
var connections = await connectionRepository.GetActiveByCompanyIdAsync(companyId, cancellationToken);
|
||
|
|
result.TotalConnections = connections.Count;
|
||
|
|
|
||
|
|
foreach (var connection in connections)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
var connectionResult = await SyncConnectionAsync(connection, cancellationToken);
|
||
|
|
result.TotalAccounts += connectionResult.TotalAccounts;
|
||
|
|
result.NewTransactions += connectionResult.NewTransactions;
|
||
|
|
result.SkippedDuplicates += connectionResult.SkippedDuplicates;
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
logger.LogError(ex, "Error syncing connection {ConnectionId}", connection.Id);
|
||
|
|
result.Errors++;
|
||
|
|
result.ErrorMessages.Add($"Connection {connection.Id}: {ex.Message}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.LogInformation(
|
||
|
|
"Manual sync for company {CompanyId} completed: {New} new transactions",
|
||
|
|
companyId, result.NewTransactions);
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<BankTransactionSyncResult> SyncConnectionAsync(
|
||
|
|
BankConnectionReadModelDto connection,
|
||
|
|
CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
var result = new BankTransactionSyncResult();
|
||
|
|
|
||
|
|
if (string.IsNullOrEmpty(connection.SessionId))
|
||
|
|
{
|
||
|
|
logger.LogWarning("Connection {ConnectionId} has no session ID", connection.Id);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (connection.Accounts == null || connection.Accounts.Count == 0)
|
||
|
|
{
|
||
|
|
logger.LogWarning("Connection {ConnectionId} has no accounts", connection.Id);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
result.TotalAccounts = connection.Accounts.Count;
|
||
|
|
|
||
|
|
var dateTo = DateOnly.FromDateTime(DateTime.UtcNow);
|
||
|
|
|
||
|
|
foreach (var account in connection.Accounts)
|
||
|
|
{
|
||
|
|
try
|
||
|
|
{
|
||
|
|
// Check if we have any transactions for this account
|
||
|
|
var hasTransactions = await transactionRepository.HasAnyAsync(account.AccountId, cancellationToken);
|
||
|
|
|
||
|
|
// If new account, fetch 1 year back. If existing, just refresh last 7 days.
|
||
|
|
var dateFrom = hasTransactions ? dateTo.AddDays(-7) : dateTo.AddDays(-365);
|
||
|
|
|
||
|
|
logger.LogInformation(
|
||
|
|
"Fetching transactions for account {AccountId} from {DateFrom} to {DateTo} (hasExisting: {HasExisting})",
|
||
|
|
account.AccountId, dateFrom, dateTo, hasTransactions);
|
||
|
|
|
||
|
|
var response = await bankingClient.GetTransactionsAsync(
|
||
|
|
connection.SessionId,
|
||
|
|
account.AccountId,
|
||
|
|
dateFrom,
|
||
|
|
dateTo,
|
||
|
|
cancellationToken);
|
||
|
|
|
||
|
|
logger.LogInformation(
|
||
|
|
"Enable Banking returned {Count} transactions for account {AccountId}",
|
||
|
|
response.Transactions.Count, account.AccountId);
|
||
|
|
|
||
|
|
if (response.Transactions.Count == 0)
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
var transactionsToUpsert = new List<BankTransactionDto>();
|
||
|
|
|
||
|
|
foreach (var tx in response.Transactions)
|
||
|
|
{
|
||
|
|
var dto = MapToDto(tx, connection, account.AccountId);
|
||
|
|
transactionsToUpsert.Add(dto);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (transactionsToUpsert.Count > 0)
|
||
|
|
{
|
||
|
|
await transactionRepository.InsertBatchAsync(transactionsToUpsert, cancellationToken);
|
||
|
|
result.NewTransactions += transactionsToUpsert.Count; // This counts all upserts (inserts + updates)
|
||
|
|
|
||
|
|
logger.LogInformation(
|
||
|
|
"Synced {Count} transactions for account {AccountId} (from {From} to {To})",
|
||
|
|
transactionsToUpsert.Count, account.AccountId, dateFrom, dateTo);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch (Exception ex)
|
||
|
|
{
|
||
|
|
logger.LogError(ex, "Error fetching transactions for account {AccountId}", account.AccountId);
|
||
|
|
result.Errors++;
|
||
|
|
result.ErrorMessages.Add($"Account {account.AccountId}: {ex.Message}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
private async Task<IReadOnlyList<BankConnectionReadModelDto>> GetAllActiveConnectionsAsync(
|
||
|
|
CancellationToken cancellationToken)
|
||
|
|
{
|
||
|
|
return await connectionRepository.GetAllActiveAsync(cancellationToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
private static BankTransactionDto MapToDto(
|
||
|
|
Transaction tx,
|
||
|
|
BankConnectionReadModelDto connection,
|
||
|
|
string bankAccountId)
|
||
|
|
{
|
||
|
|
var now = DateTime.UtcNow;
|
||
|
|
|
||
|
|
return new BankTransactionDto
|
||
|
|
{
|
||
|
|
Id = $"banktx-{Guid.NewGuid()}",
|
||
|
|
CompanyId = connection.CompanyId,
|
||
|
|
BankConnectionId = connection.Id,
|
||
|
|
BankAccountId = bankAccountId,
|
||
|
|
ExternalId = tx.TransactionId,
|
||
|
|
Amount = tx.Amount,
|
||
|
|
Currency = tx.Currency,
|
||
|
|
TransactionDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue),
|
||
|
|
BookingDate = tx.BookingDate.ToDateTime(TimeOnly.MinValue),
|
||
|
|
ValueDate = tx.ValueDate?.ToDateTime(TimeOnly.MinValue),
|
||
|
|
Description = tx.RemittanceInformation,
|
||
|
|
CounterpartyName = tx.CreditorName ?? tx.DebtorName,
|
||
|
|
CreditorName = tx.CreditorName,
|
||
|
|
DebtorName = tx.DebtorName,
|
||
|
|
Reference = tx.EndToEndId,
|
||
|
|
Status = "pending",
|
||
|
|
CreatedAt = now,
|
||
|
|
UpdatedAt = now
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|