using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Payment; using Domain.Entities.Common.ValueObject; using Domain.Entities.Payments; using Domain.Entities.Payments.Toss; using Domain.Entities.Payments.Toss.ValueObject; using Domain.Entities.Payments.ValueObject; using Domain.Entities.Wallets; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.Payment.Toss.Webhook; internal sealed class Handler( IAppDbContext db, ITossPaymentService tossPay, IAppDbContextFactory dbFactory ) : ICommandHandler { public async Task Handle(Command r, CancellationToken ct) { // paymentKey 확보 — DEPOSIT_CALLBACK 은 본문에 paymentKey 가 없어 orderId 로 TossConfirm 에서 보강 var paymentKey = r.PaymentKey; if (string.IsNullOrEmpty(paymentKey) && !string.IsNullOrEmpty(r.OrderID)) { var confirmRow = await db.TossConfirm.FirstOrDefaultAsync(c => c.OrderID == r.OrderID, ct); paymentKey = confirmRow?.PaymentKey; } if (string.IsNullOrEmpty(paymentKey)) { // 판별 불가 — 로그만 남기고 정상 응답 (재시도 무의미) await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "IGNORED", $"paymentKey 판별 불가 (eventType={r.EventType})", null, r.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } // 웹훅 본문 신뢰 금지 — 조회 API 재조회 결과만 반영 TossPaymentResult payment; try { payment = await tossPay.GetPaymentAsync(paymentKey, ct); } catch (Exception ex) { // 조회 실패 — 실패 응답으로 토스 재시도 유도 await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Error, "EXCEPTION", $"웹훅 재조회 실패: {ex.Message}", paymentKey, r.OrderID), CancellationToken.None); await db.SaveChangesAsync(CancellationToken.None); return Result.Failure(Error.Problem("Payment.WebhookRequeryFailed", "결제 재조회에 실패했습니다.")); } if (!payment.Success || string.IsNullOrEmpty(payment.OrderID)) { await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, payment.Code ?? "IGNORED", $"재조회 결과 처리 불가 (eventType={r.EventType}): {payment.Message}", paymentKey, r.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } // 조회 결과의 orderId 기준으로 주문 확정 (본문 orderId 미신뢰) var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.OrderID == payment.OrderID, ct); if (order is null) { await db.TossLog.AddAsync(TossLog.Create(null, TossLogType.Webhook, "ORDER_NOT_FOUND", $"주문 없음 (eventType={r.EventType})", paymentKey, payment.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } return payment.Status switch { "DONE" => await HandleDoneAsync(order, paymentKey, payment, r.EventType, ct), "CANCELED" or "PARTIAL_CANCELED" => await HandleCanceledAsync(order, paymentKey, payment, r.EventType, ct), "ABORTED" or "EXPIRED" => await HandleAbortedAsync(order, paymentKey, payment, ct), _ => await HandleUnknownAsync(order, paymentKey, payment, r.EventType, ct) }; } /// DONE — 승인/입금 완료. 미적립이면 멱등 적립 (가상계좌 DEPOSIT_CALLBACK 포함) private async Task HandleDoneAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct) { if (order.Status == PaymentStatus.Paid) { // 이미 적립 완료 — 멱등, 중복 적립 금지 await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_PAID", $"이미 처리된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } if (order.Status is not PaymentStatus.Pending and not PaymentStatus.WaitingDeposit and not PaymentStatus.NeedsReconciliation) { // Failed/Cancelled 주문에 DONE 웹훅 — 자동 반영하지 않고 대사 대상 기록 (보수적) await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "STATE_MISMATCH", $"주문 상태 {order.Status} 에 DONE 웹훅", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE — 주문 상태 {order.Status} 불일치, 수동 확인 필요"); return Result.Success(); } // 금액 대조 — 조회 결과 totalAmount 와 서버 저장 주문 금액 불일치 시 적립하지 않는다 if (payment.TotalAmount != order.Amount) { await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "AMOUNT_MISMATCH", $"금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}"); return Result.Success(); } // 승인 기록 갱신 — TossConfirm 행이 없으면 생성 (unique 충돌 방지 위해 기존 행 재사용) var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct); if (confirm is null) { confirm = TossConfirm.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount); await db.TossConfirm.AddAsync(confirm, ct); } confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson); // 지갑 적립 (멱등 — 위 Paid 가드로 중복 차단) + 주문 완료 단일 커밋 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) { wallet = Wallet.Create(order.MemberID); await db.Wallet.AddAsync(wallet, ct); } wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss)", order.OrderID); order.MarkPaid(paymentKey); await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"입금/승인 완료 반영 (eventType={eventType})", paymentKey, order.OrderID), ct); try { await db.SaveChangesAsync(ct); } catch (Exception ex) { // PG 는 이미 DONE — 저장 실패는 대사 로그 독립 커밋 후 실패 응답 (토스 재시도로 재처리) await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_PERSIST 실패: {ex.Message}"); return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 반영 저장에 실패했습니다.")); } return Result.Success(); } /// CANCELED — 토스 측 취소 (관리자콘솔 취소 등). Paid 주문이면 지갑 회수 반영 private async Task HandleCanceledAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct) { if (order.Status == PaymentStatus.Cancelled) { // 이미 취소 반영 완료 — 멱등 await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "ALREADY_CANCELLED", $"이미 취소된 주문 (eventType={eventType})", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } // 미적립 주문(Pending/WaitingDeposit/NeedsReconciliation)의 취소 — 적립된 것이 없으므로 Failed 처리로 종결 if (order.Status != PaymentStatus.Paid) { order.MarkFailed($"토스 취소 웹훅 (status={payment.Status})"); await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "CANCELED_BEFORE_PAID", $"미적립 주문 취소 종결 (eventType={eventType})", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } // 부분취소는 자동 반영하지 않는다 — 수동 대사 대상 (보수적) if (payment.Status == "PARTIAL_CANCELED") { await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "PARTIAL_CANCELED", "부분취소 웹훅 — 자동 반영 안 함", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, "웹훅 PARTIAL_CANCELED — 수동 확인 필요"); return Result.Success(); } // 회수 가능성 검증 — 이미 사용한 캐시가 있으면 자동 회수 불가, 수동 대사 대상 var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct); var pgCharged = wallet?.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value) ?? 0; if (wallet is null || pgCharged < order.PointAmount) { await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "REFUND_BLOCKED", $"잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged}, 필요={order.PointAmount})", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"웹훅 CANCELED — PG 취소 완료됐으나 잔액 부족으로 자동 회수 불가 (PgCharged={pgCharged})"); return Result.Success(); } // 취소 기록 (webhook 발) + 주문 취소 + 지갑 회수 단일 커밋 var last = payment.Cancels.Count > 0 ? payment.Cancels[^1] : null; var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, last?.CancelReason ?? "토스 측 취소(웹훅)", "webhook"); cancel.SetResponse(payment.Status ?? "", last?.TransactionKey, last?.CanceledAt, payment.RawJson); await db.TossCancel.AddAsync(cancel, ct); order.MarkCancelled(); wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불 (Toss 웹훅)", order.OrderID); await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "SUCCESS", $"취소 반영 (eventType={eventType})", paymentKey, order.OrderID), ct); try { await db.SaveChangesAsync(ct); } catch (Exception ex) { await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"WEBHOOK_CANCEL_PERSIST 실패: {ex.Message}"); return Result.Failure(Error.Problem("Payment.WebhookPersistFailed", "웹훅 취소 반영 저장에 실패했습니다.")); } return Result.Success(); } /// ABORTED/EXPIRED — 승인 실패/가상계좌 만료. 미적립 주문만 Failed 로 종결 private async Task HandleAbortedAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, CancellationToken ct) { if (order.Status is PaymentStatus.Pending or PaymentStatus.WaitingDeposit or PaymentStatus.NeedsReconciliation) { order.MarkFailed($"토스 웹훅 (status={payment.Status})"); } await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, payment.Status ?? "ABORTED", $"주문 종결 처리 (order.Status={order.Status})", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } /// 판별 불가 상태 — 아무것도 반영하지 않고 로그만 private async Task HandleUnknownAsync(PaymentOrder order, string paymentKey, TossPaymentResult payment, string? eventType, CancellationToken ct) { await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Webhook, "IGNORED", $"미처리 상태 (status={payment.Status}, eventType={eventType})", paymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } /// 대사 로그 독립 커밋 — best-effort. (Phase 1.5) private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason) { try { await using var logDb = dbFactory.CreateDbContext(); await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.Confirm, paymentOrderID, paymentKey, amount, reason), CancellationToken.None); await logDb.SaveChangesAsync(CancellationToken.None); } catch { // best-effort } } }