|
@@ -2,49 +2,54 @@ using Application.Abstractions.Data;
|
|
|
using Application.Abstractions.Messaging;
|
|
using Application.Abstractions.Messaging;
|
|
|
using Application.Abstractions.Payment;
|
|
using Application.Abstractions.Payment;
|
|
|
using Domain.Entities.Common.ValueObject;
|
|
using Domain.Entities.Common.ValueObject;
|
|
|
-using Domain.Entities.Payments.Danal;
|
|
|
|
|
-using Domain.Entities.Payments.Danal.ValueObject;
|
|
|
|
|
|
|
+using Domain.Entities.Payments;
|
|
|
|
|
+using Domain.Entities.Payments.Toss;
|
|
|
|
|
+using Domain.Entities.Payments.Toss.ValueObject;
|
|
|
using Domain.Entities.Payments.ValueObject;
|
|
using Domain.Entities.Payments.ValueObject;
|
|
|
|
|
+using Domain.Entities.Wallets.ValueObject;
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
using SharedKernel.Results;
|
|
using SharedKernel.Results;
|
|
|
|
|
|
|
|
-namespace Application.Features.Admin.Payment.Danal.Confirm.Cancel;
|
|
|
|
|
|
|
+namespace Application.Features.Admin.Payment.Toss.Confirm.Cancel;
|
|
|
|
|
|
|
|
|
|
+/// <summary>운영자 강제취소 — 토스 취소 API 로 전체 취소 (Phase 1.5 계약 승계)</summary>
|
|
|
public sealed class Handler(
|
|
public sealed class Handler(
|
|
|
IAppDbContext db,
|
|
IAppDbContext db,
|
|
|
- IDanalPayService danalPay,
|
|
|
|
|
|
|
+ ITossPaymentService tossPay,
|
|
|
IAppDbContextFactory dbFactory
|
|
IAppDbContextFactory dbFactory
|
|
|
) : ICommandHandler<Command, Result>
|
|
) : ICommandHandler<Command, Result>
|
|
|
{
|
|
{
|
|
|
public async Task<Result> Handle(Command request, CancellationToken ct)
|
|
public async Task<Result> Handle(Command request, CancellationToken ct)
|
|
|
{
|
|
{
|
|
|
- var confirm = await db.DanalConfirm.FirstOrDefaultAsync(x => x.ID == request.ID, ct);
|
|
|
|
|
|
|
+ var confirm = await db.TossConfirm.FirstOrDefaultAsync(c => c.ID == request.ID, ct);
|
|
|
if (confirm is null)
|
|
if (confirm is null)
|
|
|
{
|
|
{
|
|
|
- return Result.Failure(Error.NotFound("Danal.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
|
|
|
|
|
|
|
+ return Result.Failure(Error.NotFound("Toss.ConfirmNotFound", "결제 내역을 찾을 수 없습니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var order = await db.PaymentOrder.FirstOrDefaultAsync(o => o.ID == confirm.PaymentOrderID, ct);
|
|
|
|
|
|
|
+ var order = await db.PaymentOrder.FirstOrDefaultAsync(c => c.ID == confirm.PaymentOrderID, ct);
|
|
|
if (order is null)
|
|
if (order is null)
|
|
|
{
|
|
{
|
|
|
return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
|
|
return Result.Failure(Error.NotFound("Payment.OrderNotFound", "주문을 찾을 수 없습니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 진실의 근원은 PaymentOrder.Status — DanalConfirm.ResponseCode 가 비어있거나 "0000" 이 아니어도
|
|
|
|
|
- // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요. confirm.IsSuccess 단독 가드는 너무 엄격.
|
|
|
|
|
|
|
+ // 진실의 근원은 PaymentOrder.Status — TossConfirm 응답이 비정상이어도
|
|
|
|
|
+ // PG 측에 실제 결제가 잡혀있다면(=Paid) 운영자 강제 환불이 필요.
|
|
|
if (order.Status != PaymentStatus.Paid)
|
|
if (order.Status != PaymentStatus.Paid)
|
|
|
{
|
|
{
|
|
|
return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
|
|
return Result.Failure(Error.Problem("Payment.InvalidStatusForCancel", "취소할 수 없는 주문 상태입니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 이미 성공 취소된 경우 방지
|
|
// 이미 성공 취소된 경우 방지
|
|
|
- var hasSuccessfulCancel = await db.DanalCancel.AnyAsync(
|
|
|
|
|
- c => c.PaymentOrderID == order.ID && c.ResponseCode == "0000", ct);
|
|
|
|
|
|
|
+ var hasSuccessfulCancel = await db.TossCancel.AnyAsync(c => c.PaymentOrderID == order.ID && c.ResponseCode == "SUCCESS", ct);
|
|
|
if (hasSuccessfulCancel)
|
|
if (hasSuccessfulCancel)
|
|
|
{
|
|
{
|
|
|
- return Result.Failure(Error.Problem("Danal.AlreadyCancelled", "이미 결제가 취소되었습니다."));
|
|
|
|
|
|
|
+ return Result.Failure(Error.Problem("Toss.AlreadyCancelled", "이미 결제가 취소되었습니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // paymentKey 확보 — MarkPaid 시 TransactionID 에 저장됨. 없으면 TossConfirm 에서 보강.
|
|
|
|
|
+ var paymentKey = string.IsNullOrEmpty(order.TransactionID) ? confirm.PaymentKey : order.TransactionID;
|
|
|
|
|
+
|
|
|
// 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
|
|
// 환불 가능성 선검증 — PG 취소 성공 후 지갑 회수가 실패하면 PG-지갑 desync 가 되므로
|
|
|
// 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴)
|
|
// 지갑 존재와 충전 잔액(PgCharged)을 PG 호출 전에 확정한다. (API Cancel 핸들러와 동일 패턴)
|
|
|
var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
|
|
var wallet = await db.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstOrDefaultAsync(w => w.MemberID == order.MemberID, ct);
|
|
@@ -53,58 +58,44 @@ public sealed class Handler(
|
|
|
return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다."));
|
|
return Result.Failure(Error.NotFound("Payment.WalletNotFound", "지갑 정보를 찾을 수 없습니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var pgCharged = wallet.Balances.Where(b => b.Type == Domain.Entities.Wallets.ValueObject.WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
|
|
|
|
|
|
|
+ var pgCharged = wallet.Balances.Where(b => b.Type == WalletBalanceType.PgCharged).Sum(b => b.Amount.Value);
|
|
|
if (pgCharged < order.PointAmount)
|
|
if (pgCharged < order.PointAmount)
|
|
|
{
|
|
{
|
|
|
- return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 포인트가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
|
|
|
|
|
|
|
+ return Result.Failure(Error.Problem("Payment.PointAlreadySpent", "회원이 이미 사용한 캐시가 있어 결제를 취소할 수 없습니다. 수동 조정이 필요합니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 다날 결제수단 코드 매핑
|
|
|
|
|
- var methodCode = 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 cancelReason = string.IsNullOrWhiteSpace(request.CancelReason) ? "운영자 강제취소" : request.CancelReason;
|
|
|
|
|
|
|
|
// 취소 요청 기록 저장 (전체 취소)
|
|
// 취소 요청 기록 저장 (전체 취소)
|
|
|
- var cancel = DanalCancel.CreateRequest(
|
|
|
|
|
- memberID: order.MemberID,
|
|
|
|
|
- paymentOrderID: order.ID,
|
|
|
|
|
- method: order.PaymentMethod,
|
|
|
|
|
- transactionID: order.TransactionID!,
|
|
|
|
|
- orderID: order.OrderID,
|
|
|
|
|
- amount: order.Amount,
|
|
|
|
|
- merchantID: order.MerchantID,
|
|
|
|
|
- cancelType: DanalCancelType.Full,
|
|
|
|
|
- cancelRequester: request.CancelRequester,
|
|
|
|
|
- cancelReason: request.CancelReason
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- await db.DanalCancel.AddAsync(cancel, ct);
|
|
|
|
|
|
|
+ var cancel = TossCancel.CreateRequest(order.MemberID, order.ID, paymentKey, order.OrderID, order.Amount, cancelReason, request.CancelRequester ?? "admin");
|
|
|
|
|
+
|
|
|
|
|
+ await db.TossCancel.AddAsync(cancel, ct);
|
|
|
await db.SaveChangesAsync(ct);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
|
|
|
|
- // 다날 API 호출
|
|
|
|
|
- var result = await danalPay.CancelAsync(methodCode, order.TransactionID!, order.Amount, "C", ct);
|
|
|
|
|
|
|
+ // 토스 취소 API 호출 (cancelAmount 미전달 = 전액 취소) — 통신 예외도 기록을 남긴다
|
|
|
|
|
+ TossPaymentResult result;
|
|
|
|
|
+ try
|
|
|
|
|
+ {
|
|
|
|
|
+ result = await tossPay.CancelAsync(paymentKey, cancelReason, null, ct);
|
|
|
|
|
+ }
|
|
|
|
|
+ catch (Exception ex)
|
|
|
|
|
+ {
|
|
|
|
|
+ cancel.SetError("EXCEPTION", ex.Message);
|
|
|
|
|
+
|
|
|
|
|
+ await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Error, "EXCEPTION", ex.Message, paymentKey, order.OrderID), ct);
|
|
|
|
|
+ await db.SaveChangesAsync(ct);
|
|
|
|
|
+
|
|
|
|
|
+ return Result.Failure(Error.Problem("Toss.CancelFailed", "결제 취소 처리 중 오류가 발생했습니다."));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
if (!result.Success)
|
|
if (!result.Success)
|
|
|
{
|
|
{
|
|
|
- cancel.SetResponse(result.Code ?? "", result.Message ?? "취소 실패");
|
|
|
|
|
- await db.DanalLog.AddAsync(DanalLog.Create(
|
|
|
|
|
- order.MemberID,
|
|
|
|
|
- DanalLogType.Failed,
|
|
|
|
|
- result.Code ?? "FAIL",
|
|
|
|
|
- result.Message ?? "취소 실패",
|
|
|
|
|
- order.TransactionID,
|
|
|
|
|
- order.OrderID), ct);
|
|
|
|
|
|
|
+ cancel.SetError(result.Code ?? "FAIL", result.Message ?? "취소 실패");
|
|
|
|
|
+
|
|
|
|
|
+ await db.TossLog.AddAsync(TossLog.Create(order.MemberID, TossLogType.Failed, result.Code ?? "FAIL", result.Message ?? "취소 실패", paymentKey, order.OrderID), ct);
|
|
|
await db.SaveChangesAsync(ct);
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
|
|
|
|
- return Result.Failure(Error.Problem("Danal.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
|
|
|
|
|
|
|
+ return Result.Failure(Error.Problem("Toss.CancelFailed", result.Message ?? "결제 취소에 실패했습니다."));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 취소 응답 저장
|
|
// 취소 응답 저장
|
|
@@ -113,7 +104,6 @@ public sealed class Handler(
|
|
|
order.MarkCancelled();
|
|
order.MarkCancelled();
|
|
|
|
|
|
|
|
// 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
|
|
// 충전분(PgCharged) 회수 — ChargeCancelled 원장 기록으로 출처를 보존한다.
|
|
|
- // (기존: Adjusted 버킷 차감 → 항상 잔액 0 → 예외 흡수로 환불해도 포인트 미회수 결함)
|
|
|
|
|
// 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다.
|
|
// 잔액은 위에서 선검증했으므로 여기서 예외를 흡수하지 않는다.
|
|
|
// 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음)
|
|
// 주문 취소 + 회수는 단일 SaveChanges 원자 커밋 (실패 시 부분 반영 없음)
|
|
|
wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
|
|
wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
|
|
@@ -132,7 +122,7 @@ public sealed class Handler(
|
|
|
}
|
|
}
|
|
|
catch (Exception retryEx)
|
|
catch (Exception retryEx)
|
|
|
{
|
|
{
|
|
|
- await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"ADMIN_CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
|
|
|
|
|
|
|
+ await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"ADMIN_CANCEL_PERSIST 재시도 실패: {retryEx.Message} (동시성 충돌: {ex.Message})");
|
|
|
throw;
|
|
throw;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
@@ -140,7 +130,7 @@ public sealed class Handler(
|
|
|
{
|
|
{
|
|
|
// Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
|
|
// Phase 1.5: PG 취소는 성공했는데 저장 실패 — 별도 컨텍스트로 대사 로그를 독립 커밋한 뒤
|
|
|
// 기존 실패 흐름(예외 전파)을 유지한다.
|
|
// 기존 실패 흐름(예외 전파)을 유지한다.
|
|
|
- await TryWriteReconcileLogAsync(order.ID, order.TransactionID, order.PointAmount, $"ADMIN_CANCEL_PERSIST 실패: {ex.Message}");
|
|
|
|
|
|
|
+ await TryWriteReconcileLogAsync(order.ID, paymentKey, order.PointAmount, $"ADMIN_CANCEL_PERSIST 실패: {ex.Message}");
|
|
|
throw;
|
|
throw;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -148,25 +138,25 @@ public sealed class Handler(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5)</summary>
|
|
/// <summary>동시성 충돌 1회 재시도 — 새 컨텍스트로 리로드 후 취소 반영을 재적용한다. (Phase 1.5)</summary>
|
|
|
- private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, DanalCancelResult result, CancellationToken ct)
|
|
|
|
|
|
|
+ private async Task<Result> RetryCancelPersistAsync(int paymentOrderID, int cancelID, TossPaymentResult result, CancellationToken ct)
|
|
|
{
|
|
{
|
|
|
await using var retryDb = dbFactory.CreateDbContext();
|
|
await using var retryDb = dbFactory.CreateDbContext();
|
|
|
|
|
|
|
|
- var order = await retryDb.PaymentOrder.FirstAsync(o => o.ID == paymentOrderID, ct);
|
|
|
|
|
|
|
+ var order = await retryDb.PaymentOrder.FirstAsync(c => c.ID == paymentOrderID, ct);
|
|
|
if (order.Status == PaymentStatus.Cancelled)
|
|
if (order.Status == PaymentStatus.Cancelled)
|
|
|
{
|
|
{
|
|
|
// 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답
|
|
// 동시 요청이 이미 취소 반영을 끝낸 경우 — 이중회수 금지, idempotent 성공 응답
|
|
|
return Result.Success();
|
|
return Result.Success();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var cancel = await retryDb.DanalCancel.FirstAsync(c => c.ID == cancelID, ct);
|
|
|
|
|
|
|
+ var cancel = await retryDb.TossCancel.FirstAsync(c => c.ID == cancelID, ct);
|
|
|
ApplyCancelResponse(cancel, result);
|
|
ApplyCancelResponse(cancel, result);
|
|
|
|
|
|
|
|
var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct);
|
|
var wallet = await retryDb.Wallet.Include(w => w.Balances).Include(w => w.Transactions).AsSplitQuery().FirstAsync(w => w.MemberID == order.MemberID, ct);
|
|
|
|
|
|
|
|
order.MarkCancelled();
|
|
order.MarkCancelled();
|
|
|
|
|
|
|
|
- // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 포인트가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
|
|
|
|
|
|
|
+ // 리로드된 실제 잔액 기준 회수 — 충돌 사이에 캐시가 사용됐다면 Decrease 예외 → 호출부가 대사 로그를 남긴다
|
|
|
// (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상)
|
|
// (PG 환불은 이미 완료 — 자동 회수 불가, 운영자 수동 대사 대상)
|
|
|
wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
|
|
wallet.DebitChargeCancel(Money.KRW(order.PointAmount), "결제 취소 환불(운영자)", order.OrderID);
|
|
|
|
|
|
|
@@ -176,13 +166,13 @@ public sealed class Handler(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
|
|
/// <summary>대사 로그 독립 커밋 — best-effort. 로그 실패가 원래 실패 흐름을 가리면 안 된다. (Phase 1.5)</summary>
|
|
|
- private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? transactionID, int amount, string reason)
|
|
|
|
|
|
|
+ private async Task TryWriteReconcileLogAsync(int paymentOrderID, string? paymentKey, int amount, string reason)
|
|
|
{
|
|
{
|
|
|
try
|
|
try
|
|
|
{
|
|
{
|
|
|
// 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
|
|
// 호출측 취소와 무관하게 반드시 남아야 하므로 CancellationToken.None
|
|
|
await using var logDb = dbFactory.CreateDbContext();
|
|
await using var logDb = dbFactory.CreateDbContext();
|
|
|
- await logDb.PaymentReconcileLog.AddAsync(Domain.Entities.Payments.PaymentReconcileLog.Create(ReconcileKind.AdminCancel, paymentOrderID, transactionID, amount, reason), CancellationToken.None);
|
|
|
|
|
|
|
+ await logDb.PaymentReconcileLog.AddAsync(PaymentReconcileLog.Create(ReconcileKind.AdminCancel, paymentOrderID, paymentKey, amount, reason), CancellationToken.None);
|
|
|
await logDb.SaveChangesAsync(CancellationToken.None);
|
|
await logDb.SaveChangesAsync(CancellationToken.None);
|
|
|
}
|
|
}
|
|
|
catch
|
|
catch
|
|
@@ -191,19 +181,9 @@ public sealed class Handler(
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private static void ApplyCancelResponse(DanalCancel cancel, DanalCancelResult result)
|
|
|
|
|
|
|
+ private static void ApplyCancelResponse(TossCancel cancel, TossPaymentResult 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
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ var last = result.Cancels.Count > 0 ? result.Cancels[^1] : null;
|
|
|
|
|
+ cancel.SetResponse(result.Status ?? "", last?.TransactionKey, last?.CanceledAt, result.RawJson);
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
+}
|