feat(giving): offering-session batch service with server-side totals + locking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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<InvalidOperationException>(() =>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OfferingGivingLineRequest> Givings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<OfferingGivingLineDto> Givings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -120,6 +120,7 @@ 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>();
|
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||||
|
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Swagger / MVC
|
// Swagger / MVC
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using ROLAC.API.DTOs.Giving;
|
||||||
|
using ROLAC.API.DTOs.Shared;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IOfferingSessionService
|
||||||
|
{
|
||||||
|
Task<PagedResult<OfferingSessionListItemDto>> GetPagedAsync(
|
||||||
|
int page, int pageSize, DateOnly? from, DateOnly? to);
|
||||||
|
Task<OfferingSessionDto?> GetByIdAsync(int id);
|
||||||
|
Task<bool> DateExistsAsync(DateOnly date);
|
||||||
|
Task<int> CreateAsync(CreateOfferingSessionRequest request);
|
||||||
|
Task ReopenAsync(int id);
|
||||||
|
Task ReplaceAsync(int id, CreateOfferingSessionRequest request);
|
||||||
|
}
|
||||||
@@ -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<PagedResult<OfferingSessionListItemDto>> 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<OfferingSessionListItemDto>
|
||||||
|
{ Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DateExistsAsync(DateOnly date)
|
||||||
|
=> await _db.OfferingSessions.AnyAsync(s => s.SessionDate == date);
|
||||||
|
|
||||||
|
public async Task<OfferingSessionDto?> 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<int> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user