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, baseEntities/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 byAuditSaveChangesInterceptor. Do not set them manually. - The interceptor does NOT convert deletes to soft-deletes. Soft-delete (
Expense) is handled manually in the service. CurrentUserIdcomes fromIHttpContextAccessorclaimClaimTypes.NameIdentifier.- Build/test from CLI with
-c Release(VS locksbin/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 + 39 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:
Buildreturns(ExpenseService, AppDbContext, FakeStorage)so tests can spin up a second service over the same db/storage acting as a different user via theSvcAshelper. TheMember/AppUserlinkage is not needed for these tests becauseSubmittedByis taken from the acting user's claim, not from aMemberrow.
- 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/@Outputstandalone 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-dropdownlistusingtextField/valueFieldagainst 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, 39 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:
ExpensesControlleris[Authorize](any user) with per-endpoint role attributes on approve/reject/pay and service-level self-ownership checks; list/vendor restricted viaCanViewAll()/IsFinance(). Categories + monthly statement are[Authorize(Roles="finance,super_admin")]. Ministries read is[Authorize](needed by member form). - Date handling: all
yyyy-MM-ddstrings built from local Y/M/D components, nevertoISOString(). - Kendo: every
textField/valueFielddropdown 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.