# 支出追蹤 & 報銷 Expense Tracking & Reimbursement — 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:** Build the full church expense module — category seed, vendor direct payments, staff reimbursements with receipt upload and self-service submission, a finance approval workflow, and a monthly reconciliation statement. **Architecture:** ASP.NET Core 8 Web API (thin controllers → services → EF Core/PostgreSQL) mirroring the existing Giving module, plus an Angular standalone-component frontend under `features/expense/`. Receipts go through a new `IFileStorage` abstraction (local-disk impl now, Azure Blob later). A new `Ministry` entity is added as a prerequisite (it does not exist yet but `Expense.MinistryId` requires it). **Tech Stack:** C# / EF Core 8 / Npgsql / xUnit + Moq + EF InMemory; Angular 20 + Kendo UI + RxJS + Tailwind v4. **Spec:** `docs/superpowers/specs/2026-05-29-expense-tracking-design.md` **Reference patterns (read before starting):** - Entity: `API/ROLAC.API/Entities/GivingCategory.cs`, `Giving.cs`, base `Entities/Base/{AuditableEntity,SoftDeleteEntity}.cs` - DbContext config: `API/ROLAC.API/Data/AppDbContext.cs` - Seed: `API/ROLAC.API/Data/DbSeeder.cs` - Service: `API/ROLAC.API/Services/GivingCategoryService.cs`, `GivingService.cs` - Controller: `API/ROLAC.API/Controllers/GivingCategoriesController.cs`, `GivingsController.cs` - Test: `API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs` - Frontend model/service/page: `APP/src/app/features/giving/{models/giving.model.ts,services/giving-category-api.service.ts,pages/giving-categories-page/}` - Routes/nav: `APP/src/app/app.routes.ts`, `APP/src/app/portals/user-portal/user-portal.component.{ts,html}` **Conventions:** - Audit fields (`CreatedAt/By`, `UpdatedAt/By`) are stamped automatically by `AuditSaveChangesInterceptor`. **Do not set them manually.** - The interceptor does **NOT** convert deletes to soft-deletes. Soft-delete (`Expense`) is handled manually in the service. - `CurrentUserId` comes from `IHttpContextAccessor` claim `ClaimTypes.NameIdentifier`. - Build/test from CLI with `-c Release` (VS locks `bin/Debug`). **Commands:** - Build API: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` - Run all tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` - Run one test: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseServiceTests.Method"` - Add migration: `dotnet ef migrations add AddExpenseModule -p API/ROLAC.API/ROLAC.API.csproj -s API/ROLAC.API/ROLAC.API.csproj` - Frontend build: `cd APP; npm run build` --- ## Task 1: Ministry entity + seed + read endpoint (prerequisite) `Expense.MinistryId` is a required FK but no `Ministry` table exists yet. Create it per DB_SCHEMA §5 and seed the 10 ministries (§16). **Files:** - Create: `API/ROLAC.API/Entities/Ministry.cs` - Create: `API/ROLAC.API/DTOs/Ministry/MinistryDto.cs` - Create: `API/ROLAC.API/Services/IMinistryService.cs` - Create: `API/ROLAC.API/Services/MinistryService.cs` - Create: `API/ROLAC.API/Controllers/MinistriesController.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - Modify: `API/ROLAC.API/Data/DbSeeder.cs` - Modify: `API/ROLAC.API/Program.cs` - Test: `API/ROLAC.API.Tests/Services/MinistryServiceTests.cs` - [ ] **Step 1: Create the Ministry entity** `API/ROLAC.API/Entities/Ministry.cs`: ```csharp namespace ROLAC.API.Entities; public class Ministry { public int Id { get; set; } public string Name_en { get; set; } = null!; public string? Name_zh { get; set; } public string? Description_en { get; set; } public string? Description_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } = true; } ``` - [ ] **Step 2: Register DbSet + config in AppDbContext** In `AppDbContext.cs` add the DbSet near the other Phase-1 sets (after `Givings`): ```csharp public DbSet Ministries => Set(); ``` At the end of `OnModelCreating` add: ```csharp // ── Ministry ───────────────────────────────────────────────────────── builder.Entity(entity => { entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); entity.Property(e => e.Name_zh).HasMaxLength(200); }); ``` - [ ] **Step 3: Add ministry seed to DbSeeder** In `DbSeeder.cs` add a seed array near `GivingCategorySeed`: ```csharp private static readonly (string En, string Zh, int Sort)[] MinistrySeed = [ ("Administration", "行政", 1), ("Preaching", "講道", 2), ("Emcee", "司會", 3), ("Worship", "敬拜", 4), ("PPT/Media", "PPT/影音", 5), ("Sound", "音控", 6), ("Facility", "場地組", 7), ("Hospitality", "招待", 8), ("Children", "兒牧", 9), ("Catering", "餐飲", 10), ]; ``` Add the seed method: ```csharp public static async Task SeedMinistriesAsync(AppDbContext db) { foreach (var (en, zh, sort) in MinistrySeed) { 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 }); } await db.SaveChangesAsync(); } ``` In `SeedAsync`, after `await SeedGivingCategoriesAsync(db);` add: ```csharp await SeedMinistriesAsync(db); ``` - [ ] **Step 4: Create MinistryDto + service interface + impl** `API/ROLAC.API/DTOs/Ministry/MinistryDto.cs`: ```csharp namespace ROLAC.API.DTOs.Ministry; public class MinistryDto { public int Id { get; set; } public string Name_en { get; set; } = ""; public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } } ``` `API/ROLAC.API/Services/IMinistryService.cs`: ```csharp using ROLAC.API.DTOs.Ministry; namespace ROLAC.API.Services; public interface IMinistryService { Task> GetAllAsync(bool includeInactive); } ``` `API/ROLAC.API/Services/MinistryService.cs`: ```csharp using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Ministry; namespace ROLAC.API.Services; public class MinistryService : IMinistryService { private readonly AppDbContext _db; public MinistryService(AppDbContext db) => _db = db; public async Task> GetAllAsync(bool includeInactive) { var query = _db.Ministries.AsNoTracking().AsQueryable(); if (!includeInactive) query = query.Where(m => m.IsActive); return await query .OrderBy(m => m.SortOrder).ThenBy(m => m.Name_en) .Select(m => new MinistryDto { Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh, SortOrder = m.SortOrder, IsActive = m.IsActive, }) .ToListAsync(); } } ``` - [ ] **Step 5: Create the read-only controller** `API/ROLAC.API/Controllers/MinistriesController.cs` — any authenticated user may read (needed by the member self-service reimbursement form): ```csharp using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ROLAC.API.Services; namespace ROLAC.API.Controllers; [ApiController] [Route("api/ministries")] [Authorize] public class MinistriesController : ControllerBase { private readonly IMinistryService _svc; public MinistriesController(IMinistryService svc) => _svc = svc; [HttpGet] public async Task GetAll([FromQuery] bool includeInactive = false) => Ok(await _svc.GetAllAsync(includeInactive)); } ``` - [ ] **Step 6: Register the service in Program.cs** In `Program.cs` after `AddScoped` add: ```csharp builder.Services.AddScoped(); ``` - [ ] **Step 7: Write the test** `API/ROLAC.API.Tests/Services/MinistryServiceTests.cs` (mirror `GivingCategoryServiceTests` BuildDb helper): ```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 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(mock.Object)).Options); } [Fact] public async Task GetAllAsync_OrdersBySortOrder_AndExcludesInactive() { using var db = BuildDb(); db.Ministries.AddRange( new Ministry { Name_en = "B", SortOrder = 2, IsActive = true }, new Ministry { Name_en = "A", SortOrder = 1, IsActive = true }, new Ministry { Name_en = "Z", SortOrder = 3, IsActive = false }); await db.SaveChangesAsync(); var svc = new MinistryService(db); var active = await svc.GetAllAsync(includeInactive: false); var all = await svc.GetAllAsync(includeInactive: true); Assert.Equal(2, active.Count); Assert.Equal("A", active[0].Name_en); Assert.Equal(3, all.Count); } } ``` - [ ] **Step 8: Build + run tests** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~MinistryServiceTests"` Expected: PASS (1 test). - [ ] **Step 9: Commit** ```bash git add API/ROLAC.API/Entities/Ministry.cs API/ROLAC.API/DTOs/Ministry/ API/ROLAC.API/Services/IMinistryService.cs API/ROLAC.API/Services/MinistryService.cs API/ROLAC.API/Controllers/MinistriesController.cs API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Data/DbSeeder.cs API/ROLAC.API/Program.cs API/ROLAC.API.Tests/Services/MinistryServiceTests.cs git commit -m "feat(ministry): add Ministry entity, seed (10), and read endpoint" ``` --- ## Task 2: Expense category entities + seed **Files:** - Create: `API/ROLAC.API/Entities/ExpenseCategoryGroup.cs` - Create: `API/ROLAC.API/Entities/ExpenseSubCategory.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - Modify: `API/ROLAC.API/Data/DbSeeder.cs` - [ ] **Step 1: Create ExpenseCategoryGroup entity** `API/ROLAC.API/Entities/ExpenseCategoryGroup.cs`: ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; public class ExpenseCategoryGroup : AuditableEntity { public int Id { get; set; } public string Name_en { get; set; } = null!; public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } = true; public List SubCategories { get; set; } = []; } ``` - [ ] **Step 2: Create ExpenseSubCategory entity** `API/ROLAC.API/Entities/ExpenseSubCategory.cs`: ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; public class ExpenseSubCategory : AuditableEntity { public int Id { get; set; } public int GroupId { get; set; } public string Name_en { get; set; } = null!; public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } = true; public ExpenseCategoryGroup? Group { get; set; } } ``` - [ ] **Step 3: Register DbSets + config in AppDbContext** Add DbSets after `Ministries`: ```csharp public DbSet ExpenseCategoryGroups => Set(); public DbSet ExpenseSubCategories => Set(); ``` Add config at the end of `OnModelCreating`: ```csharp // ── ExpenseCategoryGroup ───────────────────────────────────────────── builder.Entity(entity => { 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); }); // ── ExpenseSubCategory ─────────────────────────────────────────────── builder.Entity(entity => { 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.HasOne(e => e.Group).WithMany(g => g.SubCategories) .HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict); }); ``` - [ ] **Step 4: Add the category seed to DbSeeder** In `DbSeeder.cs` add the seed data (11 groups + 38 subs from DB_SCHEMA §8): ```csharp // (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)]) 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","器具"),("Consumables","消耗品")]), ("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]), ("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]), ("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]), ("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]), ("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]), ("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]), ("Other", "其他", 10, [("Miscellaneous","雜支")]), ("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]), ]; ``` Add the seed method: ```csharp public static async Task SeedExpenseCategoriesAsync(AppDbContext db) { foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed) { var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn); if (group is null) { group = new ExpenseCategoryGroup { Name_en = gEn, Name_zh = gZh, SortOrder = gSort, IsActive = true }; db.ExpenseCategoryGroups.Add(group); await db.SaveChangesAsync(); // assign group.Id } var sub = 1; foreach (var (sEn, sZh) in subs) { if (!await db.ExpenseSubCategories.AnyAsync(s => s.GroupId == group.Id && s.Name_en == sEn)) db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = group.Id, Name_en = sEn, Name_zh = sZh, SortOrder = sub, IsActive = true }); sub++; } } await db.SaveChangesAsync(); } ``` In `SeedAsync`, after `await SeedMinistriesAsync(db);` add: ```csharp await SeedExpenseCategoriesAsync(db); ``` - [ ] **Step 5: Build** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded. - [ ] **Step 6: 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/Data/DbSeeder.cs git commit -m "feat(expense): add expense category entities + seed (11 groups / 38 subs)" ``` --- ## Task 3: Expense + MonthlyStatement entities **Files:** - Create: `API/ROLAC.API/Entities/Expense.cs` - Create: `API/ROLAC.API/Entities/MonthlyStatement.cs` - Modify: `API/ROLAC.API/Data/AppDbContext.cs` - [ ] **Step 1: Create the Expense entity** `API/ROLAC.API/Entities/Expense.cs`: ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; public class Expense : SoftDeleteEntity { public int Id { get; set; } public int MinistryId { get; set; } public int CategoryGroupId { get; set; } public int SubCategoryId { get; set; } public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement public string Status { get; set; } = "Draft"; // see state machine public decimal Amount { get; set; } public string Description { get; set; } = null!; public string? VendorName { get; set; } public int? MemberId { get; set; } public string? CheckNumber { get; set; } public DateOnly ExpenseDate { get; set; } public string? ReceiptBlobPath { get; set; } public string? Notes { get; set; } public string? SubmittedBy { get; set; } public DateTimeOffset? SubmittedAt { get; set; } public string? ReviewedBy { get; set; } public DateTimeOffset? ReviewedAt { get; set; } public string? ReviewNotes { get; set; } public DateTimeOffset? PaidAt { get; set; } public string? PaidBy { get; set; } public Ministry? Ministry { get; set; } public ExpenseCategoryGroup? CategoryGroup { get; set; } public ExpenseSubCategory? SubCategory { get; set; } public Member? Member { get; set; } } ``` - [ ] **Step 2: Create the MonthlyStatement entity** `API/ROLAC.API/Entities/MonthlyStatement.cs`: ```csharp using ROLAC.API.Entities.Base; namespace ROLAC.API.Entities; public class MonthlyStatement : AuditableEntity { public int Id { get; set; } public int Year { get; set; } public int Month { get; set; } public decimal OpeningBalance { get; set; } public decimal TotalGiving { get; set; } public decimal TotalOtherIncome { get; set; } public decimal TotalExpenses { get; set; } public decimal CalculatedClosingBalance { get; set; } public decimal BankStatementBalance { get; set; } public decimal Difference { get; set; } public string? Notes { get; set; } public bool IsFinalized { get; set; } public DateTimeOffset? FinalizedAt { get; set; } public string? FinalizedBy { get; set; } } ``` - [ ] **Step 3: Register DbSets + config in AppDbContext** Add DbSets after `ExpenseSubCategories`: ```csharp public DbSet Expenses => Set(); public DbSet MonthlyStatements => Set(); ``` Add config at the end of `OnModelCreating`: ```csharp // ── Expense ────────────────────────────────────────────────────────── builder.Entity(entity => { entity.HasQueryFilter(e => !e.IsDeleted); entity.Property(e => e.Type).HasMaxLength(30).IsRequired(); entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft"); entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); entity.Property(e => e.Description).HasMaxLength(500).IsRequired(); entity.Property(e => e.VendorName).HasMaxLength(200); entity.Property(e => e.CheckNumber).HasMaxLength(50); entity.Property(e => e.ReceiptBlobPath).HasMaxLength(500); entity.Property(e => e.ReviewNotes).HasMaxLength(500); entity.Property(e => e.SubmittedBy).HasMaxLength(450); entity.Property(e => e.ReviewedBy).HasMaxLength(450); entity.Property(e => e.PaidBy).HasMaxLength(450); entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.DeletedBy).HasMaxLength(450); entity.HasIndex(e => e.MinistryId); entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false"); entity.HasIndex(e => e.ExpenseDate); entity.HasOne(e => e.Ministry).WithMany() .HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.CategoryGroup).WithMany() .HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.SubCategory).WithMany() .HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.Member).WithMany() .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); }); // ── MonthlyStatement ───────────────────────────────────────────────── builder.Entity(entity => { entity.Property(e => e.OpeningBalance).HasColumnType("decimal(18,2)"); entity.Property(e => e.TotalGiving).HasColumnType("decimal(18,2)"); entity.Property(e => e.TotalOtherIncome).HasColumnType("decimal(18,2)"); entity.Property(e => e.TotalExpenses).HasColumnType("decimal(18,2)"); entity.Property(e => e.CalculatedClosingBalance).HasColumnType("decimal(18,2)"); entity.Property(e => e.BankStatementBalance).HasColumnType("decimal(18,2)"); entity.Property(e => e.Difference).HasColumnType("decimal(18,2)"); entity.Property(e => e.FinalizedBy).HasMaxLength(450); entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasIndex(e => new { e.Year, e.Month }).IsUnique(); }); ``` - [ ] **Step 4: Build** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/Entities/Expense.cs API/ROLAC.API/Entities/MonthlyStatement.cs API/ROLAC.API/Data/AppDbContext.cs git commit -m "feat(expense): add Expense + MonthlyStatement entities and EF config" ``` --- ## Task 4: EF migration **Files:** - Create: `API/ROLAC.API/Migrations/_AddExpenseModule.cs` (generated) - [ ] **Step 1: Generate the migration** Run: `dotnet ef migrations add AddExpenseModule -p API/ROLAC.API/ROLAC.API.csproj -s API/ROLAC.API/ROLAC.API.csproj` Expected: creates `Migrations/_AddExpenseModule.cs` + `.Designer.cs` + updates snapshot. - [ ] **Step 2: Review the generated migration** Open the generated `_AddExpenseModule.cs` and confirm it creates tables `Ministries`, `ExpenseCategoryGroups`, `ExpenseSubCategories`, `Expenses`, `MonthlyStatements` with the expected FKs and indexes (unique `(Year,Month)`; `Expenses` filtered Status index). No data-loss operations on existing tables. - [ ] **Step 3: Build (verifies 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(expense): add AddExpenseModule EF migration" ``` --- ## Task 5: IFileStorage abstraction + local-disk implementation **Files:** - Create: `API/ROLAC.API/Services/Storage/IFileStorage.cs` - Create: `API/ROLAC.API/Services/Storage/LocalDiskFileStorage.cs` - Modify: `API/ROLAC.API/Program.cs` - Modify: `API/ROLAC.API/appsettings.json` - Modify: `.gitignore` - Test: `API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs` - [ ] **Step 1: Create the interface** `API/ROLAC.API/Services/Storage/IFileStorage.cs`: ```csharp namespace ROLAC.API.Services.Storage; public interface IFileStorage { Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default); Task OpenReadAsync(string relativePath, CancellationToken ct = default); Task DeleteAsync(string relativePath, CancellationToken ct = default); } ``` - [ ] **Step 2: Write the failing test** `API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs`: ```csharp using System.Text; using Microsoft.Extensions.Configuration; using ROLAC.API.Services.Storage; using Xunit; namespace ROLAC.API.Tests.Services; public class LocalDiskFileStorageTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), "rolac-test-" + Guid.NewGuid()); private LocalDiskFileStorage Build() { var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["Storage:LocalRoot"] = _root }) .Build(); return new LocalDiskFileStorage(config); } [Fact] public async Task SaveThenOpen_RoundTrips() { var fs = Build(); using var input = new MemoryStream(Encoding.UTF8.GetBytes("hello")); var path = await fs.SaveAsync(input, "finance/receipts/2026/5/1-r.txt"); await using var read = await fs.OpenReadAsync(path); Assert.NotNull(read); using var sr = new StreamReader(read!); Assert.Equal("hello", await sr.ReadToEndAsync()); } [Fact] public async Task OpenRead_ReturnsNull_WhenMissing() { var fs = Build(); Assert.Null(await fs.OpenReadAsync("finance/receipts/none.txt")); } [Fact] public async Task Save_RejectsPathTraversal() { var fs = Build(); using var input = new MemoryStream(Encoding.UTF8.GetBytes("x")); await Assert.ThrowsAsync(() => fs.SaveAsync(input, "../escape.txt")); } public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } } ``` - [ ] **Step 3: Run the test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LocalDiskFileStorageTests"` Expected: FAIL/compile error — `LocalDiskFileStorage` not defined. - [ ] **Step 4: Implement LocalDiskFileStorage** `API/ROLAC.API/Services/Storage/LocalDiskFileStorage.cs`: ```csharp using Microsoft.Extensions.Configuration; namespace ROLAC.API.Services.Storage; public class LocalDiskFileStorage : IFileStorage { private readonly string _root; public LocalDiskFileStorage(IConfiguration config) { var configured = config["Storage:LocalRoot"] ?? "App_Data/storage"; _root = Path.IsPathRooted(configured) ? configured : Path.Combine(Directory.GetCurrentDirectory(), configured); Directory.CreateDirectory(_root); } private string Resolve(string relativePath) { if (string.IsNullOrWhiteSpace(relativePath)) throw new ArgumentException("relativePath is required.", nameof(relativePath)); var normalized = relativePath.Replace('\\', '/').TrimStart('/'); var full = Path.GetFullPath(Path.Combine(_root, normalized)); var rootFull = Path.GetFullPath(_root) + Path.DirectorySeparatorChar; if (!full.StartsWith(rootFull, StringComparison.Ordinal)) throw new ArgumentException("Path escapes storage root.", nameof(relativePath)); return full; } public async Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default) { var full = Resolve(relativePath); Directory.CreateDirectory(Path.GetDirectoryName(full)!); await using var file = File.Create(full); await content.CopyToAsync(file, ct); return relativePath.Replace('\\', '/').TrimStart('/'); } public Task OpenReadAsync(string relativePath, CancellationToken ct = default) { var full = Resolve(relativePath); Stream? result = File.Exists(full) ? File.OpenRead(full) : null; return Task.FromResult(result); } public Task DeleteAsync(string relativePath, CancellationToken ct = default) { var full = Resolve(relativePath); if (File.Exists(full)) File.Delete(full); return Task.CompletedTask; } } ``` - [ ] **Step 5: Run the test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LocalDiskFileStorageTests"` Expected: PASS (3 tests). - [ ] **Step 6: Register + configure** In `Program.cs` after the `IMinistryService` registration add: ```csharp builder.Services.AddScoped(); ``` In `appsettings.json` add a top-level section: ```json "Storage": { "LocalRoot": "App_Data/storage" }, ``` In `.gitignore` add: ``` API/ROLAC.API/App_Data/ ``` - [ ] **Step 7: Commit** ```bash git add API/ROLAC.API/Services/Storage/ API/ROLAC.API/Program.cs API/ROLAC.API/appsettings.json .gitignore API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs git commit -m "feat(storage): add IFileStorage + local-disk implementation" ``` --- ## Task 6: DTOs (categories, expense, monthly statement) **Files:** - Create: `API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs` - Create: `API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs` - Create: `API/ROLAC.API/DTOs/Expense/MonthlyStatementDtos.cs` - [ ] **Step 1: Category DTOs** `API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs`: ```csharp using System.ComponentModel.DataAnnotations; namespace ROLAC.API.DTOs.Expense; public class ExpenseSubCategoryDto { public int Id { get; set; } public int GroupId { get; set; } public string Name_en { get; set; } = ""; public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } } public class ExpenseCategoryGroupDto { public int Id { get; set; } public string Name_en { get; set; } = ""; public string? Name_zh { get; set; } public int SortOrder { get; set; } public bool IsActive { get; set; } public List SubCategories { get; set; } = []; } public class CreateExpenseGroupRequest { [Required, MaxLength(200)] public string Name_en { get; set; } = ""; [MaxLength(200)] public string? Name_zh { get; set; } public int SortOrder { get; set; } } public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest { public bool IsActive { get; set; } = true; } public class CreateExpenseSubCategoryRequest { [Required] public int GroupId { get; set; } [Required, MaxLength(200)] public string Name_en { get; set; } = ""; [MaxLength(200)] public string? Name_zh { get; set; } public int SortOrder { get; set; } } public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest { public bool IsActive { get; set; } = true; } ``` - [ ] **Step 2: Expense DTOs** `API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs`: ```csharp using System.ComponentModel.DataAnnotations; namespace ROLAC.API.DTOs.Expense; public class ExpenseListItemDto { public int Id { get; set; } public string Type { get; set; } = ""; public string Status { get; set; } = ""; public decimal Amount { get; set; } public string Description { get; set; } = ""; public int MinistryId { get; set; } public string MinistryName { get; set; } = ""; public int CategoryGroupId { get; set; } public string CategoryGroupName { get; set; } = ""; public int SubCategoryId { get; set; } public string SubCategoryName { get; set; } = ""; public string? VendorName { get; set; } public int? MemberId { get; set; } public string? MemberName { get; set; } public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public bool HasReceipt { get; set; } } public class ExpenseDto : ExpenseListItemDto { public string? CheckNumber { get; set; } public string? Notes { get; set; } public string? ReviewNotes { get; set; } public string? SubmittedBy { get; set; } public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? ReviewedAt { get; set; } public DateTimeOffset? PaidAt { get; set; } } public class CreateExpenseRequest { [Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement [Required] public int MinistryId { get; set; } [Required] public int CategoryGroupId { get; set; } [Required] public int SubCategoryId { get; set; } [Range(0.01, 9_999_999)] public decimal Amount { get; set; } [Required, MaxLength(500)] public string Description { get; set; } = ""; [MaxLength(200)] public string? VendorName { get; set; } public int? MemberId { get; set; } // ignored for self-service (server uses caller) [MaxLength(50)] public string? CheckNumber { get; set; } [Required] public DateOnly ExpenseDate { get; set; } public string? Notes { get; set; } } public class UpdateExpenseRequest : CreateExpenseRequest { } public class RejectExpenseRequest { [MaxLength(500)] public string? ReviewNotes { get; set; } } public class PayExpenseRequest { [MaxLength(50)] public string? CheckNumber { get; set; } public DateOnly? PaidAt { get; set; } } ``` - [ ] **Step 3: MonthlyStatement DTOs** `API/ROLAC.API/DTOs/Expense/MonthlyStatementDtos.cs`: ```csharp using System.ComponentModel.DataAnnotations; namespace ROLAC.API.DTOs.Expense; public class MonthlyStatementDto { public int Id { get; set; } public int Year { get; set; } public int Month { get; set; } public decimal OpeningBalance { get; set; } public decimal TotalGiving { get; set; } public decimal TotalOtherIncome { get; set; } public decimal TotalExpenses { get; set; } public decimal CalculatedClosingBalance { get; set; } public decimal BankStatementBalance { get; set; } public decimal Difference { get; set; } public string? Notes { get; set; } public bool IsFinalized { get; set; } } public class CreateMonthlyStatementRequest { [Range(2000, 2100)] public int Year { get; set; } [Range(1, 12)] public int Month { get; set; } public decimal OpeningBalance { get; set; } public decimal TotalOtherIncome { get; set; } public decimal BankStatementBalance { get; set; } public string? Notes { get; set; } } public class UpdateMonthlyStatementRequest { public decimal OpeningBalance { get; set; } public decimal TotalOtherIncome { get; set; } public decimal BankStatementBalance { get; set; } public string? Notes { get; set; } } ``` - [ ] **Step 4: Build** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` Expected: Build succeeded. - [ ] **Step 5: Commit** ```bash git add API/ROLAC.API/DTOs/Expense/ git commit -m "feat(expense): add expense, category, and monthly-statement DTOs" ``` --- ## Task 7: ExpenseCategoryService + tests **Files:** - Create: `API/ROLAC.API/Services/IExpenseCategoryService.cs` - Create: `API/ROLAC.API/Services/ExpenseCategoryService.cs` - Test: `API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs` - [ ] **Step 1: Interface** `API/ROLAC.API/Services/IExpenseCategoryService.cs`: ```csharp using ROLAC.API.DTOs.Expense; namespace ROLAC.API.Services; public interface IExpenseCategoryService { Task> GetAllAsync(bool includeInactive); Task CreateGroupAsync(CreateExpenseGroupRequest r); Task UpdateGroupAsync(int id, UpdateExpenseGroupRequest r); Task DeactivateGroupAsync(int id); Task CreateSubCategoryAsync(CreateExpenseSubCategoryRequest r); Task UpdateSubCategoryAsync(int id, UpdateExpenseSubCategoryRequest r); Task DeactivateSubCategoryAsync(int id); } ``` - [ ] **Step 2: Write the failing test** `API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.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.Expense; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class ExpenseCategoryServiceTests { 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(mock.Object)).Options); } [Fact] public async Task GetAll_NestsSubcategories_AndExcludesInactiveByDefault() { using var db = BuildDb(); var svc = new ExpenseCategoryService(db); var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Equipment" }); var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Purchase" }); await svc.DeactivateSubCategoryAsync(sid); var active = await svc.GetAllAsync(includeInactive: false); var all = await svc.GetAllAsync(includeInactive: true); Assert.Single(active); Assert.Empty(active[0].SubCategories); Assert.Single(all[0].SubCategories); } [Fact] public async Task DeactivateGroup_SetsInactive() { using var db = BuildDb(); var svc = new ExpenseCategoryService(db); var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Other" }); await svc.DeactivateGroupAsync(gid); Assert.Empty(await svc.GetAllAsync(includeInactive: false)); } [Fact] public async Task UpdateGroup_Throws_WhenMissing() { using var db = BuildDb(); var svc = new ExpenseCategoryService(db); await Assert.ThrowsAsync(() => svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" })); } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseCategoryServiceTests"` Expected: FAIL/compile error — `ExpenseCategoryService` not defined. - [ ] **Step 4: Implement the service** `API/ROLAC.API/Services/ExpenseCategoryService.cs`: ```csharp using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; using ROLAC.API.Entities; namespace ROLAC.API.Services; public class ExpenseCategoryService : IExpenseCategoryService { private readonly AppDbContext _db; public ExpenseCategoryService(AppDbContext db) => _db = db; public async Task> GetAllAsync(bool includeInactive) { var groups = await _db.ExpenseCategoryGroups.AsNoTracking() .Where(g => includeInactive || g.IsActive) .OrderBy(g => g.SortOrder).ThenBy(g => g.Name_en) .ToListAsync(); var subs = await _db.ExpenseSubCategories.AsNoTracking() .Where(s => includeInactive || s.IsActive) .OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en) .ToListAsync(); return groups.Select(g => new ExpenseCategoryGroupDto { Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, SortOrder = g.SortOrder, IsActive = g.IsActive, SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto { Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh, SortOrder = s.SortOrder, IsActive = s.IsActive, }).ToList(), }).ToList(); } public async Task CreateGroupAsync(CreateExpenseGroupRequest r) { var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; _db.ExpenseCategoryGroups.Add(g); await _db.SaveChangesAsync(); return g.Id; } public async Task UpdateGroupAsync(int id, UpdateExpenseGroupRequest r) { var g = await _db.ExpenseCategoryGroups.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; await _db.SaveChangesAsync(); } public async Task DeactivateGroupAsync(int id) { var g = await _db.ExpenseCategoryGroups.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); g.IsActive = false; await _db.SaveChangesAsync(); } public async Task CreateSubCategoryAsync(CreateExpenseSubCategoryRequest r) { var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; _db.ExpenseSubCategories.Add(s); await _db.SaveChangesAsync(); return s.Id; } public async Task UpdateSubCategoryAsync(int id, UpdateExpenseSubCategoryRequest r) { var s = await _db.ExpenseSubCategories.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; await _db.SaveChangesAsync(); } public async Task DeactivateSubCategoryAsync(int id) { var s = await _db.ExpenseSubCategories.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); s.IsActive = false; await _db.SaveChangesAsync(); } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseCategoryServiceTests"` Expected: PASS (3 tests). - [ ] **Step 6: Commit** ```bash git add API/ROLAC.API/Services/IExpenseCategoryService.cs API/ROLAC.API/Services/ExpenseCategoryService.cs API/ROLAC.API.Tests/Services/ExpenseCategoryServiceTests.cs git commit -m "feat(expense): add ExpenseCategoryService + tests" ``` --- ## Task 8: ExpenseService (CRUD + state machine + receipt) + tests **Files:** - Create: `API/ROLAC.API/Services/IExpenseService.cs` - Create: `API/ROLAC.API/Services/ExpenseService.cs` - Test: `API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs` The service depends on `IHttpContextAccessor` (for `CurrentUserId`) and `IFileStorage`. Authorization across "self vs finance" is enforced here: callers pass an `isFinance` flag computed by the controller from `User.IsInRole`. - [ ] **Step 1: Interface** `API/ROLAC.API/Services/IExpenseService.cs`: ```csharp using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Shared; namespace ROLAC.API.Services; public interface IExpenseService { Task> GetPagedAsync( int page, int pageSize, string? search, int? ministryId, int? categoryGroupId, string? status, DateOnly? from, DateOnly? to); Task> GetMineAsync(string userId, string? status, int page, int pageSize); Task GetByIdAsync(int id); Task CreateAsync(CreateExpenseRequest r, bool isFinance); Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance); Task DeleteAsync(int id, bool isFinance); Task SubmitAsync(int id); Task ApproveAsync(int id); Task RejectAsync(int id, string? reviewNotes); Task PayAsync(int id, string? checkNumber, DateOnly? paidAt); Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance); Task<(Stream stream, string contentType)?> OpenReceiptAsync(int id, bool isFinance); } ``` - [ ] **Step 2: Write the failing test** `API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs`: ```csharp using Microsoft.EntityFrameworkCore; using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Http; using Moq; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.DTOs.Expense; using ROLAC.API.Entities; using ROLAC.API.Services; using ROLAC.API.Services.Storage; using Xunit; namespace ROLAC.API.Tests.Services; public class ExpenseServiceTests { // In-memory IFileStorage stub. private sealed class FakeStorage : IFileStorage { public Dictionary Files = new(); public Task SaveAsync(Stream c, string p, CancellationToken ct = default) { using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); } public Task OpenReadAsync(string p, CancellationToken ct = default) => Task.FromResult(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null); public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; } } private static AppDbContext BuildDb(string userId) { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; var mock = new Mock(); mock.Setup(x => x.HttpContext).Returns(ctx); return new AppDbContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); } private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1") { var db = BuildDb(userId); db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" }); db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" }); db.SaveChanges(); var http = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; http.Setup(x => x.HttpContext).Returns(ctx); var fs = new FakeStorage(); return (new ExpenseService(db, http.Object, fs), db, fs); } private static CreateExpenseRequest Reimb() => new() { Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28), }; [Fact] public async Task Create_Vendor_AsFinance_IsImmediatelyPaid() { var (svc, db, _) = Build(); var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051"; var id = await svc.CreateAsync(r, isFinance: true); Assert.Equal("Paid", (await db.Expenses.FindAsync(id))!.Status); } [Fact] public async Task Create_Reimbursement_IsDraft_WithSubmitter() { var (svc, db, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); var e = await db.Expenses.FindAsync(id); Assert.Equal("Draft", e!.Status); Assert.Equal("alice", e.SubmittedBy); } [Fact] public async Task StateMachine_HappyPath_Submit_Approve_Pay() { var (svc, db, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(id); Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); await svc.ApproveAsync(id); Assert.Equal("Approved", (await db.Expenses.FindAsync(id))!.Status); await svc.PayAsync(id, "3001", new DateOnly(2026, 6, 1)); var paid = await db.Expenses.FindAsync(id); Assert.Equal("Paid", paid!.Status); Assert.Equal("3001", paid.CheckNumber); } [Fact] public async Task Approve_FromDraft_Throws() { var (svc, _, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await Assert.ThrowsAsync(() => svc.ApproveAsync(id)); } [Fact] public async Task Reject_RecordsNotes_AndStatus() { var (svc, db, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.SubmitAsync(id); await svc.RejectAsync(id, "Missing receipt"); var e = await db.Expenses.FindAsync(id); Assert.Equal("Rejected", e!.Status); Assert.Equal("Missing receipt", e.ReviewNotes); } [Fact] public async Task Update_OthersDraft_AsNonFinance_Throws() { var (aliceSvc, db, fs) = Build("alice"); var id = await aliceSvc.CreateAsync(Reimb(), isFinance: false); var bobSvc = SvcAs(db, fs, "bob"); // bob (non-finance) tries to edit alice's draft await Assert.ThrowsAsync(() => bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false)); } [Fact] public async Task SoftDelete_HidesFromQueries() { var (svc, db, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); await svc.DeleteAsync(id, isFinance: true); // FirstOrDefaultAsync respects the global query filter; FindAsync would NOT. Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id)); } [Fact] public async Task Receipt_SaveThenOpen_RoundTrips() { var (svc, _, _) = Build("alice"); var id = await svc.CreateAsync(Reimb(), isFinance: false); using var input = new MemoryStream(Encoding.UTF8.GetBytes("img")); await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false); var got = await svc.OpenReceiptAsync(id, isFinance: true); Assert.NotNull(got); } // Build a service over the SAME db/storage but acting as a different user // (the service reads the current user id from IHttpContextAccessor). private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId) { var http = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; http.Setup(x => x.HttpContext).Returns(ctx); return new ExpenseService(db, http.Object, fs); } private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new() { Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes, }; } ``` > **Note:** `Build` returns `(ExpenseService, AppDbContext, FakeStorage)` so tests can spin up a second service over the **same** db/storage acting as a different user via the `SvcAs` helper. The `Member`/`AppUser` linkage is not needed for these tests because `SubmittedBy` is taken from the acting user's claim, not from a `Member` row. - [ ] **Step 3: Run test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseServiceTests"` Expected: FAIL/compile error — `ExpenseService` not defined. - [ ] **Step 4: Implement the service** `API/ROLAC.API/Services/ExpenseService.cs`: ```csharp using System.Security.Claims; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Shared; using ROLAC.API.Entities; using ROLAC.API.Services.Storage; namespace ROLAC.API.Services; public class ExpenseService : IExpenseService { private readonly AppDbContext _db; private readonly IHttpContextAccessor _http; private readonly IFileStorage _storage; public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage) { _db = db; _http = http; _storage = storage; } private string CurrentUserId => _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; // ── Queries ────────────────────────────────────────────────────────────── public async Task> GetPagedAsync( int page, int pageSize, string? search, int? ministryId, int? categoryGroupId, string? status, DateOnly? from, DateOnly? to) { var query = _db.Expenses.AsNoTracking().AsQueryable(); if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value); if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value); if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status); if (from.HasValue) query = query.Where(e => e.ExpenseDate >= from.Value); if (to.HasValue) query = query.Where(e => e.ExpenseDate <= to.Value); if (!string.IsNullOrWhiteSpace(search)) { var s = search.Trim().ToLower(); var term = search.Trim(); query = query.Where(e => e.Description.ToLower().Contains(s) || (e.VendorName != null && e.VendorName.ToLower().Contains(s)) || (e.Member != null && ( (e.Member.FirstName_en + " " + e.Member.LastName_en).ToLower().Contains(s) || (e.Member.FirstName_zh != null && e.Member.FirstName_zh.Contains(term)) || (e.Member.LastName_zh != null && e.Member.LastName_zh.Contains(term))))); } return await ProjectPagedAsync(query, page, pageSize); } public async Task> GetMineAsync(string userId, string? status, int page, int pageSize) { var query = _db.Expenses.AsNoTracking().Where(e => e.SubmittedBy == userId); if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status); return await ProjectPagedAsync(query, page, pageSize); } private async Task> ProjectPagedAsync(IQueryable query, int page, int pageSize) { var total = await query.CountAsync(); var rows = await query .OrderByDescending(e => e.ExpenseDate).ThenByDescending(e => e.Id) .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name_en); var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en); var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en); var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)) .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}"); var items = rows.Select(e => new ExpenseListItemDto { Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""), VendorName = e.VendorName, MemberId = e.MemberId, MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, }).ToList(); return new PagedResult { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; } public async Task GetByIdAsync(int id) { var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); if (e is null) return null; var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? ""; var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? ""; var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? ""; string? memName = e.MemberId != null ? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync() : null; return new ExpenseDto { Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, MinistryId = e.MinistryId, MinistryName = minName, CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName, SubCategoryId = e.SubCategoryId, SubCategoryName = subName, VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt, }; } // ── Create / Update / Delete ─────────────────────────────────────────────── public async Task CreateAsync(CreateExpenseRequest r, bool isFinance) { var e = new Expense { MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId, Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName, CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes, }; if (r.Type == "VendorPayment") { if (!isFinance) throw new InvalidOperationException("Only finance can create vendor payments."); e.Status = "Paid"; e.PaidAt = DateTimeOffset.UtcNow; e.PaidBy = CurrentUserId; e.MemberId = null; } else // StaffReimbursement { e.Status = "Draft"; e.SubmittedBy = CurrentUserId; // self-service: link to the caller's member; finance-on-behalf: honor provided MemberId e.MemberId = isFinance ? r.MemberId : await CallerMemberIdAsync(); e.VendorName = null; } _db.Expenses.Add(e); await _db.SaveChangesAsync(); return e.Id; } private async Task CallerMemberIdAsync() { var uid = CurrentUserId; return await _db.Users.Where(u => u.Id == uid).Select(u => u.MemberId).FirstOrDefaultAsync(); } public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance) { // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Expense {id} not found."); if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft")) throw new InvalidOperationException("You can only edit your own draft reimbursements."); e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId; e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; if (e.Type == "VendorPayment") e.VendorName = r.VendorName; await _db.SaveChangesAsync(); } public async Task DeleteAsync(int id, bool isFinance) { var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Expense {id} not found."); if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft")) throw new InvalidOperationException("You can only delete your own draft reimbursements."); e.IsDeleted = true; e.DeletedAt = DateTimeOffset.UtcNow; e.DeletedBy = CurrentUserId; await _db.SaveChangesAsync(); } // ── State machine ────────────────────────────────────────────────────────── // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. private async Task RequireAsync(int id) => await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) ?? throw new KeyNotFoundException($"Expense {id} not found."); public async Task SubmitAsync(int id) { var e = await RequireAsync(id); if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement."); if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'."); e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); } public async Task ApproveAsync(int id) { var e = await RequireAsync(id); if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'."); e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); } public async Task RejectAsync(int id, string? reviewNotes) { var e = await RequireAsync(id); if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'."); e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes; await _db.SaveChangesAsync(); } public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt) { var e = await RequireAsync(id); if (e.Status != "Approved") throw new InvalidOperationException($"Cannot mark paid from status '{e.Status}'."); e.Status = "Paid"; if (!string.IsNullOrWhiteSpace(checkNumber)) e.CheckNumber = checkNumber; e.PaidBy = CurrentUserId; e.PaidAt = paidAt.HasValue ? new DateTimeOffset(paidAt.Value.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) : DateTimeOffset.UtcNow; await _db.SaveChangesAsync(); } // ── Receipt ──────────────────────────────────────────────────────────────── public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance) { var e = await RequireAsync(id); if (!isFinance && e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("You can only attach receipts to your own reimbursements."); var safe = Path.GetFileName(fileName).Replace(' ', '_'); var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}"; if (e.ReceiptBlobPath != null && e.ReceiptBlobPath != path) await _storage.DeleteAsync(e.ReceiptBlobPath); var saved = await _storage.SaveAsync(content, path); e.ReceiptBlobPath = saved; await _db.SaveChangesAsync(); } public async Task<(Stream stream, string contentType)?> OpenReceiptAsync(int id, bool isFinance) { var e = await RequireAsync(id); if (!isFinance && e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Not authorized to view this receipt."); if (e.ReceiptBlobPath is null) return null; var stream = await _storage.OpenReadAsync(e.ReceiptBlobPath); if (stream is null) return null; var ext = Path.GetExtension(e.ReceiptBlobPath).ToLowerInvariant(); var contentType = ext switch { ".png" => "image/png", ".webp" => "image/webp", ".pdf" => "application/pdf", _ => "image/jpeg", }; return (stream, contentType); } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ExpenseServiceTests"` Expected: PASS (8 tests). - [ ] **Step 6: Commit** ```bash git add API/ROLAC.API/Services/IExpenseService.cs API/ROLAC.API/Services/ExpenseService.cs API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs git commit -m "feat(expense): add ExpenseService with state machine + receipt storage + tests" ``` --- ## Task 9: MonthlyStatementService + tests **Files:** - Create: `API/ROLAC.API/Services/IMonthlyStatementService.cs` - Create: `API/ROLAC.API/Services/MonthlyStatementService.cs` - Test: `API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs` - [ ] **Step 1: Interface** `API/ROLAC.API/Services/IMonthlyStatementService.cs`: ```csharp using ROLAC.API.DTOs.Expense; namespace ROLAC.API.Services; public interface IMonthlyStatementService { Task> GetAllAsync(int? year); Task GetByIdAsync(int id); Task CreateAsync(CreateMonthlyStatementRequest r); Task UpdateAsync(int id, UpdateMonthlyStatementRequest r); Task FinalizeAsync(int id); } ``` - [ ] **Step 2: Write the failing test** `API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.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.Expense; using ROLAC.API.Entities; using ROLAC.API.Services; using Xunit; namespace ROLAC.API.Tests.Services; public class MonthlyStatementServiceTests { 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(mock.Object)).Options); } private static MonthlyStatementService Build(AppDbContext db) { var mock = new Mock(); var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) }; mock.Setup(x => x.HttpContext).Returns(ctx); return new MonthlyStatementService(db, mock.Object); } [Fact] public async Task Create_ComputesGivingAndPaidExpenses_ForMonthOnly() { using var db = BuildDb(); db.GivingCategories.Add(new GivingCategory { Id = 1, Name_en = "Tithe" }); db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin" }); db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Other" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" }); db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) }); db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) }); // other month db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) }); db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) }); // not Paid → excluded await db.SaveChangesAsync(); var svc = Build(db); var id = await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5, OpeningBalance = 2000m, TotalOtherIncome = 100m, BankStatementBalance = 2800m }); var dto = await svc.GetByIdAsync(id); Assert.Equal(1000m, dto!.TotalGiving); Assert.Equal(300m, dto.TotalExpenses); Assert.Equal(2800m, dto.CalculatedClosingBalance); // 2000 + 1000 + 100 - 300 Assert.Equal(0m, dto.Difference); // 2800 - 2800 } [Fact] public async Task Create_Duplicate_Throws() { using var db = BuildDb(); var svc = Build(db); await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 }); await Assert.ThrowsAsync(() => svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 })); } [Fact] public async Task Update_AfterFinalize_Throws() { using var db = BuildDb(); var svc = Build(db); var id = await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 }); await svc.FinalizeAsync(id); await Assert.ThrowsAsync(() => svc.UpdateAsync(id, new UpdateMonthlyStatementRequest { OpeningBalance = 1m })); } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~MonthlyStatementServiceTests"` Expected: FAIL/compile error — `MonthlyStatementService` not defined. - [ ] **Step 4: Implement the service** `API/ROLAC.API/Services/MonthlyStatementService.cs`: ```csharp using System.Security.Claims; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; using ROLAC.API.Entities; namespace ROLAC.API.Services; public class MonthlyStatementService : IMonthlyStatementService { private readonly AppDbContext _db; private readonly IHttpContextAccessor _http; public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; } private string CurrentUserId => _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; public async Task> GetAllAsync(int? year) { var query = _db.MonthlyStatements.AsNoTracking().AsQueryable(); if (year.HasValue) query = query.Where(s => s.Year == year.Value); return await query.OrderByDescending(s => s.Year).ThenByDescending(s => s.Month) .Select(s => Map(s)).ToListAsync(); } public async Task GetByIdAsync(int id) { var s = await _db.MonthlyStatements.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); return s is null ? null : Map(s); } public async Task CreateAsync(CreateMonthlyStatementRequest r) { if (await _db.MonthlyStatements.AnyAsync(s => s.Year == r.Year && s.Month == r.Month)) throw new InvalidOperationException($"A statement for {r.Year}-{r.Month:D2} already exists."); var s = new MonthlyStatement { Year = r.Year, Month = r.Month, OpeningBalance = r.OpeningBalance, TotalOtherIncome = r.TotalOtherIncome, BankStatementBalance = r.BankStatementBalance, Notes = r.Notes, }; await RecomputeAsync(s); _db.MonthlyStatements.Add(s); await _db.SaveChangesAsync(); return s.Id; } public async Task UpdateAsync(int id, UpdateMonthlyStatementRequest r) { var s = await _db.MonthlyStatements.FindAsync(id) ?? throw new KeyNotFoundException($"MonthlyStatement {id} not found."); if (s.IsFinalized) throw new InvalidOperationException("Statement is finalized and cannot be modified."); s.OpeningBalance = r.OpeningBalance; s.TotalOtherIncome = r.TotalOtherIncome; s.BankStatementBalance = r.BankStatementBalance; s.Notes = r.Notes; await RecomputeAsync(s); await _db.SaveChangesAsync(); } public async Task FinalizeAsync(int id) { var s = await _db.MonthlyStatements.FindAsync(id) ?? throw new KeyNotFoundException($"MonthlyStatement {id} not found."); s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId; await _db.SaveChangesAsync(); } private async Task RecomputeAsync(MonthlyStatement s) { var first = new DateOnly(s.Year, s.Month, 1); var next = first.AddMonths(1); s.TotalGiving = await _db.Givings .Where(g => g.GivingDate >= first && g.GivingDate < next) .SumAsync(g => (decimal?)g.Amount) ?? 0m; s.TotalExpenses = await _db.Expenses .Where(e => e.Status == "Paid" && e.ExpenseDate >= first && e.ExpenseDate < next) .SumAsync(e => (decimal?)e.Amount) ?? 0m; s.CalculatedClosingBalance = s.OpeningBalance + s.TotalGiving + s.TotalOtherIncome - s.TotalExpenses; s.Difference = s.CalculatedClosingBalance - s.BankStatementBalance; } private static MonthlyStatementDto Map(MonthlyStatement s) => new() { Id = s.Id, Year = s.Year, Month = s.Month, OpeningBalance = s.OpeningBalance, TotalGiving = s.TotalGiving, TotalOtherIncome = s.TotalOtherIncome, TotalExpenses = s.TotalExpenses, CalculatedClosingBalance = s.CalculatedClosingBalance, BankStatementBalance = s.BankStatementBalance, Difference = s.Difference, Notes = s.Notes, IsFinalized = s.IsFinalized, }; } ``` - [ ] **Step 5: Run test to verify it passes** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~MonthlyStatementServiceTests"` Expected: PASS (3 tests). - [ ] **Step 6: Commit** ```bash git add API/ROLAC.API/Services/IMonthlyStatementService.cs API/ROLAC.API/Services/MonthlyStatementService.cs API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs git commit -m "feat(expense): add MonthlyStatementService with server-side recompute + tests" ``` --- ## Task 10: Controllers + service registration **Files:** - Create: `API/ROLAC.API/Controllers/ExpenseCategoriesController.cs` - Create: `API/ROLAC.API/Controllers/ExpensesController.cs` - Create: `API/ROLAC.API/Controllers/MonthlyStatementsController.cs` - Modify: `API/ROLAC.API/Program.cs` - [ ] **Step 1: Register the three services in Program.cs** After the `IFileStorage` registration add: ```csharp builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); ``` - [ ] **Step 2: ExpenseCategoriesController** `API/ROLAC.API/Controllers/ExpenseCategoriesController.cs`: ```csharp using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ROLAC.API.DTOs.Expense; using ROLAC.API.Services; namespace ROLAC.API.Controllers; [ApiController] [Route("api/expense-categories")] [Authorize(Roles = "finance,super_admin")] public class ExpenseCategoriesController : ControllerBase { private readonly IExpenseCategoryService _svc; public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc; [HttpGet] public async Task GetAll([FromQuery] bool includeInactive = false) => Ok(await _svc.GetAllAsync(includeInactive)); [HttpPost("groups")] public async Task CreateGroup([FromBody] CreateExpenseGroupRequest r) => Ok(new { id = await _svc.CreateGroupAsync(r) }); [HttpPut("groups/{id:int}")] public async Task UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r) { try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } [HttpDelete("groups/{id:int}")] public async Task DeactivateGroup(int id) { try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } [HttpPost("subcategories")] public async Task CreateSub([FromBody] CreateExpenseSubCategoryRequest r) { try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } } [HttpPut("subcategories/{id:int}")] public async Task UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r) { try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } [HttpDelete("subcategories/{id:int}")] public async Task DeactivateSub(int id) { try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } } ``` - [ ] **Step 3: ExpensesController** `API/ROLAC.API/Controllers/ExpensesController.cs` — note the `[Authorize]` split: list/review/vendor endpoints are finance-only; `mine`, create, self-edit, submit, receipt are open to any authenticated user (service enforces self-ownership). `IsFinance()` reads roles from the JWT. ```csharp using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ROLAC.API.DTOs.Expense; using ROLAC.API.Services; namespace ROLAC.API.Controllers; [ApiController] [Route("api/expenses")] [Authorize] public class ExpensesController : ControllerBase { private readonly IExpenseService _svc; public ExpensesController(IExpenseService svc) => _svc = svc; private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin"); private bool CanViewAll() => IsFinance() || User.IsInRole("pastor"); [HttpGet] public async Task GetPaged( [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null, [FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null, [FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) { if (!CanViewAll()) return Forbid(); return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to)); } [HttpGet("mine")] public async Task GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) { var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value; return Ok(await _svc.GetMineAsync(uid, status, page, pageSize)); } [HttpGet("{id:int}")] public async Task GetById(int id) { var dto = await _svc.GetByIdAsync(id); if (dto is null) return NotFound(); var uid = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value; if (!CanViewAll() && dto.SubmittedBy != uid) return Forbid(); return Ok(dto); } [HttpPost] public async Task Create([FromBody] CreateExpenseRequest r) { try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPut("{id:int}")] public async Task Update(int id, [FromBody] UpdateExpenseRequest r) { try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpDelete("{id:int}")] public async Task Delete(int id) { try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPost("{id:int}/submit")] public async Task Submit(int id) { try { await _svc.SubmitAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPost("{id:int}/approve")] [Authorize(Roles = "finance,super_admin")] public async Task Approve(int id) { try { await _svc.ApproveAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPost("{id:int}/reject")] [Authorize(Roles = "finance,super_admin")] public async Task Reject(int id, [FromBody] RejectExpenseRequest r) { try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPost("{id:int}/pay")] [Authorize(Roles = "finance,super_admin")] public async Task Pay(int id, [FromBody] PayExpenseRequest r) { try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPost("{id:int}/receipt")] [RequestSizeLimit(10_485_760)] // 10 MB public async Task UploadReceipt(int id, IFormFile file) { if (file is null || file.Length == 0) return BadRequest(new { message = "No file." }); var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" }; if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." }); try { await using var stream = file.OpenReadStream(); await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance()); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpGet("{id:int}/receipt")] public async Task GetReceipt(int id) { try { var result = await _svc.OpenReceiptAsync(id, IsFinance()); if (result is null) return NotFound(); return File(result.Value.stream, result.Value.contentType); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException) { return Forbid(); } } } ``` - [ ] **Step 4: MonthlyStatementsController** `API/ROLAC.API/Controllers/MonthlyStatementsController.cs`: ```csharp using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ROLAC.API.DTOs.Expense; using ROLAC.API.Services; namespace ROLAC.API.Controllers; [ApiController] [Route("api/monthly-statements")] [Authorize(Roles = "finance,super_admin")] public class MonthlyStatementsController : ControllerBase { private readonly IMonthlyStatementService _svc; public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc; [HttpGet] public async Task GetAll([FromQuery] int? year = null) => Ok(await _svc.GetAllAsync(year)); [HttpGet("{id:int}")] public async Task GetById(int id) { var dto = await _svc.GetByIdAsync(id); return dto is null ? NotFound() : Ok(dto); } [HttpPost] public async Task Create([FromBody] CreateMonthlyStatementRequest r) { try { return Ok(new { id = await _svc.CreateAsync(r) }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPut("{id:int}")] public async Task Update(int id, [FromBody] UpdateMonthlyStatementRequest r) { try { await _svc.UpdateAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } } [HttpPost("{id:int}/finalize")] public async Task Finalize(int id) { try { await _svc.FinalizeAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } } ``` - [ ] **Step 5: Build + run full test suite** Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` then `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` Expected: Build succeeded; all tests PASS. - [ ] **Step 6: Commit** ```bash git add API/ROLAC.API/Controllers/ExpenseCategoriesController.cs API/ROLAC.API/Controllers/ExpensesController.cs API/ROLAC.API/Controllers/MonthlyStatementsController.cs API/ROLAC.API/Program.cs git commit -m "feat(expense): add controllers + register services" ``` --- ## Task 11: Frontend models + API services **Files:** - Create: `APP/src/app/features/expense/models/expense.model.ts` - Create: `APP/src/app/features/expense/services/ministry-api.service.ts` - Create: `APP/src/app/features/expense/services/expense-category-api.service.ts` - Create: `APP/src/app/features/expense/services/expense-api.service.ts` - Create: `APP/src/app/features/expense/services/monthly-statement-api.service.ts` - [ ] **Step 1: Models** `APP/src/app/features/expense/models/expense.model.ts`: ```typescript export type ExpenseType = 'VendorPayment' | 'StaffReimbursement'; export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected'; export interface PagedResult { items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number; } export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; } export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; } export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; } export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; } export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; } export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; } export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; } export interface ExpenseListItemDto { id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string; ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string; subCategoryId: number; subCategoryName: string; vendorName: string | null; memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean; } export interface ExpenseDto extends ExpenseListItemDto { checkNumber: string | null; notes: string | null; reviewNotes: string | null; submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null; } export interface CreateExpenseRequest { type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number; amount: number; description: string; vendorName: string | null; memberId: number | null; checkNumber: string | null; expenseDate: string; notes: string | null; } export type UpdateExpenseRequest = CreateExpenseRequest; export interface RejectExpenseRequest { reviewNotes: string | null; } export interface PayExpenseRequest { checkNumber: string | null; paidAt: string | null; } export interface MonthlyStatementDto { id: number; year: number; month: number; openingBalance: number; totalGiving: number; totalOtherIncome: number; totalExpenses: number; calculatedClosingBalance: number; bankStatementBalance: number; difference: number; notes: string | null; isFinalized: boolean; } export interface CreateMonthlyStatementRequest { year: number; month: number; openingBalance: number; totalOtherIncome: number; bankStatementBalance: number; notes: string | null; } export interface UpdateMonthlyStatementRequest { openingBalance: number; totalOtherIncome: number; bankStatementBalance: number; notes: string | null; } ``` - [ ] **Step 2: Ministry + category API services** `APP/src/app/features/expense/services/ministry-api.service.ts`: ```typescript import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiConfigService } from '../../../core/services/api-config.service'; import { MinistryDto } from '../models/expense.model'; @Injectable({ providedIn: 'root' }) export class MinistryApiService { private readonly endpoint: string; constructor(private http: HttpClient, apiConfig: ApiConfigService) { this.endpoint = apiConfig.getApiUrl('ministries'); } getAll(includeInactive = false): Observable { return this.http.get(this.endpoint, { params: new HttpParams().set('includeInactive', includeInactive) }); } } ``` `APP/src/app/features/expense/services/expense-category-api.service.ts`: ```typescript import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiConfigService } from '../../../core/services/api-config.service'; import { ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest, CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest, } from '../models/expense.model'; @Injectable({ providedIn: 'root' }) export class ExpenseCategoryApiService { private readonly endpoint: string; constructor(private http: HttpClient, apiConfig: ApiConfigService) { this.endpoint = apiConfig.getApiUrl('expense-categories'); } getAll(includeInactive = false): Observable { return this.http.get(this.endpoint, { params: new HttpParams().set('includeInactive', includeInactive) }); } createGroup(r: CreateExpenseGroupRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/groups`, r); } updateGroup(id: number, r: UpdateExpenseGroupRequest): Observable { return this.http.put(`${this.endpoint}/groups/${id}`, r); } deactivateGroup(id: number): Observable { return this.http.delete(`${this.endpoint}/groups/${id}`); } createSub(r: CreateExpenseSubCategoryRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(`${this.endpoint}/subcategories`, r); } updateSub(id: number, r: UpdateExpenseSubCategoryRequest): Observable { return this.http.put(`${this.endpoint}/subcategories/${id}`, r); } deactivateSub(id: number): Observable { return this.http.delete(`${this.endpoint}/subcategories/${id}`); } } ``` - [ ] **Step 3: Expense + monthly-statement API services** `APP/src/app/features/expense/services/expense-api.service.ts`: ```typescript import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiConfigService } from '../../../core/services/api-config.service'; import { PagedResult, ExpenseListItemDto, ExpenseDto, CreateExpenseRequest, UpdateExpenseRequest, RejectExpenseRequest, PayExpenseRequest, } from '../models/expense.model'; export interface ExpenseQuery { page?: number; pageSize?: number; search?: string; ministryId?: number; categoryGroupId?: number; status?: string; from?: string; to?: string; } @Injectable({ providedIn: 'root' }) export class ExpenseApiService { private readonly endpoint: string; constructor(private http: HttpClient, apiConfig: ApiConfigService) { this.endpoint = apiConfig.getApiUrl('expenses'); } private toParams(q: Record): HttpParams { let p = new HttpParams(); for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p = p.set(k, String(v)); return p; } getPaged(q: ExpenseQuery): Observable> { return this.http.get>(this.endpoint, { params: this.toParams(q as Record) }); } getMine(status?: string, page = 1, pageSize = 50): Observable> { return this.http.get>(`${this.endpoint}/mine`, { params: this.toParams({ status, page, pageSize }) }); } getById(id: number): Observable { return this.http.get(`${this.endpoint}/${id}`); } create(r: CreateExpenseRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(this.endpoint, r); } update(id: number, r: UpdateExpenseRequest): Observable { return this.http.put(`${this.endpoint}/${id}`, r); } delete(id: number): Observable { return this.http.delete(`${this.endpoint}/${id}`); } submit(id: number): Observable { return this.http.post(`${this.endpoint}/${id}/submit`, {}); } approve(id: number): Observable { return this.http.post(`${this.endpoint}/${id}/approve`, {}); } reject(id: number, r: RejectExpenseRequest): Observable { return this.http.post(`${this.endpoint}/${id}/reject`, r); } pay(id: number, r: PayExpenseRequest): Observable { return this.http.post(`${this.endpoint}/${id}/pay`, r); } uploadReceipt(id: number, file: File): Observable { const form = new FormData(); form.append('file', file); return this.http.post(`${this.endpoint}/${id}/receipt`, form); } receiptUrl(id: number): string { return `${this.endpoint}/${id}/receipt`; } } ``` `APP/src/app/features/expense/services/monthly-statement-api.service.ts`: ```typescript import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { ApiConfigService } from '../../../core/services/api-config.service'; import { MonthlyStatementDto, CreateMonthlyStatementRequest, UpdateMonthlyStatementRequest } from '../models/expense.model'; @Injectable({ providedIn: 'root' }) export class MonthlyStatementApiService { private readonly endpoint: string; constructor(private http: HttpClient, apiConfig: ApiConfigService) { this.endpoint = apiConfig.getApiUrl('monthly-statements'); } getAll(year?: number): Observable { let p = new HttpParams(); if (year) p = p.set('year', year); return this.http.get(this.endpoint, { params: p }); } getById(id: number): Observable { return this.http.get(`${this.endpoint}/${id}`); } create(r: CreateMonthlyStatementRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(this.endpoint, r); } update(id: number, r: UpdateMonthlyStatementRequest): Observable { return this.http.put(`${this.endpoint}/${id}`, r); } finalize(id: number): Observable { return this.http.post(`${this.endpoint}/${id}/finalize`, {}); } } ``` - [ ] **Step 4: Build the frontend** Run: `cd APP; npm run build` Expected: build succeeds (these files are only imported by later tasks; this confirms they compile). - [ ] **Step 5: Commit** ```bash git add APP/src/app/features/expense/models/ APP/src/app/features/expense/services/ git commit -m "feat(expense): add frontend models + API services" ``` --- ## Task 12: Expense categories management page Mirror the existing `giving-categories-page` (a Kendo Grid + add/edit dialog). This page manages two levels: a groups grid and, for the selected group, a subcategories grid. **Files:** - Create: `APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.ts` - Create: `APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html` - Create: `APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.scss` - Reference: `APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.{ts,html,scss}` - [ ] **Step 1: Read the reference page** Read all three files of `giving-categories-page` to copy its standalone-component structure, Kendo imports, dialog pattern, and notification/error handling. Replicate the same imports and lifecycle. - [ ] **Step 2: Implement the component TS** Create `expense-categories-page.component.ts` following the giving reference, with this data model and actions (full skeleton — fill grid/dialog wiring to match the reference's idioms): ```typescript import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { GridModule } from '@progress/kendo-angular-grid'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { DialogModule } from '@progress/kendo-angular-dialog'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { ExpenseCategoryApiService } from '../../services/expense-category-api.service'; import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model'; @Component({ selector: 'app-expense-categories-page', standalone: true, imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogModule, InputsModule], templateUrl: './expense-categories-page.component.html', styleUrls: ['./expense-categories-page.component.scss'], }) export class ExpenseCategoriesPageComponent implements OnInit { groups: ExpenseCategoryGroupDto[] = []; selectedGroup: ExpenseCategoryGroupDto | null = null; loading = false; // group dialog groupDialogOpen = false; editingGroupId: number | null = null; groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true }; // sub dialog subDialogOpen = false; editingSubId: number | null = null; subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true }; constructor(private api: ExpenseCategoryApiService) {} ngOnInit(): void { this.load(); } load(): void { this.loading = true; this.api.getAll(true).subscribe({ next: g => { this.groups = g; if (this.selectedGroup) this.selectedGroup = g.find(x => x.id === this.selectedGroup!.id) ?? null; this.loading = false; }, error: () => { this.loading = false; }, }); } selectGroup(g: ExpenseCategoryGroupDto): void { this.selectedGroup = g; } get subCategories(): ExpenseSubCategoryDto[] { return this.selectedGroup?.subCategories ?? []; } // ── group CRUD ── openNewGroup(): void { this.editingGroupId = null; this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true }; this.groupDialogOpen = true; } openEditGroup(g: ExpenseCategoryGroupDto): void { this.editingGroupId = g.id; this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive }; this.groupDialogOpen = true; } saveGroup(): void { const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder }; const done = () => { this.groupDialogOpen = false; this.load(); }; if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done); else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done); } deactivateGroup(g: ExpenseCategoryGroupDto): void { this.api.deactivateGroup(g.id).subscribe(() => this.load()); } // ── sub CRUD ── openNewSub(): void { if (!this.selectedGroup) return; this.editingSubId = null; this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true }; this.subDialogOpen = true; } openEditSub(s: ExpenseSubCategoryDto): void { this.editingSubId = s.id; this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive }; this.subDialogOpen = true; } saveSub(): void { if (!this.selectedGroup) return; const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder }; const done = () => { this.subDialogOpen = false; this.load(); }; if (this.editingSubId == null) this.api.createSub(body).subscribe(done); else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done); } deactivateSub(s: ExpenseSubCategoryDto): void { this.api.deactivateSub(s.id).subscribe(() => this.load()); } } ``` - [ ] **Step 3: Implement the template + styles** Create `expense-categories-page.component.html` with two side-by-side Kendo grids (groups | subcategories of selected group), each with a toolbar "+ New" button and inline Edit/Deactivate command buttons, plus two `kendo-dialog` blocks bound to `groupForm`/`subForm` (mirror the giving-categories dialog markup: paired EN/中 `kendo-textbox`, a `kendo-numerictextbox` for sortOrder, and for edit an active toggle). Create `expense-categories-page.component.scss` (can start by copying the giving reference's scss). Lay out the two-grid row with Tailwind utilities `grid grid-cols-1 md:grid-cols-2 gap-4` on a wrapper div (per project convention — form/layout widths via Tailwind, not per-component scss). - [ ] **Step 4: Build** Run: `cd APP; npm run build` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add APP/src/app/features/expense/pages/expense-categories-page/ git commit -m "feat(expense): add expense categories management page" ``` --- ## Task 13: Shared category cascade + expense form dialog component Both the finance expenses page and the member reimbursements page need the same Ministry→Group→SubCategory cascading dropdowns and amount/description/date fields. Build one reusable form dialog to keep it DRY. **Files:** - Create: `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts` - Create: `APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html` - Reference: `APP/src/app/features/giving/components/member-quick-add-dialog/` for dialog `@Input/@Output` standalone pattern; `APP/src/app/features/giving/pages/givings-page/` for member search reuse. - [ ] **Step 1: Implement the dialog component TS** `expense-form-dialog.component.ts` — emits a `CreateExpenseRequest` plus an optional receipt `File`. `mode` controls whether vendor fields or member picker show. ```typescript import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { DialogModule } from '@progress/kendo-angular-dialog'; import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { DatePickerModule } from '@progress/kendo-angular-dateinputs'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { MinistryApiService } from '../../services/ministry-api.service'; import { ExpenseCategoryApiService } from '../../services/expense-category-api.service'; import { MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest, } from '../../models/expense.model'; export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; } @Component({ selector: 'app-expense-form-dialog', standalone: true, imports: [CommonModule, FormsModule, DialogModule, DropDownsModule, InputsModule, DatePickerModule, ButtonsModule], templateUrl: './expense-form-dialog.component.html', }) export class ExpenseFormDialogComponent implements OnInit { /** 'vendor' shows vendor name + check#; 'reimbursement' shows receipt upload (+ member picker if allowMemberPick). */ @Input() mode: 'vendor' | 'reimbursement' = 'reimbursement'; @Input() allowMemberPick = false; // finance creating on behalf @Input() title = 'New Expense'; @Output() save = new EventEmitter(); @Output() cancel = new EventEmitter(); ministries: MinistryDto[] = []; groups: ExpenseCategoryGroupDto[] = []; subs: ExpenseSubCategoryDto[] = []; form = { ministryId: null as number | null, categoryGroupId: null as number | null, subCategoryId: null as number | null, amount: 0, description: '', vendorName: '', checkNumber: '', memberId: null as number | null, expenseDate: new Date(), }; receipt: File | null = null; constructor(private ministryApi: MinistryApiService, private catApi: ExpenseCategoryApiService) {} ngOnInit(): void { this.ministryApi.getAll().subscribe(m => (this.ministries = m)); this.catApi.getAll(false).subscribe(g => (this.groups = g)); } onGroupChange(groupId: number | null): void { this.form.subCategoryId = null; this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? []; } onFileSelected(event: Event): void { const input = event.target as HTMLInputElement; this.receipt = input.files?.[0] ?? null; } get isValid(): boolean { return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId && this.form.amount > 0 && this.form.description.trim().length > 0; } emitSave(): void { if (!this.isValid) return; const d = this.form.expenseDate; const expenseDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const request: CreateExpenseRequest = { type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType, ministryId: this.form.ministryId!, categoryGroupId: this.form.categoryGroupId!, subCategoryId: this.form.subCategoryId!, amount: this.form.amount, description: this.form.description.trim(), vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null, memberId: this.allowMemberPick ? this.form.memberId : null, checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null, expenseDate, notes: null, }; this.save.emit({ request, receipt: this.receipt }); } } ``` > **Note:** Uses the project's **local-date** formatting (Y/M/D components), never `toISOString()`, per the date-only convention. - [ ] **Step 2: Implement the dialog template** Create `expense-form-dialog.component.html`: a `kendo-dialog` with a Tailwind `grid grid-cols-1 md:grid-cols-2 gap-3` wrapper containing: Ministry `kendo-dropdownlist` (`[data]="ministries"` `textField="name_en"` `valueField="id"` `[valuePrimitive]="true"` `[(ngModel)]="form.ministryId"`), Category Group dropdown (`(valueChange)="onGroupChange($event)"`), SubCategory dropdown (`[data]="subs"`), amount `kendo-numerictextbox`, description `kendo-textbox`, expense date `kendo-datepicker`. When `mode==='vendor'`: show vendor name + check# textboxes. When `mode==='reimbursement'`: show ``. When `allowMemberPick`: a member search dropdown reusing `GET /api/members?search=` (mirror the givings-page member picker). Footer: Cancel → `cancel.emit()`, Save → `emitSave()` disabled when `!isValid`. > **Kendo binding reminder:** Every `kendo-dropdownlist` using `textField`/`valueField` against an object array MUST set `[valuePrimitive]="true"`, or the form binds the whole object instead of the id. - [ ] **Step 3: Build** Run: `cd APP; npm run build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add APP/src/app/features/expense/components/expense-form-dialog/ git commit -m "feat(expense): add reusable expense form dialog with category cascade" ``` --- ## Task 14: My Reimbursements page (member self-service) **Files:** - Create: `APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.ts` - Create: `APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.html` - Create: `APP/src/app/features/expense/pages/my-reimbursements-page/my-reimbursements-page.component.scss` - [ ] **Step 1: Implement the component TS** ```typescript import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { GridModule } from '@progress/kendo-angular-grid'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { ExpenseApiService } from '../../services/expense-api.service'; import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; import { ExpenseListItemDto } from '../../models/expense.model'; import { switchMap, of } from 'rxjs'; @Component({ selector: 'app-my-reimbursements-page', standalone: true, imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent], templateUrl: './my-reimbursements-page.component.html', styleUrls: ['./my-reimbursements-page.component.scss'], }) export class MyReimbursementsPageComponent implements OnInit { rows: ExpenseListItemDto[] = []; loading = false; dialogOpen = false; constructor(private api: ExpenseApiService) {} ngOnInit(): void { this.load(); } load(): void { this.loading = true; this.api.getMine().subscribe({ next: r => { this.rows = r.items; this.loading = false; }, error: () => { this.loading = false; }, }); } openNew(): void { this.dialogOpen = true; } onSave(result: ExpenseFormResult): void { this.api.create(result.request).pipe( switchMap(created => result.receipt ? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created))) : of(created)), ).subscribe(() => { this.dialogOpen = false; this.load(); }); } submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); } remove(row: ExpenseListItemDto): void { this.api.delete(row.id).subscribe(() => this.load()); } canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; } statusClass(status: string): string { return { Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' }[status] ?? ''; } } ``` - [ ] **Step 2: Implement template + styles** Create the HTML: a header with a "+ New Reimbursement" button, a `kendo-grid [data]="rows"` with columns Date, Description, Ministry, Category (`categoryGroupName` / `subCategoryName`), Amount, Status (badge using `statusClass`), and an actions column showing Submit + Delete only when `canEdit(row)`, and a "Receipt" link when `row.hasReceipt` (href `expenseApi.receiptUrl(row.id)` — inject auth token is handled by the auth interceptor; open in new tab). Conditionally render ``. Create the scss with the badge classes (`.badge-draft`, `.badge-pending`, `.badge-approved`, `.badge-paid`, `.badge-rejected`). - [ ] **Step 3: Build** Run: `cd APP; npm run build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add APP/src/app/features/expense/pages/my-reimbursements-page/ git commit -m "feat(expense): add member self-service My Reimbursements page" ``` --- ## Task 15: Expenses page (finance overview + review) **Files:** - Create: `APP/src/app/features/expense/pages/expenses-page/expenses-page.component.ts` - Create: `APP/src/app/features/expense/pages/expenses-page/expenses-page.component.html` - Create: `APP/src/app/features/expense/pages/expenses-page/expenses-page.component.scss` - Reference: `APP/src/app/features/giving/pages/givings-page/` (paged grid + filters pattern) - [ ] **Step 1: Read the reference page** Read `givings-page.component.ts` + `.html` to copy the paged Kendo Grid wiring (page change, filter inputs, search debounce) and dialog handling. - [ ] **Step 2: Implement the component TS** ```typescript import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { DialogModule } from '@progress/kendo-angular-dialog'; import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service'; import { MinistryApiService } from '../../services/ministry-api.service'; import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; import { ExpenseListItemDto, MinistryDto, ExpenseStatus } from '../../models/expense.model'; import { switchMap, of } from 'rxjs'; @Component({ selector: 'app-expenses-page', standalone: true, imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule, InputsModule, DialogModule, ExpenseFormDialogComponent], templateUrl: './expenses-page.component.html', styleUrls: ['./expenses-page.component.scss'], }) export class ExpensesPageComponent implements OnInit { rows: ExpenseListItemDto[] = []; total = 0; page = 1; pageSize = 20; loading = false; ministries: MinistryDto[] = []; readonly statuses: ExpenseStatus[] = ['Draft', 'PendingApproval', 'Approved', 'Paid', 'Rejected']; filter: ExpenseQuery = {}; vendorDialogOpen = false; reimbDialogOpen = false; // pay / reject mini-dialogs payRow: ExpenseListItemDto | null = null; payCheckNumber = ''; payDate = new Date(); rejectRow: ExpenseListItemDto | null = null; rejectNotes = ''; constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) {} ngOnInit(): void { this.ministryApi.getAll().subscribe(m => (this.ministries = m)); this.load(); } load(): void { this.loading = true; this.api.getPaged({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({ next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; }, error: () => { this.loading = false; }, }); } applyFilter(): void { this.page = 1; this.load(); } onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); } // create onVendorSave(result: ExpenseFormResult): void { this.api.create(result.request).subscribe(() => { this.vendorDialogOpen = false; this.load(); }); } onReimbSave(result: ExpenseFormResult): void { this.api.create(result.request).pipe( switchMap(c => result.receipt ? this.api.uploadReceipt(c.id, result.receipt).pipe(switchMap(() => of(c))) : of(c)), ).subscribe(() => { this.reimbDialogOpen = false; this.load(); }); } // review actions approve(row: ExpenseListItemDto): void { this.api.approve(row.id).subscribe(() => this.load()); } openReject(row: ExpenseListItemDto): void { this.rejectRow = row; this.rejectNotes = ''; } confirmReject(): void { if (!this.rejectRow) return; this.api.reject(this.rejectRow.id, { reviewNotes: this.rejectNotes || null }).subscribe(() => { this.rejectRow = null; this.load(); }); } openPay(row: ExpenseListItemDto): void { this.payRow = row; this.payCheckNumber = ''; this.payDate = new Date(); } confirmPay(): void { if (!this.payRow) return; const d = this.payDate; const paidAt = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; this.api.pay(this.payRow.id, { checkNumber: this.payCheckNumber || null, paidAt }).subscribe(() => { this.payRow = null; this.load(); }); } canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; } canPay(row: ExpenseListItemDto): boolean { return row.status === 'Approved'; } receiptUrl(id: number): string { return this.api.receiptUrl(id); } } ``` - [ ] **Step 3: Implement template + styles** Create the HTML: a filter toolbar (search `kendo-textbox`, ministry dropdown bound to `filter.ministryId` with `[valuePrimitive]="true"`, status dropdown bound to `filter.status`, an Apply button calling `applyFilter()`), two create buttons ("+ Vendor Payment" → `vendorDialogOpen=true`, "+ Reimbursement (on behalf)" → `reimbDialogOpen=true`), a paged `kendo-grid` (`[data]="rows"` `[skip]` `[pageSize]` `[total]="total"` `(pageChange)`) with columns Date, Type, Description, Ministry, Category, Payee (`vendorName || memberName`), Amount, Status, and an actions column wired to `approve`/`openReject`/`openPay` (shown per `canApproveOrReject`/`canPay`) and a Receipt link when `hasReceipt`. Add `` and another with `mode="reimbursement" [allowMemberPick]="true"` for `reimbDialogOpen`. Add two small `kendo-dialog` blocks for pay (check# + date) and reject (notes). Create scss reusing the badge classes from Task 14 (or extract to a shared scss — acceptable to duplicate per existing pattern). - [ ] **Step 4: Build** Run: `cd APP; npm run build` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add APP/src/app/features/expense/pages/expenses-page/ git commit -m "feat(expense): add finance expenses overview + review page" ``` --- ## Task 16: Monthly statement page **Files:** - Create: `APP/src/app/features/expense/pages/monthly-statement-page/monthly-statement-page.component.ts` - Create: `APP/src/app/features/expense/pages/monthly-statement-page/monthly-statement-page.component.html` - Create: `APP/src/app/features/expense/pages/monthly-statement-page/monthly-statement-page.component.scss` - [ ] **Step 1: Implement the component TS** ```typescript import { Component, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { GridModule } from '@progress/kendo-angular-grid'; import { ButtonsModule } from '@progress/kendo-angular-buttons'; import { DialogModule } from '@progress/kendo-angular-dialog'; import { InputsModule } from '@progress/kendo-angular-inputs'; import { MonthlyStatementApiService } from '../../services/monthly-statement-api.service'; import { MonthlyStatementDto } from '../../models/expense.model'; @Component({ selector: 'app-monthly-statement-page', standalone: true, imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogModule, InputsModule], templateUrl: './monthly-statement-page.component.html', styleUrls: ['./monthly-statement-page.component.scss'], }) export class MonthlyStatementPageComponent implements OnInit { rows: MonthlyStatementDto[] = []; loading = false; yearFilter: number | null = null; dialogOpen = false; editing: MonthlyStatementDto | null = null; form = { year: new Date().getFullYear(), month: new Date().getMonth() + 1, openingBalance: 0, totalOtherIncome: 0, bankStatementBalance: 0, notes: '' }; preview: MonthlyStatementDto | null = null; // populated after create/select for live totals constructor(private api: MonthlyStatementApiService) {} ngOnInit(): void { this.load(); } load(): void { this.loading = true; this.api.getAll(this.yearFilter ?? undefined).subscribe({ next: r => { this.rows = r; this.loading = false; }, error: () => { this.loading = false; }, }); } openNew(): void { this.editing = null; this.form = { year: new Date().getFullYear(), month: new Date().getMonth() + 1, openingBalance: 0, totalOtherIncome: 0, bankStatementBalance: 0, notes: '' }; this.dialogOpen = true; } openEdit(row: MonthlyStatementDto): void { this.editing = row; this.form = { year: row.year, month: row.month, openingBalance: row.openingBalance, totalOtherIncome: row.totalOtherIncome, bankStatementBalance: row.bankStatementBalance, notes: row.notes ?? '' }; this.dialogOpen = true; } save(): void { const done = () => { this.dialogOpen = false; this.load(); }; if (this.editing == null) { this.api.create({ year: this.form.year, month: this.form.month, openingBalance: this.form.openingBalance, totalOtherIncome: this.form.totalOtherIncome, bankStatementBalance: this.form.bankStatementBalance, notes: this.form.notes || null }) .subscribe(created => { this.api.getById(created.id).subscribe(s => { this.preview = s; done(); }); }); } else { this.api.update(this.editing.id, { openingBalance: this.form.openingBalance, totalOtherIncome: this.form.totalOtherIncome, bankStatementBalance: this.form.bankStatementBalance, notes: this.form.notes || null }).subscribe(done); } } finalize(row: MonthlyStatementDto): void { this.api.finalize(row.id).subscribe(() => this.load()); } } ``` - [ ] **Step 2: Implement template + styles** Create the HTML: a year filter input + a "+ New Statement" button, a `kendo-grid [data]="rows"` with columns Year, Month, Opening, Giving, Other Income, Expenses, Calculated Closing, Bank Balance, Difference (highlight non-zero red via ngClass), Finalized (badge), and actions Edit (hidden when `row.isFinalized`) + Finalize (hidden when `row.isFinalized`). Add a `kendo-dialog` bound to `form`: year + month `kendo-numerictextbox` (editable only on create — disable when `editing`), opening/otherIncome/bank `kendo-numerictextbox`, notes textbox. Show a read-only computed line for the selected/created statement's Giving / Expenses / Calculated Closing / Difference from `preview` or `editing`. Lay out fields with Tailwind `grid grid-cols-1 md:grid-cols-2 gap-3`. - [ ] **Step 3: Build** Run: `cd APP; npm run build` Expected: build succeeds. - [ ] **Step 4: Commit** ```bash git add APP/src/app/features/expense/pages/monthly-statement-page/ git commit -m "feat(expense): add monthly reconciliation statement page" ``` --- ## Task 17: Routes + navigation integration **Files:** - Modify: `APP/src/app/app.routes.ts` - Modify: `APP/src/app/portals/user-portal/user-portal.component.ts` - Modify: `APP/src/app/portals/user-portal/user-portal.component.html` - [ ] **Step 1: Add routes** In `app.routes.ts` add imports at the top: ```typescript import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component'; import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component'; import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component'; import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component'; ``` Inside the `user-portal` `children` array (after the existing `finance/...` routes) add: ```typescript { path: 'reimbursements', component: MyReimbursementsPageComponent }, { path: 'finance/expenses', component: ExpensesPageComponent, canActivate: [RoleGuard], data: { roles: ['finance', 'super_admin'] }, }, { path: 'finance/expense-categories', component: ExpenseCategoriesPageComponent, canActivate: [RoleGuard], data: { roles: ['finance', 'super_admin'] }, }, { path: 'finance/monthly-statement', component: MonthlyStatementPageComponent, canActivate: [RoleGuard], data: { roles: ['finance', 'super_admin'] }, }, ``` - [ ] **Step 2: Add nav items in user-portal.component.ts** Append to `financeNavItems`: ```typescript { text: 'Expenses', icon: this.creditCardIcon, path: '/user-portal/finance/expenses' }, { text: 'Expense Categories',icon: this.creditCardIcon, path: '/user-portal/finance/expense-categories' }, { text: 'Monthly Statement', icon: this.creditCardIcon, path: '/user-portal/finance/monthly-statement' }, ``` Add a new member-visible nav array (after `financeNavItems`): ```typescript public personalNavItems: NavItem[] = [ { text: 'My Reimbursements', icon: this.creditCardIcon, path: '/user-portal/reimbursements' }, ]; ``` Include it in the `updateActiveStates` aggregation array: ```typescript ...this.personalNavItems, ``` Add the page titles in `getPageTitle`'s `titles` map: ```typescript 'reimbursements': 'My Reimbursements', 'finance/expenses': 'Expenses', 'finance/expense-categories': 'Expense Categories', 'finance/monthly-statement': 'Monthly Statement', ``` - [ ] **Step 3: Render the personal nav section in user-portal.component.html** After the `Main` nav-section (before `Management`) add a section that every authenticated user sees: ```html ``` (The existing `Finance` `nav-section` already iterates `financeNavItems`, so the three new finance items appear automatically.) - [ ] **Step 4: Build** Run: `cd APP; npm run build` Expected: build succeeds. - [ ] **Step 5: Commit** ```bash git add APP/src/app/app.routes.ts APP/src/app/portals/user-portal/user-portal.component.ts APP/src/app/portals/user-portal/user-portal.component.html git commit -m "feat(expense): wire routes + sidebar nav for expense pages" ``` --- ## Task 18: End-to-end manual verification **Files:** none (verification only) - [ ] **Step 1: Run the full backend test suite** Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` Expected: all tests PASS (Ministry, ExpenseCategory, Expense, MonthlyStatement, LocalDiskFileStorage + pre-existing). - [ ] **Step 2: Start the API and confirm migrations + seed** Start the API (per `project_build_run_env`). Confirm on startup: migration `AddExpenseModule` applies, and the DB has 10 ministries, 11 expense groups, 38 subcategories. Check via Swagger `GET /api/ministries` and `GET /api/expense-categories?includeInactive=true` (authorize with the seeded admin `admin@rolac.org / Admin1234!`). - [ ] **Step 3: Verify the finance happy path via Swagger or UI** As `super_admin`: create a vendor payment (expect status `Paid`); create a reimbursement (Draft) → submit → approve → pay; confirm a rejected path sets `Rejected` with notes. Upload a receipt to a reimbursement (`POST /api/expenses/{id}/receipt`) and fetch it back (`GET /api/expenses/{id}/receipt`). - [ ] **Step 4: Verify member self-service in the UI** Log in (admin acts as any authenticated user). Visit `/user-portal/reimbursements`: create a reimbursement with a receipt file, confirm it appears as Draft, submit it, and confirm it becomes PendingApproval and is no longer editable. - [ ] **Step 5: Verify monthly statement reconciliation** Create a `MonthlyStatement` for a month that has seeded/paid data; confirm `TotalGiving` and `TotalExpenses` are computed server-side and `Difference` updates when `BankStatementBalance` is set to equal `CalculatedClosingBalance` (Difference → 0). Finalize it and confirm further edits are rejected (409). - [ ] **Step 6: Final commit (if any verification fixes were needed)** ```bash git add -A git commit -m "test(expense): manual end-to-end verification fixes" ``` --- ## Self-Review Notes (for the implementer) - **Spec coverage:** Category setup (Tasks 2, 7, 12) · vendor payment (Tasks 8, 10, 15) · staff reimbursement + receipt + self-service (Tasks 5, 8, 13, 14) · approval workflow (Task 8 state machine, Task 10/15 actions) · monthly statement (Tasks 3, 9, 16). Ministry prerequisite (Task 1). Storage abstraction (Task 5). Nav/routes (Task 17). - **Auth split:** `ExpensesController` is `[Authorize]` (any user) with per-endpoint role attributes on approve/reject/pay and service-level self-ownership checks; list/vendor restricted via `CanViewAll()`/`IsFinance()`. Categories + monthly statement are `[Authorize(Roles="finance,super_admin")]`. Ministries read is `[Authorize]` (needed by member form). - **Date handling:** all `yyyy-MM-dd` strings built from local Y/M/D components, never `toISOString()`. - **Kendo:** every `textField`/`valueField` dropdown sets `[valuePrimitive]="true"`. - **Known follow-ups (out of scope, see spec §11):** Azure Blob storage impl, Capacitor camera capture, ministry-leader-scoped expense visibility, automatic monthly recompute on late expense entry.