Handler.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Payment;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Payments.Toss;
  6. using Domain.Entities.Store.ValueObject;
  7. using Domain.Entities.Wallets.ValueObject;
  8. using Microsoft.EntityFrameworkCore;
  9. using SharedKernel.Results;
  10. namespace Application.Features.Admin.Store.Order.ProcessRefund;
  11. /// <summary>
  12. /// 환불 실행 흐름 (사용자 정책):
  13. /// 1. 사전 검증 (Digital 미사용 쿠폰 가치 합)
  14. /// 2. 토스 부분 취소 호출 — 실패 시 OrderRefund.Reject + 종료
  15. /// 3. TossCancel 행 기록
  16. /// 4. OrderRefund.Approve
  17. /// 5. 구매자 환원: 원본 OrderPay 트랜잭션의 BalanceType 별로 비례 분배해 출처 복원
  18. /// 7. GameSettlement 비례 차감
  19. /// 8. Digital 상품 CouponCode 처리:
  20. /// - OrderRefundItem 가 있으면 → 각 행의 PostRefundAction (Restore/Expire) 적용
  21. /// - 없으면 fallback → 모든 미사용 코드 Restore (옛 환불 호환)
  22. /// - Used 코드는 skip (안전망)
  23. /// 9. Physical 상품: 재고 복구
  24. /// 10. Order.Status 갱신 (Cancelled/Refunded/Exchanged), refund.Complete
  25. /// </summary>
  26. public sealed class Handler(IAppDbContext db, ITossPaymentService tossPay) : ICommandHandler<Command, Result>
  27. {
  28. public async Task<Result> Handle(Command request, CancellationToken ct)
  29. {
  30. var refund = await db.OrderRefund
  31. .Include(r => r.Order)
  32. .ThenInclude(o => o!.Items)
  33. .ThenInclude(i => i.Product)
  34. .ThenInclude(p => p!.Game)
  35. .Include(r => r.Order)
  36. .ThenInclude(o => o!.Items)
  37. .ThenInclude(i => i.IssuedCouponCode)
  38. .Include(r => r.Items)
  39. .FirstOrDefaultAsync(r => r.ID == request.RefundID, ct);
  40. if (refund is null)
  41. {
  42. return Result.Failure(Error.NotFound("Refund.NotFound", "환불 요청을 찾을 수 없습니다."));
  43. }
  44. if (refund.Status != RefundStatus.Requested)
  45. {
  46. return Result.Failure(Error.Problem("Refund.NotPending", "처리 대기 중인 환불 요청만 실행 가능합니다."));
  47. }
  48. var order = refund.Order!;
  49. // 1. 사전 검증 — 미사용 쿠폰 가치 합이 환불 금액 이상
  50. var digitalUnusedValue = 0;
  51. foreach (var item in order.Items)
  52. {
  53. if (item.Product?.Type != ProductType.Digital)
  54. {
  55. continue;
  56. }
  57. // 소모품(ItemKind) — 쿠폰 대신 미사용/미회수 MemberItem 가치
  58. if (item.Product.ItemKind is not null)
  59. {
  60. var unusedItems = await db.MemberItem.CountAsync(m => m.OrderItemID == item.ID && m.UsedAt == null && m.RevokedAt == null, ct);
  61. digitalUnusedValue += unusedItems * item.UnitPrice;
  62. continue;
  63. }
  64. var unusedCount = await db.MemberInventory.CountAsync(m => m.OrderItemID == item.ID && m.CouponCode!.Status != CouponCodeStatus.Used, ct);
  65. digitalUnusedValue += unusedCount * item.UnitPrice;
  66. }
  67. if (digitalUnusedValue < refund.Amount)
  68. {
  69. return Result.Failure(Error.Problem("Refund.InsufficientUnusedCoupons", $"환불 금액({refund.Amount:N0}원)에 대응하는 미사용 쿠폰 코드 가치({digitalUnusedValue:N0}원)가 부족합니다. 일부 쿠폰이 이미 사용되었습니다."));
  70. }
  71. // 2. 토스 부분/전체 취소 — PaymentOrder 조회 후 호출 (paymentKey = TransactionID)
  72. var paymentOrder = await db.PaymentOrder
  73. .Where(p => p.OrderID == order.OrderNumber && p.TransactionID != null)
  74. .OrderByDescending(p => p.ID)
  75. .FirstOrDefaultAsync(ct);
  76. if (paymentOrder is not null && !string.IsNullOrEmpty(paymentOrder.TransactionID))
  77. {
  78. var paymentKey = paymentOrder.TransactionID;
  79. var isFullCancel = refund.Amount >= paymentOrder.Amount;
  80. var cancelReason = string.IsNullOrWhiteSpace(refund.Reason) ? "주문 환불" : refund.Reason;
  81. // 취소 요청 기록 저장 (부분/전체) — 응답 전에 요청 흔적을 남긴다
  82. var tossCancel = TossCancel.CreateRequest(
  83. memberID: order.MemberID,
  84. paymentOrderID: paymentOrder.ID,
  85. paymentKey: paymentKey,
  86. orderID: order.OrderNumber,
  87. cancelAmount: refund.Amount,
  88. cancelReason: cancelReason,
  89. cancelRequester: request.AdminEmail ?? request.AdminUserId
  90. );
  91. db.TossCancel.Add(tossCancel);
  92. // 토스 취소 API 호출 (cancelAmount null = 전액 취소) — 통신 예외도 기록을 남긴다
  93. TossPaymentResult cancelResult;
  94. try
  95. {
  96. cancelResult = await tossPay.CancelAsync(paymentKey, cancelReason, isFullCancel ? null : refund.Amount, ct);
  97. }
  98. catch (Exception ex)
  99. {
  100. tossCancel.SetError("EXCEPTION", ex.Message);
  101. refund.Reject(request.AdminUserId, request.AdminEmail, $"[토스 취소 통신 오류] {ex.Message}");
  102. await db.SaveChangesAsync(ct);
  103. return Result.Failure(Error.Problem("Refund.TossCancelFailed", "결제 취소 처리 중 오류가 발생했습니다."));
  104. }
  105. if (!cancelResult.Success)
  106. {
  107. // 토스 실패 → 환불 요청 거부 처리. 운영자 메모에 토스 응답 기록.
  108. tossCancel.SetError(cancelResult.Code ?? "FAIL", cancelResult.Message ?? "취소 실패");
  109. refund.Reject(request.AdminUserId, request.AdminEmail, $"[토스 취소 실패] code={cancelResult.Code} message={cancelResult.Message}");
  110. await db.SaveChangesAsync(ct);
  111. return Result.Failure(Error.Problem("Refund.TossCancelFailed", $"토스 취소 실패: {cancelResult.Message ?? cancelResult.Code ?? "ERROR"}"));
  112. }
  113. // 3. TossCancel 응답 기록
  114. var last = cancelResult.Cancels.Count > 0 ? cancelResult.Cancels[^1] : null;
  115. tossCancel.SetResponse(cancelResult.Status ?? "", last?.TransactionKey, last?.CanceledAt, cancelResult.RawJson);
  116. }
  117. // PaymentOrder 가 없으면 (캐시만 결제 / 옛 주문) 토스 호출 skip
  118. // 4. OrderRefund 승인
  119. refund.Approve(request.AdminUserId, request.AdminEmail, request.AdminMemo);
  120. // 5. 구매자 환원: 원본 OrderPay 의 BalanceType 별 비례 분배로 출처 복원
  121. var buyerWallet = await db.Wallet
  122. .Include(w => w.Balances)
  123. .Include(w => w.Transactions)
  124. .FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  125. if (buyerWallet is null)
  126. {
  127. return Result.Failure(Error.NotFound("Wallet.NotFound", "구매자 지갑을 찾을 수 없습니다."));
  128. }
  129. var originalByType = buyerWallet.Transactions
  130. .Where(t => t.TxType == WalletTransactionType.OrderPay && t.RefID == order.OrderNumber)
  131. .GroupBy(t => t.BalanceType)
  132. .ToDictionary(g => g.Key, g => (int)g.Sum(t => t.Amount.Value));
  133. if (originalByType.Count == 0)
  134. {
  135. return Result.Failure(Error.Problem("Refund.NoOriginalTransactions",
  136. "원본 결제 트랜잭션을 찾을 수 없어 환불 출처를 복원할 수 없습니다."));
  137. }
  138. var alreadyRefundedByType = buyerWallet.Transactions
  139. .Where(t => t.TxType == WalletTransactionType.OrderRefund && t.RefID == order.OrderNumber)
  140. .GroupBy(t => t.BalanceType)
  141. .ToDictionary(g => g.Key, g => (int)g.Sum(t => t.Amount.Value));
  142. var remainingByType = originalByType.ToDictionary(
  143. kvp => kvp.Key,
  144. kvp => kvp.Value - alreadyRefundedByType.GetValueOrDefault(kvp.Key, 0));
  145. var totalRemaining = remainingByType.Values.Sum();
  146. if (totalRemaining < refund.Amount)
  147. {
  148. return Result.Failure(Error.Problem("Refund.ExceedsRemaining",
  149. $"환불 가능 잔여 금액을 초과합니다. 요청 {refund.Amount:N0}원 / 잔여 {totalRemaining:N0}원"));
  150. }
  151. var distribution = new Dictionary<WalletBalanceType, int>();
  152. var distributed = 0;
  153. foreach (var (type, remainingForType) in remainingByType.OrderBy(x => (int)x.Key))
  154. {
  155. if (remainingForType <= 0)
  156. {
  157. continue;
  158. }
  159. var share = (int)Math.Floor((decimal)refund.Amount * remainingForType / totalRemaining);
  160. if (share > 0)
  161. {
  162. distribution[type] = share;
  163. distributed += share;
  164. }
  165. }
  166. var residual = refund.Amount - distributed;
  167. if (residual > 0)
  168. {
  169. foreach (var (type, remainingForType) in remainingByType.OrderByDescending(x => x.Value))
  170. {
  171. var assigned = distribution.GetValueOrDefault(type, 0);
  172. var capacity = remainingForType - assigned;
  173. if (capacity <= 0)
  174. {
  175. continue;
  176. }
  177. var add = Math.Min(capacity, residual);
  178. distribution[type] = assigned + add;
  179. residual -= add;
  180. if (residual == 0)
  181. {
  182. break;
  183. }
  184. }
  185. }
  186. foreach (var (type, amount) in distribution)
  187. {
  188. if (amount > 0)
  189. {
  190. buyerWallet.CreditStoreOrderRefund(type, Money.KRW(amount), reason: "ORDER_REFUND", refID: order.OrderNumber);
  191. }
  192. }
  193. // 6. 채널 보상 비례 차감 — 채널 보상 적립 제거됨(2026-07-03)에 따라 차감 로직도 제거.
  194. // 과거 주문(ChannelRewardAmount > 0) 환불 시 채널 회수는 수동 정정(운영자 조정)으로 처리.
  195. // 7. GameSettlement 비례 차감
  196. if (order.TotalAmount > 0 && order.GameRevenueAmount > 0)
  197. {
  198. // Items 의 게임별로 비례 차감
  199. foreach (var item in order.Items)
  200. {
  201. if (item.Product is null || item.Product.Game is null)
  202. {
  203. continue;
  204. }
  205. var lineTotal = item.UnitPrice * item.Quantity;
  206. if (lineTotal <= 0)
  207. {
  208. continue;
  209. }
  210. var lineGameRevenue = (int)Math.Floor((decimal)lineTotal * item.Product.Game.CommissionRate / 100m);
  211. var proRataGame = (int)Math.Floor((decimal)refund.Amount * lineGameRevenue / order.TotalAmount);
  212. if (proRataGame <= 0)
  213. {
  214. continue;
  215. }
  216. var year = order.PaidAt?.Year ?? order.CreatedAt.Year;
  217. var month = order.PaidAt?.Month ?? order.CreatedAt.Month;
  218. var settlement = await db.GameSettlement
  219. .FirstOrDefaultAsync(s => s.GameID == item.Product.GameID && s.Year == year && s.Month == month, ct);
  220. if (settlement is not null && settlement.Status == GameSettlementStatus.Pending)
  221. {
  222. settlement.SubtractRevenue(proRataGame);
  223. }
  224. }
  225. }
  226. // 8. CouponCode 처리:
  227. // - OrderRefundItem 가 있으면 명시된 CouponCode 만, 명시된 PostRefundAction 적용
  228. // - 없으면 fallback (옛 환불 호환) — 미사용 코드 모두 RestoreToAvailable
  229. if (refund.Items.Count > 0)
  230. {
  231. var refundItemCouponIds = refund.Items.Select(x => x.CouponCodeID).ToList();
  232. var inventories = await db.MemberInventory
  233. .Include(m => m.CouponCode)
  234. .Where(m => refundItemCouponIds.Contains(m.CouponCodeID))
  235. .ToListAsync(ct);
  236. var invByCouponCode = inventories.ToDictionary(m => m.CouponCodeID);
  237. foreach (var refundItem in refund.Items)
  238. {
  239. if (!invByCouponCode.TryGetValue(refundItem.CouponCodeID, out var inv) || inv.CouponCode is null)
  240. {
  241. continue; // 이미 처리됐거나 데이터 없음 — skip
  242. }
  243. if (inv.CouponCode.Status == CouponCodeStatus.Used)
  244. {
  245. continue; // 안전망: Used 는 회수/만료 모두 불가
  246. }
  247. switch (refundItem.PostRefundAction)
  248. {
  249. case CouponPostRefundAction.Restore:
  250. inv.CouponCode.RestoreToAvailable();
  251. break;
  252. case CouponPostRefundAction.Expire:
  253. inv.CouponCode.Expire();
  254. break;
  255. }
  256. db.MemberInventory.Remove(inv);
  257. }
  258. }
  259. else
  260. {
  261. // Fallback: OrderRefundItem 없는 옛 환불 — 미사용 코드 모두 회수
  262. foreach (var item in order.Items)
  263. {
  264. if (item.Product is null)
  265. {
  266. continue;
  267. }
  268. if (item.Product.ItemKind is not null)
  269. {
  270. // 소모품 — 미사용 MemberItem 회수
  271. var unusedItems = await db.MemberItem
  272. .Where(m => m.OrderItemID == item.ID && m.UsedAt == null && m.RevokedAt == null)
  273. .ToListAsync(ct);
  274. foreach (var mi in unusedItems)
  275. {
  276. mi.Revoke(DateTime.UtcNow);
  277. }
  278. continue;
  279. }
  280. if (item.Product.Type == ProductType.Physical)
  281. {
  282. item.Product.RestoreStock(item.Quantity);
  283. }
  284. else if (item.Product.Type == ProductType.Digital)
  285. {
  286. var memberInventories = await db.MemberInventory
  287. .Include(m => m.CouponCode)
  288. .Where(m => m.OrderItemID == item.ID)
  289. .ToListAsync(ct);
  290. foreach (var inv in memberInventories)
  291. {
  292. if (inv.CouponCode is null)
  293. {
  294. continue;
  295. }
  296. if (inv.CouponCode.Status == CouponCodeStatus.Used)
  297. {
  298. continue;
  299. }
  300. inv.CouponCode.RestoreToAvailable();
  301. db.MemberInventory.Remove(inv);
  302. }
  303. }
  304. }
  305. }
  306. // 9. Physical 상품 재고 복구 (OrderRefundItem 분기에서는 Physical 처리 안 함 — 쿠폰 환불은 Digital 만)
  307. // OrderRefundItem 가 있는 환불은 쿠폰 환불 전용 — Physical 상품은 fallback 분기에서 처리.
  308. // 10. Order.Status 갱신
  309. switch (refund.Type)
  310. {
  311. case RefundType.Cancel:
  312. if (order.Status is OrderStatus.Pending or OrderStatus.Paid)
  313. {
  314. order.Cancel(refund.Reason);
  315. }
  316. else
  317. {
  318. order.MarkRefunded(refund.Reason);
  319. }
  320. break;
  321. case RefundType.Refund:
  322. case RefundType.Return:
  323. order.MarkRefunded(refund.Reason);
  324. break;
  325. case RefundType.Exchange:
  326. order.MarkExchanged();
  327. break;
  328. }
  329. refund.Complete();
  330. await db.SaveChangesAsync(ct);
  331. return Result.Success();
  332. }
  333. }