using Application.Abstractions.Data;
using Application.Abstractions.Messaging;
using Domain.Entities.Store.ValueObject;
using Microsoft.EntityFrameworkCore;
using SharedKernel.Results;
using OrderRefundEntity = Domain.Entities.Store.OrderRefund;
namespace Application.Features.Api.Store.Orders.RequestRefund;
///
/// 사용자 측 환불 요청. 자동 승인 없이 Requested 상태로 기록. 관리자가 ProcessRefund 로 실제 처리.
/// 정책:
/// - Cancel: OrderStatus ∈ {Paid, Preparing} 일 때만. 시점 제한 없음.
/// - Return/Exchange/Refund: OrderStatus = Delivered + 배송완료(또는 PaidAt) 후 30일 이내.
/// - 미해결 환불 요청(Requested) 이 이미 있으면 중복 차단.
/// - Amount 는 전액 (1차 — 부분환불 미지원).
/// - ReasonType=Other 인 경우 ReasonMemo 필수.
///
internal sealed class Handler(IAppDbContext db) : ICommandHandler>
{
private const int ReturnExchangeWindowDays = 30;
public async Task> Handle(Command request, CancellationToken ct)
{
var order = await db.Order.AsNoTracking()
.FirstOrDefaultAsync(o => o.ID == request.OrderID, ct);
if (order is null || order.MemberID != request.MemberID)
{
return Result.Failure(Error.NotFound("Order.NotFound", "주문을 찾을 수 없습니다."));
}
if (request.ReasonType == RefundReasonType.Other && string.IsNullOrWhiteSpace(request.ReasonMemo))
{
return Result.Failure(Error.Problem("Refund.ReasonMemoRequired", "사유가 '기타'인 경우 상세 사유를 입력해 주세요."));
}
// 중복 요청 차단 — 미해결 Requested 가 이미 있으면 거부
var hasPending = await db.OrderRefund.AsNoTracking()
.AnyAsync(r => r.OrderID == order.ID && r.Status == RefundStatus.Requested, ct);
if (hasPending)
{
return Result.Failure(Error.Conflict("Refund.AlreadyRequested", "이미 처리 대기 중인 환불 요청이 있습니다."));
}
// 상태별 허용 RefundType 검증
switch (request.Type)
{
case RefundType.Cancel:
if (order.Status is not OrderStatus.Paid and not OrderStatus.Preparing)
{
return Result.Failure(Error.Problem("Refund.NotCancellable", "결제완료 또는 출고 준비 단계의 주문만 취소할 수 있습니다."));
}
break;
case RefundType.Return:
case RefundType.Exchange:
case RefundType.Refund:
if (order.Status != OrderStatus.Delivered)
{
return Result.Failure(Error.Problem("Refund.NotEligible", "배송완료된 주문에 대해서만 반품/교환/환불을 신청할 수 있습니다."));
}
// 배송완료 후 30일 이내 — Physical 은 Shipment.DeliveredAt, Digital 은 PaidAt 기준
var shipment = await db.Shipment.AsNoTracking()
.Where(s => s.OrderID == order.ID)
.Select(s => new { s.DeliveredAt })
.FirstOrDefaultAsync(ct);
var deliveredAt = shipment?.DeliveredAt ?? order.PaidAt;
if (deliveredAt is null)
{
return Result.Failure(Error.Problem("Refund.NoDeliveryDate", "배송 완료 일시를 확인할 수 없어 신청이 불가합니다."));
}
if (DateTime.UtcNow > deliveredAt.Value.AddDays(ReturnExchangeWindowDays))
{
return Result.Failure(Error.Problem("Refund.WindowExpired", $"배송 완료 후 {ReturnExchangeWindowDays}일이 지나 신청할 수 없습니다."));
}
break;
default:
return Result.Failure(Error.Problem("Refund.InvalidType", "지원하지 않는 환불 유형입니다."));
}
var reason = string.IsNullOrWhiteSpace(request.ReasonMemo)
? GetReasonLabel(request.ReasonType)
: request.ReasonMemo.Trim();
if (reason.Length > 500)
{
reason = reason[..500];
}
var refund = OrderRefundEntity.Create(order.ID, request.Type, request.ReasonType, order.TotalAmount, reason);
db.OrderRefund.Add(refund);
await db.SaveChangesAsync(ct);
return Result.Success(new Response(refund.ID));
}
private static string GetReasonLabel(RefundReasonType type) => type switch
{
RefundReasonType.ChangedMind => "단순 변심",
RefundReasonType.OptionChange => "옵션/사이즈 변경",
RefundReasonType.ProductDefect => "상품 불량/파손",
RefundReasonType.ProductInfoMismatch => "상품 정보와 다름",
RefundReasonType.DeliveryDelay => "배송 지연/누락",
RefundReasonType.WrongDelivery => "오배송",
_ => "기타"
};
}