Handler.cs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  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. ) : ICommandHandler<Command, Result>
  15. {
  16. public async Task<Result> Handle(Command request, CancellationToken ct)
  17. {
  18. var confirm = await db.DanalConfirm.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
  19. if (confirm is null)
  20. {
  21. return Result.Failure(Error.NotFound("Danal.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
  22. }
  23. var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.ID == confirm.PaymentOrderID, ct);
  24. if (order is null)
  25. {
  26. return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
  27. }
  28. // 진실의 근원은 PaymentOrder.Status — DanalConfirm.ResponseCode 가 비어있거나 "0000" 이 아니어도
  29. // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요. confirm.IsSuccess 단독 가드는 너무 엄격.
  30. if (order.Status != PaymentStatus.Paid)
  31. {
  32. return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
  33. }
  34. // 이미 성공 취소된 경우 방지
  35. var hasSuccessfulCancel = await db.DanalCancel.AnyAsync(
  36. c => c.PaymentOrderID == order.ID && c.ResponseCode == "0000", ct);
  37. if (hasSuccessfulCancel)
  38. {
  39. return Result.Failure(Error.Problem("Danal.AlreadyCancelled", "이미 결제가 취소되었습니다."));
  40. }
  41. // 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
  42. // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴)
  43. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  44. if (wallet is null)
  45. {
  46. return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다."));
  47. }
  48. var pgCharged = wallet.Balances.Where(b => b.Type == Domain.Entities.Wallets.ValueObject.WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
  49. if (pgCharged < order.PointAmount)
  50. {
  51. return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 포인트가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
  52. }
  53. // 다날 결제수단 코드 매핑
  54. var methodCode = order.PaymentMethod switch
  55. {
  56. PaymentMethod.Integrated => "INTEGRATED",
  57. PaymentMethod.Card => "CARD",
  58. PaymentMethod.VirtualAccount => "VACCOUNT",
  59. PaymentMethod.Mobile => "MOBILE",
  60. PaymentMethod.Transfer => "TRANSFER",
  61. PaymentMethod.NaverPay => "NAVERPAY",
  62. PaymentMethod.KakaoPay => "KAKAOPAY",
  63. PaymentMethod.Payco => "PAYCO",
  64. _ => "CARD"
  65. };
  66. // 취소 요청 기록 저장 (전체 취소)
  67. var cancel = DanalCancel.CreateRequest(
  68. memberID: order.MemberID,
  69. paymentOrderID: order.ID,
  70. method: order.PaymentMethod,
  71. transactionID: order.TransactionID!,
  72. orderID: order.OrderID,
  73. amount: order.Amount,
  74. merchantID: order.MerchantID,
  75. cancelType: DanalCancelType.Full,
  76. cancelRequester: request.CancelRequester,
  77. cancelReason: request.CancelReason
  78. );
  79. await db.DanalCancel.AddAsync(cancel, ct);
  80. await db.SaveChangesAsync(ct);
  81. // 다날 API 호출
  82. var result = await danalPay.CancelAsync(methodCode, order.TransactionID!, order.Amount, "C", ct);
  83. if (!result.Success)
  84. {
  85. cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패");
  86. await db.DanalLog.AddAsync(DanalLog.Create(
  87. order.MemberID,
  88. DanalLogType.Failed,
  89. result.Code ?? "FAIL",
  90. result.Message ?? "취소 실패",
  91. order.TransactionID,
  92. order.OrderID), ct);
  93. await db.SaveChangesAsync(ct);
  94. return Result.Failure(Error.Problem("Danal.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
  95. }
  96. // 취소 응답 저장
  97. cancel.SetResponse(
  98. result.Code ?? "",
  99. result.Message ?? "",
  100. result.OriginalTransactionID,
  101. result.CancelledAmount,
  102. result.TransDate,
  103. result.TransTime,
  104. int.TryParse(result.Balance, out var balanceVal) ? balanceVal : null,
  105. int.TryParse(result.RemainedAmount, out var remainedVal) ? remainedVal : null,
  106. result.ApprovalDateTime
  107. );
  108. order.MarkCancelled();
  109. // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
  110. // (기존: Adjusted 버킷 차감 → 항상 잔액 0 → 예외 흡수로 환불해도 포인트 미회수 결함)
  111. // 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다.
  112. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
  113. await db.SaveChangesAsync(ct);
  114. return Result.Success();
  115. }
  116. }