feat(giving): single-entry giving service with paging + lock guard

Adds GivingListItemDto, GivingDto, CreateGivingRequest, UpdateGivingRequest DTOs;
IGivingService interface; GivingService implementation with category/date filtering,
OfferingSession lock guard (Submitted/Reconciled), and DI registration in Program.cs.
Covered by 4 xUnit tests (TDD: red → green).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-28 16:38:32 -07:00
parent 81efaedbc2
commit 2b6f29e775
8 changed files with 297 additions and 0 deletions
@@ -0,0 +1,103 @@
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<IHttpContextAccessor>();
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<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> 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<InvalidOperationException>(() =>
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<KeyNotFoundException>(() => svc.DeleteAsync(999));
}
}
@@ -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; }
}
+18
View File
@@ -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; }
}
@@ -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; }
}
@@ -0,0 +1,3 @@
namespace ROLAC.API.DTOs.Giving;
public class UpdateGivingRequest : CreateGivingRequest { }
+1
View File
@@ -119,6 +119,7 @@ builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IMemberService, MemberService>(); builder.Services.AddScoped<IMemberService, MemberService>();
builder.Services.AddScoped<IUserManagementService, UserManagementService>(); builder.Services.AddScoped<IUserManagementService, UserManagementService>();
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>(); builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
builder.Services.AddScoped<IGivingService, GivingService>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Swagger / MVC // Swagger / MVC
+127
View File
@@ -0,0 +1,127 @@
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<PagedResult<GivingListItemDto>> 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();
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<GivingListItemDto>
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<GivingDto?> 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<int> 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;
}
}
+14
View File
@@ -0,0 +1,14 @@
using ROLAC.API.DTOs.Giving;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IGivingService
{
Task<PagedResult<GivingListItemDto>> GetPagedAsync(
int page, int pageSize, string? search, int? categoryId, DateOnly? from, DateOnly? to);
Task<GivingDto?> GetByIdAsync(int id);
Task<int> CreateAsync(CreateGivingRequest request);
Task UpdateAsync(int id, UpdateGivingRequest request);
Task DeleteAsync(int id);
}