Compare commits
25 Commits
cfd344f48c
...
9f91683633
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f91683633 | |||
| 5aaac3246d | |||
| 677cb8f054 | |||
| f79dab163d | |||
| 4438c351e2 | |||
| 1a03a1cbba | |||
| 3f61e9ceaf | |||
| b41297f972 | |||
| a5de2dbbb1 | |||
| 1fa36ae62f | |||
| 1353b5571f | |||
| 4e83f27703 | |||
| d5e1732505 | |||
| ae757bee3d | |||
| 6e04b64466 | |||
| f70a7b5a58 | |||
| b6b110254a | |||
| d3e6b5aed5 | |||
| ac84097254 | |||
| 971bf165cc | |||
| f1faa0d435 | |||
| 9dbb1d38d8 | |||
| e908e35530 | |||
| b51f22cfba | |||
| 764464e785 |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
+2202
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+10
-1
@@ -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; }
|
||||
|
||||
+18
@@ -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>
|
||||
|
||||
+16
-10
@@ -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;
|
||||
}
|
||||
+39
@@ -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&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&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>
|
||||
+46
@@ -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
@@ -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'。Seed:Administration → 'ManagementGeneral',其餘 → 'Program' |
|
||||
|
||||
---
|
||||
|
||||
@@ -495,6 +496,27 @@ Table: GivingRecurringSchedules
|
||||
|
||||
## 8. Expense Tracking(支出)
|
||||
|
||||
### Form990ExpenseLines(IRS 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.Id,ON 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.Id,ON 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
|
||||
|
||||
### Ministries(10 個事工部門)
|
||||
```
|
||||
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 / 宣教奉獻
|
||||
```
|
||||
|
||||
### ExpenseCategoryGroups(11 個大類)
|
||||
### ExpenseCategoryGroups(14 個大類)
|
||||
```
|
||||
見 §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. 前端(Angular,admin)
|
||||
|
||||
沿用既有 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. 既有支出資料不因本次變更而遺失或錯置。
|
||||
Reference in New Issue
Block a user