Compare commits

...

25 Commits

Author SHA1 Message Date
Chris Chen 9f91683633 docs: sync DB_SCHEMA with Form 990 functional-expense schema
ci-cd-vm / ci-cd (push) Successful in 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:54:00 -07:00
Chris Chen 5aaac3246d feat(web): Form 990 functional-expenses report page, route, and nav
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:49:02 -07:00
Chris Chen 677cb8f054 feat(web): default functional class on the ministry form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:45:40 -07:00
Chris Chen f79dab163d feat(web): functional-class override on the expense form 2026-06-24 19:43:03 -07:00
Chris Chen 4438c351e2 feat(web): map expense categories to Form 990 lines in the category admin page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:38:17 -07:00
Chris Chen 1a03a1cbba feat(finance): expose Form 990 line catalog endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:36:01 -07:00
Chris Chen 3f61e9ceaf feat(web): add Form990Report permission and expense functional-class/line fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:33:37 -07:00
Chris Chen b41297f972 feat(finance): Form 990 functional-expenses report endpoint 2026-06-24 19:30:33 -07:00
Chris Chen a5de2dbbb1 feat(finance): Form 990 Part IX functional-expense aggregation service
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:26:59 -07:00
Chris Chen 1fa36ae62f fix(finance): make Form990 row DTO use properties (System.Text.Json skips fields) 2026-06-24 19:23:54 -07:00
Chris Chen 1353b5571f feat(finance): add Form 990 report DTOs and permission module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:23:05 -07:00
Chris Chen 4e83f27703 feat(seed): default Administration ministry to Management & General
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:21:04 -07:00
Chris Chen d5e1732505 feat(seed): seed Form 990 line catalog and default subcategory mappings 2026-06-24 19:17:51 -07:00
Chris Chen ae757bee3d test(seed): use Assert.Single predicate overload (xUnit2031) 2026-06-24 19:15:10 -07:00
Chris Chen 6e04b64466 feat(seed): add IT/Professional/Finance categories and rename overlapping subcategories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:12:57 -07:00
Chris Chen f70a7b5a58 feat(db): migration for Form 990 lines, category mapping, functional class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:09:00 -07:00
Chris Chen b6b110254a feat(expense): add per-expense FunctionalClass override 2026-06-24 19:05:07 -07:00
Chris Chen d3e6b5aed5 feat(ministry): add DefaultFunctionalClass for Form 990 functional split 2026-06-24 19:00:36 -07:00
Chris Chen ac84097254 test(expense): assert Form990LineCode projection resolves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:57:14 -07:00
Chris Chen 971bf165cc feat(expense): map category group/subcategory to Form 990 lines 2026-06-24 18:53:13 -07:00
Chris Chen f1faa0d435 feat(expense): add Form990ExpenseLine catalog entity and functional-class constants 2026-06-24 18:47:42 -07:00
Chris Chen 9dbb1d38d8 WIP 2026-06-24 18:45:22 -07:00
Chris Chen e908e35530 docs: implementation plan for sub-project A (Form 990 functional expenses)
17 TDD tasks: Form990ExpenseLine catalog + category mapping, Ministry
DefaultFunctionalClass, Expense FunctionalClass override, EF migration,
seed (new categories/renames/line catalog/mappings/ministry defaults),
Form990ReportService Part IX aggregation + controller, and the frontend
(category line mapping, expense + ministry functional-class controls,
report page/route/nav). DB_SCHEMA sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:41:35 -07:00
Chris Chen b51f22cfba docs: expand 990 expense-line catalog and add categories to cover gaps
Add 990 lines 5/8/11b/11c/20 to the catalog and new natural categories
(Personnel officer comp + pension, Missions foreign support, Printing
advertising, plus Professional Services / Information Technology /
Finance & Banking groups) so the category tree covers the common Part IX
lines instead of dumping uncovered lines into 24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:30:49 -07:00
Chris Chen 764464e785 docs: spec for sub-project A — expense 990 functional expenses (Part IX)
Audit-readiness at Form 990 Part IX level: functional class (Program/M&G/
Fundraising) via Ministry default + per-expense override, Form990ExpenseLine
catalog + subcategory/group mapping, duplicate-category cleanup, and the
Part IX functional-expense matrix report. 1099 (B) and revenue Part VIII (C)
are separate specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:24:17 -07:00
53 changed files with 5486 additions and 48 deletions
@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DbSeederForm990Tests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
{
using var db = BuildDb();
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
db.ExpenseCategoryGroups.Add(fnb);
await db.SaveChangesAsync();
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
await db.SaveChangesAsync();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
var groups = await db.ExpenseCategoryGroups.ToListAsync();
Assert.Contains(groups, g => g.Name_en == "Professional Services");
Assert.Contains(groups, g => g.Name_en == "Information Technology");
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
Assert.Single(groups, g => g.Name_en == "Professional Services");
}
[Fact]
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
{
using var db = BuildDb();
await DbSeeder.SeedMinistriesAsync(db);
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
Assert.Equal("Program", worship.DefaultFunctionalClass);
}
[Fact]
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Salary & Wages");
Assert.Equal("7", salary.Form990Line!.LineCode);
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Accounting & Audit");
Assert.Equal("11c", audit.Form990Line!.LineCode);
}
}
@@ -58,4 +58,23 @@ public class ExpenseCategoryServiceTests
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
}
[Fact]
public async Task CreateAndGet_RoundTrips_Form990LineId()
{
using var db = BuildDb();
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 1, LineCode = "24", Name_en = "Other" });
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
await db.SaveChangesAsync();
var svc = new ExpenseCategoryService(db);
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 1 });
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
var all = await svc.GetAllAsync(includeInactive: true);
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
Assert.Equal(7, sub.Form990LineId);
Assert.Equal("7", sub.Form990LineCode);
Assert.Equal(1, all.Single(g => g.Id == gid).Form990LineId);
Assert.Equal("24", all.Single(g => g.Id == gid).Form990LineCode);
}
}
@@ -248,6 +248,27 @@ public class ExpenseServiceTests
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
}
[Fact]
public async Task Create_PersistsFunctionalClass_AndGetReturnsIt()
{
var db = BuildDb("u1");
db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" });
db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
await db.SaveChangesAsync();
var svc = SvcAs(db, new FakeStorage(), "u1");
var id = await svc.CreateAsync(new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
FunctionalClass = "ManagementGeneral",
}, isFinance: true);
var dto = await svc.GetByIdAsync(id);
Assert.Equal("ManagementGeneral", dto!.FunctionalClass);
}
[Fact]
public async Task Receipt_SaveThenOpen_RoundTrips()
{
@@ -0,0 +1,85 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form990ReportServiceTests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static async Task SeedAsync(AppDbContext db)
{
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
await db.SaveChangesAsync();
}
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
{
MinistryId = min, CategoryGroupId = 1, SubCategoryId = sub, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
FunctionalClass = fc,
};
[Fact]
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
Assert.Equal(110m, line7.Program);
Assert.Equal(40m, line7.ManagementGeneral);
Assert.Equal(150m, line7.Total);
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
Assert.Equal(25m, line24.Program);
Assert.Equal(1, stmt.UnmappedExpenseCount);
Assert.Equal(175m, stmt.GrandTotal);
Assert.Equal(135m, stmt.ProgramTotal);
Assert.Equal(40m, stmt.ManagementGeneralTotal);
}
[Fact]
public async Task Statement_RespectsDateRange()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
db.Expenses.Add(older);
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
Assert.Equal(100m, stmt.GrandTotal);
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
@@ -41,4 +42,19 @@ public class MinistryServiceTests
Assert.Equal("A", active[0].Name_en);
Assert.Equal(3, all.Count);
}
[Fact]
public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt()
{
using var db = BuildDb();
var svc = new MinistryService(db);
var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" });
var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
Assert.Equal("Program", afterCreate.DefaultFunctionalClass);
await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" });
var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass);
}
}
+2
View File
@@ -16,6 +16,7 @@ public static class Modules
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
@@ -37,6 +38,7 @@ public static class Modules
OfferingSessions,
Ministries,
FinanceDashboard,
Form990Report,
MonthlyStatements,
ChurchProfile,
Disbursements,
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/form990-report")]
[HasPermission(Modules.Form990Report, PermissionActions.Read)]
public class Form990ReportController : ControllerBase
{
private readonly IForm990ReportService _svc;
public Form990ReportController(IForm990ReportService svc) => _svc = svc;
[HttpGet("lines")]
public async Task<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
[HttpGet("functional-expenses")]
public async Task<IActionResult> FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
=> Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to));
}
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
@@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase
public MinistriesController(IMinistryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.Ministries, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMinistryRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:int}")]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMinistryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.Ministries, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -9,6 +9,8 @@ public class ExpenseSubCategoryDto
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
}
public class ExpenseCategoryGroupDto
@@ -18,6 +20,8 @@ public class ExpenseCategoryGroupDto
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
}
@@ -26,6 +30,7 @@ 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 int? Form990LineId { get; set; }
}
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
{
@@ -38,6 +43,7 @@ public class CreateExpenseSubCategoryRequest
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
}
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
{
@@ -20,6 +20,7 @@ public class ExpenseListItemDto
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; }
public string? CheckNumber { get; set; }
public string? FunctionalClass { get; set; }
}
public class ExpenseDto : ExpenseListItemDto
@@ -45,6 +46,7 @@ public class CreateExpenseRequest
[MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
}
public class UpdateExpenseRequest : CreateExpenseRequest { }
@@ -0,0 +1,35 @@
namespace ROLAC.API.DTOs.Finance;
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
public class FunctionalExpenseRowDto
{
public string LineCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public decimal Program { get; set; }
public decimal ManagementGeneral { get; set; }
public decimal Fundraising { get; set; }
public decimal Total { get; set; }
}
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
public class FunctionalExpenseStatementDto
{
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
public decimal ProgramTotal { get; set; }
public decimal ManagementGeneralTotal { get; set; }
public decimal FundraisingTotal { get; set; }
public decimal GrandTotal { get; set; }
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
public int UnmappedExpenseCount { get; set; }
}
/// <summary>A single IRS Form 990 expense line from the catalog (used to populate mapping dropdowns).</summary>
public class Form990ExpenseLineDto
{
public int Id { get; set; }
public string LineCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Ministry;
public class CreateMinistryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public int SortOrder { get; set; }
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
}
@@ -5,6 +5,9 @@ public class MinistryDto
public int Id { get; set; }
public string Name_en { get; set; } = "";
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; }
public string DefaultFunctionalClass { get; set; } = "Program";
}
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Ministry;
public class UpdateMinistryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
}
+18
View File
@@ -20,6 +20,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<Ministry> Ministries => Set<Ministry>();
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
@@ -200,6 +201,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program");
});
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
builder.Entity<Form990ExpenseLine>(entity =>
{
entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired();
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.LineCode).IsUnique();
});
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
@@ -209,6 +222,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
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.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseSubCategory ───────────────────────────────────────────────
@@ -220,6 +235,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
});
// ── Expense ──────────────────────────────────────────────────────────
@@ -229,6 +246,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.VendorName).HasMaxLength(200);
+150 -9
View File
@@ -35,15 +35,86 @@ public static class DbSeeder
[
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]),
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
("Materials", "教材", 5, [("Curriculum 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","差旅")]),
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
("Other", "其他", 10, [("Miscellaneous","雜支")]),
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
];
// (LineCode, Name_en, Name_zh, Sort)
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
[
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
("7", "Other salaries and wages", "薪資", 5),
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
("9", "Other employee benefits", "員工福利", 7),
("10", "Payroll taxes", "薪資稅", 8),
("11b", "Legal fees", "法律服務費", 9),
("11c", "Accounting fees", "會計與審計費", 10),
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
("12", "Advertising and promotion", "廣告與推廣", 12),
("13", "Office expenses", "辦公費用", 13),
("14", "Information technology", "資訊科技", 14),
("16", "Occupancy", "場地佔用", 15),
("17", "Travel", "差旅", 16),
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
("20", "Interest", "利息", 18),
("22", "Depreciation", "折舊", 19),
("23", "Insurance", "保險", 20),
("24", "Other expenses", "其他費用", 21),
];
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
[
("Personnel", "Officer / Key Employee Compensation", "5"),
("Personnel", "Salary & Wages", "7"),
("Personnel", "Payroll Taxes", "10"),
("Personnel", "Employee Benefits", "9"),
("Personnel", "Retirement / Pension","8"),
("Personnel", "Workers Compensation","9"),
("Personnel", "Honorarium", "11g"),
("Personnel", "Contract Labor", "11g"),
("Personnel", "Staff Training", "19"),
("Facility", "Rent", "16"),
("Facility", "Utilities", "16"),
("Facility", "Property Insurance", "23"),
("Facility", "Decoration", "24"),
("Training", "Course Fees", "19"),
("Training", "Conference", "19"),
("Training", "Books", "24"),
("Training", "Travel", "17"),
("Missions", "Travel", "17"),
("Missions", "Offering Transfer", "1"),
("Missions", "Missionary Support", "1"),
("Missions", "Foreign Missions Support", "3"),
("Benevolence", "Emergency Aid", "2"),
("Benevolence", "Condolence Gifts", "2"),
("Benevolence", "Visit Expenses", "2"),
("Consumables", "Office Supplies", "13"),
("Printing", "Bulletins", "13"),
("Printing", "Order of Service", "13"),
("Printing", "Posters", "12"),
("Printing", "Advertising & Promotion", "12"),
("Materials", "Curriculum Printing", "13"),
("Professional Services", "Legal", "11b"),
("Professional Services", "Accounting & Audit", "11c"),
("Professional Services", "Other Professional", "11g"),
("Information Technology", "Software & Subscriptions", "14"),
("Information Technology", "Website & Hosting", "14"),
("Information Technology", "Internet & Telecom", "14"),
("Finance & Banking", "Interest", "20"),
];
private static readonly (string Name, string Description)[] Roles =
@@ -87,13 +158,32 @@ public static class DbSeeder
("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
("pastor", Modules.Form990Report, true, false, false, false),
("board_member", Modules.Form990Report, true, false, false, false),
// Ministries — secretary maintains the list; coworker_chair edits; ministry
// leaders and pastor read.
("secretary", Modules.Ministries, true, true, true, false),
("coworker_chair", Modules.Ministries, true, true, false, false),
("ministry_leader", Modules.Ministries, true, false, false, false),
("pastor", Modules.Ministries, true, false, false, false),
// Meal attendance — secretary and coworkers record; finance and pastor read.
("secretary", Modules.MealAttendance, true, true, false, false),
("coworker", Modules.MealAttendance, true, true, false, false),
("finance", Modules.MealAttendance, true, false, false, false),
("pastor", Modules.MealAttendance, true, false, false, false),
// Users, Permissions, and Settings are intentionally super_admin-only:
// super_admin bypasses all checks, so no seed rows are needed here.
];
public static async Task SeedRolePermissionsAsync(AppDbContext db)
@@ -163,13 +253,35 @@ public static class DbSeeder
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 });
db.Ministries.Add(new Ministry
{
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
DefaultFunctionalClass = en == "Administration"
? FunctionalClasses.ManagementGeneral
: FunctionalClasses.Program,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
{
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
// only fires while the old name still exists. (New installs never hit this.)
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
{
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
};
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
{
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
if (grp is null) continue;
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
}
await db.SaveChangesAsync();
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
{
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
@@ -192,6 +304,34 @@ public static class DbSeeder
await db.SaveChangesAsync();
}
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
{
foreach (var (code, en, zh, sort) in Form990LineSeed)
{
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
db.Form990ExpenseLines.Add(new Form990ExpenseLine
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
}
await db.SaveChangesAsync();
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var fallbackId = linesByCode["24"];
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
group.Form990LineId ??= fallbackId;
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
sub.Form990LineId = lineId;
}
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db)
{
// Singleton row used by the disbursement module (issuer info + check counter).
@@ -270,6 +410,7 @@ public static class DbSeeder
await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db);
await SeedForm990ExpenseLinesAsync(db);
await SeedChurchProfileAsync(db);
await SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config);
+1
View File
@@ -9,6 +9,7 @@ public class Expense : SoftDeleteEntity, IAuditable
public int SubCategoryId { get; set; }
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
public string Status { get; set; } = "Draft"; // see state machine
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; }
public string Description { get; set; } = null!;
public string? VendorName { get; set; }
@@ -9,5 +9,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
}
@@ -10,5 +10,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public ExpenseCategoryGroup? Group { get; set; }
}
@@ -0,0 +1,13 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages".</summary>
public class Form990ExpenseLine : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string LineCode { get; set; } = null!; // "7", "11b", "16", "24"
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,18 @@
namespace ROLAC.API.Entities;
/// <summary>
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass.
/// </summary>
public static class FunctionalClasses
{
public const string Program = "Program";
public const string ManagementGeneral = "ManagementGeneral";
public const string Fundraising = "Fundraising";
public static readonly IReadOnlyList<string> All = [Program, ManagementGeneral, Fundraising];
/// <summary>Returns the value if valid, otherwise Program (the safe default).</summary>
public static string Normalize(string? value) =>
value is not null && All.Contains(value) ? value : Program;
}
+1
View File
@@ -11,4 +11,5 @@ public class Ministry : IAuditable
public string? Description_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public string DefaultFunctionalClass { get; set; } = "Program";
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddForm990FunctionalExpenses : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultFunctionalClass",
table: "Ministries",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Program");
migrationBuilder.AddColumn<int>(
name: "Form990LineId",
table: "ExpenseSubCategories",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FunctionalClass",
table: "Expenses",
type: "character varying(20)",
maxLength: 20,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Form990LineId",
table: "ExpenseCategoryGroups",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "Form990ExpenseLines",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LineCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Form990ExpenseLines", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExpenseSubCategories_Form990LineId",
table: "ExpenseSubCategories",
column: "Form990LineId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseCategoryGroups_Form990LineId",
table: "ExpenseCategoryGroups",
column: "Form990LineId");
migrationBuilder.CreateIndex(
name: "IX_Form990ExpenseLines_LineCode",
table: "Form990ExpenseLines",
column: "LineCode",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
table: "ExpenseCategoryGroups",
column: "Form990LineId",
principalTable: "Form990ExpenseLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
table: "ExpenseSubCategories",
column: "Form990LineId",
principalTable: "Form990ExpenseLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropForeignKey(
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropTable(
name: "Form990ExpenseLines");
migrationBuilder.DropIndex(
name: "IX_ExpenseSubCategories_Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropIndex(
name: "IX_ExpenseCategoryGroups_Form990LineId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropColumn(
name: "DefaultFunctionalClass",
table: "Ministries");
migrationBuilder.DropColumn(
name: "Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropColumn(
name: "FunctionalClass",
table: "Expenses");
migrationBuilder.DropColumn(
name: "Form990LineId",
table: "ExpenseCategoryGroups");
}
}
}
@@ -555,6 +555,10 @@ namespace ROLAC.API.Migrations
b.Property<DateOnly>("ExpenseDate")
.HasColumnType("date");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
@@ -657,6 +661,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
@@ -682,6 +689,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("Form990LineId");
b.ToTable("ExpenseCategoryGroups");
});
@@ -701,6 +710,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<int>("GroupId")
.HasColumnType("integer");
@@ -729,6 +741,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("Form990LineId");
b.HasIndex("GroupId");
b.ToTable("ExpenseSubCategories");
@@ -772,6 +786,58 @@ namespace ROLAC.API.Migrations
b.ToTable("FamilyUnits");
});
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LineCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("LineCode")
.IsUnique();
b.ToTable("Form990ExpenseLines");
});
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{
b.Property<int>("Id")
@@ -1245,6 +1311,13 @@ namespace ROLAC.API.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DefaultFunctionalClass")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Program");
b.Property<string>("Description_en")
.HasColumnType("text");
@@ -1966,14 +2039,31 @@ namespace ROLAC.API.Migrations
b.Navigation("SubCategory");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Form990Line");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group")
.WithMany("SubCategories")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Form990Line");
b.Navigation("Group");
});
+1
View File
@@ -155,6 +155,7 @@ builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
@@ -22,21 +22,28 @@ public class ExpenseCategoryService : IExpenseCategoryService
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
.ToListAsync();
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
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,
Form990LineId = g.Form990LineId,
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
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,
Form990LineId = s.Form990LineId,
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
}).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 };
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
_db.ExpenseCategoryGroups.Add(g);
await _db.SaveChangesAsync();
return g.Id;
@@ -46,7 +53,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{
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;
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId;
await _db.SaveChangesAsync();
}
@@ -62,7 +69,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{
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 };
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId };
_db.ExpenseSubCategories.Add(s);
await _db.SaveChangesAsync();
return s.Id;
@@ -72,7 +79,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{
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;
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId;
await _db.SaveChangesAsync();
}
+4 -1
View File
@@ -97,6 +97,7 @@ public class ExpenseService : IExpenseService
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber,
FunctionalClass = e.FunctionalClass,
}).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
@@ -122,6 +123,7 @@ public class ExpenseService : IExpenseService
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,
FunctionalClass = e.FunctionalClass,
};
}
@@ -132,6 +134,7 @@ public class ExpenseService : IExpenseService
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,
FunctionalClass = r.FunctionalClass,
};
if (r.Type == "VendorPayment")
@@ -179,7 +182,7 @@ public class ExpenseService : IExpenseService
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;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.FunctionalClass = r.FunctionalClass;
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
await _db.SaveChangesAsync();
}
@@ -0,0 +1,91 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
/// Single function per expense (direct-charge); no cost splitting.
/// </summary>
public class Form990ReportService : IForm990ReportService
{
private readonly AppDbContext _db;
public Form990ReportService(AppDbContext db) => _db = db;
public async Task<List<Form990ExpenseLineDto>> GetLinesAsync() =>
await _db.Form990ExpenseLines.AsNoTracking().Where(l => l.IsActive)
.OrderBy(l => l.SortOrder)
.Select(l => new Form990ExpenseLineDto
{
Id = l.Id,
LineCode = l.LineCode,
Name_en = l.Name_en,
Name_zh = l.Name_zh,
SortOrder = l.SortOrder,
})
.ToListAsync();
public async Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to)
{
var lines = await _db.Form990ExpenseLines.AsNoTracking()
.Where(l => l.IsActive).OrderBy(l => l.SortOrder).ToListAsync();
var fallbackId = lines.FirstOrDefault(l => l.LineCode == "24")?.Id;
var expenses = _db.Expenses.Where(e => e.Status == "Paid" || e.Status == "Approved");
if (from.HasValue) expenses = expenses.Where(e => e.ExpenseDate >= from.Value);
if (to.HasValue) expenses = expenses.Where(e => e.ExpenseDate <= to.Value);
var rows = await (
from e in expenses
join m in _db.Ministries on e.MinistryId equals m.Id
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id
select new
{
e.Amount,
e.FunctionalClass,
MinistryDefault = m.DefaultFunctionalClass,
SubLineId = sub.Form990LineId,
GroupLineId = grp.Form990LineId,
}).ToListAsync();
var acc = new Dictionary<int, (decimal P, decimal M, decimal F)>();
var unmapped = 0;
foreach (var r in rows)
{
var function = FunctionalClasses.Normalize(r.FunctionalClass ?? r.MinistryDefault);
var lineId = r.SubLineId ?? r.GroupLineId ?? fallbackId;
if (lineId is null) continue;
if (r.SubLineId is null) unmapped++;
var cur = acc.GetValueOrDefault(lineId.Value);
acc[lineId.Value] = function switch
{
FunctionalClasses.ManagementGeneral => (cur.P, cur.M + r.Amount, cur.F),
FunctionalClasses.Fundraising => (cur.P, cur.M, cur.F + r.Amount),
_ => (cur.P + r.Amount, cur.M, cur.F),
};
}
var dto = new FunctionalExpenseStatementDto { UnmappedExpenseCount = unmapped };
foreach (var line in lines)
{
var v = acc.GetValueOrDefault(line.Id);
dto.Rows.Add(new FunctionalExpenseRowDto
{
LineCode = line.LineCode, Name_en = line.Name_en, Name_zh = line.Name_zh,
Program = v.P, ManagementGeneral = v.M, Fundraising = v.F, Total = v.P + v.M + v.F,
});
dto.ProgramTotal += v.P;
dto.ManagementGeneralTotal += v.M;
dto.FundraisingTotal += v.F;
}
dto.GrandTotal = dto.ProgramTotal + dto.ManagementGeneralTotal + dto.FundraisingTotal;
return dto;
}
}
@@ -0,0 +1,8 @@
using ROLAC.API.DTOs.Finance;
namespace ROLAC.API.Services;
public interface IForm990ReportService
{
Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(DateOnly? from, DateOnly? to);
Task<List<Form990ExpenseLineDto>> GetLinesAsync();
}
@@ -4,4 +4,7 @@ namespace ROLAC.API.Services;
public interface IMinistryService
{
Task<List<MinistryDto>> GetAllAsync(bool includeInactive);
Task<int> CreateAsync(CreateMinistryRequest request);
Task UpdateAsync(int id, UpdateMinistryRequest request);
Task DeactivateAsync(int id); // soft-disable: IsActive = false
}
+36
View File
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
@@ -18,8 +19,43 @@ public class MinistryService : IMinistryService
.Select(m => new MinistryDto
{
Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh,
Description_en = m.Description_en, Description_zh = m.Description_zh,
SortOrder = m.SortOrder, IsActive = m.IsActive,
DefaultFunctionalClass = m.DefaultFunctionalClass,
})
.ToListAsync();
}
public async Task<int> CreateAsync(CreateMinistryRequest r)
{
var entity = new Ministry
{
Name_en = r.Name_en, Name_zh = r.Name_zh,
Description_en = r.Description_en, Description_zh = r.Description_zh,
SortOrder = r.SortOrder, IsActive = true,
DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass),
};
_db.Ministries.Add(entity);
await _db.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(int id, UpdateMinistryRequest r)
{
var m = await _db.Ministries.FindAsync(id)
?? throw new KeyNotFoundException($"Ministry {id} not found.");
m.Name_en = r.Name_en; m.Name_zh = r.Name_zh;
m.Description_en = r.Description_en; m.Description_zh = r.Description_zh;
m.IsActive = r.IsActive; m.SortOrder = r.SortOrder;
m.DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass);
await _db.SaveChangesAsync();
}
public async Task DeactivateAsync(int id)
{
var m = await _db.Ministries.FindAsync(id)
?? throw new KeyNotFoundException($"Ministry {id} not found.");
m.IsActive = false;
await _db.SaveChangesAsync();
}
}
+20
View File
@@ -7,6 +7,7 @@ import { PermissionGuard } from './core/guards/permission.guard';
import { PermissionModules } from './core/models/permission.model';
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
import { MinistriesPageComponent } from './features/ministry/pages/ministries-page/ministries-page.component';
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
@@ -19,6 +20,7 @@ import { FinanceDashboardPageComponent } from './features/finance-dashboard/page
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component';
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
@@ -65,6 +67,15 @@ export const routes: Routes = [
title: 'Member Management', titleZh: '會友管理', section: 'Admin',
},
},
{
path: 'admin/ministries',
component: MinistriesPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Ministries, action: 'read' },
title: 'Ministry Management', titleZh: '事工管理', section: 'Admin',
},
},
{
path: 'admin/users',
component: UsersPageComponent,
@@ -196,6 +207,15 @@ export const routes: Routes = [
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
},
},
{
path: 'finance/form-990-report',
component: Form990ReportPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Form990Report, action: 'read' },
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
},
},
]
},
@@ -24,6 +24,7 @@ export const PermissionModules = {
OfferingSessions: 'OfferingSessions',
Ministries: 'Ministries',
FinanceDashboard: 'FinanceDashboard',
Form990Report: 'Form990Report',
MonthlyStatements: 'MonthlyStatements',
ChurchProfile: 'ChurchProfile',
Disbursements: 'Disbursements',
@@ -59,6 +59,19 @@
</kendo-dropdownlist>
</label>
<!-- Functional Class override -->
<label class="flex flex-col gap-1">
<span>Functional Class / 功能別</span>
<kendo-dropdownlist
[data]="functionalClassOptions"
textField="label"
valueField="value"
[valuePrimitive]="true"
[defaultItem]="{ value: null, label: '(Inherit ministry / 沿用事工)' }"
[(ngModel)]="form.functionalClass">
</kendo-dropdownlist>
</label>
<!-- Amount -->
<label class="flex flex-col gap-1">Amount
<kendo-numerictextbox
@@ -12,7 +12,7 @@ import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseListItemDto,
ExpenseListItemDto, FunctionalClass,
} from '../../models/expense.model';
export interface ExpenseFormResult {
@@ -52,6 +52,12 @@ export class ExpenseFormDialogComponent implements OnInit {
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
continueEntry = false;
readonly functionalClassOptions: { value: FunctionalClass; label: string }[] = [
{ value: 'Program', label: 'Program / 事工服務' },
{ value: 'ManagementGeneral', label: 'Management & General / 管理' },
{ value: 'Fundraising', label: 'Fundraising / 募款' },
];
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */
get showContinueEntry(): boolean {
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
@@ -67,6 +73,7 @@ export class ExpenseFormDialogComponent implements OnInit {
checkNumber: '',
memberId: null as number | null,
expenseDate: new Date(),
functionalClass: null as FunctionalClass | null,
};
receipt: File | null = null;
@@ -101,6 +108,7 @@ export class ExpenseFormDialogComponent implements OnInit {
checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId,
expenseDate: new Date(year, month - 1, day),
functionalClass: expense.functionalClass ?? null,
};
}
@@ -146,6 +154,7 @@ export class ExpenseFormDialogComponent implements OnInit {
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
expenseDate,
notes: null,
functionalClass: this.form.functionalClass,
};
// The request and receipt are snapshotted here, so resetting the form right
// after emitting is safe even though the parent saves asynchronously.
@@ -1,5 +1,6 @@
export type ExpenseType = 'VendorPayment' | 'StaffReimbursement';
export type ExpenseStatus = 'Draft' | 'PendingApproval' | 'Approved' | 'Paid' | 'Rejected';
export type FunctionalClass = 'Program' | 'ManagementGeneral' | 'Fundraising';
export interface PagedResult<T> {
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
@@ -7,11 +8,11 @@ export interface PagedResult<T> {
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; }
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; }
export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; }
export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; }
export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; }
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; }
export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; }
export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
export interface ExpenseListItemDto {
@@ -19,7 +20,7 @@ export interface ExpenseListItemDto {
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;
checkNumber: string | null;
checkNumber: string | null; functionalClass: FunctionalClass | null;
}
export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; reviewNotes: string | null;
@@ -28,7 +29,7 @@ export interface ExpenseDto extends ExpenseListItemDto {
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;
checkNumber: string | null; expenseDate: string; notes: string | null; functionalClass: FunctionalClass | null;
}
export type UpdateExpenseRequest = CreateExpenseRequest;
export interface RejectExpenseRequest { reviewNotes: string | null; }
@@ -61,6 +61,15 @@
Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
[(ngModel)]="groupForm.form990LineId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
</label>
@@ -89,6 +98,15 @@
Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
[(ngModel)]="subForm.form990LineId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
</label>
@@ -5,14 +5,16 @@ import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angula
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
@Component({
selector: 'app-expense-categories-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule],
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, DropDownsModule, ContextMenuModule],
templateUrl: './expense-categories-page.component.html',
styleUrls: ['./expense-categories-page.component.scss'],
})
@@ -20,6 +22,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groups: ExpenseCategoryGroupDto[] = [];
selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false;
form990Lines: Form990ExpenseLineDto[] = [];
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
@@ -30,15 +33,18 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
subDialogOpen = false;
editingSubId: number | null = null;
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
constructor(private api: ExpenseCategoryApiService) {}
ngOnInit(): void { this.load(); }
ngOnInit(): void {
this.load();
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
}
load(): void {
this.loading = true;
@@ -101,16 +107,16 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
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.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
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 body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
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);
@@ -123,17 +129,17 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewSub(): void {
if (!this.selectedGroup) return;
this.editingSubId = null;
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true };
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
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.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
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 body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
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);
@@ -7,11 +7,12 @@ import {
ExpenseCategoryGroupDto, CreateExpenseGroupRequest, UpdateExpenseGroupRequest,
CreateExpenseSubCategoryRequest, UpdateExpenseSubCategoryRequest,
} from '../models/expense.model';
import { Form990ExpenseLineDto } from '../../finance-report/models/form990-report.model';
@Injectable({ providedIn: 'root' })
export class ExpenseCategoryApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
constructor(private http: HttpClient, private apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('expense-categories');
}
getAll(includeInactive = false): Observable<ExpenseCategoryGroupDto[]> {
@@ -29,4 +30,8 @@ export class ExpenseCategoryApiService {
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}`); }
getForm990Lines(): Observable<Form990ExpenseLineDto[]> {
return this.http.get<Form990ExpenseLineDto[]>(this.apiConfig.getApiUrl('form990-report') + '/lines')
.pipe(map(rows => rows.map(r => ({ ...r, label: `${r.lineCode}${r.name_en}${r.name_zh ? ' / ' + r.name_zh : ''}` }))));
}
}
@@ -0,0 +1,27 @@
export interface Form990ExpenseLineDto {
id: number;
lineCode: string;
name_en: string;
name_zh: string | null;
sortOrder: number;
label?: string; // bilingual "code — name", filled by service
}
export interface FunctionalExpenseRowDto {
lineCode: string;
name_en: string;
name_zh: string | null;
program: number;
managementGeneral: number;
fundraising: number;
total: number;
}
export interface FunctionalExpenseStatementDto {
rows: FunctionalExpenseRowDto[];
programTotal: number;
managementGeneralTotal: number;
fundraisingTotal: number;
grandTotal: number;
unmappedExpenseCount: number;
}
@@ -0,0 +1,39 @@
<div class="flex flex-wrap items-end gap-3 mb-4">
<label class="flex flex-col gap-1"><span>From / 起</span>
<kendo-datepicker [(value)]="from"></kendo-datepicker></label>
<label class="flex flex-col gap-1"><span>To / 迄</span>
<kendo-datepicker [(value)]="to"></kendo-datepicker></label>
<button kendoButton themeColor="primary" (click)="load()">Apply / 套用</button>
</div>
<div *ngIf="statement?.unmappedExpenseCount" class="mb-3 p-2 rounded bg-amber-50 text-amber-800 text-sm">
{{ statement?.unmappedExpenseCount }} expense(s) have no Form 990 mapping — counted under line 24.
尚有支出未對應 990 行,已暫計入 line 24。
</div>
<div class="hidden md:block">
<kendo-grid [data]="statement?.rows ?? []">
<kendo-grid-column field="lineCode" title="Line" [width]="80"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Description / 說明"></kendo-grid-column>
<kendo-grid-column field="program" title="Program" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="managementGeneral" title="Mgmt & General" format="{0:c2}" [width]="150"></kendo-grid-column>
<kendo-grid-column field="fundraising" title="Fundraising" format="{0:c2}" [width]="140"></kendo-grid-column>
<kendo-grid-column field="total" title="Total" format="{0:c2}" [width]="140"></kendo-grid-column>
</kendo-grid>
<div class="flex justify-end gap-8 mt-2 font-semibold" *ngIf="statement">
<span>Program: {{ statement.programTotal | currency }}</span>
<span>M&amp;G: {{ statement.managementGeneralTotal | currency }}</span>
<span>Fundraising: {{ statement.fundraisingTotal | currency }}</span>
<span>Total: {{ statement.grandTotal | currency }}</span>
</div>
</div>
<div class="md:hidden flex flex-col gap-3">
<div *ngFor="let row of statement?.rows ?? []" class="rounded border p-3">
<div class="font-semibold">{{ row.lineCode }} — {{ row.name_en }}</div>
<div class="text-sm flex justify-between"><span>Program</span><span>{{ row.program | currency }}</span></div>
<div class="text-sm flex justify-between"><span>M&amp;G</span><span>{{ row.managementGeneral | currency }}</span></div>
<div class="text-sm flex justify-between"><span>Fundraising</span><span>{{ row.fundraising | currency }}</span></div>
<div class="text-sm flex justify-between font-semibold"><span>Total</span><span>{{ row.total | currency }}</span></div>
</div>
</div>
@@ -0,0 +1,46 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { DatePickerModule } from '@progress/kendo-angular-dateinputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { Form990ReportApiService } from '../../services/form990-report-api.service';
import { FunctionalExpenseStatementDto } from '../../models/form990-report.model';
@Component({
selector: 'app-form990-report-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, DatePickerModule, ButtonsModule],
templateUrl: './form990-report-page.component.html',
})
export class Form990ReportPageComponent implements OnInit {
from: Date = new Date(new Date().getFullYear(), 0, 1);
to: Date = new Date(new Date().getFullYear(), 11, 31);
statement: FunctionalExpenseStatementDto | null = null;
loading = false;
constructor(private api: Form990ReportApiService) {}
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
const fmt = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
this.api.getFunctionalExpenses(fmt(this.from), fmt(this.to)).subscribe({
next: (statement) => {
this.statement = statement;
this.loading = false;
},
error: () => {
this.loading = false;
},
});
}
}
@@ -0,0 +1,24 @@
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 { FunctionalExpenseStatementDto } from '../models/form990-report.model';
@Injectable({ providedIn: 'root' })
export class Form990ReportApiService {
private readonly endpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('form990-report');
}
getFunctionalExpenses(from?: string, to?: string): Observable<FunctionalExpenseStatementDto> {
let params = new HttpParams();
if (from) { params = params.set('from', from); }
if (to) { params = params.set('to', to); }
return this.http.get<FunctionalExpenseStatementDto>(
`${this.endpoint}/functional-expenses`,
{ params }
);
}
}
@@ -0,0 +1,24 @@
// ── Ministries ────────────────────────────────────────────────────
export interface MinistryDto {
id: number;
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
isActive: boolean;
sortOrder: number;
defaultFunctionalClass: string;
/** Display-only bilingual label, computed in the API service. */
label?: string;
}
export interface CreateMinistryRequest {
name_en: string;
name_zh: string | null;
description_en: string | null;
description_zh: string | null;
sortOrder: number;
defaultFunctionalClass: string | null;
}
export interface UpdateMinistryRequest extends CreateMinistryRequest {
isActive: boolean;
}
@@ -0,0 +1,63 @@
<div class="page">
<ng-template appPageHeaderActions>
<label class="inactive-toggle">
<input type="checkbox" [(ngModel)]="includeInactive" (change)="load()" /> Show inactive
</label>
<button kendoButton themeColor="primary" (click)="openAdd()">+ Add</button>
</ng-template>
<kendo-grid [data]="data" [loading]="isLoading">
<kendo-grid-column field="sortOrder" title="#" [width]="60"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="90">
<ng-template kendoGridCellTemplate let-m>{{ m.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-m>
<button kendoButton fillMode="flat" (click)="openEdit(m)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="m.isActive" (click)="deactivate(m)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-dialog *ngIf="showDialog" [title]="editing ? 'Edit Ministry' : 'Add Ministry'" (close)="showDialog=false" [width]="480" [maxWidth]="'95vw'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1">
Name (EN) *
<kendo-textbox [(ngModel)]="form.name_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
名稱 (中)
<kendo-textbox [(ngModel)]="form.name_zh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Description (EN)
<kendo-textbox [(ngModel)]="form.description_en"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
說明 (中)
<kendo-textbox [(ngModel)]="form.description_zh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Sort order
<kendo-numerictextbox [(ngModel)]="form.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1">
<span>Default Functional Class / 預設功能別</span>
<kendo-dropdownlist
[data]="functionalClassOptions"
textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="form.defaultFunctionalClass">
</kendo-dropdownlist>
</label>
<label *ngIf="editing" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="form.isActive" /> Active
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="showDialog=false">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!form.name_en" (click)="save()">Save</button>
</kendo-dialog-actions>
</kendo-dialog>
</div>
@@ -0,0 +1,12 @@
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.inactive-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
@@ -0,0 +1,84 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { MinistryApiService } from '../../services/ministry-api.service';
import {
MinistryDto, CreateMinistryRequest, UpdateMinistryRequest,
} from '../../models/ministry.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({
selector: 'app-ministries-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, PageHeaderActionsDirective],
templateUrl: './ministries-page.component.html',
styleUrls: ['./ministries-page.component.scss'],
})
export class MinistriesPageComponent implements OnInit {
data: MinistryDto[] = [];
isLoading = false;
includeInactive = false;
readonly functionalClassOptions = [
{ value: 'Program', label: 'Program / 事工服務' },
{ value: 'ManagementGeneral', label: 'Management & General / 管理' },
{ value: 'Fundraising', label: 'Fundraising / 募款' },
];
showDialog = false;
editing: MinistryDto | null = null;
form: UpdateMinistryRequest = this.blankForm();
constructor(private api: MinistryApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.isLoading = true;
this.api.getAll(this.includeInactive).subscribe({
next: rows => { this.data = rows; this.isLoading = false; },
error: () => { this.isLoading = false; },
});
}
openAdd(): void { this.editing = null; this.form = this.blankForm(); this.showDialog = true; }
openEdit(m: MinistryDto): void {
this.editing = m;
this.form = {
name_en: m.name_en, name_zh: m.name_zh,
description_en: m.description_en, description_zh: m.description_zh,
isActive: m.isActive, sortOrder: m.sortOrder,
defaultFunctionalClass: m.defaultFunctionalClass || 'Program',
};
this.showDialog = true;
}
save(): void {
if (this.editing) {
this.api.update(this.editing.id, this.form).subscribe(() => { this.showDialog = false; this.load(); });
} else {
const create: CreateMinistryRequest = {
name_en: this.form.name_en, name_zh: this.form.name_zh,
description_en: this.form.description_en, description_zh: this.form.description_zh,
sortOrder: this.form.sortOrder,
defaultFunctionalClass: this.form.defaultFunctionalClass,
};
this.api.create(create).subscribe(() => { this.showDialog = false; this.load(); });
}
}
deactivate(m: MinistryDto): void {
if (!confirm(`Deactivate "${m.name_en}"?`)) return;
this.api.deactivate(m.id).subscribe(() => this.load());
}
private blankForm(): UpdateMinistryRequest {
return { name_en: '', name_zh: null, description_en: null, description_zh: null, isActive: true, sortOrder: 0, defaultFunctionalClass: 'Program' };
}
}
@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { bilingual } from '../../../shared/i18n/bilingual';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
MinistryDto, CreateMinistryRequest, UpdateMinistryRequest,
} from '../models/ministry.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[]> {
const params = new HttpParams().set('includeInactive', includeInactive);
return this.http.get<MinistryDto[]>(this.endpoint, { params }).pipe(
map(list => list.map(m => ({ ...m, label: bilingual(m.name_en, m.name_zh) }))),
);
}
create(request: CreateMinistryRequest): Observable<{ id: number }> {
return this.http.post<{ id: number }>(this.endpoint, request);
}
update(id: number, request: UpdateMinistryRequest): Observable<void> {
return this.http.put<void>(`${this.endpoint}/${id}`, request);
}
deactivate(id: number): Observable<void> {
return this.http.delete<void>(`${this.endpoint}/${id}`);
}
}
@@ -81,6 +81,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
public memberAdminNavItems: NavItem[] = [
{ text: 'Members', icon: groupIcon, path: '/user-portal/admin/members',
permission: { module: PermissionModules.Members, action: 'read' } },
{ text: 'Ministries', icon: groupIcon, path: '/user-portal/admin/ministries',
permission: { module: PermissionModules.Ministries, action: 'read' } },
];
public userAdminNavItems: NavItem[] = [
@@ -106,6 +108,8 @@ export class UserPortalComponent implements OnInit, OnDestroy {
permission: { module: PermissionModules.FinanceDashboard, action: 'read' } },
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement',
permission: { module: PermissionModules.MonthlyStatements, action: 'read' } },
{ text: 'Form 990 Report', icon: fileReportIcon, path: '/user-portal/finance/form-990-report',
permission: { module: PermissionModules.Form990Report, action: 'read' } },
],
},
{
+59 -16
View File
@@ -279,6 +279,7 @@ Table: Ministries
| Description_zh | text? | |
| SortOrder | int NOT NULL DEFAULT 0 | 顯示排序 |
| IsActive | bool NOT NULL DEFAULT true | |
| **DefaultFunctionalClass** | varchar(20) NOT NULL DEFAULT 'Program' | IRS Form 990 功能性費用分類:'Program' \| 'ManagementGeneral' \| 'Fundraising'。SeedAdministration → 'ManagementGeneral',其餘 → 'Program' |
---
@@ -495,6 +496,27 @@ Table: GivingRecurringSchedules
## 8. Expense Tracking(支出)
### Form990ExpenseLinesIRS Form 990 Part IX 自然費用科目)
```
Table: Form990ExpenseLines
```
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| LineCode | varchar(10) NOT NULL UNIQUE | Part IX 行號,如 "7"、"11b"、"16"、"24" |
| Name_en | varchar(200) NOT NULL | 英文科目名稱 |
| Name_zh | varchar(200)? | 中文科目名稱 |
| SortOrder | int NOT NULL | 排序 |
| IsActive | bool NOT NULL DEFAULT true | |
| CreatedAt | timestamp NOT NULL | |
| CreatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
> **IRS Form 990 Part IX 說明:** Part IX「Statement of Functional Expenses」要求將每筆支出依 IRS 自然費用科目(LineCode)分類,同時按功能性費用分類(Program / Management & General / Fundraising)橫向彙總,生成 Form 990 Schedule Part IX 報表。
### ExpenseCategoryGroup(支出大類)
```
@@ -508,8 +530,9 @@ Table: ExpenseCategoryGroups
| Name_zh | varchar(200)? | |
| SortOrder | int NOT NULL DEFAULT 0 | |
| IsActive | bool NOT NULL DEFAULT true | |
| **Form990LineId** | int? | FK → Form990ExpenseLines.IdON DELETE SET NULL。大類層級預設 990 行號;Seed 預設為行 "24"(其他費用)|
**Seed 大類(11 個):**
**Seed 大類(14 個):**
| Id | Name_en | Name_zh |
|----|---------|---------|
@@ -524,6 +547,9 @@ Table: ExpenseCategoryGroups
| 9 | Benevolence | 關懷救助 |
| 10 | Other | 其他 |
| 11 | Personnel | 人事 |
| 12 | Professional Services | 專業服務 |
| 13 | Information Technology | 資訊科技 |
| 14 | Finance & Banking | 財務與銀行 |
### ExpenseSubCategory(支出子項目)
@@ -539,6 +565,9 @@ Table: ExpenseSubCategories
| Name_zh | varchar(200)? | |
| SortOrder | int NOT NULL DEFAULT 0 | |
| IsActive | bool NOT NULL DEFAULT true | |
| **Form990LineId** | int? | FK → Form990ExpenseLines.IdON DELETE SET NULL。子項目層級 990 行號(優先於大類值)|
> **有效 990 行號解析順序:** `SubCategory.Form990LineId ?? Group.Form990LineId ?? "24"`(先取子項目的行號;若為 null 則取大類的行號;仍為 null 則視為行 "24" — Other Expenses)。
**Seed 子項目(完整種子):**
@@ -554,12 +583,12 @@ Table: ExpenseSubCategories
| 3 Food & Beverage | Catering | 出餐費用 |
| 3 Food & Beverage | Food Ingredients | 食材採購 |
| 3 Food & Beverage | Utensils | 器具 |
| 3 Food & Beverage | Consumables | 消耗品 |
| 3 Food & Beverage | Disposable Tableware | 一次性餐具 |
| 4 Training | Course Fees | 課程費用 |
| 4 Training | Books | 書籍 |
| 4 Training | Conference | 研討會 |
| 4 Training | Travel | 差旅 |
| 5 Materials | Printing | 印刷費用 |
| 5 Materials | Curriculum Printing | 教材印刷 |
| 5 Materials | Craft Supplies | 手工材料 |
| 5 Materials | Copyright & Licensing | 版權購買 |
| 6 Facility | Rent | 場地租金 |
@@ -569,22 +598,27 @@ Table: ExpenseSubCategories
| 7 Printing | Bulletins | 週報 |
| 7 Printing | Order of Service | 程序單 |
| 7 Printing | Posters | 海報 |
| 7 Printing | Advertising & Promotion | 廣告與推廣 |
| 8 Missions | Offering Transfer | 奉獻轉帳 |
| 8 Missions | Missionary Support | 宣教士支援 |
| 8 Missions | Foreign Missions Support | 海外宣教支援 |
| 8 Missions | Travel | 差旅 |
| 9 Benevolence | Emergency Aid | 急難救助 |
| 9 Benevolence | Condolence Gifts | 慰問禮品 |
| 9 Benevolence | Visit Expenses | 探訪費用 |
| 10 Other | Miscellaneous | 雜支 |
| 11 Personnel | Salary & Wages | 薪資 |
| 11 Personnel | Officer / Key Employee Compensation | 主任/關鍵員工薪酬 |
| 11 Personnel | Payroll Taxes | 薪資稅費 |
| 11 Personnel | Employee Benefits | 員工福利 |
| 11 Personnel | Retirement / Pension | 退休/養老金 |
| 11 Personnel | Workers Compensation | 勞工保險 |
| 11 Personnel | Honorarium | 酬庸 |
| 11 Personnel | Staff Training | 同工進修 |
| 11 Personnel | Contract Labor | 外包勞務 |
> **備注:** `Facility > 財產保險` 指建築物/場地責任險;員工健保、團體保險等歸 `Personnel > 員工福利`。同工代墊報銷依**實際購買物**選大類,不歸人事。
> **備注:** `Facility > 財產保險` 指建築物/場地責任險;員工健保、團體保險等歸 `Personnel > 員工福利`。同工代墊報銷依**實際購買物**選大類,不歸人事。
> **子項目更名說明:** `Food & Beverage > Consumables`(消耗品)更名為 `Disposable Tableware`(一次性餐具)以消除與大類同名的歧義;`Materials > Printing`(印刷費用)更名為 `Curriculum Printing`(教材印刷)以與 Printing 大類區分。
### Expense(支出記錄)
@@ -614,6 +648,7 @@ Table: Expenses
| ReviewNotes | varchar(500)? | 審核備注 |
| PaidAt | timestamp? | 標記已付款時間 |
| PaidBy | varchar(450)? | FK → AspNetUsers.Id |
| **FunctionalClass** | varchar(20)? | IRS Form 990 功能性費用分類個別覆寫:'Program' \| 'ManagementGeneral' \| 'Fundraising'null = 繼承 Ministry.DefaultFunctionalClass |
| IsDeleted | bool NOT NULL DEFAULT false | |
| DeletedAt | timestamp? | |
| DeletedBy | varchar(450)? | FK → AspNetUsers.Id |
@@ -622,6 +657,8 @@ Table: Expenses
| UpdatedAt | timestamp NOT NULL | |
| UpdatedBy | varchar(450) NOT NULL | FK → AspNetUsers.Id |
> **有效功能性費用分類解析順序:** `Expense.FunctionalClass ?? Ministry.DefaultFunctionalClass ?? 'Program'`(先取費用記錄的個別覆寫值;若為 null 則取所屬事工的預設值;仍為 null 則視為 'Program')。
**Status 工作流程:**
```
@@ -960,16 +997,16 @@ super_admin, pastor, board_member, coworker_chair, ministry_leader, district_lea
### Ministries10 個事工部門)
```
1. Administration / 行政
2. Preaching / 講道
3. Emcee / 司會
4. Worship / 敬拜
5. PPT/Media / PPT/影音
6. Sound / 音控
7. Facility / 場地組
8. Hospitality / 招待
9. Children / 兒牧
10. Catering / 餐飲
1. Administration / 行政 DefaultFunctionalClass = 'ManagementGeneral'
2. Preaching / 講道 DefaultFunctionalClass = 'Program'
3. Emcee / 司會 DefaultFunctionalClass = 'Program'
4. Worship / 敬拜 DefaultFunctionalClass = 'Program'
5. PPT/Media / PPT/影音 DefaultFunctionalClass = 'Program'
6. Sound / 音控 DefaultFunctionalClass = 'Program'
7. Facility / 場地組 DefaultFunctionalClass = 'Program'
8. Hospitality / 招待 DefaultFunctionalClass = 'Program'
9. Children / 兒牧 DefaultFunctionalClass = 'Program'
10. Catering / 餐飲 DefaultFunctionalClass = 'Program'
```
### GivingCategories(奉獻類型)
@@ -981,9 +1018,14 @@ super_admin, pastor, board_member, coworker_chair, ministry_leader, district_lea
5. Mission / 宣教奉獻
```
### ExpenseCategoryGroups11 個大類)
### ExpenseCategoryGroups14 個大類)
```
見 §8 Seed 大類列表
見 §8 Seed 大類列表(含新增 Professional Services、Information Technology、Finance & Banking
```
### Form990 權限模組
```
Form990Report — 唯讀報表權限,授予角色:finance、pastor、board_member
```
### CmsPages(靜態頁面 Slug
@@ -1057,6 +1099,7 @@ public class RolacDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<GivingReceipt> GivingReceipts => Set<GivingReceipt>();
public DbSet<GivingRecurringSchedule> GivingRecurringSchedules => Set<GivingRecurringSchedule>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Expense> Expenses => Set<Expense>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,277 @@
# 子專案 A — 支出 990 化(Functional Expenses / Part IX)設計
**日期:** 2026-06-24
**狀態:** Draft(待 user review
**範圍:** 僅子專案 A。1099 收款人(B)、收入端 Part VIII(C)為獨立 spec,不在此。
---
## 1. 目標與背景
教會依 IRC §6033(a)(3)(A) **免於申報** Form 990,但本系統要做到 **990 查帳就緒(audit-readiness)**:在 IRS 檢查時,能依需求產出等同 **Form 990 Part IX — Statement of Functional Expenses** 的功能性費用表及佐證明細。
Part IX 本質是一個矩陣:
```
Program | Mgmt & General | Fundraising | Total
自然費用行(990 line)
7 Salaries & wages ... ... ... ...
9 Employee benefits ... ... ... ...
10 Payroll taxes ... ... ... ...
16 Occupancy ... ... ... ...
...
24 Other expenses ... ... ... ...
─────────────────────────────────────────────────────────────
Total ... ... ... ...
```
現況:`FinanceDashboardService` 已能按 `Ministry → CategoryGroup → SubCategory` 彙總(Paid+Approved 口徑),但**沒有功能別維度**,自然類別也**未對應 990 行**。本子專案在現有兩條軸之上疊一層「990 對照」,不重寫分類樹。
### 設計原則
- 維持現有兩條軸:`Ministry` = 功能別來源;`ExpenseCategoryGroup → ExpenseSubCategory` = 自然科目。
- 990 對照以**映射欄位 + 參考表**實現(資料驅動,與 RolePermission / Categories 同風格)。
- 單筆支出**單一功能別**(direct-charge),不做跨功能比例分攤。
- 向後相容:新增欄位皆 nullable,既有資料不破。
---
## 2. 資料模型變更
### 2.1 功能別(Part IX 三欄)
功能別值域(字串,沿用 codebase 用 `string``Type`/`Status` 的慣例):
`"Program"` | `"ManagementGeneral"` | `"Fundraising"`
**`Ministry`**(新增欄位)
| 欄位 | 型別 | 說明 |
|------|------|------|
| DefaultFunctionalClass | varchar(20) NOT NULL DEFAULT 'Program' | 該事工支出的預設功能別 |
**`Expense`**(新增欄位)
| 欄位 | 型別 | 說明 |
|------|------|------|
| FunctionalClass | varchar(20)? | 覆寫;null = 繼承 Ministry |
**有效功能別**(報表計算用):
```
EffectiveFunctionalClass = Expense.FunctionalClass
?? Ministry.DefaultFunctionalClass
?? "Program" // 最終保底
```
Ministry 預設 seed:`Administration → ManagementGeneral`,其餘 9 個事工 → `Program`。目前無 Fundraising 事工(教會少見),需要時用單筆覆寫。
### 2.2 990 Part IX 行目錄 + 映射
**新表 `Form990ExpenseLine`**(參考表 / 資料驅動)
| 欄位 | 型別 | 說明 |
|------|------|------|
| Id | int PK | |
| LineCode | varchar(10) NOT NULL UNIQUE | 990 行編號,如 `"7"``"11g"``"16"``"24"` |
| Name_en | varchar(200) NOT NULL | 如 "Other salaries and wages" |
| Name_zh | varchar(200)? | |
| SortOrder | int NOT NULL | 報表列順序 |
| IsActive | bool NOT NULL DEFAULT true | |
繼承 `AuditableEntity`(與其他類別表一致)。
**映射欄位**(兩處,nullable):
- `ExpenseSubCategory.Form990LineId`(int? FK → Form990ExpenseLine.Id)— **主要映射**
- `ExpenseCategoryGroup.Form990LineId`(int? FK)— 大類預設(便利用)
**有效 990 行**:
```
EffectiveLine = SubCategory.Form990LineId
?? Group.Form990LineId
?? <line "24 Other expenses"> // 保底,確保無漏列
```
> 映射**必須能下到子項目層**:Personnel 大類底下 Salary→line 7、Payroll Taxes→line 10、Benefits→line 9 是三個不同 990 行,大類層無法表達。
**seed 的 990 行子集**(教會常用):
| LineCode | Name_en | Name_zh |
|----------|---------|---------|
| 1 | Grants to domestic organizations | 對國內機構之捐贈 |
| 2 | Grants to domestic individuals | 對國內個人之捐贈 |
| 3 | Grants to foreign organizations/individuals | 對國外之捐贈 |
| 5 | Compensation of current officers / key employees | 主要職員/負責人薪酬 |
| 7 | Other salaries and wages | 薪資 |
| 8 | Pension plan accruals and contributions | 退休金提撥 |
| 9 | Other employee benefits | 員工福利 |
| 10 | Payroll taxes | 薪資稅 |
| 11b| Legal fees | 法律服務費 |
| 11c| Accounting fees | 會計與審計費 |
| 11g| Other fees for services (non-employee) | 其他勞務報酬(非員工) |
| 12 | Advertising and promotion | 廣告與推廣 |
| 13 | Office expenses | 辦公費用 |
| 14 | Information technology | 資訊科技 |
| 16 | Occupancy | 場地佔用 |
| 17 | Travel | 差旅 |
| 19 | Conferences, conventions, and meetings | 會議與研習 |
| 20 | Interest | 利息 |
| 22 | Depreciation | 折舊(本子專案不映射,留行供未來資本化使用) |
| 23 | Insurance | 保險 |
| 24 | Other expenses | 其他費用 |
**現有子項目 → 990 行的預設映射 seed**:
| 子項目(大類) | 990 行 |
|---|---|
| Personnel > Salary & Wages | 7 |
| Personnel > Payroll Taxes | 10 |
| Personnel > Employee Benefits | 9 |
| Personnel > Workers Compensation | 9 |
| Personnel > Honorarium | 11g |
| Personnel > Contract Labor | 11g |
| Personnel > Staff Training | 19 |
| Facility > Rent | 16 |
| Facility > Utilities | 16 |
| Facility > Property Insurance | 23 |
| Facility > Decoration | 24 |
| Training > Course Fees | 19 |
| Training > Conference | 19 |
| Training > Books | 24 |
| Training > Travel | 17 |
| Missions > Travel | 17 |
| Missions > Offering Transfer | 1 |
| Missions > Missionary Support | 1 |
| Benevolence > Emergency Aid | 2 |
| Benevolence > Condolence Gifts | 2 |
| Benevolence > Visit Expenses | 2 |
| Consumables > Office Supplies | 13 |
| Consumables > Batteries / Accessories / Cleaning Supplies | 24 |
| Printing > Bulletins / Order of Service | 13 |
| Printing > Posters | 12 |
| Materials > Curriculum Printing(見 §2.3 | 13 |
| Materials > Craft Supplies / Copyright & Licensing | 24 |
| Food & Beverage > 全部子項目 | 24 |
| Equipment > 全部子項目 | 24 |
| Other > Miscellaneous | 24 |
大類層 `Form990LineId` 一律 seed 為 `24`(保底),確保未細映的子項目仍落在 line 24。
### 2.3 類別清理(互斥化)
只用**改名**解決真正的歧義(不需搬移既有支出),把同名收斂成唯一含義:
| 現況 | 問題 | 解法 |
|---|---|---|
| `Food & Beverage > Consumables` | 與大類 `Consumables` 同名 | 改名 → **"Disposable Tableware" / 一次性餐具** |
| `Materials > Printing` | 與大類 `Printing` 同名 | 改名 → **"Curriculum Printing" / 教材印刷** |
| `Training > Travel``Missions > Travel` | 同名但**父類不同** | **不算衝突**,維持原樣;兩者都映射到 990 line 17,報表自動合併 |
改名落地方式:
- **新安裝**:更新 `DbSeeder` 的 seed 字串。
- **既有 DB**:一次性資料 migration,依 `(GroupId, 舊 Name_en)` 定位後更新 `Name_en`/`Name_zh`(seed 採 insert-if-not-exists,不會自動改既有列,故需 migration)。
`SubCategoryId` 搬移,既有 `Expense` 不受影響。
### 2.4 新增類別(補齊 990 行覆蓋)
現有 11 大類對某些 990 行無對應(會讓那些行恆為 0 或被硬塞 line 24)。補上以下類別讓自然樹真正覆蓋常用 990 行。所有新增皆走 `DbSeeder`,並更新 DB_SCHEMA.md。
**加進現有大類**
| 大類 | 新子項目 | 990 行 |
|---|---|---|
| Personnel 人事 | Officer / Key Employee Compensation 主要職員薪酬 | **5** |
| Personnel 人事 | Retirement / Pension 退休金 | **8** |
| Missions 宣教 | Foreign Missions Support 國外宣教支援 | **3** |
| Printing 印刷 | Advertising & Promotion 廣告推廣 | **12** |
> Personnel 既有 `Salary & Wages` 維持 line 7(一般員工);牧師等 officer/key employee 薪酬改記新子項目 → line 5。
> Missions 既有 `Missionary Support` / `Offering Transfer` 視為國內/未分 → line 1;國外走新子項目 → line 3。
**新增大類**
| 新大類 | 子項目 | 990 行 |
|---|---|---|
| **Professional Services 專業服務** | Legal 法律服務 | **11b** |
| | Accounting & Audit 會計與審計 | **11c** |
| | Other Professional 其他專業服務 | 11g |
| **Information Technology 資訊科技** | Software & Subscriptions 軟體與訂閱 | **14** |
| | Website & Hosting 網站與主機 | **14** |
| | Internet & Telecom 網路與電信 | **14** |
| **Finance & Banking 財務與銀行** | Interest 利息支出 | **20** |
| | Bank & Processing Fees 銀行/金流手續費 | 24 |
大類數由 11 → **14**(新增 Professional Services、Information Technology、Finance & Banking)。新大類 `Form990LineId` 預設仍 seed 為 24(保底),實際映射在子項目層。
---
## 3. 報表層
**新服務 `Form990ReportService`**(與 `FinanceDashboardService` 並列,讀取為主)。
```csharp
Task<FunctionalExpenseStatementDto> GetFunctionalExpenseStatementAsync(
DateOnly? from, DateOnly? to);
```
- 支出口徑沿用 `FinanceDashboardService` 既有約定:`Status == "Paid" || "Approved"`,選用 `ExpenseDate` 區間。
- 對每筆支出計算 `EffectiveFunctionalClass``EffectiveLine`,彙總成矩陣。
**`FunctionalExpenseStatementDto`**
```
Rows: [ { LineCode, Name_en, Name_zh,
Program, ManagementGeneral, Fundraising, Total } ] // 依 SortOrder
ColumnTotals: { Program, ManagementGeneral, Fundraising, GrandTotal }
UnmappedExpenseCount: int // 落到保底 line 24 的「未明確映射」筆數,提示待補映射
```
`UnmappedExpenseCount` 讓財務知道哪些還沒細映(治理用),但金額仍計入 line 24,不漏帳。
**匯出:** 沿用既有報表/DevExpress 管道,輸出可交付會計師的表格(PDF/試算表)。
---
## 4. 前端(Angularadmin)
沿用既有 portal 慣例(`UserPortalComponent` 導覽、unified header、Kendo UI、表單版面用 Tailwind utilities、行動裝置友善 `hidden md:block` + `md:hidden` 卡片)。
1. **Part IX 報表頁**:Kendo Grid 矩陣(列=990 行,欄=三功能別+Total),年度/區間篩選,雙語,行動裝置卡片版;`UnmappedExpenseCount` 以提示列顯示;匯出鈕。
2. **支出表單**(`expense-form-dialog`):新增 `FunctionalClass` 下拉(可空=繼承事工);Kendo DropdownList 設 `[valuePrimitive]="true"`
3. **類別維護頁**(`expense-categories-page`):每個大類/子項目可設 `Form990LineId`(990 行下拉)。
4. **事工維護**:`DefaultFunctionalClass` 下拉。
---
## 5. 測試
沿用既有測試模式(`ExpenseServiceTests` 等;受測元件用 inline template,Edge via `CHROME_BIN`,以 `--include` 縮限)。
- `EffectiveFunctionalClass` 解析:覆寫優先、否則繼承事工、再保底 Program。
- `EffectiveLine` 解析:子項目優先、否則大類、再保底 line 24。
- 矩陣彙總:多筆跨功能別/跨行正確加總;欄合計與總計一致。
- 保底行為:未映射子項目進 line 24 且 `UnmappedExpenseCount` 正確。
- 狀態口徑:僅 Paid+Approved 計入;區間篩選正確。
---
## 6. Migration / 落地
EF Core code-first:
1. 新表 `Form990ExpenseLines`
2. 新欄 `Ministries.DefaultFunctionalClass``Expenses.FunctionalClass``ExpenseSubCategories.Form990LineId``ExpenseCategoryGroups.Form990LineId`(後二者建 FK)。
3. 資料 migration:類別改名(§2.3)。
4. `DbSeeder`:seed `Form990ExpenseLine`、子項目→行的預設映射、Ministry 預設功能別、更新改名後的 seed 字串。
DB_SCHEMA.md 同步更新(新表 + 新欄 + §8 備注)。
---
## 7. 不在此範圍(已知缺口)
- **資本化 / 折舊(line 22)**:line 已 seed 但不映射;Equipment 購置暫入 line 24。需要時另開。
- **1099 收款人追蹤**:子專案 B。
- **收入端 Part VIII**:子專案 C。
- **跨功能比例分攤**:本系統維持單筆單一功能別,不支援拆分。
---
## 8. 驗收標準
1. 可在報表頁選定年度,產出 Part IX 矩陣(三功能別 × 990 行),欄合計與總計正確,且金額等於同口徑(Paid+Approved)的支出總額。
2. 變更某事工 `DefaultFunctionalClass` 或某筆 `FunctionalClass` 後,報表對應欄位即時反映。
3. 未細映的子項目金額落在 line 24,且 `UnmappedExpenseCount` 正確顯示。
4. 類別樹中不再有「同父同名」歧義;`Training/Missions > Travel` 維持並正確合併至 line 17。
5. 既有支出資料不因本次變更而遺失或錯置。