Handler.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  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.Danal;
  6. using Domain.Entities.Payments.Danal.ValueObject;
  7. using Domain.Entities.Store.ValueObject;
  8. using Domain.Entities.Wallets.ValueObject;
  9. using Microsoft.EntityFrameworkCore;
  10. using SharedKernel.Results;
  11. namespace Application.Features.Admin.Store.Order.ProcessRefund;
  12. /// <summary>
  13. /// 환불 실행 흐름 (사용자 정책):
  14. /// 1. 사전 검증 (Digital 미사용 쿠폰 가치 합)
  15. /// 2. 다날 부분 취소 호출 — 실패 시 OrderRefund.Reject + 종료
  16. /// 3. DanalCancel 행 기록
  17. /// 4. OrderRefund.Approve
  18. /// 5. 구매자 환원: 원본 OrderPay 트랜잭션의 BalanceType 별로 비례 분배해 출처 복원
  19. /// 6. 채널 보상 비례 차감 (StoreRevenue 에서). Donation 은 건드리지 않음 (출처 보존)
  20. /// 7. GameSettlement 비례 차감
  21. /// 8. Digital 상품 CouponCode 처리:
  22. /// - OrderRefundItem 가 있으면 → 각 행의 PostRefundAction (Restore/Expire) 적용
  23. /// - 없으면 fallback → 모든 미사용 코드 Restore (옛 환불 호환)
  24. /// - Used 코드는 skip (안전망)
  25. /// 9. Physical 상품: 재고 복구
  26. /// 10. Order.Status 갱신 (Cancelled/Refunded/Exchanged), refund.Complete
  27. /// </summary>
  28. public sealed class Handler(IAppDbContext db, IDanalPayService danal) : ICommandHandler<Command, Result>
  29. {
  30. public async Task<Result> Handle(Command request, CancellationToken ct)
  31. {
  32. var refund = await db.OrderRefund
  33. .Include(r => r.Order)
  34. .ThenInclude(o => o!.Items)
  35. .ThenInclude(i => i.Product)
  36. .ThenInclude(p => p!.Game)
  37. .Include(r => r.Order)
  38. .ThenInclude(o => o!.Items)
  39. .ThenInclude(i => i.IssuedCouponCode)
  40. .Include(r => r.Items)
  41. .FirstOrDefaultAsync(r => r.ID == request.RefundID, ct);
  42. if (refund is null)
  43. {
  44. return Result.Failure(Error.NotFound("Refund.NotFound", "환불 요청을 찾을 수 없습니다."));
  45. }
  46. if (refund.Status != RefundStatus.Requested)
  47. {
  48. return Result.Failure(Error.Problem("Refund.NotPending", "처리 대기 중인 환불 요청만 실행 가능합니다."));
  49. }
  50. var order = refund.Order!;
  51. // 1. 사전 검증 — 미사용 쿠폰 가치 합이 환불 금액 이상
  52. var digitalUnusedValue = 0;
  53. foreach (var item in order.Items)
  54. {
  55. if (item.Product?.Type != ProductType.Digital)
  56. {
  57. continue;
  58. }
  59. var unusedCount = await db.MemberInventory.CountAsync(m => m.OrderItemID == item.ID && m.CouponCode!.Status != CouponCodeStatus.Used, ct);
  60. digitalUnusedValue += unusedCount * item.UnitPrice;
  61. }
  62. if (digitalUnusedValue < refund.Amount)
  63. {
  64. return Result.Failure(Error.Problem("Refund.InsufficientUnusedCoupons", $"환불 금액({refund.Amount:N0}원)에 대응하는 미사용 쿠폰 코드 가치({digitalUnusedValue:N0}원)가 부족합니다. 일부 쿠폰이 이미 사용되었습니다."));
  65. }
  66. // 2. 다날 부분 취소 — PaymentOrder + DanalConfirm 조회 후 호출
  67. var paymentOrder = await db.PaymentOrder
  68. .Where(p => p.OrderID == order.OrderNumber && p.TransactionID != null)
  69. .OrderByDescending(p => p.ID)
  70. .FirstOrDefaultAsync(ct);
  71. DanalCancel? danalCancel = null;
  72. if (paymentOrder is not null && !string.IsNullOrEmpty(paymentOrder.TransactionID))
  73. {
  74. // 다날 잔액 사전 확인은 생략 (다날 응답이 RemainedAmount 반환)
  75. var methodCode = ((int)paymentOrder.PaymentMethod).ToString(); // 다날 API method 파라미터 (실제 매핑은 DanalPayService 내부)
  76. var methodEnum = paymentOrder.PaymentMethod switch
  77. {
  78. Domain.Entities.Payments.ValueObject.PaymentMethod.Card => "card",
  79. Domain.Entities.Payments.ValueObject.PaymentMethod.VirtualAccount => "vbank",
  80. Domain.Entities.Payments.ValueObject.PaymentMethod.Mobile => "mobile",
  81. Domain.Entities.Payments.ValueObject.PaymentMethod.Transfer => "transfer",
  82. Domain.Entities.Payments.ValueObject.PaymentMethod.NaverPay => "naverpay",
  83. Domain.Entities.Payments.ValueObject.PaymentMethod.KakaoPay => "kakaopay",
  84. Domain.Entities.Payments.ValueObject.PaymentMethod.Payco => "payco",
  85. _ => "card"
  86. };
  87. var cancelType = refund.Amount >= paymentOrder.Amount ? "FULL" : "PARTIAL";
  88. var cancelResult = await danal.CancelAsync(methodEnum, paymentOrder.TransactionID, refund.Amount, cancelType, ct);
  89. if (!cancelResult.Success)
  90. {
  91. // 다날 실패 → 환불 요청 거부 처리. 운영자 메모에 다날 응답 기록.
  92. refund.Reject(request.AdminUserId, request.AdminEmail, $"[다날 부분취소 실패] code={cancelResult.Code} message={cancelResult.Message}");
  93. await db.SaveChangesAsync(ct);
  94. return Result.Failure(Error.Problem("Refund.DanalCancelFailed", $"다날 부분 취소 실패: {cancelResult.Message ?? cancelResult.Code ?? "ERROR"}"));
  95. }
  96. // 3. DanalCancel 행 기록
  97. danalCancel = DanalCancel.CreateRequest(
  98. memberID: order.MemberID,
  99. paymentOrderID: paymentOrder.ID,
  100. method: paymentOrder.PaymentMethod,
  101. transactionID: paymentOrder.TransactionID,
  102. orderID: order.OrderNumber,
  103. amount: refund.Amount,
  104. merchantID: paymentOrder.MerchantID,
  105. cancelType: cancelType == "FULL" ? DanalCancelType.Full : DanalCancelType.Partial,
  106. cancelRequester: request.AdminEmail ?? request.AdminUserId,
  107. cancelReason: refund.Reason
  108. );
  109. danalCancel.SetResponse(
  110. code: cancelResult.Code ?? "SUCCESS",
  111. message: cancelResult.Message ?? "OK",
  112. originalTransactionID: cancelResult.OriginalTransactionID,
  113. cancelledAmount: cancelResult.CancelledAmount,
  114. transDate: cancelResult.TransDate,
  115. transTime: cancelResult.TransTime,
  116. balance: int.TryParse(cancelResult.Balance, out var bal) ? bal : null,
  117. remainedAmount: int.TryParse(cancelResult.RemainedAmount, out var rem) ? rem : null,
  118. approvalDateTime: cancelResult.ApprovalDateTime
  119. );
  120. db.DanalCancel.Add(danalCancel);
  121. }
  122. // PaymentOrder 가 없으면 (포인트만 결제 / 옛 주문) 다날 호출 skip
  123. // 4. OrderRefund 승인
  124. refund.Approve(request.AdminUserId, request.AdminEmail, request.AdminMemo);
  125. // 5. 구매자 환원: 원본 OrderPay 의 BalanceType 별 비례 분배로 출처 복원
  126. var buyerWallet = await db.Wallet
  127. .Include(w => w.Balances)
  128. .Include(w => w.Transactions)
  129. .FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  130. if (buyerWallet is null)
  131. {
  132. return Result.Failure(Error.NotFound("Wallet.NotFound", "구매자 지갑을 찾을 수 없습니다."));
  133. }
  134. var originalByType = buyerWallet.Transactions
  135. .Where(t => t.TxType == WalletTransactionType.OrderPay && t.RefID == order.OrderNumber)
  136. .GroupBy(t => t.BalanceType)
  137. .ToDictionary(g => g.Key, g => (int)g.Sum(t => t.Amount.Value));
  138. if (originalByType.Count == 0)
  139. {
  140. return Result.Failure(Error.Problem("Refund.NoOriginalTransactions",
  141. "원본 결제 트랜잭션을 찾을 수 없어 환불 출처를 복원할 수 없습니다."));
  142. }
  143. var alreadyRefundedByType = buyerWallet.Transactions
  144. .Where(t => t.TxType == WalletTransactionType.OrderRefund && t.RefID == order.OrderNumber)
  145. .GroupBy(t => t.BalanceType)
  146. .ToDictionary(g => g.Key, g => (int)g.Sum(t => t.Amount.Value));
  147. var remainingByType = originalByType.ToDictionary(
  148. kvp => kvp.Key,
  149. kvp => kvp.Value - alreadyRefundedByType.GetValueOrDefault(kvp.Key, 0));
  150. var totalRemaining = remainingByType.Values.Sum();
  151. if (totalRemaining < refund.Amount)
  152. {
  153. return Result.Failure(Error.Problem("Refund.ExceedsRemaining",
  154. $"환불 가능 잔여 금액을 초과합니다. 요청 {refund.Amount:N0}원 / 잔여 {totalRemaining:N0}원"));
  155. }
  156. var distribution = new Dictionary<WalletBalanceType, int>();
  157. var distributed = 0;
  158. foreach (var (type, remainingForType) in remainingByType.OrderBy(x => (int)x.Key))
  159. {
  160. if (remainingForType <= 0)
  161. {
  162. continue;
  163. }
  164. var share = (int)Math.Floor((decimal)refund.Amount * remainingForType / totalRemaining);
  165. if (share > 0)
  166. {
  167. distribution[type] = share;
  168. distributed += share;
  169. }
  170. }
  171. var residual = refund.Amount - distributed;
  172. if (residual > 0)
  173. {
  174. foreach (var (type, remainingForType) in remainingByType.OrderByDescending(x => x.Value))
  175. {
  176. var assigned = distribution.GetValueOrDefault(type, 0);
  177. var capacity = remainingForType - assigned;
  178. if (capacity <= 0)
  179. {
  180. continue;
  181. }
  182. var add = Math.Min(capacity, residual);
  183. distribution[type] = assigned + add;
  184. residual -= add;
  185. if (residual == 0)
  186. {
  187. break;
  188. }
  189. }
  190. }
  191. foreach (var (type, amount) in distribution)
  192. {
  193. if (amount > 0)
  194. {
  195. buyerWallet.CreditStoreOrderRefund(type, Money.KRW(amount), reason: "ORDER_REFUND", refID: order.OrderNumber);
  196. }
  197. }
  198. // 6. 채널 보상 비례 차감 (Order.TotalAmount 가 0이면 비율 계산 불가 — 방어)
  199. if (order.TotalAmount > 0 && order.ChannelID.HasValue && order.ChannelRewardAmount > 0)
  200. {
  201. var proRataReward = (int)Math.Floor((decimal)refund.Amount * order.ChannelRewardAmount / order.TotalAmount);
  202. if (proRataReward > 0)
  203. {
  204. var channel = await db.Channel.AsNoTracking()
  205. .Where(c => c.ID == order.ChannelID.Value)
  206. .Select(c => new { c.MemberID })
  207. .FirstOrDefaultAsync(ct);
  208. if (channel is not null)
  209. {
  210. var channelWallet = await db.Wallet
  211. .Include(w => w.Balances)
  212. .Include(w => w.Transactions)
  213. .FirstOrDefaultAsync(w => w.MemberID == channel.MemberID, ct);
  214. if (channelWallet is not null)
  215. {
  216. var storeRevenueBalance = channelWallet.GetBalance(WalletBalanceType.StoreRevenue);
  217. if (storeRevenueBalance.Value >= proRataReward)
  218. {
  219. channelWallet.DebitStoreRevenueRefund(Money.KRW(proRataReward), reason: "ORDER_REFUND_CHANNEL", refID: order.OrderNumber);
  220. }
  221. // 잔액 부족 시: 채널주가 이미 출금한 상황 — 정책상 환불 일시 정지 또는 음수 허용 필요
  222. // 1차 정책: 부족하면 차감 skip + AdminMemo 에 기록 (별도 정정 절차)
  223. else
  224. {
  225. refund.Reject(request.AdminUserId, request.AdminEmail, $"채널 보상 차감 실패: 잔액 부족 (필요 {proRataReward:N0}, 잔액 {(int)storeRevenueBalance.Value:N0}). 별도 정정 정산 필요.");
  226. await db.SaveChangesAsync(ct);
  227. return Result.Failure(Error.Problem("Refund.ChannelInsufficient", "채널 보상 차감 실패: 잔액 부족"));
  228. }
  229. }
  230. }
  231. }
  232. }
  233. // 7. GameSettlement 비례 차감
  234. if (order.TotalAmount > 0 && order.GameRevenueAmount > 0)
  235. {
  236. // Items 의 게임별로 비례 차감
  237. foreach (var item in order.Items)
  238. {
  239. if (item.Product is null || item.Product.Game is null)
  240. {
  241. continue;
  242. }
  243. var lineTotal = item.UnitPrice * item.Quantity;
  244. if (lineTotal <= 0)
  245. {
  246. continue;
  247. }
  248. var lineGameRevenue = (int)Math.Floor((decimal)lineTotal * item.Product.Game.CommissionRate / 100m);
  249. var proRataGame = (int)Math.Floor((decimal)refund.Amount * lineGameRevenue / order.TotalAmount);
  250. if (proRataGame <= 0)
  251. {
  252. continue;
  253. }
  254. var year = order.PaidAt?.Year ?? order.CreatedAt.Year;
  255. var month = order.PaidAt?.Month ?? order.CreatedAt.Month;
  256. var settlement = await db.GameSettlement
  257. .FirstOrDefaultAsync(s => s.GameID == item.Product.GameID && s.Year == year && s.Month == month, ct);
  258. if (settlement is not null && settlement.Status == GameSettlementStatus.Pending)
  259. {
  260. settlement.SubtractRevenue(proRataGame);
  261. }
  262. }
  263. }
  264. // 8. CouponCode 처리:
  265. // - OrderRefundItem 가 있으면 명시된 CouponCode 만, 명시된 PostRefundAction 적용
  266. // - 없으면 fallback (옛 환불 호환) — 미사용 코드 모두 RestoreToAvailable
  267. if (refund.Items.Count > 0)
  268. {
  269. var refundItemCouponIds = refund.Items.Select(x => x.CouponCodeID).ToList();
  270. var inventories = await db.MemberInventory
  271. .Include(m => m.CouponCode)
  272. .Where(m => refundItemCouponIds.Contains(m.CouponCodeID))
  273. .ToListAsync(ct);
  274. var invByCouponCode = inventories.ToDictionary(m => m.CouponCodeID);
  275. foreach (var refundItem in refund.Items)
  276. {
  277. if (!invByCouponCode.TryGetValue(refundItem.CouponCodeID, out var inv) || inv.CouponCode is null)
  278. {
  279. continue; // 이미 처리됐거나 데이터 없음 — skip
  280. }
  281. if (inv.CouponCode.Status == CouponCodeStatus.Used)
  282. {
  283. continue; // 안전망: Used 는 회수/만료 모두 불가
  284. }
  285. switch (refundItem.PostRefundAction)
  286. {
  287. case CouponPostRefundAction.Restore:
  288. inv.CouponCode.RestoreToAvailable();
  289. break;
  290. case CouponPostRefundAction.Expire:
  291. inv.CouponCode.Expire();
  292. break;
  293. }
  294. db.MemberInventory.Remove(inv);
  295. }
  296. }
  297. else
  298. {
  299. // Fallback: OrderRefundItem 없는 옛 환불 — 미사용 코드 모두 회수
  300. foreach (var item in order.Items)
  301. {
  302. if (item.Product is null)
  303. {
  304. continue;
  305. }
  306. if (item.Product.Type == ProductType.Physical)
  307. {
  308. item.Product.RestoreStock(item.Quantity);
  309. }
  310. else if (item.Product.Type == ProductType.Digital)
  311. {
  312. var memberInventories = await db.MemberInventory
  313. .Include(m => m.CouponCode)
  314. .Where(m => m.OrderItemID == item.ID)
  315. .ToListAsync(ct);
  316. foreach (var inv in memberInventories)
  317. {
  318. if (inv.CouponCode is null)
  319. {
  320. continue;
  321. }
  322. if (inv.CouponCode.Status == CouponCodeStatus.Used)
  323. {
  324. continue;
  325. }
  326. inv.CouponCode.RestoreToAvailable();
  327. db.MemberInventory.Remove(inv);
  328. }
  329. }
  330. }
  331. }
  332. // 9. Physical 상품 재고 복구 (OrderRefundItem 분기에서는 Physical 처리 안 함 — 쿠폰 환불은 Digital 만)
  333. // OrderRefundItem 가 있는 환불은 쿠폰 환불 전용 — Physical 상품은 fallback 분기에서 처리.
  334. // 10. Order.Status 갱신
  335. switch (refund.Type)
  336. {
  337. case RefundType.Cancel:
  338. if (order.Status is OrderStatus.Pending or OrderStatus.Paid)
  339. {
  340. order.Cancel(refund.Reason);
  341. }
  342. else
  343. {
  344. order.MarkRefunded(refund.Reason);
  345. }
  346. break;
  347. case RefundType.Refund:
  348. case RefundType.Return:
  349. order.MarkRefunded(refund.Reason);
  350. break;
  351. case RefundType.Exchange:
  352. order.MarkExchanged();
  353. break;
  354. }
  355. refund.Complete();
  356. await db.SaveChangesAsync(ct);
  357. return Result.Success();
  358. }
  359. }