| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198 |
- 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;
- /// <summary>
- /// 게임사(파트너 앱)가 공개 API 로 보고한 게임 내 결제 원장 (채널 판매 수수료).
- /// 등록 시 지갑에 넣지 않고 Pending 보류 — ConfirmDueAt(등록 +14일, 달력일) 도래 시
- /// 확정 배치가 채널 소유 회원 지갑(StoreRevenue)에 수수료를 적립한다.
- /// 보류 중 취소는 지갑 무관, 확정 후 취소만 회수(부족분 shortfall) 대상.
- /// </summary>
- public class ApiPurchase
- {
- /// <summary>수수료 보류 일수 (달력일 — 주말/공휴일 포함)</summary>
- 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; }
- /// <summary>적립 대상 회원 (등록 시점 채널 소유 회원 스냅샷). 확정 적립/취소 회수 모두 이 회원 지갑에 수행.</summary>
- public int CreditedMemberID { get; private set; }
- public int GameID { get; private set; }
- /// <summary>마켓 거래 ID 원문 (Google "GPA.xxxx-xxxx", Apple transactionId 등). (ApplicationID, Marketplace, OrderID) UNIQUE.</summary>
- public string OrderID { get; private set; } = default!;
- public ApiPurchaseMarketplace Marketplace { get; private set; }
- /// <summary>인앱 상품 SKU (권장). GameProductCatalog 정가 검증 대상.</summary>
- public string? ProductID { get; private set; }
- /// <summary>파트너 측 보조 식별자 (선택)</summary>
- public string? SubID { get; private set; }
- /// <summary>보고된 결제 금액 (KRW)</summary>
- public int OrderPrice { get; private set; }
- /// <summary>등록 시점 Game.ApiCommissionRate 스냅샷 (%)</summary>
- public decimal CommissionRate { get; private set; }
- /// <summary>채널 수수료 = Round(OrderPrice × CommissionRate / 100)</summary>
- public int CommissionAmount { get; private set; }
- /// <summary>카탈로그 hit 시 정가 스냅샷 (SKU 미제공/카탈로그 미등록이면 null)</summary>
- public int? CatalogPrice { get; private set; }
- /// <summary>카탈로그 정가와 보고가 불일치 — 등록은 통과시키되 Admin 검토 대상 표시</summary>
- public bool PriceMismatched { get; private set; }
- public ApiPurchaseStatus Status { get; private set; } = ApiPurchaseStatus.Pending;
- public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
- /// <summary>확정 예정 시각 = CreatedAt + HoldDays</summary>
- 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)
- };
- }
- /// <summary>보류 → 확정. 호출자(확정 배치)가 지갑 적립과 같은 트랜잭션에서 수행할 것.</summary>
- public void Confirm()
- {
- if (Status != ApiPurchaseStatus.Pending)
- {
- throw new InvalidOperationException("Pending 상태만 확정할 수 있습니다.");
- }
- Status = ApiPurchaseStatus.Confirmed;
- ConfirmedAt = DateTime.UtcNow;
- }
- /// <summary>취소. 확정 후 취소의 지갑 회수는 호출자가 수행하고 ApiPurchaseCancel 로 기록할 것.</summary>
- 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();
- }
- }
|