e908e35530
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 <noreply@anthropic.com>
1575 lines
71 KiB
Markdown
1575 lines
71 KiB
Markdown
# 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 <Name> --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;
|
||
|
||
/// <summary>
|
||
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
|
||
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass.
|
||
/// </summary>
|
||
public static class FunctionalClasses
|
||
{
|
||
public const string Program = "Program";
|
||
public const string ManagementGeneral = "ManagementGeneral";
|
||
public const string Fundraising = "Fundraising";
|
||
|
||
public static readonly IReadOnlyList<string> All = [Program, ManagementGeneral, Fundraising];
|
||
|
||
/// <summary>Returns the value if valid, otherwise Program (the safe default).</summary>
|
||
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;
|
||
|
||
/// <summary>A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages".</summary>
|
||
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<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||
```
|
||
And add an entity config block right before the `// ── ExpenseCategoryGroup` block (around line 205):
|
||
```csharp
|
||
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
|
||
builder.Entity<Form990ExpenseLine>(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<IHttpContextAccessor>();
|
||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||
.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/<timestamp>_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<IHttpContextAccessor>();
|
||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||
.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;
|
||
|
||
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
|
||
public class FunctionalExpenseStatementDto
|
||
{
|
||
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
|
||
public decimal ProgramTotal { get; set; }
|
||
public decimal ManagementGeneralTotal { get; set; }
|
||
public decimal FundraisingTotal { get; set; }
|
||
public decimal GrandTotal { get; set; }
|
||
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
|
||
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<IHttpContextAccessor>();
|
||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||
.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<FunctionalExpenseStatementDto> 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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public class Form990ReportService : IForm990ReportService
|
||
{
|
||
private readonly AppDbContext _db;
|
||
public Form990ReportService(AppDbContext db) => _db = db;
|
||
|
||
public async Task<FunctionalExpenseStatementDto> 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<int, (decimal P, decimal M, decimal F)>();
|
||
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<IForm990ReportService, Form990ReportService>();
|
||
```
|
||
|
||
- [ ] **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<IActionResult> 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 <permission-modules-file>
|
||
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<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
|
||
```
|
||
Add to `IForm990ReportService` + implement in `Form990ReportService`:
|
||
```csharp
|
||
Task<List<Form990ExpenseLineDto>> GetLinesAsync();
|
||
```
|
||
```csharp
|
||
public async Task<List<Form990ExpenseLineDto>> 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<Form990ExpenseLineDto[]> {
|
||
return this.http.get<Form990ExpenseLineDto[]>(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
|
||
<label class="flex flex-col gap-1">
|
||
<span>Form 990 Line / 990 行</span>
|
||
<kendo-dropdownlist
|
||
[data]="form990Lines"
|
||
textField="label" valueField="id" [valuePrimitive]="true"
|
||
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
|
||
[(ngModel)]="form.form990LineId">
|
||
</kendo-dropdownlist>
|
||
</label>
|
||
```
|
||
(`[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
|
||
<label class="flex flex-col gap-1">
|
||
<span>Functional Class / 功能別</span>
|
||
<kendo-dropdownlist
|
||
[data]="functionalClassOptions"
|
||
textField="label" valueField="value" [valuePrimitive]="true"
|
||
[defaultItem]="{ value: null, label: '(Inherit ministry / 沿用事工)' }"
|
||
[(ngModel)]="form.functionalClass">
|
||
</kendo-dropdownlist>
|
||
</label>
|
||
```
|
||
|
||
- [ ] **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
|
||
<label class="flex flex-col gap-1">
|
||
<span>Default Functional Class / 預設功能別</span>
|
||
<kendo-dropdownlist
|
||
[data]="functionalClassOptions"
|
||
textField="label" valueField="value" [valuePrimitive]="true"
|
||
[(ngModel)]="form.defaultFunctionalClass">
|
||
</kendo-dropdownlist>
|
||
</label>
|
||
```
|
||
|
||
- [ ] **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 '<existing path to ApiConfigService>';
|
||
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<FunctionalExpenseStatementDto> {
|
||
let params = new HttpParams();
|
||
if (from) params = params.set('from', from);
|
||
if (to) params = params.set('to', to);
|
||
return this.http.get<FunctionalExpenseStatementDto>(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
|
||
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||
<label class="flex flex-col gap-1"><span>From / 起</span>
|
||
<kendo-datepicker [(value)]="from"></kendo-datepicker></label>
|
||
<label class="flex flex-col gap-1"><span>To / 迄</span>
|
||
<kendo-datepicker [(value)]="to"></kendo-datepicker></label>
|
||
<button kendoButton themeColor="primary" (click)="load()">Apply / 套用</button>
|
||
</div>
|
||
|
||
<div *ngIf="statement?.unmappedExpenseCount" class="mb-3 p-2 rounded bg-amber-50 text-amber-800 text-sm">
|
||
{{ statement?.unmappedExpenseCount }} expense(s) have no Form 990 mapping — counted under line 24.
|
||
尚有支出未對應 990 行,已暫計入 line 24。
|
||
</div>
|
||
|
||
<!-- Desktop -->
|
||
<div class="hidden md:block">
|
||
<kendo-grid [data]="statement?.rows ?? []" [hideHeader]="false">
|
||
<kendo-grid-column field="lineCode" title="Line" [width]="80"></kendo-grid-column>
|
||
<kendo-grid-column field="name_en" title="Description / 說明"></kendo-grid-column>
|
||
<kendo-grid-column field="program" title="Program" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||
<kendo-grid-column field="managementGeneral" title="Mgmt & General" format="{0:c2}" [width]="150"></kendo-grid-column>
|
||
<kendo-grid-column field="fundraising" title="Fundraising" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||
<kendo-grid-column field="total" title="Total" format="{0:c2}" [width]="140"></kendo-grid-column>
|
||
</kendo-grid>
|
||
<div class="flex justify-end gap-8 mt-2 font-semibold" *ngIf="statement">
|
||
<span>Program: {{ statement.programTotal | currency }}</span>
|
||
<span>M&G: {{ statement.managementGeneralTotal | currency }}</span>
|
||
<span>Fundraising: {{ statement.fundraisingTotal | currency }}</span>
|
||
<span>Total: {{ statement.grandTotal | currency }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Mobile cards (flex utilities only — never set display in SCSS on a md:hidden element) -->
|
||
<div class="md:hidden flex flex-col gap-3">
|
||
<div *ngFor="let r of statement?.rows ?? []" class="rounded border p-3">
|
||
<div class="font-semibold">{{ r.lineCode }} — {{ r.name_en }}</div>
|
||
<div class="text-sm flex justify-between"><span>Program</span><span>{{ r.program | currency }}</span></div>
|
||
<div class="text-sm flex justify-between"><span>M&G</span><span>{{ r.managementGeneral | currency }}</span></div>
|
||
<div class="text-sm flex justify-between"><span>Fundraising</span><span>{{ r.fundraising | currency }}</span></div>
|
||
<div class="text-sm flex justify-between font-semibold"><span>Total</span><span>{{ r.total | currency }}</span></div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **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.
|