Handler.cs 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Abstractions.Notification;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Developers;
  6. using Domain.Entities.Developers.ValueObject;
  7. using Domain.Entities.Notifications.ValueObject;
  8. using Domain.Entities.Wallets.ValueObject;
  9. using Microsoft.EntityFrameworkCore;
  10. using SharedKernel.Results;
  11. namespace Application.Features.Api.V1.Purchases.Cancel;
  12. /// <summary>
  13. /// 게임사 결제 취소 — 등록한 앱만 가능.
  14. /// 보류(Pending) 중 취소: 지갑 무관, 상태만 Canceled (shortfall 없음).
  15. /// 확정(Confirmed) 후 취소: 적립 시점 회원(CreditedMemberID)의 StoreRevenue 에서 회수,
  16. /// 잔액 부족 시 가용분만 회수하고 부족분을 ShortfallAmount 로 기록 (Admin 검토 대상).
  17. /// </summary>
  18. internal sealed class Handler(IAppDbContext db, INotificationService notificationService, IAdminAlertService adminAlertService) : ICommandHandler<Command, Result<Response>>
  19. {
  20. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  21. {
  22. if (string.IsNullOrWhiteSpace(request.OrderID))
  23. {
  24. return Result.Failure<Response>(Error.Problem("Purchase.OrderIDRequired", "orderID 는 필수입니다."));
  25. }
  26. if (request.Marketplace.HasValue && !Enum.IsDefined(typeof(ApiPurchaseMarketplace), request.Marketplace.Value))
  27. {
  28. return Result.Failure<Response>(Error.Problem("Purchase.MarketplaceInvalid", "marketplace 는 1(구글)~6(기타) 값이어야 합니다."));
  29. }
  30. var orderID = request.OrderID.Trim();
  31. var query = db.ApiPurchase.Where(c => c.ApplicationID == request.ApplicationID && c.OrderID == orderID);
  32. if (request.Marketplace.HasValue)
  33. {
  34. var marketplace = (ApiPurchaseMarketplace)request.Marketplace.Value;
  35. query = query.Where(c => c.Marketplace == marketplace);
  36. }
  37. var purchases = await query.ToListAsync(ct);
  38. if (purchases.Count == 0)
  39. {
  40. return Result.Failure<Response>(Error.NotFound("Purchase.NotFound", "등록된 주문을 찾을 수 없습니다."));
  41. }
  42. if (purchases.Count > 1)
  43. {
  44. return Result.Failure<Response>(Error.Problem("Purchase.AmbiguousOrderID", "여러 마켓에 동일한 orderID 가 등록되어 있습니다. marketplace 를 지정하세요."));
  45. }
  46. var purchase = purchases[0];
  47. if (purchase.Status == ApiPurchaseStatus.Canceled)
  48. {
  49. return Result.Failure<Response>(Error.Conflict("Purchase.AlreadyCanceled", "이미 취소된 주문입니다."));
  50. }
  51. var gameName = await db.Game.AsNoTracking().Where(c => c.ID == purchase.GameID).Select(c => c.KorName).FirstOrDefaultAsync(ct) ?? "게임";
  52. var wasConfirmed = purchase.Status == ApiPurchaseStatus.Confirmed;
  53. var recovered = 0;
  54. var shortfall = 0;
  55. if (wasConfirmed)
  56. {
  57. // 확정 후 취소 — 적립 시점 회원 지갑에서 회수
  58. var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == purchase.CreditedMemberID, ct);
  59. if (wallet is null)
  60. {
  61. shortfall = purchase.CommissionAmount;
  62. }
  63. else
  64. {
  65. var available = wallet.GetBalance(WalletBalanceType.StoreRevenue).Value;
  66. recovered = (int)Math.Min(available, purchase.CommissionAmount);
  67. shortfall = purchase.CommissionAmount - recovered;
  68. if (recovered > 0)
  69. {
  70. wallet.DebitApiCommissionRevoke(Money.KRW(recovered), reason: $"{gameName} 결제 취소 회수", refID: $"apipurchase:{purchase.ID}");
  71. }
  72. }
  73. }
  74. purchase.Cancel();
  75. db.ApiPurchaseCancel.Add(ApiPurchaseCancel.Create(purchase, wasConfirmed, recovered, shortfall));
  76. await db.SaveChangesAsync(ct);
  77. var message = wasConfirmed
  78. ? $"「{gameName}」 결제 취소 — 수수료 {recovered:N0}원이 회수되었습니다." + (shortfall > 0 ? $" (잔액 부족 미회수 {shortfall:N0}원)" : string.Empty)
  79. : $"「{gameName}」 결제 취소 — 보류 중이던 수수료 {purchase.CommissionAmount:N0}원이 취소되었습니다.";
  80. await notificationService.SendAsync(
  81. memberID: purchase.CreditedMemberID,
  82. type: NotificationType.ApiCommissionRevoked,
  83. title: "판매 수수료 취소",
  84. message: message,
  85. actionUrl: "/studio/wallet/revenue",
  86. relatedType: "ApiPurchase",
  87. relatedID: purchase.ID,
  88. imageUrl: null,
  89. ct: ct
  90. );
  91. // 운영 알림 — 활동 방 (전 건) + 미회수 발생 시 알림 방
  92. var cancelKind = wasConfirmed ? $"확정 회수 {recovered:N0}원" : "보류 취소";
  93. await adminAlertService.SendActivityAsync($"↩️ 결제 취소 — {gameName} / {purchase.OrderID} / {cancelKind}", ct);
  94. if (shortfall > 0)
  95. {
  96. await adminAlertService.SendAlertAsync($"🚨 미회수 발생 — {gameName} / {purchase.OrderID} / 수수료 {purchase.CommissionAmount:N0}원 중 {shortfall:N0}원 회수 실패 (회원 ID {purchase.CreditedMemberID})", ct);
  97. }
  98. return Result.Success(new Response(
  99. purchase.ID,
  100. purchase.OrderID,
  101. purchase.Status.ToString(),
  102. wasConfirmed,
  103. recovered,
  104. shortfall,
  105. purchase.CanceledAt!.Value
  106. ));
  107. }
  108. }