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)";
}
}