185 lines
9.4 KiB
C#
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);
|
|
}
|
|
}
|