Handler.cs 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. using Application.Abstractions.Data;
  2. using Application.Abstractions.Messaging;
  3. using Application.Helpers;
  4. using Domain.Entities.Common.ValueObject;
  5. using Domain.Entities.Paper.ValueObject;
  6. using Microsoft.EntityFrameworkCore;
  7. using SharedKernel.Results;
  8. using PaperLedgerEntity = Domain.Entities.Paper.PaperLedger;
  9. namespace Application.Features.Api.Paper.Withdraw;
  10. /// <summary>
  11. /// 모의투자 출금 — 계좌 자유 현금 토큰만 인출(예약·포지션 토큰은 불가). 좌수 소각 후 지갑 Reward 로 환급 (d4 §③/§⑤).
  12. /// WithdrawProfitBurnBp: 기본 0(밸브 꺼짐). >0 이면 수익분(gain)에만 소각 적용 —
  13. /// 근사: proportionalPrincipal = unitsBurned × nav₀(=1) → gain = tokenAmount − unitsBurned,
  14. /// burn = floor(max(0, gain) × bp / 10000). (좌수 최초 nav 는 1 이므로 소각좌수가 원금 근사)
  15. /// </summary>
  16. internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  17. {
  18. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  19. {
  20. var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct);
  21. if (paper is null)
  22. {
  23. return Result.Failure<Response>(Error.Problem("Paper.ConfigMissing", "모의투자 설정을 찾을 수 없습니다."));
  24. }
  25. var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  26. if (account is null)
  27. {
  28. return Result.Failure<Response>(Error.NotFound("Paper.NoAccount", "모의투자 계좌가 없습니다."));
  29. }
  30. // 출금액 확정 (All = 자유 현금 전액)
  31. var tokenAmount = request.All ? account.Token : request.TokenAmount;
  32. if (tokenAmount <= 0)
  33. {
  34. return Result.Failure<Response>(Error.Problem("Paper.InvalidAmount", "출금액은 0보다 커야 합니다."));
  35. }
  36. // 자유 현금만 인출 가능 — 예약/포지션 토큰은 출금 불가
  37. if (tokenAmount > account.Token)
  38. {
  39. return Result.Failure<Response>(Error.Conflict("Paper.InsufficientFreeToken", $"출금 가능한 자유 토큰은 {(long)account.Token:N0} 입니다. 예약·보유 종목 토큰은 출금할 수 없습니다."));
  40. }
  41. // nav 계산 (소각 좌수 산정)
  42. var positions = await db.PaperPosition.AsNoTracking()
  43. .Where(c => c.AccountID == account.ID && c.Quantity > 0)
  44. .Select(c => new { c.StockCode, c.Quantity })
  45. .ToListAsync(ct);
  46. var codes = positions.Select(c => c.StockCode).Distinct().ToList();
  47. var latestCloses = await PaperValuation.GetLatestClosesAsync(db, codes, ct);
  48. var equity = PaperValuation.ComputeEquity(account.Token, account.ReservedToken, positions.Select(c => (c.StockCode, c.Quantity)), latestCloses);
  49. var nav = PaperValuation.ComputeNav(equity, account.Units);
  50. var unitsBurned = tokenAmount / nav;
  51. if (unitsBurned > account.Units)
  52. {
  53. unitsBurned = account.Units;
  54. }
  55. // 수익분 소각 (기본 0)
  56. var burn = 0m;
  57. if (paper.WithdrawProfitBurnBp > 0)
  58. {
  59. var gain = tokenAmount - unitsBurned; // nav₀ = 1 기준 원금 근사
  60. if (gain > 0)
  61. {
  62. burn = Math.Floor(gain * paper.WithdrawProfitBurnBp / 10000m);
  63. }
  64. }
  65. var credited = tokenAmount - burn;
  66. // 지갑 로드
  67. var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  68. if (wallet is null)
  69. {
  70. return Result.Failure<Response>(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다."));
  71. }
  72. var refID = $"paper-withdraw:{request.MemberID}:{Guid.NewGuid():N}";
  73. // 계좌 차감 (자유 토큰/좌수)
  74. account.Withdraw(tokenAmount, unitsBurned);
  75. // 지갑 Reward 환급 (소각분 제외) — credited > 0 일 때만
  76. if (credited > 0)
  77. {
  78. wallet.CreditPaperWithdraw(Money.KRW(credited), reason: "PAPER_WITHDRAW", refID: refID);
  79. }
  80. db.PaperLedger.Add(PaperLedgerEntity.Create(
  81. account.ID,
  82. PaperLedgerType.Withdraw,
  83. tokenAmount,
  84. -unitsBurned,
  85. refID,
  86. account.Token));
  87. await db.SaveChangesAsync(ct);
  88. return Result.Success(new Response(account.Token, account.Units, unitsBurned, credited, burn));
  89. }
  90. }