using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Payment; using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments.Toss; using Domain.Entities.Store.ValueObject; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Admin.Store.Order.ProcessRefund; /// /// 환불 실행 흐름 (사용자 정책): /// 1. 사전 검증 (Digital 미사용 쿠폰 가치 합) /// 2. 토스 부분 취소 호출 — 실패 시 OrderRefund.Reject + 종료 /// 3. TossCancel 행 기록 /// 4. OrderRefund.Approve /// 5. 구매자 환원: 원본 OrderPay 트랜잭션의 BalanceType 별로 비례 분배해 출처 복원 /// 7. GameSettlement 비례 차감 /// 8. Digital 상품 CouponCode 처리: /// - OrderRefundItem 가 있으면 → 각 행의 PostRefundAction (Restore/Expire) 적용 /// - 없으면 fallback → 모든 미사용 코드 Restore (옛 환불 호환) /// - Used 코드는 skip (안전망) /// 9. Physical 상품: 재고 복구 /// 10. Order.Status 갱신 (Cancelled/Refunded/Exchanged), refund.Complete /// public sealed class Handler(IAppDbContext db, ITossPaymentService tossPay) : ICommandHandler { public async Task Handle(Command request, CancellationToken ct) { var refund = await db.OrderRefund .Include(r => r.Order) .ThenInclude(o => o!.Items) .ThenInclude(i => i.Product) .ThenInclude(p => p!.Game) .Include(r => r.Order) .ThenInclude(o => o!.Items) .ThenInclude(i => i.IssuedCouponCode) .Include(r => r.Items) .FirstOrDefaultAsync(r => r.ID == request.RefundID, ct); if (refund is null) { return Result.Failure(Error.NotFound("Refund.NotFound", "환불 요청을 찾을 수 없습니다.")); } if (refund.Status != RefundStatus.Requested) { return Result.Failure(Error.Problem("Refund.NotPending", "처리 대기 중인 환불 요청만 실행 가능합니다.")); } var order = refund.Order!; // 1. 사전 검증 — 미사용 쿠폰 가치 합이 환불 금액 이상 var digitalUnusedValue = 0; foreach (var item in order.Items) { if (item.Product?.Type != ProductType.Digital) { continue; } // 소모품(ItemKind) — 쿠폰 대신 미사용/미회수 MemberItem 가치 if (item.Product.ItemKind is not null) { var unusedItems = await db.MemberItem.CountAsync(m => m.OrderItemID == item.ID && m.UsedAt == null && m.RevokedAt == null, ct); digitalUnusedValue += unusedItems * item.UnitPrice; continue; } var unusedCount = await db.MemberInventory.CountAsync(m => m.OrderItemID == item.ID && m.CouponCode!.Status != CouponCodeStatus.Used, ct); digitalUnusedValue += unusedCount * item.UnitPrice; } if (digitalUnusedValue < refund.Amount) { return Result.Failure(Error.Problem("Refund.InsufficientUnusedCoupons", $"환불 금액({refund.Amount:N0}원)에 대응하는 미사용 쿠폰 코드 가치({digitalUnusedValue:N0}원)가 부족합니다. 일부 쿠폰이 이미 사용되었습니다.")); } // 2. 토스 부분/전체 취소 — PaymentOrder 조회 후 호출 (paymentKey = TransactionID) var paymentOrder = await db.PaymentOrder .Where(p => p.OrderID == order.OrderNumber && p.TransactionID != null) .OrderByDescending(p => p.ID) .FirstOrDefaultAsync(ct); if (paymentOrder is not null && !string.IsNullOrEmpty(paymentOrder.TransactionID)) { var paymentKey = paymentOrder.TransactionID; var isFullCancel = refund.Amount >= paymentOrder.Amount; var cancelReason = string.IsNullOrWhiteSpace(refund.Reason) ? "주문 환불" : refund.Reason; // 취소 요청 기록 저장 (부분/전체) — 응답 전에 요청 흔적을 남긴다 var tossCancel = TossCancel.CreateRequest( memberID: order.MemberID, paymentOrderID: paymentOrder.ID, paymentKey: paymentKey, orderID: order.OrderNumber, cancelAmount: refund.Amount, cancelReason: cancelReason, cancelRequester: request.AdminEmail ?? request.AdminUserId ); db.TossCancel.Add(tossCancel); // 토스 취소 API 호출 (cancelAmount null = 전액 취소) — 통신 예외도 기록을 남긴다 TossPaymentResult cancelResult; try { cancelResult = await tossPay.CancelAsync(paymentKey, cancelReason, isFullCancel ? null : refund.Amount, ct); } catch (Exception ex) { tossCancel.SetError("EXCEPTION", ex.Message); refund.Reject(request.AdminUserId, request.AdminEmail, $"[토스 취소 통신 오류] {ex.Message}"); await db.SaveChangesAsync(ct); return Result.Failure(Error.Problem("Refund.TossCancelFailed", "결제 취소 처리 중 오류가 발생했습니다.")); } if (!cancelResult.Success) { // 토스 실패 → 환불 요청 거부 처리. 운영자 메모에 토스 응답 기록. tossCancel.SetError(cancelResult.Code ?? "FAIL", cancelResult.Message ?? "취소 실패"); refund.Reject(request.AdminUserId, request.AdminEmail, $"[토스 취소 실패] code={cancelResult.Code} message={cancelResult.Message}"); await db.SaveChangesAsync(ct); return Result.Failure(Error.Problem("Refund.TossCancelFailed", $"토스 취소 실패: {cancelResult.Message ?? cancelResult.Code ?? "ERROR"}")); } // 3. TossCancel 응답 기록 var last = cancelResult.Cancels.Count > 0 ? cancelResult.Cancels[^1] : null; tossCancel.SetResponse(cancelResult.Status ?? "", last?.TransactionKey, last?.CanceledAt, cancelResult.RawJson); } // PaymentOrder 가 없으면 (캐시만 결제 / 옛 주문) 토스 호출 skip // 4. OrderRefund 승인 refund.Approve(request.AdminUserId, request.AdminEmail, request.AdminMemo); // 5. 구매자 환원: 원본 OrderPay 의 BalanceType 별 비례 분배로 출처 복원 var buyerWallet = await db.Wallet .Include(w => w.Balances) .Include(w => w.Transactions) .FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct); if (buyerWallet is null) { return Result.Failure(Error.NotFound("Wallet.NotFound", "구매자 지갑을 찾을 수 없습니다.")); } var originalByType = buyerWallet.Transactions .Where(t => t.TxType == WalletTransactionType.OrderPay && t.RefID == order.OrderNumber) .GroupBy(t => t.BalanceType) .ToDictionary(g => g.Key, g => (int)g.Sum(t => t.Amount.Value)); if (originalByType.Count == 0) { return Result.Failure(Error.Problem("Refund.NoOriginalTransactions", "원본 결제 트랜잭션을 찾을 수 없어 환불 출처를 복원할 수 없습니다.")); } var alreadyRefundedByType = buyerWallet.Transactions .Where(t => t.TxType == WalletTransactionType.OrderRefund && t.RefID == order.OrderNumber) .GroupBy(t => t.BalanceType) .ToDictionary(g => g.Key, g => (int)g.Sum(t => t.Amount.Value)); var remainingByType = originalByType.ToDictionary( kvp => kvp.Key, kvp => kvp.Value - alreadyRefundedByType.GetValueOrDefault(kvp.Key, 0)); var totalRemaining = remainingByType.Values.Sum(); if (totalRemaining < refund.Amount) { return Result.Failure(Error.Problem("Refund.ExceedsRemaining", $"환불 가능 잔여 금액을 초과합니다. 요청 {refund.Amount:N0}원 / 잔여 {totalRemaining:N0}원")); } var distribution = new Dictionary(); var distributed = 0; foreach (var (type, remainingForType) in remainingByType.OrderBy(x => (int)x.Key)) { if (remainingForType <= 0) { continue; } var share = (int)Math.Floor((decimal)refund.Amount * remainingForType / totalRemaining); if (share > 0) { distribution[type] = share; distributed += share; } } var residual = refund.Amount - distributed; if (residual > 0) { foreach (var (type, remainingForType) in remainingByType.OrderByDescending(x => x.Value)) { var assigned = distribution.GetValueOrDefault(type, 0); var capacity = remainingForType - assigned; if (capacity <= 0) { continue; } var add = Math.Min(capacity, residual); distribution[type] = assigned + add; residual -= add; if (residual == 0) { break; } } } foreach (var (type, amount) in distribution) { if (amount > 0) { buyerWallet.CreditStoreOrderRefund(type, Money.KRW(amount), reason: "ORDER_REFUND", refID: order.OrderNumber); } } // 6. 채널 보상 비례 차감 — 채널 보상 적립 제거됨(2026-07-03)에 따라 차감 로직도 제거. // 과거 주문(ChannelRewardAmount > 0) 환불 시 채널 회수는 수동 정정(운영자 조정)으로 처리. // 7. GameSettlement 비례 차감 if (order.TotalAmount > 0 && order.GameRevenueAmount > 0) { // Items 의 게임별로 비례 차감 foreach (var item in order.Items) { if (item.Product is null || item.Product.Game is null) { continue; } var lineTotal = item.UnitPrice * item.Quantity; if (lineTotal <= 0) { continue; } var lineGameRevenue = (int)Math.Floor((decimal)lineTotal * item.Product.Game.CommissionRate / 100m); var proRataGame = (int)Math.Floor((decimal)refund.Amount * lineGameRevenue / order.TotalAmount); if (proRataGame <= 0) { continue; } var year = order.PaidAt?.Year ?? order.CreatedAt.Year; var month = order.PaidAt?.Month ?? order.CreatedAt.Month; var settlement = await db.GameSettlement .FirstOrDefaultAsync(s => s.GameID == item.Product.GameID && s.Year == year && s.Month == month, ct); if (settlement is not null && settlement.Status == GameSettlementStatus.Pending) { settlement.SubtractRevenue(proRataGame); } } } // 8. CouponCode 처리: // - OrderRefundItem 가 있으면 명시된 CouponCode 만, 명시된 PostRefundAction 적용 // - 없으면 fallback (옛 환불 호환) — 미사용 코드 모두 RestoreToAvailable if (refund.Items.Count > 0) { var refundItemCouponIds = refund.Items.Select(x => x.CouponCodeID).ToList(); var inventories = await db.MemberInventory .Include(m => m.CouponCode) .Where(m => refundItemCouponIds.Contains(m.CouponCodeID)) .ToListAsync(ct); var invByCouponCode = inventories.ToDictionary(m => m.CouponCodeID); foreach (var refundItem in refund.Items) { if (!invByCouponCode.TryGetValue(refundItem.CouponCodeID, out var inv) || inv.CouponCode is null) { continue; // 이미 처리됐거나 데이터 없음 — skip } if (inv.CouponCode.Status == CouponCodeStatus.Used) { continue; // 안전망: Used 는 회수/만료 모두 불가 } switch (refundItem.PostRefundAction) { case CouponPostRefundAction.Restore: inv.CouponCode.RestoreToAvailable(); break; case CouponPostRefundAction.Expire: inv.CouponCode.Expire(); break; } db.MemberInventory.Remove(inv); } } else { // Fallback: OrderRefundItem 없는 옛 환불 — 미사용 코드 모두 회수 foreach (var item in order.Items) { if (item.Product is null) { continue; } if (item.Product.ItemKind is not null) { // 소모품 — 미사용 MemberItem 회수 var unusedItems = await db.MemberItem .Where(m => m.OrderItemID == item.ID && m.UsedAt == null && m.RevokedAt == null) .ToListAsync(ct); foreach (var mi in unusedItems) { mi.Revoke(DateTime.UtcNow); } continue; } if (item.Product.Type == ProductType.Physical) { item.Product.RestoreStock(item.Quantity); } else if (item.Product.Type == ProductType.Digital) { var memberInventories = await db.MemberInventory .Include(m => m.CouponCode) .Where(m => m.OrderItemID == item.ID) .ToListAsync(ct); foreach (var inv in memberInventories) { if (inv.CouponCode is null) { continue; } if (inv.CouponCode.Status == CouponCodeStatus.Used) { continue; } inv.CouponCode.RestoreToAvailable(); db.MemberInventory.Remove(inv); } } } } // 9. Physical 상품 재고 복구 (OrderRefundItem 분기에서는 Physical 처리 안 함 — 쿠폰 환불은 Digital 만) // OrderRefundItem 가 있는 환불은 쿠폰 환불 전용 — Physical 상품은 fallback 분기에서 처리. // 10. Order.Status 갱신 switch (refund.Type) { case RefundType.Cancel: if (order.Status is OrderStatus.Pending or OrderStatus.Paid) { order.Cancel(refund.Reason); } else { order.MarkRefunded(refund.Reason); } break; case RefundType.Refund: case RefundType.Return: order.MarkRefunded(refund.Reason); break; case RefundType.Exchange: order.MarkExchanged(); break; } refund.Complete(); await db.SaveChangesAsync(ct); return Result.Success(); } }