using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Application.Helpers; using Domain.Entities.Common.ValueObject; using Domain.Entities.Paper.ValueObject; using Microsoft.EntityFrameworkCore; using SharedKernel.Results; using PaperLedgerEntity = Domain.Entities.Paper.PaperLedger; namespace Application.Features.Api.Paper.Withdraw; /// /// 모의투자 출금 — 계좌 자유 현금 토큰만 인출(예약·포지션 토큰은 불가). 좌수 소각 후 지갑 Reward 로 환급 (d4 §③/§⑤). /// WithdrawProfitBurnBp: 기본 0(밸브 꺼짐). >0 이면 수익분(gain)에만 소각 적용 — /// 근사: proportionalPrincipal = unitsBurned × nav₀(=1) → gain = tokenAmount − unitsBurned, /// burn = floor(max(0, gain) × bp / 10000). (좌수 최초 nav 는 1 이므로 소각좌수가 원금 근사) /// internal sealed class Handler(IAppDbContext db) : ICommandHandler> { public async Task> Handle(Command request, CancellationToken ct) { var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct); if (paper is null) { return Result.Failure(Error.Problem("Paper.ConfigMissing", "모의투자 설정을 찾을 수 없습니다.")); } var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct); if (account is null) { return Result.Failure(Error.NotFound("Paper.NoAccount", "모의투자 계좌가 없습니다.")); } // 출금액 확정 (All = 자유 현금 전액) var tokenAmount = request.All ? account.Token : request.TokenAmount; if (tokenAmount <= 0) { return Result.Failure(Error.Problem("Paper.InvalidAmount", "출금액은 0보다 커야 합니다.")); } // 자유 현금만 인출 가능 — 예약/포지션 토큰은 출금 불가 if (tokenAmount > account.Token) { return Result.Failure(Error.Conflict("Paper.InsufficientFreeToken", $"출금 가능한 자유 토큰은 {(long)account.Token:N0} 입니다. 예약·보유 종목 토큰은 출금할 수 없습니다.")); } // nav 계산 (소각 좌수 산정) var positions = await db.PaperPosition.AsNoTracking() .Where(c => c.AccountID == account.ID && c.Quantity > 0) .Select(c => new { c.StockCode, c.Quantity }) .ToListAsync(ct); var codes = positions.Select(c => c.StockCode).Distinct().ToList(); var latestCloses = await PaperValuation.GetLatestClosesAsync(db, codes, ct); var equity = PaperValuation.ComputeEquity(account.Token, account.ReservedToken, positions.Select(c => (c.StockCode, c.Quantity)), latestCloses); var nav = PaperValuation.ComputeNav(equity, account.Units); var unitsBurned = tokenAmount / nav; if (unitsBurned > account.Units) { unitsBurned = account.Units; } // 수익분 소각 (기본 0) var burn = 0m; if (paper.WithdrawProfitBurnBp > 0) { var gain = tokenAmount - unitsBurned; // nav₀ = 1 기준 원금 근사 if (gain > 0) { burn = Math.Floor(gain * paper.WithdrawProfitBurnBp / 10000m); } } var credited = tokenAmount - burn; // 지갑 로드 var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct); if (wallet is null) { return Result.Failure(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다.")); } var refID = $"paper-withdraw:{request.MemberID}:{Guid.NewGuid():N}"; // 계좌 차감 (자유 토큰/좌수) account.Withdraw(tokenAmount, unitsBurned); // 지갑 Reward 환급 (소각분 제외) — credited > 0 일 때만 if (credited > 0) { wallet.CreditPaperWithdraw(Money.KRW(credited), reason: "PAPER_WITHDRAW", refID: refID); } db.PaperLedger.Add(PaperLedgerEntity.Create( account.ID, PaperLedgerType.Withdraw, tokenAmount, -unitsBurned, refID, account.Token)); await db.SaveChangesAsync(ct); return Result.Success(new Response(account.Token, account.Units, unitsBurned, credited, burn)); } }