feat(expense): add MonthlyStatementService with server-side recompute + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class MonthlyStatementServiceTests
|
||||
{
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
||||
}
|
||||
|
||||
private static MonthlyStatementService Build(AppDbContext db)
|
||||
{
|
||||
var mock = new Mock<IHttpContextAccessor>();
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||
return new MonthlyStatementService(db, mock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_ComputesGivingAndPaidExpenses_ForMonthOnly()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
db.GivingCategories.Add(new GivingCategory { Id = 1, Name_en = "Tithe" });
|
||||
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
|
||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = Build(db);
|
||||
|
||||
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest
|
||||
{ Year = 2026, Month = 5, OpeningBalance = 2000m, TotalOtherIncome = 100m, BankStatementBalance = 2800m });
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
Assert.Equal(1000m, dto!.TotalGiving);
|
||||
Assert.Equal(300m, dto.TotalExpenses);
|
||||
Assert.Equal(2800m, dto.CalculatedClosingBalance);
|
||||
Assert.Equal(0m, dto.Difference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Duplicate_Throws()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = Build(db);
|
||||
await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_AfterFinalize_Throws()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = Build(db);
|
||||
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
|
||||
await svc.FinalizeAsync(id);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.UpdateAsync(id, new UpdateMonthlyStatementRequest { OpeningBalance = 1m }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public interface IMonthlyStatementService
|
||||
{
|
||||
Task<List<MonthlyStatementDto>> GetAllAsync(int? year);
|
||||
Task<MonthlyStatementDto?> GetByIdAsync(int id);
|
||||
Task<int> CreateAsync(CreateMonthlyStatementRequest r);
|
||||
Task UpdateAsync(int id, UpdateMonthlyStatementRequest r);
|
||||
Task FinalizeAsync(int id);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Services;
|
||||
|
||||
public class MonthlyStatementService : IMonthlyStatementService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; }
|
||||
|
||||
private string CurrentUserId =>
|
||||
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
|
||||
|
||||
public async Task<List<MonthlyStatementDto>> GetAllAsync(int? year)
|
||||
{
|
||||
var query = _db.MonthlyStatements.AsNoTracking().AsQueryable();
|
||||
if (year.HasValue) query = query.Where(s => s.Year == year.Value);
|
||||
var list = await query.OrderByDescending(s => s.Year).ThenByDescending(s => s.Month).ToListAsync();
|
||||
return list.Select(Map).ToList();
|
||||
}
|
||||
|
||||
public async Task<MonthlyStatementDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var s = await _db.MonthlyStatements.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
return s is null ? null : Map(s);
|
||||
}
|
||||
|
||||
public async Task<int> CreateAsync(CreateMonthlyStatementRequest r)
|
||||
{
|
||||
if (await _db.MonthlyStatements.AnyAsync(s => s.Year == r.Year && s.Month == r.Month))
|
||||
throw new InvalidOperationException($"A statement for {r.Year}-{r.Month:D2} already exists.");
|
||||
|
||||
var s = new MonthlyStatement
|
||||
{
|
||||
Year = r.Year, Month = r.Month,
|
||||
OpeningBalance = r.OpeningBalance, TotalOtherIncome = r.TotalOtherIncome,
|
||||
BankStatementBalance = r.BankStatementBalance, Notes = r.Notes,
|
||||
};
|
||||
await RecomputeAsync(s);
|
||||
_db.MonthlyStatements.Add(s);
|
||||
await _db.SaveChangesAsync();
|
||||
return s.Id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(int id, UpdateMonthlyStatementRequest r)
|
||||
{
|
||||
var s = await _db.MonthlyStatements.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
|
||||
if (s.IsFinalized) throw new InvalidOperationException("Statement is finalized and cannot be modified.");
|
||||
s.OpeningBalance = r.OpeningBalance; s.TotalOtherIncome = r.TotalOtherIncome;
|
||||
s.BankStatementBalance = r.BankStatementBalance; s.Notes = r.Notes;
|
||||
await RecomputeAsync(s);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task FinalizeAsync(int id)
|
||||
{
|
||||
var s = await _db.MonthlyStatements.FindAsync(id)
|
||||
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
|
||||
s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task RecomputeAsync(MonthlyStatement s)
|
||||
{
|
||||
var first = new DateOnly(s.Year, s.Month, 1);
|
||||
var next = first.AddMonths(1);
|
||||
|
||||
s.TotalGiving = await _db.Givings
|
||||
.Where(g => g.GivingDate >= first && g.GivingDate < next)
|
||||
.SumAsync(g => (decimal?)g.Amount) ?? 0m;
|
||||
|
||||
s.TotalExpenses = await _db.Expenses
|
||||
.Where(e => e.Status == "Paid" && e.ExpenseDate >= first && e.ExpenseDate < next)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||
|
||||
s.CalculatedClosingBalance = s.OpeningBalance + s.TotalGiving + s.TotalOtherIncome - s.TotalExpenses;
|
||||
s.Difference = s.CalculatedClosingBalance - s.BankStatementBalance;
|
||||
}
|
||||
|
||||
private static MonthlyStatementDto Map(MonthlyStatement s) => new()
|
||||
{
|
||||
Id = s.Id, Year = s.Year, Month = s.Month,
|
||||
OpeningBalance = s.OpeningBalance, TotalGiving = s.TotalGiving, TotalOtherIncome = s.TotalOtherIncome,
|
||||
TotalExpenses = s.TotalExpenses, CalculatedClosingBalance = s.CalculatedClosingBalance,
|
||||
BankStatementBalance = s.BankStatementBalance, Difference = s.Difference,
|
||||
Notes = s.Notes, IsFinalized = s.IsFinalized,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user