Compare commits

...

45 Commits

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:24:17 -07:00
Chris Chen cfd344f48c Update dashboard.component.html
ci-cd-vm / ci-cd (push) Successful in 1m45s
2026-06-24 12:34:56 -07:00
Chris Chen 4dc7ff7df7 Update member.model.ts
ci-cd-vm / ci-cd (push) Successful in 1m38s
2026-06-24 12:07:02 -07:00
Chris Chen e9aad74df6 update quick add.
ci-cd-vm / ci-cd (push) Successful in 1m40s
2026-06-24 12:01:55 -07:00
Chris Chen e768f53ccc feat(giving): show Sunday attendance per session and add edit action 2026-06-24 11:40:44 -07:00
Chris Chen b0e2e112fc feat(giving): add sundayAttendanceCount model field and attendance setCounts API
ci-cd-vm / ci-cd (push) Successful in 2m21s
2026-06-24 11:35:34 -07:00
Chris Chen 28eba8a3ea feat(giving): include Sunday attendance total in offering session list 2026-06-24 11:24:31 -07:00
Chris Chen 7eb6a4db78 feat(attendance): add PUT /api/meal-attendance/{date} to overwrite a Sunday's counts 2026-06-24 11:18:27 -07:00
Chris Chen 7dc03f3bc0 docs(attendance): explain SetCountsAsync divergence from ExecuteUpdate path 2026-06-24 11:17:19 -07:00
Chris Chen 8d91bbeb31 feat(attendance): add SetCountsAsync to set all three age groups for a date 2026-06-24 11:14:09 -07:00
Chris Chen 182f8bf74c Merge branch 'feature/member-invitation-links'
ci-cd-vm / ci-cd (push) Successful in 2m31s
Add member invitation links: passwordless first login with forced password
set. Admins generate a single-use, 7-day link (copy or email); the member
opens it to set their own password and is logged straight in. Auto-creates a
passwordless account for members without one; re-issuing revokes prior links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:54:55 -07:00
Chris Chen a88567fea6 Track AddUserInvitations migration files
Force-add the EF migration excluded by the Migrations/ gitignore rule, so
the UserInvitations table migration is versioned alongside the feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:54:45 -07:00
Chris Chen e53cea7a82 Add init link. 2026-06-24 10:53:13 -07:00
Chris Chen e88ea7917f add church profile.
ci-cd-vm / ci-cd (push) Successful in 2m31s
2026-06-24 08:21:31 -07:00
Chris Chen 99585a1c0e Update dashboard.component.ts
ci-cd-vm / ci-cd (push) Successful in 3m0s
2026-06-23 20:38:11 -07:00
Chris Chen d327a5146c Merge branch 'feature/change-password' 2026-06-23 20:36:26 -07:00
Chris Chen 4276ca890b WIP 2026-06-23 20:36:18 -07:00
146 changed files with 12387 additions and 418 deletions
@@ -169,6 +169,48 @@ public class AuthServiceTests
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
}
[Fact]
public async Task Login_LinkedMember_ReturnsMemberInfo()
{
var db = BuildDb();
db.Members.Add(new Member
{
Id = 7,
NickName = "Johnny",
FirstName_en = "John",
LastName_en = "Chen",
LastName_zh = "陳",
CreatedBy = "seed",
UpdatedBy = "seed",
});
await db.SaveChangesAsync();
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.NotNull(response.User.MemberInfo);
Assert.Equal(7, response.User.MemberInfo!.Id);
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
}
[Fact]
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.Null(response.User.MemberInfo);
}
// -----------------------------------------------------------------------
// Refresh tests
// -----------------------------------------------------------------------
@@ -0,0 +1,142 @@
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);
// Activity/shepherding ministries are an attribution axis only; they default to Program
// so adding them never distorts the 990 functional columns.
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
Assert.Equal("Program", specialEvents.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);
}
[Fact]
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
async Task<string> CodeOf(string subEn) =>
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
// Newly mapped subcategories that previously fell through to line 24.
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
Assert.Equal("13", await CodeOf("Rental"));
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
Assert.Equal("13", await CodeOf("Craft Supplies"));
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
Assert.Equal("24", await CodeOf("Gifts"));
// Visitation is a travel/program cost, not a grant to an individual.
Assert.Equal("17", await CodeOf("Visit Expenses"));
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
Assert.Equal("2", await CodeOf("Missionary Support"));
}
[Fact]
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
await db.SaveChangesAsync();
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await db.Entry(visit).ReloadAsync();
await db.Entry(missionary).ReloadAsync();
await db.Entry(transfer).ReloadAsync();
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
}
}
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor,
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
};
[Fact]
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
Assert.Equal("1 Main St", member.Address);
}
[Fact]
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
{
var (svc, db, _) = Build();
db.Expenses.Add(new Expense
{
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
},
});
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
Assert.Equal("Multiple / 多類別", line.CategoryName);
}
[Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{
@@ -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);
}
}
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage;
using ROLAC.API.Tests.TestSupport;
using Xunit;
namespace ROLAC.API.Tests.Services;
@@ -55,6 +58,14 @@ public class ExpenseServiceTests
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs, audit);
}
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
@@ -67,14 +78,20 @@ public class ExpenseServiceTests
private static CreateExpenseRequest Reimb() => new()
{
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
Type = "StaffReimbursement", MinistryId = 1,
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
};
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
{
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
Type = r.Type, MinistryId = r.MinistryId,
Lines = r.Lines.Select(l => new ExpenseLineInput
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
}).ToList(),
Description = r.Description,
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
};
@@ -207,7 +224,7 @@ public class ExpenseServiceTests
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb());
edit.Amount = 99.99m;
edit.Lines[0].Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
@@ -248,6 +265,84 @@ 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,
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
}, isFinance: true);
var dto = await svc.GetByIdAsync(id);
Assert.Equal("ManagementGeneral", dto!.Lines.Single().FunctionalClass);
}
[Fact]
public async Task Create_MultiLine_SetsHeaderTotal_AndRoundTripsLines()
{
var (svc, db, _) = Build("u1");
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
await db.SaveChangesAsync();
var r = new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1, VendorName = "Costco",
Description = "Mixed invoice", ExpenseDate = new DateOnly(2026, 5, 1),
Lines =
{
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 12.50m },
},
};
var id = await svc.CreateAsync(r, isFinance: true);
Assert.Equal(42.50m, (await db.Expenses.FindAsync(id))!.Amount);
var dto = await svc.GetByIdAsync(id);
Assert.Equal(2, dto!.Lines.Count);
Assert.Equal(42.50m, dto.Amount);
Assert.Equal(2, dto.LineCount);
}
[Fact]
public async Task Create_WithNoLines_Throws()
{
var (svc, _, _) = Build("u1");
var r = Reimb(); r.Lines.Clear();
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r, isFinance: false));
}
[Fact]
public async Task Update_ReplacesLines_AndRecomputesTotal()
{
var (svc, db, _) = Build("alice");
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Reimb(), isFinance: false);
var edit = CloneToUpdate(Reimb());
edit.Lines = new()
{
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 10m },
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 5m },
};
await svc.UpdateAsync(id, edit, isFinance: false);
Assert.Equal(15m, (await db.Expenses.FindAsync(id))!.Amount);
Assert.Equal(2, await db.ExpenseLines.CountAsync(l => l.ExpenseId == id));
}
[Fact]
public async Task Receipt_SaveThenOpen_RoundTrips()
{
@@ -258,4 +353,93 @@ public class ExpenseServiceTests
var got = await svc.OpenReceiptAsync(id, isFinance: true);
Assert.NotNull(got);
}
[Fact]
public async Task Reject_WritesAuditEntry_WithReason()
{
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
var audit = new CapturingAuditLogger();
await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake");
var entry = Assert.Single(audit.Entries);
Assert.Equal(AuditActions.ExpenseRejected, entry.Action);
Assert.Equal(AuditCategories.Business, entry.Category);
Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName);
Assert.Equal(id.ToString(), entry.EntityId);
Assert.Contains("Receipt unclear", entry.Summary);
}
[Fact]
public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview()
{
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing");
// Owner fixes the issue and re-submits.
await svc.SubmitAsync(id);
var e = await db.Expenses.FindAsync(id);
Assert.Equal("PendingApproval", e!.Status);
Assert.Null(e.ReviewedBy);
Assert.Null(e.ReviewedAt);
Assert.Null(e.ReviewNotes);
}
[Fact]
public async Task Update_OwnRejected_AsNonFinance_Succeeds()
{
// A rejected reimbursement can be corrected by its owner before re-submitting.
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt");
var edit = CloneToUpdate(Reimb());
edit.Lines[0].Amount = 77.77m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal(77.77m, e!.Amount);
Assert.Equal("Rejected", e.Status);
}
[Fact]
public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds()
{
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake");
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false);
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
}
[Fact]
public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback()
{
var (svc, db, fs) = Build("alice");
// Reviewer linked to a member → shows the member's full name.
db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" });
db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 });
// Reviewer with no member → falls back to email.
db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" });
await db.SaveChangesAsync();
var withMember = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(withMember);
await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember);
Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName);
var noMember = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(noMember);
await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission");
Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName);
}
}
@@ -0,0 +1,112 @@
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, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, 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);
}
[Fact]
public async Task Statement_SplitsOneExpenseAcrossLines()
{
// One invoice with two lines of different categories must land on two different 990 lines.
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(new Expense
{
MinistryId = 2, Type = "VendorPayment", Status = "Paid", Amount = 70m,
Description = "mixed", ExpenseDate = new DateOnly(2026, 5, 10),
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m }, // sub→line 7
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 2, Amount = 20m }, // sub unmapped→group fallback line 24
},
});
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
Assert.Equal(50m, stmt.Rows.Single(r => r.LineCode == "7").Program); // ministry 2 default = Program
Assert.Equal(20m, stmt.Rows.Single(r => r.LineCode == "24").Program);
Assert.Equal(70m, stmt.GrandTotal);
Assert.Equal(1, stmt.UnmappedExpenseCount); // one unmapped line
}
}
@@ -0,0 +1,60 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
// service tests do) so those columns are stamped automatically on SaveChanges.
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(
new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
@@ -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);
}
}
@@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
db.Expenses.Add(new Expense { MinistryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 300m } } });
db.Expenses.Add(new Expense { MinistryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 999m } } });
await db.SaveChangesAsync();
var svc = Build(db);
@@ -1,6 +1,5 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Services.Notifications;
using Xunit;
@@ -8,6 +7,14 @@ namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests
{
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
private sealed class StubSettings : INotificationSettingsService
{
public SmtpOptions GetSmtp() => new();
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
public void Reload() { }
}
// Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler
{
@@ -28,8 +35,7 @@ public class LineMessageChannelTests
private static LineMessageChannel BuildChannel(CapturingHandler handler)
{
var http = new HttpClient(handler);
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
return new LineMessageChannel(http, options);
return new LineMessageChannel(http, new StubSettings());
}
[Fact]
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
Assert.Equal("PP-456", line.PayPalTransactionId);
Assert.Equal("C-789", line.CheckNumber);
}
[Fact]
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var withDate = new DateOnly(2026, 5, 31);
var withoutDate = new DateOnly(2026, 5, 24);
await svc.CreateAsync(BuildRequest(catId, withDate));
await svc.CreateAsync(BuildRequest(catId, withoutDate));
db.MealAttendances.Add(new MealAttendance
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
await db.SaveChangesAsync();
var page = await svc.GetPagedAsync(1, 20, null, null);
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
}
}
@@ -0,0 +1,21 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
public sealed class CapturingAuditLogger : IAuditLogger
{
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
public readonly List<Entry> Entries = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
Entries.Add(new Entry(action, category, entityName, entityId, summary));
}
}
+4
View File
@@ -16,6 +16,7 @@ public static class Modules
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
@@ -23,6 +24,7 @@ public static class Modules
public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs";
public const string Settings = "Settings";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
@@ -36,6 +38,7 @@ public static class Modules
OfferingSessions,
Ministries,
FinanceDashboard,
Form990Report,
MonthlyStatements,
ChurchProfile,
Disbursements,
@@ -43,6 +46,7 @@ public static class Modules
Permissions,
SystemLogs,
AuditLogs,
Settings,
];
public static bool IsValid(string module) => All.Contains(module);
+44 -1
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
using ROLAC.API.Services;
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService;
private readonly IInvitationService _invitations;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env;
public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
IAuthService authService, IInvitationService invitations,
UserManager<AppUser> userManager, IWebHostEnvironment env)
{
_authService = authService;
_invitations = invitations;
_userManager = userManager;
_env = env;
}
@@ -186,6 +190,45 @@ public class AuthController : ControllerBase
return NoContent();
}
// -------------------------------------------------------------------------
// GET /api/auth/invitation/validate?token=...
// -------------------------------------------------------------------------
/// <summary>
/// Checks whether an invitation token can still be used. Anonymous so the public
/// "set your password" page can decide what to show before the member types anything.
/// </summary>
[HttpGet("invitation/validate")]
[AllowAnonymous]
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
=> Ok(await _invitations.ValidateAsync(token));
// -------------------------------------------------------------------------
// POST /api/auth/accept-invitation
// -------------------------------------------------------------------------
/// <summary>
/// Consumes an invitation: sets the account password and, on success, logs the member in
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
/// </summary>
[HttpPost("accept-invitation")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
{
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
if (user is null)
return BadRequest(new { message = error });
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var device = Request.Headers.UserAgent.FirstOrDefault();
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
SetRefreshCookie(raw);
return Ok(response);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
@@ -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));
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for generating and e-mailing first-login invitation links.
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
/// refresh-token cookie and stay anonymous.
/// </summary>
[ApiController]
[Route("api/invitations")]
[Authorize]
public class InvitationsController : ControllerBase
{
private readonly IInvitationService _invitations;
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
{
try { return Ok(await _invitations.CreateAsync(request)); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
[HttpPost("send")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
{
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -2,7 +2,6 @@ using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Notifications;
@@ -22,14 +21,14 @@ public sealed class LineWebhookController : ControllerBase
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly LineOptions _options;
private readonly INotificationSettingsService _settings;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
{
_line = line;
_channel = channel;
_options = options.Value;
_settings = settings;
}
[HttpPost("webhook")]
@@ -40,7 +39,7 @@ public sealed class LineWebhookController : ControllerBase
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.MealAttendance;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
@@ -23,4 +24,10 @@ public class MealAttendanceController : ControllerBase
[Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to));
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
[HttpPut("{date}")]
[Authorize]
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
}
@@ -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(); }
}
}
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
NickName = request.NickName,
FirstName_zh = request.FirstName_zh,
LastName_zh = request.LastName_zh,
Entity = request.Entity,
PhoneCell = request.PhoneCell,
Status = "Visitor",
Country = "USA",
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
{
Id = id, NickName = request.NickName,
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
Entity = request.Entity,
});
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Settings;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
/// </summary>
[ApiController]
[Route("api/settings")]
[Authorize]
public class SettingsController : ControllerBase
{
private readonly ISettingsService _settings;
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly CurrentUserAccessor _currentUser;
public SettingsController(
ISettingsService settings,
IEmailService email,
ILineNotificationService line,
CurrentUserAccessor currentUser)
{
_settings = settings;
_email = email;
_line = line;
_currentUser = currentUser;
}
// ── Site settings ────────────────────────────────────────────────────────
[HttpGet("site")]
[HasPermission(Modules.Settings, PermissionActions.Read)]
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
[HttpPut("site")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
{
await _settings.UpdateSiteAsync(request);
return NoContent();
}
// ── Notification settings ──────────────────────────────────────────────────
[HttpGet("notification")]
[HasPermission(Modules.Settings, PermissionActions.Read)]
public async Task<IActionResult> GetNotification()
{
var dto = await _settings.GetNotificationAsync();
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
return Ok(dto);
}
[HttpPut("notification")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
{
await _settings.UpdateNotificationAsync(request);
return NoContent();
}
[HttpPost("notification/test-email")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
{
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
if (string.IsNullOrWhiteSpace(to))
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
var result = await _email.SendAsync(new EmailMessage(
MemberIds: Array.Empty<int>(),
Addresses: new[] { to },
Subject: "ROLAC test email / 測試郵件",
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
SentByUserId: _currentUser.UserIdOrSystem), ct);
return Ok(result);
}
[HttpPost("notification/test-line")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
{
if (request.MemberId is null && request.GroupId is null)
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
var result = await _line.SendLineAsync(
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
sentByUserId: _currentUser.UserIdOrSystem,
ct);
return Ok(result);
}
}
+18
View File
@@ -25,4 +25,22 @@ public class UserInfo
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
/// <summary>
/// The church member linked to this login account, or null for admin-only
/// accounts (no MemberId) and accounts whose member record was deleted.
/// Lets the SPA greet the user by their real name.
/// </summary>
public MemberInfo? MemberInfo { get; set; }
}
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
public class MemberInfo
{
public int Id { get; set; }
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
}
@@ -5,6 +5,10 @@ public class ChurchProfileDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? NameZh { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
@@ -18,6 +22,10 @@ public class ChurchProfileDto
public class UpdateChurchProfileRequest
{
[Required, MaxLength(200)] public string Name { get; set; } = "";
[MaxLength(200)] public string? NameZh { get; set; }
[MaxLength(50)] public string? Phone { get; set; }
[MaxLength(200), EmailAddress] public string? Email { get; set; }
[MaxLength(300)] public string? Website { get; set; }
[MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; }
@@ -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
{
+30 -10
View File
@@ -1,44 +1,64 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
public class ExpenseLineItemDto
{
public int Id { get; set; }
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public string? FunctionalClass { get; set; }
public decimal Amount { get; set; }
public string? Description { get; set; }
}
public class ExpenseListItemDto
{
public int Id { get; set; }
public string Type { get; set; } = "";
public string Status { get; set; } = "";
public decimal Amount { get; set; }
public decimal Amount { get; set; } // header total = sum of line amounts
public string Description { get; set; } = "";
public int MinistryId { get; set; }
public string MinistryName { get; set; } = "";
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public int LineCount { get; set; }
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
public string? VendorName { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; }
public string? CheckNumber { get; set; }
// Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date".
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
public DateTimeOffset? ReviewedAt { get; set; }
public string? ReviewNotes { get; set; } // reject reason (or approval note)
}
public class ExpenseDto : ExpenseListItemDto
{
public string? Notes { get; set; }
public string? ReviewNotes { get; set; }
public string? SubmittedBy { get; set; }
public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public DateTimeOffset? PaidAt { get; set; }
public List<ExpenseLineItemDto> Lines { get; set; } = new();
}
public class ExpenseLineInput
{
[Required] public int CategoryGroupId { get; set; }
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
[MaxLength(500)] public string? Description { get; set; }
}
public class CreateExpenseRequest
{
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
[Required] public int MinistryId { get; set; }
[Required] public int CategoryGroupId { get; set; }
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
[Required, MaxLength(500)] public string Description { get; set; } = "";
[MaxLength(200)] public string? VendorName { get; set; }
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
@@ -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; }
}
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
public string? Entity { get; set; } // company / business name (公司行號), if any
}
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
public decimal Difference { get; set; }
public int LineCount { get; set; }
public bool HasProof { get; set; }
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
}
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
[MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(30)] public string? PhoneCell { get; set; }
}
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Invitations;
/// <summary>
/// Admin request to generate a first-login invitation link for a member. If the member has no
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
/// </summary>
public class CreateInvitationRequest
{
[Required]
public int MemberId { get; set; }
/// <summary>Optional override for the login email when the member has none on file.</summary>
public string? Email { get; set; }
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
public List<string>? Roles { get; set; }
}
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
public class CreateInvitationResult
{
public string Token { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
}
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
public class SendInvitationRequest
{
[Required]
public int MemberId { get; set; }
[Required]
public string Link { get; set; } = null!;
}
/// <summary>Public result describing whether an invitation token can still be used.</summary>
public class ValidateInvitationResult
{
public bool Valid { get; set; }
public bool Expired { get; set; }
public string? MemberName { get; set; }
public string? Email { get; set; }
}
/// <summary>Public request to consume an invitation and set the account password.</summary>
public class AcceptInvitationRequest
{
[Required]
public string Token { get; set; } = null!;
[Required]
[StringLength(128, MinimumLength = 8)]
public string NewPassword { get; set; } = null!;
}
@@ -0,0 +1,9 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
public class SetAttendanceRequest
{
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
@@ -8,6 +8,7 @@ public class CreateMemberRequest
[MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(10)] public string? Gender { get; set; }
public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; }
@@ -8,6 +8,7 @@ public class MemberListItemDto
public string? NickName { get; set; }
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
public string? Entity { get; set; }
public string Status { get; set; } = "";
public string? Email { get; set; }
public string? PhoneCell { 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; }
}
@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Settings;
// ── Site settings ──────────────────────────────────────────────────────────
public class SiteSettingDto
{
public string SiteTitle { get; set; } = "";
public string? SiteTitleZh { get; set; }
public string DefaultLanguage { get; set; } = "en";
public string TimeZone { get; set; } = "";
public string DateFormat { get; set; } = "";
public string Currency { get; set; } = "";
}
public class UpdateSiteSettingRequest
{
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
[MaxLength(200)] public string? SiteTitleZh { get; set; }
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
[Required, MaxLength(10)] public string Currency { get; set; } = "";
}
// ── Notification settings ──────────────────────────────────────────────────
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
// shows a write-only field where a blank value on update means "keep the stored secret".
public class NotificationSettingDto
{
public bool EnableEmail { get; set; }
public string SmtpHost { get; set; } = "";
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public string SmtpUser { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
public bool HasSmtpPassword { get; set; }
public bool EnableLine { get; set; }
public bool HasLineChannelAccessToken { get; set; }
public bool HasLineChannelSecret { get; set; }
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
public string WebhookUrl { get; set; } = "";
}
public class UpdateNotificationSettingRequest
{
public bool EnableEmail { get; set; }
[MaxLength(200)] public string SmtpHost { get; set; } = "";
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
public bool SmtpUseSsl { get; set; } = true;
[MaxLength(200)] public string SmtpUser { get; set; } = "";
[MaxLength(200)] public string? FromAddress { get; set; }
[MaxLength(200)] public string? FromName { get; set; }
/// <summary>Blank = keep the stored password unchanged.</summary>
[MaxLength(500)] public string? SmtpPassword { get; set; }
public bool EnableLine { get; set; }
/// <summary>Blank = keep the stored token unchanged.</summary>
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
/// <summary>Blank = keep the stored secret unchanged.</summary>
[MaxLength(200)] public string? LineChannelSecret { get; set; }
}
// ── Test-send requests ─────────────────────────────────────────────────────
public class TestEmailRequest
{
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
}
public class TestLineRequest
{
public int? MemberId { get; set; }
public int? GroupId { get; set; }
}
+91 -2
View File
@@ -11,6 +11,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
public DbSet<Member> Members => Set<Member>();
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
@@ -19,7 +20,9 @@ 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<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>();
@@ -32,6 +35,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
@@ -53,6 +59,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Ignore(e => e.IsActive);
});
// ── UserInvitation (single-use, expiring first-login links) ─────────
builder.Entity<UserInvitation>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsUsed);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
builder.Entity<AppUser>(entity =>
{
@@ -97,6 +120,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.NickName).HasMaxLength(100);
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
entity.Property(e => e.LastName_zh).HasMaxLength(100);
entity.Property(e => e.Entity).HasMaxLength(200);
entity.Property(e => e.Gender).HasMaxLength(10);
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
entity.Property(e => e.Email).HasMaxLength(200);
@@ -178,6 +202,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 ─────────────────────────────────────────────
@@ -187,6 +223,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 ───────────────────────────────────────────────
@@ -198,6 +236,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 ──────────────────────────────────────────────────────────
@@ -226,12 +266,30 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasOne(e => e.Ministry).WithMany()
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
builder.Entity<ExpenseLine>(entity =>
{
// Mirror the parent Expense's soft-delete filter (required relationship).
entity.HasQueryFilter(l => !l.Expense!.IsDeleted);
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.ExpenseId);
entity.HasOne(e => e.Expense).WithMany(x => x.Lines)
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CategoryGroup).WithMany()
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.SubCategory).WithMany()
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ChurchProfile (singleton settings) ───────────────────────────────
@@ -245,12 +303,43 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.NameZh).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(50);
entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.Website).HasMaxLength(300);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
// Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion();
});
// ── SiteSetting (singleton presentation/locale settings) ─────────────
builder.Entity<SiteSetting>(entity =>
{
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
builder.Entity<NotificationSetting>(entity =>
{
entity.Property(e => e.SmtpHost).HasMaxLength(200);
entity.Property(e => e.SmtpUser).HasMaxLength(200);
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
entity.Property(e => e.FromAddress).HasMaxLength(200);
entity.Property(e => e.FromName).HasMaxLength(200);
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── Check (disbursement) ─────────────────────────────────────────────
builder.Entity<Check>(entity =>
{
+239 -8
View File
@@ -28,6 +28,8 @@ public static class DbSeeder
("Hospitality", "招待", 8),
("Children", "兒牧", 9),
("Catering", "餐飲", 10),
("Cell Groups", "小組牧養", 11),
("Special Events", "特別活動", 12),
];
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
@@ -35,15 +37,115 @@ 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","版權購買")]),
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]),
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]),
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]),
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
("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","外包勞務")]),
("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
("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"),
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
("Facility", "Repairs & Maintenance", "16"),
("Training", "Course Fees", "19"),
("Training", "Conference", "19"),
("Training", "Books", "24"),
("Training", "Travel", "17"),
("Missions", "Travel", "17"),
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
("Missions", "Offering Transfer", "2"),
("Missions", "Missionary Support", "2"),
("Missions", "Foreign Missions Support", "3"),
("Benevolence", "Emergency Aid", "2"),
("Benevolence", "Condolence Gifts", "2"),
// Visitation is the church's own travel/program cost, not a grant to an individual.
("Benevolence", "Visit Expenses", "17"),
("Consumables", "Office Supplies", "13"),
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
("Consumables", "Batteries", "13"),
("Consumables", "Accessories", "13"),
("Consumables", "Cleaning Supplies", "13"),
// IRS line 13 covers equipment rental and maintenance.
("Equipment", "Rental", "13"),
("Equipment", "Maintenance & Repair", "13"),
("Printing", "Bulletins", "13"),
("Printing", "Order of Service", "13"),
("Printing", "Posters", "12"),
("Printing", "Advertising & Promotion", "12"),
("Materials", "Curriculum Printing", "13"),
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
("Materials", "Craft Supplies", "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"),
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
("Finance & Banking", "Bank & Processing Fees", "13"),
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
("Other", "Gifts", "24"),
];
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
// value — this block does. Idempotent: each row fires only while the subcategory still holds
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
[
("Benevolence", "Visit Expenses", "2", "17"),
("Missions", "Missionary Support", "1", "2"),
("Missions", "Offering Transfer", "1", "2"),
];
private static readonly (string Name, string Description)[] Roles =
@@ -87,6 +189,7 @@ 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.
@@ -94,6 +197,24 @@ public static class DbSeeder
("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 +284,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 +335,46 @@ 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;
}
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is null) continue;
if (linesByCode.TryGetValue(oldCode, out var oldId)
&& linesByCode.TryGetValue(newCode, out var newId)
&& sub.Form990LineId == oldId)
sub.Form990LineId = newId;
}
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db)
{
// Singleton row used by the disbursement module (issuer info + check counter).
@@ -208,6 +391,50 @@ public static class DbSeeder
}
}
public static async Task SeedSiteSettingAsync(AppDbContext db)
{
// Singleton row holding site-wide presentation/locale settings.
if (!await db.SiteSettings.AnyAsync())
{
db.SiteSettings.Add(new SiteSetting
{
SiteTitle = "River Of Life Christian Church",
SiteTitleZh = "生命河靈糧堂",
DefaultLanguage = "en",
TimeZone = "America/Los_Angeles",
DateFormat = "yyyy-MM-dd",
Currency = "USD",
});
await db.SaveChangesAsync();
}
}
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
{
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
if (!await db.NotificationSettings.AnyAsync())
{
var smtp = config.GetSection("Smtp");
var line = config.GetSection("Line");
db.NotificationSettings.Add(new NotificationSetting
{
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
SmtpHost = smtp["Host"] ?? "",
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
SmtpUser = smtp["User"] ?? "",
SmtpPassword = smtp["Password"] ?? "",
FromAddress = smtp["FromAddress"] ?? "",
FromName = smtp["FromName"] ?? "",
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
LineChannelSecret = line["ChannelSecret"] ?? "",
});
await db.SaveChangesAsync();
}
}
/// <summary>
/// Seeds roles and (in Development) the default admin account.
/// Called once on application startup after migrations have been applied.
@@ -217,6 +444,7 @@ public static class DbSeeder
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
var userManager = services.GetRequiredService<UserManager<AppUser>>();
var env = services.GetRequiredService<IWebHostEnvironment>();
var config = services.GetRequiredService<IConfiguration>();
await SeedRolesAsync(roleManager);
@@ -225,7 +453,10 @@ 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);
if (env.IsDevelopment())
await SeedAdminUserAsync(userManager);
+14 -2
View File
@@ -157,6 +157,8 @@ rows AS (
mi."Id" AS ministry_id,
gp."Id" AS group_id,
sc."Id" AS sub_id,
-- pre-allocate the expense id so the matching ExpenseLine can reference it
nextval(pg_get_serial_sequence('"Expenses"','Id')) AS new_id,
sp.is_reimb,
sp.vendor,
sp.descr,
@@ -172,13 +174,14 @@ rows AS (
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
)
, ins_exp AS (
INSERT INTO "Expenses"
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount",
("Id","MinistryId","Type","Status","Amount",
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
SELECT
r.ministry_id, r.group_id, r.sub_id,
r.new_id, r.ministry_id,
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
r.status,
r.amount,
@@ -196,6 +199,15 @@ SELECT
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
FROM rows r
)
-- one line per mock expense (single-category), mirroring the migrated production shape
INSERT INTO "ExpenseLines"
("ExpenseId","CategoryGroupId","SubCategoryId","FunctionalClass","Amount","Description",
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
SELECT
r.new_id, r.group_id, r.sub_id, NULL, r.amount, NULL,
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata'
FROM rows r;
COMMIT;
+4
View File
@@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? NameZh { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
+2 -5
View File
@@ -5,11 +5,9 @@ public class Expense : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public int MinistryId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
public string Status { get; set; } = "Draft"; // see state machine
public decimal Amount { get; set; }
public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
public string Description { get; set; } = null!;
public string? VendorName { get; set; }
public int? MemberId { get; set; }
@@ -26,7 +24,6 @@ public class Expense : SoftDeleteEntity, IAuditable
public string? PaidBy { get; set; }
public Ministry? Ministry { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { get; set; }
public Member? Member { get; set; }
public List<ExpenseLine> Lines { get; set; } = new();
}
@@ -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; } = [];
}
+23
View File
@@ -0,0 +1,23 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// One category line of an <see cref="Expense"/>. A single invoice/payment can span
/// multiple expense categories, so the category / amount / functional-class axis lives
/// here per line; the Expense header keeps payment-level info and a denormalized total.
/// Lines are wholly owned by the header (replaced as a set on update, like CheckLine).
/// </summary>
public class ExpenseLine : AuditableEntity
{
public int Id { get; set; }
public int ExpenseId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; }
public string? Description { get; set; } // optional per-line note (header description is authoritative for check printing)
public Expense? Expense { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { 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 ExpenseLine.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;
}
+6 -2
View File
@@ -48,16 +48,20 @@ public static class AuditActions
public const string PasswordChanged = "PasswordChanged";
public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged";
public const string InvitationCreated = "InvitationCreated";
public const string InvitationAccepted = "InvitationAccepted";
public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved";
public const string ExpenseRejected = "ExpenseRejected";
public const string StatementFinalized = "StatementFinalized";
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized,
PasswordChanged, UserDeactivated, PermissionChanged,
InvitationCreated, InvitationAccepted, CheckIssued,
CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
];
}
+1
View File
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
public string? NickName { get; set; }
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
public string? Entity { get; set; } // company / business name (公司行號) — used for company-check offerings
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; }
+1
View File
@@ -11,4 +11,5 @@ public class Ministry : IAuditable
public string? Description_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public string DefaultFunctionalClass { get; set; } = "Program";
}
@@ -0,0 +1,32 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// Singleton (Id == 1) holding the editable SMTP + Line notification settings. This row — not the
/// "Smtp"/"Line" appsettings sections — is the runtime source of truth; those sections only seed
/// this row once on first startup. Read at send time via <c>INotificationSettingsService</c> so
/// edits apply without restarting the API.
///
/// Secrets (<see cref="SmtpPassword"/>, <see cref="LineChannelAccessToken"/>,
/// <see cref="LineChannelSecret"/>) are stored plaintext and protected by RBAC (the <c>Settings</c>
/// module / super_admin) per the project decision for this small single-VM internal app.
/// </summary>
public class NotificationSetting : AuditableEntity, IAuditable
{
public int Id { get; set; }
// ── Email (SMTP) ─────────────────────────────────────────────────────────
public bool EnableEmail { get; set; }
public string SmtpHost { get; set; } = "";
public int SmtpPort { get; set; } = 587;
public bool SmtpUseSsl { get; set; } = true; // true → STARTTLS
public string SmtpUser { get; set; } = "";
public string SmtpPassword { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
// ── Line ─────────────────────────────────────────────────────────────────
public bool EnableLine { get; set; }
public string LineChannelAccessToken { get; set; } = "";
public string LineChannelSecret { get; set; } = "";
}
+18
View File
@@ -0,0 +1,18 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the
/// Church Profile → Site Settings tab (gated by the <c>Settings</c> permission module).
/// Seeded with sensible defaults on startup.
/// </summary>
public class SiteSetting : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string SiteTitle { get; set; } = "";
public string? SiteTitleZh { get; set; }
public string DefaultLanguage { get; set; } = "en"; // "en" | "zh"
public string TimeZone { get; set; } = "America/Los_Angeles";
public string DateFormat { get; set; } = "yyyy-MM-dd";
public string Currency { get; set; } = "USD";
}
+35
View File
@@ -0,0 +1,35 @@
namespace ROLAC.API.Entities;
/// <summary>
/// A single-use, expiring invitation that lets a member set their own password and log in for
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
/// </summary>
public class UserInvitation
{
public int Id { get; set; }
public string UserId { get; set; } = null!;
public AppUser User { get; set; } = null!;
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
public string TokenHash { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>Id of the admin who generated the link.</summary>
public string CreatedBy { get; set; } = null!;
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
public DateTime? UsedAt { get; set; }
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
public DateTime? RevokedAt { get; set; }
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
public bool IsUsed => UsedAt.HasValue;
public bool IsRevoked => RevokedAt.HasValue;
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddUserInvitations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserInvitations",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserInvitations", x => x.Id);
table.ForeignKey(
name: "FK_UserInvitations_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UserInvitations_TokenHash",
table: "UserInvitations",
column: "TokenHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_UserInvitations_UserId",
table: "UserInvitations",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserInvitations");
}
}
}
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");
}
}
}
@@ -463,14 +463,26 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NameZh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("NextCheckNumber")
.HasColumnType("integer");
b.Property<string>("Phone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("State")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Website")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("ZipCode")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
@@ -509,9 +525,6 @@ namespace ROLAC.API.Migrations
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<string>("CheckNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -580,9 +593,6 @@ namespace ROLAC.API.Migrations
.HasColumnType("character varying(30)")
.HasDefaultValue("Draft");
b.Property<int>("SubCategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("timestamp with time zone");
@@ -609,8 +619,6 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("CategoryGroupId");
b.HasIndex("ExpenseDate");
b.HasIndex("MemberId");
@@ -620,8 +628,6 @@ namespace ROLAC.API.Migrations
b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false");
b.HasIndex("SubCategoryId");
b.ToTable("Expenses");
});
@@ -641,6 +647,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
@@ -666,9 +675,66 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("Form990LineId");
b.ToTable("ExpenseCategoryGroups");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("ExpenseId")
.HasColumnType("integer");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("SubCategoryId")
.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("CategoryGroupId");
b.HasIndex("ExpenseId");
b.HasIndex("SubCategoryId");
b.ToTable("ExpenseLines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{
b.Property<int>("Id")
@@ -685,6 +751,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<int>("GroupId")
.HasColumnType("integer");
@@ -713,6 +782,8 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("Form990LineId");
b.HasIndex("GroupId");
b.ToTable("ExpenseSubCategories");
@@ -756,6 +827,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")
@@ -1124,6 +1247,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Entity")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("FamilyUnitId")
.HasColumnType("integer");
@@ -1225,6 +1352,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");
@@ -1323,6 +1457,82 @@ namespace ROLAC.API.Migrations
b.ToTable("MonthlyStatements");
});
modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", 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>("EnableEmail")
.HasColumnType("boolean");
b.Property<bool>("EnableLine")
.HasColumnType("boolean");
b.Property<string>("FromAddress")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("FromName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("LineChannelAccessToken")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("LineChannelSecret")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SmtpHost")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SmtpPassword")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("SmtpPort")
.HasColumnType("integer");
b.Property<bool>("SmtpUseSsl")
.HasColumnType("boolean");
b.Property<string>("SmtpUser")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.ToTable("NotificationSettings");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
{
b.Property<int>("Id")
@@ -1653,6 +1863,109 @@ namespace ROLAC.API.Migrations
b.ToTable("RolePermissions");
});
modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", 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<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("DateFormat")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("DefaultLanguage")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("SiteTitle")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SiteTitleZh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TimeZone")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.ToTable("SiteSettings");
});
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime?>("UsedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("UserInvitations");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
@@ -1735,12 +2048,6 @@ namespace ROLAC.API.Migrations
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
.WithMany()
.HasForeignKey("CategoryGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
@@ -1752,6 +2059,35 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Member");
b.Navigation("Ministry");
});
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.ExpenseLine", b =>
{
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
.WithMany()
.HasForeignKey("CategoryGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Expense", "Expense")
.WithMany("Lines")
.HasForeignKey("ExpenseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
.WithMany()
.HasForeignKey("SubCategoryId")
@@ -1760,21 +2096,26 @@ namespace ROLAC.API.Migrations
b.Navigation("CategoryGroup");
b.Navigation("Member");
b.Navigation("Ministry");
b.Navigation("Expense");
b.Navigation("SubCategory");
});
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");
});
@@ -1874,6 +2215,17 @@ namespace ROLAC.API.Migrations
b.Navigation("Role");
});
modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{
b.Navigation("RefreshTokens");
@@ -1884,6 +2236,11 @@ namespace ROLAC.API.Migrations
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.Navigation("SubCategories");
+7
View File
@@ -144,6 +144,7 @@ builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IMemberService, MemberService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddScoped<IInvitationService, InvitationService>();
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
builder.Services.AddScoped<IGivingService, GivingService>();
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
@@ -154,15 +155,21 @@ 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>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
// IOptions binding stays only as the one-time seed/fallback; the runtime source of truth is the
// DB-backed NotificationSetting row, read (and hot-reloaded) via INotificationSettingsService.
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
builder.Services.AddSingleton<ROLAC.API.Services.Notifications.INotificationSettingsService,
ROLAC.API.Services.Notifications.NotificationSettingsService>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
+40 -6
View File
@@ -60,6 +60,22 @@ public class AuthService : IAuthService
throw new UnauthorizedAccessException("Account is inactive.");
}
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return await IssueSessionAsync(user, ipAddress, deviceInfo);
}
// -------------------------------------------------------------------------
// Issue session (shared by login and passwordless flows like invitations)
// -------------------------------------------------------------------------
public async Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
AppUser user, string? ipAddress = null, string? deviceInfo = null)
{
var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles);
var rawRefresh = _tokenService.GenerateRefreshToken();
@@ -79,12 +95,6 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
}
@@ -225,5 +235,29 @@ public class AuthService : IAuthService
Roles = roles,
LanguagePreference = user.LanguagePreference,
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
MemberInfo = await BuildMemberInfoAsync(user),
};
/// <summary>
/// Loads the linked member's display fields, or null when the account has no
/// MemberId or its member record was soft-deleted (excluded by query filter).
/// </summary>
private async Task<MemberInfo?> BuildMemberInfoAsync(AppUser user)
{
if (user.MemberId is not int memberId)
return null;
return await _db.Members
.Where(member => member.Id == memberId)
.Select(member => new MemberInfo
{
Id = member.Id,
NickName = member.NickName,
FirstName_en = member.FirstName_en,
LastName_en = member.LastName_en,
FirstName_zh = member.FirstName_zh,
LastName_zh = member.LastName_zh,
})
.FirstOrDefaultAsync();
}
}
@@ -15,7 +15,8 @@ public class ChurchProfileService : IChurchProfileService
var p = await GetOrCreateAsync();
return new ChurchProfileDto
{
Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State,
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
};
@@ -24,7 +25,8 @@ public class ChurchProfileService : IChurchProfileService
public async Task UpdateAsync(UpdateChurchProfileRequest r)
{
var p = await GetOrCreateAsync();
p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
await _db.SaveChangesAsync();
+14 -1
View File
@@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);
// Category label per expense: the single line's category, or "Multiple" when it spans several.
var expenseIds = rows.Select(r => r.Id).ToList();
var lineGroups = await _db.ExpenseLines.AsNoTracking()
.Where(l => expenseIds.Contains(l.ExpenseId))
.OrderBy(l => l.Id)
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
.ToListAsync();
var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary(
g => g.Key,
g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1
? "Multiple / 多類別"
: grpNames.GetValueOrDefault(g.First().CategoryGroupId, ""));
var groups = new Dictionary<string, PayeeGroupDto>();
foreach (var e in rows)
{
@@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
Description = e.Description, Amount = e.Amount,
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
});
g.TotalAmount += e.Amount;
}
@@ -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();
}
+117 -21
View File
@@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService
{
var query = _db.Expenses.AsNoTracking().AsQueryable();
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value);
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
// Category filters now match against any line of the expense.
if (categoryGroupId.HasValue) query = query.Where(e => e.Lines.Any(l => l.CategoryGroupId == categoryGroupId.Value));
if (subCategoryId.HasValue) query = query.Where(e => e.Lines.Any(l => l.SubCategoryId == subCategoryId.Value));
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
// request the Paid+Approved set in one call.
if (!string.IsNullOrWhiteSpace(statuses))
@@ -81,57 +82,139 @@ public class ExpenseService : IExpenseService
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}");
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
var items = rows.Select(e => new ExpenseListItemDto
// Line count + first line's category, per expense on this page.
var expenseIds = rows.Select(r => r.Id).ToList();
var lineRows = await _db.ExpenseLines.AsNoTracking()
.Where(l => expenseIds.Contains(l.ExpenseId))
.OrderBy(l => l.Id)
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
.ToListAsync();
var linesByExpense = lineRows.GroupBy(l => l.ExpenseId)
.ToDictionary(g => g.Key, g => g.ToList());
var items = rows.Select(e =>
{
linesByExpense.TryGetValue(e.Id, out var ls);
var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
return new ExpenseListItemDto
{
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""),
LineCount = ls?.Count ?? 0,
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
VendorName = e.VendorName, MemberId = e.MemberId,
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber,
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
ReviewedAt = e.ReviewedAt,
ReviewNotes = e.ReviewNotes,
};
}).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
// Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
// the linked Member's full name when present, otherwise the account email.
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
{
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
if (ids.Count == 0) return new Dictionary<string, string>();
var users = await _db.Users.AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.Email, u.MemberId })
.ToListAsync();
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
var memberNames = await _db.Members.AsNoTracking()
.Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
return users.ToDictionary(
u => u.Id,
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
? name
: (u.Email ?? u.Id));
}
public async Task<ExpenseDto?> GetByIdAsync(int id)
{
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return null;
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? "";
var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? "";
string? memName = e.MemberId != null
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
: null;
var reviewerName = e.ReviewedBy != null
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
: null;
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en);
var lineDtos = lines.Select(l => new ExpenseLineItemDto
{
Id = l.Id, CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
return new ExpenseDto
{
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minName,
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName,
SubCategoryId = e.SubCategoryId, SubCategoryName = subName,
LineCount = lineDtos.Count,
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
Lines = lineDtos,
};
}
// Lines are the source of truth: ≥1 line, each with a category/subcategory and a positive amount.
private static void ValidateLines(List<ExpenseLineInput> lines)
{
if (lines is null || lines.Count == 0)
throw new InvalidOperationException("An expense must have at least one line.");
foreach (var l in lines)
{
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
throw new InvalidOperationException("Each expense line needs a category group and subcategory.");
if (l.Amount <= 0)
throw new InvalidOperationException("Each expense line amount must be greater than zero.");
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
}
}
private static List<ExpenseLine> BuildLines(List<ExpenseLineInput> inputs) =>
inputs.Select(l => new ExpenseLine
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
{
ValidateLines(r.Lines);
var e = new Expense
{
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId,
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName,
MinistryId = r.MinistryId,
Type = r.Type, Amount = r.Lines.Sum(l => l.Amount), Description = r.Description, VendorName = r.VendorName,
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
Lines = BuildLines(r.Lines),
};
if (r.Type == "VendorPayment")
@@ -171,16 +254,21 @@ public class ExpenseService : IExpenseService
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
{
ValidateLines(r.Lines);
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
// Replace the line set wholesale (lines are owned by the header), recompute the total.
_db.ExpenseLines.RemoveRange(e.Lines);
e.Lines = BuildLines(r.Lines);
e.Amount = r.Lines.Sum(l => l.Amount);
await _db.SaveChangesAsync();
}
@@ -203,8 +291,11 @@ public class ExpenseService : IExpenseService
{
var e = await RequireAsync(id);
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
// Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
// Clear the prior review so the expense returns to a clean pending state.
e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
await _db.SaveChangesAsync();
}
@@ -227,6 +318,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
}
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
@@ -245,8 +341,8 @@ public class ExpenseService : IExpenseService
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
{
var e = await RequireAsync(id);
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
var safe = Path.GetFileName(fileName).Replace(' ', '_');
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
@@ -54,16 +54,23 @@ public class FinanceDashboardService : IFinanceDashboardService
{
var q = PaidApproved(from, to);
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value);
// Group by the deepest level whose parent id is supplied.
// Lines belonging to the scoped (Paid+Approved, optionally ministry-filtered) expenses.
var scopedLines = from l in _db.ExpenseLines
join e in q on l.ExpenseId equals e.Id
select l;
// Group by the deepest level whose parent id is supplied. Category levels aggregate
// over LINES (line amounts); the ministry level uses the header total to avoid
// double-counting a multi-line expense across its lines.
List<(int Id, decimal Amount)> grouped;
if (categoryGroupId.HasValue)
grouped = (await q.GroupBy(e => e.SubCategoryId)
grouped = (await scopedLines.Where(l => l.CategoryGroupId == categoryGroupId.Value)
.GroupBy(l => l.SubCategoryId)
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
.Select(x => (x.Id, x.Amount)).ToList();
else if (ministryId.HasValue)
grouped = (await q.GroupBy(e => e.CategoryGroupId)
grouped = (await scopedLines.GroupBy(l => l.CategoryGroupId)
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
.Select(x => (x.Id, x.Amount)).ToList();
else
@@ -0,0 +1,92 @@
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.
/// Each expense line is categorized independently, so one invoice can span multiple lines.
/// </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 l in _db.ExpenseLines on e.Id equals l.ExpenseId
join m in _db.Ministries on e.MinistryId equals m.Id
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new
{
l.Amount,
l.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;
}
}
+10
View File
@@ -25,6 +25,16 @@ public interface IAuthService
string rawRefreshToken,
string? ipAddress = null);
/// <summary>
/// Issues a fresh access token + refresh token for an already-verified user (no password
/// check). Stores the refresh token and returns the raw value for the caller to put in the
/// HttpOnly cookie. Used by passwordless flows such as accepting an invitation link.
/// </summary>
Task<(LoginResponse Response, string RawRefreshToken)> IssueSessionAsync(
AppUser user,
string? ipAddress = null,
string? deviceInfo = null);
/// <summary>
/// Revokes the refresh token identified by its raw value.
/// Silently succeeds if the token is not found.
@@ -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();
}
@@ -0,0 +1,28 @@
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public interface IInvitationService
{
/// <summary>
/// Generates a single-use, 7-day invitation link for a member. Auto-creates the member's
/// login account (no password) when none exists, and revokes any prior unused invitation for
/// that account. Returns the raw token (shown once) and its expiry.
/// Throws <see cref="InvalidOperationException"/> when the member is missing or has no email.
/// </summary>
Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request);
/// <summary>Checks whether a raw token is still usable, without mutating it.</summary>
Task<ValidateInvitationResult> ValidateAsync(string rawToken);
/// <summary>
/// Consumes an invitation: validates the token, sets the account password (enforcing the
/// Identity policy), and marks the invitation used. Returns the account on success, or an
/// error message describing why it failed (invalid/expired/used token or a policy violation).
/// </summary>
Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword);
/// <summary>E-mails an already-generated invitation link to the member via IEmailService.</summary>
Task SendEmailAsync(int memberId, string link);
}
@@ -22,6 +22,13 @@ public interface IMealAttendanceService
/// </summary>
Task<AttendanceCountsDto> SetAsync(DateOnly date, string category, int value);
/// <summary>
/// Overwrites all three age-group columns for <paramref name="date"/> with absolute
/// values (each clamped at zero), creating the row if it does not exist, and returns
/// the resulting authoritative counts. Used by the back-office Sunday-attendance editor.
/// </summary>
Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid);
/// <summary>Returns the daily counts within the inclusive date range, ordered by date (for the dashboard).</summary>
Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to);
}
@@ -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
}
@@ -0,0 +1,17 @@
using ROLAC.API.DTOs.Settings;
namespace ROLAC.API.Services;
/// <summary>
/// Reads and writes the singleton SiteSetting and NotificationSetting rows. Notification secrets
/// are masked on read and treated as write-only on update (blank = keep). After a notification
/// update the runtime cache is reloaded so changes apply without an API restart.
/// </summary>
public interface ISettingsService
{
Task<SiteSettingDto> GetSiteAsync();
Task UpdateSiteAsync(UpdateSiteSettingRequest request);
Task<NotificationSettingDto> GetNotificationAsync();
Task UpdateNotificationAsync(UpdateNotificationSettingRequest request);
}
+237
View File
@@ -0,0 +1,237 @@
using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Services;
public class InvitationService : IInvitationService
{
/// <summary>Lifetime of a freshly issued invitation link.</summary>
private const int InvitationLifetimeDays = 7;
private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db;
private readonly ITokenService _tokenService;
private readonly IEmailService _emailService;
private readonly IAuditLogger _audit;
private readonly CurrentUserAccessor _currentUser;
public InvitationService(
UserManager<AppUser> userManager,
AppDbContext db,
ITokenService tokenService,
IEmailService emailService,
IAuditLogger audit,
CurrentUserAccessor currentUser)
{
_userManager = userManager;
_db = db;
_tokenService = tokenService;
_emailService = emailService;
_audit = audit;
_currentUser = currentUser;
}
// ── Create ───────────────────────────────────────────────────────────────
public async Task<CreateInvitationResult> CreateAsync(CreateInvitationRequest request)
{
var member = await _db.Members.FindAsync(request.MemberId)
?? throw new InvalidOperationException($"Member {request.MemberId} does not exist.");
var email = (request.Email ?? member.Email)?.Trim();
if (string.IsNullOrWhiteSpace(email))
throw new InvalidOperationException(
"This member has no email address. Add an email before creating an invitation.");
var user = await _userManager.Users.FirstOrDefaultAsync(u => u.MemberId == request.MemberId);
if (user is null)
user = await CreateAccountAsync(member, email, request.Roles);
var now = DateTime.UtcNow;
// Re-issue: revoke any prior unused invitation so only one link is ever live.
var existing = await _db.UserInvitations
.Where(invitation => invitation.UserId == user.Id
&& invitation.UsedAt == null
&& invitation.RevokedAt == null)
.ToListAsync();
foreach (var invitation in existing)
invitation.RevokedAt = now;
var rawToken = GenerateRawToken();
var expiresAt = now.AddDays(InvitationLifetimeDays);
_db.UserInvitations.Add(new UserInvitation
{
UserId = user.Id,
TokenHash = _tokenService.HashToken(rawToken),
ExpiresAt = expiresAt,
CreatedAt = now,
CreatedBy = _currentUser.UserIdOrSystem,
});
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.InvitationCreated, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Invitation link created for {user.Email}");
return new CreateInvitationResult { Token = rawToken, ExpiresAt = expiresAt };
}
/// <summary>Creates a passwordless login account linked to the member; mirrors UserManagementService.</summary>
private async Task<AppUser> CreateAccountAsync(Member member, string email, List<string>? roles)
{
if (await _userManager.FindByEmailAsync(email) is not null)
throw new InvalidOperationException($"Email '{email}' is already in use by another account.");
var user = new AppUser
{
UserName = email,
Email = email,
EmailConfirmed = true,
MemberId = member.Id,
LanguagePreference = member.LanguagePreference,
IsActive = true,
CreatedAt = DateTime.UtcNow,
};
// No-password overload: the member sets their own password via the invitation link.
var result = await _userManager.CreateAsync(user);
if (!result.Succeeded)
throw new InvalidOperationException(
string.Join("; ", result.Errors.Select(error => error.Description)));
var rolesToAssign = roles is { Count: > 0 } ? roles : new List<string> { "member" };
await _userManager.AddToRolesAsync(user, rolesToAssign);
return user;
}
// ── Validate ───────────────────────────────────────────────────────────────
public async Task<ValidateInvitationResult> ValidateAsync(string rawToken)
{
var invitation = await FindByRawTokenAsync(rawToken);
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
return new ValidateInvitationResult { Valid = false, Expired = false };
if (invitation.IsExpired)
return new ValidateInvitationResult { Valid = false, Expired = true };
var user = await _userManager.FindByIdAsync(invitation.UserId);
return new ValidateInvitationResult
{
Valid = true,
Expired = false,
Email = user?.Email,
MemberName = await ResolveMemberNameAsync(user),
};
}
// ── Accept ───────────────────────────────────────────────────────────────
public async Task<(AppUser? User, string? Error)> AcceptAsync(string rawToken, string newPassword)
{
var invitation = await FindByRawTokenAsync(rawToken);
if (invitation is null || invitation.IsUsed || invitation.IsRevoked)
return (null, "This invitation link is invalid or has already been used.");
if (invitation.IsExpired)
return (null, "This invitation link has expired. Please ask for a new one.");
var user = await _userManager.FindByIdAsync(invitation.UserId);
if (user is null)
return (null, "The account for this invitation no longer exists.");
// Set the password — works whether or not one already exists, and enforces the policy.
var resetToken = await _userManager.GeneratePasswordResetTokenAsync(user);
var result = await _userManager.ResetPasswordAsync(user, resetToken, newPassword);
if (!result.Succeeded)
return (null, string.Join(" ", result.Errors.Select(error => error.Description)));
invitation.UsedAt = DateTime.UtcNow;
user.EmailConfirmed = true;
user.IsActive = true;
await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.InvitationAccepted, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Invitation accepted — password set for {user.Email}",
userId: user.Id, userEmail: user.Email);
return (user, null);
}
// ── Send email ───────────────────────────────────────────────────────────
public async Task SendEmailAsync(int memberId, string link)
{
var member = await _db.Members.FindAsync(memberId)
?? throw new InvalidOperationException($"Member {memberId} does not exist.");
var name = WebUtility.HtmlEncode(member.NickName ?? member.FirstName_en);
var safeLink = WebUtility.HtmlEncode(link);
var subject = "Your River Of Life Christian Church account invitation";
var htmlBody =
$"<p>Hi {name},</p>" +
"<p>You've been invited to set up your account for the River Of Life Christian Church portal.</p>" +
$"<p>Click the link below to set your password and sign in. This link expires in {InvitationLifetimeDays} days and can only be used once.</p>" +
$"<p><a href=\"{safeLink}\">Set your password and sign in</a></p>" +
"<p>If the button doesn't work, copy and paste this address into your browser:</p>" +
$"<p>{safeLink}</p>";
var result = await _emailService.SendAsync(new EmailMessage(
MemberIds: new[] { memberId },
Addresses: Array.Empty<string>(),
Subject: subject,
HtmlBody: htmlBody));
if (result.SentCount == 0)
throw new InvalidOperationException(
result.Failures.Count > 0
? $"Failed to send email: {result.Failures[0].Error}"
: "No email address on file for this member.");
}
// ── Helpers ───────────────────────────────────────────────────────────────
private Task<UserInvitation?> FindByRawTokenAsync(string rawToken)
{
if (string.IsNullOrWhiteSpace(rawToken))
return Task.FromResult<UserInvitation?>(null);
var hash = _tokenService.HashToken(rawToken);
return _db.UserInvitations.FirstOrDefaultAsync(invitation => invitation.TokenHash == hash);
}
private async Task<string?> ResolveMemberNameAsync(AppUser? user)
{
if (user?.MemberId is not int memberId)
return null;
return await _db.Members
.Where(member => member.Id == memberId)
.Select(member => (member.NickName ?? member.FirstName_en) + " " + member.LastName_en)
.FirstOrDefaultAsync();
}
/// <summary>32 cryptographically-random bytes as a URL-safe base64 string.</summary>
private static string GenerateRawToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
}
@@ -82,6 +82,26 @@ public class MealAttendanceService : IMealAttendanceService
return await ReadAsync(date);
}
public async Task<AttendanceCountsDto> SetCountsAsync(DateOnly date, int adult, int youth, int kid)
{
// Single-editor back-office path, so a tracked load + SaveChanges is fine here; no need for the
// race-safe EnsureRowAsync + ExecuteUpdateAsync pattern, which the EF InMemory test provider can't run.
var row = await _db.MealAttendances.FirstOrDefaultAsync(a => a.AttendanceDate == date);
if (row is null)
{
row = new MealAttendance { AttendanceDate = date };
_db.MealAttendances.Add(row);
}
// Counts can never be negative; clamp before writing.
row.AdultCount = adult < 0 ? 0 : adult;
row.YouthCount = youth < 0 ? 0 : youth;
row.KidCount = kid < 0 ? 0 : kid;
await _db.SaveChangesAsync();
return ToDto(row);
}
public async Task<IReadOnlyList<AttendanceCountsDto>> GetRangeAsync(DateOnly from, DateOnly to)
{
var rows = await _db.MealAttendances.AsNoTracking()
+5
View File
@@ -38,6 +38,7 @@ public class MemberService : IMemberService
(m.NickName != null && m.NickName.ToLower().Contains(s)) ||
(m.FirstName_zh != null && m.FirstName_zh.Contains(search)) ||
(m.LastName_zh != null && m.LastName_zh.Contains(search)) ||
(m.Entity != null && m.Entity.ToLower().Contains(s)) ||
(m.Email != null && m.Email.ToLower().Contains(s)));
}
@@ -74,6 +75,7 @@ public class MemberService : IMemberService
NickName = m.NickName,
FirstName_zh = m.FirstName_zh,
LastName_zh = m.LastName_zh,
Entity = m.Entity,
Status = m.Status,
Email = m.Email,
PhoneCell = m.PhoneCell,
@@ -105,6 +107,7 @@ public class MemberService : IMemberService
{
Id = m.Id, FirstName_en = m.FirstName_en, LastName_en = m.LastName_en,
NickName = m.NickName, FirstName_zh = m.FirstName_zh, LastName_zh = m.LastName_zh,
Entity = m.Entity,
Gender = m.Gender, DateOfBirth = m.DateOfBirth, BaptismDate = m.BaptismDate,
BaptismChurch = m.BaptismChurch, Email = m.Email, PhoneCell = m.PhoneCell,
PhoneHome = m.PhoneHome, Address = m.Address, City = m.City, State = m.State,
@@ -157,6 +160,7 @@ public class MemberService : IMemberService
{
FirstName_en = r.FirstName_en, LastName_en = r.LastName_en,
NickName = r.NickName, FirstName_zh = r.FirstName_zh, LastName_zh = r.LastName_zh,
Entity = r.Entity,
Gender = r.Gender, DateOfBirth = r.DateOfBirth, BaptismDate = r.BaptismDate,
BaptismChurch = r.BaptismChurch, Email = r.Email, PhoneCell = r.PhoneCell,
PhoneHome = r.PhoneHome, Address = r.Address, City = r.City, State = r.State,
@@ -169,6 +173,7 @@ public class MemberService : IMemberService
{
m.FirstName_en = r.FirstName_en; m.LastName_en = r.LastName_en;
m.NickName = r.NickName; m.FirstName_zh = r.FirstName_zh; m.LastName_zh = r.LastName_zh;
m.Entity = r.Entity;
m.Gender = r.Gender; m.DateOfBirth = r.DateOfBirth; m.BaptismDate = r.BaptismDate;
m.BaptismChurch = r.BaptismChurch; m.Email = r.Email; m.PhoneCell = r.PhoneCell;
m.PhoneHome = r.PhoneHome; m.Address = r.Address; m.City = r.City; m.State = r.State;
+36
View File
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
@@ -18,8 +19,43 @@ public class MinistryService : IMinistryService
.Select(m => new MinistryDto
{
Id = m.Id, Name_en = m.Name_en, Name_zh = m.Name_zh,
Description_en = m.Description_en, Description_zh = m.Description_zh,
SortOrder = m.SortOrder, IsActive = m.IsActive,
DefaultFunctionalClass = m.DefaultFunctionalClass,
})
.ToListAsync();
}
public async Task<int> CreateAsync(CreateMinistryRequest r)
{
var entity = new Ministry
{
Name_en = r.Name_en, Name_zh = r.Name_zh,
Description_en = r.Description_en, Description_zh = r.Description_zh,
SortOrder = r.SortOrder, IsActive = true,
DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass),
};
_db.Ministries.Add(entity);
await _db.SaveChangesAsync();
return entity.Id;
}
public async Task UpdateAsync(int id, UpdateMinistryRequest r)
{
var m = await _db.Ministries.FindAsync(id)
?? throw new KeyNotFoundException($"Ministry {id} not found.");
m.Name_en = r.Name_en; m.Name_zh = r.Name_zh;
m.Description_en = r.Description_en; m.Description_zh = r.Description_zh;
m.IsActive = r.IsActive; m.SortOrder = r.SortOrder;
m.DefaultFunctionalClass = ROLAC.API.Entities.FunctionalClasses.Normalize(r.DefaultFunctionalClass);
await _db.SaveChangesAsync();
}
public async Task DeactivateAsync(int id)
{
var m = await _db.Ministries.FindAsync(id)
?? throw new KeyNotFoundException($"Ministry {id} not found.");
m.IsActive = false;
await _db.SaveChangesAsync();
}
}
@@ -1,6 +1,5 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Notifications;
@@ -11,12 +10,12 @@ public sealed class LineMessageChannel : IMessageChannel
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
private readonly HttpClient _http;
private readonly LineOptions _options;
private readonly INotificationSettingsService _settings;
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
public LineMessageChannel(HttpClient http, INotificationSettingsService settings)
{
_http = http;
_options = options.Value;
_settings = settings;
}
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
@@ -36,7 +35,8 @@ public sealed class LineMessageChannel : IMessageChannel
{
Content = JsonContent.Create(payload),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", _settings.GetLine().ChannelAccessToken);
using var response = await _http.SendAsync(request, ct);
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
@@ -1,21 +1,22 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
namespace ROLAC.API.Services.Notifications;
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
/// <summary>Sends a single email via MailKit using the current (DB-backed) SMTP settings.</summary>
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
{
private readonly SmtpOptions _options;
private readonly INotificationSettingsService _settings;
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
public MailKitSmtpDispatcher(INotificationSettingsService settings) => _settings = settings;
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
var options = _settings.GetSmtp();
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
message.From.Add(new MailboxAddress(options.FromName, options.FromAddress));
message.To.Add(MailboxAddress.Parse(email.ToAddress));
message.Subject = email.Subject;
@@ -28,10 +29,10 @@ public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
message.Body = builder.ToMessageBody();
using var client = new SmtpClient();
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
if (!string.IsNullOrEmpty(_options.User))
await client.AuthenticateAsync(_options.User, _options.Password, ct);
var socketOptions = options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(options.Host, options.Port, socketOptions, ct);
if (!string.IsNullOrEmpty(options.User))
await client.AuthenticateAsync(options.User, options.Password, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Supplies the current SMTP/Line settings from the <c>NotificationSetting</c> singleton row,
/// caching a snapshot in memory so send paths don't hit the DB on every message. Registered as a
/// singleton; the Settings UI calls <see cref="Reload"/> after an edit so changes take effect
/// without restarting the API. Falls back to the "Smtp"/"Line" appsettings sections if the row
/// has not been seeded yet.
/// </summary>
public interface INotificationSettingsService
{
SmtpOptions GetSmtp();
LineOptions GetLine();
void Reload();
}
public sealed class NotificationSettingsService : INotificationSettingsService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IOptions<SmtpOptions> _smtpFallback;
private readonly IOptions<LineOptions> _lineFallback;
private readonly object _gate = new();
private SmtpOptions? _smtp;
private LineOptions? _line;
public NotificationSettingsService(
IServiceScopeFactory scopeFactory,
IOptions<SmtpOptions> smtpFallback,
IOptions<LineOptions> lineFallback)
{
_scopeFactory = scopeFactory;
_smtpFallback = smtpFallback;
_lineFallback = lineFallback;
}
public SmtpOptions GetSmtp()
{
EnsureLoaded();
return _smtp!;
}
public LineOptions GetLine()
{
EnsureLoaded();
return _line!;
}
public void Reload()
{
lock (_gate)
{
_smtp = null;
_line = null;
}
}
private void EnsureLoaded()
{
lock (_gate)
{
if (_smtp is not null && _line is not null)
return;
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var row = db.NotificationSettings.AsNoTracking().OrderBy(s => s.Id).FirstOrDefault();
if (row is null)
{
// Not seeded yet — use the appsettings values so sends still work.
_smtp = _smtpFallback.Value;
_line = _lineFallback.Value;
return;
}
_smtp = new SmtpOptions
{
Host = row.SmtpHost,
Port = row.SmtpPort,
UseSsl = row.SmtpUseSsl,
User = row.SmtpUser,
Password = row.SmtpPassword,
FromAddress = row.FromAddress,
FromName = row.FromName,
};
_line = new LineOptions
{
ChannelAccessToken = row.LineChannelAccessToken,
ChannelSecret = row.LineChannelSecret,
};
}
}
}
@@ -45,6 +45,11 @@ public class OfferingSessionService : IOfferingSessionService
.Select(grp => new { Id = grp.Key, Count = grp.Count() })
.ToDictionaryAsync(x => x.Id, x => x.Count);
var dates = rows.Select(r => r.SessionDate).ToList();
var attendance = await _db.MealAttendances.AsNoTracking()
.Where(a => dates.Contains(a.AttendanceDate))
.ToDictionaryAsync(a => a.AttendanceDate, a => a.AdultCount + a.YouthCount + a.KidCount);
var items = rows.Select(s => new OfferingSessionListItemDto
{
Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status,
@@ -52,6 +57,7 @@ public class OfferingSessionService : IOfferingSessionService
SystemTotal = s.SystemTotal, Difference = s.Difference,
LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0,
HasProof = s.ProofPdfPath != null,
SundayAttendanceCount = attendance.TryGetValue(s.SessionDate, out var att) ? att : (int?)null,
}).ToList();
return new PagedResult<OfferingSessionListItemDto>
+115
View File
@@ -0,0 +1,115 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Settings;
using ROLAC.API.Entities;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Services;
public class SettingsService : ISettingsService
{
private readonly AppDbContext _db;
private readonly INotificationSettingsService _notificationSettings;
public SettingsService(AppDbContext db, INotificationSettingsService notificationSettings)
{
_db = db;
_notificationSettings = notificationSettings;
}
public async Task<SiteSettingDto> GetSiteAsync()
{
var s = await GetOrCreateSiteAsync();
return new SiteSettingDto
{
SiteTitle = s.SiteTitle,
SiteTitleZh = s.SiteTitleZh,
DefaultLanguage = s.DefaultLanguage,
TimeZone = s.TimeZone,
DateFormat = s.DateFormat,
Currency = s.Currency,
};
}
public async Task UpdateSiteAsync(UpdateSiteSettingRequest r)
{
var s = await GetOrCreateSiteAsync();
s.SiteTitle = r.SiteTitle;
s.SiteTitleZh = r.SiteTitleZh;
s.DefaultLanguage = r.DefaultLanguage;
s.TimeZone = r.TimeZone;
s.DateFormat = r.DateFormat;
s.Currency = r.Currency;
await _db.SaveChangesAsync();
}
public async Task<NotificationSettingDto> GetNotificationAsync()
{
var n = await GetOrCreateNotificationAsync();
return new NotificationSettingDto
{
EnableEmail = n.EnableEmail,
SmtpHost = n.SmtpHost,
SmtpPort = n.SmtpPort,
SmtpUseSsl = n.SmtpUseSsl,
SmtpUser = n.SmtpUser,
FromAddress = n.FromAddress,
FromName = n.FromName,
HasSmtpPassword = !string.IsNullOrEmpty(n.SmtpPassword),
EnableLine = n.EnableLine,
HasLineChannelAccessToken = !string.IsNullOrEmpty(n.LineChannelAccessToken),
HasLineChannelSecret = !string.IsNullOrEmpty(n.LineChannelSecret),
// WebhookUrl is filled by the controller (needs the request host).
};
}
public async Task UpdateNotificationAsync(UpdateNotificationSettingRequest r)
{
var n = await GetOrCreateNotificationAsync();
n.EnableEmail = r.EnableEmail;
n.SmtpHost = r.SmtpHost;
n.SmtpPort = r.SmtpPort;
n.SmtpUseSsl = r.SmtpUseSsl;
n.SmtpUser = r.SmtpUser;
n.FromAddress = r.FromAddress ?? "";
n.FromName = r.FromName ?? "";
n.EnableLine = r.EnableLine;
// Secrets are write-only: a blank value means "keep what's stored".
if (!string.IsNullOrWhiteSpace(r.SmtpPassword))
n.SmtpPassword = r.SmtpPassword;
if (!string.IsNullOrWhiteSpace(r.LineChannelAccessToken))
n.LineChannelAccessToken = r.LineChannelAccessToken;
if (!string.IsNullOrWhiteSpace(r.LineChannelSecret))
n.LineChannelSecret = r.LineChannelSecret;
await _db.SaveChangesAsync();
// Drop the cached snapshot so the new values are used on the next send — no restart needed.
_notificationSettings.Reload();
}
private async Task<SiteSetting> GetOrCreateSiteAsync()
{
var s = await _db.SiteSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
if (s is null)
{
s = new SiteSetting { SiteTitle = "Church" };
_db.SiteSettings.Add(s);
await _db.SaveChangesAsync();
}
return s;
}
private async Task<NotificationSetting> GetOrCreateNotificationAsync()
{
var n = await _db.NotificationSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
if (n is null)
{
n = new NotificationSetting();
_db.NotificationSettings.Add(n);
await _db.SaveChangesAsync();
}
return n;
}
}
+24
View File
@@ -7,6 +7,7 @@ import { PermissionGuard } from './core/guards/permission.guard';
import { PermissionModules } from './core/models/permission.model';
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
import { MinistriesPageComponent } from './features/ministry/pages/ministries-page/ministries-page.component';
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component';
@@ -19,16 +20,21 @@ 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';
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
import { AcceptInvitationComponent } from './features/accept-invitation/accept-invitation.component';
export const routes: Routes = [
// Public routes
{ path: 'login', component: LoginPage },
// Public first-login page — member sets their own password from a secret invitation link.
{ path: 'accept-invitation', component: AcceptInvitationComponent },
// Public Sunday meal attendance counter — no login required (volunteers on phones).
{ path: 'attendance', component: AttendanceCounterPageComponent },
@@ -61,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,
@@ -192,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',
@@ -31,6 +32,7 @@ export const PermissionModules = {
Permissions: 'Permissions',
SystemLogs: 'SystemLogs',
AuditLogs: 'AuditLogs',
Settings: 'Settings',
} as const;
/** A required permission, used in route data and the *appHasPermission directive. */
@@ -0,0 +1,158 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { IndicatorsModule } from '@progress/kendo-angular-indicators';
import { AuthService } from '../../shared/services/auth.service';
import {
passwordStrengthValidator,
passwordMatchValidator,
} from '../account/validators/password.validators';
type Step = 'loading' | 'invalid' | 'form';
@Component({
selector: 'app-accept-invitation',
standalone: true,
imports: [
CommonModule, ReactiveFormsModule,
InputsModule, LabelModule, ButtonsModule, IndicatorsModule,
],
template: `
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h1 class="text-xl font-semibold mb-1">River Of Life Christian Church</h1>
<!-- Validating the link -->
<ng-container *ngIf="step === 'loading'">
<div class="text-center py-6">
<kendo-loader></kendo-loader>
<p class="mt-2 text-gray-600">Checking your invitation…</p>
</div>
</ng-container>
<!-- Invalid / expired link -->
<ng-container *ngIf="step === 'invalid'">
<p class="text-base font-medium mb-2">This invitation can't be used</p>
<p class="text-gray-600 mb-4">{{ invalidMessage }}</p>
<button kendoButton themeColor="primary" (click)="goToLogin()">Go to sign in</button>
</ng-container>
<!-- Set password form -->
<ng-container *ngIf="step === 'form'">
<p class="text-gray-600 mb-4">
Welcome<span *ngIf="memberName">, <strong>{{ memberName }}</strong></span>. Set a password to
finish creating your account and sign in.
</p>
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
<div class="grid grid-cols-1 gap-y-3">
<kendo-formfield>
<kendo-label text="New Password *"></kendo-label>
<kendo-textbox formControlName="newPassword" type="password" [clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">Required.</kendo-formerror>
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
Must be at least 8 characters with an uppercase letter, a lowercase letter,
a digit, and a special character.
</kendo-formerror>
</kendo-formfield>
<kendo-formfield>
<kendo-label text="Confirm Password *"></kendo-label>
<kendo-textbox formControlName="confirmPassword" type="password" [clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">Required.</kendo-formerror>
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
Passwords do not match.
</kendo-formerror>
</kendo-formfield>
<p *ngIf="errorMessage" class="k-color-error">{{ errorMessage }}</p>
<div class="mt-2">
<button kendoButton themeColor="primary" type="submit" [disabled]="form.invalid || submitting">
<span *ngIf="submitting">…</span>
Set password &amp; sign in
</button>
</div>
</div>
</form>
</ng-container>
</div>
</div>
`,
})
export class AcceptInvitationComponent implements OnInit {
step: Step = 'loading';
form: FormGroup;
submitting = false;
memberName: string | null = null;
invalidMessage = 'This invitation link is invalid or has already been used.';
errorMessage = '';
private token = '';
constructor(
private fb: FormBuilder,
private auth: AuthService,
private route: ActivatedRoute,
private router: Router,
) {
this.form = this.fb.group(
{
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
confirmPassword: ['', [Validators.required]],
},
{ validators: passwordMatchValidator() },
);
}
ngOnInit(): void {
this.token = this.route.snapshot.queryParamMap.get('token') ?? '';
if (!this.token) {
this.step = 'invalid';
return;
}
this.auth.validateInvitation(this.token).subscribe({
next: (result) => {
if (result.valid) {
this.memberName = result.memberName ?? null;
this.step = 'form';
} else {
this.invalidMessage = result.expired
? 'This invitation link has expired. Please ask for a new one.'
: 'This invitation link is invalid or has already been used.';
this.step = 'invalid';
}
},
error: () => { this.step = 'invalid'; },
});
}
onSubmit(): void {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.submitting = true;
this.errorMessage = '';
this.auth.acceptInvitation(this.token, this.form.value.newPassword).subscribe({
next: () => {
this.router.navigate(['/user-portal/dashboard']);
},
error: (err) => {
this.errorMessage = err.error?.message ?? 'Could not set your password. The link may have expired.';
this.submitting = false;
},
});
}
goToLogin(): void {
this.router.navigate(['/login']);
}
}
@@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto {
}
export interface ChurchProfileDto {
id: number; name: string; address: string | null; city: string | null;
id: number; name: string; nameZh: string | null; phone: string | null;
email: string | null; website: string | null; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
}
@@ -1,10 +1,30 @@
<div class="page">
<div *ngIf="model" class="max-w-3xl">
<kendo-tabstrip>
<!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
<kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
<ng-template kendoTabContent>
<div *ngIf="model" class="max-w-3xl pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
<label class="flex flex-col gap-1">
Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Church Name (ZH) / 教會名稱(中)
<kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Phone / 電話
<kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Email / 電子郵件
<kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Website / 網站
<kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
@@ -46,4 +66,21 @@
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 2: Site Settings (Settings permission) ─────────────────────── -->
<kendo-tabstrip-tab title="Site Settings / 網站設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<app-site-settings-tab></app-site-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
<!-- ── Tab 3: Notification Settings (Settings permission) ─────────────── -->
<kendo-tabstrip-tab title="Notifications / 通知設定" *appHasPermission="settingsPermission">
<ng-template kendoTabContent>
<app-notification-settings-tab></app-notification-settings-tab>
</ng-template>
</kendo-tabstrip-tab>
</kendo-tabstrip>
</div>
@@ -3,13 +3,21 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LayoutModule } from '@progress/kendo-angular-layout';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model';
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
import { PermissionModules } from '../../../../core/models/permission.model';
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
import { NotificationSettingsTabComponent } from '../../../settings/components/notification-settings-tab/notification-settings-tab.component';
@Component({
selector: 'app-church-profile-page',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
imports: [
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
],
templateUrl: './church-profile-page.component.html',
})
export class ChurchProfilePageComponent implements OnInit {
@@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit {
saving = false;
savedMsg = '';
/** Settings module gates the Site / Notification tabs. */
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
constructor(private api: DisbursementApiService) {}
ngOnInit(): void {
@@ -1,5 +1,8 @@
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<!-- Two columns on desktop: form on the left, receipt preview on the right. Stacks on mobile. -->
<div class="flex flex-col gap-4 md:flex-row">
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
@@ -7,77 +10,80 @@
<span>連續登打 / Continuous Entry</span>
</label>
<!-- Description -->
<label class="flex flex-col gap-1 md:col-span-2">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
</label>
<!-- Member picker (finance creating on behalf of a member) -->
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
<kendo-dropdownlist
[data]="memberResults"
textField="displayName"
valueField="id"
[valuePrimitive]="true"
[filterable]="true"
(filterChange)="onMemberFilter($event)"
[(ngModel)]="form.memberId"
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
[filterable]="true" (filterChange)="onMemberFilter($event)" [(ngModel)]="form.memberId"
placeholder="Search member by name">
</kendo-dropdownlist>
</label>
<!-- Ministry -->
<label class="flex flex-col gap-1">Ministry
<kendo-dropdownlist
[data]="ministries"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.ministryId"
[defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="form.ministryId" [defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
</kendo-dropdownlist>
</label>
<!-- Category Group -->
<label class="flex flex-col gap-1">Category Group
<kendo-dropdownlist
[data]="groups"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.categoryGroupId"
(valueChange)="onGroupChange($event)"
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
</kendo-dropdownlist>
</label>
<!-- Sub-Category -->
<label class="flex flex-col gap-1">Sub-Category
<kendo-dropdownlist
[data]="subs"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="form.subCategoryId"
[defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
[disabled]="!form.categoryGroupId">
</kendo-dropdownlist>
</label>
<!-- Amount -->
<label class="flex flex-col gap-1">Amount
<kendo-numerictextbox
[(ngModel)]="form.amount"
[min]="0"
[format]="'c2'">
</kendo-numerictextbox>
</label>
<!-- Expense Date -->
<label class="flex flex-col gap-1">Expense Date
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
</label>
<!-- Description -->
<label class="flex flex-col gap-1 md:col-span-2">Description
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
<!-- Category lines: one invoice can span several categories -->
<div class="md:col-span-2 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-semibold">明細 / Line Items</span>
<span class="text-sm text-gray-600">合計 / Total: {{ totalAmount | currency }}</span>
</div>
<div *ngFor="let line of lines; let i = index" class="flex flex-col gap-3 rounded border border-gray-200 p-3">
<!-- Line header: number + remove -->
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">明細 {{ i + 1 }} / Item {{ i + 1 }}</span>
<button kendoButton fillMode="flat" themeColor="error" size="small" [disabled]="lines.length === 1"
(click)="removeLine(i)" title="Remove line / 刪除此列">✕ 刪除</button>
</div>
<!-- Row 1: Category Group + Sub-Category -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="flex flex-col gap-1">Category Group
<kendo-dropdownlist [data]="groups" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="line.categoryGroupId" (valueChange)="onLineGroupChange(line, $event)"
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">Sub-Category
<kendo-dropdownlist [data]="line.subs" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="line.subCategoryId" [defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
[disabled]="!line.categoryGroupId">
</kendo-dropdownlist>
</label>
</div>
<!-- Row 2: Description (optional) + Amount -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="flex flex-col gap-1">Description / 說明 <span class="text-gray-400">(Optional)</span>
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">Amount
<kendo-numerictextbox [(ngModel)]="line.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
</label>
</div>
</div>
<div>
<button kendoButton fillMode="outline" (click)="addLine()">+ 新增一列 / Add Line</button>
</div>
</div>
<!-- Vendor mode: vendor name + check number -->
<ng-container *ngIf="mode === 'vendor'">
<label class="flex flex-col gap-1">Vendor Name
@@ -96,17 +102,43 @@
from bubbling up to the host, where it would collide with this component's
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
-->
<input
#receiptInput
type="file"
accept="image/*,application/pdf"
(change)="onFileSelected($event)"
<input #receiptInput type="file" accept="image/*,application/pdf" (change)="onFileSelected($event)"
(cancel)="$event.stopPropagation()"
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
</label>
</ng-container>
</div>
<!-- /left form column -->
<!-- Right: receipt preview (shown once a file is selected or an existing receipt is loaded) -->
<div *ngIf="showReceiptPanel" class="md:w-[34rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-semibold">收據預覽 / Receipt</span>
<!-- Zoom controls (image only; PDF uses the browser viewer's own zoom) -->
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out"></button>
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset"></button>
</div>
</div>
<!-- Scrollable so a zoomed-in image can be panned for comparison -->
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50"
style="max-height: 72vh;">
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100"
class="block max-w-none" />
</div>
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
</div>
</div>
<!-- /two-column body -->
<kendo-dialog-actions>
<button kendoButton (click)="cancel.emit()">Cancel</button>
@@ -1,6 +1,7 @@
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
@@ -8,11 +9,12 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseApiService } from '../../services/expense-api.service';
import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseListItemDto,
ExpenseDto, FunctionalClass,
} from '../../models/expense.model';
export interface ExpenseFormResult {
@@ -25,18 +27,30 @@ export interface ExpenseFormResult {
/** Flattened member item with a single displayName field for the dropdown. */
interface MemberOption { id: number; displayName: string; }
/** One editable category line. `subs` holds the sub-category list for this row's chosen group. */
interface ExpenseLineForm {
categoryGroupId: number | null;
subCategoryId: number | null;
amount: number;
description: string;
/** Functional class is no longer exposed in the form (too complex for volunteers); it stays
* null = inherit the ministry default. Kept here so existing overrides survive an edit. */
functionalClass: FunctionalClass | null;
subs: ExpenseSubCategoryDto[];
}
@Component({
selector: 'app-expense-form-dialog',
standalone: true,
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
templateUrl: './expense-form-dialog.component.html',
})
export class ExpenseFormDialogComponent implements OnInit {
export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
@Input() allowMemberPick = false;
@Input() title = 'New Expense';
/** When set, the dialog prefills from this row for editing instead of starting blank. */
@Input() expense: ExpenseListItemDto | null = null;
/** When set, the dialog prefills from this expense (with its lines) for editing. */
@Input() expense: ExpenseDto | null = null;
@Output() save = new EventEmitter<ExpenseFormResult>();
@Output() cancel = new EventEmitter<void>();
@@ -45,7 +59,6 @@ export class ExpenseFormDialogComponent implements OnInit {
ministries: MinistryDto[] = [];
groups: ExpenseCategoryGroupDto[] = [];
subs: ExpenseSubCategoryDto[] = [];
memberResults: MemberOption[] = [];
@@ -59,54 +72,108 @@ export class ExpenseFormDialogComponent implements OnInit {
form = {
ministryId: null as number | null,
categoryGroupId: null as number | null,
subCategoryId: null as number | null,
amount: 0,
description: '',
vendorName: '',
checkNumber: '',
memberId: null as number | null,
expenseDate: new Date(),
};
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
lines: ExpenseLineForm[] = [this.emptyLine()];
receipt: File | null = null;
// ── Receipt preview (right panel) ────────────────────────────────────────
/** Blob URL for an image receipt, bound directly to <img [src]>. */
receiptImageUrl: string | null = null;
/** Sanitized blob URL for a PDF receipt, bound to <iframe [src]>. */
receiptPdfUrl: SafeResourceUrl | null = null;
/** Raw object URL kept so it can be revoked. */
private receiptObjectUrl: string | null = null;
/** Image zoom factor (1 = fit panel width); lets volunteers blow up a receipt to compare. */
receiptZoom = 1;
readonly minZoom = 0.5;
readonly maxZoom = 5;
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
resetZoom(): void { this.receiptZoom = 1; }
constructor(
private ministryApi: MinistryApiService,
private catApi: ExpenseCategoryApiService,
private memberApi: MemberApiService,
private expenseApi: ExpenseApiService,
private sanitizer: DomSanitizer,
) {}
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
this.catApi.getAll(false).subscribe(groups => {
this.groups = groups;
// Populate the sub-category list for the prefilled group so its value displays on edit.
if (this.expense) {
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
}
// Populate each line's sub-category list once the catalog is loaded (edit mode).
if (this.expense) this.hydrateLineSubs();
});
if (this.expense) this.prefill(this.expense);
if (this.expense) {
this.prefill(this.expense);
// Edit mode: load the existing receipt into the preview panel.
if (this.expense.hasReceipt) {
this.expenseApi.downloadReceipt(this.expense.id)
.subscribe(blob => this.setPreview(blob, blob.type));
}
}
}
private prefill(expense: ExpenseListItemDto): void {
ngOnDestroy(): void { this.clearPreview(); }
private emptyLine(): ExpenseLineForm {
return { categoryGroupId: null, subCategoryId: null, amount: 0, description: '', functionalClass: null, subs: [] };
}
private prefill(expense: ExpenseDto): void {
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
const [year, month, day] = expense.expenseDate.split('-').map(Number);
this.form = {
ministryId: expense.ministryId,
categoryGroupId: expense.categoryGroupId,
subCategoryId: expense.subCategoryId,
amount: expense.amount,
description: expense.description,
vendorName: expense.vendorName ?? '',
checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId,
expenseDate: new Date(year, month - 1, day),
};
this.lines = (expense.lines ?? []).map(l => ({
categoryGroupId: l.categoryGroupId,
subCategoryId: l.subCategoryId,
amount: l.amount,
description: l.description ?? '',
functionalClass: l.functionalClass,
subs: [],
}));
if (this.lines.length === 0) this.lines = [this.emptyLine()];
}
onGroupChange(groupId: number | null): void {
this.form.subCategoryId = null;
this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
/** Fill each line's sub-category list from its chosen group (used after the catalog loads on edit). */
private hydrateLineSubs(): void {
for (const line of this.lines) {
line.subs = this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [];
}
}
onLineGroupChange(line: ExpenseLineForm, groupId: number | null): void {
line.subCategoryId = null;
line.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
}
addLine(): void { this.lines.push(this.emptyLine()); }
removeLine(index: number): void {
if (this.lines.length > 1) this.lines.splice(index, 1);
}
get totalAmount(): number {
return this.lines.reduce((sum, l) => sum + (l.amount || 0), 0);
}
onMemberFilter(term: string): void {
@@ -122,12 +189,34 @@ export class ExpenseFormDialogComponent implements OnInit {
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
this.receipt = input.files?.[0] ?? null;
const file = input.files?.[0] ?? null;
this.receipt = file;
if (file) this.setPreview(file, file.type);
}
/** Show a newly-selected file or a fetched existing receipt in the right-hand preview panel. */
private setPreview(blob: Blob, contentType: string): void {
this.clearPreview();
this.receiptZoom = 1;
this.receiptObjectUrl = URL.createObjectURL(blob);
if (contentType.startsWith('image/')) {
this.receiptImageUrl = this.receiptObjectUrl;
} else {
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
}
}
private clearPreview(): void {
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
this.receiptImageUrl = null;
this.receiptPdfUrl = null;
}
get isValid(): boolean {
return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId
&& this.form.amount > 0 && this.form.description.trim().length > 0;
return !!this.form.ministryId
&& this.form.description.trim().length > 0
&& this.lines.length > 0
&& this.lines.every(l => !!l.categoryGroupId && !!l.subCategoryId && l.amount > 0);
}
emitSave(): void {
@@ -137,9 +226,13 @@ export class ExpenseFormDialogComponent implements OnInit {
const request: CreateExpenseRequest = {
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
ministryId: this.form.ministryId!,
categoryGroupId: this.form.categoryGroupId!,
subCategoryId: this.form.subCategoryId!,
amount: this.form.amount,
lines: this.lines.map(l => ({
categoryGroupId: l.categoryGroupId!,
subCategoryId: l.subCategoryId!,
amount: l.amount,
functionalClass: l.functionalClass,
description: l.description.trim() || null,
})),
description: this.form.description.trim(),
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
memberId: this.allowMemberPick ? this.form.memberId : null,
@@ -154,14 +247,14 @@ export class ExpenseFormDialogComponent implements OnInit {
}
/**
* Clear only the per-entry fields, keeping Member, Ministry, Category Group,
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
* user can immediately log the next reimbursement.
* Clear only the per-entry fields, keeping Member, Ministry and Expense Date so the
* user can immediately log the next reimbursement. Lines reset to a single blank row.
*/
private resetForNext(): void {
this.form.amount = 0;
this.lines = [this.emptyLine()];
this.form.description = '';
this.receipt = null;
this.clearPreview();
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
}
}
@@ -0,0 +1,98 @@
<kendo-dialog title="Review Expense / 審核支出" (close)="cancel.emit()"
[width]="showReceiptPanel ? 1100 : 620" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
<div *ngIf="loading" class="p-6 text-center text-gray-500">Loading… / 載入中…</div>
<div *ngIf="!loading && expense" class="flex flex-col gap-4 md:flex-row">
<!-- Left: read-only expense detail -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="text-gray-500">Date / 日期</div><div>{{ expense.expenseDate }}</div>
<div class="text-gray-500">Ministry / 事工</div><div>{{ expense.ministryName }}</div>
<div class="text-gray-500">Payee / 收款人</div><div>{{ expense.vendorName || expense.memberName || '—' }}</div>
<div class="text-gray-500">Description / 說明</div><div>{{ expense.description }}</div>
<div class="text-gray-500">Status / 狀態</div><div>{{ expense.status }}</div>
</div>
<!-- Line items -->
<div class="flex flex-col gap-1">
<div class="font-semibold text-sm">明細 / Line Items</div>
<table class="w-full text-sm border-collapse">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="py-1">Category / 類別</th>
<th class="py-1">Description / 說明</th>
<th class="py-1 text-right">Amount / 金額</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let line of expense.lines" class="border-b border-gray-100">
<td class="py-1">{{ line.categoryGroupName }}<span *ngIf="line.subCategoryName"> / {{ line.subCategoryName }}</span></td>
<td class="py-1 text-gray-600">{{ line.description || '—' }}</td>
<td class="py-1 text-right tabular-nums">{{ line.amount | currency }}</td>
</tr>
</tbody>
<tfoot>
<tr class="font-semibold">
<td class="py-1" colspan="2">Total / 合計</td>
<td class="py-1 text-right tabular-nums">{{ expense.amount | currency }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- Reject reason capture (shown after clicking Reject) -->
<div *ngIf="rejecting" class="flex flex-col gap-2 rounded border border-red-200 bg-red-50 p-3">
<label class="flex flex-col gap-1 text-sm">Reject Reason / 拒絕原因
<kendo-dropdownlist [data]="rejectReasons" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="rejectReason" [defaultItem]="{ value: null, label: '-- Select reason --/請選擇原因' }">
</kendo-dropdownlist>
</label>
<label *ngIf="isOtherReason" class="flex flex-col gap-1 text-sm">Detail / 說明
<kendo-textbox [(ngModel)]="rejectOther" placeholder="Please enter the reason / 請輸入原因"></kendo-textbox>
</label>
</div>
</div>
<!-- Right: receipt preview -->
<div class="md:w-[30rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-semibold">收據預覽 / Receipt</span>
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out"></button>
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in"></button>
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset"></button>
</div>
</div>
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50" style="max-height: 72vh;">
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100" class="block max-w-none" />
</div>
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
<div *ngIf="!showReceiptPanel" class="rounded border border-dashed border-gray-300 p-6 text-center text-gray-400 text-sm">
No receipt attached / 無收據
</div>
</div>
</div>
<kendo-dialog-actions>
<!-- Decision row -->
<ng-container *ngIf="!rejecting">
<button kendoButton (click)="cancel.emit()">Cancel / 取消</button>
<button kendoButton themeColor="error" fillMode="flat" (click)="startReject()">Reject / 拒絕</button>
<button kendoButton themeColor="success" (click)="confirmApprove()">Approve / 核准</button>
</ng-container>
<!-- Reject confirmation row -->
<ng-container *ngIf="rejecting">
<button kendoButton (click)="cancelReject()">Back / 返回</button>
<button kendoButton themeColor="error" [disabled]="!canConfirmReject" (click)="confirmReject()">Confirm Reject / 確認拒絕</button>
</ng-container>
</kendo-dialog-actions>
</kendo-dialog>
@@ -0,0 +1,106 @@
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseDto } from '../../models/expense.model';
import { EXPENSE_REJECT_REASON_OPTIONS } from '../../../../shared/i18n/option-lists';
/**
* Approval review dialog: shows the full expense detail and a receipt preview side by side,
* then lets a reviewer Approve or Reject (with a templated or free-text reason). The parent
* performs the actual api.approve / api.reject call from the emitted events.
*/
@Component({
selector: 'app-expense-review-dialog',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, DialogsModule, DropDownsModule, InputsModule],
templateUrl: './expense-review-dialog.component.html',
})
export class ExpenseReviewDialogComponent implements OnInit, OnDestroy {
/** Expense to review; the full detail (with lines) is fetched on open. */
@Input() expenseId!: number;
@Output() approve = new EventEmitter<void>();
@Output() reject = new EventEmitter<string>(); // emits the composed reviewNotes
@Output() cancel = new EventEmitter<void>();
expense: ExpenseDto | null = null;
loading = true;
readonly rejectReasons = EXPENSE_REJECT_REASON_OPTIONS;
/** false = the Approve/Reject choice; true = the reject reason is being collected. */
rejecting = false;
rejectReason: string | null = null;
rejectOther = '';
// ── Receipt preview (mirrors expense-form-dialog) ───────────────────────
receiptImageUrl: string | null = null;
receiptPdfUrl: SafeResourceUrl | null = null;
private receiptObjectUrl: string | null = null;
receiptZoom = 1;
readonly minZoom = 0.5;
readonly maxZoom = 5;
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
resetZoom(): void { this.receiptZoom = 1; }
constructor(private api: ExpenseApiService, private sanitizer: DomSanitizer) {}
ngOnInit(): void {
this.api.getById(this.expenseId).subscribe({
next: dto => {
this.expense = dto;
this.loading = false;
if (dto.hasReceipt) {
this.api.downloadReceipt(dto.id).subscribe(blob => this.setPreview(blob, blob.type));
}
},
error: () => { this.loading = false; },
});
}
ngOnDestroy(): void { this.clearPreview(); }
private setPreview(blob: Blob, contentType: string): void {
this.clearPreview();
this.receiptZoom = 1;
this.receiptObjectUrl = URL.createObjectURL(blob);
if (contentType.startsWith('image/')) {
this.receiptImageUrl = this.receiptObjectUrl;
} else {
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
}
}
private clearPreview(): void {
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
this.receiptImageUrl = null;
this.receiptPdfUrl = null;
}
startReject(): void { this.rejecting = true; this.rejectReason = null; this.rejectOther = ''; }
cancelReject(): void { this.rejecting = false; }
get isOtherReason(): boolean { return this.rejectReason === 'Other'; }
/** The reason text actually sent: the template value, or the free text when "Other" is chosen. */
get composedReviewNotes(): string {
return this.isOtherReason ? this.rejectOther.trim() : (this.rejectReason ?? '');
}
get canConfirmReject(): boolean { return this.composedReviewNotes.length > 0; }
confirmApprove(): void { this.approve.emit(); }
confirmReject(): void {
if (!this.canConfirmReject) return;
this.reject.emit(this.composedReviewNotes);
}
}
@@ -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,27 +8,38 @@ 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 ExpenseLineItemDto {
id: number; categoryGroupId: number; categoryGroupName: string;
subCategoryId: number; subCategoryName: string;
functionalClass: FunctionalClass | null; amount: number; description: string | null;
}
export interface ExpenseListItemDto {
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string;
subCategoryId: number; subCategoryName: string; vendorName: string | null;
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
vendorName: string | null;
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
checkNumber: string | null;
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
}
export interface ExpenseDto extends ExpenseListItemDto {
notes: string | null; reviewNotes: string | null;
submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null;
notes: string | null;
submittedBy: string | null; submittedAt: string | null; paidAt: string | null;
lines: ExpenseLineItemDto[];
}
export interface ExpenseLineInput {
categoryGroupId: number; subCategoryId: number; amount: number;
functionalClass: FunctionalClass | null; description: string | null;
}
export interface CreateExpenseRequest {
type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number;
amount: number; description: string; vendorName: string | null; memberId: number | null;
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
description: string; vendorName: string | null; memberId: number | null;
checkNumber: string | null; expenseDate: string; notes: string | null;
}
export type UpdateExpenseRequest = CreateExpenseRequest;
@@ -61,6 +61,15 @@
Sort order
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
[(ngModel)]="groupForm.form990LineId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
</label>
@@ -89,6 +98,15 @@
Sort order
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Form 990 Line / 990 行</span>
<kendo-dropdownlist
[data]="form990Lines"
textField="label" valueField="id" [valuePrimitive]="true"
[defaultItem]="{ id: null, label: '(Unmapped / 未對應)' }"
[(ngModel)]="subForm.form990LineId">
</kendo-dropdownlist>
</label>
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
</label>
@@ -5,14 +5,16 @@ import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angula
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
@Component({
selector: 'app-expense-categories-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule],
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, DropDownsModule, ContextMenuModule],
templateUrl: './expense-categories-page.component.html',
styleUrls: ['./expense-categories-page.component.scss'],
})
@@ -20,6 +22,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groups: ExpenseCategoryGroupDto[] = [];
selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false;
form990Lines: Form990ExpenseLineDto[] = [];
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
@@ -30,15 +33,18 @@ export class ExpenseCategoriesPageComponent implements OnInit {
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
subDialogOpen = false;
editingSubId: number | null = null;
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
constructor(private api: ExpenseCategoryApiService) {}
ngOnInit(): void { this.load(); }
ngOnInit(): void {
this.load();
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
}
load(): void {
this.loading = true;
@@ -101,16 +107,16 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
this.groupDialogOpen = true;
}
openEditGroup(g: ExpenseCategoryGroupDto): void {
this.editingGroupId = g.id;
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive };
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
this.groupDialogOpen = true;
}
saveGroup(): void {
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder };
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
const done = () => { this.groupDialogOpen = false; this.load(); };
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
@@ -123,17 +129,17 @@ export class ExpenseCategoriesPageComponent implements OnInit {
openNewSub(): void {
if (!this.selectedGroup) return;
this.editingSubId = null;
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true };
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
this.subDialogOpen = true;
}
openEditSub(s: ExpenseSubCategoryDto): void {
this.editingSubId = s.id;
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive };
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
this.subDialogOpen = true;
}
saveSub(): void {
if (!this.selectedGroup) return;
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder };
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
const done = () => { this.subDialogOpen = false; this.load(); };
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
@@ -43,7 +43,7 @@
<kendo-grid-column title="Category" [width]="360">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
</ng-template>
</kendo-grid-column>
@@ -62,19 +62,23 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Status" [width]="140">
<kendo-grid-column title="Status" [width]="200">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div *ngIf="dataItem.reviewedByName && dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
✗ Rejected by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
</div>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="160">
<ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<ng-container *ngIf="canApproveOrReject(dataItem)">
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
<button kendoButton themeColor="error" fillMode="flat" (click)="openReject(dataItem)">Reject</button>
</ng-container>
<button *ngIf="canApproveOrReject(dataItem)" kendoButton themeColor="primary" fillMode="flat"
(click)="openReview(dataItem)">Review</button>
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
(click)="openPay(dataItem)">Pay</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
@@ -118,19 +122,10 @@
</kendo-dialog-actions>
</kendo-dialog>
<!-- Reject dialog -->
<kendo-dialog *ngIf="rejectRow" title="Reject Expense" [width]="400" [maxWidth]="'95vw'" (close)="rejectRow = null">
<div class="grid grid-cols-1 gap-3 p-2">
<label class="flex flex-col gap-1">
Review Notes
<kendo-textbox [(ngModel)]="rejectNotes" placeholder="Optional notes for submitter"></kendo-textbox>
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="rejectRow = null">Cancel</button>
<button kendoButton themeColor="error" (click)="confirmReject()">Reject</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Review dialog: detail + receipt preview, with Approve / Reject(reason) -->
<app-expense-review-dialog *ngIf="reviewRow" [expenseId]="reviewRow.id"
(approve)="onReviewApprove()" (reject)="onReviewReject($event)" (cancel)="closeReview()">
</app-expense-review-dialog>
<!-- Transient save confirmation (sits above the open dialog during continuous entry) -->
<div *ngIf="toast" class="save-toast">{{ toast }}</div>
@@ -45,6 +45,24 @@
text-decoration: underline;
}
// Small "Approved/Rejected by X · date" note under the status badge.
.review-meta {
margin-top: 4px;
font-size: 0.7rem;
line-height: 1.2;
color: #6b7280;
}
.review-meta-reject {
color: #b91c1c;
}
.review-reason {
margin-top: 2px;
font-style: italic;
color: #991b1b;
}
// Save confirmation pill. z-index sits above the Kendo dialog overlay so it
// stays visible while the continuous-entry dialog remains open.
.save-toast {
@@ -11,7 +11,8 @@ import { EXPENSE_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { ExpenseApiService, ExpenseQuery } from '../../services/expense-api.service';
import { MinistryApiService } from '../../services/ministry-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
import { ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
import { ExpenseReviewDialogComponent } from '../../components/expense-review-dialog/expense-review-dialog.component';
import { ExpenseDto, ExpenseListItemDto, MinistryDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs';
@Component({
@@ -20,6 +21,7 @@ import { switchMap, of } from 'rxjs';
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
InputsModule, DialogsModule, DateInputsModule, ExpenseFormDialogComponent,
ExpenseReviewDialogComponent,
],
templateUrl: './expenses-page.component.html',
styleUrls: ['./expenses-page.component.scss'],
@@ -39,15 +41,15 @@ export class ExpensesPageComponent implements OnInit {
vendorDialogOpen = false;
reimbDialogOpen = false;
editRow: ExpenseListItemDto | null = null;
editRow: ExpenseDto | null = null;
editMode: 'vendor' | 'reimbursement' = 'reimbursement';
payRow: ExpenseListItemDto | null = null;
payCheckNumber = '';
payDate = new Date();
rejectRow: ExpenseListItemDto | null = null;
rejectNotes = '';
/** Row whose detail+receipt are open in the review dialog for an approve/reject decision. */
reviewRow: ExpenseListItemDto | null = null;
/** Transient confirmation pill, used so the user gets feedback during continuous entry. */
toast: string | null = null;
@@ -95,8 +97,9 @@ export class ExpensesPageComponent implements OnInit {
}
openEdit(row: ExpenseListItemDto): void {
this.editRow = row;
// Fetch the full expense (with its lines) before opening the dialog for editing.
this.editMode = row.type === 'VendorPayment' ? 'vendor' : 'reimbursement';
this.api.getById(row.id).subscribe(dto => (this.editRow = dto));
}
closeEdit(): void { this.editRow = null; }
@@ -109,19 +112,18 @@ export class ExpensesPageComponent implements OnInit {
).subscribe(() => { this.closeEdit(); this.load(); });
}
approve(row: ExpenseListItemDto): void {
this.api.approve(row.id).subscribe(() => this.load());
openReview(row: ExpenseListItemDto): void { this.reviewRow = row; }
closeReview(): void { this.reviewRow = null; }
onReviewApprove(): void {
if (!this.reviewRow) return;
this.api.approve(this.reviewRow.id).subscribe(() => { this.reviewRow = null; this.load(); });
}
openReject(row: ExpenseListItemDto): void {
this.rejectRow = row;
this.rejectNotes = '';
}
confirmReject(): void {
if (!this.rejectRow) return;
this.api.reject(this.rejectRow.id, { reviewNotes: this.rejectNotes || null }).subscribe(() => {
this.rejectRow = null;
onReviewReject(reviewNotes: string): void {
if (!this.reviewRow) return;
this.api.reject(this.reviewRow.id, { reviewNotes: reviewNotes || null }).subscribe(() => {
this.reviewRow = null;
this.load();
});
}
@@ -11,19 +11,26 @@
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
<kendo-grid-column title="Category">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
{{ dataItem.primaryCategoryName }}<span *ngIf="dataItem.lineCount > 1" class="text-gray-500"> +{{ dataItem.lineCount - 1 }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="110" format="c2"></kendo-grid-column>
<kendo-grid-column title="Status" [width]="140">
<kendo-grid-column title="Status" [width]="220">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
<div *ngIf="dataItem.reviewedByName && (dataItem.status === 'Approved' || dataItem.status === 'Paid')"
class="review-meta">✓ Approved by {{ dataItem.reviewedByName }}<br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</div>
<div *ngIf="dataItem.status === 'Rejected'" class="review-meta review-meta-reject">
✗ Rejected<span *ngIf="dataItem.reviewedByName"> by {{ dataItem.reviewedByName }}</span><br>{{ dataItem.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
<div *ngIf="dataItem.reviewNotes" class="review-reason">{{ dataItem.reviewNotes }}</div>
</div>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="200">
<kendo-grid-column title="Actions" [width]="230">
<ng-template kendoGridCellTemplate let-dataItem>
<button *ngIf="canEdit(dataItem)" kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button *ngIf="isDraft(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
<button *ngIf="isRejected(dataItem)" kendoButton themeColor="primary" fillMode="flat" (click)="resubmit(dataItem)">Resubmit</button>
<button *ngIf="isDraft(dataItem)" kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
@@ -52,7 +59,7 @@
</div>
<div>
<dt>Category</dt>
<dd>{{ row.categoryGroupName }} / {{ row.subCategoryName }}</dd>
<dd>{{ row.primaryCategoryName }}<span *ngIf="row.lineCount > 1"> +{{ row.lineCount - 1 }}</span></dd>
</div>
</dl>
@@ -60,9 +67,20 @@
<span [class]="statusClass(row.status)">{{ row.status }}</span>
</div>
<!-- Rejection feedback: what the submitter must fix before resubmitting -->
<div *ngIf="row.status === 'Rejected'" class="rmb-card__reject">
✗ Rejected<span *ngIf="row.reviewedByName"> by {{ row.reviewedByName }}</span>
<span *ngIf="row.reviewedAt"> · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }}</span>
<div *ngIf="row.reviewNotes" class="rmb-card__reject-reason">{{ row.reviewNotes }}</div>
</div>
<div *ngIf="row.reviewedByName && (row.status === 'Approved' || row.status === 'Paid')" class="rmb-card__approved">
✓ Approved by {{ row.reviewedByName }} · {{ row.reviewedAt | date:'yyyy-MM-dd HH:mm' }}
</div>
<div class="rmb-card__actions" *ngIf="canEdit(row) || row.hasReceipt">
<button *ngIf="canEdit(row)" kendoButton fillMode="outline" (click)="openEdit(row)">Edit</button>
<button *ngIf="isDraft(row)" kendoButton themeColor="primary" (click)="submit(row)">Submit</button>
<button *ngIf="isRejected(row)" kendoButton themeColor="primary" (click)="resubmit(row)">Resubmit</button>
<button *ngIf="isDraft(row)" kendoButton fillMode="outline" (click)="remove(row)">Delete</button>
<button *ngIf="row.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(row.id)">Receipt</button>
</div>
@@ -45,6 +45,24 @@
text-decoration: underline;
}
// Small "Approved/Rejected by X · date" note under the status badge (desktop grid).
.review-meta {
margin-top: 4px;
font-size: 0.7rem;
line-height: 1.2;
color: #6b7280;
}
.review-meta-reject {
color: #b91c1c;
}
.review-reason {
margin-top: 2px;
font-style: italic;
color: #991b1b;
}
// Mobile card list
// NOTE: display/flex layout lives on the element via Tailwind (flex flex-col gap-3)
// so the responsive `md:hidden` utility wins on desktop. Setting `display: flex`
@@ -116,6 +134,28 @@
margin-top: 12px;
}
&__reject {
margin-top: 10px;
padding: 8px 10px;
border-radius: 8px;
background: #fef2f2;
border: 1px solid #fecaca;
font-size: 0.8rem;
color: #b91c1c;
}
&__reject-reason {
margin-top: 2px;
font-weight: 600;
color: #991b1b;
}
&__approved {
margin-top: 10px;
font-size: 0.8rem;
color: #047857;
}
&__actions {
margin-top: 12px;
padding-top: 12px;
@@ -4,7 +4,7 @@ import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
import { ExpenseListItemDto } from '../../models/expense.model';
import { ExpenseDto, ExpenseListItemDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@@ -19,7 +19,7 @@ export class MyReimbursementsPageComponent implements OnInit {
rows: ExpenseListItemDto[] = [];
loading = false;
dialogOpen = false;
editRow: ExpenseListItemDto | null = null;
editRow: ExpenseDto | null = null;
constructor(private api: ExpenseApiService) {}
@@ -34,7 +34,10 @@ export class MyReimbursementsPageComponent implements OnInit {
}
openNew(): void { this.editRow = null; this.dialogOpen = true; }
openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; }
openEdit(row: ExpenseListItemDto): void {
// Fetch the full expense (with its lines) before opening the dialog for editing.
this.api.getById(row.id).subscribe(dto => { this.editRow = dto; this.dialogOpen = true; });
}
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
onSave(result: ExpenseFormResult): void {
@@ -53,6 +56,8 @@ export class MyReimbursementsPageComponent implements OnInit {
}
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
/** Re-submit a rejected reimbursement after fixing the flagged issue (clears the prior review server-side). */
resubmit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
remove(row: ExpenseListItemDto): void {
if (!confirm('Delete this reimbursement?')) return;
this.api.delete(row.id).subscribe(() => this.load());
@@ -66,10 +71,14 @@ export class MyReimbursementsPageComponent implements OnInit {
});
}
/** Editing (and reuploading the photo) is allowed while a reimbursement is still Draft or awaiting review. */
canEdit(row: ExpenseListItemDto): boolean { return row.status === 'Draft' || row.status === 'PendingApproval'; }
/** Editing (and reuploading the photo) is allowed while Draft, awaiting review, or after a rejection. */
canEdit(row: ExpenseListItemDto): boolean {
return row.status === 'Draft' || row.status === 'PendingApproval' || row.status === 'Rejected';
}
/** Submit and Delete only apply before the reimbursement has been submitted. */
isDraft(row: ExpenseListItemDto): boolean { return row.status === 'Draft'; }
/** A rejected reimbursement can be fixed and re-submitted by its owner. */
isRejected(row: ExpenseListItemDto): boolean { return row.status === 'Rejected'; }
statusClass(status: string): string {
return ({ Draft: 'badge-draft', PendingApproval: 'badge-pending', Approved: 'badge-approved', Paid: 'badge-paid', Rejected: 'badge-rejected' } as Record<string, string>)[status] ?? '';
}

Some files were not shown because too many files have changed in this diff Show More