Files
ROLAC/docs/superpowers/plans/2026-05-29-expense-tracking.md
T
Chris Chen 50e518095e docs(expense): add expense tracking implementation plan
18 TDD tasks: Ministry prerequisite (entity/seed/endpoint), expense category
entities + seed, Expense + MonthlyStatement entities, EF migration,
IFileStorage + local-disk impl, DTOs, three services with tests, controllers
with auth split, and four Angular pages + nav/routes wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:54:13 -07:00

129 KiB

支出追蹤 & 報銷 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:

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):

    public DbSet<Ministry> Ministries => Set<Ministry>();

At the end of OnModelCreating add:

        // ── Ministry ─────────────────────────────────────────────────────────
        builder.Entity<Ministry>(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:

    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:

    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:

        await SeedMinistriesAsync(db);
  • Step 4: Create MinistryDto + service interface + impl

API/ROLAC.API/DTOs/Ministry/MinistryDto.cs:

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:

using ROLAC.API.DTOs.Ministry;
namespace ROLAC.API.Services;

public interface IMinistryService
{
    Task<List<MinistryDto>> GetAllAsync(bool includeInactive);
}

API/ROLAC.API/Services/MinistryService.cs:

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<List<MinistryDto>> 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):

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<IActionResult> GetAll([FromQuery] bool includeInactive = false)
        => Ok(await _svc.GetAllAsync(includeInactive));
}
  • Step 6: Register the service in Program.cs

In Program.cs after AddScoped<IOfferingSessionService, ...> add:

builder.Services.AddScoped<IMinistryService, MinistryService>();
  • Step 7: Write the test

API/ROLAC.API.Tests/Services/MinistryServiceTests.cs (mirror GivingCategoryServiceTests BuildDb helper):

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<IHttpContextAccessor>();
        mock.Setup(x => x.HttpContext).Returns(ctx);
        return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .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
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:

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<ExpenseSubCategory> SubCategories { get; set; } = [];
}
  • Step 2: Create ExpenseSubCategory entity

API/ROLAC.API/Entities/ExpenseSubCategory.cs:

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:

    public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
    public DbSet<ExpenseSubCategory>   ExpenseSubCategories  => Set<ExpenseSubCategory>();

