namespace KArtSell.Modules.ModelOperations.PortfolioReconciliation; using System; using System.Text.Json; using System.Threading.Tasks; using KArtSell.BuildingBlocks.Data; using KArtSell.BuildingBlocks.Hashing; using KArtSell.BuildingBlocks.Reliability; using KArtSell.BuildingBlocks.Time; /// /// Handles trade reconciliation command. /// Updates holdings, calculates cost basis, detects mismatches. /// Publishes reconciliation event to Outbox. /// public class ReconcileTradeCommand { public Guid TradeId { get; set; } public Guid SecurityId { get; set; } public int ExecutedQuantity { get; set; } public decimal ExecutedPrice { get; set; } public int ApprovedQuantity { get; set; } public decimal ApprovedPrice { get; set; } public DateTime TradeDate { get; set; } public DateTime ExpectedSettlementDate { get; set; } public DateTime? ActualSettlementDate { get; set; } public Guid CorrelationId { get; set; } public string? IdempotencyKey { get; set; } } public class ReconcileTradeHandler { private readonly ReconciliationEngine _engine; private readonly IDbConnectionFactory _connectionFactory; private readonly IOutboxWriter _outboxWriter; private readonly IClock _clock; public ReconcileTradeHandler( ReconciliationEngine engine, IDbConnectionFactory connectionFactory, IOutboxWriter outboxWriter, IClock clock) { _engine = engine ?? throw new ArgumentNullException(nameof(engine)); _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); _outboxWriter = outboxWriter ?? throw new ArgumentNullException(nameof(outboxWriter)); _clock = clock ?? throw new ArgumentNullException(nameof(clock)); } public async Task HandleAsync(ReconcileTradeCommand command) { ArgumentNullException.ThrowIfNull(command); var result = await _engine.ReconcileTradeAsync( command.TradeId, command.SecurityId, command.ExecutedQuantity, command.ExecutedPrice, command.ApprovedQuantity, command.ApprovedPrice, command.TradeDate, command.ExpectedSettlementDate, command.ActualSettlementDate, command.CorrelationId); if (!result.Success) { throw new InvalidOperationException($"Reconciliation failed: {result.Error}"); } // Publish event to Outbox for async processing var @event = new TradeReconciledEvent { EventId = Guid.NewGuid(), TradeId = command.TradeId, HoldingId = result.Holding!.Id, SecurityId = command.SecurityId, QuantityAfter = result.Holding.Quantity, CostBasisAfter = result.Holding.TotalCostBasis, MismatchDetected = result.MismatchDetected, MismatchSummary = result.MismatchDetected ? FormatMismatchSummary(result.Mismatches) : null, ReconciliationTimestamp = _clock.UtcNow.UtcDateTime, CorrelationId = command.CorrelationId, IdempotencyKey = command.IdempotencyKey ?? Guid.NewGuid().ToString() }; await PublishAsync("TradeReconciled", @event, command.CorrelationId, CancellationToken.None); // If mismatches detected, publish alert event if (result.MismatchDetected) { var alertEvent = new ReconciliationMismatchAlertEvent { EventId = Guid.NewGuid(), TradeId = command.TradeId, HoldingId = result.Holding.Id, MismatchCount = result.Mismatches.Count, HighSeverityCount = CountBysSeverity(result.Mismatches, MismatchSeverity.High), MismatchDetails = FormatMismatchDetails(result.Mismatches), AlertedAt = _clock.UtcNow.UtcDateTime, CorrelationId = command.CorrelationId }; await PublishAsync("ReconciliationMismatchAlert", alertEvent, command.CorrelationId, CancellationToken.None); } } /// /// DEBT-TRADE-001: outbox write happens in its own transaction, separate from the /// preceding holding/log writes owned by ReconciliationSql. Not yet atomic with the /// entity write. See TECH_DEBT_REGISTER.md. /// private async Task PublishAsync(string eventType, T @event, Guid correlationId, CancellationToken ct) where T : class { var payload = JsonSerializer.Serialize(@event); var message = new OutboxMessage( Guid.NewGuid(), eventType, 1, payload, correlationId.ToString(), _clock.UtcNow, ContentHasher.Sha256(payload)); await using var connection = await _connectionFactory.OpenAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); await _outboxWriter.AddAsync(connection, transaction, message, ct); await transaction.CommitAsync(ct); } private int CountBysSeverity(List mismatches, MismatchSeverity severity) { return mismatches.Count(m => m.Severity == severity); } private string FormatMismatchSummary(List mismatches) { return string.Join("; ", mismatches.ConvertAll(m => m.Type.ToString())); } private string FormatMismatchDetails(List mismatches) { return string.Join("\n", mismatches.ConvertAll(m => $"{m.Type}: {m.Description}")); } } /// /// Event published when trade is reconciled. /// public class TradeReconciledEvent { public Guid EventId { get; set; } public Guid TradeId { get; set; } public Guid HoldingId { get; set; } public Guid SecurityId { get; set; } public int QuantityAfter { get; set; } public decimal CostBasisAfter { get; set; } public bool MismatchDetected { get; set; } public string? MismatchSummary { get; set; } public DateTime ReconciliationTimestamp { get; set; } public Guid CorrelationId { get; set; } public string IdempotencyKey { get; set; } = string.Empty; } /// /// Event published when reconciliation mismatches are detected. /// public class ReconciliationMismatchAlertEvent { public Guid EventId { get; set; } public Guid TradeId { get; set; } public Guid HoldingId { get; set; } public int MismatchCount { get; set; } public int HighSeverityCount { get; set; } public string MismatchDetails { get; set; } = string.Empty; public DateTime AlertedAt { get; set; } public Guid CorrelationId { get; set; } }