using System.Security.Claims; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; using ROLAC.API.Entities; namespace ROLAC.API.Services; public class ExpenseSnapshotService : IExpenseSnapshotService { private readonly AppDbContext _db; private readonly IHttpContextAccessor _http; public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; } // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false), // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it), // then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production. private string CurrentUserId => _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? _http.HttpContext?.User.FindFirstValue("sub") ?? "system"; public async Task> GetAllAsync() { var snaps = await _db.ExpenseSnapshots.AsNoTracking() .OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id) .ToListAsync(); if (snaps.Count == 0) return new(); var ids = snaps.Select(s => s.Id).ToList(); var lines = await _db.ExpenseSnapshotLines.AsNoTracking() .Where(l => ids.Contains(l.SnapshotId)).ToListAsync(); var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList()); var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}"); var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy)); return snaps.Select(s => { linesBySnapshot.TryGetValue(s.Id, out var ls); return new ExpenseSnapshotDto { Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""), Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes, TotalAmount = ls?.Sum(l => l.Amount) ?? 0, LineCount = ls?.Count ?? 0, CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy), CreatedAt = s.CreatedAt, }; }).ToList(); } public async Task GetByIdAsync(int id) { var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); if (s is null) return null; var lines = await _db.ExpenseSnapshotLines.AsNoTracking() .Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync(); var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? ""; var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en); var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en); var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy); return new ExpenseSnapshotDto { Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName, Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes, TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count, CreatedByName = creatorName, CreatedAt = s.CreatedAt, Lines = lines.Select(l => new ExpenseSnapshotLineDto { CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""), SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""), FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description, }).ToList(), }; } public async Task CreateAsync(CreateExpenseSnapshotRequest r) { ValidateLines(r.Lines); var s = new ExpenseSnapshot { Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description, VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes, Lines = BuildLines(r.Lines), }; _db.ExpenseSnapshots.Add(s); await _db.SaveChangesAsync(); return s.Id; } public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r) { ValidateLines(r.Lines); var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Snapshot {id} not found."); s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description; s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes; _db.ExpenseSnapshotLines.RemoveRange(s.Lines); s.Lines = BuildLines(r.Lines); await _db.SaveChangesAsync(); } public async Task DeleteAsync(int id) { var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Snapshot {id} not found."); s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId; await _db.SaveChangesAsync(); } private static void ValidateLines(List lines) { if (lines is null || lines.Count == 0) throw new InvalidOperationException("A snapshot must have at least one line."); foreach (var l in lines) { if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0) throw new InvalidOperationException("Each snapshot line needs a category group and subcategory."); if (l.Amount <= 0) throw new InvalidOperationException("Each snapshot line amount must be greater than zero."); if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass)) throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'."); } } private static List BuildLines(List inputs) => inputs.Select(l => new ExpenseSnapshotLine { CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId, FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description, }).ToList(); // Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked // Member's full name when present, otherwise the account email. Mirrors ExpenseService. private async Task> ResolveUserNamesAsync(IEnumerable userIds) { var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList(); if (ids.Count == 0) return new(); var users = await _db.Users.AsNoTracking() .Where(u => ids.Contains(u.Id)) .Select(u => new { u.Id, u.Email, u.MemberId }) .ToListAsync(); var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet(); var memberNames = await _db.Members.AsNoTracking() .Where(m => memberIds.Contains(m.Id)) .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim()); return users.ToDictionary( u => u.Id, u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0 ? name : (u.Email ?? u.Id)); } }