using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Abstractions.Notification; using Domain.Entities.Developers; using Domain.Entities.Developers.ValueObject; using Domain.Entities.Notifications.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; namespace Application.Features.Api.V1.Purchases.Register; /// /// 게임사 결제 등록 — 검증 후 Pending 원장 생성 (지갑 미적립). /// 수수료는 ConfirmDueAt(+14일, 달력일) 도래 시 ApiPurchaseConfirmService 가 채널주 StoreRevenue 에 적립. /// SKU 카탈로그 정가와 보고가 불일치 시 PriceMismatched flag (등록은 통과 — Admin 검토 대상). /// internal sealed class Handler(IAppDbContext db, INotificationService notificationService, IAdminAlertService adminAlertService) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { // 1. 입력 검증 if (string.IsNullOrWhiteSpace(request.ChannelCode)) { return Result.Failure(Error.Problem("Purchase.ChannelCodeRequired", "channelCode 는 필수입니다.")); } if (string.IsNullOrWhiteSpace(request.OrderID)) { return Result.Failure(Error.Problem("Purchase.OrderIDRequired", "orderID 는 필수입니다.")); } var orderID = request.OrderID.Trim(); if (orderID.Length > 255) { return Result.Failure(Error.Problem("Purchase.OrderIDTooLong", "orderID 는 255자 이하여야 합니다.")); } if (!Enum.IsDefined(typeof(ApiPurchaseMarketplace), request.Marketplace)) { return Result.Failure(Error.Problem("Purchase.MarketplaceInvalid", "marketplace 는 1(구글)~6(기타) 값이어야 합니다.")); } if (string.IsNullOrWhiteSpace(request.GameCode)) { return Result.Failure(Error.Problem("Purchase.GameCodeRequired", "gameCode 는 필수입니다.")); } if (request.OrderPrice <= 0) { return Result.Failure(Error.Problem("Purchase.OrderPriceInvalid", "orderPrice 는 1 이상이어야 합니다.")); } if (request.ProductID is not null && request.ProductID.Trim().Length > 100) { return Result.Failure(Error.Problem("Purchase.ProductIDTooLong", "productID 는 100자 이하여야 합니다.")); } if (request.SubID is not null && request.SubID.Trim().Length > 100) { return Result.Failure(Error.Problem("Purchase.SubIDTooLong", "subID 는 100자 이하여야 합니다.")); } var marketplace = (ApiPurchaseMarketplace)request.Marketplace; // 2. 앱 상태 (토큰 발급 후 정지되었을 수 있음) var appActive = await db.ApiApplication.AsNoTracking().AnyAsync(c => c.ID == request.ApplicationID && c.Status == ApplicationStatus.Active, ct); if (!appActive) { return Result.Failure(Error.Forbidden("Purchase.AppNotActive", "앱이 활성 상태가 아닙니다.")); } // 3. 채널 (후원 코드 — 대문자 저장) var channelCode = request.ChannelCode.Trim().ToUpperInvariant(); var channel = await db.Channel .AsNoTracking() .Where(c => c.DonationCode == channelCode) .Select(c => new { c.ID, c.MemberID, c.SID, c.Name, c.IsActive, MemberGone = c.Member.IsWithdraw || c.Member.DeletedAt != null }) .FirstOrDefaultAsync(ct); if (channel is null) { return Result.Failure(Error.NotFound("Channel.NotFound", "해당 후원 코드의 채널을 찾을 수 없습니다.")); } if (!channel.IsActive || channel.MemberGone) { return Result.Failure(Error.Problem("Channel.Inactive", "비활성 또는 탈퇴한 채널입니다.")); } // 4. 게임 var gameCode = request.GameCode.Trim(); var game = await db.Game .AsNoTracking() .Where(c => c.Code == gameCode) .Select(c => new { c.ID, c.Code, c.KorName, c.IsActive, c.ApiCommissionRate }) .FirstOrDefaultAsync(ct); if (game is null) { return Result.Failure(Error.NotFound("Game.NotFound", "해당 코드의 게임을 찾을 수 없습니다.")); } if (!game.IsActive) { return Result.Failure(Error.Problem("Game.Inactive", "비활성 게임입니다.")); } if (game.ApiCommissionRate <= 0) { return Result.Failure(Error.Problem("Game.ApiCommissionNotConfigured", "해당 게임은 결제 보고 수수료 대상이 아닙니다.")); } // 5. 중복 방지 — (앱, marketplace, orderID) 멱등 var duplicated = await db.ApiPurchase.AsNoTracking().AnyAsync(c => c.ApplicationID == request.ApplicationID && c.Marketplace == marketplace && c.OrderID == orderID, ct); if (duplicated) { return Result.Failure(Error.Conflict("Purchase.Duplicate", "이미 등록된 주문입니다. (marketplace + orderID 중복)")); } // 6. SKU 카탈로그 대조 (신뢰 레이어 — 미등록 SKU 는 통과) int? catalogPrice = null; var productID = string.IsNullOrWhiteSpace(request.ProductID) ? null : request.ProductID.Trim(); if (productID is not null) { catalogPrice = await db.GameProductCatalog.AsNoTracking().Where(c => c.GameID == game.ID && c.ProductID == productID && c.IsActive).Select(c => (int?)c.Price).FirstOrDefaultAsync(ct); } // 7. 원장 생성 — Pending (지갑 미적립, 확정 배치가 적립) var purchase = ApiPurchase.Create( applicationID: request.ApplicationID, channelID: channel.ID, creditedMemberID: channel.MemberID, gameID: game.ID, orderID: orderID, marketplace: marketplace, orderPrice: request.OrderPrice, commissionRate: game.ApiCommissionRate, productID: productID, subID: request.SubID, catalogPrice: catalogPrice ); db.ApiPurchase.Add(purchase); try { await db.SaveChangesAsync(ct); } catch (DbUpdateException) { // 동시 요청이 unique index (ApplicationID, Marketplace, OrderID) 에 경합한 경우 return Result.Failure(Error.Conflict("Purchase.Duplicate", "이미 등록된 주문입니다. (marketplace + orderID 중복)")); } // 8. 채널주 알림 — 보류 적립 예정 await notificationService.SendAsync( memberID: channel.MemberID, type: NotificationType.ApiCommissionPending, title: "판매 수수료 보류 적립", message: $"「{game.KorName}」 결제 보고 — 수수료 {purchase.CommissionAmount:N0}원이 {purchase.ConfirmDueAt:yyyy-MM-dd} 확정 예정입니다.", actionUrl: "/studio/wallet/revenue", relatedType: "ApiPurchase", relatedID: purchase.ID, imageUrl: null, ct: ct ); // 운영 알림 — 활동 방 (전 건) + 정가 불일치 시 알림 방 var marketLabel = marketplace switch { ApiPurchaseMarketplace.GooglePlay => "구글", ApiPurchaseMarketplace.AppStore => "애플", ApiPurchaseMarketplace.Microsoft => "MS", ApiPurchaseMarketplace.GalaxyStore => "갤럭시", ApiPurchaseMarketplace.OneStore => "원스토어", _ => "기타" }; await adminAlertService.SendActivityAsync($"💳 결제 등록 — {game.KorName} / {channel.Name}({channelCode}) / {purchase.OrderPrice:N0}원 → 수수료 {purchase.CommissionAmount:N0}원 / {marketLabel} / {purchase.OrderID}", ct); if (purchase.PriceMismatched) { await adminAlertService.SendAlertAsync($"⚠️ 정가 불일치 — {game.KorName} SKU {productID}: 보고 {purchase.OrderPrice:N0}원 vs 정가 {catalogPrice:N0}원 / {channel.Name}({channelCode}) / {purchase.OrderID}", ct); } return Result.Success(new Response( purchase.ID, purchase.OrderID, (int)purchase.Marketplace, channelCode, channel.SID, channel.Name, game.Code!, purchase.OrderPrice, purchase.CommissionRate, purchase.CommissionAmount, purchase.Status.ToString(), purchase.ConfirmDueAt, purchase.CreatedAt )); } }