using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using Tiku.Application.Commerce; using Tiku.Domain.Commerce; using Tiku.Infrastructure.Security; namespace Tiku.Infrastructure.Commerce; internal sealed partial class RefundAdministrationService(CommerceAdministrationDependencies dependencies) : CommerceAdministrationServiceBase(dependencies), IRefundAdministrationService { public async Task GetRefundsAsync( CommerceAdminActor actor, CommerceAdminQuery query, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refunds = commercePersistence.CommerceRefundRequests.AsNoTracking() .Where(item => item.TenantId == actor.TenantId) .ApplyDataScope( scope, item => item.RequestedBy == actor.UserId || commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), item => commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))); if (!string.IsNullOrWhiteSpace(query.Status)) refunds = refunds.Where(item => item.Status == ParseRefundStatus(query.Status)); var items = await refunds .OrderByDescending(item => item.CreatedAt) .Take(Math.Clamp(query.Limit ?? 50, 1, 200)) .ToArrayAsync(cancellationToken); return new TenantRefundList(items); } public async Task CreateRefundRequestAsync( CommerceAdminActor actor, CreateRefundRequestCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var order = await commercePersistence.Orders .Where(item => item.TenantId == actor.TenantId && item.Id == command.OrderId) .ApplyDataScope( scope, item => item.UserId == actor.UserId, item => item.RegionId.HasValue && regionIds.Contains(item.RegionId.Value)) .SingleOrDefaultAsync(cancellationToken) ?? throw new CommerceException("Order was not found.", "order_not_found"); if (order.Status is not (OrderStatus.Paid or OrderStatus.PartiallyRefunded)) throw new CommerceException("Only paid orders can be refunded.", "order_not_refundable"); if (command.AmountCents <= 0 || command.AmountCents > order.AmountCents - order.RefundedAmountCents) throw new CommerceException("Refund amount is invalid.", "invalid_refund_amount"); if (command.PaymentId.HasValue) { var paymentExists = await commercePersistence.Payments.AnyAsync( item => item.TenantId == actor.TenantId && item.Id == command.PaymentId.Value && item.OrderId == order.Id, cancellationToken); if (!paymentExists) throw new CommerceException("Payment was not found.", "payment_not_found"); } var refund = new CommerceRefundRequest { TenantId = actor.TenantId, OrderId = order.Id, PaymentId = command.PaymentId, RequestedBy = actor.UserId, RefundNo = $"RF{DateTimeOffset.UtcNow:yyyyMMddHHmmss}{RandomNumberGenerator.GetInt32(1000, 9999)}", Provider = order.PayProvider, Status = CommerceRefundStatus.Requested, AmountCents = command.AmountCents, Reason = command.Reason?.Trim(), EntitlementAction = command.EntitlementAction, Metadata = JsonObjectOrDefault(command.Metadata) }; commercePersistence.CommerceRefundRequests.Add(refund); AddRefundEvent(refund, null, CommerceRefundStatus.Requested, "created", actor.UserId, new { refund.AmountCents, refund.Reason }); await AddAuditAsync(actor, "commerce.refund.created", "commerce_refund_requests", refund.Id, new { refund.RefundNo, refund.AmountCents }, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); return refund; } public async Task UpdateRefundStatusAsync( CommerceAdminActor actor, UpdateRefundStatusCommand command, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refund = await commercePersistence.CommerceRefundRequests .Where(item => item.TenantId == actor.TenantId && item.Id == command.RefundRequestId) .ApplyDataScope( scope, item => item.RequestedBy == actor.UserId || commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), item => commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))) .SingleOrDefaultAsync(cancellationToken) ?? throw new CommerceException("Refund request was not found.", "refund_not_found"); var fromStatus = refund.Status; if (!IsAllowedRefundTransition(fromStatus, command.Status)) throw new CommerceException("Refund status transition is invalid.", "invalid_refund_transition"); refund.Status = command.Status; refund.ProviderRefundNo = string.IsNullOrWhiteSpace(command.ProviderRefundNo) ? refund.ProviderRefundNo : command.ProviderRefundNo.Trim(); switch (command.Status) { case CommerceRefundStatus.Approved: refund.ReviewedBy = actor.UserId; refund.ReviewedAt = DateTimeOffset.UtcNow; break; case CommerceRefundStatus.Processing: refund.ProcessedBy = actor.UserId; refund.ProcessedAt = DateTimeOffset.UtcNow; break; case CommerceRefundStatus.Succeeded: refund.SucceededAt = DateTimeOffset.UtcNow; await ApplyRefundToOrderAsync(refund, cancellationToken); break; case CommerceRefundStatus.Failed: refund.FailedAt = DateTimeOffset.UtcNow; refund.FailureReason = command.Reason; break; case CommerceRefundStatus.Cancelled or CommerceRefundStatus.Rejected: refund.CancelledAt = DateTimeOffset.UtcNow; break; } AddRefundEvent(refund, fromStatus, command.Status, "status_changed", actor.UserId, new { command.Reason, command.ProviderRefundNo }); await AddAuditAsync(actor, "commerce.refund.status_changed", "commerce_refund_requests", refund.Id, new { refund.RefundNo, From = fromStatus, To = command.Status }, cancellationToken); await unitOfWork.SaveChangesAsync(cancellationToken); return refund; } public async Task GetRefundEventsAsync( CommerceAdminActor actor, Guid refundRequestId, CancellationToken cancellationToken = default) { await AssertAdminAsync(actor, cancellationToken); var scope = await RequireDataScopeAsync(actor, cancellationToken); var regionIds = scope.RegionIds.ToArray(); var refundExists = await commercePersistence.CommerceRefundRequests .Where(item => item.TenantId == actor.TenantId && item.Id == refundRequestId) .ApplyDataScope( scope, item => item.RequestedBy == actor.UserId || commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.UserId == actor.UserId), item => commercePersistence.Orders.Any(order => order.TenantId == actor.TenantId && order.Id == item.OrderId && order.RegionId.HasValue && regionIds.Contains(order.RegionId.Value))) .AnyAsync(cancellationToken); if (!refundExists) throw new CommerceException("Refund request was not found.", "refund_not_found"); var items = await commercePersistence.CommerceRefundEvents.AsNoTracking() .Where(item => item.TenantId == actor.TenantId && item.RefundRequestId == refundRequestId) .OrderBy(item => item.CreatedAt) .ToArrayAsync(cancellationToken); return new TenantRefundEventList(items); } }