using Application.Abstractions.Data; using Application.Abstractions.Messaging; using Domain.Entities.Common.ValueObject; using Domain.Entities.Donations; using Domain.Entities.Donations.ValueObject; using Microsoft.EntityFrameworkCore; namespace Application.Features.Api.Donation.Send; internal sealed class Handler(IAppDbContext db) : ICommandHandler { public async Task Handle(Command request, CancellationToken ct) { // 0. ChannelSID → ChannelID 변환 (ChannelSID가 제공된 경우) if (request.ChannelID == 0 && !string.IsNullOrEmpty(request.ChannelSID)) { var resolved = await db.Channel.AsNoTracking() .Where(c => c.SID == request.ChannelSID) .Select(c => c.ID) .FirstOrDefaultAsync(ct); if (resolved == 0) throw new KeyNotFoundException("채널을 찾을 수 없습니다."); request = request with { ChannelID = resolved }; } // 1. 채널 조회 + 수수료율 var channel = await db.Channel .AsNoTracking() .Where(c => c.ID == request.ChannelID && c.IsActive) .Select(c => new { c.ID, c.MemberID, c.PlatformFeeRate }) .FirstOrDefaultAsync(ct); if (channel is null) { throw new KeyNotFoundException("채널을 찾을 수 없습니다."); } // 2. 자기 자신에게 후원 불가 if (request.SponsorMemberID == channel.MemberID && !request.IsTest) { throw new InvalidOperationException("본인 채널에 후원할 수 없습니다."); } // 3. 후원 수락 상태 확인 var meta = await db.DonationMeta .AsNoTracking() .FirstOrDefaultAsync(m => m.ChannelID == request.ChannelID, ct); if (meta is not null && !meta.IsAccepting) { throw new InvalidOperationException("현재 후원을 받지 않는 채널입니다."); } var minAmount = meta?.MinAmount ?? DonationConstants.MinAmount; if (request.Amount < minAmount) { throw new InvalidOperationException($"최소 후원 금액은 {minAmount}원입니다."); } if (request.Amount > DonationConstants.MaxAmount) { throw new InvalidOperationException($"최대 후원 금액은 {DonationConstants.MaxAmount:N0}원입니다."); } // 4. 크루 세션 검증 (크루 후원인 경우) if (request.CrewSessionID.HasValue) { var session = await db.CrewSession .AsNoTracking() .FirstOrDefaultAsync(s => s.ID == request.CrewSessionID.Value && s.Status == CrewSessionStatus.Active, ct); if (session is null) { throw new InvalidOperationException("활성화된 크루 후원 방송이 아닙니다."); } } // 5. 후원자 지갑 조회 + 잔액 차감 var sponsorWallet = await db.Wallet .Include(w => w.Balances) .FirstOrDefaultAsync(w => w.MemberID == request.SponsorMemberID, ct); if (sponsorWallet is null) { throw new KeyNotFoundException("지갑을 찾을 수 없습니다."); } var spendAmount = Money.KRW(request.Amount); var totalAvailable = sponsorWallet.GetTotalAvailable(); if (totalAvailable.Value < spendAmount.Value) { throw new InvalidOperationException("잔액이 부족합니다."); } // 6. Donation 생성 (수수료 스냅샷) var donation = Domain.Entities.Donations.Donation.Create( sponsorMemberID: request.SponsorMemberID, receiverMemberID: channel.MemberID, channelID: channel.ID, amount: request.Amount, feeRate: channel.PlatformFeeRate, message: request.Message, sendName: request.SendName, crewSessionID: request.CrewSessionID, crewMemberID: request.CrewMemberID, isTest: request.IsTest ); db.Donation.Add(donation); // 7. 후원자 지갑 차감 if (!request.IsTest) { sponsorWallet.Spend(spendAmount, "DONATION_OUT", donation.ID.ToString()); } // 8. 수신자 지갑에 입금 (NetAmount) if (!request.IsTest) { var receiverWallet = await db.Wallet .Include(w => w.Balances) .FirstOrDefaultAsync(w => w.MemberID == channel.MemberID, ct); if (receiverWallet is not null) { receiverWallet.CreditDonationIn(Money.KRW(donation.NetAmount), "DONATION_IN", donation.ID.ToString()); } } // SaveChanges로 Donation ID 확보 await db.SaveChangesAsync(ct); // 9. DonationAlert 생성 var alert = Domain.Entities.Donations.DonationAlert.Create( donationID: donation.ID, sponsorMemberID: request.SponsorMemberID, receiverMemberID: channel.MemberID ); db.DonationAlert.Add(alert); // 10. 크루 후원인 경우 집계 갱신 if (request.CrewSessionID.HasValue && request.CrewMemberID.HasValue) { var summary = await db.CrewDonationSummary .FirstOrDefaultAsync(s => s.CrewSessionID == request.CrewSessionID.Value && s.CrewMemberID == request.CrewMemberID.Value, ct); if (summary is null) { summary = CrewDonationSummary.Create(request.CrewSessionID.Value, request.CrewMemberID.Value); db.CrewDonationSummary.Add(summary); } summary.AddDonation(request.Amount); // 세션 전체 합계 갱신 var session = await db.CrewSession.FindAsync([request.CrewSessionID.Value], ct); session?.AddDonation(request.Amount); // 기여도 재계산 if (session is not null && session.TotalAmount > 0) { var allSummaries = await db.CrewDonationSummary .Where(s => s.CrewSessionID == session.ID) .OrderByDescending(s => s.TotalAmount) .ToListAsync(ct); var rank = 1; foreach (var s in allSummaries) { var rate = (decimal)s.TotalAmount / session.TotalAmount * 100; s.UpdateContribution(rate, rank++); } } } await db.SaveChangesAsync(ct); // SignalR Push는 Endpoint 레이어 또는 별도 서비스에서 처리 return new Response(donation.ID, alert.CorrelationID, donation.Amount, donation.FeeAmount, donation.NetAmount); } }