diff --git a/API/ROLAC.API.Tests/Services/GivingServiceTests.cs b/API/ROLAC.API.Tests/Services/GivingServiceTests.cs new file mode 100644 index 0000000..f400994 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/GivingServiceTests.cs @@ -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(); + 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)); + } +} 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/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/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/Program.cs b/API/ROLAC.API/Program.cs index 95b4dea..ce050c0 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -119,6 +119,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/GivingService.cs b/API/ROLAC.API/Services/GivingService.cs new file mode 100644 index 0000000..b3a105f --- /dev/null +++ b/API/ROLAC.API/Services/GivingService.cs @@ -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> 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 + { 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/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); +}