using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Domain.Entities.Common.ValueObject; using Domain.Entities.Store.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; using OrderEntity = Domain.Entities.Store.Order; using OrderItemEntity = Domain.Entities.Store.OrderItem; using GameSettlementEntity = Domain.Entities.Store.GameSettlement; using MemberInventoryEntity = Domain.Entities.Store.MemberInventory; namespace Application.Features.Api.Store.Orders.Place; /// /// 상점 결제 핸들러 — 사용자 정책 핵심: /// • PgCharged(다날 충전 예치금) 잔액에서만 차감 /// • 게임사 몫은 GameSettlement 큐에 적재 (월별 정산) /// • Digital 상품은 결제 즉시 쿠폰 코드 발급 + MemberInventory 등록 + Delivered 점프 /// • Physical 상품은 Paid 상태로 머무름 (이후 배송 단계 진행) /// internal sealed class Handler(IAppDbContext db) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { // 0. 입력 검증 if (request.Items is null || request.Items.Count == 0) { return Result.Failure(Error.Problem("Order.NoItems", "주문할 상품이 없습니다.")); } if (request.Items.Any(i => i.Quantity <= 0)) { return Result.Failure(Error.Problem("Order.InvalidQuantity", "수량은 1 이상이어야 합니다.")); } // 1. 채널 검증 (선택된 경우) — 채널 보상 적립은 제거됨(2026-07-03), 채널 귀속 스냅샷만 유지 if (request.ChannelID.HasValue) { var channel = await db.Channel.AsNoTracking() .Where(c => c.ID == request.ChannelID.Value && c.IsActive) .Select(c => new { c.ID, c.MemberID }) .FirstOrDefaultAsync(ct); if (channel is null) { return Result.Failure(Error.NotFound("Order.ChannelNotFound", "후원 채널을 찾을 수 없습니다.")); } // 본인 채널 선택 금지 (기존 정책 유지) if (channel.MemberID == request.MemberID) { return Result.Failure(Error.Problem("Order.SelfChannelNotAllowed", "본인 채널은 후원 코드로 선택할 수 없습니다.")); } } // 2. 상품 로드 (Include Game) var productIDs = request.Items.Select(i => i.ProductID).Distinct().ToList(); var products = await db.Product .Include(p => p.Game) .Where(p => productIDs.Contains(p.ID)) .ToListAsync(ct); if (products.Count != productIDs.Count) { return Result.Failure(Error.NotFound("Order.ProductNotFound", "일부 상품을 찾을 수 없습니다.")); } var now = DateTime.UtcNow; foreach (var p in products) { if (!p.IsActive) { return Result.Failure(Error.Problem("Order.ProductInactive", $"비활성 상품입니다: {p.Name}")); } if (p.SaleStartAt.HasValue && p.SaleStartAt.Value > now) { return Result.Failure(Error.Problem("Order.NotYetOnSale", $"판매 시작 전 상품입니다: {p.Name}")); } if (p.SaleEndAt.HasValue && p.SaleEndAt.Value <= now) { return Result.Failure(Error.Problem("Order.SaleEnded", $"판매 종료된 상품입니다: {p.Name}")); } } // 2.5 후원 채널 필수 상품 검증 — 하나라도 필수면 ChannelID 가 반드시 있어야 함 (프런트 우회 방어) if (!request.ChannelID.HasValue && products.Any(p => p.RequireDonationChannel)) { return Result.Failure(Error.Problem("Order.DonationChannelRequired", "후원 채널 선택이 필수인 상품이 포함되어 있습니다. 후원 채널을 선택해 주세요.")); } // 3. 라인별 합계 + 게임별 그룹화 (수익 배분용) var lineTotals = new Dictionary(); // ProductID -> Quantity (입력 중복 합산) foreach (var item in request.Items) { lineTotals[item.ProductID] = lineTotals.GetValueOrDefault(item.ProductID) + item.Quantity; } var productByID = products.ToDictionary(p => p.ID); var totalAmount = 0; int gameRevenueTotal = 0; var perGameRevenue = new Dictionary(); // GameID -> 누적 GameRevenueAmount (정산 큐 적재용) foreach (var (productID, qty) in lineTotals) { var p = productByID[productID]; if (qty < p.MinPurchase) { return Result.Failure(Error.Problem("Order.BelowMinPurchase", $"{p.Name}: 최소 {p.MinPurchase}개 이상 주문해야 합니다.")); } if (qty > p.MaxPurchase) { return Result.Failure(Error.Problem("Order.AboveMaxPurchase", $"{p.Name}: 최대 {p.MaxPurchase}개까지 주문 가능합니다.")); } // 할인 반영 — 서버 기준 GetSalePrice() 사용 (정가 p.Price 가 아님) var unitPrice = p.GetSalePrice(); var lineAmount = checked(unitPrice * qty); totalAmount = checked(totalAmount + lineAmount); // 게임별 수익 배분 (스냅샷) var gameCommission = p.Game!.CommissionRate; var gameRevenue = (int)Math.Floor(lineAmount * gameCommission / 100m); gameRevenueTotal = checked(gameRevenueTotal + gameRevenue); perGameRevenue[p.GameID] = perGameRevenue.GetValueOrDefault(p.GameID) + gameRevenue; } var platformFeeTotal = checked(totalAmount - gameRevenueTotal); if (platformFeeTotal < 0) { return Result.Failure(Error.Problem("Order.NegativeFee", "수익 배분 계산에 실패했습니다 (PlatformFee < 0).")); } // 3.5 판매 정책 검증 — 회원당 누적 구매 수량 제한 (ProductLimitConfig) var limitProductIDs = lineTotals.Keys.ToList(); var limitConfigs = await db.ProductLimitConfig.AsNoTracking() .Where(c => limitProductIDs.Contains(c.ProductID)) .ToDictionaryAsync(c => c.ProductID, ct); foreach (var (productID, qty) in lineTotals) { if (!limitConfigs.TryGetValue(productID, out var cfg)) { continue; } if (!cfg.UseQuantityLimit || cfg.MaxQuantity <= 0) { continue; } var product = productByID[productID]; var windowStart = cfg.GetWindowStart(now); var windowEnd = cfg.GetWindowEnd(now); var pastQtyQuery = db.OrderItem.AsNoTracking() .Where(oi => oi.ProductID == productID && oi.Order!.MemberID == request.MemberID && oi.Order.Status != Domain.Entities.Store.ValueObject.OrderStatus.Cancelled && oi.Order.Status != Domain.Entities.Store.ValueObject.OrderStatus.Refunded); if (windowStart.HasValue) { var ws = windowStart.Value; pastQtyQuery = pastQtyQuery.Where(oi => oi.Order!.CreatedAt >= ws); } if (windowEnd.HasValue) { var we = windowEnd.Value; pastQtyQuery = pastQtyQuery.Where(oi => oi.Order!.CreatedAt < we); } var pastQty = await pastQtyQuery.SumAsync(oi => oi.Quantity, ct); if (pastQty + qty > cfg.MaxQuantity) { var windowDesc = (windowStart.HasValue || windowEnd.HasValue) ? GetWindowLabel(cfg.PeriodType, cfg.PeriodStartAt, cfg.PeriodEndAt) : "전체 기간"; return Result.Failure(Error.Problem("Order.LimitExceeded", $"{product.Name}: 회원당 최대 {cfg.MaxQuantity}개까지 구매 가능합니다 ({windowDesc}). 누적 {pastQty}개 / 신규 {qty}개")); } } // 4. 구매자 지갑 로드 + PgCharged 잔액 검증 var buyerWallet = await db.Wallet .Include(w => w.Balances) .Include(w => w.Transactions) .FirstOrDefaultAsync(w => w.MemberID == request.MemberID, ct); if (buyerWallet is null) { return Result.Failure(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다.")); } var storeAvailable = buyerWallet.GetStoreOrderAvailable(); if (storeAvailable.Value < totalAmount) { return Result.Failure(Error.Problem("Wallet.InsufficientBalance", $"잔액이 부족합니다. 필요: {totalAmount:N0}원 / 사용 가능: {(int)storeAvailable.Value:N0}원.")); } // 5. 재고 차감 (RowVersion 동시성 보호 — SaveChanges 가 자동 처리) foreach (var (productID, qty) in lineTotals) { productByID[productID].DecreaseStock(qty); } // 6. Digital 상품 — 쿠폰 코드 점유 (Available → Issued, 직접 발급) var digitalLineCodes = new Dictionary>(); // ProductID -> 발급된 코드 리스트 foreach (var (productID, qty) in lineTotals) { var product = productByID[productID]; if (product.Type != ProductType.Digital) { continue; } // Digital 상품에 연결된 Coupon 1건 (1:1 가정 — 첫 번째 활성 쿠폰 사용) var coupon = await db.Coupon.Where(c => c.ProductID == productID).FirstOrDefaultAsync(ct); if (coupon is null) { return Result.Failure(Error.Problem("Order.NoCouponForDigital", $"상품에 쿠폰이 등록되지 않았습니다: {product.Name}")); } // 사용 가능한 코드 qty 개 점유 var availableCodes = await db.CouponCode .Where(c => c.CouponID == coupon.ID && c.Status == CouponCodeStatus.Available) .OrderBy(c => c.ID) .Take(qty) .ToListAsync(ct); if (availableCodes.Count < qty) { return Result.Failure(Error.Problem("Order.InsufficientCouponCodes", $"쿠폰 코드가 부족합니다 ({product.Name} 잔여 {availableCodes.Count}개, 필요 {qty}개)")); } foreach (var code in availableCodes) { code.Reserve(); code.Issue(request.MemberID); } digitalLineCodes[productID] = availableCodes; } // 7. Order 생성 var orderNumber = GenerateOrderNumber(); var order = OrderEntity.Create( orderNumber: orderNumber, memberID: request.MemberID, totalAmount: totalAmount, channelID: request.ChannelID, platformFeeAmount: platformFeeTotal, gameRevenueAmount: gameRevenueTotal, channelRewardAmount: 0 ); // OrderItem 추가 (각 라인별) bool hasPhysical = false; bool hasDigital = false; foreach (var (productID, qty) in lineTotals) { var product = productByID[productID]; // OrderItem 의 unitPrice 는 결제 시점의 할인된 단가 (영수증/환불 일관성) var orderItem = OrderItemEntity.Create(productID, qty, product.GetSalePrice()); if (product.Type == ProductType.Digital) { hasDigital = true; } else { hasPhysical = true; } order.AddItem(orderItem); } order.MarkPaid(); // Digital 단독 주문이면 즉시 Delivered 점프 if (hasDigital && !hasPhysical) { order.MarkDelivered(); } db.Order.Add(order); // 8. 구매자 잔액 차감 (SpendPolicy.StoreOrderSpendOrder 순서로 분할, 출처별 트랜잭션 기록) buyerWallet.DebitForStoreOrder(Money.KRW(totalAmount), reason: "ORDER_PAY", refID: orderNumber); // 10. GameSettlement 큐 적재 (월별) var year = now.Year; var month = now.Month; foreach (var (gameID, revenue) in perGameRevenue) { if (revenue <= 0) { continue; } var settlement = await db.GameSettlement .FirstOrDefaultAsync(s => s.GameID == gameID && s.Year == year && s.Month == month, ct); if (settlement is null) { settlement = GameSettlementEntity.Create(gameID, year, month); db.GameSettlement.Add(settlement); } settlement.AddRevenue(revenue); } // 11. 1차 저장 — Order.ID, OrderItem.ID 확보 await db.SaveChangesAsync(ct); // 12. Digital 상품: MemberInventory + OrderItem.IssuedCouponCodeID 연결 if (hasDigital) { foreach (var orderItem in order.Items) { var product = productByID[orderItem.ProductID]; if (product.Type != ProductType.Digital) { continue; } if (!digitalLineCodes.TryGetValue(product.ID, out var codes)) { continue; } // 첫 코드를 OrderItem 에 연결, 모든 코드를 MemberInventory 에 등록 if (codes.Count > 0) { orderItem.AssignCouponCode(codes[0].ID); } foreach (var code in codes) { DateTime? expiresAt = null; var coupon = await db.Coupon.AsNoTracking().FirstOrDefaultAsync(c => c.ID == code.CouponID, ct); if (coupon is not null) { expiresAt = coupon.ExpiresAt; } var inv = MemberInventoryEntity.Create(request.MemberID, orderItem.ID, code.ID, expiresAt); db.MemberInventory.Add(inv); } } await db.SaveChangesAsync(ct); } return Result.Success(new Response( OrderID: order.ID, OrderNumber: orderNumber, TotalAmount: totalAmount, PlatformFeeAmount: platformFeeTotal, GameRevenueAmount: gameRevenueTotal, ChannelRewardAmount: 0, HasPhysical: hasPhysical, HasDigital: hasDigital )); } private static string GenerateOrderNumber() { var date = DateTime.UtcNow.ToString("yyyyMMdd"); var random = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant(); return $"{date}-{random}"; } private static string GetWindowLabel( Domain.Entities.Store.ValueObject.LimitPeriodType type, DateTime? periodStartAt, DateTime? periodEndAt ) => type switch { Domain.Entities.Store.ValueObject.LimitPeriodType.Day => "최근 1일", Domain.Entities.Store.ValueObject.LimitPeriodType.Week => "최근 1주", Domain.Entities.Store.ValueObject.LimitPeriodType.Month => "최근 30일", Domain.Entities.Store.ValueObject.LimitPeriodType.Custom => FormatCustomRange(periodStartAt, periodEndAt), _ => "전체 기간" }; private static string FormatCustomRange(DateTime? startAt, DateTime? endAt) { if (!startAt.HasValue || !endAt.HasValue) { return "지정 기간"; } return $"{startAt.Value:yyyy-MM-dd HH:mm:ss} ~ {endAt.Value:yyyy-MM-dd HH:mm:ss} (UTC)"; } }