Handler.cs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Payment;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Payments;
  6. using Domain.Entities.Payments.Toss;
  7. using Domain.Entities.Payments.Toss.ValueObject;
  8. using Domain.Entities.Payments.ValueObject;
  9. using Domain.Entities.Wallets;
  10. using Microsoft.EntityFrameworkCore;
  11. using SharedKernel.Results;
  12. namespace Application.Features.Api.Payment.Toss.Reconcile;
  13. internal sealed class Handler(
  14. IAppDbContext db,
  15. ITossPaymentService tossPay
  16. ) : ICommandHandler<Command, Result>
  17. {
  18. public async Task<Result> Handle(Command r, CancellationToken ct)
  19. {
  20. var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.ID == r.PaymentOrderID, ct);
  21. if (order is null)
  22. {
  23. return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
  24. }
  25. // 대사 대상 상태만 처리 — 그 사이 사용자 재승인 등으로 상태가 바뀌었으면 멱등 skip
  26. if (order.Status != PaymentStatus.NeedsReconciliation)
  27. {
  28. return Result.Success();
  29. }
  30. // 토스 주문만 처리 (타 PG 주문 오처리 방지 — TossConfirm 행 존재 = Toss 결제)
  31. var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.PaymentOrderID == order.ID, ct);
  32. if (confirm is null)
  33. {
  34. return Result.Success();
  35. }
  36. // 조회 API 로 실제 상태 확인 — 통신 실패는 아무것도 반영하지 않고 로그만 (다음 주기 재시도)
  37. TossPaymentResult payment;
  38. try
  39. {
  40. payment = await tossPay.GetPaymentAsync(confirm.PaymentKey, ct);
  41. }
  42. catch (Exception ex)
  43. {
  44. await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 조회 실패: {ex.Message}", ct);
  45. return Result.Success();
  46. }
  47. // 미결제 주문(승인 자체가 안 된 paymentKey)은 404/NOT_FOUND 계열 — Failed 단정 대신 로그만 (보수적)
  48. if (!payment.Success)
  49. {
  50. await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 조회 거절: {payment.Code} {payment.Message}", ct);
  51. return Result.Success();
  52. }
  53. if (payment.Status == "DONE")
  54. {
  55. return await SettleDoneAsync(order, confirm, payment, ct);
  56. }
  57. if (payment.Status is "CANCELED" or "ABORTED" or "EXPIRED")
  58. {
  59. // 적립 전 주문의 종결 상태 — Failed 처리 (지갑 반영 없음)
  60. confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
  61. order.MarkFailed($"자동 대사 결과 {payment.Status}");
  62. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Reconcile, payment.Status ?? "", "자동 대사 — 주문 Failed 종결", confirm.PaymentKey, order.OrderID), ct);
  63. await db.SaveChangesAsync(ct);
  64. return Result.Success();
  65. }
  66. // 그 외(WAITING_FOR_DEPOSIT/IN_PROGRESS 등)/판별 불가 — 아무것도 하지 않고 대사 로그 기록
  67. await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 판별 불가: status={payment.Status}", ct);
  68. return Result.Success();
  69. }
  70. /// <summary>조회 결과 DONE — Confirm 완료 로직 재사용, 멱등 적립</summary>
  71. private async Task<Result> SettleDoneAsync(PaymentOrder order, TossConfirm confirm, TossPaymentResult payment, CancellationToken ct)
  72. {
  73. // 금액 대조 — 불일치 시 적립하지 않고 수동 대사 대상
  74. if (payment.TotalAmount != order.Amount)
  75. {
  76. await WriteReconcileLogAsync(order.ID, confirm.PaymentKey, order.PointAmount, $"자동 대사 DONE 금액 불일치: 조회={payment.TotalAmount}, 주문={order.Amount}", ct);
  77. return Result.Success();
  78. }
  79. confirm.SetResponse(payment.Status ?? "", payment.Method, payment.TotalAmount, payment.ApprovedAt, payment.ReceiptUrl, payment.RawJson);
  80. var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
  81. if (wallet is null)
  82. {
  83. wallet = Wallet.Create(order.MemberID);
  84. await db.Wallet.AddAsync(wallet, ct);
  85. }
  86. wallet.CreditPgCharge(Money.KRW(order.PointAmount), "캐시 충전 (Toss 자동 대사)", order.OrderID);
  87. order.MarkPaid(confirm.PaymentKey);
  88. await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Reconcile, "SUCCESS", "자동 대사 — 승인 확인, 멱등 적립 완료", confirm.PaymentKey, order.OrderID), ct);
  89. // 저장 실패 시 커밋된 것 없음(원자) — 주문은 NeedsReconciliation 로 남아 다음 주기에 재시도된다.
  90. await db.SaveChangesAsync(ct);
  91. return Result.Success();
  92. }
  93. /// <summary>대사 로그 기록 — 배치 컨텍스트에는 미커밋 변경이 없어 현재 컨텍스트로 바로 커밋</summary>
  94. private async Task WriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason, CancellationToken ct)
  95. {
  96. await db.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.AutoReconcile, paymentOrderID, paymentKey, amount, reason), ct);
  97. await db.SaveChangesAsync(ct);
  98. }
  99. }