using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Notification; using Domain.Entities.Common.ValueObject; using Domain.Entities.Developers; using Domain.Entities.Developers.ValueObject; using Domain.Entities.Notifications.ValueObject; using Domain.Entities.Wallets.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.V1.Purchases.Cancel; /// /// 게임사 결제 취소 — 등록한 앱만 가능. /// 보류(Pending) 중 취소: 지갑 무관, 상태만 Canceled (shortfall 없음). /// 확정(Confirmed) 후 취소: 적립 시점 회원(CreditedMemberID)의 StoreRevenue 에서 회수, /// 잔액 부족 시 가용분만 회수하고 부족분을 ShortfallAmount 로 기록 (Admin 검토 대상). /// internal sealed class Handler(IAppDbContext db, INotificationService notificationService, IAdminAlertService adminAlertService) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.OrderID)) { return Result.Failure(Error.Problem("Purchase.OrderIDRequired", "orderID 는 필수입니다.")); } if (request.Marketplace.HasValue && !Enum.IsDefined(typeof(ApiPurchaseMarketplace), request.Marketplace.Value)) { return Result.Failure(Error.Problem("Purchase.MarketplaceInvalid", "marketplace 는 1(구글)~6(기타) 값이어야 합니다.")); } var orderID = request.OrderID.Trim(); var query = db.ApiPurchase.Where(c => c.ApplicationID == request.ApplicationID && c.OrderID == orderID); if (request.Marketplace.HasValue) { var marketplace = (ApiPurchaseMarketplace)request.Marketplace.Value; query = query.Where(c => c.Marketplace == marketplace); } var purchases = await query.ToListAsync(ct); if (purchases.Count == 0) { return Result.Failure(Error.NotFound("Purchase.NotFound", "등록된 주문을 찾을 수 없습니다.")); } if (purchases.Count > 1) { return Result.Failure(Error.Problem("Purchase.AmbiguousOrderID", "여러 마켓에 동일한 orderID 가 등록되어 있습니다. marketplace 를 지정하세요.")); } var purchase = purchases[0]; if (purchase.Status == ApiPurchaseStatus.Canceled) { return Result.Failure(Error.Conflict("Purchase.AlreadyCanceled", "이미 취소된 주문입니다.")); } var gameName = await db.Game.AsNoTracking().Where(c => c.ID == purchase.GameID).Select(c => c.KorName).FirstOrDefaultAsync(ct) ?? "게임"; var wasConfirmed = purchase.Status == ApiPurchaseStatus.Confirmed; var recovered = 0; var shortfall = 0; if (wasConfirmed) { // 확정 후 취소 — 적립 시점 회원 지갑에서 회수 var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == purchase.CreditedMemberID, ct); if (wallet is null) { shortfall = purchase.CommissionAmount; } else { var available = wallet.GetBalance(WalletBalanceType.StoreRevenue).Value; recovered = (int)Math.Min(available, purchase.CommissionAmount); shortfall = purchase.CommissionAmount - recovered; if (recovered > 0) { wallet.DebitApiCommissionRevoke(Money.KRW(recovered), reason: $"{gameName} 결제 취소 회수", refID: $"apipurchase:{purchase.ID}"); } } } purchase.Cancel(); db.ApiPurchaseCancel.Add(ApiPurchaseCancel.Create(purchase, wasConfirmed, recovered, shortfall)); await db.SaveChangesAsync(ct); var message = wasConfirmed ? $"「{gameName}」 결제 취소 — 수수료 {recovered:N0}원이 회수되었습니다." + (shortfall > 0 ? $" (잔액 부족 미회수 {shortfall:N0}원)" : string.Empty) : $"「{gameName}」 결제 취소 — 보류 중이던 수수료 {purchase.CommissionAmount:N0}원이 취소되었습니다."; await notificationService.SendAsync( memberID: purchase.CreditedMemberID, type: NotificationType.ApiCommissionRevoked, title: "판매 수수료 취소", message: message, actionUrl: "/studio/wallet/revenue", relatedType: "ApiPurchase", relatedID: purchase.ID, imageUrl: null, ct: ct ); // 운영 알림 — 활동 방 (전 건) + 미회수 발생 시 알림 방 var cancelKind = wasConfirmed ? $"확정 회수 {recovered:N0}원" : "보류 취소"; await adminAlertService.SendActivityAsync($"↩️ 결제 취소 — {gameName} / {purchase.OrderID} / {cancelKind}", ct); if (shortfall > 0) { await adminAlertService.SendAlertAsync($"🚨 미회수 발생 — {gameName} / {purchase.OrderID} / 수수료 {purchase.CommissionAmount:N0}원 중 {shortfall:N0}원 회수 실패 (회원 ID {purchase.CreditedMemberID})", ct); } return Result.Success(new Response( purchase.ID, purchase.OrderID, purchase.Status.ToString(), wasConfirmed, recovered, shortfall, purchase.CanceledAt!.Value )); } }