Handler.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  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;
  10. using Domain.Entities.Wallets.ValueObject;
  11. using Microsoft.EntityFrameworkCore;
  12. using SharedKernel.Results;
  13. namespace Application.Features.Api.Payment.Toss.Webhook;
  14. internal sealed class Handler(
  15. IAppDbContext db,
  16. ITossPaymentService tossPay,
  17. IAppDbContextFactory dbFactory
  18. ) : ICommandHandler<Command, Result>
  19. {
  20. public async Task<Result> Handle(Command r, CancellationToken ct)
  21. {
  22. // paymentKey 확보 — DEPOSIT_CALLBACK 은 본문에 paymentKey 가 없어 orderId 로 TossConfirm 에서 보강
  23. var paymentKey = r.PaymentKey;
  24. if (string.IsNullOrEmpty(paymentKey) && !string.IsNullOrEmpty(r.OrderID))
  25. {
  26. var confirmRow = await db.TossConfirm.FirstOrDefaultAsync(c => c.OrderID == r.OrderID, ct);
  27. paymentKey = confirmRow?.PaymentKey;
  28. }
  29. if (string.IsNullOrEmpty(paymentKey))
  30. {
  31. // 판별 불가 — 로그만 남기고 정상 응답 (재시도 무의미)
  32. await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "IGNORED", $"paymentKey 판별 불가 (eventType={r.EventType})", null, r.OrderID), ct);
  33. await db.SaveChangesAsync(ct);
  34. return Result.Success();
  35. }
  36. // 웹훅 본문 신뢰 금지 — 조회 API 재조회 결과만 반영
  37. TossPaymentResult payment;
  38. try
  39. {
  40. payment = await tossPay.GetPaymentAsync(paymentKey, ct);
  41. }
  42. catch (Exception ex)
  43. {
  44. // 조회 실패 — 실패 응답으로 토스 재시도 유도
  45. await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Error, "EXCEPTION", $"웹훅 재조회 실패: {ex.Message}", paymentKey, r.OrderID), CancellationToken.None);
  46. await db.SaveChangesAsync(CancellationToken.None);
  47. return Result.Failure(Error.Problem("Payment.WebhookRequeryFailed", "결제 재조회에 실패했습니다."));
  48. }
  49. if (!payment.Success || string.IsNullOrEmpty(payment.OrderID))
  50. {
  51. await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, payment.Code ?? "IGNORED", $"재조회 결과 처리 불가 (eventType={r.EventType}): {payment.Message}", paymentKey, r.OrderID), ct);
  52. await db.SaveChangesAsync(ct);
  53. return Result.Success();
  54. }
  55. // 조회 결과의 orderId 기준으로 주문 확정 (본문 orderId 미신뢰)
  56. var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == payment.OrderID, ct);
  57. if (order is null)
  58. {
  59. await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "ORDER_NOT_FOUND", $"주문 없음 (eventType={r.EventType})", paymentKey, payment.OrderID), ct);
  60. await db.SaveChangesAsync(ct);
  61. return Result.Success();
  62. }
  63. return payment.Status switch
  64. {
  65. "DONE" => await HandleDoneAsync(order, paymentKey, payment, r.EventType, ct),
  66. "CANCELED" or "PARTIAL_CANCELED" => await HandleCanceledAsync(order, paymentKey, payment, r.EventType, ct),
  67. "ABORTED" or "EXPIRED" => await HandleAbortedAsync(order, paymentKey, payment, ct),
  68. _ => await HandleUnknownAsync(order, paymentKey, payment, r.EventType, ct)
  69. };
  70. }
  71. /// <summary>DONE — 승인/입금 완료. 미적립이면 멱등 적립 (가상계좌 DEPOSIT_CALLBACK 포함)</summary>
  72. private async Task<Result> HandleDoneAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
  73. {
  74. if (order.Status == PaymentStatus.Paid)
  75. {
  76. // 이미 적립 완료 — 멱등, 중복 적립 금지
  77. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_PAID", $"이미 처리된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct);
  78. await db.SaveChangesAsync(ct);
  79. return Result.Success();
  80. }
  81. if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation)
  82. {
  83. // Failed/Cancelled 주문에 DONE 웹훅 — 자동 반영하지 않고 대사 대상 기록 (보수적)
  84. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "STATE_MISMATCH", $"주문 상태 {order.Status} 에 DONE 웹훅", paymentKey, order.OrderID), ct);
  85. await db.SaveChangesAsync(ct);
  86. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE — 주문 상태 {order.Status} 불일치, 수동 확인 필요");
  87. return Result.Success();
  88. }
  89. // 금액 대조 — 조회 결과 totalAmount 와 서버 저장 주문 금액 불일치 시 적립하지 않는다
  90. if (payment.TotalAmount != order.Amount)
  91. {
  92. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "AMOUNT_MISMATCH", $"금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", paymentKey, order.OrderID), ct);
  93. await db.SaveChangesAsync(ct);
  94. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}");
  95. return Result.Success();
  96. }
  97. // 승인 기록 갱신 — TossConfirm 행이 없으면 생성 (unique 충돌 방지 위해 기존 행 재사용)
  98. var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
  99. if (confirm is null)
  100. {
  101. confirm = TossConfirm.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount);
  102. await db.TossConfirm.AddAsync(confirm, ct);
  103. }
  104. confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
  105. // 지갑 적립 (멱등 — 위 Paid 가드로 중복 차단) + 주문 완료 단일 커밋
  106. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  107. if (wallet is null)
  108. {
  109. wallet = Wallet.Create(order.MemberID);
  110. await db.Wallet.AddAsync(wallet, ct);
  111. }
  112. wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID);
  113. order.MarkPaid(paymentKey);
  114. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"입금/승인 완료 반영 (eventType={eventType})", paymentKey, order.OrderID), ct);
  115. try
  116. {
  117. await db.SaveChangesAsync(ct);
  118. }
  119. catch (Exception ex)
  120. {
  121. // PG 는 이미 DONE — 저장 실패는 대사 로그 독립 커밋 후 실패 응답 (토스 재시도로 재처리)
  122. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_PERSIST 실패: {ex.Message}");
  123. return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 반영 저장에 실패했습니다."));
  124. }
  125. return Result.Success();
  126. }
  127. /// <summary>CANCELED — 토스 측 취소 (관리자콘솔 취소 등). Paid 주문이면 지갑 회수 반영</summary>
  128. private async Task<Result> HandleCanceledAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
  129. {
  130. if (order.Status == PaymentStatus.Cancelled)
  131. {
  132. // 이미 취소 반영 완료 — 멱등
  133. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_CANCELLED", $"이미 취소된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct);
  134. await db.SaveChangesAsync(ct);
  135. return Result.Success();
  136. }
  137. // 미적립 주문(Pending/WaitingDeposit/NeedsReconciliation)의 취소 — 적립된 것이 없으므로 Failed 처리로 종결
  138. if (order.Status != PaymentStatus.Paid)
  139. {
  140. order.MarkFailed($"토스 취소 웹훅 (status={payment.Status})");
  141. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "CANCELED_BEFORE_PAID", $"미적립 주문 취소 종결 (eventType={eventType})", paymentKey, order.OrderID), ct);
  142. await db.SaveChangesAsync(ct);
  143. return Result.Success();
  144. }
  145. // 부분취소는 자동 반영하지 않는다 — 수동 대사 대상 (보수적)
  146. if (payment.Status == "PARTIAL_CANCELED")
  147. {
  148. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "PARTIAL_CANCELED", "부분취소 웹훅 — 자동 반영 안 함", paymentKey, order.OrderID), ct);
  149. await db.SaveChangesAsync(ct);
  150. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, "웹훅 PARTIAL_CANCELED — 수동 확인 필요");
  151. return Result.Success();
  152. }
  153. // 회수 가능성 검증 — 이미 사용한 캐시가 있으면 자동 회수 불가, 수동 대사 대상
  154. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  155. var pgCharged = wallet?.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value) ?? 0;
  156. if (wallet is null || pgCharged < order.PointAmount)
  157. {
  158. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "REFUND_BLOCKED", $"잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged}, 필요={order.PointAmount})", paymentKey, order.OrderID), ct);
  159. await db.SaveChangesAsync(ct);
  160. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 CANCELED — PG 취소 완료됐으나 잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged})");
  161. return Result.Success();
  162. }
  163. // 취소 기록 (webhook 발) + 주문 취소 + 지갑 회수 단일 커밋
  164. var last = payment.Cancels.Count > 0 ? payment.Cancels[^1] : null;
  165. var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, last?.CancelReason ?? "토스 측 취소(웹훅)", "webhook");
  166. cancel.SetResponse(payment.Status ?? "", last?.TransactionKey, last?.CanceledAt, payment.RawJson);
  167. await db.TossCancel.AddAsync(cancel, ct);
  168. order.MarkCancelled();
  169. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불 (Toss 웹훅)", order.OrderID);
  170. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"취소 반영 (eventType={eventType})", paymentKey, order.OrderID), ct);
  171. try
  172. {
  173. await db.SaveChangesAsync(ct);
  174. }
  175. catch (Exception ex)
  176. {
  177. await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_CANCEL_PERSIST 실패: {ex.Message}");
  178. return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 취소 반영 저장에 실패했습니다."));
  179. }
  180. return Result.Success();
  181. }
  182. /// <summary>ABORTED/EXPIRED — 승인 실패/가상계좌 만료. 미적립 주문만 Failed 로 종결</summary>
  183. private async Task<Result> HandleAbortedAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, CancellationToken ct)
  184. {
  185. if (order.Status is PaymentStatus.Pending or PaymentStatus.WaitingDeposit or PaymentStatus.NeedsReconciliation)
  186. {
  187. order.MarkFailed($"토스 웹훅 (status={payment.Status})");
  188. }
  189. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, payment.Status ?? "ABORTED", $"주문 종결 처리 (order.Status={order.Status})", paymentKey, order.OrderID), ct);
  190. await db.SaveChangesAsync(ct);
  191. return Result.Success();
  192. }
  193. /// <summary>판별 불가 상태 — 아무것도 반영하지 않고 로그만</summary>
  194. private async Task<Result> HandleUnknownAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct)
  195. {
  196. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "IGNORED", $"미처리 상태 (status={payment.Status}, eventType={eventType})", paymentKey, order.OrderID), ct);
  197. await db.SaveChangesAsync(ct);
  198. return Result.Success();
  199. }
  200. /// <summary>대사 로그 독립 커밋 — best-effort. (Phase 1.5)</summary>
  201. private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
  202. {
  203. try
  204. {
  205. await using var logDb = dbFactory.CreateDbContext();
  206. await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
  207. await logDb.SaveChangesAsync(CancellationToken.None);
  208. }
  209. catch
  210. {
  211. // best-effort
  212. }
  213. }
  214. }