using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Domain.Entities.Members; namespace Domain.Entities.Paper; /// /// 모의투자 계좌 — 회원당 1개(상시 단일 계좌). 토큰을 입금해 매매, 출금해 회수한다. /// 좌수제(펀드 NAV): 입출금 왜곡 없이 순수 운용 성과를 측정 (d4 §③ 좌수제). /// Equity/nav 등 평가값은 핸들러가 시세로 계산 — 엔티티에 저장하지 않는다. /// public class PaperAccount { [ForeignKey(nameof(MemberID))] public virtual Member Member { get; private set; } = null!; [Key] public int ID { get; private set; } public int MemberID { get; private set; } /// 자유 현금 토큰 (매수 예약 제외) public decimal Token { get; private set; } /// 매수 주문으로 예약된 토큰 public decimal ReservedToken { get; private set; } /// 좌수 (18,8) — 발행/소각으로만 변동, 매매는 좌수 불변 public decimal Units { get; private set; } /// 누적 입금 토큰 public decimal TotalDeposited { get; private set; } /// 누적 출금 토큰 public decimal TotalWithdrawn { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; [Timestamp] public byte[] RowVersion { get; private set; } = default!; private PaperAccount() { } public static PaperAccount Create(int memberID) { if (memberID <= 0) { throw new ArgumentOutOfRangeException(nameof(memberID)); } return new PaperAccount { MemberID = memberID, Token = 0, ReservedToken = 0, Units = 0, TotalDeposited = 0, TotalWithdrawn = 0 }; } /// 입금 — 토큰/좌수 증가. 좌수 발행 계산은 호출자(nav 필요). public void Deposit(decimal tokenAmount, decimal unitsIssued) { if (tokenAmount <= 0) { throw new ArgumentOutOfRangeException(nameof(tokenAmount)); } if (unitsIssued <= 0) { throw new ArgumentOutOfRangeException(nameof(unitsIssued)); } Token += tokenAmount; Units += unitsIssued; TotalDeposited += tokenAmount; } /// 출금 — 자유 현금 토큰만 인출 가능. 좌수 소각. public void Withdraw(decimal tokenAmount, decimal unitsBurned) { if (tokenAmount <= 0) { throw new ArgumentOutOfRangeException(nameof(tokenAmount)); } if (Token < tokenAmount) { throw new InvalidOperationException("출금 가능한 자유 토큰이 부족합니다."); } if (unitsBurned <= 0 || unitsBurned > Units) { throw new ArgumentOutOfRangeException(nameof(unitsBurned)); } Token -= tokenAmount; Units -= unitsBurned; TotalWithdrawn += tokenAmount; } /// 매수 예약 — 자유 토큰을 예약금으로 이동. public void ReserveForBuy(decimal amount) { if (amount <= 0) { throw new ArgumentOutOfRangeException(nameof(amount)); } if (Token < amount) { throw new InvalidOperationException("매수 예약 가능한 토큰이 부족합니다."); } Token -= amount; ReservedToken += amount; } /// 매수 예약 환원 (주문 취소·거부). public void ReleaseBuyReserve(decimal amount) { if (amount <= 0) { throw new ArgumentOutOfRangeException(nameof(amount)); } if (ReservedToken < amount) { throw new InvalidOperationException("환원할 예약금이 부족합니다."); } ReservedToken -= amount; Token += amount; } /// /// 매수 체결 정산 (배치 M2) — 예약금 전액을 해제하고 실제 사용분(actualCost)을 제외한 잔여를 자유 토큰으로 환급한다. /// ReservedToken -= reservedAmount; Token += (reservedAmount - actualCost). 좌수는 불변. /// public void SettleBuy(decimal reservedAmount, decimal actualCost) { if (reservedAmount <= 0) { throw new ArgumentOutOfRangeException(nameof(reservedAmount)); } if (actualCost < 0 || actualCost > reservedAmount) { throw new ArgumentOutOfRangeException(nameof(actualCost)); } if (ReservedToken < reservedAmount) { throw new InvalidOperationException("정산할 예약금이 부족합니다."); } ReservedToken -= reservedAmount; Token += reservedAmount - actualCost; } /// 매도 대금·청산 대금 입금 (배치 M2) — 자유 토큰 증가. 좌수는 불변. public void AddCash(decimal amount) { if (amount < 0) { throw new ArgumentOutOfRangeException(nameof(amount)); } Token += amount; } }