ApiPurchase.cs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using Domain.Entities.Developers.ValueObject;
  4. using Domain.Entities.Members;
  5. using Domain.Entities.Store;
  6. namespace Domain.Entities.Developers;
  7. /// <summary>
  8. /// 게임사(파트너 앱)가 공개 API 로 보고한 게임 내 결제 원장 (채널 판매 수수료).
  9. /// 등록 시 지갑에 넣지 않고 Pending 보류 — ConfirmDueAt(등록 +14일, 달력일) 도래 시
  10. /// 확정 배치가 채널 소유 회원 지갑(StoreRevenue)에 수수료를 적립한다.
  11. /// 보류 중 취소는 지갑 무관, 확정 후 취소만 회수(부족분 shortfall) 대상.
  12. /// </summary>
  13. public class ApiPurchase
  14. {
  15. /// <summary>수수료 보류 일수 (달력일 — 주말/공휴일 포함)</summary>
  16. public const int HoldDays = 14;
  17. [ForeignKey(nameof(ApplicationID))]
  18. public virtual ApiApplication? Application { get; private set; }
  19. [ForeignKey(nameof(ChannelID))]
  20. public virtual Channel? Channel { get; private set; }
  21. [ForeignKey(nameof(GameID))]
  22. public virtual Game? Game { get; private set; }
  23. [Key]
  24. public int ID { get; private set; }
  25. public int ApplicationID { get; private set; }
  26. public int ChannelID { get; private set; }
  27. /// <summary>적립 대상 회원 (등록 시점 채널 소유 회원 스냅샷). 확정 적립/취소 회수 모두 이 회원 지갑에 수행.</summary>
  28. public int CreditedMemberID { get; private set; }
  29. public int GameID { get; private set; }
  30. /// <summary>마켓 거래 ID 원문 (Google "GPA.xxxx-xxxx", Apple transactionId 등). (ApplicationID, Marketplace, OrderID) UNIQUE.</summary>
  31. public string OrderID { get; private set; } = default!;
  32. public ApiPurchaseMarketplace Marketplace { get; private set; }
  33. /// <summary>인앱 상품 SKU (권장). GameProductCatalog 정가 검증 대상.</summary>
  34. public string? ProductID { get; private set; }
  35. /// <summary>파트너 측 보조 식별자 (선택)</summary>
  36. public string? SubID { get; private set; }
  37. /// <summary>보고된 결제 금액 (KRW)</summary>
  38. public int OrderPrice { get; private set; }
  39. /// <summary>등록 시점 Game.ApiCommissionRate 스냅샷 (%)</summary>
  40. public decimal CommissionRate { get; private set; }
  41. /// <summary>채널 수수료 = Round(OrderPrice × CommissionRate / 100)</summary>
  42. public int CommissionAmount { get; private set; }
  43. /// <summary>카탈로그 hit 시 정가 스냅샷 (SKU 미제공/카탈로그 미등록이면 null)</summary>
  44. public int? CatalogPrice { get; private set; }
  45. /// <summary>카탈로그 정가와 보고가 불일치 — 등록은 통과시키되 Admin 검토 대상 표시</summary>
  46. public bool PriceMismatched { get; private set; }
  47. public ApiPurchaseStatus Status { get; private set; } = ApiPurchaseStatus.Pending;
  48. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  49. /// <summary>확정 예정 시각 = CreatedAt + HoldDays</summary>
  50. public DateTime ConfirmDueAt { get; private set; }
  51. public DateTime? ConfirmedAt { get; private set; }
  52. public DateTime? CanceledAt { get; private set; }
  53. private ApiPurchase() { }
  54. public static ApiPurchase Create(
  55. int applicationID,
  56. int channelID,
  57. int creditedMemberID,
  58. int gameID,
  59. string orderID,
  60. ApiPurchaseMarketplace marketplace,
  61. int orderPrice,
  62. decimal commissionRate,
  63. string? productID = null,
  64. string? subID = null,
  65. int? catalogPrice = null
  66. ) {
  67. if (applicationID <= 0)
  68. {
  69. throw new ArgumentOutOfRangeException(nameof(applicationID));
  70. }
  71. if (channelID <= 0)
  72. {
  73. throw new ArgumentOutOfRangeException(nameof(channelID));
  74. }
  75. if (creditedMemberID <= 0)
  76. {
  77. throw new ArgumentOutOfRangeException(nameof(creditedMemberID));
  78. }
  79. if (gameID <= 0)
  80. {
  81. throw new ArgumentOutOfRangeException(nameof(gameID));
  82. }
  83. if (string.IsNullOrWhiteSpace(orderID))
  84. {
  85. throw new ArgumentException("OrderID is required.", nameof(orderID));
  86. }
  87. if (orderID.Length > 255)
  88. {
  89. throw new ArgumentOutOfRangeException(nameof(orderID), "OrderID 는 255자 이하로 입력하세요.");
  90. }
  91. if (orderPrice <= 0)
  92. {
  93. throw new ArgumentOutOfRangeException(nameof(orderPrice), "OrderPrice must be positive.");
  94. }
  95. if (commissionRate <= 0 || commissionRate > 100)
  96. {
  97. throw new ArgumentOutOfRangeException(nameof(commissionRate), "0 초과 100 이하의 값이어야 합니다.");
  98. }
  99. if (productID is not null && productID.Length > 100)
  100. {
  101. throw new ArgumentOutOfRangeException(nameof(productID), "ProductID 는 100자 이하로 입력하세요.");
  102. }
  103. if (subID is not null && subID.Length > 100)
  104. {
  105. throw new ArgumentOutOfRangeException(nameof(subID), "SubID 는 100자 이하로 입력하세요.");
  106. }
  107. var now = DateTime.UtcNow;
  108. return new ApiPurchase
  109. {
  110. ApplicationID = applicationID,
  111. ChannelID = channelID,
  112. CreditedMemberID = creditedMemberID,
  113. GameID = gameID,
  114. OrderID = orderID.Trim(),
  115. Marketplace = marketplace,
  116. ProductID = NormalizeOptional(productID),
  117. SubID = NormalizeOptional(subID),
  118. OrderPrice = orderPrice,
  119. CommissionRate = commissionRate,
  120. CommissionAmount = (int)Math.Round(orderPrice * commissionRate / 100m, MidpointRounding.AwayFromZero),
  121. CatalogPrice = catalogPrice,
  122. PriceMismatched = catalogPrice.HasValue && catalogPrice.Value != orderPrice,
  123. CreatedAt = now,
  124. ConfirmDueAt = now.AddDays(HoldDays)
  125. };
  126. }
  127. /// <summary>보류 → 확정. 호출자(확정 배치)가 지갑 적립과 같은 트랜잭션에서 수행할 것.</summary>
  128. public void Confirm()
  129. {
  130. if (Status != ApiPurchaseStatus.Pending)
  131. {
  132. throw new InvalidOperationException("Pending 상태만 확정할 수 있습니다.");
  133. }
  134. Status = ApiPurchaseStatus.Confirmed;
  135. ConfirmedAt = DateTime.UtcNow;
  136. }
  137. /// <summary>취소. 확정 후 취소의 지갑 회수는 호출자가 수행하고 ApiPurchaseCancel 로 기록할 것.</summary>
  138. public void Cancel()
  139. {
  140. if (Status == ApiPurchaseStatus.Canceled)
  141. {
  142. throw new InvalidOperationException("이미 취소된 결제입니다.");
  143. }
  144. Status = ApiPurchaseStatus.Canceled;
  145. CanceledAt = DateTime.UtcNow;
  146. }
  147. private static string? NormalizeOptional(string? value)
  148. {
  149. if (string.IsNullOrWhiteSpace(value))
  150. {
  151. return null;
  152. }
  153. return value.Trim();
  154. }
  155. }