using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Payment; using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments.Danal; using Domain.Entities.Payments.Danal.ValueObject; using Domain.Entities.Payments.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Admin.Payment.Danal.Confirm.Cancel; public sealed class Handler( IAppDbContext db, IDanalPayService danalPay ) : ICommandHandler { public async Task Handle(Command request, CancellationToken ct) { var confirm = await db.DanalConfirm.FirstOrDefaultAsync(x => x.ID == request.ID, ct); if (confirm is null) { return Result.Failure(Error.NotFound("Danal.ConfirmNotFound", "결제 내역을 찾을 수 없습니다.")); } var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.ID == confirm.PaymentOrderID, ct); if (order is null) { return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다.")); } // 진실의 근원은 PaymentOrder.Status — DanalConfirm.ResponseCode 가 비어있거나 "0000" 이 아니어도 // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요. confirm.IsSuccess 단독 가드는 너무 엄격. if (order.Status != PaymentStatus.Paid) { return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다.")); } // 이미 성공 취소된 경우 방지 var hasSuccessfulCancel = await db.DanalCancel.AnyAsync( c => c.PaymentOrderID == order.ID && c.ResponseCode == "0000", ct); if (hasSuccessfulCancel) { return Result.Failure(Error.Problem("Danal.AlreadyCancelled", "이미 결제가 취소되었습니다.")); } // 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로 // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴) var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct); if (wallet is null) { return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다.")); } var pgCharged = wallet.Balances.Where(b => b.Type == Domain.Entities.Wallets.ValueObject.WalletBalanceType.PgCharged).Sum(b => b.Amount.Value); if (pgCharged < order.PointAmount) { return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 포인트가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다.")); } // 다날 결제수단 코드 매핑 var methodCode = order.PaymentMethod switch { PaymentMethod.Integrated => "INTEGRATED", PaymentMethod.Card => "CARD", PaymentMethod.VirtualAccount => "VACCOUNT", PaymentMethod.Mobile => "MOBILE", PaymentMethod.Transfer => "TRANSFER", PaymentMethod.NaverPay => "NAVERPAY", PaymentMethod.KakaoPay => "KAKAOPAY", PaymentMethod.Payco => "PAYCO", _ => "CARD" }; // 취소 요청 기록 저장 (전체 취소) var cancel = DanalCancel.CreateRequest( memberID: order.MemberID, paymentOrderID: order.ID, method: order.PaymentMethod, transactionID: order.TransactionID!, orderID: order.OrderID, amount: order.Amount, merchantID: order.MerchantID, cancelType: DanalCancelType.Full, cancelRequester: request.CancelRequester, cancelReason: request.CancelReason ); await db.DanalCancel.AddAsync(cancel, ct); await db.SaveChangesAsync(ct); // 다날 API 호출 var result = await danalPay.CancelAsync(methodCode, order.TransactionID!, order.Amount, "C", ct); if (!result.Success) { cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패"); await db.DanalLog.AddAsync(DanalLog.Create( order.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", order.TransactionID, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Failure(Error.Problem("Danal.CancelFailed", result.Message ?? "결제 취소에 실패했습니다.")); } // 취소 응답 저장 cancel.SetResponse( result.Code ?? "", result.Message ?? "", result.OriginalTransactionID, result.CancelledAmount, result.TransDate, result.TransTime, int.TryParse(result.Balance, out var balanceVal) ? balanceVal : null, int.TryParse(result.RemainedAmount, out var remainedVal) ? remainedVal : null, result.ApprovalDateTime ); order.MarkCancelled(); // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다. // (기존: Adjusted 버킷 차감 → 항상 잔액 0 → 예외 흡수로 환불해도 포인트 미회수 결함) // 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다. wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID); await db.SaveChangesAsync(ct); return Result.Success(); } }