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 => "오배송", _ => "기타" }; }