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