books/backend/Books.Api/Banking/BankTransactionSyncJob.cs
Nicolaj Hartmann 1f75c5d791 Add all backend domain, commands, repositories, and tests
This commit includes all previously untracked backend files:

Domain:
- Accounts, Attachments, BankConnections, Customers
- FiscalYears, Invoices, JournalEntryDrafts
- Orders, Products, UserAccess

Commands & Handlers:
- Full CQRS command structure for all domains

Repositories:
- PostgreSQL repositories for all read models
- Bank transaction and ledger repositories

GraphQL:
- Input types, scalars, and types for all entities
- Mutations and queries

Infrastructure:
- Banking integration (Enable Banking client)
- File storage, Invoicing, Reporting, SAF-T export
- Database migrations (003-029)

Tests:
- Integration tests for GraphQL endpoints
- Domain tests
- Invoicing and reporting tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:19:42 +01:00

221 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
};
}
}