diff --git a/docs/superpowers/plans/2026-05-29-expense-tracking.md b/docs/superpowers/plans/2026-05-29-expense-tracking.md new file mode 100644 index 0000000..59cb274 --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-expense-tracking.md @@ -0,0 +1,2983 @@ +# 支出追蹤 & 報銷 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.