Handler.cs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Domain.Entities.Store.ValueObject;
  4. using Microsoft.EntityFrameworkCore;
  5. using SharedKernel.Results;
  6. using OrderRefundEntity = Domain.Entities.Store.OrderRefund;
  7. namespace Application.Features.Api.Store.Orders.RequestRefund;
  8. /// <summary>
  9. /// 사용자 측 환불 요청. 자동 승인 없이 Requested 상태로 기록. 관리자가 ProcessRefund 로 실제 처리.
  10. /// 정책:
  11. /// - Cancel: OrderStatus ∈ {Paid, Preparing} 일 때만. 시점 제한 없음.
  12. /// - Return/Exchange/Refund: OrderStatus = Delivered + 배송완료(또는 PaidAt) 후 30일 이내.
  13. /// - 미해결 환불 요청(Requested) 이 이미 있으면 중복 차단.
  14. /// - Amount 는 전액 (1차 — 부분환불 미지원).
  15. /// - ReasonType=Other 인 경우 ReasonMemo 필수.
  16. /// </summary>
  17. internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  18. {
  19. private const int ReturnExchangeWindowDays = 30;
  20. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  21. {
  22. var order = await db.Order.AsNoTracking()
  23. .FirstOrDefaultAsync(o => o.ID == request.OrderID, ct);
  24. if (order is null || order.MemberID != request.MemberID)
  25. {
  26. return Result.Failure<Response>(Error.NotFound("Order.NotFound", "주문을 찾을 수 없습니다."));
  27. }
  28. if (request.ReasonType == RefundReasonType.Other && string.IsNullOrWhiteSpace(request.ReasonMemo))
  29. {
  30. return Result.Failure<Response>(Error.Problem("Refund.ReasonMemoRequired", "사유가 '기타'인 경우 상세 사유를 입력해 주세요."));
  31. }
  32. // 중복 요청 차단 — 미해결 Requested 가 이미 있으면 거부
  33. var hasPending = await db.OrderRefund.AsNoTracking()
  34. .AnyAsync(r => r.OrderID == order.ID && r.Status == RefundStatus.Requested, ct);
  35. if (hasPending)
  36. {
  37. return Result.Failure<Response>(Error.Conflict("Refund.AlreadyRequested", "이미 처리 대기 중인 환불 요청이 있습니다."));
  38. }
  39. // 상태별 허용 RefundType 검증
  40. switch (request.Type)
  41. {
  42. case RefundType.Cancel:
  43. if (order.Status is not OrderStatus.Paid and not OrderStatus.Preparing)
  44. {
  45. return Result.Failure<Response>(Error.Problem("Refund.NotCancellable", "결제완료 또는 출고 준비 단계의 주문만 취소할 수 있습니다."));
  46. }
  47. break;
  48. case RefundType.Return:
  49. case RefundType.Exchange:
  50. case RefundType.Refund:
  51. if (order.Status != OrderStatus.Delivered)
  52. {
  53. return Result.Failure<Response>(Error.Problem("Refund.NotEligible", "배송완료된 주문에 대해서만 반품/교환/환불을 신청할 수 있습니다."));
  54. }
  55. // 배송완료 후 30일 이내 — Physical 은 Shipment.DeliveredAt, Digital 은 PaidAt 기준
  56. var shipment = await db.Shipment.AsNoTracking()
  57. .Where(s => s.OrderID == order.ID)
  58. .Select(s => new { s.DeliveredAt })
  59. .FirstOrDefaultAsync(ct);
  60. var deliveredAt = shipment?.DeliveredAt ?? order.PaidAt;
  61. if (deliveredAt is null)
  62. {
  63. return Result.Failure<Response>(Error.Problem("Refund.NoDeliveryDate", "배송 완료 일시를 확인할 수 없어 신청이 불가합니다."));
  64. }
  65. if (DateTime.UtcNow > deliveredAt.Value.AddDays(ReturnExchangeWindowDays))
  66. {
  67. return Result.Failure<Response>(Error.Problem("Refund.WindowExpired", $"배송 완료 후 {ReturnExchangeWindowDays}일이 지나 신청할 수 없습니다."));
  68. }
  69. break;
  70. default:
  71. return Result.Failure<Response>(Error.Problem("Refund.InvalidType", "지원하지 않는 환불 유형입니다."));
  72. }
  73. var reason = string.IsNullOrWhiteSpace(request.ReasonMemo)
  74. ? GetReasonLabel(request.ReasonType)
  75. : request.ReasonMemo.Trim();
  76. if (reason.Length > 500)
  77. {
  78. reason = reason[..500];
  79. }
  80. var refund = OrderRefundEntity.Create(order.ID, request.Type, request.ReasonType, order.TotalAmount, reason);
  81. db.OrderRefund.Add(refund);
  82. await db.SaveChangesAsync(ct);
  83. return Result.Success(new Response(refund.ID));
  84. }
  85. private static string GetReasonLabel(RefundReasonType type) => type switch
  86. {
  87. RefundReasonType.ChangedMind => "단순 변심",
  88. RefundReasonType.OptionChange => "옵션/사이즈 변경",
  89. RefundReasonType.ProductDefect => "상품 불량/파손",
  90. RefundReasonType.ProductInfoMismatch => "상품 정보와 다름",
  91. RefundReasonType.DeliveryDelay => "배송 지연/누락",
  92. RefundReasonType.WrongDelivery => "오배송",
  93. _ => "기타"
  94. };
  95. }