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:
@@ -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; }
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -119,6 +119,7 @@ builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Swagger / MVC
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user