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 Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.Payment.Toss.Reconcile; internal sealed class Handler( IAppDbContext db, ITossPaymentService tossPay ) : ICommandHandler { public async Task Handle(Command r, CancellationToken ct) { var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.ID == r.PaymentOrderID, ct); if (order is null) { return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다.")); } // 대사 대상 상태만 처리 — 그 사이 사용자 재승인 등으로 상태가 바뀌었으면 멱등 skip if (order.Status != PaymentStatus.NeedsReconciliation) { return Result.Success(); } // 토스 주문만 처리 (타 PG 주문 오처리 방지 — TossConfirm 행 존재 = Toss 결제) var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct); if (confirm is null) { return Result.Success(); } // 조회 API 로 실제 상태 확인 — 통신 실패는 아무것도 반영하지 않고 로그만 (다음 주기 재시도) TossPaymentResult payment; try { payment = await tossPay.GetPaymentAsync(confirm.PaymentKey, ct); } catch (Exception ex) { await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 조회 실패: {ex.Message}", ct); return Result.Success(); } // 미결제 주문(승인 자체가 안 된 paymentKey)은 404/NOT_FOUND 계열 — Failed 단정 대신 로그만 (보수적) if (!payment.Success) { await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 조회 거절: {payment.Code} {payment.Message}", ct); return Result.Success(); } if (payment.Status == "DONE") { return await SettleDoneAsync(order, confirm, payment, ct); } if (payment.Status is "CANCELED" or "ABORTED" or "EXPIRED") { // 적립 전 주문의 종결 상태 — Failed 처리 (지갑 반영 없음) confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson); order.MarkFailed($"자동 대사 결과 {payment.Status}"); await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Reconcile, payment.Status ?? "", "자동 대사 — 주문 Failed 종결", confirm.PaymentKey, order.OrderID), ct); await db.SaveChangesAsync(ct); return Result.Success(); } // 그 외(WAITING_FOR_DEPOSIT/IN_PROGRESS 등)/판별 불가 — 아무것도 하지 않고 대사 로그 기록 await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 판별 불가: status={payment.Status}", ct); return Result.Success(); } /// 조회 결과 DONE — Confirm 완료 로직 재사용, 멱등 적립 private async Task SettleDoneAsync(PaymentOrder order, TossConfirm confirm, TossPaymentResult payment, CancellationToken ct) { // 금액 대조 — 불일치 시 적립하지 않고 수동 대사 대상 if (payment.TotalAmount != order.Amount) { await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", ct); return Result.Success(); } confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson); 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(confirm.PaymentKey); await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Reconcile, "SUCCESS", "자동 대사 — 승인 확인, 멱등 적립 완료", confirm.PaymentKey, order.OrderID), ct); // 저장 실패 시 커밋된 것 없음(원자) — 주문은 NeedsReconciliation 로 남아 다음 주기에 재시도된다. await db.SaveChangesAsync(ct); return Result.Success(); } /// 대사 로그 기록 — 배치 컨텍스트에는 미커밋 변경이 없어 현재 컨텍스트로 바로 커밋 private async Task WriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason, CancellationToken ct) { await db.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.AutoReconcile, paymentOrderID, paymentKey, amount, reason), ct); await db.SaveChangesAsync(ct); } }