Handler.cs 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  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 PaperAccountEntity = Domain.Entities.Paper.PaperAccount;
  9. using PaperLedgerEntity = Domain.Entities.Paper.PaperLedger;
  10. namespace Application.Features.Api.Paper.Deposit;
  11. /// <summary>
  12. /// 모의투자 입금 — 지갑 토큰(Reward → Airdrop) 차감 → 계좌 입금. 계좌 없으면 자동 개설.
  13. /// 좌수 발행: 입금 직전 nav = Equity/Units (Units=0 이면 1) → unitsIssued = tokenAmount / nav (d4 §③).
  14. /// </summary>
  15. internal sealed class Handler(IAppDbContext db) : ICommandHandler<Command, Result<Response>>
  16. {
  17. public async Task<Result<Response>> Handle(Command request, CancellationToken ct)
  18. {
  19. var paper = await db.Config.AsNoTracking().OrderByDescending(c => c.ID).Select(c => c.Paper).FirstOrDefaultAsync(ct);
  20. if (paper is null || !paper.Enabled)
  21. {
  22. return Result.Failure<Response>(Error.Problem("Paper.Disabled", "모의투자가 비활성화되어 있습니다."));
  23. }
  24. if (request.TokenAmount <= 0)
  25. {
  26. return Result.Failure<Response>(Error.Problem("Paper.InvalidAmount", "입금액은 0보다 커야 합니다."));
  27. }
  28. if (request.TokenAmount < paper.MinDeposit)
  29. {
  30. return Result.Failure<Response>(Error.Problem("Paper.BelowMinDeposit", $"최소 입금 토큰은 {paper.MinDeposit:N0} 입니다."));
  31. }
  32. var account = await db.PaperAccount.FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  33. if (account is null)
  34. {
  35. account = PaperAccountEntity.Create(request.MemberID);
  36. db.PaperAccount.Add(account);
  37. }
  38. // MaxHolding 검증 (0 = 무제한)
  39. if (paper.MaxHolding > 0 && account.Token + account.ReservedToken + request.TokenAmount > paper.MaxHolding)
  40. {
  41. return Result.Failure<Response>(Error.Conflict("Paper.MaxHoldingExceeded", $"계좌 최대 보유 토큰({paper.MaxHolding:N0})을 초과합니다."));
  42. }
  43. // 지갑 로드 + 토큰 파티션 잔액 검증
  44. var wallet = await db.Wallet.Include(c => c.Balances).Include(c => c.Transactions).FirstOrDefaultAsync(c => c.MemberID == request.MemberID, ct);
  45. if (wallet is null)
  46. {
  47. return Result.Failure<Response>(Error.NotFound("Wallet.NotFound", "지갑을 찾을 수 없습니다."));
  48. }
  49. var tokenAvailable = wallet.GetPaperTokenAvailable();
  50. if (tokenAvailable.Value < request.TokenAmount)
  51. {
  52. return Result.Failure<Response>(Error.Conflict("Wallet.InsufficientToken", $"토큰 잔액이 부족합니다. 필요: {request.TokenAmount:N0} / 보유: {(long)tokenAvailable.Value:N0}."));
  53. }
  54. // 입금 직전 nav 계산 (신규 계좌는 Units=0 → nav=1)
  55. var positions = await db.PaperPosition.AsNoTracking()
  56. .Where(c => c.AccountID == account.ID && c.Quantity > 0)
  57. .Select(c => new { c.StockCode, c.Quantity })
  58. .ToListAsync(ct);
  59. var codes = positions.Select(c => c.StockCode).Distinct().ToList();
  60. var latestCloses = await PaperValuation.GetLatestClosesAsync(db, codes, ct);
  61. var equity = PaperValuation.ComputeEquity(account.Token, account.ReservedToken, positions.Select(c => (c.StockCode, c.Quantity)), latestCloses);
  62. var nav = PaperValuation.ComputeNav(equity, account.Units);
  63. var unitsIssued = request.TokenAmount / nav;
  64. var refID = $"paper-deposit:{request.MemberID}:{Guid.NewGuid():N}";
  65. // 지갑 차감 (Reward → Airdrop) + PaperDeposit 트랜잭션
  66. wallet.DebitForPaperDeposit(Money.KRW(request.TokenAmount), reason: "PAPER_DEPOSIT", refID: refID);
  67. // 계좌 반영
  68. account.Deposit(request.TokenAmount, unitsIssued);
  69. db.PaperLedger.Add(PaperLedgerEntity.CreateFor(
  70. account,
  71. PaperLedgerType.Deposit,
  72. request.TokenAmount,
  73. unitsIssued,
  74. refID,
  75. account.Token));
  76. await db.SaveChangesAsync(ct);
  77. return Result.Success(new Response(account.Token, account.Units, unitsIssued));
  78. }
  79. }