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 PaperAccountEntity = Domain.Entities.Paper.PaperAccount; using PaperLedgerEntity = Domain.Entities.Paper.PaperLedger; namespace Application.Features.Api.Paper.Deposit; /// /// 모의투자 입금 — 지갑 토큰(Reward → Airdrop) 차감 → 계좌 입금. 계좌 없으면 자동 개설. /// 좌수 발행: 입금 직전 nav = Equity/Units (Units=0 이면 1) → unitsIssued = tokenAmount / nav (d4 §③). /// 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 || !paper.Enabled) { return Result.Failure(Error.Problem("Paper.Disabled", "모의투자가 비활성화되어 있습니다.")); } if (request.TokenAmount <= 0) { return Result.Failure(Error.Problem("Paper.InvalidAmount", "입금액은 0보다 커야 합니다.")); } if (request.TokenAmount < paper.MinDeposit) { return Result.Failure(Error.Problem("Paper.BelowMinDeposit", $"최소 입금 토큰은 {paper.MinDeposit:N0} 입니다.")); } var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct); if (account is null) { account = PaperAccountEntity.Create(request.MemberID); db.PaperAccount.Add(account); } // MaxHolding 검증 (0 = 무제한) if (paper.MaxHolding > 0 && account.Token + account.ReservedToken + request.TokenAmount > paper.MaxHolding) { return Result.Failure(Error.Conflict("Paper.MaxHoldingExceeded", $"계좌 최대 보유 토큰({paper.MaxHolding:N0})을 초과합니다.")); } // 지갑 로드 + 토큰 파티션 잔액 검증 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 tokenAvailable = wallet.GetPaperTokenAvailable(); if (tokenAvailable.Value < request.TokenAmount) { return Result.Failure(Error.Conflict("Wallet.InsufficientToken", $"토큰 잔액이 부족합니다. 필요: {request.TokenAmount:N0} / 보유: {(long)tokenAvailable.Value:N0}.")); } // 입금 직전 nav 계산 (신규 계좌는 Units=0 → nav=1) 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 unitsIssued = request.TokenAmount / nav; var refID = $"paper-deposit:{request.MemberID}:{Guid.NewGuid():N}"; // 지갑 차감 (Reward → Airdrop) + PaperDeposit 트랜잭션 wallet.DebitForPaperDeposit(Money.KRW(request.TokenAmount), reason: "PAPER_DEPOSIT", refID: refID); // 계좌 반영 account.Deposit(request.TokenAmount, unitsIssued); db.PaperLedger.Add(PaperLedgerEntity.CreateFor( account, PaperLedgerType.Deposit, request.TokenAmount, unitsIssued, refID, account.Token)); await db.SaveChangesAsync(ct); return Result.Success(new Response(account.Token, account.Units, unitsIssued)); } }