From e908e35530032525e75364d5f6785f885a2a6fd0 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Wed, 24 Jun 2026 18:41:35 -0700 Subject: [PATCH] docs: implementation plan for sub-project A (Form 990 functional expenses) 17 TDD tasks: Form990ExpenseLine catalog + category mapping, Ministry DefaultFunctionalClass, Expense FunctionalClass override, EF migration, seed (new categories/renames/line catalog/mappings/ministry defaults), Form990ReportService Part IX aggregation + controller, and the frontend (category line mapping, expense + ministry functional-class controls, report page/route/nav). DB_SCHEMA sync. Co-Authored-By: Claude Opus 4.8 --- ...6-06-24-expense-990-functional-expenses.md | 1574 +++++++++++++++++ 1 file changed, 1574 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-24-expense-990-functional-expenses.md diff --git a/docs/superpowers/plans/2026-06-24-expense-990-functional-expenses.md b/docs/superpowers/plans/2026-06-24-expense-990-functional-expenses.md new file mode 100644 index 0000000..5859513 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-expense-990-functional-expenses.md @@ -0,0 +1,1574 @@ +# 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.