Handler.cs 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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;
  6. using Domain.Entities.Payments.Toss;
  7. using Domain.Entities.Payments.Toss.ValueObject;
  8. using Domain.Entities.Payments.ValueObject;
  9. using Domain.Entities.Wallets.ValueObject;
  10. using Microsoft.EntityFrameworkCore;
  11. using SharedKernel.Results;
  12. namespace Application.Features.Admin.Payment.Toss.Confirm.Cancel;
  13. /// <summary>운영자 강제취소 — 토스 취소 API 로 전체 취소 (Phase 1.5 계약 승계)</summary>
  14. public sealed class Handler(
  15. IAppDbContext db,
  16. ITossPaymentService tossPay,
  17. IAppDbContextFactory dbFactory
  18. ) : ICommandHandler<Command, Result>
  19. {
  20. public async Task<Result> Handle(Command request, CancellationToken ct)
  21. {
  22. var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
  23. if (confirm is null)
  24. {
  25. return Result.Failure(Error.NotFound("Toss.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
  26. }
  27. var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.ID == confirm.PaymentOrderID, ct);
  28. if (order is null)
  29. {
  30. return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
  31. }
  32. // 진실의 근원은 PaymentOrder.Status — TossConfirm 응답이 비정상이어도
  33. // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요.
  34. if (order.Status != PaymentStatus.Paid)
  35. {
  36. return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
  37. }
  38. // 이미 성공 취소된 경우 방지
  39. var hasSuccessfulCancel = await db.TossCancel.AnyAsync(c => c.PaymentOrderID == order.ID && c.ResponseCode == "SUCCESS", ct);
  40. if (hasSuccessfulCancel)
  41. {
  42. return Result.Failure(Error.Problem("Toss.AlreadyCancelled", "이미 결제가 취소되었습니다."));
  43. }
  44. // paymentKey 확보 — MarkPaid 시 TransactionID 에 저장됨. 없으면 TossConfirm 에서 보강.
  45. var paymentKey = string.IsNullOrEmpty(order.TransactionID) ? confirm.PaymentKey : order.TransactionID;
  46. // 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
  47. // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴)
  48. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  49. if (wallet is null)
  50. {
  51. return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다."));
  52. }
  53. var pgCharged = wallet.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
  54. if (pgCharged < order.PointAmount)
  55. {
  56. return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 캐시가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
  57. }
  58. var cancelReason = string.IsNullOrWhiteSpace(request.CancelReason) ? "운영자 강제취소" : request.CancelReason;
  59. // 취소 요청 기록 저장 (전체 취소)
  60. var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, cancelReason, request.CancelRequester ?? "admin");
  61. await db.TossCancel.AddAsync(cancel, ct);
  62. await db.SaveChangesAsync(ct);
  63. // 토스 취소 API 호출 (cancelAmount 미전달 = 전액 취소) — 통신 예외도 기록을 남긴다
  64. TossPaymentResult result;
  65. try
  66. {
  67. result = await tossPay.CancelAsync(paymentKey, cancelReason, null, ct);
  68. }
  69. catch (Exception ex)
  70. {
  71. cancel.SetError("EXCEPTION", ex.Message);
  72. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, paymentKey, order.OrderID), ct);
  73. await db.SaveChangesAsync(ct);
  74. return Result.Failure(Error.Problem("Toss.CancelFailed", "결제 취소 처리 중 오류가 발생했습니다."));
  75. }
  76. if (!result.Success)
  77. {
  78. cancel.SetError(result.Code ?? "FAIL", result.Message ?? "취소 실패");
  79. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", paymentKey, order.OrderID), ct);
  80. await db.SaveChangesAsync(ct);
  81. return Result.Failure(Error.Problem("Toss.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
  82. }
  83. // 취소 응답 저장
  84. ApplyCancelResponse(cancel, result);
  85. order.MarkCancelled();
  86. // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
  87. // 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다.
  88. // 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음)
  89. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
  90. try
  91. {
  92. await db.SaveChangesAsync(ct);
  93. }
  94. catch (DbUpdateConcurrencyException ex)
  95. {
  96. // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음)
  97. // 새 컨텍스트로 리로드해 도메인 연산을 1회 재적용한다. 첫 시도의 원장 기록은 미영속이므로 이중회수 없음.
  98. try
  99. {
  100. return await RetryCancelPersistAsync(order.ID, cancel.ID, result, ct);
  101. }
  102. catch (Exception retryEx)
  103. {
  104. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"ADMIN_CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
  105. throw;
  106. }
  107. }
  108. catch (Exception ex)
  109. {
  110. // Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
  111. // 기존 실패 흐름(예외 전파)을 유지한다.
  112. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"ADMIN_CANCEL_PERSIST 실패: {ex.Message}");
  113. throw;
  114. }
  115. return Result.Success();
  116. }
  117. /// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5)</summary>
  118. private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, TossPaymentResult result, CancellationToken ct)
  119. {
  120. await using var retryDb = dbFactory.CreateDbContext();
  121. var order = await retryDb.PaymentOrder.FirstAsync(c => c.ID == paymentOrderID, ct);
  122. if (order.Status == PaymentStatus.Cancelled)
  123. {
  124. // 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답
  125. return Result.Success();
  126. }
  127. var cancel = await retryDb.TossCancel.FirstAsync(c => c.ID == cancelID, ct);
  128. ApplyCancelResponse(cancel, result);
  129. var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct);
  130. order.MarkCancelled();
  131. // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 캐시가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
  132. // (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상)
  133. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
  134. await retryDb.SaveChangesAsync(ct);
  135. return Result.Success();
  136. }
  137. /// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
  138. private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
  139. {
  140. try
  141. {
  142. // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
  143. await using var logDb = dbFactory.CreateDbContext();
  144. await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.AdminCancel, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
  145. await logDb.SaveChangesAsync(CancellationToken.None);
  146. }
  147. catch
  148. {
  149. // best-effort — 원래 예외를 유지한다
  150. }
  151. }
  152. private static void ApplyCancelResponse(TossCancel cancel, TossPaymentResult result)
  153. {
  154. var last = result.Cancels.Count > 0 ? result.Cancels[^1] : null;
  155. cancel.SetResponse(result.Status ?? "", last?.TransactionKey, last?.CanceledAt, result.RawJson);
  156. }
  157. }