From 971bf165cc8c45ce1e2e023be29d92ee6c410c7c Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Wed, 24 Jun 2026 18:53:13 -0700 Subject: [PATCH] feat(expense): map category group/subcategory to Form 990 lines --- .../Services/ExpenseCategoryServiceTests.cs | 16 ++++++++++++++++ .../DTOs/Expense/ExpenseCategoryDtos.cs | 6 ++++++ API/ROLAC.API/Data/AppDbContext.cs | 4 ++++ API/ROLAC.API/Entities/ExpenseCategoryGroup.cs | 3 +++ API/ROLAC.API/Entities/ExpenseSubCategory.cs | 3 +++ API/ROLAC.API/Services/ExpenseCategoryService.cs | 15 +++++++++++---- 6 files changed, 43 insertions(+), 4 deletions(-) diff --git a/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs index afea594..9303b46 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs @@ -58,4 +58,20 @@ public class ExpenseCategoryServiceTests await Assert.ThrowsAsync(() => svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" })); } + + [Fact] + public async Task CreateAndGet_RoundTrips_Form990LineId() + { + using var db = BuildDb(); + db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" }); + await db.SaveChangesAsync(); + var svc = new ExpenseCategoryService(db); + var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 24 }); + var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 }); + + var all = await svc.GetAllAsync(includeInactive: true); + var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid); + Assert.Equal(7, sub.Form990LineId); + Assert.Equal(24, all.Single(g => g.Id == gid).Form990LineId); + } } diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs index 0d235b3..78e1250 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs @@ -9,6 +9,8 @@ public class ExpenseSubCategoryDto public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } + public int? Form990LineId { get; set; } + public string? Form990LineCode { get; set; } } public class ExpenseCategoryGroupDto @@ -18,6 +20,8 @@ public class ExpenseCategoryGroupDto public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } + public int? Form990LineId { get; set; } + public string? Form990LineCode { get; set; } public List SubCategories { get; set; } = []; } @@ -26,6 +30,7 @@ public class CreateExpenseGroupRequest [Required, MaxLength(200)] public string Name_en { get; set; } = ""; [MaxLength(200)] public string? Name_zh { get; set; } public int SortOrder { get; set; } + public int? Form990LineId { get; set; } } public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest { @@ -38,6 +43,7 @@ public class CreateExpenseSubCategoryRequest [Required, MaxLength(200)] public string Name_en { get; set; } = ""; [MaxLength(200)] public string? Name_zh { get; set; } public int SortOrder { get; set; } + public int? Form990LineId { get; set; } } public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest { diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 7360594..458dcd9 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -221,6 +221,8 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.HasOne(e => e.Form990Line).WithMany() + .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); }); // ── ExpenseSubCategory ─────────────────────────────────────────────── @@ -232,6 +234,8 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasOne(e => e.Group).WithMany(g => g.SubCategories) .HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.Form990Line).WithMany() + .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); }); // ── Expense ────────────────────────────────────────────────────────── diff --git a/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs b/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs index 2339744..7349323 100644 --- a/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs +++ b/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs @@ -9,5 +9,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable public int SortOrder { get; set; } public bool IsActive { get; set; } = true; + public int? Form990LineId { get; set; } + public Form990ExpenseLine? Form990Line { get; set; } + public List SubCategories { get; set; } = []; } diff --git a/API/ROLAC.API/Entities/ExpenseSubCategory.cs b/API/ROLAC.API/Entities/ExpenseSubCategory.cs index 4011175..0c20a3a 100644 --- a/API/ROLAC.API/Entities/ExpenseSubCategory.cs +++ b/API/ROLAC.API/Entities/ExpenseSubCategory.cs @@ -10,5 +10,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable public int SortOrder { get; set; } public bool IsActive { get; set; } = true; + public int? Form990LineId { get; set; } + public Form990ExpenseLine? Form990Line { get; set; } + public ExpenseCategoryGroup? Group { get; set; } } diff --git a/API/ROLAC.API/Services/ExpenseCategoryService.cs b/API/ROLAC.API/Services/ExpenseCategoryService.cs index cd19f18..ae6bb87 100644 --- a/API/ROLAC.API/Services/ExpenseCategoryService.cs +++ b/API/ROLAC.API/Services/ExpenseCategoryService.cs @@ -22,21 +22,28 @@ public class ExpenseCategoryService : IExpenseCategoryService .OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en) .ToListAsync(); + var lineCodes = await _db.Form990ExpenseLines.AsNoTracking() + .ToDictionaryAsync(l => l.Id, l => l.LineCode); + return groups.Select(g => new ExpenseCategoryGroupDto { Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, SortOrder = g.SortOrder, IsActive = g.IsActive, + Form990LineId = g.Form990LineId, + Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null, SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto { Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh, SortOrder = s.SortOrder, IsActive = s.IsActive, + Form990LineId = s.Form990LineId, + Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null, }).ToList(), }).ToList(); } public async Task CreateGroupAsync(CreateExpenseGroupRequest r) { - var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; + var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId }; _db.ExpenseCategoryGroups.Add(g); await _db.SaveChangesAsync(); return g.Id; @@ -46,7 +53,7 @@ public class ExpenseCategoryService : IExpenseCategoryService { var g = await _db.ExpenseCategoryGroups.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); - g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; + g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; await _db.SaveChangesAsync(); } @@ -62,7 +69,7 @@ public class ExpenseCategoryService : IExpenseCategoryService { var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); - var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; + var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId }; _db.ExpenseSubCategories.Add(s); await _db.SaveChangesAsync(); return s.Id; @@ -72,7 +79,7 @@ public class ExpenseCategoryService : IExpenseCategoryService { var s = await _db.ExpenseSubCategories.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); - s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; + s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; await _db.SaveChangesAsync(); }