|
|
@@ -0,0 +1,261 @@
|
|
|
+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<Command, Result>
|
|
|
+{
|
|
|
+ public async Task<Result> 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)
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>DONE — 승인/입금 완료. 미적립이면 멱등 적립 (가상계좌 DEPOSIT_CALLBACK 포함)</summary>
|
|
|
+ private async Task<Result> 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>CANCELED — 토스 측 취소 (관리자콘솔 취소 등). Paid 주문이면 지갑 회수 반영</summary>
|
|
|
+ private async Task<Result> 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>ABORTED/EXPIRED — 승인 실패/가상계좌 만료. 미적립 주문만 Failed 로 종결</summary>
|
|
|
+ private async Task<Result> 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>판별 불가 상태 — 아무것도 반영하지 않고 로그만</summary>
|
|
|
+ private async Task<Result> 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>대사 로그 독립 커밋 — best-effort. (Phase 1.5)</summary>
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|