# Expense 990 Functional Expenses (Part IX) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make expense tracking audit-ready at IRS Form 990 Part IX level — every paid/approved expense rolls up into a functional-expense matrix (natural 990 line × Program/M&G/Fundraising). **Architecture:** Keep the existing two axes (`Ministry` = functional source, `ExpenseCategoryGroup → ExpenseSubCategory` = natural account). Add a 990 mapping layer: a `Form990ExpenseLine` catalog table, nullable `Form990LineId` on category group + subcategory, a `DefaultFunctionalClass` on `Ministry`, a nullable `FunctionalClass` override on `Expense`, and a read-only `Form990ReportService` that aggregates the Part IX matrix. Single function per expense (direct-charge), no cost splitting. **Tech Stack:** C# / .NET 8 / EF Core 8 (PostgreSQL, code-first migrations), xUnit + Moq + EF in-memory for tests; Angular (standalone) + Kendo UI v20 + Tailwind v4 frontend. **Spec:** [docs/superpowers/specs/2026-06-24-expense-990-functional-expenses-design.md](../specs/2026-06-24-expense-990-functional-expenses-design.md) --- ## ⚠️ Pre-flight notes for the implementer - **Working tree has uncommitted in-progress work** (per session start): `Ministry` DTOs/service/controller and `APP/src/app/features/ministry/` are mid-edit, plus modified `MinistriesController.cs`, `DbSeeder.cs`, `app.routes.ts`, `user-portal.component.ts`. **Stage only the exact files each task lists** (never `git add -A`) so unrelated in-progress work isn't swept into these commits. Run `git status` before every commit. - **Build/test from CLI must use Release** (VS holds the Debug DLL lock): `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`. EF tooling too: `dotnet ef migrations add --project API/ROLAC.API/ROLAC.API.csproj --configuration Release`. - **Tests** use EF in-memory + `AuditSaveChangesInterceptor`; copy the `BuildDb()` helper already in `ExpenseCategoryServiceTests.cs` / `MonthlyStatementServiceTests.cs`. - **Frontend** conventions to honor: Kendo DropdownList against object arrays needs `[valuePrimitive]="true"` + `valueField`/`textField`; form layout via Tailwind utilities (`grid grid-cols-1 md:grid-cols-2`) on a neutral wrapper, not component SCSS; every screen mobile-friendly (`hidden md:block` grid + `md:hidden` card list), and never set `display` in SCSS on a `md:hidden` element. --- ## File Structure **Backend — create:** - `API/ROLAC.API/Entities/Form990ExpenseLine.cs` — catalog entity - `API/ROLAC.API/Entities/FunctionalClasses.cs` — the 3 functional-class string constants + `All` - `API/ROLAC.API/DTOs/Finance/Form990ReportDtos.cs` — statement DTOs - `API/ROLAC.API/Services/IForm990ReportService.cs` + `Form990ReportService.cs` - `API/ROLAC.API/Controllers/Form990ReportController.cs` - `API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs` - `API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs` **Backend — modify:** - `API/ROLAC.API/Entities/{ExpenseCategoryGroup,ExpenseSubCategory,Ministry,Expense}.cs` — new fields - `API/ROLAC.API/Data/AppDbContext.cs` — DbSet + entity config - `API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs` — line id on DTOs + requests - `API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs` — functional class on DTO + requests - `API/ROLAC.API/DTOs/Ministry/{MinistryDto,CreateMinistryRequest,UpdateMinistryRequest}.cs` - `API/ROLAC.API/Services/ExpenseCategoryService.cs`, `ExpenseService.cs`, `MinistryService.cs` - `API/ROLAC.API/Authorization/Modules.cs` — `Form990Report` module - `API/ROLAC.API/Data/DbSeeder.cs` — new categories, renames, line catalog, mappings, ministry defaults, permission seed - `API/ROLAC.API/Program.cs` — register `IForm990ReportService` - `API/ROLAC.API/Migrations/` — one new migration - `docs/DB_SCHEMA.md` — schema doc sync **Frontend — create:** - `APP/src/app/features/finance-report/models/form990-report.model.ts` - `APP/src/app/features/finance-report/services/form990-report-api.service.ts` - `APP/src/app/features/finance-report/pages/form990-report-page/form990-report-page.component.ts` + `.html` **Frontend — modify:** - `APP/src/app/features/expense/models/expense.model.ts` — line id + functional class fields - `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.{ts,html}` — functional class dropdown - `APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.{ts,html}` — 990 line dropdown - `APP/src/app/features/ministry/.../ministries-page.component.{ts,html}` + ministry model — default functional class - the `PermissionModules` enum source (frontend) — `Form990Report` - `APP/src/app/app.routes.ts` — report route - `APP/src/app/portals/user-portal/user-portal.component.ts` — nav entry --- ## Task 1: Functional class constants + Form990ExpenseLine entity **Files:** - Create: `API/ROLAC.API/Entities/FunctionalClasses.cs` - Create: `API/ROLAC.API/Entities/Form990ExpenseLine.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - [ ] **Step 1: Create the functional-class constants** `API/ROLAC.API/Entities/FunctionalClasses.cs`: ```csharp namespace ROLAC.API.Entities; /// /// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in /// Ministry.DefaultFunctionalClass and Expense.FunctionalClass. /// public static class FunctionalClasses { public const string Program = "Program"; public const string ManagementGeneral = "ManagementGeneral"; public const string Fundraising = "Fundraising"; public static readonly IReadOnlyList All = [Program, ManagementGeneral, Fundraising]; /// Returns the value if valid, otherwise Program (the safe default). public static string Normalize(string? value) => value is not null && All.Contains(value) ? value : Program; } ``` - [ ] **Step 2: Create the catalog entity** `API/ROLAC.API/Entities/Form990ExpenseLine.cs`: ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; /// A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages". public class Form990ExpenseLine : AuditableEntity, IAuditable { public int Id { get; set; } public string LineCode { get; set; } = null!; // "7", "11b", "16", "24" public string Name_en { get; set; } = null!; public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } = true; } ``` - [ ] **Step 3: Register DbSet + entity config in AppDbContext** In `AppDbContext.cs`, add the DbSet next to the other expense sets (after line 22 `ExpenseSubCategories`): ```csharp public DbSet Form990ExpenseLines => Set(); ``` And add an entity config block right before the `// ── ExpenseCategoryGroup` block (around line 205): ```csharp // ── Form990ExpenseLine (Part IX natural-expense line catalog) ───────── builder.Entity(entity => { entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired(); entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasIndex(e => e.LineCode).IsUnique(); }); ``` - [ ] **Step 4: Build to verify it compiles** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded (no migration needed yet; in-memory tests don't require it). - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/Entities/FunctionalClasses.cs API/ROLAC.API/Entities/Form990ExpenseLine.cs API/ROLAC.API/Data/AppDbContext.cs git commit -m "feat(expense): add Form990ExpenseLine catalog entity and functional-class constants" ``` --- ## Task 2: 990 line mapping on category group + subcategory **Files:** - Modify: `API/ROLAC.API/Entities/ExpenseCategoryGroup.cs`, `ExpenseSubCategory.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - Modify: `API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs` - Modify: `API/ROLAC.API/Services/ExpenseCategoryService.cs` - Test: `API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs` - [ ] **Step 1: Write the failing test** Add to `ExpenseCategoryServiceTests.cs`: ```csharp [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); } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "RoundTrips_Form990LineId"` Expected: FAIL — `CreateExpenseGroupRequest` has no `Form990LineId` (compile error). - [ ] **Step 3: Add the entity fields** In `ExpenseCategoryGroup.cs`, add inside the class: ```csharp public int? Form990LineId { get; set; } public Form990ExpenseLine? Form990Line { get; set; } ``` In `ExpenseSubCategory.cs`, add inside the class: ```csharp public int? Form990LineId { get; set; } public Form990ExpenseLine? Form990Line { get; set; } ``` - [ ] **Step 4: Configure the FKs in AppDbContext** In the `ExpenseCategoryGroup` config block add: ```csharp entity.HasOne(e => e.Form990Line).WithMany() .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); ``` In the `ExpenseSubCategory` config block add: ```csharp entity.HasOne(e => e.Form990Line).WithMany() .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); ``` - [ ] **Step 5: Add fields to DTOs + requests** In `ExpenseCategoryDtos.cs`, add to `ExpenseSubCategoryDto` and `ExpenseCategoryGroupDto`: ```csharp public int? Form990LineId { get; set; } public string? Form990LineCode { get; set; } ``` Add to `CreateExpenseGroupRequest` and `CreateExpenseSubCategoryRequest`: ```csharp public int? Form990LineId { get; set; } ``` (`UpdateExpenseGroupRequest`/`UpdateExpenseSubCategoryRequest` inherit it.) - [ ] **Step 6: Map the fields in ExpenseCategoryService** In `GetAllAsync`, load a line-code lookup and project the fields. After the `subs` query add: ```csharp var lineCodes = await _db.Form990ExpenseLines.AsNoTracking() .ToDictionaryAsync(l => l.Id, l => l.LineCode); ``` In the group projection add `Form990LineId = g.Form990LineId,` and `Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,`. In the subcategory projection add `Form990LineId = s.Form990LineId,` and `Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,`. In `CreateGroupAsync` add `Form990LineId = r.Form990LineId,` to the `new ExpenseCategoryGroup { … }`. In `UpdateGroupAsync` add `g.Form990LineId = r.Form990LineId;`. In `CreateSubCategoryAsync` add `Form990LineId = r.Form990LineId,` to the `new ExpenseSubCategory { … }`. In `UpdateSubCategoryAsync` add `s.Form990LineId = r.Form990LineId;`. - [ ] **Step 7: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "RoundTrips_Form990LineId"` Expected: PASS. Also run the full `ExpenseCategoryServiceTests` class to confirm no regressions. - [ ] **Step 8: Commit** ```bash git add API/ROLAC.API/Entities/ExpenseCategoryGroup.cs API/ROLAC.API/Entities/ExpenseSubCategory.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs API/ROLAC.API/Services/ExpenseCategoryService.cs API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs git commit -m "feat(expense): map category group/subcategory to Form 990 lines" ``` --- ## Task 3: Ministry DefaultFunctionalClass **Files:** - Modify: `API/ROLAC.API/Entities/Ministry.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - Modify: `API/ROLAC.API/DTOs/Ministry/{MinistryDto,CreateMinistryRequest,UpdateMinistryRequest}.cs` - Modify: `API/ROLAC.API/Services/MinistryService.cs` - Test: create `API/ROLAC.API.Tests/Services/MinistryServiceTests.cs` - [ ] **Step 1: Write the failing test** Create `API/ROLAC.API.Tests/Services/MinistryServiceTests.cs`: ```csharp using Microsoft.EntityFrameworkCore; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Moq; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.DTOs.Ministry; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class MinistryServiceTests { private static AppDbContext BuildDb() { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }; 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 ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } [Fact] public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt() { using var db = BuildDb(); var svc = new MinistryService(db); var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" }); var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id); Assert.Equal("Program", afterCreate.DefaultFunctionalClass); await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" }); var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id); Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "DefaultsFunctionalClassToProgram"` Expected: FAIL — `MinistryDto`/requests have no `DefaultFunctionalClass` (compile error). - [ ] **Step 3: Add the entity field + config** In `Ministry.cs` add inside the class: ```csharp public string DefaultFunctionalClass { get; set; } = "Program"; ``` In `AppDbContext.cs`, in the `Ministry` config block add: ```csharp entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program"); ``` - [ ] **Step 4: Add the field to DTOs** In `MinistryDto.cs` add `public string DefaultFunctionalClass { get; set; } = "Program";` In `CreateMinistryRequest.cs` add `[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }` In `UpdateMinistryRequest.cs` add `[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }` - [ ] **Step 5: Map it in MinistryService** In `GetAllAsync` projection add `DefaultFunctionalClass = m.DefaultFunctionalClass,`. In `CreateAsync`'s `new Ministry { … }` add `DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass),`. In `UpdateAsync` add `m.DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass);`. - [ ] **Step 6: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "DefaultsFunctionalClassToProgram"` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add API/ROLAC.API/Entities/Ministry.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/DTOs/Ministry/MinistryDto.cs API/ROLAC.API/DTOs/Ministry/CreateMinistryRequest.cs API/ROLAC.API/DTOs/Ministry/UpdateMinistryRequest.cs API/ROLAC.API/Services/MinistryService.cs API/ROLAC.API.Tests/Services/MinistryServiceTests.cs git commit -m "feat(ministry): add DefaultFunctionalClass for Form 990 functional split" ``` --- ## Task 4: Expense FunctionalClass override **Files:** - Modify: `API/ROLAC.API/Entities/Expense.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - Modify: `API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs` - Modify: `API/ROLAC.API/Services/ExpenseService.cs` - Test: `API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs` - [ ] **Step 1: Write the failing test** Add to `ExpenseServiceTests.cs` (reuse its existing `BuildDb`/service-build helpers and seeding style; create a minimal ministry/group/sub first as other tests do): ```csharp [Fact] public async Task Create_PersistsFunctionalClass_AndGetReturnsIt() { using var db = BuildDb(); db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" }); db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" }); db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" }); await db.SaveChangesAsync(); var svc = BuildService(db); // existing helper in this test file var id = await svc.CreateAsync(new CreateExpenseRequest { Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1), FunctionalClass = "ManagementGeneral", }, isFinance: true); var dto = await svc.GetByIdAsync(id); Assert.Equal("ManagementGeneral", dto!.FunctionalClass); } ``` > If `ExpenseServiceTests` uses a differently-named service builder, match it. The point is: create with `FunctionalClass` set, read it back. - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "PersistsFunctionalClass"` Expected: FAIL — `CreateExpenseRequest`/`ExpenseDto` have no `FunctionalClass`. - [ ] **Step 3: Add the entity field + config** In `Expense.cs` add inside the class (near `Status`): ```csharp public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass ``` In `AppDbContext.cs`, in the `Expense` config block add: ```csharp entity.Property(e => e.FunctionalClass).HasMaxLength(20); ``` - [ ] **Step 4: Add the field to DTOs** In `ExpenseDtos.cs`, add to `ExpenseListItemDto`: ```csharp public string? FunctionalClass { get; set; } ``` Add to `CreateExpenseRequest`: ```csharp [MaxLength(20)] public string? FunctionalClass { get; set; } ``` (`UpdateExpenseRequest` inherits it; `ExpenseDto` inherits from `ExpenseListItemDto`.) - [ ] **Step 5: Map it in ExpenseService** In `CreateAsync`'s `new Expense { … }` add `FunctionalClass = r.FunctionalClass,`. In `UpdateAsync` add `e.FunctionalClass = r.FunctionalClass;` (alongside the other field assignments). In `ProjectPagedAsync`'s `new ExpenseListItemDto { … }` add `FunctionalClass = e.FunctionalClass,`. In `GetByIdAsync`'s `new ExpenseDto { … }` add `FunctionalClass = e.FunctionalClass,`. - [ ] **Step 6: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "PersistsFunctionalClass"` Expected: PASS. Run the full `ExpenseServiceTests` class to confirm no regressions. - [ ] **Step 7: Commit** ```bash git add API/ROLAC.API/Entities/Expense.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs API/ROLAC.API/Services/ExpenseService.cs API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs git commit -m "feat(expense): add per-expense FunctionalClass override" ``` --- ## Task 5: EF migration for the schema changes **Files:** - Create: `API/ROLAC.API/Migrations/_AddForm990FunctionalExpenses.cs` (generated) - Modify: `API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs` (generated) - [ ] **Step 1: Generate the migration** Run: ```bash dotnet ef migrations add AddForm990FunctionalExpenses --project API/ROLAC.API/ROLAC.API.csproj --configuration Release ``` Expected: a new migration file is created. (If `dotnet ef` is missing: `dotnet tool install --global dotnet-ef`.) - [ ] **Step 2: Review the generated Up()** Open the new migration and confirm it contains: `CreateTable("Form990ExpenseLines", …)` with a unique index on `LineCode`; `AddColumn Form990LineId` on both `ExpenseCategoryGroups` and `ExpenseSubCategories` with FKs to `Form990ExpenseLines`; `AddColumn DefaultFunctionalClass` (string, default "Program") on `Ministries`; `AddColumn FunctionalClass` (nullable string) on `Expenses`. No unexpected drops/renames. - [ ] **Step 3: Build to verify the migration compiles** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded. - [ ] **Step 4: Commit** ```bash git add API/ROLAC.API/Migrations/ git commit -m "feat(db): migration for Form 990 lines, category mapping, functional class" ``` --- ## Task 6: Seed new categories + rename overlapping subcategories **Files:** - Modify: `API/ROLAC.API/Data/DbSeeder.cs` - Test: create `API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs` - [ ] **Step 1: Write the failing test** Create `API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs`: ```csharp using Microsoft.EntityFrameworkCore; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Moq; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.Entities; using Xunit; namespace ROLAC.API.Tests.Services; public class DbSeederForm990Tests { private static AppDbContext BuildDb() { var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) }; var mock = new Mock(); mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } [Fact] public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent() { using var db = BuildDb(); // Pre-existing old-named subcategory under Food & Beverage to exercise the rename. var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 }; db.ExpenseCategoryGroups.Add(fnb); await db.SaveChangesAsync(); db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" }); await db.SaveChangesAsync(); await DbSeeder.SeedExpenseCategoriesAsync(db); await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run var groups = await db.ExpenseCategoryGroups.ToListAsync(); Assert.Contains(groups, g => g.Name_en == "Professional Services"); Assert.Contains(groups, g => g.Name_en == "Information Technology"); Assert.Contains(groups, g => g.Name_en == "Finance & Banking"); // The old F&B "Consumables" was renamed, not duplicated. var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync(); Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables"); Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware"); // Idempotent: exactly one Professional Services group. Assert.Single(groups.Where(g => g.Name_en == "Professional Services")); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "AddsNewGroups_RenamesDuplicates"` Expected: FAIL — new groups not seeded, rename not applied. - [ ] **Step 3: Update the seed data tuples** In `DbSeeder.cs`, replace the `ExpenseCategorySeed` array with the expanded version (new subs added, two renames applied, three new groups appended): ```csharp private static readonly (string En, string Zh, int Sort, (string En, string Zh)[] Subs)[] ExpenseCategorySeed = [ ("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]), ("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]), ("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]), ("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]), ("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]), ("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]), ("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]), ("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]), ("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]), ("Other", "其他", 10, [("Miscellaneous","雜支")]), ("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]), ("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]), ("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]), ("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]), ]; ``` - [ ] **Step 4: Add rename handling + keep seeding idempotent** The existing `SeedExpenseCategoriesAsync` only inserts missing rows, so renamed seeds would duplicate. Add an explicit rename pass at the **top** of `SeedExpenseCategoriesAsync` (before the insert loop): ```csharp // One-time renames to remove same-name-different-parent ambiguity. Idempotent: // only fires while the old name still exists. (New installs never hit this.) var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[] { ("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"), ("Materials", "Printing", "Curriculum Printing", "教材印刷"), }; foreach (var (groupEn, oldSub, newEn, newZh) in renames) { var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn); if (grp is null) continue; var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub); if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; } } await db.SaveChangesAsync(); ``` - [ ] **Step 5: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "AddsNewGroups_RenamesDuplicates"` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add API/ROLAC.API/Data/DbSeeder.cs API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs git commit -m "feat(seed): add IT/Professional/Finance categories and rename overlapping subcategories" ``` --- ## Task 7: Seed the Form990ExpenseLine catalog + default mappings **Files:** - Modify: `API/ROLAC.API/Data/DbSeeder.cs` - Test: `API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs` - [ ] **Step 1: Write the failing test** Add to `DbSeederForm990Tests.cs`: ```csharp [Fact] public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories() { using var db = BuildDb(); await DbSeeder.SeedExpenseCategoriesAsync(db); await DbSeeder.SeedForm990ExpenseLinesAsync(db); await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7")); Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24")); var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line) .FirstAsync(s => s.Name_en == "Salary & Wages"); Assert.Equal("7", salary.Form990Line!.LineCode); var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line) .FirstAsync(s => s.Name_en == "Accounting & Audit"); Assert.Equal("11c", audit.Form990Line!.LineCode); } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "SeedForm990Lines_CreatesCatalog"` Expected: FAIL — `SeedForm990ExpenseLinesAsync` does not exist. - [ ] **Step 3: Add the line catalog + mapping seed data** In `DbSeeder.cs` add these static arrays (near the other seed arrays): ```csharp // (LineCode, Name_en, Name_zh, Sort) private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed = [ ("1", "Grants to domestic organizations", "對國內機構之捐贈", 1), ("2", "Grants to domestic individuals", "對國內個人之捐贈", 2), ("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3), ("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4), ("7", "Other salaries and wages", "薪資", 5), ("8", "Pension plan accruals and contributions", "退休金提撥", 6), ("9", "Other employee benefits", "員工福利", 7), ("10", "Payroll taxes", "薪資稅", 8), ("11b", "Legal fees", "法律服務費", 9), ("11c", "Accounting fees", "會計與審計費", 10), ("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11), ("12", "Advertising and promotion", "廣告與推廣", 12), ("13", "Office expenses", "辦公費用", 13), ("14", "Information technology", "資訊科技", 14), ("16", "Occupancy", "場地佔用", 15), ("17", "Travel", "差旅", 16), ("19", "Conferences, conventions, and meetings", "會議與研習", 17), ("20", "Interest", "利息", 18), ("22", "Depreciation", "折舊", 19), ("23", "Insurance", "保險", 20), ("24", "Other expenses", "其他費用", 21), ]; // (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping. private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed = [ ("Personnel", "Officer / Key Employee Compensation", "5"), ("Personnel", "Salary & Wages", "7"), ("Personnel", "Payroll Taxes", "10"), ("Personnel", "Employee Benefits", "9"), ("Personnel", "Retirement / Pension","8"), ("Personnel", "Workers Compensation","9"), ("Personnel", "Honorarium", "11g"), ("Personnel", "Contract Labor", "11g"), ("Personnel", "Staff Training", "19"), ("Facility", "Rent", "16"), ("Facility", "Utilities", "16"), ("Facility", "Property Insurance", "23"), ("Facility", "Decoration", "24"), ("Training", "Course Fees", "19"), ("Training", "Conference", "19"), ("Training", "Books", "24"), ("Training", "Travel", "17"), ("Missions", "Travel", "17"), ("Missions", "Offering Transfer", "1"), ("Missions", "Missionary Support", "1"), ("Missions", "Foreign Missions Support", "3"), ("Benevolence", "Emergency Aid", "2"), ("Benevolence", "Condolence Gifts", "2"), ("Benevolence", "Visit Expenses", "2"), ("Consumables", "Office Supplies", "13"), ("Printing", "Bulletins", "13"), ("Printing", "Order of Service", "13"), ("Printing", "Posters", "12"), ("Printing", "Advertising & Promotion", "12"), ("Materials", "Curriculum Printing", "13"), ("Professional Services", "Legal", "11b"), ("Professional Services", "Accounting & Audit", "11c"), ("Professional Services", "Other Professional", "11g"), ("Information Technology", "Software & Subscriptions", "14"), ("Information Technology", "Website & Hosting", "14"), ("Information Technology", "Internet & Telecom", "14"), ("Finance & Banking", "Interest", "20"), ]; ``` - [ ] **Step 4: Add the seeder method** In `DbSeeder.cs` add: ```csharp public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db) { foreach (var (code, en, zh, sort) in Form990LineSeed) { if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code)) db.Form990ExpenseLines.Add(new Form990ExpenseLine { LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true }); } await db.SaveChangesAsync(); var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id); var fallbackId = linesByCode["24"]; // Every group defaults to line 24 (safety net); precise mapping lives on subcategories. foreach (var group in await db.ExpenseCategoryGroups.ToListAsync()) group.Form990LineId ??= fallbackId; // Subcategory default mappings — only set when not already mapped (never clobber an admin edit). var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync(); foreach (var (groupEn, subEn, code) in Form990SubMappingSeed) { var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn); if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId)) sub.Form990LineId = lineId; } await db.SaveChangesAsync(); } ``` - [ ] **Step 5: Wire it into the seed orchestration** In `SeedAsync` (around line 288), add the call right after `SeedExpenseCategoriesAsync(db)`: ```csharp await SeedExpenseCategoriesAsync(db); await SeedForm990ExpenseLinesAsync(db); ``` - [ ] **Step 6: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "SeedForm990Lines_CreatesCatalog"` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add API/ROLAC.API/Data/DbSeeder.cs API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs git commit -m "feat(seed): seed Form 990 line catalog and default subcategory mappings" ``` --- ## Task 8: Seed Ministry default functional classes **Files:** - Modify: `API/ROLAC.API/Data/DbSeeder.cs` - Test: `API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs` - [ ] **Step 1: Write the failing test** Add to `DbSeederForm990Tests.cs`: ```csharp [Fact] public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram() { using var db = BuildDb(); await DbSeeder.SeedMinistriesAsync(db); var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration"); var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship"); Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass); Assert.Equal("Program", worship.DefaultFunctionalClass); } ``` - [ ] **Step 2: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "SetsAdministrationToManagementGeneral"` Expected: FAIL — Administration defaults to "Program". - [ ] **Step 3: Set the default in the ministry seeder** In `SeedMinistriesAsync`, set the functional class when inserting. Replace the insert line: ```csharp if (!await db.Ministries.AnyAsync(m => m.Name_en == en)) db.Ministries.Add(new Ministry { Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true, DefaultFunctionalClass = en == "Administration" ? FunctionalClasses.ManagementGeneral : FunctionalClasses.Program, }); ``` (Add `using ROLAC.API.Entities;` if not already imported — it is.) - [ ] **Step 4: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "SetsAdministrationToManagementGeneral"` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/Data/DbSeeder.cs API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs git commit -m "feat(seed): default Administration ministry to Management & General" ``` --- ## Task 9: Report DTOs + permission module **Files:** - Create: `API/ROLAC.API/DTOs/Finance/Form990ReportDtos.cs` - Modify: `API/ROLAC.API/Authorization/Modules.cs` - Modify: `API/ROLAC.API/Data/DbSeeder.cs` (permission seed) - [ ] **Step 1: Create the DTOs** `API/ROLAC.API/DTOs/Finance/Form990ReportDtos.cs`: ```csharp namespace ROLAC.API.DTOs.Finance; /// One Part IX row: a 990 line split across the three functional columns. public class FunctionalExpenseRowDto { public string LineCode = ""; public string Name_en = ""; public string? Name_zh; public decimal Program; public decimal ManagementGeneral; public decimal Fundraising; public decimal Total; } /// The full Part IX Statement of Functional Expenses for a date range. public class FunctionalExpenseStatementDto { public List Rows { get; set; } = []; public decimal ProgramTotal { get; set; } public decimal ManagementGeneralTotal { get; set; } public decimal FundraisingTotal { get; set; } public decimal GrandTotal { get; set; } /// Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup. public int UnmappedExpenseCount { get; set; } } ``` - [ ] **Step 2: Add the permission module** In `Modules.cs` add the constant after `FinanceDashboard`: ```csharp public const string Form990Report = "Form990Report"; ``` And add `Form990Report,` to the `All` list (after `FinanceDashboard,`). - [ ] **Step 3: Seed read permission for finance/pastor/board_member** In `DbSeeder.cs`, in `RolePermissionSeed`, add (in the finance block and governance block): ```csharp ("finance", Modules.Form990Report, true, false, false, false), ("pastor", Modules.Form990Report, true, false, false, false), ("board_member", Modules.Form990Report, true, false, false, false), ``` - [ ] **Step 4: Build to verify** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/DTOs/Finance/Form990ReportDtos.cs API/ROLAC.API/Authorization/Modules.cs API/ROLAC.API/Data/DbSeeder.cs git commit -m "feat(finance): add Form 990 report DTOs and permission module" ``` --- ## Task 10: Form990ReportService aggregation **Files:** - Create: `API/ROLAC.API/Services/IForm990ReportService.cs`, `Form990ReportService.cs` - Modify: `API/ROLAC.API/Program.cs` - Test: create `API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs` - [ ] **Step 1: Write the failing tests** Create `API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs`: ```csharp using Microsoft.EntityFrameworkCore; using System.Security.Claims; using Microsoft.AspNetCore.Http; using Moq; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.Entities; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class Form990ReportServiceTests { private static AppDbContext BuildDb() { var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) }; var mock = new Mock(); mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options); } // Lines 7 and 24, two ministries (Program + M&G), a mapped sub (line 7) and an unmapped sub. private static async Task SeedAsync(AppDbContext db) { db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 }); db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 }); db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" }); db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" }); db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null }); await db.SaveChangesAsync(); } private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new() { MinistryId = min, CategoryGroupId = 1, SubCategoryId = sub, Type = "VendorPayment", Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10), FunctionalClass = fc, }; [Fact] public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount() { using var db = BuildDb(); await SeedAsync(db); db.Expenses.Add(Exp(2, 1, 100m, "Paid")); // Worship→Program, line 7 db.Expenses.Add(Exp(1, 1, 40m, "Approved")); // Admin→M&G, line 7 db.Expenses.Add(Exp(2, 2, 25m, "Paid")); // Worship→Program, unmapped → line 24 db.Expenses.Add(Exp(2, 1, 999m, "Draft")); // excluded (not Paid/Approved) db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));// override M&G→Program, line 7 await db.SaveChangesAsync(); var svc = new Form990ReportService(db); var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null); var line7 = stmt.Rows.Single(r => r.LineCode == "7"); Assert.Equal(110m, line7.Program); // 100 + 10 override Assert.Equal(40m, line7.ManagementGeneral); Assert.Equal(150m, line7.Total); var line24 = stmt.Rows.Single(r => r.LineCode == "24"); Assert.Equal(25m, line24.Program); Assert.Equal(1, stmt.UnmappedExpenseCount); Assert.Equal(175m, stmt.GrandTotal); Assert.Equal(135m, stmt.ProgramTotal); Assert.Equal(40m, stmt.ManagementGeneralTotal); } [Fact] public async Task Statement_RespectsDateRange() { using var db = BuildDb(); await SeedAsync(db); db.Expenses.Add(Exp(2, 1, 100m, "Paid")); // 2026-05-10 (in range) var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1); db.Expenses.Add(older); await db.SaveChangesAsync(); var svc = new Form990ReportService(db); var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31)); Assert.Equal(100m, stmt.GrandTotal); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "Form990ReportServiceTests"` Expected: FAIL — `Form990ReportService` does not exist. - [ ] **Step 3: Create the interface** `API/ROLAC.API/Services/IForm990ReportService.cs`: ```csharp using ROLAC.API.DTOs.Finance; namespace ROLAC.API.Services; public interface IForm990ReportService { Task GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to); } ``` - [ ] **Step 4: Implement the service** `API/ROLAC.API/Services/Form990ReportService.cs`: ```csharp using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Finance; using ROLAC.API.Entities; namespace ROLAC.API.Services; /// /// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional /// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only. /// Single function per expense (direct-charge); no cost splitting. /// public class Form990ReportService : IForm990ReportService { private readonly AppDbContext _db; public Form990ReportService(AppDbContext db) => _db = db; public async Task GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to) { var lines = await _db.Form990ExpenseLines.AsNoTracking() .Where(l => l.IsActive).OrderBy(l => l.SortOrder).ToListAsync(); var fallbackId = lines.FirstOrDefault(l => l.LineCode == "24")?.Id; // Explicit joins (provider-agnostic). The Expense soft-delete query filter still applies. var expenses = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved"); if (from.HasValue) expenses = expenses.Where(e => e.ExpenseDate >= from.Value); if (to.HasValue) expenses = expenses.Where(e => e.ExpenseDate <= to.Value); var rows = await ( from e in expenses join m in _db.Ministries on e.MinistryId equals m.Id join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id select new { e.Amount, e.FunctionalClass, MinistryDefault = m.DefaultFunctionalClass, SubLineId = sub.Form990LineId, GroupLineId = grp.Form990LineId, }).ToListAsync(); // accumulator[lineId] = (program, mg, fundraising) var acc = new Dictionary(); var unmapped = 0; foreach (var r in rows) { var function = FunctionalClasses.Normalize(r.FunctionalClass ?? r.MinistryDefault); var lineId = r.SubLineId ?? r.GroupLineId ?? fallbackId; if (lineId is null) continue; // no catalog at all — nothing to bucket into if (r.SubLineId is null && r.GroupLineId is null) unmapped++; var cur = acc.GetValueOrDefault(lineId.Value); acc[lineId.Value] = function switch { FunctionalClasses.ManagementGeneral => (cur.P, cur.M + r.Amount, cur.F), FunctionalClasses.Fundraising => (cur.P, cur.M, cur.F + r.Amount), _ => (cur.P + r.Amount, cur.M, cur.F), }; } var dto = new FunctionalExpenseStatementDto { UnmappedExpenseCount = unmapped }; foreach (var line in lines) { var v = acc.GetValueOrDefault(line.Id); dto.Rows.Add(new FunctionalExpenseRowDto { LineCode = line.LineCode, Name_en = line.Name_en, Name_zh = line.Name_zh, Program = v.P, ManagementGeneral = v.M, Fundraising = v.F, Total = v.P + v.M + v.F, }); dto.ProgramTotal += v.P; dto.ManagementGeneralTotal += v.M; dto.FundraisingTotal += v.F; } dto.GrandTotal = dto.ProgramTotal + dto.ManagementGeneralTotal + dto.FundraisingTotal; return dto; } } ``` - [ ] **Step 5: Register the service** In `Program.cs`, after line 157 (`IFinanceDashboardService`) add: ```csharp builder.Services.AddScoped(); ``` - [ ] **Step 6: Run the tests to verify they pass** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "Form990ReportServiceTests"` Expected: PASS (both tests). - [ ] **Step 7: Commit** ```bash git add API/ROLAC.API/Services/IForm990ReportService.cs API/ROLAC.API/Services/Form990ReportService.cs API/ROLAC.API/Program.cs API/ROLAC.API.Tests/Services/Form990ReportServiceTests.cs git commit -m "feat(finance): Form 990 Part IX functional-expense aggregation service" ``` --- ## Task 11: Form990ReportController **Files:** - Create: `API/ROLAC.API/Controllers/Form990ReportController.cs` - [ ] **Step 1: Create the controller** `API/ROLAC.API/Controllers/Form990ReportController.cs`: ```csharp using Microsoft.AspNetCore.Mvc; using ROLAC.API.Authorization; using ROLAC.API.Services; namespace ROLAC.API.Controllers; [ApiController] [Route("api/form990-report")] [HasPermission(Modules.Form990Report, PermissionActions.Read)] public class Form990ReportController : ControllerBase { private readonly IForm990ReportService _svc; public Form990ReportController(IForm990ReportService svc) => _svc = svc; [HttpGet("functional-expenses")] public async Task FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to) => Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to)); } ``` - [ ] **Step 2: Build to verify** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded. - [ ] **Step 3: Run the full backend test suite** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` Expected: All tests pass (new + existing). - [ ] **Step 4: Commit** ```bash git add API/ROLAC.API/Controllers/Form990ReportController.cs git commit -m "feat(finance): Form 990 functional-expenses report endpoint" ``` --- ## Task 12: Frontend — permission module + expense model fields **Files:** - Modify: the frontend `PermissionModules` enum source (find with `grep -rl "FinanceDashboard" APP/src/app` among `*.ts` constants — likely `APP/src/app/core/.../permission-modules.ts` or similar) - Modify: `APP/src/app/features/expense/models/expense.model.ts` - [ ] **Step 1: Add the permission module constant** Locate the `PermissionModules` definition (the enum/const object containing `FinanceDashboard`, `Expenses`, etc.). Add: ```typescript Form990Report: 'Form990Report', ``` (Match the existing literal style — if it's a string enum use `Form990Report = 'Form990Report',`.) - [ ] **Step 2: Add fields to expense models** In `APP/src/app/features/expense/models/expense.model.ts`: - Add a functional-class union near the top: ```typescript export type FunctionalClass = 'Program' | 'ManagementGeneral' | 'Fundraising'; ``` - Add to `ExpenseListItemDto`: `functionalClass: FunctionalClass | null;` - Add to `CreateExpenseRequest`: `functionalClass: FunctionalClass | null;` - Add to `ExpenseCategoryGroupDto` and `ExpenseSubCategoryDto`: ```typescript form990LineId: number | null; form990LineCode: string | null; ``` - [ ] **Step 3: Verify the app compiles** Run: `cd APP && npx ng build --configuration development` Expected: Build completes without type errors. (If `ng` is unavailable, use `npm run build`.) - [ ] **Step 4: Commit** ```bash git add APP/src/app/features/expense/models/expense.model.ts git commit -m "feat(web): add Form990Report permission and expense functional-class/line fields" ``` --- ## Task 13: Frontend — 990 line dropdown in the category admin page **Files:** - Create: `APP/src/app/features/finance-report/models/form990-report.model.ts` (shared `Form990ExpenseLineDto`) - Modify: `APP/src/app/features/expense/services/expense-category-api.service.ts` - Modify: `APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.{ts,html}` > The catalog list endpoint: the report needs the 990 line list for the dropdown. Add a lightweight GET. Reuse the existing categories controller is not appropriate; instead expose lines via the report controller. - [ ] **Step 1: Add a lines endpoint (backend) + service method** Add to `Form990ReportController.cs`: ```csharp [HttpGet("lines")] public async Task Lines() => Ok(await _svc.GetLinesAsync()); ``` Add to `IForm990ReportService` + implement in `Form990ReportService`: ```csharp Task> GetLinesAsync(); ``` ```csharp public async Task> GetLinesAsync() => await _db.Form990ExpenseLines.AsNoTracking().Where(l => l.IsActive) .OrderBy(l => l.SortOrder) .Select(l => new Form990ExpenseLineDto { Id = l.Id, LineCode = l.LineCode, Name_en = l.Name_en, Name_zh = l.Name_zh, SortOrder = l.SortOrder }) .ToListAsync(); ``` Add `Form990ExpenseLineDto` to `Form990ReportDtos.cs`: ```csharp public class Form990ExpenseLineDto { public int Id { get; set; } public string LineCode { get; set; } = ""; public string Name_en { get; set; } = ""; public string? Name_zh { get; set; } public int SortOrder { get; set; } } ``` Re-run backend build: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` → succeeds. Commit this backend addition with the frontend (or separately): ```bash git add API/ROLAC.API/Controllers/Form990ReportController.cs API/ROLAC.API/Services/IForm990ReportService.cs API/ROLAC.API/Services/Form990ReportService.cs API/ROLAC.API/DTOs/Finance/Form990ReportDtos.cs git commit -m "feat(finance): expose Form 990 line catalog endpoint" ``` - [ ] **Step 2: Frontend model + service** Create `APP/src/app/features/finance-report/models/form990-report.model.ts`: ```typescript export interface Form990ExpenseLineDto { id: number; lineCode: string; name_en: string; name_zh: string | null; sortOrder: number; label?: string; // bilingual "code — name", filled by service } ``` In `expense-category-api.service.ts`, add a method to fetch lines (use the existing `ApiConfigService`/HttpClient pattern in that file): ```typescript getForm990Lines(): Observable { return this.http.get(this.apiConfig.getApiUrl('form990-report') + '/lines') .pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode} — ${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` })))); } ``` Also extend `CreateExpenseGroupRequest`/`CreateExpenseSubCategoryRequest` types (in the model file used by this service) with `form990LineId: number | null;`. - [ ] **Step 3: Wire the dropdown into the category dialogs** In `expense-categories-page.component.ts`: load lines on init (`getForm990Lines()` → `this.form990Lines`); add `form990LineId` to both the group form object and the sub form object; include it in the create/update payloads; when opening an edit dialog, prefill `form990LineId` from the row. In `expense-categories-page.component.html`, add to **both** the group dialog and the subcategory dialog form (inside the existing Tailwind grid wrapper): ```html ``` (`[valuePrimitive]="true"` is required so the form binds the id, not the object — see the value-primitive convention.) - [ ] **Step 4: Verify compile + manual check** Run: `cd APP && npx ng build --configuration development` → succeeds. Then verify in the preview (see Task 16's verification note): open Expense Categories, edit a subcategory, confirm the 990 Line dropdown lists lines and saves. - [ ] **Step 5: Commit** ```bash git add APP/src/app/features/finance-report/models/form990-report.model.ts APP/src/app/features/expense/services/expense-category-api.service.ts APP/src/app/features/expense/pages/expense-categories-page/ git commit -m "feat(web): map expense categories to Form 990 lines in the category admin page" ``` --- ## Task 14: Frontend — functional-class dropdown in the expense form **Files:** - Modify: `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.{ts,html}` - [ ] **Step 1: Add the control to the component** In `expense-form-dialog.component.ts`: add `functionalClass: null` to the `form` object; add a static options list: ```typescript readonly functionalClassOptions = [ { value: 'Program', label: 'Program / 事工服務' }, { value: 'ManagementGeneral', label: 'Management & General / 管理' }, { value: 'Fundraising', label: 'Fundraising / 募款' }, ]; ``` Include `functionalClass: this.form.functionalClass` in the create/update payload; when editing, prefill from the loaded expense. - [ ] **Step 2: Add the dropdown to the template** In `expense-form-dialog.component.html`, inside the existing field grid (after ministry/category, since it overrides the ministry default), add: ```html ``` - [ ] **Step 3: Verify compile + manual check** Run: `cd APP && npx ng build --configuration development` → succeeds. Verify in preview: create an expense leaving functional class on "(Inherit …)", and another set to Management & General; confirm both save. - [ ] **Step 4: Commit** ```bash git add APP/src/app/features/expense/components/expense-form-dialog/ git commit -m "feat(web): functional-class override on the expense form" ``` --- ## Task 15: Frontend — default functional class on the ministry form **Files:** - Modify: the ministry model file + ministry edit page under `APP/src/app/features/ministry/` > This folder has uncommitted in-progress work — re-read the actual files before editing and stage only the ministry files. - [ ] **Step 1: Add the field to the ministry model** In `APP/src/app/features/ministry/models/ministry.model.ts`: add `defaultFunctionalClass: string;` to `MinistryDto`, and `defaultFunctionalClass: string | null;` to `CreateMinistryRequest`/`UpdateMinistryRequest`. - [ ] **Step 2: Add the control to the ministry edit dialog** In the ministries page component `.ts`, add `defaultFunctionalClass: 'Program'` to the form object and the same `functionalClassOptions` list as Task 14 step 1; include it in create/update payloads; prefill on edit. In the `.html`, inside the dialog's Tailwind field grid, add: ```html ``` - [ ] **Step 3: Verify compile + manual check** Run: `cd APP && npx ng build --configuration development` → succeeds. Verify in preview: edit the Administration ministry, confirm it shows "Management & General", change Worship and save. - [ ] **Step 4: Commit** ```bash git add APP/src/app/features/ministry/ git commit -m "feat(web): default functional class on the ministry form" ``` --- ## Task 16: Frontend — Form 990 report page **Files:** - Create: `APP/src/app/features/finance-report/services/form990-report-api.service.ts` - Create: `APP/src/app/features/finance-report/pages/form990-report-page/form990-report-page.component.{ts,html}` - Modify: `APP/src/app/app.routes.ts` - Modify: `APP/src/app/portals/user-portal/user-portal.component.ts` - [ ] **Step 1: Add statement model + API service** Add to `form990-report.model.ts`: ```typescript export interface FunctionalExpenseRowDto { lineCode: string; name_en: string; name_zh: string | null; program: number; managementGeneral: number; fundraising: number; total: number; } export interface FunctionalExpenseStatementDto { rows: FunctionalExpenseRowDto[]; programTotal: number; managementGeneralTotal: number; fundraisingTotal: number; grandTotal: number; unmappedExpenseCount: number; } ``` Create `form990-report-api.service.ts` (mirror an existing finance API service for HttpClient/ApiConfig usage): ```typescript import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiConfigService } from ''; import { FunctionalExpenseStatementDto } from '../models/form990-report.model'; @Injectable({ providedIn: 'root' }) export class Form990ReportApiService { private http = inject(HttpClient); private apiConfig = inject(ApiConfigService); private base = this.apiConfig.getApiUrl('form990-report'); getFunctionalExpenses(from?: string, to?: string): Observable { let params = new HttpParams(); if (from) params = params.set('from', from); if (to) params = params.set('to', to); return this.http.get(this.base + '/functional-expenses', { params }); } } ``` - [ ] **Step 2: Create the report page component (.ts)** `form990-report-page.component.ts` — standalone component importing Kendo Grid + Buttons + FormsModule + CommonModule and `PageHeaderActionsDirective` if used elsewhere. It loads the statement, exposes a date range (default current year), and exposes `rows`, totals, and `unmappedExpenseCount`. Keep it focused: a single `load()` that calls the service and assigns the result. ```typescript import { Component, OnInit, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { GridModule } from '@progress/kendo-angular-grid'; import { DatePickerModule } from '@progress/kendo-angular-dateinputs'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { Form990ReportApiService } from '../../services/form990-report-api.service'; import { FunctionalExpenseStatementDto } from '../../models/form990-report.model'; @Component({ selector: 'app-form990-report-page', standalone: true, imports: [CommonModule, FormsModule, GridModule, DatePickerModule, ButtonsModule], templateUrl: './form990-report-page.component.html', }) export class Form990ReportPageComponent implements OnInit { private api = inject(Form990ReportApiService); from: Date = new Date(new Date().getFullYear(), 0, 1); to: Date = new Date(new Date().getFullYear(), 11, 31); statement: FunctionalExpenseStatementDto | null = null; loading = false; ngOnInit(): void { this.load(); } load(): void { this.loading = true; // Local yyyy-MM-dd (never toISOString — it shifts the day by timezone). const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.api.getFunctionalExpenses(fmt(this.from), fmt(this.to)).subscribe({ next: s => { this.statement = s; this.loading = false; }, error: () => { this.loading = false; }, }); } } ``` > Date formatting uses local Y/M/D, never `toISOString()` (timezone-safe, per project convention). - [ ] **Step 3: Create the template (.html) — desktop grid + mobile cards** `form990-report-page.component.html`: ```html
{{ statement?.unmappedExpenseCount }} expense(s) have no Form 990 mapping — counted under line 24. 尚有支出未對應 990 行,已暫計入 line 24。
{{ r.lineCode }} — {{ r.name_en }}
Program{{ r.program | currency }}
M&G{{ r.managementGeneral | currency }}
Fundraising{{ r.fundraising | currency }}
Total{{ r.total | currency }}
``` - [ ] **Step 4: Add the route** In `app.routes.ts`, add (matching the existing finance route shape): ```typescript { path: 'finance/form-990-report', loadComponent: () => import('./features/finance-report/pages/form990-report-page/form990-report-page.component').then(m => m.Form990ReportPageComponent), canActivate: [PermissionGuard], data: { permission: { module: PermissionModules.Form990Report, action: 'read' }, title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance', }, }, ``` (If sibling finance routes use eager `component:` imports instead of `loadComponent`, follow that style and add the import at the top.) - [ ] **Step 5: Add the sidebar nav entry** In `user-portal.component.ts`, add to the `financeGroups` "Overview" group's `items` array: ```typescript { text: 'Form 990 Report', icon: fileReportIcon, path: '/user-portal/finance/form-990-report', permission: { module: PermissionModules.Form990Report, action: 'read' } }, ``` (`fileReportIcon` is already imported for "Monthly Statement".) - [ ] **Step 6: Verify compile + manual check in the preview** Run: `cd APP && npx ng build --configuration development` → succeeds. Then verify the feature end-to-end. The dev API must be running (see build/run env note) and seeded; create a couple of Paid expenses across two ministries, open **Finance → Form 990 Report**, and confirm: rows show by 990 line, the three functional columns + totals sum to the expense total, and changing the date range refreshes. (Use the preview tooling for screenshots; do not ask the user to check manually.) - [ ] **Step 7: Commit** ```bash git add APP/src/app/features/finance-report/ APP/src/app/app.routes.ts APP/src/app/portals/user-portal/user-portal.component.ts git commit -m "feat(web): Form 990 functional-expenses report page, route, and nav" ``` --- ## Task 17: Update DB_SCHEMA.md **Files:** - Modify: `docs/DB_SCHEMA.md` - [ ] **Step 1: Document the schema changes** In `docs/DB_SCHEMA.md` §8 Expense Tracking: add the `Form990ExpenseLines` table; add the `Form990LineId` column to `ExpenseCategoryGroups` and `ExpenseSubCategories`; add `FunctionalClass` to `Expenses`; in §5 Ministry add `DefaultFunctionalClass`. Update the §8 seed lists to reflect the 14 groups + new subcategories and the renamed subcategories (Disposable Tableware, Curriculum Printing). Add a short note describing the Part IX line catalog and the effective-line/effective-function resolution. - [ ] **Step 2: Commit** ```bash git add docs/DB_SCHEMA.md git commit -m "docs: sync DB_SCHEMA with Form 990 functional-expense schema" ``` --- ## Self-Review (completed during planning) - **Spec coverage:** §2.1 functional class → Tasks 3, 4, 8; §2.2 line catalog + mapping → Tasks 1, 2, 7; §2.3 renames → Task 6; §2.4 new categories → Task 6; §3 report → Tasks 9–11, 16; §4 frontend → Tasks 12–16; §6 migration → Task 5; DB_SCHEMA sync → Task 17. Permission module (implied by §4 admin pages) → Task 9. All covered. - **Type consistency:** `Form990LineId` (int?), `Form990LineCode` (string?), `DefaultFunctionalClass` (string, default "Program"), `FunctionalClass` (string?), `FunctionalClasses.Normalize`, `GetFunctionalExpenseStatementAsync(from,to)` used consistently across backend tasks; frontend `functionalClass`/`form990LineId`/`form990LineCode` mirror the camelCased API. - **Known follow-ups (out of scope, per spec §7):** capital/depreciation (line 22 seeded, unmapped), 1099 (sub-project B), revenue Part VIII (sub-project C). Not gaps — deferred by design.