diff --git a/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs b/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs new file mode 100644 index 0000000..b8c8ba7 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class GivingCategoryServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + private static AppDbContext BuildDb(string userId = "test-user") + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + [Fact] + public async Task CreateAsync_ReturnsId_AndDefaultsActive() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db); + + var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" }); + + var saved = await db.GivingCategories.FindAsync(id); + Assert.NotNull(saved); + Assert.True(saved!.IsActive); + Assert.Equal("Tithe", saved.Name_en); + } + + [Fact] + public async Task GetAllAsync_ExcludesInactive_ByDefault() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db); + var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" }); + var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" }); + await svc.DeactivateAsync(id2); + + var active = await svc.GetAllAsync(includeInactive: false); + var all = await svc.GetAllAsync(includeInactive: true); + + Assert.Single(active); + Assert.Equal(2, all.Count); + } + + [Fact] + public async Task DeactivateAsync_SetsIsActiveFalse() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db); + var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" }); + + await svc.DeactivateAsync(id); + + var saved = await db.GivingCategories.FindAsync(id); + Assert.False(saved!.IsActive); + } + + [Fact] + public async Task UpdateAsync_Throws_WhenMissing() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db); + await Assert.ThrowsAsync(() => + svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" })); + } + + [Fact] + public async Task DeactivateAsync_Throws_WhenMissing() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db); + await Assert.ThrowsAsync(() => svc.DeactivateAsync(999)); + } +} diff --git a/API/ROLAC.API.Tests/Services/GivingServiceTests.cs b/API/ROLAC.API.Tests/Services/GivingServiceTests.cs new file mode 100644 index 0000000..b540fa3 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/GivingServiceTests.cs @@ -0,0 +1,161 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class GivingServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + private static AppDbContext BuildDb(string userId = "test-user") + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task SeedCategoryAsync(AppDbContext db) + { + var c = new GivingCategory { Name_en = "Tithe", IsActive = true }; + db.GivingCategories.Add(c); + await db.SaveChangesAsync(); + return c.Id; + } + + [Fact] + public async Task CreateAsync_PersistsGiving() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new GivingService(db); + + var id = await svc.CreateAsync(new CreateGivingRequest + { + GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true, + }); + + var saved = await db.Givings.FindAsync(id); + Assert.NotNull(saved); + Assert.Equal(100m, saved!.Amount); + Assert.Null(saved.OfferingSessionId); + } + + [Fact] + public async Task GetPagedAsync_FiltersByCategory() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new GivingService(db); + await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }); + + var page = await svc.GetPagedAsync(1, 20, null, catId, null, null); + + Assert.Equal(1, page.TotalCount); + Assert.Equal("Tithe", page.Items[0].CategoryName); + } + + [Fact] + public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" }; + db.OfferingSessions.Add(session); + await db.SaveChangesAsync(); + var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id }; + db.Givings.Add(giving); + await db.SaveChangesAsync(); + + var svc = new GivingService(db); + + await Assert.ThrowsAsync(() => + svc.UpdateAsync(giving.Id, new UpdateGivingRequest + { GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) })); + } + + [Fact] + public async Task DeleteAsync_Throws_WhenMissing() + { + using var db = BuildDb(); + var svc = new GivingService(db); + await Assert.ThrowsAsync(() => svc.DeleteAsync(999)); + } + + [Fact] + public async Task CreateAsync_Anonymous_NullsProvidedMemberId() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new GivingService(db); + + var id = await svc.CreateAsync(new CreateGivingRequest + { + GivingCategoryId = catId, Amount = 25m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026, 5, 31), + IsAnonymous = true, MemberId = 12345, // provided, but must be stripped + }); + + var saved = await db.Givings.FindAsync(id); + Assert.True(saved!.IsAnonymous); + Assert.Null(saved.MemberId); + } + + [Fact] + public async Task DeleteAsync_Throws_WhenGivingBelongsToSubmittedSession() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var session = new OfferingSession { SessionDate = new DateOnly(2026, 5, 31), Status = "Submitted" }; + db.OfferingSessions.Add(session); + await db.SaveChangesAsync(); + var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026, 5, 31), OfferingSessionId = session.Id }; + db.Givings.Add(giving); + await db.SaveChangesAsync(); + + var svc = new GivingService(db); + + await Assert.ThrowsAsync(() => svc.DeleteAsync(giving.Id)); + } + + [Fact] + public async Task GetPagedAsync_MatchesByMemberName() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var member = new Member { FirstName_en = "Grace", LastName_en = "Lee" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + var svc = new GivingService(db); + await svc.CreateAsync(new CreateGivingRequest + { + GivingCategoryId = catId, Amount = 75m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026, 5, 31), MemberId = member.Id, + }); + + var page = await svc.GetPagedAsync(1, 20, "grace", null, null, null); + + Assert.Equal(1, page.TotalCount); + Assert.Equal(member.Id, page.Items[0].MemberId); + } +} diff --git a/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs new file mode 100644 index 0000000..824fd45 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class OfferingSessionServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + private static AppDbContext BuildDb(string userId = "test-user") + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task SeedCategoryAsync(AppDbContext db) + { + var c = new GivingCategory { Name_en = "Tithe", IsActive = true }; + db.GivingCategories.Add(c); + await db.SaveChangesAsync(); + return c.Id; + } + + private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new() + { + SessionDate = date, CashTotal = 150m, CheckTotal = 0m, + Givings = + [ + new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" }, + new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true }, + ], + }; + + [Fact] + public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + var saved = await db.OfferingSessions.FindAsync(id); + Assert.Equal("Submitted", saved!.Status); + Assert.Equal(150m, saved.SystemTotal); + Assert.Equal(0m, saved.Difference); + Assert.NotNull(saved.SubmittedAt); + Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id)); + } + + [Fact] + public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync(); + Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate)); + Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null); + } + + [Fact] + public async Task CreateAsync_Throws_OnDuplicateSessionDate() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)))); + } + + [Fact] + public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + await Assert.ThrowsAsync(() => + svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31)))); + } + + [Fact] + public async Task ReopenThenReplace_SwapsLinesAndResubmits() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + await svc.ReopenAsync(id); + var reopened = await db.OfferingSessions.FindAsync(id); + Assert.Equal("Draft", reopened!.Status); + + var newReq = new CreateOfferingSessionRequest + { + SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m, + Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ], + }; + await svc.ReplaceAsync(id, newReq); + + var after = await db.OfferingSessions.FindAsync(id); + Assert.Equal("Submitted", after!.Status); + Assert.Equal(200m, after.SystemTotal); + Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id)); + } + + [Fact] + public async Task GetByIdAsync_ReturnsCheckZelleAndPayPalRefs() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + var req = new CreateOfferingSessionRequest + { + SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m, + Givings = [ new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Zelle", + ZelleReferenceCode = "Z-123", PayPalTransactionId = "PP-456", CheckNumber = "C-789" } ], + }; + var id = await svc.CreateAsync(req); + + var dto = await svc.GetByIdAsync(id); + + Assert.NotNull(dto); + var line = Assert.Single(dto!.Givings); + Assert.Equal("Z-123", line.ZelleReferenceCode); + Assert.Equal("PP-456", line.PayPalTransactionId); + Assert.Equal("C-789", line.CheckNumber); + } +} diff --git a/API/ROLAC.API/Controllers/GivingCategoriesController.cs b/API/ROLAC.API/Controllers/GivingCategoriesController.cs new file mode 100644 index 0000000..235492e --- /dev/null +++ b/API/ROLAC.API/Controllers/GivingCategoriesController.cs @@ -0,0 +1,40 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/giving-categories")] +[Authorize(Roles = "finance,super_admin")] +public class GivingCategoriesController : ControllerBase +{ + private readonly IGivingCategoryService _svc; + public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc; + + [HttpGet] + public async Task GetAll([FromQuery] bool includeInactive = false) + => Ok(await _svc.GetAllAsync(includeInactive)); + + [HttpPost] + public async Task Create([FromBody] CreateGivingCategoryRequest request) + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetAll), new { id }, new { id }); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateGivingCategoryRequest request) + { + try { await _svc.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } + + [HttpDelete("{id:int}")] + public async Task Deactivate(int id) + { + try { await _svc.DeactivateAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } +} diff --git a/API/ROLAC.API/Controllers/GivingsController.cs b/API/ROLAC.API/Controllers/GivingsController.cs new file mode 100644 index 0000000..78d3fcd --- /dev/null +++ b/API/ROLAC.API/Controllers/GivingsController.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/givings")] +[Authorize(Roles = "finance,super_admin")] +public class GivingsController : ControllerBase +{ + private readonly IGivingService _svc; + public GivingsController(IGivingService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, + [FromQuery] string? search = null, [FromQuery] int? categoryId = null, + [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + => Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to)); + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateGivingRequest request) + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetById), new { id }, new { id }); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateGivingRequest request) + { + try { await _svc.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + try { await _svc.DeleteAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } +} diff --git a/API/ROLAC.API/Controllers/OfferingSessionsController.cs b/API/ROLAC.API/Controllers/OfferingSessionsController.cs new file mode 100644 index 0000000..b17b556 --- /dev/null +++ b/API/ROLAC.API/Controllers/OfferingSessionsController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/offering-sessions")] +[Authorize(Roles = "finance,super_admin")] +public class OfferingSessionsController : ControllerBase +{ + private readonly IOfferingSessionService _svc; + public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, + [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + => Ok(await _svc.GetPagedAsync(page, pageSize, from, to)); + + [HttpGet("check-date")] + public async Task CheckDate([FromQuery] DateOnly date) + => Ok(new { exists = await _svc.DateExistsAsync(date) }); + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateOfferingSessionRequest request) + { + try + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetById), new { id }, new { id }); + } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/reopen")] + public async Task Reopen(int id) + { + try { await _svc.ReopenAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPut("{id:int}")] + public async Task Replace(int id, [FromBody] CreateOfferingSessionRequest request) + { + try { await _svc.ReplaceAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } +} diff --git a/API/ROLAC.API/DTOs/Giving/CreateGivingCategoryRequest.cs b/API/ROLAC.API/DTOs/Giving/CreateGivingCategoryRequest.cs new file mode 100644 index 0000000..6fa94de --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/CreateGivingCategoryRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class CreateGivingCategoryRequest +{ + [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; } +} diff --git a/API/ROLAC.API/DTOs/Giving/CreateGivingRequest.cs b/API/ROLAC.API/DTOs/Giving/CreateGivingRequest.cs new file mode 100644 index 0000000..2678d69 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/CreateGivingRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class CreateGivingRequest +{ + public int? MemberId { get; set; } + [Required] public int GivingCategoryId { get; set; } + [Range(0.01, 9999999)] public decimal Amount { get; set; } + [Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash"; + [MaxLength(50)] public string? CheckNumber { get; set; } + [MaxLength(100)] public string? ZelleReferenceCode { get; set; } + [MaxLength(100)] public string? PayPalTransactionId { get; set; } + public DateOnly GivingDate { get; set; } + public bool IsAnonymous { get; set; } + [MaxLength(500)] public string? Notes { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/CreateOfferingSessionRequest.cs b/API/ROLAC.API/DTOs/Giving/CreateOfferingSessionRequest.cs new file mode 100644 index 0000000..c5f024c --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/CreateOfferingSessionRequest.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class CreateOfferingSessionRequest +{ + [Required] public DateOnly SessionDate { get; set; } + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public string? Notes { get; set; } + public List Givings { get; set; } = []; +} diff --git a/API/ROLAC.API/DTOs/Giving/GivingCategoryDto.cs b/API/ROLAC.API/DTOs/Giving/GivingCategoryDto.cs new file mode 100644 index 0000000..3b1b612 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/GivingCategoryDto.cs @@ -0,0 +1,12 @@ +namespace ROLAC.API.DTOs.Giving; + +public class GivingCategoryDto +{ + 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 bool IsActive { get; set; } + public int SortOrder { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/GivingDto.cs b/API/ROLAC.API/DTOs/Giving/GivingDto.cs new file mode 100644 index 0000000..f0f1aed --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/GivingDto.cs @@ -0,0 +1,18 @@ +namespace ROLAC.API.DTOs.Giving; + +public class GivingDto +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } + public int GivingCategoryId { get; set; } + public int? OfferingSessionId { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = ""; + public string? CheckNumber { get; set; } + public string? ZelleReferenceCode { get; set; } + public string? PayPalTransactionId { get; set; } + public DateOnly GivingDate { get; set; } + public bool IsAnonymous { get; set; } + public string? Notes { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/GivingListItemDto.cs b/API/ROLAC.API/DTOs/Giving/GivingListItemDto.cs new file mode 100644 index 0000000..a44ecc3 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/GivingListItemDto.cs @@ -0,0 +1,15 @@ +namespace ROLAC.API.DTOs.Giving; + +public class GivingListItemDto +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } + public int GivingCategoryId { get; set; } + public string CategoryName { get; set; } = ""; + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = ""; + public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd + public bool IsAnonymous { get; set; } + public int? OfferingSessionId { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingGivingLineDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingGivingLineDto.cs new file mode 100644 index 0000000..687f474 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingGivingLineDto.cs @@ -0,0 +1,17 @@ +namespace ROLAC.API.DTOs.Giving; + +public class OfferingGivingLineDto +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } + public int GivingCategoryId { get; set; } + public string CategoryName { get; set; } = ""; + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = ""; + public string? CheckNumber { get; set; } + public string? ZelleReferenceCode { get; set; } + public string? PayPalTransactionId { get; set; } + public bool IsAnonymous { get; set; } + public string? Notes { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingGivingLineRequest.cs b/API/ROLAC.API/DTOs/Giving/OfferingGivingLineRequest.cs new file mode 100644 index 0000000..3ccaf9f --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingGivingLineRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class OfferingGivingLineRequest +{ + public int? MemberId { get; set; } + [Required] public int GivingCategoryId { get; set; } + [Range(0.01, 9999999)] public decimal Amount { get; set; } + [Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash"; + [MaxLength(50)] public string? CheckNumber { get; set; } + [MaxLength(100)] public string? ZelleReferenceCode { get; set; } + [MaxLength(100)] public string? PayPalTransactionId { get; set; } + public bool IsAnonymous { get; set; } + [MaxLength(500)] public string? Notes { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs new file mode 100644 index 0000000..6f65c84 --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs @@ -0,0 +1,14 @@ +namespace ROLAC.API.DTOs.Giving; + +public class OfferingSessionDto +{ + public int Id { get; set; } + public DateOnly SessionDate{ get; set; } + public string Status { get; set; } = ""; + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public decimal SystemTotal { get; set; } + public decimal Difference { get; set; } + public string? Notes { get; set; } + public List Givings { get; set; } = []; +} diff --git a/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs new file mode 100644 index 0000000..e2f1fea --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs @@ -0,0 +1,13 @@ +namespace ROLAC.API.DTOs.Giving; + +public class OfferingSessionListItemDto +{ + public int Id { get; set; } + public string SessionDate { get; set; } = ""; // yyyy-MM-dd + public string Status { get; set; } = ""; + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public decimal SystemTotal { get; set; } + public decimal Difference { get; set; } + public int LineCount { get; set; } +} diff --git a/API/ROLAC.API/DTOs/Giving/UpdateGivingCategoryRequest.cs b/API/ROLAC.API/DTOs/Giving/UpdateGivingCategoryRequest.cs new file mode 100644 index 0000000..84ded3a --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/UpdateGivingCategoryRequest.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class UpdateGivingCategoryRequest +{ + [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; } +} diff --git a/API/ROLAC.API/DTOs/Giving/UpdateGivingRequest.cs b/API/ROLAC.API/DTOs/Giving/UpdateGivingRequest.cs new file mode 100644 index 0000000..944df7a --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/UpdateGivingRequest.cs @@ -0,0 +1,3 @@ +namespace ROLAC.API.DTOs.Giving; + +public class UpdateGivingRequest : CreateGivingRequest { } diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 60ff785..f82bd04 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -11,6 +11,9 @@ public class AppDbContext : IdentityDbContext public DbSet RefreshTokens => Set(); public DbSet Members => Set(); public DbSet FamilyUnits => Set(); + public DbSet GivingCategories => Set(); + public DbSet OfferingSessions => Set(); + public DbSet Givings => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -89,5 +92,55 @@ public class AppDbContext : IdentityDbContext entity.HasOne(e => e.FamilyUnit).WithMany() .HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull); }); + + // ── GivingCategory ─────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Name_zh).HasMaxLength(200); + entity.Property(e => e.Description_en).HasMaxLength(500); + entity.Property(e => e.Description_zh).HasMaxLength(500); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + }); + + // ── OfferingSession ────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft"); + entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)"); + entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)"); + entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)"); + entity.Property(e => e.Difference).HasColumnType("decimal(18,2)"); + entity.Property(e => e.SubmittedBy).HasMaxLength(450); + entity.Property(e => e.ReconciledBy).HasMaxLength(450); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.HasIndex(e => e.SessionDate).IsUnique(); + }); + + // ── Giving ─────────────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); + entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired(); + entity.Property(e => e.CheckNumber).HasMaxLength(50); + entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100); + entity.Property(e => e.PayPalTransactionId).HasMaxLength(100); + entity.Property(e => e.Notes).HasMaxLength(500); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + + entity.HasIndex(e => new { e.MemberId, e.GivingDate }); + entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL"); + entity.HasIndex(e => e.GivingDate); + + entity.HasOne(e => e.GivingCategory).WithMany() + .HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.Member).WithMany() + .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings) + .HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index a5aa05d..4dddc42 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -1,10 +1,20 @@ using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using ROLAC.API.Entities; namespace ROLAC.API.Data; public static class DbSeeder { + private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed = + [ + ("Tithe", "什一奉獻", 1), + ("General Offering", "一般奉獻", 2), + ("Special Offering", "特別奉獻", 3), + ("Building Fund", "建堂基金", 4), + ("Mission", "宣教奉獻", 5), + ]; + private static readonly (string Name, string Description)[] Roles = [ ("super_admin", "System administrator — full access"), @@ -37,6 +47,25 @@ public static class DbSeeder } } + public static async Task SeedGivingCategoriesAsync(AppDbContext db) + { + foreach (var (en, zh, sort) in GivingCategorySeed) + { + if (!await db.GivingCategories.AnyAsync(c => c.Name_en == en)) + { + db.GivingCategories.Add(new GivingCategory + { + Name_en = en, + Name_zh = zh, + SortOrder = sort, + IsActive = true, + // Audit fields are stamped by AuditSaveChangesInterceptor on save. + }); + } + } + await db.SaveChangesAsync(); + } + /// /// Seeds roles and (in Development) the default admin account. /// Called once on application startup after migrations have been applied. @@ -49,6 +78,9 @@ public static class DbSeeder await SeedRolesAsync(roleManager); + var db = services.GetRequiredService(); + await SeedGivingCategoriesAsync(db); + if (env.IsDevelopment()) await SeedAdminUserAsync(userManager); } diff --git a/API/ROLAC.API/Entities/Giving.cs b/API/ROLAC.API/Entities/Giving.cs new file mode 100644 index 0000000..32ad486 --- /dev/null +++ b/API/ROLAC.API/Entities/Giving.cs @@ -0,0 +1,23 @@ +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class Giving : AuditableEntity +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public int GivingCategoryId { get; set; } + public int? OfferingSessionId { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = "Cash"; // Cash|Check|Zelle|PayPal|Other + public string? CheckNumber { get; set; } + public string? ZelleReferenceCode { get; set; } + public string? PayPalTransactionId{ get; set; } + public DateOnly GivingDate { get; set; } + public bool IsAnonymous { get; set; } + public string? Notes { get; set; } + + public Member? Member { get; set; } + public GivingCategory? GivingCategory { get; set; } + public OfferingSession? OfferingSession { get; set; } +} diff --git a/API/ROLAC.API/Entities/GivingCategory.cs b/API/ROLAC.API/Entities/GivingCategory.cs new file mode 100644 index 0000000..234ba02 --- /dev/null +++ b/API/ROLAC.API/Entities/GivingCategory.cs @@ -0,0 +1,14 @@ +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class GivingCategory : AuditableEntity +{ + public int Id { get; set; } + public string Name_en { get; set; } = null!; + public string? Name_zh { get; set; } + public string? Description_en { get; set; } + public string? Description_zh { get; set; } + public bool IsActive { get; set; } = true; + public int SortOrder { get; set; } +} diff --git a/API/ROLAC.API/Entities/OfferingSession.cs b/API/ROLAC.API/Entities/OfferingSession.cs new file mode 100644 index 0000000..460e89e --- /dev/null +++ b/API/ROLAC.API/Entities/OfferingSession.cs @@ -0,0 +1,21 @@ +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class OfferingSession : AuditableEntity +{ + public int Id { get; set; } + public DateOnly SessionDate { get; set; } + public string Status { get; set; } = "Draft"; // Draft | Submitted | Reconciled + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public decimal SystemTotal { get; set; } + public decimal Difference { get; set; } + public string? Notes { get; set; } + public DateTimeOffset? SubmittedAt { get; set; } + public string? SubmittedBy { get; set; } + public DateTimeOffset? ReconciledAt { get; set; } + public string? ReconciledBy { get; set; } + + public List Givings { get; set; } = []; +} diff --git a/API/ROLAC.API/Migrations/20260528232422_AddGivingModule.Designer.cs b/API/ROLAC.API/Migrations/20260528232422_AddGivingModule.Designer.cs new file mode 100644 index 0000000..e328f65 --- /dev/null +++ b/API/ROLAC.API/Migrations/20260528232422_AddGivingModule.Designer.cs @@ -0,0 +1,792 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ROLAC.API.Data; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260528232422_AddGivingModule")] + partial class AddGivingModule + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LanguagePreference") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .IsUnique() + .HasFilter("\"MemberId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.FamilyUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("FamilyName_en") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FamilyName_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FamilyUnits"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GivingCategoryId") + .HasColumnType("integer"); + + b.Property("GivingDate") + .HasColumnType("date"); + + b.Property("IsAnonymous") + .HasColumnType("boolean"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OfferingSessionId") + .HasColumnType("integer"); + + b.Property("PayPalTransactionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZelleReferenceCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("GivingCategoryId"); + + b.HasIndex("GivingDate"); + + b.HasIndex("OfferingSessionId") + .HasFilter("\"OfferingSessionId\" IS NOT NULL"); + + b.HasIndex("MemberId", "GivingDate"); + + b.ToTable("Givings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description_en") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Description_zh") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("GivingCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Member", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaptismChurch") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaptismDate") + .HasColumnType("date"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasDefaultValue("USA"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FamilyUnitId") + .HasColumnType("integer"); + + b.Property("FirstName_en") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FirstName_zh") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinDate") + .HasColumnType("date"); + + b.Property("LanguagePreference") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("LastName_en") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName_zh") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PhoneCell") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PhoneHome") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PhotoBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("State") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Member"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZipCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("FamilyUnitId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CashTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Difference") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReconciledBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SessionDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Draft"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SystemTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("SessionDate") + .IsUnique(); + + b.ToTable("OfferingSessions"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ReplacedByHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory") + .WithMany() + .HasForeignKey("GivingCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession") + .WithMany("Givings") + .HasForeignKey("OfferingSessionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GivingCategory"); + + b.Navigation("Member"); + + b.Navigation("OfferingSession"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Member", b => + { + b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit") + .WithMany() + .HasForeignKey("FamilyUnitId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FamilyUnit"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => + { + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Navigation("Givings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/ROLAC.API/Migrations/20260528232422_AddGivingModule.cs b/API/ROLAC.API/Migrations/20260528232422_AddGivingModule.cs new file mode 100644 index 0000000..7a327ff --- /dev/null +++ b/API/ROLAC.API/Migrations/20260528232422_AddGivingModule.cs @@ -0,0 +1,150 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + /// + public partial class AddGivingModule : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "GivingCategories", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name_en = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Name_zh = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Description_en = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + Description_zh = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_GivingCategories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OfferingSessions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SessionDate = table.Column(type: "date", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"), + CashTotal = table.Column(type: "numeric(18,2)", nullable: false), + CheckTotal = table.Column(type: "numeric(18,2)", nullable: false), + SystemTotal = table.Column(type: "numeric(18,2)", nullable: false), + Difference = table.Column(type: "numeric(18,2)", nullable: false), + Notes = table.Column(type: "text", nullable: true), + SubmittedAt = table.Column(type: "timestamp with time zone", nullable: true), + SubmittedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + ReconciledAt = table.Column(type: "timestamp with time zone", nullable: true), + ReconciledBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OfferingSessions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Givings", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MemberId = table.Column(type: "integer", nullable: true), + GivingCategoryId = table.Column(type: "integer", nullable: false), + OfferingSessionId = table.Column(type: "integer", nullable: true), + Amount = table.Column(type: "numeric(18,2)", nullable: false), + PaymentMethod = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + CheckNumber = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ZelleReferenceCode = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + PayPalTransactionId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + GivingDate = table.Column(type: "date", nullable: false), + IsAnonymous = table.Column(type: "boolean", nullable: false), + Notes = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Givings", x => x.Id); + table.ForeignKey( + name: "FK_Givings_GivingCategories_GivingCategoryId", + column: x => x.GivingCategoryId, + principalTable: "GivingCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_Givings_Members_MemberId", + column: x => x.MemberId, + principalTable: "Members", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Givings_OfferingSessions_OfferingSessionId", + column: x => x.OfferingSessionId, + principalTable: "OfferingSessions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Givings_GivingCategoryId", + table: "Givings", + column: "GivingCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_Givings_GivingDate", + table: "Givings", + column: "GivingDate"); + + migrationBuilder.CreateIndex( + name: "IX_Givings_MemberId_GivingDate", + table: "Givings", + columns: new[] { "MemberId", "GivingDate" }); + + migrationBuilder.CreateIndex( + name: "IX_Givings_OfferingSessionId", + table: "Givings", + column: "OfferingSessionId", + filter: "\"OfferingSessionId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OfferingSessions_SessionDate", + table: "OfferingSessions", + column: "SessionDate", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Givings"); + + migrationBuilder.DropTable( + name: "GivingCategories"); + + migrationBuilder.DropTable( + name: "OfferingSessions"); + } + } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 62a4b23..12dba9e 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -283,6 +283,135 @@ namespace ROLAC.API.Migrations b.ToTable("FamilyUnits"); }); + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GivingCategoryId") + .HasColumnType("integer"); + + b.Property("GivingDate") + .HasColumnType("date"); + + b.Property("IsAnonymous") + .HasColumnType("boolean"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OfferingSessionId") + .HasColumnType("integer"); + + b.Property("PayPalTransactionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZelleReferenceCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("GivingCategoryId"); + + b.HasIndex("GivingDate"); + + b.HasIndex("OfferingSessionId") + .HasFilter("\"OfferingSessionId\" IS NOT NULL"); + + b.HasIndex("MemberId", "GivingDate"); + + b.ToTable("Givings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description_en") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Description_zh") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("GivingCategories"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Member", b => { b.Property("Id") @@ -428,6 +557,77 @@ namespace ROLAC.API.Migrations b.ToTable("Members"); }); + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CashTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Difference") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReconciledBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SessionDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Draft"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SystemTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("SessionDate") + .IsUnique(); + + b.ToTable("OfferingSessions"); + }); + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => { b.Property("Id") @@ -528,6 +728,31 @@ namespace ROLAC.API.Migrations .IsRequired(); }); + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory") + .WithMany() + .HasForeignKey("GivingCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession") + .WithMany("Givings") + .HasForeignKey("OfferingSessionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GivingCategory"); + + b.Navigation("Member"); + + b.Navigation("OfferingSession"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Member", b => { b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit") @@ -553,6 +778,11 @@ namespace ROLAC.API.Migrations { b.Navigation("RefreshTokens"); }); + + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Navigation("Givings"); + }); #pragma warning restore 612, 618 } } diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index c5086fb..ab80cb9 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -118,6 +118,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC diff --git a/API/ROLAC.API/Services/GivingCategoryService.cs b/API/ROLAC.API/Services/GivingCategoryService.cs new file mode 100644 index 0000000..8b964ee --- /dev/null +++ b/API/ROLAC.API/Services/GivingCategoryService.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class GivingCategoryService : IGivingCategoryService +{ + private readonly AppDbContext _db; + public GivingCategoryService(AppDbContext db) => _db = db; + + public async Task> GetAllAsync(bool includeInactive) + { + var query = _db.GivingCategories.AsNoTracking().AsQueryable(); + if (!includeInactive) query = query.Where(c => c.IsActive); + + return await query + .OrderBy(c => c.SortOrder).ThenBy(c => c.Name_en) + .Select(c => new GivingCategoryDto + { + Id = c.Id, Name_en = c.Name_en, Name_zh = c.Name_zh, + Description_en = c.Description_en, Description_zh = c.Description_zh, + IsActive = c.IsActive, SortOrder = c.SortOrder, + }) + .ToListAsync(); + } + + public async Task CreateAsync(CreateGivingCategoryRequest r) + { + var entity = new GivingCategory + { + 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, + }; + _db.GivingCategories.Add(entity); + await _db.SaveChangesAsync(); + return entity.Id; + } + + public async Task UpdateAsync(int id, UpdateGivingCategoryRequest r) + { + var c = await _db.GivingCategories.FindAsync(id) + ?? throw new KeyNotFoundException($"GivingCategory {id} not found."); + c.Name_en = r.Name_en; c.Name_zh = r.Name_zh; + c.Description_en = r.Description_en; c.Description_zh = r.Description_zh; + c.IsActive = r.IsActive; c.SortOrder = r.SortOrder; + await _db.SaveChangesAsync(); + } + + public async Task DeactivateAsync(int id) + { + var c = await _db.GivingCategories.FindAsync(id) + ?? throw new KeyNotFoundException($"GivingCategory {id} not found."); + c.IsActive = false; + await _db.SaveChangesAsync(); + } +} diff --git a/API/ROLAC.API/Services/GivingService.cs b/API/ROLAC.API/Services/GivingService.cs new file mode 100644 index 0000000..f9ed2a3 --- /dev/null +++ b/API/ROLAC.API/Services/GivingService.cs @@ -0,0 +1,132 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class GivingService : IGivingService +{ + private readonly AppDbContext _db; + public GivingService(AppDbContext db) => _db = db; + + public async Task> GetPagedAsync( + int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to) + { + var query = _db.Givings.AsNoTracking().AsQueryable(); + + if (categoryId.HasValue) query = query.Where(g => g.GivingCategoryId == categoryId.Value); + if (from.HasValue) query = query.Where(g => g.GivingDate >= from.Value); + if (to.HasValue) query = query.Where(g => g.GivingDate <= to.Value); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + var term = search.Trim(); + query = query.Where(g => + (g.CheckNumber != null && g.CheckNumber.ToLower().Contains(s)) || + (g.Notes != null && g.Notes.ToLower().Contains(s)) || + (g.Member != null && ( + (g.Member.FirstName_en + " " + g.Member.LastName_en).ToLower().Contains(s) || + (g.Member.FirstName_zh != null && g.Member.FirstName_zh.Contains(term)) || + (g.Member.LastName_zh != null && g.Member.LastName_zh.Contains(term))))); + } + + var total = await query.CountAsync(); + var rows = await query + .OrderByDescending(g => g.GivingDate).ThenByDescending(g => g.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + var catNames = await _db.GivingCategories.AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name_en); + var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.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}"); + + var items = rows.Select(g => new GivingListItemDto + { + Id = g.Id, MemberId = g.MemberId, + MemberName = g.MemberId != null && memberNames.TryGetValue(g.MemberId.Value, out var n) ? n : null, + GivingCategoryId = g.GivingCategoryId, + CategoryName = catNames.TryGetValue(g.GivingCategoryId, out var cn) ? cn : "", + Amount = g.Amount, PaymentMethod = g.PaymentMethod, + GivingDate = g.GivingDate.ToString("yyyy-MM-dd"), + IsAnonymous = g.IsAnonymous, OfferingSessionId = g.OfferingSessionId, + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + public async Task GetByIdAsync(int id) + { + var g = await _db.Givings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (g is null) return null; + + string? memberName = null; + if (g.MemberId != null) + memberName = await _db.Members.AsNoTracking() + .Where(m => m.Id == g.MemberId) + .Select(m => m.FirstName_en + " " + m.LastName_en) + .FirstOrDefaultAsync(); + + return new GivingDto + { + Id = g.Id, MemberId = g.MemberId, MemberName = memberName, + GivingCategoryId = g.GivingCategoryId, OfferingSessionId = g.OfferingSessionId, + Amount = g.Amount, PaymentMethod = g.PaymentMethod, CheckNumber = g.CheckNumber, + ZelleReferenceCode = g.ZelleReferenceCode, PayPalTransactionId = g.PayPalTransactionId, + GivingDate = g.GivingDate, IsAnonymous = g.IsAnonymous, Notes = g.Notes, + }; + } + + public async Task CreateAsync(CreateGivingRequest r) + { + var g = MapFromRequest(new Giving(), r); + g.OfferingSessionId = null; + _db.Givings.Add(g); + await _db.SaveChangesAsync(); + return g.Id; + } + + public async Task UpdateAsync(int id, UpdateGivingRequest r) + { + var g = await _db.Givings.FindAsync(id) + ?? throw new KeyNotFoundException($"Giving {id} not found."); + await GuardSessionNotLockedAsync(g.OfferingSessionId); + MapFromRequest(g, r); + await _db.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var g = await _db.Givings.FindAsync(id) + ?? throw new KeyNotFoundException($"Giving {id} not found."); + await GuardSessionNotLockedAsync(g.OfferingSessionId); + _db.Givings.Remove(g); + await _db.SaveChangesAsync(); + } + + private async Task GuardSessionNotLockedAsync(int? sessionId) + { + if (sessionId is null) return; + var status = await _db.OfferingSessions + .Where(s => s.Id == sessionId).Select(s => s.Status).FirstOrDefaultAsync(); + if (status is "Submitted" or "Reconciled") + throw new InvalidOperationException( + "This giving belongs to a locked offering session. Reopen the session to edit."); + } + + private static Giving MapFromRequest(Giving g, CreateGivingRequest r) + { + g.MemberId = r.IsAnonymous ? null : r.MemberId; + g.GivingCategoryId = r.GivingCategoryId; + g.Amount = r.Amount; g.PaymentMethod = r.PaymentMethod; + g.CheckNumber = r.CheckNumber; g.ZelleReferenceCode = r.ZelleReferenceCode; + g.PayPalTransactionId = r.PayPalTransactionId; g.GivingDate = r.GivingDate; + g.IsAnonymous = r.IsAnonymous; g.Notes = r.Notes; + return g; + } +} diff --git a/API/ROLAC.API/Services/IGivingCategoryService.cs b/API/ROLAC.API/Services/IGivingCategoryService.cs new file mode 100644 index 0000000..6a67372 --- /dev/null +++ b/API/ROLAC.API/Services/IGivingCategoryService.cs @@ -0,0 +1,11 @@ +using ROLAC.API.DTOs.Giving; + +namespace ROLAC.API.Services; + +public interface IGivingCategoryService +{ + Task> GetAllAsync(bool includeInactive); + Task CreateAsync(CreateGivingCategoryRequest request); + Task UpdateAsync(int id, UpdateGivingCategoryRequest request); + Task DeactivateAsync(int id); // soft-disable: IsActive = false +} diff --git a/API/ROLAC.API/Services/IGivingService.cs b/API/ROLAC.API/Services/IGivingService.cs new file mode 100644 index 0000000..8cfcf1b --- /dev/null +++ b/API/ROLAC.API/Services/IGivingService.cs @@ -0,0 +1,14 @@ +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IGivingService +{ + Task> GetPagedAsync( + int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to); + Task GetByIdAsync(int id); + Task CreateAsync(CreateGivingRequest request); + Task UpdateAsync(int id, UpdateGivingRequest request); + Task DeleteAsync(int id); +} diff --git a/API/ROLAC.API/Services/IOfferingSessionService.cs b/API/ROLAC.API/Services/IOfferingSessionService.cs new file mode 100644 index 0000000..20a318b --- /dev/null +++ b/API/ROLAC.API/Services/IOfferingSessionService.cs @@ -0,0 +1,15 @@ +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IOfferingSessionService +{ + Task> GetPagedAsync( + int page, int pageSize, DateOnly? from, DateOnly? to); + Task GetByIdAsync(int id); + Task DateExistsAsync(DateOnly date); + Task CreateAsync(CreateOfferingSessionRequest request); + Task ReopenAsync(int id); + Task ReplaceAsync(int id, CreateOfferingSessionRequest request); +} diff --git a/API/ROLAC.API/Services/OfferingSessionService.cs b/API/ROLAC.API/Services/OfferingSessionService.cs new file mode 100644 index 0000000..c90b368 --- /dev/null +++ b/API/ROLAC.API/Services/OfferingSessionService.cs @@ -0,0 +1,174 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class OfferingSessionService : IOfferingSessionService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + + public OfferingSessionService(AppDbContext db, IHttpContextAccessor http) + { + _db = db; + _http = http; + } + + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + + public async Task> GetPagedAsync( + int page, int pageSize, DateOnly? from, DateOnly? to) + { + var query = _db.OfferingSessions.AsNoTracking().AsQueryable(); + if (from.HasValue) query = query.Where(s => s.SessionDate >= from.Value); + if (to.HasValue) query = query.Where(s => s.SessionDate <= to.Value); + + var total = await query.CountAsync(); + var rows = await query + .OrderByDescending(s => s.SessionDate) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + var ids = rows.Select(r => r.Id).ToList(); + var counts = await _db.Givings.AsNoTracking() + .Where(g => g.OfferingSessionId != null && ids.Contains(g.OfferingSessionId.Value)) + .GroupBy(g => g.OfferingSessionId!.Value) + .Select(grp => new { Id = grp.Key, Count = grp.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Count); + + var items = rows.Select(s => new OfferingSessionListItemDto + { + Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status, + CashTotal = s.CashTotal, CheckTotal = s.CheckTotal, + SystemTotal = s.SystemTotal, Difference = s.Difference, + LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0, + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + public async Task DateExistsAsync(DateOnly date) + => await _db.OfferingSessions.AnyAsync(s => s.SessionDate == date); + + // Distinguishes a unique-index collision on SessionDate (concurrent insert) from other DB errors. + private async Task DateExistsConcurrentlyAsync(DateOnly date, int excludeId) + => await _db.OfferingSessions.AsNoTracking().AnyAsync(s => s.SessionDate == date && s.Id != excludeId); + + public async Task GetByIdAsync(int id) + { + var s = await _db.OfferingSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (s is null) return null; + + var lines = await _db.Givings.AsNoTracking() + .Where(g => g.OfferingSessionId == id).ToListAsync(); + + var catNames = await _db.GivingCategories.AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name_en); + var memberIds = lines.Where(l => l.MemberId != null).Select(l => l.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}"); + + return new OfferingSessionDto + { + Id = s.Id, SessionDate = s.SessionDate, Status = s.Status, + CashTotal = s.CashTotal, CheckTotal = s.CheckTotal, + SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes, + Givings = lines.Select(l => new OfferingGivingLineDto + { + Id = l.Id, MemberId = l.MemberId, + MemberName = l.MemberId != null && memberNames.TryGetValue(l.MemberId.Value, out var n) ? n : null, + GivingCategoryId = l.GivingCategoryId, + CategoryName = catNames.TryGetValue(l.GivingCategoryId, out var cn) ? cn : "", + Amount = l.Amount, PaymentMethod = l.PaymentMethod, + CheckNumber = l.CheckNumber, + ZelleReferenceCode = l.ZelleReferenceCode, + PayPalTransactionId = l.PayPalTransactionId, + IsAnonymous = l.IsAnonymous, Notes = l.Notes, + }).ToList(), + }; + } + + public async Task CreateAsync(CreateOfferingSessionRequest r) + { + if (await DateExistsAsync(r.SessionDate)) + throw new InvalidOperationException($"An offering session for {r.SessionDate:yyyy-MM-dd} already exists."); + + var systemTotal = r.Givings.Sum(g => g.Amount); + var session = new OfferingSession + { + SessionDate = r.SessionDate, Status = "Submitted", + CashTotal = r.CashTotal, CheckTotal = r.CheckTotal, + SystemTotal = systemTotal, + Difference = (r.CashTotal + r.CheckTotal) - systemTotal, + Notes = r.Notes, + SubmittedAt = DateTimeOffset.UtcNow, SubmittedBy = CurrentUserId, + Givings = r.Givings.Select(line => MapLine(line, r.SessionDate)).ToList(), + }; + _db.OfferingSessions.Add(session); + try + { + await _db.SaveChangesAsync(); + } + catch (DbUpdateException) + { + if (await DateExistsConcurrentlyAsync(r.SessionDate, session.Id)) + throw new InvalidOperationException($"An offering session for {r.SessionDate:yyyy-MM-dd} already exists."); + throw; + } + return session.Id; + } + + public async Task ReopenAsync(int id) + { + var s = await _db.OfferingSessions.FindAsync(id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + if (s.Status != "Submitted") + throw new InvalidOperationException($"Only a Submitted session can be reopened (current: {s.Status})."); + s.Status = "Draft"; + s.SubmittedAt = null; s.SubmittedBy = null; + await _db.SaveChangesAsync(); + } + + public async Task ReplaceAsync(int id, CreateOfferingSessionRequest r) + { + var s = await _db.OfferingSessions + .Include(x => x.Givings) + .FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + if (s.Status != "Draft") + throw new InvalidOperationException($"Only a Draft (reopened) session can be edited (current: {s.Status})."); + + _db.Givings.RemoveRange(s.Givings); + var systemTotal = r.Givings.Sum(g => g.Amount); + s.CashTotal = r.CashTotal; s.CheckTotal = r.CheckTotal; + s.SystemTotal = systemTotal; + s.Difference = (r.CashTotal + r.CheckTotal) - systemTotal; + s.Notes = r.Notes; + s.Status = "Submitted"; + s.SubmittedAt = DateTimeOffset.UtcNow; s.SubmittedBy = CurrentUserId; + s.Givings = r.Givings.Select(line => MapLine(line, s.SessionDate)).ToList(); + await _db.SaveChangesAsync(); + } + + private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new() + { + MemberId = line.IsAnonymous ? null : line.MemberId, + GivingCategoryId = line.GivingCategoryId, + Amount = line.Amount, + PaymentMethod = line.PaymentMethod, + CheckNumber = line.CheckNumber, + ZelleReferenceCode = line.ZelleReferenceCode, + PayPalTransactionId = line.PayPalTransactionId, + GivingDate = sessionDate, + IsAnonymous = line.IsAnonymous, + Notes = line.Notes, + }; +} diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 2b02596..f9d6097 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -6,6 +6,9 @@ import { AuthGuard } from './core/guards/auth.guard'; import { RoleGuard } from './core/guards/role.guard'; import { MembersPageComponent } from './features/members/pages/members-page/members-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'; +import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component'; export const routes: Routes = [ // Public routes @@ -26,6 +29,24 @@ export const routes: Routes = [ canActivate: [RoleGuard], data: { roles: ['super_admin'] }, }, + { + path: 'finance/giving-categories', + component: GivingCategoriesPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/givings', + component: GivingsPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/offering-session', + component: OfferingSessionPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, ] }, diff --git a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html new file mode 100644 index 0000000..5e5f1c1 --- /dev/null +++ b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.html @@ -0,0 +1,13 @@ + +
+ + + + + +
+ + + + +
diff --git a/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts new file mode 100644 index 0000000..caf7de5 --- /dev/null +++ b/APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts @@ -0,0 +1,76 @@ +import { Component, EventEmitter, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { CreateMemberRequest, MemberListItemDto } from '../../../members/models/member.model'; + +@Component({ + selector: 'app-member-quick-add-dialog', + standalone: true, + imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule], + templateUrl: './member-quick-add-dialog.component.html', +}) +export class MemberQuickAddDialogComponent { + @Output() created = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + firstName_en = ''; + lastName_en = ''; + firstName_zh: string | null = null; + lastName_zh: string | null = null; + phoneCell: string | null = null; + saving = false; + + constructor(private memberApi: MemberApiService) {} + + save(): void { + if (!this.firstName_en || !this.lastName_en) return; + this.saving = true; + const req: CreateMemberRequest = { + firstName_en: this.firstName_en, + lastName_en: this.lastName_en, + nickName: null, + firstName_zh: this.firstName_zh, + lastName_zh: this.lastName_zh, + gender: null, + dateOfBirth: null, + baptismDate: null, + baptismChurch: null, + email: null, + phoneCell: this.phoneCell, + phoneHome: null, + address: null, + city: null, + state: null, + zipCode: null, + country: 'USA', + status: 'Visitor', + languagePreference: 'en', + joinDate: null, + notes: null, + familyUnitId: null, + }; + this.memberApi.create(req).subscribe({ + next: ({ id }) => { + this.saving = false; + this.created.emit({ + id, + firstName_en: this.firstName_en, + lastName_en: this.lastName_en, + nickName: null, + firstName_zh: this.firstName_zh, + lastName_zh: this.lastName_zh, + status: 'Visitor', + email: null, + phoneCell: this.phoneCell, + joinDate: null, + linkedUserId: null, + }); + }, + error: () => { this.saving = false; }, + }); + } +} diff --git a/APP/src/app/features/giving/models/giving.model.ts b/APP/src/app/features/giving/models/giving.model.ts new file mode 100644 index 0000000..c595c64 --- /dev/null +++ b/APP/src/app/features/giving/models/giving.model.ts @@ -0,0 +1,119 @@ +export type PaymentMethod = 'Cash' | 'Check' | 'Zelle' | 'PayPal' | 'Other'; +export type SessionStatus = 'Draft' | 'Submitted' | 'Reconciled'; + +export interface PagedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +// ── Giving categories ───────────────────────────────────────────── +export interface GivingCategoryDto { + id: number; + name_en: string; + name_zh: string | null; + description_en: string | null; + description_zh: string | null; + isActive: boolean; + sortOrder: number; +} +export interface CreateGivingCategoryRequest { + name_en: string; + name_zh: string | null; + description_en: string | null; + description_zh: string | null; + sortOrder: number; +} +export interface UpdateGivingCategoryRequest extends CreateGivingCategoryRequest { + isActive: boolean; +} + +// ── Single giving ───────────────────────────────────────────────── +export interface GivingListItemDto { + id: number; + memberId: number | null; + memberName: string | null; + givingCategoryId: number; + categoryName: string; + amount: number; + paymentMethod: PaymentMethod; + givingDate: string; // yyyy-MM-dd + isAnonymous: boolean; + offeringSessionId: number | null; +} +export interface CreateGivingRequest { + memberId: number | null; + givingCategoryId: number; + amount: number; + paymentMethod: PaymentMethod; + checkNumber: string | null; + zelleReferenceCode: string | null; + payPalTransactionId: string | null; + givingDate: string; // yyyy-MM-dd + isAnonymous: boolean; + notes: string | null; +} +export type UpdateGivingRequest = CreateGivingRequest; + +// ── Offering session (batch) ────────────────────────────────────── +export interface OfferingGivingLineRequest { + memberId: number | null; + givingCategoryId: number; + amount: number; + paymentMethod: PaymentMethod; + checkNumber: string | null; + zelleReferenceCode: string | null; + payPalTransactionId: string | null; + isAnonymous: boolean; + notes: string | null; +} +export interface CreateOfferingSessionRequest { + sessionDate: string; // yyyy-MM-dd + cashTotal: number; + checkTotal: number; + notes: string | null; + givings: OfferingGivingLineRequest[]; +} +export interface OfferingGivingLineDto { + id: number; + memberId: number | null; + memberName: string | null; + givingCategoryId: number; + categoryName: string; + amount: number; + paymentMethod: PaymentMethod; + checkNumber: string | null; + zelleReferenceCode: string | null; + payPalTransactionId: string | null; + isAnonymous: boolean; + notes: string | null; +} +export interface OfferingSessionDto { + id: number; + sessionDate: string; + status: SessionStatus; + cashTotal: number; + checkTotal: number; + systemTotal: number; + difference: number; + notes: string | null; + givings: OfferingGivingLineDto[]; +} +export interface OfferingSessionListItemDto { + id: number; + sessionDate: string; + status: SessionStatus; + cashTotal: number; + checkTotal: number; + systemTotal: number; + difference: number; + lineCount: number; +} + +/** A row held in the client-side batch buffer before submit. */ +export interface OfferingBufferLine extends OfferingGivingLineRequest { + memberName: string | null; // for display only + categoryName: string; // for display only +} diff --git a/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.html b/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.html new file mode 100644 index 0000000..79bdd68 --- /dev/null +++ b/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.html @@ -0,0 +1,58 @@ +
+ + + + + + + + {{ c.isActive ? 'Yes' : 'No' }} + + + + + + + + + + +
+ + + + + + +
+ + + + +
+
diff --git a/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.scss b/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.scss new file mode 100644 index 0000000..e249197 --- /dev/null +++ b/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.scss @@ -0,0 +1,31 @@ +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.header-actions { + display: flex; + align-items: center; + gap: 1rem; +} + +.inactive-toggle { + display: flex; + align-items: center; + gap: 0.25rem; + cursor: pointer; +} + +.form-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.form-grid label { + display: flex; + flex-direction: column; + gap: 0.25rem; +} diff --git a/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.ts b/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.ts new file mode 100644 index 0000000..3d61cf5 --- /dev/null +++ b/APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.ts @@ -0,0 +1,74 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { GivingCategoryApiService } from '../../services/giving-category-api.service'; +import { + GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest, +} from '../../models/giving.model'; + +@Component({ + selector: 'app-giving-categories-page', + standalone: true, + imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, DialogsModule], + templateUrl: './giving-categories-page.component.html', + styleUrls: ['./giving-categories-page.component.scss'], +}) +export class GivingCategoriesPageComponent implements OnInit { + data: GivingCategoryDto[] = []; + isLoading = false; + includeInactive = false; + + showDialog = false; + editing: GivingCategoryDto | null = null; + form: UpdateGivingCategoryRequest = this.blankForm(); + + constructor(private api: GivingCategoryApiService) {} + + ngOnInit(): void { this.load(); } + + load(): void { + this.isLoading = true; + this.api.getAll(this.includeInactive).subscribe({ + next: rows => { this.data = rows; this.isLoading = false; }, + error: () => { this.isLoading = false; }, + }); + } + + openAdd(): void { this.editing = null; this.form = this.blankForm(); this.showDialog = true; } + + openEdit(c: GivingCategoryDto): void { + this.editing = c; + this.form = { + name_en: c.name_en, name_zh: c.name_zh, + description_en: c.description_en, description_zh: c.description_zh, + isActive: c.isActive, sortOrder: c.sortOrder, + }; + this.showDialog = true; + } + + save(): void { + if (this.editing) { + this.api.update(this.editing.id, this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } else { + const create: CreateGivingCategoryRequest = { + name_en: this.form.name_en, name_zh: this.form.name_zh, + description_en: this.form.description_en, description_zh: this.form.description_zh, + sortOrder: this.form.sortOrder, + }; + this.api.create(create).subscribe(() => { this.showDialog = false; this.load(); }); + } + } + + deactivate(c: GivingCategoryDto): void { + if (!confirm(`Deactivate "${c.name_en}"?`)) return; + this.api.deactivate(c.id).subscribe(() => this.load()); + } + + private blankForm(): UpdateGivingCategoryRequest { + return { name_en: '', name_zh: null, description_en: null, description_zh: null, isActive: true, sortOrder: 0 }; + } +} diff --git a/APP/src/app/features/giving/pages/givings-page/givings-page.component.html b/APP/src/app/features/giving/pages/givings-page/givings-page.component.html new file mode 100644 index 0000000..c5861a1 --- /dev/null +++ b/APP/src/app/features/giving/pages/givings-page/givings-page.component.html @@ -0,0 +1,85 @@ +
+ + +
+ + + +
+ + + + + {{ g.isAnonymous ? '(Anonymous)' : g.memberName }} + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+
diff --git a/APP/src/app/features/giving/pages/givings-page/givings-page.component.scss b/APP/src/app/features/giving/pages/givings-page/givings-page.component.scss new file mode 100644 index 0000000..2663f68 --- /dev/null +++ b/APP/src/app/features/giving/pages/givings-page/givings-page.component.scss @@ -0,0 +1,4 @@ +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; } +.form-grid { display: flex; flex-direction: column; gap: 0.75rem; } +.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; } diff --git a/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts b/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts new file mode 100644 index 0000000..3b333d5 --- /dev/null +++ b/APP/src/app/features/giving/pages/givings-page/givings-page.component.ts @@ -0,0 +1,157 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, GridDataResult, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; +import { GivingApiService } from '../../services/giving-api.service'; +import { GivingCategoryApiService } from '../../services/giving-category-api.service'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; +import { + GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult, +} from '../../models/giving.model'; + +/** Flattened member item with a single displayName field for the dropdown. */ +interface MemberOption { + id: number; + displayName: string; +} + +@Component({ + selector: 'app-givings-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, + DropDownsModule, DialogsModule, DateInputsModule, + ], + templateUrl: './givings-page.component.html', + styleUrls: ['./givings-page.component.scss'], +}) +export class GivingsPageComponent implements OnInit { + gridData: GridDataResult = { data: [], total: 0 }; + page = 1; + pageSize = 20; + isLoading = false; + + search = ''; + filterCategoryId: number | null = null; + categories: GivingCategoryDto[] = []; + + readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other']; + + memberResults: MemberOption[] = []; + + showDialog = false; + editingId: number | null = null; + form: CreateGivingRequest = this.blankForm(); + selectedMemberId: number | null = null; + + /** Separate Date field for kendo-datepicker (which binds Date, not string). */ + givingDateValue: Date = new Date(); + + constructor( + private api: GivingApiService, + private categoryApi: GivingCategoryApiService, + private memberApi: MemberApiService, + ) {} + + ngOnInit(): void { + this.categoryApi.getAll(false).subscribe(c => this.categories = c); + this.load(); + } + + load(): void { + this.isLoading = true; + this.api.getPaged({ + page: this.page, pageSize: this.pageSize, + search: this.search || undefined, + categoryId: this.filterCategoryId ?? undefined, + }).subscribe({ + next: (r: PagedResult) => { + this.gridData = { data: r.items, total: r.totalCount }; this.isLoading = false; + }, + error: () => { this.isLoading = false; }, + }); + } + + onPageChange(e: PageChangeEvent): void { + this.page = e.skip / this.pageSize + 1; this.pageSize = e.take; this.load(); + } + + onSearch(): void { this.page = 1; this.load(); } + + onMemberFilter(term: string): void { + if (!term || term.length < 1) { this.memberResults = []; return; } + this.memberApi.getPaged({ search: term, pageSize: 10 }) + .subscribe(r => { + this.memberResults = r.items.map((m: MemberListItemDto) => ({ + id: m.id, + displayName: memberDisplayName(m), + })); + }); + } + + openAdd(): void { + this.editingId = null; + this.form = this.blankForm(); + this.selectedMemberId = null; + this.givingDateValue = new Date(); + this.memberResults = []; + this.showDialog = true; + } + + /** Called from template valueChange — receives the primitive id (number | null). */ + onMemberIdSelected(id: number | null): void { + this.selectedMemberId = id ?? null; + this.form.memberId = this.selectedMemberId; + } + + toggleAnonymous(): void { + this.form.isAnonymous = !this.form.isAnonymous; + if (this.form.isAnonymous) { + this.form.memberId = null; + this.selectedMemberId = null; + } + } + + save(): void { + // Sync the datepicker Date back to the ISO string field. + this.form.givingDate = this.givingDateValue + ? this.givingDateValue.toISOString().slice(0, 10) + : new Date().toISOString().slice(0, 10); + + if (this.editingId) { + this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } else { + this.api.create(this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } + } + + delete(g: GivingListItemDto): void { + if (!confirm('Delete this giving record?')) return; + this.api.delete(g.id).subscribe({ + next: () => this.load(), + error: (err: { error?: { message?: string } }) => + alert(err?.error?.message ?? 'Delete failed (record may belong to a locked session).'), + }); + } + + private blankForm(): CreateGivingRequest { + return { + memberId: null, + givingCategoryId: this.categories[0]?.id ?? 0, + amount: 0, + paymentMethod: 'Cash', + checkNumber: null, + zelleReferenceCode: null, + payPalTransactionId: null, + givingDate: new Date().toISOString().slice(0, 10), + isAnonymous: false, + notes: null, + }; + } +} diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html new file mode 100644 index 0000000..deff8b5 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.html @@ -0,0 +1,92 @@ +
+ + +
+ Editing submitted session — make changes and click "Update Session". + +
+ +
+ An offering session for this date already exists. Pick another date, or reopen the existing session to edit. +
+ +
+ + Anonymous + + + + + + + +
+ + + +
+
+ + + + {{ l.isAnonymous ? '(Anonymous)' : l.memberName }} + + + + + + + + + + + + + +
+
Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}
+ + +
Difference: {{ difference | currency }}
+ +
+ +
+

Recent Sessions

+ + + + + + + + + + + + +
+ + +
diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss new file mode 100644 index 0000000..b79e630 --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.scss @@ -0,0 +1,11 @@ +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; } +.entry-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 1rem; } +.entry-row label { display: flex; flex-direction: column; gap: 0.25rem; } +.entry-actions { display: flex; gap: 0.5rem; } +.anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; } +.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; } +.reconcile .ok { color: green; font-weight: 600; } +.reconcile .bad { color: #c00; font-weight: 600; } +.edit-banner { display: flex; align-items: center; gap: 1rem; background: #fff3cd; border-left: 4px solid #f0a500; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; font-weight: 500; } +.sessions-list { margin-top: 2rem; } diff --git a/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts new file mode 100644 index 0000000..fc7f19e --- /dev/null +++ b/APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts @@ -0,0 +1,225 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Observable } from 'rxjs'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; +import { OfferingSessionApiService } from '../../services/offering-session-api.service'; +import { GivingCategoryApiService } from '../../services/giving-category-api.service'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; +import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component'; +import { + GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest, + OfferingSessionListItemDto, OfferingSessionDto, +} from '../../models/giving.model'; + +interface MemberOption { id: number; displayName: string; } + +@Component({ + selector: 'app-offering-session-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, + DropDownsModule, DateInputsModule, MemberQuickAddDialogComponent, + ], + templateUrl: './offering-session-page.component.html', + styleUrls: ['./offering-session-page.component.scss'], +}) +export class OfferingSessionPageComponent implements OnInit { + sessionDate: Date = new Date(); + dateConflict = false; + categories: GivingCategoryDto[] = []; + readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other']; + + memberResults: MemberOption[] = []; + selectedMemberId: number | null = null; + selectedMemberName: string | null = null; + entry = this.blankEntry(); + + buffer: OfferingBufferLine[] = []; + editingIndex: number | null = null; + + cashTotal = 0; + checkTotal = 0; + notes: string | null = null; + + showQuickAdd = false; + submitting = false; + + sessions: OfferingSessionListItemDto[] = []; + editingSessionId: number | null = null; + + constructor( + private api: OfferingSessionApiService, + private categoryApi: GivingCategoryApiService, + private memberApi: MemberApiService, + ) {} + + ngOnInit(): void { + this.categoryApi.getAll(false).subscribe(c => { + this.categories = c; + this.entry.givingCategoryId = c[0]?.id ?? 0; + }); + this.checkDate(); + this.loadSessions(); + } + + get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); } + get difference(): number { return (this.cashTotal + this.checkTotal) - this.systemTotal; } + + checkDate(): void { + this.api.checkDate(this.toIso(this.sessionDate)).subscribe(r => this.dateConflict = r.exists); + } + + loadSessions(): void { + this.api.getPaged(1, 20).subscribe(r => this.sessions = r.items); + } + + reopenAndEdit(s: OfferingSessionListItemDto): void { + if (s.status !== 'Submitted') return; + this.api.reopen(s.id).subscribe({ + next: () => this.api.getById(s.id).subscribe(dto => this.loadIntoBuffer(dto)), + error: (err: { error?: { message?: string } }) => alert(err?.error?.message ?? 'Reopen failed.'), + }); + } + + private loadIntoBuffer(dto: OfferingSessionDto): void { + this.editingSessionId = dto.id; + this.sessionDate = new Date(dto.sessionDate + 'T00:00:00'); + this.dateConflict = false; + this.cashTotal = dto.cashTotal; + this.checkTotal = dto.checkTotal; + this.notes = dto.notes; + this.buffer = dto.givings.map(g => ({ + memberId: g.memberId, givingCategoryId: g.givingCategoryId, amount: g.amount, + paymentMethod: g.paymentMethod, checkNumber: g.checkNumber, + zelleReferenceCode: g.zelleReferenceCode, payPalTransactionId: g.payPalTransactionId, + isAnonymous: g.isAnonymous, notes: g.notes, + memberName: g.memberName, categoryName: g.categoryName, + })); + this.resetEntry(); + } + + cancelEdit(): void { + this.editingSessionId = null; + this.editingIndex = null; + this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; + this.sessionDate = new Date(); + this.checkDate(); + } + + onMemberFilter(term: string): void { + if (!term) { this.memberResults = []; return; } + this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r => + this.memberResults = r.items.map((m: MemberListItemDto) => ({ id: m.id, displayName: memberDisplayName(m) }))); + } + + onMemberSelected(id: number | null): void { + this.selectedMemberId = id ?? null; + this.entry.memberId = this.selectedMemberId; + this.selectedMemberName = this.memberResults.find(m => m.id === id)?.displayName ?? null; + if (id != null) this.entry.isAnonymous = false; + } + + markAnonymous(): void { + this.entry.isAnonymous = true; this.entry.memberId = null; + this.selectedMemberId = null; this.selectedMemberName = null; + } + + addLine(): void { + if (this.entry.amount <= 0) return; + if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return; + const cat = this.categories.find(c => c.id === this.entry.givingCategoryId); + const line: OfferingBufferLine = { + ...this.entry, + memberName: this.entry.isAnonymous ? null : this.selectedMemberName, + categoryName: cat?.name_en ?? '', + }; + if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; } + else { this.buffer = [...this.buffer, line]; } + this.resetEntry(); + } + + editLine(i: number): void { + const l = this.buffer[i]; + this.entry = { ...l }; + this.selectedMemberId = l.memberId; + this.selectedMemberName = l.memberName; + // Seed the dropdown so the giver name stays visible while editing the line. + this.memberResults = (l.memberId && l.memberName) + ? [{ id: l.memberId, displayName: l.memberName }] + : []; + this.editingIndex = i; + } + + removeLine(i: number): void { this.buffer = this.buffer.filter((_, idx) => idx !== i); } + + onMemberQuickCreated(m: MemberListItemDto): void { + this.showQuickAdd = false; + const opt: MemberOption = { id: m.id, displayName: memberDisplayName(m) }; + this.memberResults = [opt, ...this.memberResults.filter(x => x.id !== m.id)]; + this.onMemberSelected(m.id); + } + + submit(): void { + if (this.buffer.length === 0 || (this.editingSessionId == null && this.dateConflict)) return; + this.submitting = true; + const req: CreateOfferingSessionRequest = { + sessionDate: this.toIso(this.sessionDate), + cashTotal: this.cashTotal, + checkTotal: this.checkTotal, + notes: this.notes, + givings: this.buffer.map(l => ({ + memberId: l.memberId, + givingCategoryId: l.givingCategoryId, + amount: l.amount, + paymentMethod: l.paymentMethod, + checkNumber: l.checkNumber, + zelleReferenceCode: l.zelleReferenceCode, + payPalTransactionId: l.payPalTransactionId, + isAnonymous: l.isAnonymous, + notes: l.notes, + })), + }; + const obs: Observable = this.editingSessionId != null + ? this.api.replace(this.editingSessionId, req) + : this.api.create(req); + obs.subscribe({ + next: () => { + this.submitting = false; + alert(this.editingSessionId != null ? 'Offering session updated.' : 'Offering session submitted.'); + this.editingSessionId = null; + this.editingIndex = null; + this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; + this.sessionDate = new Date(); + this.checkDate(); + this.loadSessions(); + }, + error: (err: { error?: { message?: string } }) => { + this.submitting = false; + alert(err?.error?.message ?? 'Submit failed.'); + }, + }); + } + + private resetEntry(): void { + this.editingIndex = null; + this.selectedMemberId = null; this.selectedMemberName = null; this.memberResults = []; + this.entry = this.blankEntry(); + this.entry.givingCategoryId = this.categories[0]?.id ?? 0; + } + + private blankEntry(): OfferingBufferLine { + return { + memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash', + checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null, + isAnonymous: false, notes: null, memberName: null, categoryName: '', + }; + } + + private toIso(d: Date): string { return d.toISOString().slice(0, 10); } +} diff --git a/APP/src/app/features/giving/services/giving-api.service.ts b/APP/src/app/features/giving/services/giving-api.service.ts new file mode 100644 index 0000000..8800706 --- /dev/null +++ b/APP/src/app/features/giving/services/giving-api.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + GivingListItemDto, CreateGivingRequest, UpdateGivingRequest, PagedResult, +} from '../models/giving.model'; + +export interface GivingQuery { + page?: number; pageSize?: number; search?: string; + categoryId?: number; from?: string; to?: string; +} + +@Injectable({ providedIn: 'root' }) +export class GivingApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('givings'); + } + + getPaged(q: GivingQuery = {}): Observable> { + let p = new HttpParams().set('page', q.page ?? 1).set('pageSize', q.pageSize ?? 20); + if (q.search) p = p.set('search', q.search); + if (q.categoryId != null) p = p.set('categoryId', q.categoryId); + if (q.from) p = p.set('from', q.from); + if (q.to) p = p.set('to', q.to); + return this.http.get>(this.endpoint, { params: p }); + } + create(request: CreateGivingRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + update(id: number, request: UpdateGivingRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} diff --git a/APP/src/app/features/giving/services/giving-category-api.service.ts b/APP/src/app/features/giving/services/giving-category-api.service.ts new file mode 100644 index 0000000..e8029ab --- /dev/null +++ b/APP/src/app/features/giving/services/giving-category-api.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest, +} from '../models/giving.model'; + +@Injectable({ providedIn: 'root' }) +export class GivingCategoryApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('giving-categories'); + } + + getAll(includeInactive = false): Observable { + const params = new HttpParams().set('includeInactive', includeInactive); + return this.http.get(this.endpoint, { params }); + } + create(request: CreateGivingCategoryRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + update(id: number, request: UpdateGivingCategoryRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + deactivate(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} diff --git a/APP/src/app/features/giving/services/offering-session-api.service.ts b/APP/src/app/features/giving/services/offering-session-api.service.ts new file mode 100644 index 0000000..8b54a80 --- /dev/null +++ b/APP/src/app/features/giving/services/offering-session-api.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + OfferingSessionDto, OfferingSessionListItemDto, + CreateOfferingSessionRequest, PagedResult, +} from '../models/giving.model'; + +@Injectable({ providedIn: 'root' }) +export class OfferingSessionApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('offering-sessions'); + } + + getPaged(page = 1, pageSize = 20): Observable> { + const params = new HttpParams().set('page', page).set('pageSize', pageSize); + return this.http.get>(this.endpoint, { params }); + } + getById(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + checkDate(date: string): Observable<{ exists: boolean }> { + const params = new HttpParams().set('date', date); + return this.http.get<{ exists: boolean }>(`${this.endpoint}/check-date`, { params }); + } + create(request: CreateOfferingSessionRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + reopen(id: number): Observable { + return this.http.post(`${this.endpoint}/${id}/reopen`, {}); + } + replace(id: number, request: CreateOfferingSessionRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } +} diff --git a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html index 01f88c7..40e4bcf 100644 --- a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html +++ b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.html @@ -51,6 +51,15 @@ + + diff --git a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts index 33f484a..1ac5a30 100644 --- a/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts +++ b/APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts @@ -68,8 +68,15 @@ export class UserNavbarComponent implements OnInit, OnDestroy { { text: 'User Management', icon: userIcon, path: '/user-portal/admin/users' }, ]; + public financeNavItems: NavItem[] = [ + { text: 'Offering Entry', icon: this.creditCardIcon, path: '/user-portal/finance/offering-session' }, + { text: 'Givings', icon: this.chartIcon, path: '/user-portal/finance/givings' }, + { text: 'Giving Types', icon: this.buildingIcon, path: '/user-portal/finance/giving-categories' }, + ]; + public showMemberAdminSection = false; public showUserAdminSection = false; + public showFinanceSection = false; private destroy$ = new Subject(); @@ -97,6 +104,7 @@ export class UserNavbarComponent implements OnInit, OnDestroy { const roles = user?.roles ?? []; this.showMemberAdminSection = roles.some(r => r === 'super_admin' || r === 'secretary'); this.showUserAdminSection = roles.includes('super_admin'); + this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin'); }); } @@ -113,12 +121,12 @@ export class UserNavbarComponent implements OnInit, OnDestroy { private updateActiveStates(currentUrl: string): void { // Reset all active states [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, - ...this.memberAdminNavItems, ...this.userAdminNavItems] + ...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems] .forEach(item => item.active = false); // Set active state for current route const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, - ...this.memberAdminNavItems, ...this.userAdminNavItems] + ...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems] .find(item => currentUrl.startsWith(item.path)); if (activeItem) { diff --git a/docs/superpowers/plans/2026-05-28-giving-donation-tracking.md b/docs/superpowers/plans/2026-05-28-giving-donation-tracking.md new file mode 100644 index 0000000..f7ac2e4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-giving-donation-tracking.md @@ -0,0 +1,2780 @@ +# Giving / Donation Tracking Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the manual giving module — giving-category config, single-entry giving, and a keyboard-first Sunday offering batch-entry screen with finance-gated reconciliation. + +**Architecture:** ASP.NET Core 8 Web API (thin controller → service → EF Core/PostgreSQL) + standalone Angular 18 + Kendo UI. Batch entry buffers all rows client-side and submits the whole `OfferingSession` (header + lines) in one transactional `POST` (spec decision B); the server is authoritative for `SystemTotal`/`Difference`. Once `Submitted`, a session is locked — editing requires `finance` to `reopen` it. + +**Tech Stack:** C#, EF Core 8 (Npgsql), xUnit + Moq + EF InMemory, Angular standalone components, `@progress/kendo-angular-grid`, RxJS. + +**Spec:** `docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md` + +**Conventions to follow (from existing code):** +- Entities inherit `AuditableEntity` (`CreatedAt/By`, `UpdatedAt/By`, all `DateTimeOffset`). `AuditSaveChangesInterceptor` stamps these automatically. **There is no separate `AuditLog` table** — "audit" = the stamp fields (spec R4). +- Services: `interface + impl`, ctor-inject `AppDbContext` + `IHttpContextAccessor`, `CurrentUserId` helper, manual DTO mapping, `PagedResult` for lists. +- Controllers: thin, `[Authorize(Roles=...)]`, translate `KeyNotFoundException`→404, lock conflicts→409. +- Tests: `BuildDb()` InMemory + `AuditSaveChangesInterceptor` (see `ROLAC.API.Tests/Services/MemberServiceTests.cs`). +- Frontend feature folder: `features//{pages,components,services,models}`, standalone components, `ApiConfigService.getApiUrl(...)`, DTO interfaces in `models/*.model.ts` (camelCase, `_en/_zh` suffixes preserved as `name_en`). + +**Run commands (from repo root unless noted):** +- Backend build: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +- Backend tests: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj` +- Migrations (run inside `API/ROLAC.API`): `dotnet ef migrations add ` / applied automatically on app startup via `db.Database.MigrateAsync()` +- Frontend build: `cd APP; npm run build` + +--- + +## File Structure + +**Backend — create:** +- `API/ROLAC.API/Entities/GivingCategory.cs` +- `API/ROLAC.API/Entities/OfferingSession.cs` +- `API/ROLAC.API/Entities/Giving.cs` +- `API/ROLAC.API/DTOs/Giving/` — `GivingCategoryDto.cs`, `CreateGivingCategoryRequest.cs`, `UpdateGivingCategoryRequest.cs`, `GivingDto.cs`, `GivingListItemDto.cs`, `CreateGivingRequest.cs`, `UpdateGivingRequest.cs`, `OfferingSessionDto.cs`, `OfferingSessionListItemDto.cs`, `OfferingGivingLineDto.cs`, `CreateOfferingSessionRequest.cs`, `OfferingGivingLineRequest.cs` +- `API/ROLAC.API/Services/IGivingCategoryService.cs` + `GivingCategoryService.cs` +- `API/ROLAC.API/Services/IGivingService.cs` + `GivingService.cs` +- `API/ROLAC.API/Services/IOfferingSessionService.cs` + `OfferingSessionService.cs` +- `API/ROLAC.API/Controllers/GivingCategoriesController.cs` +- `API/ROLAC.API/Controllers/GivingsController.cs` +- `API/ROLAC.API/Controllers/OfferingSessionsController.cs` +- `API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs` +- `API/ROLAC.API.Tests/Services/GivingServiceTests.cs` +- `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs` + +**Backend — modify:** +- `API/ROLAC.API/Data/AppDbContext.cs` (add 3 `DbSet` + fluent config) +- `API/ROLAC.API/Data/DbSeeder.cs` (seed giving categories) +- `API/ROLAC.API/Program.cs` (register 3 services) + +**Frontend — create:** +- `APP/src/app/features/giving/models/giving.model.ts` +- `APP/src/app/features/giving/services/giving-category-api.service.ts` +- `APP/src/app/features/giving/services/giving-api.service.ts` +- `APP/src/app/features/giving/services/offering-session-api.service.ts` +- `APP/src/app/features/giving/pages/giving-categories-page/` (component .ts/.html/.scss) +- `APP/src/app/features/giving/pages/givings-page/` (component .ts/.html/.scss) +- `APP/src/app/features/giving/pages/offering-session-page/` (component .ts/.html/.scss) +- `APP/src/app/features/giving/components/member-quick-add-dialog/` (component .ts/.html) + +**Frontend — modify:** +- `APP/src/app/app.routes.ts` (3 routes, RoleGuard finance/super_admin) +- `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts` + `.html` (finance nav section) + +--- + +## Task 1: Giving entities + EF Core configuration + +Entities have no behavior, so verification is a clean build (no unit test). + +**Files:** +- Create: `API/ROLAC.API/Entities/GivingCategory.cs`, `OfferingSession.cs`, `Giving.cs` +- Modify: `API/ROLAC.API/Data/AppDbContext.cs` + +- [ ] **Step 1: Create `GivingCategory.cs`** + +```csharp +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class GivingCategory : AuditableEntity +{ + public int Id { get; set; } + public string Name_en { get; set; } = null!; + public string? Name_zh { get; set; } + public string? Description_en { get; set; } + public string? Description_zh { get; set; } + public bool IsActive { get; set; } = true; + public int SortOrder { get; set; } +} +``` + +- [ ] **Step 2: Create `OfferingSession.cs`** + +```csharp +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class OfferingSession : AuditableEntity +{ + public int Id { get; set; } + public DateOnly SessionDate { get; set; } + public string Status { get; set; } = "Draft"; // Draft | Submitted | Reconciled + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public decimal SystemTotal { get; set; } + public decimal Difference { get; set; } + public string? Notes { get; set; } + public DateTimeOffset? SubmittedAt { get; set; } + public string? SubmittedBy { get; set; } + public DateTimeOffset? ReconciledAt { get; set; } + public string? ReconciledBy { get; set; } + + public List Givings { get; set; } = []; +} +``` + +- [ ] **Step 3: Create `Giving.cs`** + +```csharp +using ROLAC.API.Entities.Base; + +namespace ROLAC.API.Entities; + +public class Giving : AuditableEntity +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public int GivingCategoryId { get; set; } + public int? OfferingSessionId { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = "Cash"; // Cash|Check|Zelle|PayPal|Other + public string? CheckNumber { get; set; } + public string? ZelleReferenceCode { get; set; } + public string? PayPalTransactionId{ get; set; } + public DateOnly GivingDate { get; set; } + public bool IsAnonymous { get; set; } + public string? Notes { get; set; } + + public Member? Member { get; set; } + public GivingCategory? GivingCategory { get; set; } + public OfferingSession? OfferingSession { get; set; } +} +``` + +- [ ] **Step 4: Add DbSets + fluent config in `AppDbContext.cs`** + +Add after the existing `DbSet` line (around line 13): + +```csharp + public DbSet GivingCategories => Set(); + public DbSet OfferingSessions => Set(); + public DbSet Givings => Set(); +``` + +Add inside `OnModelCreating`, after the `Member` configuration block (before the closing brace of the method): + +```csharp + // ── GivingCategory ─────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Name_zh).HasMaxLength(200); + entity.Property(e => e.Description_en).HasMaxLength(500); + entity.Property(e => e.Description_zh).HasMaxLength(500); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + }); + + // ── OfferingSession ────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft"); + entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)"); + entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)"); + entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)"); + entity.Property(e => e.Difference).HasColumnType("decimal(18,2)"); + entity.Property(e => e.SubmittedBy).HasMaxLength(450); + entity.Property(e => e.ReconciledBy).HasMaxLength(450); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.HasIndex(e => e.SessionDate).IsUnique(); + }); + + // ── Giving ─────────────────────────────────────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); + entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired(); + entity.Property(e => e.CheckNumber).HasMaxLength(50); + entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100); + entity.Property(e => e.PayPalTransactionId).HasMaxLength(100); + entity.Property(e => e.Notes).HasMaxLength(500); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + + entity.HasIndex(e => new { e.MemberId, e.GivingDate }); + entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL"); + entity.HasIndex(e => e.GivingDate); + + entity.HasOne(e => e.GivingCategory).WithMany() + .HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.Member).WithMany() + .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings) + .HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade); + }); +``` + +- [ ] **Step 5: Build to verify it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Entities/GivingCategory.cs API/ROLAC.API/Entities/OfferingSession.cs API/ROLAC.API/Entities/Giving.cs API/ROLAC.API/Data/AppDbContext.cs +git commit -m "feat(giving): add GivingCategory, OfferingSession, Giving entities + EF config" +``` + +--- + +## Task 2: Seed giving categories + +**Files:** +- Modify: `API/ROLAC.API/Data/DbSeeder.cs` + +- [ ] **Step 1: Add seed data array + method to `DbSeeder`** + +Add this static array near the top `Roles` array: + +```csharp + private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed = + [ + ("Tithe", "什一奉獻", 1), + ("General Offering", "一般奉獻", 2), + ("Special Offering", "特別奉獻", 3), + ("Building Fund", "建堂基金", 4), + ("Mission", "宣教奉獻", 5), + ]; + + public static async Task SeedGivingCategoriesAsync(AppDbContext db) + { + foreach (var (en, zh, sort) in GivingCategorySeed) + { + if (!db.GivingCategories.Any(c => c.Name_en == en)) + { + db.GivingCategories.Add(new GivingCategory + { + Name_en = en, + Name_zh = zh, + SortOrder = sort, + IsActive = true, + // Audit fields are stamped by AuditSaveChangesInterceptor on save. + }); + } + } + await db.SaveChangesAsync(); + } +``` + +- [ ] **Step 2: Call it from `SeedAsync`** + +In `SeedAsync(IServiceProvider services)`, after `await SeedRolesAsync(roleManager);`, add: + +```csharp + var db = services.GetRequiredService(); + await SeedGivingCategoriesAsync(db); +``` + +Add `using ROLAC.API.Data;` is unnecessary (same namespace); ensure `using Microsoft.Extensions.DependencyInjection;` is present (it is via implicit usings). + +- [ ] **Step 3: Build** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Data/DbSeeder.cs +git commit -m "feat(giving): seed default giving categories" +``` + +--- + +## Task 3: EF migration + +**Files:** +- Create: `API/ROLAC.API/Migrations/*_AddGivingModule.cs` (generated) + +- [ ] **Step 1: Generate the migration** + +Run (from `API/ROLAC.API`): `dotnet ef migrations add AddGivingModule` +Expected: New migration files generated under `Migrations/`. If `dotnet ef` is missing: `dotnet tool install --global dotnet-ef`. + +- [ ] **Step 2: Inspect the generated migration** + +Open the generated `*_AddGivingModule.cs` and confirm it creates `GivingCategories`, `OfferingSessions`, `Givings` tables with the unique index on `OfferingSessions.SessionDate`, the three `Givings` indexes, and the FK restrict/setnull/cascade behaviors. No manual edits expected. + +- [ ] **Step 3: Build** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Migrations/ +git commit -m "feat(giving): add EF migration for giving module" +``` + +--- + +## Task 4: GivingCategory DTOs + service (TDD) + +**Files:** +- Create: `API/ROLAC.API/DTOs/Giving/GivingCategoryDto.cs`, `CreateGivingCategoryRequest.cs`, `UpdateGivingCategoryRequest.cs` +- Create: `API/ROLAC.API/Services/IGivingCategoryService.cs`, `GivingCategoryService.cs` +- Create: `API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs` +- Modify: `API/ROLAC.API/Program.cs` + +- [ ] **Step 1: Create the DTOs** + +`DTOs/Giving/GivingCategoryDto.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class GivingCategoryDto +{ + 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 bool IsActive { get; set; } + public int SortOrder { get; set; } +} +``` + +`DTOs/Giving/CreateGivingCategoryRequest.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class CreateGivingCategoryRequest +{ + [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; } +} +``` + +`DTOs/Giving/UpdateGivingCategoryRequest.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class UpdateGivingCategoryRequest +{ + [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; } +} +``` + +- [ ] **Step 2: Create the service interface** + +`Services/IGivingCategoryService.cs`: +```csharp +using ROLAC.API.DTOs.Giving; + +namespace ROLAC.API.Services; + +public interface IGivingCategoryService +{ + Task> GetAllAsync(bool includeInactive); + Task CreateAsync(CreateGivingCategoryRequest request); + Task UpdateAsync(int id, UpdateGivingCategoryRequest request); + Task DeactivateAsync(int id); // soft-disable: IsActive = false +} +``` + +- [ ] **Step 3: Write the failing tests** + +`API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class GivingCategoryServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + private static AppDbContext BuildDb(string userId = "test-user") + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + [Fact] + public async Task CreateAsync_ReturnsId_AndDefaultsActive() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db, BuildAccessor()); + + var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" }); + + var saved = await db.GivingCategories.FindAsync(id); + Assert.NotNull(saved); + Assert.True(saved!.IsActive); + Assert.Equal("Tithe", saved.Name_en); + } + + [Fact] + public async Task GetAllAsync_ExcludesInactive_ByDefault() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db, BuildAccessor()); + var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" }); + var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" }); + await svc.DeactivateAsync(id2); + + var active = await svc.GetAllAsync(includeInactive: false); + var all = await svc.GetAllAsync(includeInactive: true); + + Assert.Single(active); + Assert.Equal(2, all.Count); + } + + [Fact] + public async Task DeactivateAsync_SetsIsActiveFalse() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db, BuildAccessor()); + var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" }); + + await svc.DeactivateAsync(id); + + var saved = await db.GivingCategories.FindAsync(id); + Assert.False(saved!.IsActive); + } + + [Fact] + public async Task UpdateAsync_Throws_WhenMissing() + { + using var db = BuildDb(); + var svc = new GivingCategoryService(db, BuildAccessor()); + await Assert.ThrowsAsync(() => + svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" })); + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingCategoryServiceTests` +Expected: Compile error / FAIL — `GivingCategoryService` does not exist yet. + +- [ ] **Step 5: Implement the service** + +`Services/GivingCategoryService.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class GivingCategoryService : IGivingCategoryService +{ + private readonly AppDbContext _db; + public GivingCategoryService(AppDbContext db, IHttpContextAccessor http) => _db = db; + + public async Task> GetAllAsync(bool includeInactive) + { + var query = _db.GivingCategories.AsNoTracking().AsQueryable(); + if (!includeInactive) query = query.Where(c => c.IsActive); + + return await query + .OrderBy(c => c.SortOrder).ThenBy(c => c.Name_en) + .Select(c => new GivingCategoryDto + { + Id = c.Id, Name_en = c.Name_en, Name_zh = c.Name_zh, + Description_en = c.Description_en, Description_zh = c.Description_zh, + IsActive = c.IsActive, SortOrder = c.SortOrder, + }) + .ToListAsync(); + } + + public async Task CreateAsync(CreateGivingCategoryRequest r) + { + var entity = new GivingCategory + { + 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, + }; + _db.GivingCategories.Add(entity); + await _db.SaveChangesAsync(); + return entity.Id; + } + + public async Task UpdateAsync(int id, UpdateGivingCategoryRequest r) + { + var c = await _db.GivingCategories.FindAsync(id) + ?? throw new KeyNotFoundException($"GivingCategory {id} not found."); + c.Name_en = r.Name_en; c.Name_zh = r.Name_zh; + c.Description_en = r.Description_en; c.Description_zh = r.Description_zh; + c.IsActive = r.IsActive; c.SortOrder = r.SortOrder; + await _db.SaveChangesAsync(); + } + + public async Task DeactivateAsync(int id) + { + var c = await _db.GivingCategories.FindAsync(id) + ?? throw new KeyNotFoundException($"GivingCategory {id} not found."); + c.IsActive = false; + await _db.SaveChangesAsync(); + } +} +``` + +- [ ] **Step 6: Register the service in `Program.cs`** + +After `builder.Services.AddScoped();` add: +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingCategoryServiceTests` +Expected: PASS (4 tests). + +- [ ] **Step 8: Commit** + +```bash +git add API/ROLAC.API/DTOs/Giving/ API/ROLAC.API/Services/IGivingCategoryService.cs API/ROLAC.API/Services/GivingCategoryService.cs API/ROLAC.API.Tests/Services/GivingCategoryServiceTests.cs API/ROLAC.API/Program.cs +git commit -m "feat(giving): giving-category service with CRUD + soft-disable" +``` + +--- + +## Task 5: GivingCategoriesController + +**Files:** +- Create: `API/ROLAC.API/Controllers/GivingCategoriesController.cs` + +- [ ] **Step 1: Create the controller** + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/giving-categories")] +[Authorize(Roles = "finance,super_admin")] +public class GivingCategoriesController : ControllerBase +{ + private readonly IGivingCategoryService _svc; + public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc; + + [HttpGet] + public async Task GetAll([FromQuery] bool includeInactive = false) + => Ok(await _svc.GetAllAsync(includeInactive)); + + [HttpPost] + public async Task Create([FromBody] CreateGivingCategoryRequest request) + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetAll), new { id }, new { id }); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateGivingCategoryRequest request) + { + try { await _svc.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } + + [HttpDelete("{id:int}")] + public async Task Deactivate(int id) + { + try { await _svc.DeactivateAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/Controllers/GivingCategoriesController.cs +git commit -m "feat(giving): add giving-categories controller" +``` + +--- + +## Task 6: Giving DTOs + single-entry service (TDD) + +**Files:** +- Create: `API/ROLAC.API/DTOs/Giving/GivingDto.cs`, `GivingListItemDto.cs`, `CreateGivingRequest.cs`, `UpdateGivingRequest.cs` +- Create: `API/ROLAC.API/Services/IGivingService.cs`, `GivingService.cs` +- Create: `API/ROLAC.API.Tests/Services/GivingServiceTests.cs` +- Modify: `API/ROLAC.API/Program.cs` + +- [ ] **Step 1: Create the DTOs** + +`DTOs/Giving/GivingListItemDto.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class GivingListItemDto +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } // resolved display name, null if anonymous + public int GivingCategoryId { get; set; } + public string CategoryName { get; set; } = ""; + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = ""; + public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd + public bool IsAnonymous { get; set; } + public int? OfferingSessionId{ get; set; } +} +``` + +`DTOs/Giving/GivingDto.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class GivingDto +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } + public int GivingCategoryId { get; set; } + public int? OfferingSessionId { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = ""; + public string? CheckNumber { get; set; } + public string? ZelleReferenceCode { get; set; } + public string? PayPalTransactionId { get; set; } + public DateOnly GivingDate { get; set; } + public bool IsAnonymous { get; set; } + public string? Notes { get; set; } +} +``` + +`DTOs/Giving/CreateGivingRequest.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class CreateGivingRequest +{ + public int? MemberId { get; set; } + [Required] public int GivingCategoryId { get; set; } + [Range(0.01, 9999999)] public decimal Amount { get; set; } + [Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash"; + [MaxLength(50)] public string? CheckNumber { get; set; } + [MaxLength(100)] public string? ZelleReferenceCode { get; set; } + [MaxLength(100)] public string? PayPalTransactionId { get; set; } + public DateOnly GivingDate { get; set; } + public bool IsAnonymous { get; set; } + [MaxLength(500)] public string? Notes { get; set; } +} +``` + +`DTOs/Giving/UpdateGivingRequest.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class UpdateGivingRequest : CreateGivingRequest { } +``` + +- [ ] **Step 2: Create the service interface** + +`Services/IGivingService.cs`: +```csharp +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IGivingService +{ + Task> GetPagedAsync( + int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to); + Task GetByIdAsync(int id); + Task CreateAsync(CreateGivingRequest request); + Task UpdateAsync(int id, UpdateGivingRequest request); + Task DeleteAsync(int id); +} +``` + +- [ ] **Step 3: Write the failing tests** + +`API/ROLAC.API.Tests/Services/GivingServiceTests.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class GivingServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + private static AppDbContext BuildDb(string userId = "test-user") + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task SeedCategoryAsync(AppDbContext db) + { + var c = new GivingCategory { Name_en = "Tithe", IsActive = true }; + db.GivingCategories.Add(c); + await db.SaveChangesAsync(); + return c.Id; + } + + [Fact] + public async Task CreateAsync_PersistsGiving() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new GivingService(db, BuildAccessor()); + + var id = await svc.CreateAsync(new CreateGivingRequest + { + GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true, + }); + + var saved = await db.Givings.FindAsync(id); + Assert.NotNull(saved); + Assert.Equal(100m, saved!.Amount); + Assert.Null(saved.OfferingSessionId); // single entry is not session-bound + } + + [Fact] + public async Task GetPagedAsync_FiltersByCategory() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new GivingService(db, BuildAccessor()); + await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }); + + var page = await svc.GetPagedAsync(1, 20, null, catId, null, null); + + Assert.Equal(1, page.TotalCount); + Assert.Equal("Tithe", page.Items[0].CategoryName); + } + + [Fact] + public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" }; + db.OfferingSessions.Add(session); + await db.SaveChangesAsync(); + var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", + GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id }; + db.Givings.Add(giving); + await db.SaveChangesAsync(); + + var svc = new GivingService(db, BuildAccessor()); + + await Assert.ThrowsAsync(() => + svc.UpdateAsync(giving.Id, new UpdateGivingRequest + { GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) })); + } + + [Fact] + public async Task DeleteAsync_Throws_WhenMissing() + { + using var db = BuildDb(); + var svc = new GivingService(db, BuildAccessor()); + await Assert.ThrowsAsync(() => svc.DeleteAsync(999)); + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingServiceTests` +Expected: Compile error / FAIL — `GivingService` does not exist. + +- [ ] **Step 5: Implement the service** + +`Services/GivingService.cs`: +```csharp +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class GivingService : IGivingService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + + public GivingService(AppDbContext db, IHttpContextAccessor http) + { + _db = db; + _http = http; + } + + public async Task> GetPagedAsync( + int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to) + { + var query = _db.Givings.AsNoTracking().AsQueryable(); + + if (categoryId.HasValue) query = query.Where(g => g.GivingCategoryId == categoryId.Value); + if (from.HasValue) query = query.Where(g => g.GivingDate >= from.Value); + if (to.HasValue) query = query.Where(g => g.GivingDate <= to.Value); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); + query = query.Where(g => + (g.CheckNumber != null && g.CheckNumber.ToLower().Contains(s)) || + (g.Notes != null && g.Notes.ToLower().Contains(s))); + } + + var total = await query.CountAsync(); + var rows = await query + .OrderByDescending(g => g.GivingDate).ThenByDescending(g => g.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + // Resolve names via separate lookups (mirrors MemberService InMemory-safe pattern). + var catNames = await _db.GivingCategories.AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name_en); + var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.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}"); + + var items = rows.Select(g => new GivingListItemDto + { + Id = g.Id, MemberId = g.MemberId, + MemberName = g.MemberId != null && memberNames.TryGetValue(g.MemberId.Value, out var n) ? n : null, + GivingCategoryId = g.GivingCategoryId, + CategoryName = catNames.TryGetValue(g.GivingCategoryId, out var cn) ? cn : "", + Amount = g.Amount, PaymentMethod = g.PaymentMethod, + GivingDate = g.GivingDate.ToString("yyyy-MM-dd"), + IsAnonymous = g.IsAnonymous, OfferingSessionId = g.OfferingSessionId, + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + public async Task GetByIdAsync(int id) + { + var g = await _db.Givings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (g is null) return null; + + string? memberName = null; + if (g.MemberId != null) + memberName = await _db.Members.AsNoTracking() + .Where(m => m.Id == g.MemberId) + .Select(m => m.FirstName_en + " " + m.LastName_en) + .FirstOrDefaultAsync(); + + return new GivingDto + { + Id = g.Id, MemberId = g.MemberId, MemberName = memberName, + GivingCategoryId = g.GivingCategoryId, OfferingSessionId = g.OfferingSessionId, + Amount = g.Amount, PaymentMethod = g.PaymentMethod, CheckNumber = g.CheckNumber, + ZelleReferenceCode = g.ZelleReferenceCode, PayPalTransactionId = g.PayPalTransactionId, + GivingDate = g.GivingDate, IsAnonymous = g.IsAnonymous, Notes = g.Notes, + }; + } + + public async Task CreateAsync(CreateGivingRequest r) + { + var g = MapFromRequest(new Giving(), r); + g.OfferingSessionId = null; // single entry is never session-bound + _db.Givings.Add(g); + await _db.SaveChangesAsync(); + return g.Id; + } + + public async Task UpdateAsync(int id, UpdateGivingRequest r) + { + var g = await _db.Givings.FindAsync(id) + ?? throw new KeyNotFoundException($"Giving {id} not found."); + await GuardSessionNotLockedAsync(g.OfferingSessionId); + MapFromRequest(g, r); + await _db.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var g = await _db.Givings.FindAsync(id) + ?? throw new KeyNotFoundException($"Giving {id} not found."); + await GuardSessionNotLockedAsync(g.OfferingSessionId); + _db.Givings.Remove(g); + await _db.SaveChangesAsync(); + } + + // A giving that belongs to a Submitted/Reconciled session cannot be edited directly. + private async Task GuardSessionNotLockedAsync(int? sessionId) + { + if (sessionId is null) return; + var status = await _db.OfferingSessions + .Where(s => s.Id == sessionId).Select(s => s.Status).FirstOrDefaultAsync(); + if (status is "Submitted" or "Reconciled") + throw new InvalidOperationException( + "This giving belongs to a locked offering session. Reopen the session to edit."); + } + + private static Giving MapFromRequest(Giving g, CreateGivingRequest r) + { + g.MemberId = r.IsAnonymous ? null : r.MemberId; + g.GivingCategoryId = r.GivingCategoryId; + g.Amount = r.Amount; g.PaymentMethod = r.PaymentMethod; + g.CheckNumber = r.CheckNumber; g.ZelleReferenceCode = r.ZelleReferenceCode; + g.PayPalTransactionId = r.PayPalTransactionId; g.GivingDate = r.GivingDate; + g.IsAnonymous = r.IsAnonymous; g.Notes = r.Notes; + return g; + } +} +``` + +- [ ] **Step 6: Register the service in `Program.cs`** + +After the `IGivingCategoryService` registration add: +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter GivingServiceTests` +Expected: PASS (4 tests). + +- [ ] **Step 8: Commit** + +```bash +git add API/ROLAC.API/DTOs/Giving/ API/ROLAC.API/Services/IGivingService.cs API/ROLAC.API/Services/GivingService.cs API/ROLAC.API.Tests/Services/GivingServiceTests.cs API/ROLAC.API/Program.cs +git commit -m "feat(giving): single-entry giving service with paging + lock guard" +``` + +--- + +## Task 7: GivingsController + +**Files:** +- Create: `API/ROLAC.API/Controllers/GivingsController.cs` + +- [ ] **Step 1: Create the controller** + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/givings")] +[Authorize(Roles = "finance,super_admin")] +public class GivingsController : ControllerBase +{ + private readonly IGivingService _svc; + public GivingsController(IGivingService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, + [FromQuery] string? search = null, [FromQuery] int? categoryId = null, + [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + => Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to)); + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateGivingRequest request) + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetById), new { id }, new { id }); + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateGivingRequest request) + { + try { await _svc.UpdateAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + try { await _svc.DeleteAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/Controllers/GivingsController.cs +git commit -m "feat(giving): add givings controller" +``` + +--- + +## Task 8: OfferingSession DTOs + batch service (TDD) + +This is the core. The service builds the whole session + lines in one save, recomputes `SystemTotal`/`Difference` server-side, and enforces locking. + +**Files:** +- Create: `API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs`, `OfferingSessionListItemDto.cs`, `OfferingGivingLineDto.cs`, `CreateOfferingSessionRequest.cs`, `OfferingGivingLineRequest.cs` +- Create: `API/ROLAC.API/Services/IOfferingSessionService.cs`, `OfferingSessionService.cs` +- Create: `API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs` +- Modify: `API/ROLAC.API/Program.cs` + +- [ ] **Step 1: Create the DTOs** + +`DTOs/Giving/OfferingGivingLineRequest.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class OfferingGivingLineRequest +{ + public int? MemberId { get; set; } + [Required] public int GivingCategoryId { get; set; } + [Range(0.01, 9999999)] public decimal Amount { get; set; } + [Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash"; + [MaxLength(50)] public string? CheckNumber { get; set; } + [MaxLength(100)] public string? ZelleReferenceCode { get; set; } + [MaxLength(100)] public string? PayPalTransactionId { get; set; } + public bool IsAnonymous { get; set; } + [MaxLength(500)] public string? Notes { get; set; } +} +``` + +`DTOs/Giving/CreateOfferingSessionRequest.cs`: +```csharp +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Giving; + +public class CreateOfferingSessionRequest +{ + [Required] public DateOnly SessionDate { get; set; } + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public string? Notes { get; set; } + public List Givings { get; set; } = []; +} +``` + +`DTOs/Giving/OfferingGivingLineDto.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class OfferingGivingLineDto +{ + public int Id { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } + public int GivingCategoryId { get; set; } + public string CategoryName { get; set; } = ""; + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = ""; + public string? CheckNumber { get; set; } + public bool IsAnonymous { get; set; } + public string? Notes { get; set; } +} +``` + +`DTOs/Giving/OfferingSessionListItemDto.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class OfferingSessionListItemDto +{ + public int Id { get; set; } + public string SessionDate { get; set; } = ""; // yyyy-MM-dd + public string Status { get; set; } = ""; + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public decimal SystemTotal { get; set; } + public decimal Difference { get; set; } + public int LineCount { get; set; } +} +``` + +`DTOs/Giving/OfferingSessionDto.cs`: +```csharp +namespace ROLAC.API.DTOs.Giving; + +public class OfferingSessionDto +{ + public int Id { get; set; } + public DateOnly SessionDate{ get; set; } + public string Status { get; set; } = ""; + public decimal CashTotal { get; set; } + public decimal CheckTotal { get; set; } + public decimal SystemTotal { get; set; } + public decimal Difference { get; set; } + public string? Notes { get; set; } + public List Givings { get; set; } = []; +} +``` + +- [ ] **Step 2: Create the service interface** + +`Services/IOfferingSessionService.cs`: +```csharp +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; + +namespace ROLAC.API.Services; + +public interface IOfferingSessionService +{ + Task> GetPagedAsync( + int page, int pageSize, DateOnly? from, DateOnly? to); + Task GetByIdAsync(int id); + Task DateExistsAsync(DateOnly date); + Task CreateAsync(CreateOfferingSessionRequest request); // creates + submits in one tx + Task ReopenAsync(int id); // Submitted -> Draft + Task ReplaceAsync(int id, CreateOfferingSessionRequest request); // edit a reopened (Draft) session +} +``` + +- [ ] **Step 3: Write the failing tests** + +`API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs`: +```csharp +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class OfferingSessionServiceTests +{ + private static IHttpContextAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return mock.Object; + } + + private static AppDbContext BuildDb(string userId = "test-user") + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task SeedCategoryAsync(AppDbContext db) + { + var c = new GivingCategory { Name_en = "Tithe", IsActive = true }; + db.GivingCategories.Add(c); + await db.SaveChangesAsync(); + return c.Id; + } + + private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new() + { + SessionDate = date, CashTotal = 150m, CheckTotal = 0m, + Givings = + [ + new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" }, + new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true }, + ], + }; + + [Fact] + public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + var saved = await db.OfferingSessions.FindAsync(id); + Assert.Equal("Submitted", saved!.Status); + Assert.Equal(150m, saved.SystemTotal); // 100 + 50, server-computed + Assert.Equal(0m, saved.Difference); // (150 cash + 0 check) - 150 + Assert.NotNull(saved.SubmittedAt); + Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id)); + } + + [Fact] + public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync(); + Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate)); + Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null); + } + + [Fact] + public async Task CreateAsync_Throws_OnDuplicateSessionDate() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + await Assert.ThrowsAsync(() => + svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)))); + } + + [Fact] + public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + await Assert.ThrowsAsync(() => + svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31)))); + } + + [Fact] + public async Task ReopenThenReplace_SwapsLinesAndResubmits() + { + using var db = BuildDb(); + var catId = await SeedCategoryAsync(db); + var svc = new OfferingSessionService(db, BuildAccessor()); + var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))); + + await svc.ReopenAsync(id); + var reopened = await db.OfferingSessions.FindAsync(id); + Assert.Equal("Draft", reopened!.Status); + + var newReq = new CreateOfferingSessionRequest + { + SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m, + Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ], + }; + await svc.ReplaceAsync(id, newReq); + + var after = await db.OfferingSessions.FindAsync(id); + Assert.Equal("Submitted", after!.Status); + Assert.Equal(200m, after.SystemTotal); + Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id)); + } +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter OfferingSessionServiceTests` +Expected: Compile error / FAIL — `OfferingSessionService` does not exist. + +- [ ] **Step 5: Implement the service** + +`Services/OfferingSessionService.cs`: +```csharp +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class OfferingSessionService : IOfferingSessionService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + + public OfferingSessionService(AppDbContext db, IHttpContextAccessor http) + { + _db = db; + _http = http; + } + + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + + public async Task> GetPagedAsync( + int page, int pageSize, DateOnly? from, DateOnly? to) + { + var query = _db.OfferingSessions.AsNoTracking().AsQueryable(); + if (from.HasValue) query = query.Where(s => s.SessionDate >= from.Value); + if (to.HasValue) query = query.Where(s => s.SessionDate <= to.Value); + + var total = await query.CountAsync(); + var rows = await query + .OrderByDescending(s => s.SessionDate) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + var ids = rows.Select(r => r.Id).ToList(); + var counts = await _db.Givings.AsNoTracking() + .Where(g => g.OfferingSessionId != null && ids.Contains(g.OfferingSessionId.Value)) + .GroupBy(g => g.OfferingSessionId!.Value) + .Select(grp => new { Id = grp.Key, Count = grp.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Count); + + var items = rows.Select(s => new OfferingSessionListItemDto + { + Id = s.Id, SessionDate = s.SessionDate.ToString("yyyy-MM-dd"), Status = s.Status, + CashTotal = s.CashTotal, CheckTotal = s.CheckTotal, + SystemTotal = s.SystemTotal, Difference = s.Difference, + LineCount = counts.TryGetValue(s.Id, out var c) ? c : 0, + }).ToList(); + + return new PagedResult + { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + public async Task DateExistsAsync(DateOnly date) + => await _db.OfferingSessions.AnyAsync(s => s.SessionDate == date); + + public async Task GetByIdAsync(int id) + { + var s = await _db.OfferingSessions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (s is null) return null; + + var lines = await _db.Givings.AsNoTracking() + .Where(g => g.OfferingSessionId == id).ToListAsync(); + + var catNames = await _db.GivingCategories.AsNoTracking() + .ToDictionaryAsync(c => c.Id, c => c.Name_en); + var memberIds = lines.Where(l => l.MemberId != null).Select(l => l.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}"); + + return new OfferingSessionDto + { + Id = s.Id, SessionDate = s.SessionDate, Status = s.Status, + CashTotal = s.CashTotal, CheckTotal = s.CheckTotal, + SystemTotal = s.SystemTotal, Difference = s.Difference, Notes = s.Notes, + Givings = lines.Select(l => new OfferingGivingLineDto + { + Id = l.Id, MemberId = l.MemberId, + MemberName = l.MemberId != null && memberNames.TryGetValue(l.MemberId.Value, out var n) ? n : null, + GivingCategoryId = l.GivingCategoryId, + CategoryName = catNames.TryGetValue(l.GivingCategoryId, out var cn) ? cn : "", + Amount = l.Amount, PaymentMethod = l.PaymentMethod, + CheckNumber = l.CheckNumber, IsAnonymous = l.IsAnonymous, Notes = l.Notes, + }).ToList(), + }; + } + + public async Task CreateAsync(CreateOfferingSessionRequest r) + { + if (await DateExistsAsync(r.SessionDate)) + throw new InvalidOperationException($"An offering session for {r.SessionDate:yyyy-MM-dd} already exists."); + + var systemTotal = r.Givings.Sum(g => g.Amount); + var session = new OfferingSession + { + SessionDate = r.SessionDate, Status = "Submitted", + CashTotal = r.CashTotal, CheckTotal = r.CheckTotal, + SystemTotal = systemTotal, + Difference = (r.CashTotal + r.CheckTotal) - systemTotal, + Notes = r.Notes, + SubmittedAt = DateTimeOffset.UtcNow, SubmittedBy = CurrentUserId, + Givings = r.Givings.Select(line => MapLine(line, r.SessionDate)).ToList(), + }; + _db.OfferingSessions.Add(session); + await _db.SaveChangesAsync(); // header + lines inserted together + return session.Id; + } + + public async Task ReopenAsync(int id) + { + var s = await _db.OfferingSessions.FindAsync(id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + if (s.Status != "Submitted") + throw new InvalidOperationException($"Only a Submitted session can be reopened (current: {s.Status})."); + s.Status = "Draft"; + s.SubmittedAt = null; s.SubmittedBy = null; + await _db.SaveChangesAsync(); + } + + public async Task ReplaceAsync(int id, CreateOfferingSessionRequest r) + { + var s = await _db.OfferingSessions + .Include(x => x.Givings) + .FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"OfferingSession {id} not found."); + if (s.Status != "Draft") + throw new InvalidOperationException($"Only a Draft (reopened) session can be edited (current: {s.Status})."); + + _db.Givings.RemoveRange(s.Givings); // drop old lines + var systemTotal = r.Givings.Sum(g => g.Amount); + s.CashTotal = r.CashTotal; s.CheckTotal = r.CheckTotal; + s.SystemTotal = systemTotal; + s.Difference = (r.CashTotal + r.CheckTotal) - systemTotal; + s.Notes = r.Notes; + s.Status = "Submitted"; + s.SubmittedAt = DateTimeOffset.UtcNow; s.SubmittedBy = CurrentUserId; + s.Givings = r.Givings.Select(line => MapLine(line, s.SessionDate)).ToList(); + await _db.SaveChangesAsync(); + } + + private static Giving MapLine(OfferingGivingLineRequest line, DateOnly sessionDate) => new() + { + MemberId = line.IsAnonymous ? null : line.MemberId, + GivingCategoryId = line.GivingCategoryId, + Amount = line.Amount, + PaymentMethod = line.PaymentMethod, + CheckNumber = line.CheckNumber, + ZelleReferenceCode = line.ZelleReferenceCode, + PayPalTransactionId= line.PayPalTransactionId, + GivingDate = sessionDate, + IsAnonymous = line.IsAnonymous, + Notes = line.Notes, + }; +} +``` + +> **Note on transactions:** With Npgsql, a single `SaveChangesAsync` wraps all inserts in one DB transaction automatically, satisfying spec decision B. The EF InMemory provider used in tests does not support real transactions but still applies all changes in the one `SaveChangesAsync`, so the tests are valid. + +- [ ] **Step 6: Register the service in `Program.cs`** + +After the `IGivingService` registration add: +```csharp +builder.Services.AddScoped(); +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj --filter OfferingSessionServiceTests` +Expected: PASS (5 tests). + +- [ ] **Step 8: Run the full backend test suite** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj` +Expected: All tests pass (existing + new). + +- [ ] **Step 9: Commit** + +```bash +git add API/ROLAC.API/DTOs/Giving/ API/ROLAC.API/Services/IOfferingSessionService.cs API/ROLAC.API/Services/OfferingSessionService.cs API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs API/ROLAC.API/Program.cs +git commit -m "feat(giving): offering-session batch service with server-side totals + locking" +``` + +--- + +## Task 9: OfferingSessionsController + +**Files:** +- Create: `API/ROLAC.API/Controllers/OfferingSessionsController.cs` + +- [ ] **Step 1: Create the controller** + +```csharp +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Giving; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/offering-sessions")] +[Authorize(Roles = "finance,super_admin")] +public class OfferingSessionsController : ControllerBase +{ + private readonly IOfferingSessionService _svc; + public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc; + + [HttpGet] + public async Task GetPaged( + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, + [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) + => Ok(await _svc.GetPagedAsync(page, pageSize, from, to)); + + [HttpGet("check-date")] + public async Task CheckDate([FromQuery] DateOnly date) + => Ok(new { exists = await _svc.DateExistsAsync(date) }); + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateOfferingSessionRequest request) + { + try + { + var id = await _svc.CreateAsync(request); + return CreatedAtAction(nameof(GetById), new { id }, new { id }); + } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPost("{id:int}/reopen")] + public async Task Reopen(int id) + { + try { await _svc.ReopenAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPut("{id:int}")] + public async Task Replace(int id, [FromBody] CreateOfferingSessionRequest request) + { + try { await _svc.ReplaceAsync(id, request); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } +} +``` + +- [ ] **Step 2: Build** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/Controllers/OfferingSessionsController.cs +git commit -m "feat(giving): add offering-sessions controller" +``` + +--- + +## Task 10: Frontend models + API services + +**Files:** +- Create: `APP/src/app/features/giving/models/giving.model.ts` +- Create: `APP/src/app/features/giving/services/giving-category-api.service.ts`, `giving-api.service.ts`, `offering-session-api.service.ts` + +- [ ] **Step 1: Create the models file** + +`APP/src/app/features/giving/models/giving.model.ts`: +```typescript +export type PaymentMethod = 'Cash' | 'Check' | 'Zelle' | 'PayPal' | 'Other'; +export type SessionStatus = 'Draft' | 'Submitted' | 'Reconciled'; + +export interface PagedResult { + items: T[]; + totalCount: number; + page: number; + pageSize: number; + totalPages: number; +} + +// ── Giving categories ───────────────────────────────────────────── +export interface GivingCategoryDto { + id: number; + name_en: string; + name_zh: string | null; + description_en: string | null; + description_zh: string | null; + isActive: boolean; + sortOrder: number; +} +export interface CreateGivingCategoryRequest { + name_en: string; + name_zh: string | null; + description_en: string | null; + description_zh: string | null; + sortOrder: number; +} +export interface UpdateGivingCategoryRequest extends CreateGivingCategoryRequest { + isActive: boolean; +} + +// ── Single giving ───────────────────────────────────────────────── +export interface GivingListItemDto { + id: number; + memberId: number | null; + memberName: string | null; + givingCategoryId: number; + categoryName: string; + amount: number; + paymentMethod: PaymentMethod; + givingDate: string; // yyyy-MM-dd + isAnonymous: boolean; + offeringSessionId: number | null; +} +export interface CreateGivingRequest { + memberId: number | null; + givingCategoryId: number; + amount: number; + paymentMethod: PaymentMethod; + checkNumber: string | null; + zelleReferenceCode: string | null; + payPalTransactionId: string | null; + givingDate: string; // yyyy-MM-dd + isAnonymous: boolean; + notes: string | null; +} +export type UpdateGivingRequest = CreateGivingRequest; + +// ── Offering session (batch) ────────────────────────────────────── +export interface OfferingGivingLineRequest { + memberId: number | null; + givingCategoryId: number; + amount: number; + paymentMethod: PaymentMethod; + checkNumber: string | null; + zelleReferenceCode: string | null; + payPalTransactionId: string | null; + isAnonymous: boolean; + notes: string | null; +} +export interface CreateOfferingSessionRequest { + sessionDate: string; // yyyy-MM-dd + cashTotal: number; + checkTotal: number; + notes: string | null; + givings: OfferingGivingLineRequest[]; +} +export interface OfferingGivingLineDto { + id: number; + memberId: number | null; + memberName: string | null; + givingCategoryId: number; + categoryName: string; + amount: number; + paymentMethod: PaymentMethod; + checkNumber: string | null; + isAnonymous: boolean; + notes: string | null; +} +export interface OfferingSessionDto { + id: number; + sessionDate: string; + status: SessionStatus; + cashTotal: number; + checkTotal: number; + systemTotal: number; + difference: number; + notes: string | null; + givings: OfferingGivingLineDto[]; +} +export interface OfferingSessionListItemDto { + id: number; + sessionDate: string; + status: SessionStatus; + cashTotal: number; + checkTotal: number; + systemTotal: number; + difference: number; + lineCount: number; +} + +/** A row held in the client-side batch buffer before submit. */ +export interface OfferingBufferLine extends OfferingGivingLineRequest { + memberName: string | null; // for display only + categoryName: string; // for display only +} +``` + +- [ ] **Step 2: Create the giving-category API service** + +`APP/src/app/features/giving/services/giving-category-api.service.ts`: +```typescript +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest, +} from '../models/giving.model'; + +@Injectable({ providedIn: 'root' }) +export class GivingCategoryApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('giving-categories'); + } + + getAll(includeInactive = false): Observable { + const params = new HttpParams().set('includeInactive', includeInactive); + return this.http.get(this.endpoint, { params }); + } + create(request: CreateGivingCategoryRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + update(id: number, request: UpdateGivingCategoryRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + deactivate(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} +``` + +- [ ] **Step 3: Create the giving API service** + +`APP/src/app/features/giving/services/giving-api.service.ts`: +```typescript +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + GivingListItemDto, CreateGivingRequest, UpdateGivingRequest, PagedResult, +} from '../models/giving.model'; + +export interface GivingQuery { + page?: number; pageSize?: number; search?: string; + categoryId?: number; from?: string; to?: string; +} + +@Injectable({ providedIn: 'root' }) +export class GivingApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('givings'); + } + + getPaged(q: GivingQuery = {}): Observable> { + let p = new HttpParams().set('page', q.page ?? 1).set('pageSize', q.pageSize ?? 20); + if (q.search) p = p.set('search', q.search); + if (q.categoryId != null) p = p.set('categoryId', q.categoryId); + if (q.from) p = p.set('from', q.from); + if (q.to) p = p.set('to', q.to); + return this.http.get>(this.endpoint, { params: p }); + } + create(request: CreateGivingRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + update(id: number, request: UpdateGivingRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} +``` + +- [ ] **Step 4: Create the offering-session API service** + +`APP/src/app/features/giving/services/offering-session-api.service.ts`: +```typescript +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + OfferingSessionDto, OfferingSessionListItemDto, + CreateOfferingSessionRequest, PagedResult, +} from '../models/giving.model'; + +@Injectable({ providedIn: 'root' }) +export class OfferingSessionApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('offering-sessions'); + } + + getPaged(page = 1, pageSize = 20): Observable> { + const params = new HttpParams().set('page', page).set('pageSize', pageSize); + return this.http.get>(this.endpoint, { params }); + } + getById(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + checkDate(date: string): Observable<{ exists: boolean }> { + const params = new HttpParams().set('date', date); + return this.http.get<{ exists: boolean }>(`${this.endpoint}/check-date`, { params }); + } + create(request: CreateOfferingSessionRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, request); + } + reopen(id: number): Observable { + return this.http.post(`${this.endpoint}/${id}/reopen`, {}); + } + replace(id: number, request: CreateOfferingSessionRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, request); + } +} +``` + +- [ ] **Step 5: Build the frontend to verify it compiles** + +Run: `cd APP; npm run build` +Expected: Build succeeds (no usages yet — just type-checking the new files). + +- [ ] **Step 6: Commit** + +```bash +git add APP/src/app/features/giving/models APP/src/app/features/giving/services +git commit -m "feat(giving): frontend models + API services" +``` + +--- + +## Task 11: Routes + role-gated navigation + +**Files:** +- Modify: `APP/src/app/app.routes.ts` +- Modify: `APP/src/app/portals/user-portal/components/user-navbar/user-navbar.component.ts` + `.html` + +- [ ] **Step 1: Add routes** + +In `app.routes.ts`, add these imports at the top: +```typescript +import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component'; +import { GivingsPageComponent } from './features/giving/pages/givings-page/givings-page.component'; +import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component'; +``` + +Inside the `user-portal` `children` array, after the `admin/users` route, add: +```typescript + { + path: 'finance/giving-categories', + component: GivingCategoriesPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/givings', + component: GivingsPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, + { + path: 'finance/offering-session', + component: OfferingSessionPageComponent, + canActivate: [RoleGuard], + data: { roles: ['finance', 'super_admin'] }, + }, +``` + +> These imports reference components created in Tasks 12-14. If executing strictly in order, this step will fail to build until those components exist. **Execute Step 1 of this task last** (after Tasks 12-14), or temporarily stub the three components as empty standalone components. The nav changes (Steps 2-3) are independent and can be done now. + +- [ ] **Step 2: Add the finance nav section to `user-navbar.component.ts`** + +Add a nav array after `userAdminNavItems` (around line 69): +```typescript + public financeNavItems: NavItem[] = [ + { text: 'Offering Entry', icon: this.creditCardIcon, path: '/user-portal/finance/offering-session' }, + { text: 'Givings', icon: this.creditCardIcon, path: '/user-portal/finance/givings' }, + { text: 'Giving Types', icon: this.creditCardIcon, path: '/user-portal/finance/giving-categories' }, + ]; + + public showFinanceSection = false; +``` + +In `ngOnInit`, inside the `currentUser$` subscription (after `this.showUserAdminSection = ...`), add: +```typescript + this.showFinanceSection = roles.some(r => r === 'finance' || r === 'super_admin'); +``` + +In `updateActiveStates`, add `...this.financeNavItems` to **both** the reset array and the find array (lines ~115-122): +```typescript + [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, + ...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems] + .forEach(item => item.active = false); + + const activeItem = [...this.mainNavItems, ...this.managementNavItems, ...this.supportNavItems, + ...this.memberAdminNavItems, ...this.userAdminNavItems, ...this.financeNavItems] + .find(item => currentUrl.startsWith(item.path)); +``` + +- [ ] **Step 3: Render the finance section in `user-navbar.component.html`** + +Find the existing member/user admin section markup (rendered with `*ngIf="showMemberAdminSection"` / `showUserAdminSection`). Mirror that exact structure for finance. Add after the user-admin block: +```html + + + + +``` +> Match the actual class names / button markup used by the existing admin sections in this file — copy their pattern rather than the placeholder classes above if they differ. + +- [ ] **Step 4: Commit (nav only; routes after Tasks 12-14)** + +```bash +git add APP/src/app/portals/user-portal/components/user-navbar/ +git commit -m "feat(giving): add role-gated finance nav section" +``` + +--- + +## Task 12: Giving categories management page + +**Files:** +- Create: `APP/src/app/features/giving/pages/giving-categories-page/giving-categories-page.component.ts` + `.html` + `.scss` + +Follow the `MembersPageComponent` pattern (Kendo Grid + inline/dialog edit). Categories are few, so a simple grid + add/edit form panel is enough (no paging). + +- [ ] **Step 1: Create the component .ts** + +`giving-categories-page.component.ts`: +```typescript +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { GivingCategoryApiService } from '../../services/giving-category-api.service'; +import { + GivingCategoryDto, CreateGivingCategoryRequest, UpdateGivingCategoryRequest, +} from '../../models/giving.model'; + +@Component({ + selector: 'app-giving-categories-page', + standalone: true, + imports: [CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule], + templateUrl: './giving-categories-page.component.html', + styleUrls: ['./giving-categories-page.component.scss'], +}) +export class GivingCategoriesPageComponent implements OnInit { + data: GivingCategoryDto[] = []; + isLoading = false; + includeInactive = false; + + showDialog = false; + editing: GivingCategoryDto | null = null; + form: UpdateGivingCategoryRequest = this.blankForm(); + + constructor(private api: GivingCategoryApiService) {} + + ngOnInit(): void { this.load(); } + + load(): void { + this.isLoading = true; + this.api.getAll(this.includeInactive).subscribe({ + next: rows => { this.data = rows; this.isLoading = false; }, + error: () => { this.isLoading = false; }, + }); + } + + openAdd(): void { this.editing = null; this.form = this.blankForm(); this.showDialog = true; } + + openEdit(c: GivingCategoryDto): void { + this.editing = c; + this.form = { + name_en: c.name_en, name_zh: c.name_zh, + description_en: c.description_en, description_zh: c.description_zh, + isActive: c.isActive, sortOrder: c.sortOrder, + }; + this.showDialog = true; + } + + save(): void { + if (this.editing) { + this.api.update(this.editing.id, this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } else { + const create: CreateGivingCategoryRequest = { + name_en: this.form.name_en, name_zh: this.form.name_zh, + description_en: this.form.description_en, description_zh: this.form.description_zh, + sortOrder: this.form.sortOrder, + }; + this.api.create(create).subscribe(() => { this.showDialog = false; this.load(); }); + } + } + + deactivate(c: GivingCategoryDto): void { + if (!confirm(`Deactivate "${c.name_en}"?`)) return; + this.api.deactivate(c.id).subscribe(() => this.load()); + } + + private blankForm(): UpdateGivingCategoryRequest { + return { name_en: '', name_zh: null, description_en: null, description_zh: null, isActive: true, sortOrder: 0 }; + } +} +``` + +- [ ] **Step 2: Create the .html** + +`giving-categories-page.component.html`: +```html +
+ + + + + + + + {{ c.isActive ? 'Yes' : 'No' }} + + + + + + + + + + +
+ + + + + + +
+ + + + +
+
+``` +> Add `DialogsModule` from `@progress/kendo-angular-dialog` to the component `imports` array (needed for `kendo-dialog`). + +- [ ] **Step 3: Create an empty .scss** + +`giving-categories-page.component.scss`: +```scss +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.form-grid { display: flex; flex-direction: column; gap: 0.75rem; } +.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; } +``` + +- [ ] **Step 4: Build** + +Run: `cd APP; npm run build` +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/giving/pages/giving-categories-page/ +git commit -m "feat(giving): giving categories management page" +``` + +--- + +## Task 13: Single giving entry page + +**Files:** +- Create: `APP/src/app/features/giving/pages/givings-page/givings-page.component.ts` + `.html` + `.scss` + +Reuses the member search endpoint (`GET /api/members?search=`) for picking a giver. Import `MemberApiService` from the members feature. + +- [ ] **Step 1: Create the component .ts** + +`givings-page.component.ts`: +```typescript +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { DatePickerModule } from '@progress/kendo-angular-dateinputs'; +import { GivingApiService } from '../../services/giving-api.service'; +import { GivingCategoryApiService } from '../../services/giving-category-api.service'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; +import { + GivingListItemDto, GivingCategoryDto, CreateGivingRequest, PaymentMethod, PagedResult, +} from '../../models/giving.model'; + +@Component({ + selector: 'app-givings-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, + DropDownsModule, DialogsModule, DatePickerModule, + ], + templateUrl: './givings-page.component.html', + styleUrls: ['./givings-page.component.scss'], +}) +export class GivingsPageComponent implements OnInit { + data: GivingListItemDto[] = []; + totalCount = 0; + page = 1; + pageSize = 20; + isLoading = false; + + search = ''; + filterCategoryId: number | null = null; + categories: GivingCategoryDto[] = []; + + readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other']; + readonly memberDisplayName = memberDisplayName; + + // Member search for the dialog + memberResults: MemberListItemDto[] = []; + + showDialog = false; + editingId: number | null = null; + form: CreateGivingRequest = this.blankForm(); + selectedMember: MemberListItemDto | null = null; + + constructor( + private api: GivingApiService, + private categoryApi: GivingCategoryApiService, + private memberApi: MemberApiService, + ) {} + + ngOnInit(): void { + this.categoryApi.getAll(false).subscribe(c => this.categories = c); + this.load(); + } + + load(): void { + this.isLoading = true; + this.api.getPaged({ + page: this.page, pageSize: this.pageSize, + search: this.search || undefined, + categoryId: this.filterCategoryId ?? undefined, + }).subscribe({ + next: (r: PagedResult) => { + this.data = r.items; this.totalCount = r.totalCount; this.isLoading = false; + }, + error: () => { this.isLoading = false; }, + }); + } + + onPageChange(e: PageChangeEvent): void { + this.page = e.skip / this.pageSize + 1; this.pageSize = e.take; this.load(); + } + onSearch(): void { this.page = 1; this.load(); } + + onMemberFilter(term: string): void { + if (!term || term.length < 1) { this.memberResults = []; return; } + this.memberApi.getPaged({ search: term, pageSize: 10 }) + .subscribe(r => this.memberResults = r.items); + } + + openAdd(): void { + this.editingId = null; this.form = this.blankForm(); this.selectedMember = null; + this.showDialog = true; + } + + onMemberSelected(m: MemberListItemDto | null): void { + this.selectedMember = m; + this.form.memberId = m ? m.id : null; + } + + toggleAnonymous(): void { + this.form.isAnonymous = !this.form.isAnonymous; + if (this.form.isAnonymous) { this.form.memberId = null; this.selectedMember = null; } + } + + save(): void { + const today = new Date().toISOString().slice(0, 10); + if (!this.form.givingDate) this.form.givingDate = today; + if (this.editingId) { + this.api.update(this.editingId, this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } else { + this.api.create(this.form).subscribe(() => { this.showDialog = false; this.load(); }); + } + } + + delete(g: GivingListItemDto): void { + if (!confirm('Delete this giving record?')) return; + this.api.delete(g.id).subscribe({ + next: () => this.load(), + error: err => alert(err?.error?.message ?? 'Delete failed (record may belong to a locked session).'), + }); + } + + private blankForm(): CreateGivingRequest { + return { + memberId: null, givingCategoryId: this.categories[0]?.id ?? 0, amount: 0, + paymentMethod: 'Cash', checkNumber: null, zelleReferenceCode: null, + payPalTransactionId: null, givingDate: new Date().toISOString().slice(0, 10), + isAnonymous: false, notes: null, + }; + } +} +``` + +- [ ] **Step 2: Create the .html** + +`givings-page.component.html`: +```html +
+ + +
+ + + +
+ + + + + {{ g.isAnonymous ? '(Anonymous)' : g.memberName }} + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + +
+
+``` +> The Kendo `datepicker` binds a `Date`; the model uses an ISO string. If binding errors occur, convert in `save()` (`new Date(...).toISOString().slice(0,10)`) and store the picker value in a separate `Date` field. Keep it simple — adjust only if the build/runtime complains. + +- [ ] **Step 3: Create the .scss** + +`givings-page.component.scss`: +```scss +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; } +.form-grid { display: flex; flex-direction: column; gap: 0.75rem; } +.form-grid label { display: flex; flex-direction: column; gap: 0.25rem; } +``` + +- [ ] **Step 4: Build** + +Run: `cd APP; npm run build` +Expected: Build succeeds. + +- [ ] **Step 5: Commit** + +```bash +git add APP/src/app/features/giving/pages/givings-page/ +git commit -m "feat(giving): single giving entry page" +``` + +--- + +## Task 14: Sunday offering batch-entry page (core) + +**Files:** +- Create: `APP/src/app/features/giving/pages/offering-session-page/offering-session-page.component.ts` + `.html` + `.scss` +- Create: `APP/src/app/features/giving/components/member-quick-add-dialog/member-quick-add-dialog.component.ts` + `.html` + +Client-buffered batch (decision B). All lines live in `buffer: OfferingBufferLine[]`; the front-end subtotal is `Σ amount`; one `POST` submits everything. + +- [ ] **Step 1: Create the quick-add member dialog component** + +`member-quick-add-dialog.component.ts`: +```typescript +import { Component, EventEmitter, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { CreateMemberRequest, MemberListItemDto } from '../../../members/models/member.model'; + +@Component({ + selector: 'app-member-quick-add-dialog', + standalone: true, + imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule], + templateUrl: './member-quick-add-dialog.component.html', +}) +export class MemberQuickAddDialogComponent { + @Output() created = new EventEmitter(); + @Output() cancelled = new EventEmitter(); + + firstName_en = ''; + lastName_en = ''; + firstName_zh: string | null = null; + lastName_zh: string | null = null; + phoneCell: string | null = null; + saving = false; + + constructor(private memberApi: MemberApiService) {} + + save(): void { + if (!this.firstName_en || !this.lastName_en) return; + this.saving = true; + const req: CreateMemberRequest = { + firstName_en: this.firstName_en, lastName_en: this.lastName_en, + nickName: null, firstName_zh: this.firstName_zh, lastName_zh: this.lastName_zh, + gender: null, dateOfBirth: null, baptismDate: null, baptismChurch: null, + email: null, phoneCell: this.phoneCell, phoneHome: null, address: null, + city: null, state: null, zipCode: null, country: 'USA', + status: 'Visitor', languagePreference: 'en', joinDate: null, + notes: null, familyUnitId: null, + }; + this.memberApi.create(req).subscribe({ + next: ({ id }) => { + this.saving = false; + this.created.emit({ + id, firstName_en: this.firstName_en, lastName_en: this.lastName_en, + nickName: null, firstName_zh: this.firstName_zh, lastName_zh: this.lastName_zh, + status: 'Visitor', email: null, phoneCell: this.phoneCell, joinDate: null, + linkedUserId: null, + }); + }, + error: () => { this.saving = false; }, + }); + } +} +``` + +`member-quick-add-dialog.component.html`: +```html + +
+ + + + + +
+ + + + +
+``` + +- [ ] **Step 2: Create the batch page component .ts** + +`offering-session-page.component.ts`: +```typescript +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule } from '@progress/kendo-angular-grid'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { DatePickerModule } from '@progress/kendo-angular-dateinputs'; +import { OfferingSessionApiService } from '../../services/offering-session-api.service'; +import { GivingCategoryApiService } from '../../services/giving-category-api.service'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { MemberListItemDto } from '../../../members/models/member.model'; +import { MemberQuickAddDialogComponent } from '../../components/member-quick-add-dialog/member-quick-add-dialog.component'; +import { + GivingCategoryDto, PaymentMethod, OfferingBufferLine, CreateOfferingSessionRequest, +} from '../../models/giving.model'; + +@Component({ + selector: 'app-offering-session-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, + DropDownsModule, DatePickerModule, MemberQuickAddDialogComponent, + ], + templateUrl: './offering-session-page.component.html', + styleUrls: ['./offering-session-page.component.scss'], +}) +export class OfferingSessionPageComponent implements OnInit { + sessionDate: Date = new Date(); + dateConflict = false; + categories: GivingCategoryDto[] = []; + readonly paymentMethods: PaymentMethod[] = ['Cash', 'Check', 'Zelle', 'PayPal', 'Other']; + + // Current entry row + memberResults: MemberListItemDto[] = []; + selectedMember: MemberListItemDto | null = null; + entry = this.blankEntry(); + + // Buffer + buffer: OfferingBufferLine[] = []; + editingIndex: number | null = null; + + // Reconciliation + cashTotal = 0; + checkTotal = 0; + notes: string | null = null; + + showQuickAdd = false; + submitting = false; + + constructor( + private api: OfferingSessionApiService, + private categoryApi: GivingCategoryApiService, + private memberApi: MemberApiService, + ) {} + + ngOnInit(): void { + this.categoryApi.getAll(false).subscribe(c => { + this.categories = c; + this.entry.givingCategoryId = c[0]?.id ?? 0; + }); + this.checkDate(); + } + + get systemTotal(): number { return this.buffer.reduce((s, l) => s + (l.amount || 0), 0); } + get difference(): number { return (this.cashTotal + this.checkTotal) - this.systemTotal; } + + checkDate(): void { + const d = this.toIso(this.sessionDate); + this.api.checkDate(d).subscribe(r => this.dateConflict = r.exists); + } + + onMemberFilter(term: string): void { + if (!term) { this.memberResults = []; return; } + this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe(r => this.memberResults = r.items); + } + onMemberSelected(m: MemberListItemDto | null): void { + this.selectedMember = m; + this.entry.memberId = m ? m.id : null; + this.entry.isAnonymous = false; + } + markAnonymous(): void { + this.entry.isAnonymous = true; this.entry.memberId = null; this.selectedMember = null; + } + + addLine(): void { + if (this.entry.amount <= 0) return; + if (this.entry.paymentMethod === 'Check' && !this.entry.checkNumber) return; + const cat = this.categories.find(c => c.id === this.entry.givingCategoryId); + const line: OfferingBufferLine = { + ...this.entry, + memberName: this.entry.isAnonymous ? null : (this.selectedMember + ? `${this.selectedMember.firstName_en} ${this.selectedMember.lastName_en}` : null), + categoryName: cat?.name_en ?? '', + }; + if (this.editingIndex !== null) { this.buffer[this.editingIndex] = line; this.editingIndex = null; } + else { this.buffer = [...this.buffer, line]; } + this.resetEntry(); + } + + editLine(i: number): void { + const l = this.buffer[i]; + this.entry = { ...l }; + this.selectedMember = null; + this.editingIndex = i; + } + removeLine(i: number): void { this.buffer = this.buffer.filter((_, idx) => idx !== i); } + + onMemberQuickCreated(m: MemberListItemDto): void { + this.showQuickAdd = false; + this.onMemberSelected(m); + } + + submit(): void { + if (this.buffer.length === 0 || this.dateConflict) return; + this.submitting = true; + const req: CreateOfferingSessionRequest = { + sessionDate: this.toIso(this.sessionDate), + cashTotal: this.cashTotal, checkTotal: this.checkTotal, notes: this.notes, + givings: this.buffer.map(l => ({ + memberId: l.memberId, givingCategoryId: l.givingCategoryId, amount: l.amount, + paymentMethod: l.paymentMethod, checkNumber: l.checkNumber, + zelleReferenceCode: l.zelleReferenceCode, payPalTransactionId: l.payPalTransactionId, + isAnonymous: l.isAnonymous, notes: l.notes, + })), + }; + this.api.create(req).subscribe({ + next: () => { + this.submitting = false; + alert('Offering session submitted.'); + this.buffer = []; this.cashTotal = 0; this.checkTotal = 0; this.notes = null; + this.checkDate(); + }, + error: err => { + this.submitting = false; + alert(err?.error?.message ?? 'Submit failed.'); + }, + }); + } + + private resetEntry(): void { + this.selectedMember = null; this.memberResults = []; + this.entry = this.blankEntry(); + this.entry.givingCategoryId = this.categories[0]?.id ?? 0; + } + private blankEntry(): OfferingBufferLine { + return { + memberId: null, givingCategoryId: 0, amount: 0, paymentMethod: 'Cash', + checkNumber: null, zelleReferenceCode: null, payPalTransactionId: null, + isAnonymous: false, notes: null, memberName: null, categoryName: '', + }; + } + private toIso(d: Date): string { return d.toISOString().slice(0, 10); } +} +``` + +- [ ] **Step 3: Create the batch page .html** + +`offering-session-page.component.html`: +```html +
+ + +
+ An offering session for this date already exists. Pick another date, or reopen the existing session to edit. +
+ +
+ + Anonymous + + + + + + + +
+ + + +
+
+ + + + {{ l.isAnonymous ? '(Anonymous)' : l.memberName }} + + + + + + + + + + + + + +
+
Lines: {{ buffer.length }} | System total: {{ systemTotal | currency }}
+ + +
+ Difference: {{ difference | currency }} +
+ +
+ + +
+``` + +- [ ] **Step 4: Create the batch page .scss** + +`offering-session-page.component.scss`: +```scss +.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } +.warn { background: #fff3cd; padding: 0.5rem 1rem; border-radius: 4px; margin-bottom: 1rem; } +.entry-row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; margin-bottom: 1rem; } +.entry-row label { display: flex; flex-direction: column; gap: 0.25rem; } +.entry-actions { display: flex; gap: 0.5rem; } +.anon-chip { padding: 0.25rem 0.5rem; background: #eee; border-radius: 4px; } +.reconcile { display: flex; gap: 1rem; align-items: flex-end; margin-top: 1rem; } +.reconcile .ok { color: green; font-weight: 600; } +.reconcile .bad { color: #c00; font-weight: 600; } +``` + +- [ ] **Step 5: Build** + +Run: `cd APP; npm run build` +Expected: Build succeeds. + +- [ ] **Step 6: Wire the routes (Task 11 Step 1)** + +Now apply Task 11 Step 1 (add the three component imports + routes to `app.routes.ts`). + +Run: `cd APP; npm run build` +Expected: Build succeeds with routes wired. + +- [ ] **Step 7: Commit** + +```bash +git add APP/src/app/features/giving/pages/offering-session-page/ APP/src/app/features/giving/components/member-quick-add-dialog/ APP/src/app/app.routes.ts +git commit -m "feat(giving): keyboard-first Sunday offering batch entry page + routes" +``` + +--- + +## Task 15: End-to-end manual verification + +**Files:** none (manual) + +- [ ] **Step 1: Start the backend** + +Run (from `API/ROLAC.API`): `dotnet run` +Expected: App starts, migrations apply, giving categories seeded. Check Swagger at the dev URL shows `giving-categories`, `givings`, `offering-sessions` endpoints. + +- [ ] **Step 2: Start the frontend** + +Run (from `APP`): `npm start` +Expected: App serves on http://localhost:4200. + +- [ ] **Step 3: Verify the happy path as a finance/super_admin user** + +Log in as `admin@rolac.org` / `Admin1234!` (super_admin). Confirm: +1. Finance nav section is visible; non-finance roles do not see it. +2. Giving Types page lists the 5 seeded categories; add/edit/deactivate works. +3. Single Givings page: add a cash giving and an anonymous giving; both appear in the grid. +4. Offering Entry page: pick a date, add several lines (member + anonymous + a check with check #), watch the system subtotal update; enter cash/check counted; Difference shows; Submit succeeds. +5. Re-submitting the same date shows the duplicate-date conflict warning. +6. Quick-add member during batch entry creates a Visitor and selects them into the current line. + +- [ ] **Step 4: Verify locking** + +In Swagger or via the UI, attempt to edit/delete a giving that belongs to the submitted session → expect 409. `POST /offering-sessions/{id}/reopen` → then `PUT /offering-sessions/{id}` with new lines succeeds and re-locks. + +- [ ] **Step 5: Final full test run** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj` +Expected: All tests pass. + +- [ ] **Step 6: Commit any verification fixes, then finish the branch** + +Use the `superpowers:finishing-a-development-branch` skill to decide merge/PR. + +--- + +## Notes for the implementer + +- **No AuditLog table exists** (spec R4). "Audit" = `CreatedBy/UpdatedBy/UpdatedAt` stamp fields set automatically by `AuditSaveChangesInterceptor`. Reopen/replace overwrites `UpdatedBy/UpdatedAt`; old line values are not retained beyond that. If full audit history is later required, that's a separate module. +- **Decision B trade-off (spec R2):** the batch buffer is client-only until Submit. If the browser closes mid-entry, unsaved lines are lost. `localStorage` autosave is intentionally out of scope; add later if users hit this. +- **Kendo module names** may need small adjustments to import paths/module names depending on the installed `@progress/kendo-angular-*` versions. Follow whatever the existing members/users pages import. +- **DateOnly / date strings:** backend uses `DateOnly` (JSON `yyyy-MM-dd` via the configured `TolerantDateOnlyConverter`). Kendo date pickers bind `Date`; convert at the boundary (`toIso`). +``` diff --git a/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md b/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md new file mode 100644 index 0000000..6bf0a65 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md @@ -0,0 +1,298 @@ +# Design Spec: 奉獻追蹤(手動)Giving / Donation Tracking + +**日期:** 2026-05-28 +**作者:** Chris Chen +**狀態:** 待核准 +**對應規劃:** `docs/PLANNING.md §3.6 / §3.6c`、`docs/DB_SCHEMA.md §7` + +--- + +## 1. 範疇 + +本 spec 只規劃**奉獻追蹤(手動)**模組,三項核心功能: + +1. **奉獻類型設定** — `GivingCategory` CRUD(什一 / 一般 / 特別 / 建堂 / 宣教…) +2. **單筆奉獻記錄** — 單筆 `Giving`(現金 / 支票 / Zelle / PayPal 手動) +3. **主日奉獻袋批次輸入** — `OfferingSession` 鍵盤優先快速錄入 + 對帳 + +**明確不在本次範圍(之後另開 spec):** +- 支出追蹤 & 報銷、月結對帳表(屬另一模組) +- 年度 IRS 收據(`GivingReceipt`,PLANNING §3.7) +- 個人奉獻歷史查詢、奉獻摘要報表(PLANNING §3.9) +- 線上金流 Stripe / PayPal Checkout、定期奉獻(`GivingRecurringSchedule`,Phase 4) +- 收據照片上傳(奉獻無此需求;blob 儲存尚未建置) + +--- + +## 2. 關鍵決策摘要 + +| # | 決策 | 選擇 | +|---|------|------| +| D1 | 批次錄入儲存策略 | **B — 純前端緩衝、最後一次 bulk submit**。前端記憶體累積整批,提交時一次 `POST` 建立 Session + 所有 Giving(單一 transaction)。 | +| D2 | Session 鎖定 | **Submitted 後鎖定**。修改需 `finance` 角色 `reopen`(回 Draft、整批載回前端)→ 編輯 → 重新提交整批替換。所有變動進 Audit Log。 | +| D3 | 奇款人識別 | giver 永遠是 **Member 或匿名**(維持 DB_SCHEMA 不變,不加 GiverName 自由欄)。非匿名又查無此人時,批次介面可**快速新增 Member**。 | +| D4 | 快速新增教友欄位 | 快速表單**要求英文姓名**(`FirstName_en` / `LastName_en`),與現有 `Member` schema 必填一致;`Status` 預設 `Visitor`。 | +| D5 | 授權 | 奉獻所有寫入操作限 `finance` + `super_admin`(對應 PLANNING §4 權限矩陣)。 | + +--- + +## 3. 資料模型 + +照 `DB_SCHEMA.md §7`,新增 3 個 entity,全部繼承 `AuditableEntity`(非軟刪除 —— 奉獻記錄為金錢帳目,刪除走實刪 + Audit Log;與 `Member` 的軟刪除不同)。 + +> **討論點(待確認):** DB_SCHEMA §7 的 Giving / OfferingSession 未列 `IsDeleted`。本 spec 採實刪 + Audit。若日後需保留刪除痕跡,可改繼承 `SoftDeleteEntity`,但會影響月結加總邏輯(需排除 `IsDeleted`)。本次維持實刪。 + +### 3.1 GivingCategory(奉獻類型) + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| Name_en | varchar(200) NOT NULL | e.g. 'Tithe' | +| Name_zh | varchar(200)? | e.g. '什一奉獻' | +| Description_en | varchar(500)? | | +| Description_zh | varchar(500)? | | +| IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用(`IsActive=false`),不實刪,以保留歷史 Giving 的 FK | +| SortOrder | int NOT NULL DEFAULT 0 | | +| + AuditableEntity 欄位 | | CreatedAt/By, UpdatedAt/By | + +### 3.2 OfferingSession(主日奉獻袋批次) + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| SessionDate | date NOT NULL **UNIQUE** | 一個主日只有一個 Session | +| Status | varchar(20) NOT NULL DEFAULT 'Draft' | 'Draft' \| 'Submitted' \| 'Reconciled' | +| CashTotal | decimal(18,2) NOT NULL DEFAULT 0 | 財務人工清點現金總額 | +| CheckTotal | decimal(18,2) NOT NULL DEFAULT 0 | 支票清點總額 | +| SystemTotal | decimal(18,2) NOT NULL DEFAULT 0 | **伺服器**計算的明細加總 | +| Difference | decimal(18,2) NOT NULL DEFAULT 0 | = (CashTotal + CheckTotal) − SystemTotal,目標 0 | +| Notes | text? | | +| SubmittedAt / SubmittedBy | timestamp? / varchar(450)? | | +| ReconciledAt / ReconciledBy | timestamp? / varchar(450)? | | +| + AuditableEntity 欄位 | | | + +> **Reconciled 狀態用途:** 本次仍實作 `submit`(Draft→Submitted)。`Reconciled` 作為日後月結對帳模組對接的終態保留欄位,本次 UI 不主動設定(只在 schema 預留)。 + +### 3.3 Giving(奉獻記錄) + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| MemberId | int? | FK → Members.Id;null = 匿名 | +| GivingCategoryId | int NOT NULL | FK → GivingCategories.Id | +| OfferingSessionId | int? | FK → OfferingSessions.Id;null = 非批次單筆 | +| Amount | decimal(18,2) NOT NULL | IRS 收據用金額 | +| PaymentMethod | varchar(20) NOT NULL | 'Cash' \| 'Check' \| 'Zelle' \| 'PayPal' \| 'Other' | +| CheckNumber | varchar(50)? | PaymentMethod=Check 時必填 | +| ZelleReferenceCode | varchar(100)? | PaymentMethod=Zelle | +| PayPalTransactionId | varchar(100)? | PaymentMethod=PayPal | +| GivingDate | date NOT NULL | 批次時 = SessionDate | +| IsAnonymous | bool NOT NULL DEFAULT false | | +| Notes | varchar(500)? | | +| + AuditableEntity 欄位 | | | + +> Phase 4 欄位(`GrossAmount` / `FeeAmount` / `StripePaymentIntentId` / `GivingRecurringScheduleId`)本次**不建**,待線上金流模組再加,屆時為純新增欄位,不影響本次結構。 + +### 3.4 EF Core 設定(`AppDbContext.OnModelCreating`) + +- 新增 3 個 `DbSet` / `` / ``。 +- fluent 設定:各 `Name_*` / `Description_*` maxlength;decimal 欄位 `decimal(18,2)`(沿用既有全域 decimal 慣例,或逐欄設定)。 +- `OfferingSession`:`HasIndex(SessionDate).IsUnique()`。 +- `Giving` 索引(對應 DB_SCHEMA §17): + - `idx_givings_member_date` → `(MemberId, GivingDate)` + - `idx_givings_session` → `(OfferingSessionId)` filter not null + - `idx_givings_date` → `(GivingDate)` +- FK 行為: + - `Giving.GivingCategoryId` → `Restrict`(類型有奉獻時不可實刪,只能軟停用) + - `Giving.MemberId` → `SetNull` + - `Giving.OfferingSessionId` → `Cascade`(刪 Session 連帶刪明細;一般不會發生,僅資料一致性) + +### 3.5 Seed(`DbSeeder`) + +新增 `SeedGivingCategoriesAsync`(沿用既有 code-based 風格,非 `HasData`): + +``` +1. Tithe / 什一奉獻 +2. General Offering / 一般奉獻 +3. Special Offering / 特別奉獻 +4. Building Fund / 建堂基金 +5. Mission / 宣教奉獻 +``` + +### 3.6 Migration + +一支 EF migration(如 `AddGivingModule`),涵蓋 3 表 + 索引 + FK。 + +--- + +## 4. API 介面 + +全部 `[Authorize(Roles = "finance,super_admin")]`。Controller 維持 thin → 委派 service,沿用既有 `PagedResult<>` 與錯誤轉換慣例(`KeyNotFoundException`→404,鎖定衝突→409)。 + +### 4.1 奉獻類型 `GivingCategoriesController` — `/api/giving-categories` +``` +GET /api/giving-categories?includeInactive=false 全部(預設僅 active) +POST /api/giving-categories +PUT /api/giving-categories/{id} +DELETE /api/giving-categories/{id} 軟停用 IsActive=false +``` + +### 4.2 單筆奉獻 `GivingsController` — `/api/givings` +``` +GET /api/givings?page&pageSize&search&categoryId&from&to 分頁列表 +GET /api/givings/{id} +POST /api/givings 單筆,OfferingSessionId=null +PUT /api/givings/{id} +DELETE /api/givings/{id} +``` +- `search` 比對 Member 姓名(中英)/ CheckNumber / Notes。 +- 屬於某 Session(`OfferingSessionId != null`)的 Giving,若該 Session 已 `Submitted`,單筆 PUT/DELETE 一律拒絕(409)—— 必須走 Session `reopen`。 + +### 4.3 批次 `OfferingSessionsController` — `/api/offering-sessions` +``` +GET /api/offering-sessions?page&pageSize&from&to 歷次 Session 列表 +GET /api/offering-sessions/{id} 含完整明細 + 小計(reopen/檢視用) +POST /api/offering-sessions 一次建立整批(見 §6) +POST /api/offering-sessions/{id}/reopen Submitted→Draft(finance,進 Audit) +PUT /api/offering-sessions/{id} 整批替換明細 + 總額,重新 Submitted +GET /api/offering-sessions/check-date?date=... 檢查該日期是否已有 Session +``` +- 批次教友搜尋:**復用** `GET /api/members?search=`(已支援中英文,見 `MemberService`)。 +- 批次內快速新增教友:**復用** `POST /api/members`(現有 `CreateMemberRequest`)。 + +#### POST /api/offering-sessions 請求形狀 +```jsonc +{ + "sessionDate": "2026-05-31", + "cashTotal": 1250.00, + "checkTotal": 800.00, + "notes": "...", + "givings": [ + { "memberId": 12, "givingCategoryId": 1, "amount": 100.00, + "paymentMethod": "Cash", "isAnonymous": false, "notes": null }, + { "memberId": null, "givingCategoryId": 2, "amount": 50.00, + "paymentMethod": "Cash", "isAnonymous": true }, + { "memberId": 8, "givingCategoryId": 1, "amount": 300.00, + "paymentMethod": "Check", "checkNumber": "1043" } + ] +} +``` +回傳建立後的 Session(含 `id`、伺服器算的 `systemTotal`、`difference`、`status="Submitted"`)。 + +--- + +## 5. 後端 Service 層 + +3 個 service(interface + impl,沿用 `MemberService` 風格:建構式注入 `AppDbContext` + `IHttpContextAccessor`、`CurrentUserId`、手動 DTO mapping)。 + +- **`IGivingCategoryService`** — CRUD;DELETE = 設 `IsActive=false`。 +- **`IGivingService`** — 單筆 CRUD + 分頁查詢;寫入前檢查所屬 Session 鎖定狀態。 +- **`IOfferingSessionService`** — 核心: + - `CreateAsync(request)`:單一 transaction 內建立 `OfferingSession`(Status=Submitted)+ 所有 `Giving`(`OfferingSessionId` 回填、`GivingDate=SessionDate`);**伺服器重算** `SystemTotal = Σ amount`、`Difference`;`SubmittedAt/By` 設值。`SessionDate` 撞 unique → 409。 + - `GetByIdAsync(id)`:含明細投影。 + - `ReopenAsync(id)`:僅 `Submitted` 可 reopen → `Draft`,清 `SubmittedAt/By`。 + - `ReplaceAsync(id, request)`:僅 `Draft` 可改;刪舊明細、插新明細、重算、回 `Submitted`。 + - **鎖定守門**:對已 `Submitted` 的修改丟 `InvalidOperationException` → controller 轉 409。 + - 金額一律以伺服器加總為準(不信任前端 `systemTotal`)。 + +--- + +## 6. 批次錄入流程(前端,核心體驗) + +頁面:`features/giving/pages/offering-session-page`。採決策 D1(純前端緩衝)。 + +``` +1. 選日期(預設今天)→ 呼叫 check-date 確認當日尚無 Session +2. 逐筆快速錄入(全部存在前端記憶體 buffer) + ┌─────────────────────────────────────────────┐ + │ 搜尋教友:[即時下拉] ← 復用 GET /api/members │ + │ 類型:[GivingCategory ▼] │ + │ 付款:(●現金 ○支票 ○Zelle ○PayPal) │ + │ 現金→信封號(選填) / 支票→支票號(必填) │ + │ 金額:[$____] 備註:[____] │ + │ [匿名] [快速新增教友] [+ 新增(Enter)] │ + │ ───────────────────────────────────────── │ + │ 已錄入 N 筆 | 前端小計:$X,XXX.XX │ + └─────────────────────────────────────────────┘ + - Tab 跳欄、Enter 新增;新增後焦點回搜尋框 + - 樂觀 UI:新增即在右側清單顯示、即時更新前端小計 + - 清單每列可即時改 / 刪(純前端操作) +3. 對帳步驟:輸入實點 CashTotal / CheckTotal + → 前端顯示 Difference = (Cash+Check) − 前端小計(目標 0) +4. 「提交」→ 一次 POST /api/offering-sessions(整批) + → 伺服器 transaction 建立 + 重算 + 回傳 → 鎖定 +5. 編輯已提交:finance 在歷次列表點 reopen + → GET 載回整批進 buffer → 編輯 → PUT 整批替換 → 重新 Submitted +``` + +**鍵盤優先要點:** 復用既有 `focus-navigator` / `init-focus` directives 與 `currency-input` / `drop-down-list` 共用元件。 + +--- + +## 7. 快速新增教友(批次介面內) + +- 搜尋無結果時出現「快速新增教友」→ inline 小 dialog。 +- 收最少欄位:**`FirstName_en`、`LastName_en`(必填,決策 D4)**、`FirstName_zh`/`LastName_zh`(選填)、`PhoneCell`(選填)。 +- `Status` 預設 `Visitor`。 +- 呼叫現有 `POST /api/members`(`CreateMemberRequest`)→ 取回 `id` → 自動帶回當前錄入列、焦點移至金額。 + +--- + +## 8. 前端 — 其餘頁面 + +依既有 feature 結構 `features/giving/{pages,components,services,models}`,標準元件:standalone component + Kendo Grid/Inputs/Buttons/Dropdowns + RxJS + `ApiConfigService`。 + +### 8.1 奉獻類型管理頁 `giving-categories-page` +Kendo Grid 列表 + 新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 users/members 頁模式。 + +### 8.2 單筆奉獻頁 `givings-page` +Kendo Grid 列表(搜尋 / 日期區間 / 類型篩選 / 分頁)+ 單筆新增/編輯 dialog。付款方式切換時動態顯示支票號 / Zelle ref / PayPal txn 欄位。教友選擇復用 `GET /api/members?search=`。 + +### 8.3 API service / model +每頁一支 `*-api.service.ts` + `models/*.model.ts`(DTO interface,對齊後端 DTO 命名)。 + +--- + +## 9. 授權 & 導覽 + +- 後端:所有奉獻 controller `[Authorize(Roles="finance,super_admin")]`。 +- 前端:沿用既有 role-gated sidebar nav(commit `bc67146` 的 Administration 機制),在 Finance/Administration 區塊新增「奉獻」分組,內含三頁,僅 `finance` / `super_admin` 可見。 +- route guard 沿用 `core/guards` 既有角色守衛。 + +--- + +## 10. 測試 + +後端 `ROLAC.API.Tests`(沿用既有測試風格,InMemory provider): + +- `GivingCategoryService`:CRUD、DELETE 軟停用、有奉獻時類型不可實刪。 +- `GivingService`:單筆 CRUD、分頁/篩選、屬已 Submitted Session 的單筆寫入被拒(409)。 +- `OfferingSessionService`(重點): + - 整批建立 → `SystemTotal` / `Difference` 伺服器重算正確(不信前端值)。 + - `SessionDate` 撞 unique → 409。 + - 已 `Submitted` 修改被拒(鎖定守門)。 + - `reopen` → `replace` → 重新 `Submitted` 流程,明細正確替換。 + - 匿名筆(`MemberId=null` / `IsAnonymous=true`)正確記錄。 +- 授權:非 finance/super_admin 角色被 403。 + +--- + +## 11. 風險 / 待確認 + +| # | 項目 | 說明 | +|---|------|------| +| R1 | 實刪 vs 軟刪除(§3 討論點) | 本次 Giving/Session 採實刪 + Audit。若財務合規要求保留刪除痕跡,需改軟刪除並調整加總邏輯。**待 Chris 確認。** | +| R2 | 前端緩衝資料遺失(決策 B 的取捨) | 提交前瀏覽器崩潰 → 整批未存的資料遺失。可選緩解:`localStorage` 暫存草稿(本次列為可選,不強制)。 | +| R3 | reopen 整批替換的 Audit 粒度 | 整批替換會產生「刪舊 N 筆 + 插新 M 筆」的 Audit 記錄,而非逐筆 diff。對帳追溯時需理解此模型。 | +| R4 | Audit Log 基礎設施 | 現有 `AuditSaveChangesInterceptor` 是否已涵蓋這些新 entity 的 Create/Update/Delete,需在實作時確認;`AuditLog` 表本身屬另一模組,若尚未建置需補。 | + +--- + +## 12. 交付順序建議 + +1. Entities + EF 設定 + Seed + migration +2. Service 層(3 個)+ 單元測試 +3. Controllers + 授權 +4. 前端 models + api services +5. 奉獻類型管理頁 → 單筆奉獻頁 → 批次錄入頁 +6. 導覽整合 + 端到端手動驗證