From e04776460db61681010653bf5df8a05f2634d2a6 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 28 May 2026 16:47:19 -0700 Subject: [PATCH] feat(giving): offering-session batch service with server-side totals + locking Co-Authored-By: Claude Sonnet 4.6 --- .../Services/OfferingSessionServiceTests.cs | 132 +++++++++++++++ .../Giving/CreateOfferingSessionRequest.cs | 11 ++ .../DTOs/Giving/OfferingGivingLineDto.cs | 15 ++ .../DTOs/Giving/OfferingGivingLineRequest.cs | 15 ++ .../DTOs/Giving/OfferingSessionDto.cs | 14 ++ .../DTOs/Giving/OfferingSessionListItemDto.cs | 13 ++ API/ROLAC.API/Program.cs | 1 + .../Services/IOfferingSessionService.cs | 15 ++ .../Services/OfferingSessionService.cs | 158 ++++++++++++++++++ 9 files changed, 374 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs create mode 100644 API/ROLAC.API/DTOs/Giving/CreateOfferingSessionRequest.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingGivingLineDto.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingGivingLineRequest.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingSessionDto.cs create mode 100644 API/ROLAC.API/DTOs/Giving/OfferingSessionListItemDto.cs create mode 100644 API/ROLAC.API/Services/IOfferingSessionService.cs create mode 100644 API/ROLAC.API/Services/OfferingSessionService.cs diff --git a/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs new file mode 100644 index 0000000..4e9593e --- /dev/null +++ b/API/ROLAC.API.Tests/Services/OfferingSessionServiceTests.cs @@ -0,0 +1,132 @@ +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)); + } +} 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/OfferingGivingLineDto.cs b/API/ROLAC.API/DTOs/Giving/OfferingGivingLineDto.cs new file mode 100644 index 0000000..28c822b --- /dev/null +++ b/API/ROLAC.API/DTOs/Giving/OfferingGivingLineDto.cs @@ -0,0 +1,15 @@ +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; } +} 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/Program.cs b/API/ROLAC.API/Program.cs index ce050c0..ab80cb9 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -120,6 +120,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC 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..6b7df50 --- /dev/null +++ b/API/ROLAC.API/Services/OfferingSessionService.cs @@ -0,0 +1,158 @@ +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); + + 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(); + 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, + }; +}