using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Domain.Entities.Developers.ValueObject; using Domain.Entities.Members; using Domain.Entities.Store; namespace Domain.Entities.Developers; /// /// 게임사(파트너 앱)가 공개 API 로 보고한 게임 내 결제 원장 (채널 판매 수수료). /// 등록 시 지갑에 넣지 않고 Pending 보류 — ConfirmDueAt(등록 +14일, 달력일) 도래 시 /// 확정 배치가 채널 소유 회원 지갑(StoreRevenue)에 수수료를 적립한다. /// 보류 중 취소는 지갑 무관, 확정 후 취소만 회수(부족분 shortfall) 대상. /// public class ApiPurchase { /// 수수료 보류 일수 (달력일 — 주말/공휴일 포함) public const int HoldDays = 14; [ForeignKey(nameof(ApplicationID))] public virtual ApiApplication? Application { get; private set; } [ForeignKey(nameof(ChannelID))] public virtual Channel? Channel { get; private set; } [ForeignKey(nameof(GameID))] public virtual Game? Game { get; private set; } [Key] public int ID { get; private set; } public int ApplicationID { get; private set; } public int ChannelID { get; private set; } /// 적립 대상 회원 (등록 시점 채널 소유 회원 스냅샷). 확정 적립/취소 회수 모두 이 회원 지갑에 수행. public int CreditedMemberID { get; private set; } public int GameID { get; private set; } /// 마켓 거래 ID 원문 (Google "GPA.xxxx-xxxx", Apple transactionId 등). (ApplicationID, Marketplace, OrderID) UNIQUE. public string OrderID { get; private set; } = default!; public ApiPurchaseMarketplace Marketplace { get; private set; } /// 인앱 상품 SKU (권장). GameProductCatalog 정가 검증 대상. public string? ProductID { get; private set; } /// 파트너 측 보조 식별자 (선택) public string? SubID { get; private set; } /// 보고된 결제 금액 (KRW) public int OrderPrice { get; private set; } /// 등록 시점 Game.ApiCommissionRate 스냅샷 (%) public decimal CommissionRate { get; private set; } /// 채널 수수료 = Round(OrderPrice × CommissionRate / 100) public int CommissionAmount { get; private set; } /// 카탈로그 hit 시 정가 스냅샷 (SKU 미제공/카탈로그 미등록이면 null) public int? CatalogPrice { get; private set; } /// 카탈로그 정가와 보고가 불일치 — 등록은 통과시키되 Admin 검토 대상 표시 public bool PriceMismatched { get; private set; } public ApiPurchaseStatus Status { get; private set; } = ApiPurchaseStatus.Pending; public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; /// 확정 예정 시각 = CreatedAt + HoldDays public DateTime ConfirmDueAt { get; private set; } public DateTime? ConfirmedAt { get; private set; } public DateTime? CanceledAt { get; private set; } private ApiPurchase() { } public static ApiPurchase Create( int applicationID, int channelID, int creditedMemberID, int gameID, string orderID, ApiPurchaseMarketplace marketplace, int orderPrice, decimal commissionRate, string? productID = null, string? subID = null, int? catalogPrice = null ) { if (applicationID <= 0) { throw new ArgumentOutOfRangeException(nameof(applicationID)); } if (channelID <= 0) { throw new ArgumentOutOfRangeException(nameof(channelID)); } if (creditedMemberID <= 0) { throw new ArgumentOutOfRangeException(nameof(creditedMemberID)); } if (gameID <= 0) { throw new ArgumentOutOfRangeException(nameof(gameID)); } if (string.IsNullOrWhiteSpace(orderID)) { throw new ArgumentException("OrderID is required.", nameof(orderID)); } if (orderID.Length > 255) { throw new ArgumentOutOfRangeException(nameof(orderID), "OrderID 는 255자 이하로 입력하세요."); } if (orderPrice <= 0) { throw new ArgumentOutOfRangeException(nameof(orderPrice), "OrderPrice must be positive."); } if (commissionRate <= 0 || commissionRate > 100) { throw new ArgumentOutOfRangeException(nameof(commissionRate), "0 초과 100 이하의 값이어야 합니다."); } if (productID is not null && productID.Length > 100) { throw new ArgumentOutOfRangeException(nameof(productID), "ProductID 는 100자 이하로 입력하세요."); } if (subID is not null && subID.Length > 100) { throw new ArgumentOutOfRangeException(nameof(subID), "SubID 는 100자 이하로 입력하세요."); } var now = DateTime.UtcNow; return new ApiPurchase { ApplicationID = applicationID, ChannelID = channelID, CreditedMemberID = creditedMemberID, GameID = gameID, OrderID = orderID.Trim(), Marketplace = marketplace, ProductID = NormalizeOptional(productID), SubID = NormalizeOptional(subID), OrderPrice = orderPrice, CommissionRate = commissionRate, CommissionAmount = (int)Math.Round(orderPrice * commissionRate / 100m, MidpointRounding.AwayFromZero), CatalogPrice = catalogPrice, PriceMismatched = catalogPrice.HasValue && catalogPrice.Value != orderPrice, CreatedAt = now, ConfirmDueAt = now.AddDays(HoldDays) }; } /// 보류 → 확정. 호출자(확정 배치)가 지갑 적립과 같은 트랜잭션에서 수행할 것. public void Confirm() { if (Status != ApiPurchaseStatus.Pending) { throw new InvalidOperationException("Pending 상태만 확정할 수 있습니다."); } Status = ApiPurchaseStatus.Confirmed; ConfirmedAt = DateTime.UtcNow; } /// 취소. 확정 후 취소의 지갑 회수는 호출자가 수행하고 ApiPurchaseCancel 로 기록할 것. public void Cancel() { if (Status == ApiPurchaseStatus.Canceled) { throw new InvalidOperationException("이미 취소된 결제입니다."); } Status = ApiPurchaseStatus.Canceled; CanceledAt = DateTime.UtcNow; } private static string? NormalizeOptional(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } return value.Trim(); } }