using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Domain.Entities.Members; using Domain.Entities.Store.ValueObject; namespace Domain.Entities.Store; /// /// 상점 주문 헤더. 단일 결제 = 단일 Order. /// 결제 시점에 수익 배분 (PlatformFee/GameRevenue/ChannelReward) 동결. /// 채널 미선택 주문은 ChannelRewardAmount=0, ChannelID=null. ChannelReward 만큼 PlatformFee 가 증가 (antooza 100%). /// public class Order { [ForeignKey(nameof(MemberID))] public virtual Member? Member { get; private set; } [ForeignKey(nameof(ChannelID))] public virtual Channel? Channel { get; private set; } private readonly List _items = []; public IReadOnlyCollection Items => _items; private readonly List _refunds = []; public IReadOnlyCollection Refunds => _refunds; [Key] public int ID { get; private set; } /// 주문 번호 (예: "20260522-A1B2C3"). UNIQUE. 사용자 표시용. public string OrderNumber { get; private set; } = default!; public int MemberID { get; private set; } /// 선물 수신자 회원 ID (선택). null 이면 본인 구매. 값이 있으면 발급 대상은 수신자, 결제자는 MemberID. public int? GiftToMemberID { get; private set; } /// 선물 메시지 (선택, 최대 200자). public string? GiftMessage { get; private set; } /// 후원 채널 (선택). null 이면 antooza 100% 수익. public int? ChannelID { get; private set; } public int TotalAmount { get; private set; } /// antooza 몫 (= TotalAmount - GameRevenueAmount - ChannelRewardAmount). public int PlatformFeeAmount { get; private set; } /// 게임사 정산 큐 적재 금액 (= TotalAmount × Game.CommissionRate / 100). public int GameRevenueAmount { get; private set; } /// [비활성] 채널 보상 입금 금액 — 채널 보상 적립 제거됨(2026-07-03), 과거 주문 호환용 유지. 신규 주문은 항상 0. public int ChannelRewardAmount { get; private set; } public OrderStatus Status { get; private set; } = OrderStatus.Pending; public string? CancelReason { get; private set; } public string? RefundReason { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; public DateTime? PaidAt { get; private set; } [Timestamp] public byte[] RowVersion { get; private set; } = default!; private Order() { } public static Order Create( string orderNumber, int memberID, int totalAmount, int? channelID, int platformFeeAmount, int gameRevenueAmount, int channelRewardAmount, int? giftToMemberID = null, string? giftMessage = null ) { if (string.IsNullOrWhiteSpace(orderNumber)) { throw new ArgumentException("OrderNumber is required.", nameof(orderNumber)); } if (orderNumber.Length > 20) { throw new ArgumentOutOfRangeException(nameof(orderNumber)); } if (memberID <= 0) { throw new ArgumentOutOfRangeException(nameof(memberID)); } if (giftToMemberID is <= 0) { throw new ArgumentOutOfRangeException(nameof(giftToMemberID)); } if (giftToMemberID == memberID) { throw new ArgumentException("본인에게는 선물할 수 없습니다.", nameof(giftToMemberID)); } var trimmedGiftMessage = string.IsNullOrWhiteSpace(giftMessage) ? null : giftMessage.Trim(); if (trimmedGiftMessage is { Length: > 200 }) { throw new ArgumentOutOfRangeException(nameof(giftMessage), "선물 메시지는 200자 이하여야 합니다."); } if (totalAmount <= 0) { throw new ArgumentOutOfRangeException(nameof(totalAmount)); } if (platformFeeAmount < 0 || gameRevenueAmount < 0 || channelRewardAmount < 0) { throw new ArgumentOutOfRangeException(null, "수익 배분 금액은 음수 불가."); } if (platformFeeAmount + gameRevenueAmount + channelRewardAmount != totalAmount) { throw new ArgumentException("PlatformFee + GameRevenue + ChannelReward 의 합이 TotalAmount 와 일치해야 합니다."); } if (channelID is null && channelRewardAmount > 0) { throw new ArgumentException("채널 미선택 주문은 ChannelRewardAmount=0 이어야 합니다."); } return new Order { OrderNumber = orderNumber, MemberID = memberID, GiftToMemberID = giftToMemberID, GiftMessage = trimmedGiftMessage, ChannelID = channelID, TotalAmount = totalAmount, PlatformFeeAmount = platformFeeAmount, GameRevenueAmount = gameRevenueAmount, ChannelRewardAmount = channelRewardAmount }; } /// 선물 주문 여부. public bool IsGift => GiftToMemberID is not null; /// 아이템 발급 대상 회원 ID (선물이면 수신자, 아니면 구매자). public int RecipientMemberID => GiftToMemberID ?? MemberID; public void AddItem(OrderItem item) { ArgumentNullException.ThrowIfNull(item); _items.Add(item); } public void MarkPaid() { if (Status != OrderStatus.Pending) { throw new InvalidOperationException($"Cannot mark paid from status {Status}."); } Status = OrderStatus.Paid; PaidAt = DateTime.UtcNow; } /// Digital 상품 단독 주문 시 Paid → Delivered 점프. public void MarkDelivered() { if (Status is not OrderStatus.Paid and not OrderStatus.Shipped) { throw new InvalidOperationException($"Cannot mark delivered from status {Status}."); } Status = OrderStatus.Delivered; } public void MarkPreparing() { if (Status != OrderStatus.Paid) { throw new InvalidOperationException($"Cannot mark preparing from status {Status}."); } Status = OrderStatus.Preparing; } public void MarkShipped() { if (Status is not OrderStatus.Paid and not OrderStatus.Preparing) { throw new InvalidOperationException($"Cannot mark shipped from status {Status}."); } Status = OrderStatus.Shipped; } public void Cancel(string reason) { if (Status is not OrderStatus.Pending and not OrderStatus.Paid) { throw new InvalidOperationException($"Cannot cancel from status {Status}."); } if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Cancel reason is required.", nameof(reason)); } Status = OrderStatus.Cancelled; CancelReason = reason; } public void MarkRefunded(string reason) { if (string.IsNullOrWhiteSpace(reason)) { throw new ArgumentException("Refund reason is required.", nameof(reason)); } Status = OrderStatus.Refunded; RefundReason = reason; } public void MarkExchanged() { Status = OrderStatus.Exchanged; } }