using System.Text.Json; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Application.Jobs; using Tiku.Domain.Commerce; namespace Tiku.Infrastructure.Commerce; internal sealed partial class ReconciliationAdministrationService { public async Task GetReconciliationItemsAsync( CommerceAdminActor actor, Guid batchId, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var batchExists = await commercePersistence.CommerceReconciliationBatches.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == batchId, cancellationToken); if (!batchExists) throw new CommerceException("Reconciliation batch was not found.", "reconciliation_batch_not_found"); var items = await commercePersistence.CommerceReconciliationItems.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.BatchId == batchId) .OrderBy(item => item.RowNo) .Take(500) .ToArrayAsync(cancellationToken); return new TenantReconciliationItemList(items); } public async Task GetReconciliationIssueEventsAsync( CommerceAdminActor actor, Guid issueId, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var issueExists = await commercePersistence.CommerceReconciliationIssues.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == issueId, cancellationToken); if (!issueExists) throw new CommerceException("Reconciliation issue was not found.", "reconciliation_issue_not_found"); var events = await commercePersistence.CommerceReconciliationIssueEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.IssueId == issueId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); return new TenantReconciliationIssueEventList(events); } public async Task PreviewReconciliationImportAsync( CommerceAdminActor actor, PreviewReconciliationImportCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); return BuildImportPreview(command.Provider, command.Rows); } public async Task ImportReconciliationAsync( CommerceAdminActor actor, ImportReconciliationCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var preview = BuildImportPreview(command.Provider, command.Rows); var batch = new CommerceReconciliationBatch { TenantId = actor.TenantId, CreatedBy = actor.UserId, Provider = NormalizeProvider(command.Provider), BillDate = command.BillDate, BillType = command.BillType, Source = ReconciliationSource.ManualUpload, SourceName = command.SourceName.Trim(), SourceHash = preview.SourceHash, Status = preview.InvalidCount == 0 ? ReconciliationBatchStatus.Completed : ReconciliationBatchStatus.CompletedWithIssues, TotalCount = preview.TotalCount, MatchedCount = preview.TotalCount - preview.InvalidCount, MismatchCount = preview.InvalidCount, AmountCents = preview.AmountCents, RefundAmountCents = preview.RefundAmountCents, CompletedAt = DateTimeOffset.UtcNow, Metadata = JsonSerializer.SerializeToElement(new { preview.PaymentCount, preview.RefundCount, preview.InvalidCount }) }; commercePersistence.CommerceReconciliationBatches.Add(batch); var rowNo = 0; foreach (var row in EnumerateImportRows(command.Rows)) { rowNo++; var item = CreateReconciliationItem(actor.TenantId, batch.Id, rowNo, NormalizeProvider(command.Provider), row); commercePersistence.CommerceReconciliationItems.Add(item); if (item.MatchStatus != ReconciliationMatchStatus.Matched) commercePersistence.CommerceReconciliationIssues.Add(new CommerceReconciliationIssue { TenantId = actor.TenantId, BatchId = batch.Id, Provider = item.Provider, TransactionType = item.TransactionType, IssueNo = $"RC{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{rowNo:0000}", MatchStatus = item.MatchStatus switch { ReconciliationMatchStatus.MissingLocal => ReconciliationIssueMatchStatus.MissingLocal, ReconciliationMatchStatus.MissingProvider => ReconciliationIssueMatchStatus.MissingProvider, ReconciliationMatchStatus.Duplicate => ReconciliationIssueMatchStatus.Duplicate, ReconciliationMatchStatus.StatusMismatch => ReconciliationIssueMatchStatus.StatusMismatch, _ => ReconciliationIssueMatchStatus.AmountMismatch }, Severity = item.Severity, Status = ReconciliationIssueStatus.Open, OrderNo = item.OrderNo, RefundNo = item.RefundNo, ProviderTradeNo = item.ProviderTradeNo, ProviderRefundNo = item.ProviderRefundNo, AmountCents = item.AmountCents, RefundAmountCents = item.RefundAmountCents, Summary = item.IssueCode, CreatedBy = actor.UserId, Metadata = item.Details }); } await AddAuditAsync(actor, "commerce.reconciliation.imported", "commerce_reconciliation_batches", batch.Id, new { batch.Provider, batch.BillDate, batch.TotalCount }, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); return batch; } public async Task RequestProviderBillJobAsync( CommerceAdminActor actor, RequestProviderBillJobCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var job = await backgroundJobQueue.EnqueueAsync( new CreateBackgroundJobCommand( actor.TenantId, "commerce_reconciliation", JsonSerializer.SerializeToElement(new { provider = NormalizeProvider(command.Provider), command.BillDate, billType = command.BillType.ToString() }), command.RunAfter, 5), cancellationToken); await AddAuditAsync(actor, "commerce.reconciliation.provider_bill_requested", "background_jobs", job.Id, new { command.Provider, command.BillDate, command.BillType }, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); return job; } public async Task> GetProviderBillJobsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); return await backgroundJobOperations.ListAsync(actor.TenantId, "commerce_reconciliation", Math.Clamp(query.Limit ?? 50, 1, 200), cancellationToken); } }