Files
tiku-backend.net/Tiku.Infrastructure/Commerce/Refunds/RefundAdministrationService.cs
xiong 33375a38d7
Some checks failed
ci / release-gate (push) Has been cancelled
refactor(architecture): harden module boundaries
2026-08-04 12:10:36 +08:00

185 lines
9.4 KiB
C#

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<TenantRefundList> 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<CommerceRefundRequest> 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<CommerceRefundRequest> 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<TenantRefundEventList> 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);
}
}