using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Payment; using SharedKernel.Results; using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments.Danal; using Domain.Entities.Payments.Danal.ValueObject; using Domain.Entities.Payments.ValueObject; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Payment.Cancel; internal sealed class Handler( IAppDbContext db, IDanalPayService danalPay, IAppDbContextFactory dbFactory ) : ICommandHandler { public async Task Handle(Command r, CancellationToken ct) { var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.OrderID == r.OrderID && o.MemberID == r.MemberID, ct); if (order is null) { return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다.")); } if (order.Status != PaymentStatus.Paid) { return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다.")); } // 환불 가능성 선검증 — PG 취소가 성공한 뒤 지갑 회수가 실패하면 PG-지갑 desync 가 되므로 // 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. 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 == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value); if (pgCharged < order.PointAmount) { return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "이미 사용한 포인트가 있어 결제를 취소할 수 없습니다.")); } // 다날 결제수단 코드 매핑 var method = 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(r.MemberID, order.ID, order.PaymentMethod, order.TransactionID!, order.OrderID, order.Amount, order.MerchantID, DanalCancelType.Full); await db.DanalCancel.AddAsync(cancel, ct); await db.SaveChangesAsync(ct); // 다날 취소 API 호출 — 통신 예외도 기록을 남긴다 DanalCancelResult result; try { result = await danalPay.CancelAsync(method, order.TransactionID!, order.Amount, "C", ct); } catch (Exception ex) { cancel.SetResponse("EXCEPTION", ex.Message); await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Error, "EXCEPTION", ex.Message, order.TransactionID, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Failure(Error.Problem("Payment.CancelFailed", "결제 취소 처리 중 오류가 발생했습니다.")); } if (!result.Success) { cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패"); await db.DanalLog.AddAsync(DanalLog.Create(r.MemberID, DanalLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", order.TransactionID, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Failure(Error.Problem("Payment.CancelFailed", result.Message ?? "결제 취소에 실패했습니다.")); } // 취소 응답 저장 ApplyCancelResponse(cancel, result); order.MarkCancelled(); // 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다. // 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음) wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불", order.OrderID); try { await db.SaveChangesAsync(ct); } catch (DbUpdateConcurrencyException ex) { // Phase 1.5: WalletBalance rowversion 동시성 충돌 — 오염된 컨텍스트는 버리고(커밋된 것 없음) // 새 컨텍스트로 리로드해 도메인 연산을 1회 재적용한다. 첫 시도의 원장 기록은 미영속이므로 이중회수 없음. try { return await RetryCancelPersistAsync(order.ID, cancel.ID, result, ct); } catch (Exception retryEx) { await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})"); throw; } } catch (Exception ex) { // Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤 // 기존 실패 흐름(예외 전파)을 유지한다. await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"CANCEL_PERSIST 실패: {ex.Message}"); throw; } return Result.Success(); } /// 동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5) private async Task RetryCancelPersistAsync(int paymentOrderID, int cancelID, DanalCancelResult result, CancellationToken ct) { await using var retryDb = dbFactory.CreateDbContext(); var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct); if (order.Status == PaymentStatus.Cancelled) { // 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답 return Result.Success(); } var cancel = await retryDb.DanalCancel.FirstAsync(c => c.ID == cancelID, ct); ApplyCancelResponse(cancel, result); var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct); order.MarkCancelled(); // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 포인트가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다 // (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상) wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불", order.OrderID); await retryDb.SaveChangesAsync(ct); return Result.Success(); } /// 대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5) private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason) { try { // 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None await using var logDb = dbFactory.CreateDbContext(); await logDb.PaymentReconcileLog.AddAsync(Domain.Entities.Payments.PaymentReconcileLog.Create(ReconcileKind.Cancel, paymentOrderID, transactionID, amount, reason), CancellationToken.None); await logDb.SaveChangesAsync(CancellationToken.None); } catch { // best-effort — 원래 예외를 유지한다 } } private static void ApplyCancelResponse(DanalCancel cancel, DanalCancelResult result) { // Balance, RemainedAmount는 string? → int? 변환 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 ); } }