| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115 |
- 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;
- /// <summary>
- /// 사용자 측 환불 요청. 자동 승인 없이 Requested 상태로 기록. 관리자가 ProcessRefund 로 실제 처리.
- /// 정책:
- /// - Cancel: OrderStatus ∈ {Paid, Preparing} 일 때만. 시점 제한 없음.
- /// - Return/Exchange/Refund: OrderStatus = Delivered + 배송완료(또는 PaidAt) 후 30일 이내.
- /// - 미해결 환불 요청(Requested) 이 이미 있으면 중복 차단.
- /// - Amount 는 전액 (1차 — 부분환불 미지원).
- /// - ReasonType=Other 인 경우 ReasonMemo 필수.
- /// </summary>
- internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
- {
- private const int ReturnExchangeWindowDays = 30;
- public async Task<Result<Response>> 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<Response>(Error.NotFound("Order.NotFound", "주문을 찾을 수 없습니다."));
- }
- if (request.ReasonType == RefundReasonType.Other && string.IsNullOrWhiteSpace(request.ReasonMemo))
- {
- return Result.Failure<Response>(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<Response>(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<Response>(Error.Problem("Refund.NotCancellable", "결제완료 또는 출고 준비 단계의 주문만 취소할 수 있습니다."));
- }
- break;
- case RefundType.Return:
- case RefundType.Exchange:
- case RefundType.Refund:
- if (order.Status != OrderStatus.Delivered)
- {
- return Result.Failure<Response>(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<Response>(Error.Problem("Refund.NoDeliveryDate", "배송 완료 일시를 확인할 수 없어 신청이 불가합니다."));
- }
- if (DateTime.UtcNow > deliveredAt.Value.AddDays(ReturnExchangeWindowDays))
- {
- return Result.Failure<Response>(Error.Problem("Refund.WindowExpired", $"배송 완료 후 {ReturnExchangeWindowDays}일이 지나 신청할 수 없습니다."));
- }
- break;
- default:
- return Result.Failure<Response>(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 => "오배송",
- _ => "기타"
- };
- }
|