Handler.cs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  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.Payments.ValueObject;
  8. using Microsoft.EntityFrameworkCore;
  9. using SharedKernel.Results;
  10. namespace Application.Features.Admin.Payment.Danal.Confirm.Cancel;
  11. public sealed class Handler(
  12. IAppDbContext db,
  13. IDanalPayService danalPay,
  14. IAppDbContextFactory dbFactory
  15. ) : ICommandHandler<Command, Result>
  16. {
  17. public async Task<Result> Handle(Command request, CancellationToken ct)
  18. {
  19. var confirm = await db.DanalConfirm.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
  20. if (confirm is null)
  21. {
  22. return Result.Failure(Error.NotFound("Danal.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
  23. }
  24. var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.ID == confirm.PaymentOrderID, ct);
  25. if (order is null)
  26. {
  27. return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
  28. }
  29. // 진실의 근원은 PaymentOrder.Status — DanalConfirm.ResponseCode 가 비어있거나 "0000" 이 아니어도
  30. // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요. confirm.IsSuccess 단독 가드는 너무 엄격.
  31. if (order.Status != PaymentStatus.Paid)
  32. {
  33. return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
  34. }
  35. // 이미 성공 취소된 경우 방지
  36. var hasSuccessfulCancel = await db.DanalCancel.AnyAsync(
  37. c => c.PaymentOrderID == order.ID && c.ResponseCode == "0000", ct);
  38. if (hasSuccessfulCancel)
  39. {
  40. return Result.Failure(Error.Problem("Danal.AlreadyCancelled", "이미 결제가 취소되었습니다."));
  41. }
  42. // 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
  43. // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴)
  44. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  45. if (wallet is null)
  46. {
  47. return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다."));
  48. }
  49. var pgCharged = wallet.Balances.Where(b => b.Type == Domain.Entities.Wallets.ValueObject.WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
  50. if (pgCharged < order.PointAmount)
  51. {
  52. return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 포인트가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
  53. }
  54. // 다날 결제수단 코드 매핑
  55. var methodCode = order.PaymentMethod switch
  56. {
  57. PaymentMethod.Integrated => "INTEGRATED",
  58. PaymentMethod.Card => "CARD",
  59. PaymentMethod.VirtualAccount => "VACCOUNT",
  60. PaymentMethod.Mobile => "MOBILE",
  61. PaymentMethod.Transfer => "TRANSFER",
  62. PaymentMethod.NaverPay => "NAVERPAY",
  63. PaymentMethod.KakaoPay => "KAKAOPAY",
  64. PaymentMethod.Payco => "PAYCO",
  65. _ => "CARD"
  66. };
  67. // 취소 요청 기록 저장 (전체 취소)
  68. var cancel = DanalCancel.CreateRequest(
  69. memberID: order.MemberID,
  70. paymentOrderID: order.ID,
  71. method: order.PaymentMethod,
  72. transactionID: order.TransactionID!,
  73. orderID: order.OrderID,
  74. amount: order.Amount,
  75. merchantID: order.MerchantID,
  76. cancelType: DanalCancelType.Full,
  77. cancelRequester: request.CancelRequester,
  78. cancelReason: request.CancelReason
  79. );
  80. await db.DanalCancel.AddAsync(cancel, ct);
  81. await db.SaveChangesAsync(ct);
  82. // 다날 API 호출
  83. var result = await danalPay.CancelAsync(methodCode, order.TransactionID!, order.Amount, "C", ct);
  84. if (!result.Success)
  85. {
  86. cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패");
  87. await db.DanalLog.AddAsync(DanalLog.Create(
  88. order.MemberID,
  89. DanalLogType.Failed,
  90. result.Code ?? "FAIL",
  91. result.Message ?? "취소 실패",
  92. order.TransactionID,
  93. order.OrderID), ct);
  94. await db.SaveChangesAsync(ct);
  95. return Result.Failure(Error.Problem("Danal.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
  96. }
  97. // 취소 응답 저장
  98. ApplyCancelResponse(cancel, result);
  99. order.MarkCancelled();
  100. // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
  101. // (기존: Adjusted 버킷 차감 → 항상 잔액 0 → 예외 흡수로 환불해도 포인트 미회수 결함)
  102. // 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다.
  103. // 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음)
  104. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
  105. try
  106. {
  107. await db.SaveChangesAsync(ct);
  108. }
  109. catch (DbUpdateConcurrencyException ex)
  110. {
  111. // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음)
  112. // 새 컨텍스트로 리로드해 도메인 연산을 1회 재적용한다. 첫 시도의 원장 기록은 미영속이므로 이중회수 없음.
  113. try
  114. {
  115. return await RetryCancelPersistAsync(order.ID, cancel.ID, result, ct);
  116. }
  117. catch (Exception retryEx)
  118. {
  119. await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"ADMIN_CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
  120. throw;
  121. }
  122. }
  123. catch (Exception ex)
  124. {
  125. // Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
  126. // 기존 실패 흐름(예외 전파)을 유지한다.
  127. await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"ADMIN_CANCEL_PERSIST 실패: {ex.Message}");
  128. throw;
  129. }
  130. return Result.Success();
  131. }
  132. /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5)</summary>
  133. private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, DanalCancelResult result, CancellationToken ct)
  134. {
  135. await using var retryDb = dbFactory.CreateDbContext();
  136. var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct);
  137. if (order.Status == PaymentStatus.Cancelled)
  138. {
  139. // 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답
  140. return Result.Success();
  141. }
  142. var cancel = await retryDb.DanalCancel.FirstAsync(c => c.ID == cancelID, ct);
  143. ApplyCancelResponse(cancel, result);
  144. var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct);
  145. order.MarkCancelled();
  146. // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 포인트가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
  147. // (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상)
  148. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
  149. await retryDb.SaveChangesAsync(ct);
  150. return Result.Success();
  151. }
  152. /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
  153. private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason)
  154. {
  155. try
  156. {
  157. // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
  158. await using var logDb = dbFactory.CreateDbContext();
  159. await logDb.PaymentReconcileLog.AddAsync(Domain.Entities.Payments.PaymentReconcileLog.Create(ReconcileKind.AdminCancel, paymentOrderID, transactionID, amount, reason), CancellationToken.None);
  160. await logDb.SaveChangesAsync(CancellationToken.None);
  161. }
  162. catch
  163. {
  164. // best-effort — 원래 예외를 유지한다
  165. }
  166. }
  167. private static void ApplyCancelResponse(DanalCancel cancel, DanalCancelResult result)
  168. {
  169. // Balance, RemainedAmount는 string? → int? 변환
  170. cancel.SetResponse(
  171. result.Code ?? "",
  172. result.Message ?? "",
  173. result.OriginalTransactionID,
  174. result.CancelledAmount,
  175. result.TransDate,
  176. result.TransTime,
  177. int.TryParse(result.Balance, out var balanceVal) ? balanceVal : null,
  178. int.TryParse(result.RemainedAmount, out var remainedVal) ? remainedVal : null,
  179. result.ApprovalDateTime
  180. );
  181. }
  182. }