diff --git a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs new file mode 100644 index 0000000..d8918b7 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs @@ -0,0 +1,141 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using ROLAC.API.Services.Logging; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ExpenseSnapshotServiceTests +{ + private static AppDbContext BuildDb(string userId) + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(mock.Object))).Options); + } + + private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1") + { + var db = BuildDb(userId); + db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" }); + db.SaveChanges(); + return (new ExpenseSnapshotService(db), db); + } + + private static CreateExpenseSnapshotRequest Rent() => new() + { + Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X", + CheckNumber = "1001", + Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } }, + }; + + [Fact] + public async Task Create_PersistsHeaderAndLines_StampsCreator() + { + var (svc, db) = Build("creator-1"); + var id = await svc.CreateAsync(Rent()); + + var saved = await db.ExpenseSnapshots.FindAsync(id); + Assert.Equal("Monthly Rent", saved!.Name); + Assert.Equal("creator-1", saved.CreatedBy); + Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id)); + } + + [Fact] + public async Task Create_WithNoLines_Throws() + { + var (svc, _) = Build(); + var r = Rent(); r.Lines.Clear(); + await Assert.ThrowsAsync(() => svc.CreateAsync(r)); + } + + [Fact] + public async Task GetById_ReturnsLines_TotalsAndCreatorName() + { + var (svc, db) = Build("creator-1"); + db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" }); + db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 }); + await db.SaveChangesAsync(); + + var id = await svc.CreateAsync(Rent()); + var dto = await svc.GetByIdAsync(id); + + Assert.NotNull(dto); + Assert.Equal(1200m, dto!.TotalAmount); + Assert.Equal(1, dto.LineCount); + Assert.Equal("Rent", dto.Lines.Single().SubCategoryName); + Assert.Equal("Joy Wong", dto.CreatedByName); + } + + [Fact] + public async Task GetAll_ReturnsNewestFirst() + { + var (svc, _) = Build(); + var first = await svc.CreateAsync(Rent()); + var second = await svc.CreateAsync(Rent()); + + var all = await svc.GetAllAsync(); + + Assert.Equal(2, all.Count); + Assert.Equal(second, all[0].Id); + Assert.Equal(first, all[1].Id); + } + + [Fact] + public async Task Update_RenamesAndReplacesLines() + { + var (svc, db) = Build(); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" }); + await db.SaveChangesAsync(); + + var id = await svc.CreateAsync(Rent()); + await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest + { + Name = "Monthly Internet", MinistryId = 1, Description = "ISP", + Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } }, + }); + + var dto = await svc.GetByIdAsync(id); + Assert.Equal("Monthly Internet", dto!.Name); + Assert.Equal(80m, dto.TotalAmount); + Assert.Equal("Internet", dto.Lines.Single().SubCategoryName); + Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id)); + } + + [Fact] + public async Task Update_MissingId_Throws() + { + var (svc, _) = Build(); + await Assert.ThrowsAsync(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest + { + Name = "x", MinistryId = 1, Description = "x", + Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } }, + })); + } + + [Fact] + public async Task Delete_SoftDeletes_HidesFromQueries() + { + var (svc, db) = Build(); + var id = await svc.CreateAsync(Rent()); + + await svc.DeleteAsync(id); + + Assert.Empty(await svc.GetAllAsync()); + Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id)); + } +} diff --git a/API/ROLAC.API/Services/ExpenseSnapshotService.cs b/API/ROLAC.API/Services/ExpenseSnapshotService.cs new file mode 100644 index 0000000..cd85877 --- /dev/null +++ b/API/ROLAC.API/Services/ExpenseSnapshotService.cs @@ -0,0 +1,151 @@ +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; + public ExpenseSnapshotService(AppDbContext db) => _db = db; + + 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; + 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."); + } + } + + 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)); + } +} diff --git a/API/ROLAC.API/Services/IExpenseSnapshotService.cs b/API/ROLAC.API/Services/IExpenseSnapshotService.cs new file mode 100644 index 0000000..db87623 --- /dev/null +++ b/API/ROLAC.API/Services/IExpenseSnapshotService.cs @@ -0,0 +1,11 @@ +using ROLAC.API.DTOs.Expense; +namespace ROLAC.API.Services; + +public interface IExpenseSnapshotService +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(CreateExpenseSnapshotRequest r); + Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r); + Task DeleteAsync(int id); +}