Add config at the end of OnModelCreating:

        // ── ExpenseCategoryGroup ─────────────────────────────────────────────
        builder.Entity<ExpenseCategoryGroup>(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<ExpenseSubCategory>(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):

    // (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:

    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:

        await SeedExpenseCategoriesAsync(db);
  • Step 5: Build

Run: dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release Expected: Build succeeded.

  • Step 6: Commit
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:

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:

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:

    public DbSet<Expense>          Expenses          => Set<Expense>();
    public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();

Add config at the end of OnModelCreating:

        // ── Expense ──────────────────────────────────────────────────────────
        builder.Entity<Expense>(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<MonthlyStatement>(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
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/<timestamp>_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/<timestamp>_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
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:

namespace ROLAC.API.Services.Storage;

public interface IFileStorage
{
    Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default);
    Task<Stream?> 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:

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<string, string?> { ["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<ArgumentException>(() => 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:

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<string> 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<Stream?> 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:

builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
                           ROLAC.API.Services.Storage.LocalDiskFileStorage>();

In appsettings.json add a top-level section:

  "Storage": {
    "LocalRoot": "App_Data/storage"
  },

In .gitignore add:

API/ROLAC.API/App_Data/
  • Step 7: Commit
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:

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<ExpenseSubCategoryDto> 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:

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:

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
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:

using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services;

public interface IExpenseCategoryService
{
    Task<List<ExpenseCategoryGroupDto>> GetAllAsync(bool includeInactive);
    Task<int> CreateGroupAsync(CreateExpenseGroupRequest r);
    Task UpdateGroupAsync(int id, UpdateExpenseGroupRequest r);
    Task DeactivateGroupAsync(int id);
    Task<int> 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:

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<IHttpContextAccessor>();
        mock.Setup(x => x.HttpContext).Returns(ctx);
        return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .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<KeyNotFoundException>(() =>
            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:

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<List<ExpenseCategoryGroupDto>> 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<int> 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<int> 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
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:

using ROLAC.API.DTOs.Expense;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;

public interface IExpenseService
{
    Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
        int page, int pageSize, string? search, int? ministryId,
        int? categoryGroupId, string? status, DateOnly? from, DateOnly? to);
    Task<PagedResult<ExpenseListItemDto>> GetMineAsync(string userId, string? status, int page, int pageSize);
    Task<ExpenseDto?> GetByIdAsync(int id);
    Task<int> 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:

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<string, byte[]> Files = new();
        public Task<string> 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<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
        => Task.FromResult<Stream?>(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<IHttpContextAccessor>();
        mock.Setup(x => x.HttpContext).Returns(ctx);
        return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .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<IHttpContextAccessor>();
        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<InvalidOperationException>(() => 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<InvalidOperationException>(() =>
            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<IHttpContextAccessor>();
        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:

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<PagedResult<ExpenseListItemDto>> 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<PagedResult<ExpenseListItemDto>> 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<PagedResult<ExpenseListItemDto>> ProjectPagedAsync(IQueryable<Expense> 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<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
    }

    public async Task<ExpenseDto?> 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<int> 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<int?> 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<Expense> 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
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:

using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services;

public interface IMonthlyStatementService
{
    Task<List<MonthlyStatementDto>> GetAllAsync(int? year);
    Task<MonthlyStatementDto?> GetByIdAsync(int id);
    Task<int> 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:

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<IHttpContextAccessor>();
        mock.Setup(x => x.HttpContext).Returns(ctx);
        return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
    }

    private static MonthlyStatementService Build(AppDbContext db)
    {
        var mock = new Mock<IHttpContextAccessor>();
        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<InvalidOperationException>(() =>
            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<InvalidOperationException>(() =>
            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:

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<List<MonthlyStatementDto>> 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<MonthlyStatementDto?> GetByIdAsync(int id)
    {
        var s = await _db.MonthlyStatements.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
        return s is null ? null : Map(s);
    }

    public async Task<int> 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
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:

builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
  • Step 2: ExpenseCategoriesController

API/ROLAC.API/Controllers/ExpenseCategoriesController.cs:

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<IActionResult> GetAll([FromQuery] bool includeInactive = false)
        => Ok(await _svc.GetAllAsync(includeInactive));

    [HttpPost("groups")]
    public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
        => Ok(new { id = await _svc.CreateGroupAsync(r) });

    [HttpPut("groups/{id:int}")]
    public async Task<IActionResult> 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<IActionResult> DeactivateGroup(int id)
    { try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }

    [HttpPost("subcategories")]
    public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
    { try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }

    [HttpPut("subcategories/{id:int}")]
    public async Task<IActionResult> 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<IActionResult> 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.

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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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:

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<IActionResult> GetAll([FromQuery] int? year = null)
        => Ok(await _svc.GetAllAsync(year));

    [HttpGet("{id:int}")]
    public async Task<IActionResult> GetById(int id)
    {
        var dto = await _svc.GetByIdAsync(id);
        return dto is null ? NotFound() : Ok(dto);
    }

    [HttpPost]
    public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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
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:

export type ExpenseType = 'VendorPayment' | 'StaffReimbursement';
export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected';

export interface PagedResult<T> {
  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:

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<MinistryDto[]> {
    return this.http.get<MinistryDto[]>(this.endpoint, { params: new HttpParams().set('includeInactive', includeInactive) });
  }
}

APP/src/app/features/expense/services/expense-category-api.service.ts:

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<ExpenseCategoryGroupDto[]> {
    return this.http.get<ExpenseCategoryGroupDto[]>(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<void> { return this.http.put<void>(`${this.endpoint}/groups/${id}`, r); }
  deactivateGroup(id: number): Observable<void> { return this.http.delete<void>(`${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<void> { return this.http.put<void>(`${this.endpoint}/subcategories/${id}`, r); }
  deactivateSub(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/subcategories/${id}`); }
}
  • Step 3: Expense + monthly-statement API services

APP/src/app/features/expense/services/expense-api.service.ts:

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<string, unknown>): 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<PagedResult<ExpenseListItemDto>> {
    return this.http.get<PagedResult<ExpenseListItemDto>>(this.endpoint, { params: this.toParams(q as Record<string, unknown>) });
  }
  getMine(status?: string, page = 1, pageSize = 50): Observable<PagedResult<ExpenseListItemDto>> {
    return this.http.get<PagedResult<ExpenseListItemDto>>(`${this.endpoint}/mine`, { params: this.toParams({ status, page, pageSize }) });
  }
  getById(id: number): Observable<ExpenseDto> { return this.http.get<ExpenseDto>(`${this.endpoint}/${id}`); }
  create(r: CreateExpenseRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(this.endpoint, r); }
  update(id: number, r: UpdateExpenseRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/${id}`, r); }
  delete(id: number): Observable<void> { return this.http.delete<void>(`${this.endpoint}/${id}`); }
  submit(id: number): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/submit`, {}); }
  approve(id: number): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/approve`, {}); }
  reject(id: number, r: RejectExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/reject`, r); }
  pay(id: number, r: PayExpenseRequest): Observable<void> { return this.http.post<void>(`${this.endpoint}/${id}/pay`, r); }
  uploadReceipt(id: number, file: File): Observable<void> {
    const form = new FormData(); form.append('file', file);
    return this.http.post<void>(`${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:

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<MonthlyStatementDto[]> {
    let p = new HttpParams(); if (year) p = p.set('year', year);
    return this.http.get<MonthlyStatementDto[]>(this.endpoint, { params: p });
  }
  getById(id: number): Observable<MonthlyStatementDto> { return this.http.get<MonthlyStatementDto>(`${this.endpoint}/${id}`); }
  create(r: CreateMonthlyStatementRequest): Observable<{ id: number }> { return this.http.post<{ id: number }>(this.endpoint, r); }
  update(id: number, r: UpdateMonthlyStatementRequest): Observable<void> { return this.http.put<void>(`${this.endpoint}/${id}`, r); }
  finalize(id: number): Observable<void> { return this.http.post<void>(`${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
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):

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
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.

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<ExpenseFormResult>();
  @Output() cancel = new EventEmitter<void>();

  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 <input type="file" accept="image/*,application/pdf" (change)="onFileSelected($event)">. 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
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

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 <app-expense-form-dialog *ngIf="dialogOpen" mode="reimbursement" title="New Reimbursement" (save)="onSave($event)" (cancel)="dialogOpen=false">. 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
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
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 <app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)" (cancel)="vendorDialogOpen=false"> 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
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

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
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:

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:

            { 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:

    { 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):

  public personalNavItems: NavItem[] = [
    { text: 'My Reimbursements', icon: this.creditCardIcon, path: '/user-portal/reimbursements' },
  ];

Include it in the updateActiveStates aggregation array:

      ...this.personalNavItems,

Add the page titles in getPageTitle's titles map:

      '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:

                <div class="nav-section">
                    <h4 *ngIf="!sidebarCollapsed">Personal</h4>
                    <a *ngFor="let item of personalNavItems" class="nav-item" [class.active]="item.active"
                        [title]="item.text" (click)="navigateTo(item.path)">
                        <div class="nav-icon">
                            <kendo-svgicon [icon]="item.icon"></kendo-svgicon>
                        </div>
                        <span *ngIf="!sidebarCollapsed">{{ item.text }}</span>
                    </a>
                </div>

(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
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)
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.