From 86d9879a6d79e96a4f11f99ce65ca2ecfa36085b Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Fri, 29 May 2026 18:34:39 -0700 Subject: [PATCH] feat(expense): add MonthlyStatementService with server-side recompute + tests Co-Authored-By: Claude Sonnet 4.6 --- .../Services/MonthlyStatementServiceTests.cs | 80 ++++++++++++++++ .../Services/IMonthlyStatementService.cs | 11 +++ .../Services/MonthlyStatementService.cs | 93 +++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs create mode 100644 API/ROLAC.API/Services/IMonthlyStatementService.cs create mode 100644 API/ROLAC.API/Services/MonthlyStatementService.cs diff --git a/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs b/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs new file mode 100644 index 0000000..1fa5b34 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/MonthlyStatementServiceTests.cs @@ -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(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + } + + private static MonthlyStatementService Build(AppDbContext db) + { + var mock = new Mock(); + 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(() => + 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(() => + svc.UpdateAsync(id, new UpdateMonthlyStatementRequest { OpeningBalance = 1m })); + } +} diff --git a/API/ROLAC.API/Services/IMonthlyStatementService.cs b/API/ROLAC.API/Services/IMonthlyStatementService.cs new file mode 100644 index 0000000..898bc2a --- /dev/null +++ b/API/ROLAC.API/Services/IMonthlyStatementService.cs @@ -0,0 +1,11 @@ +using ROLAC.API.DTOs.Expense; +namespace ROLAC.API.Services; + +public interface IMonthlyStatementService +{ + Task> GetAllAsync(int? year); + Task GetByIdAsync(int id); + Task CreateAsync(CreateMonthlyStatementRequest r); + Task UpdateAsync(int id, UpdateMonthlyStatementRequest r); + Task FinalizeAsync(int id); +} diff --git a/API/ROLAC.API/Services/MonthlyStatementService.cs b/API/ROLAC.API/Services/MonthlyStatementService.cs new file mode 100644 index 0000000..222b8a8 --- /dev/null +++ b/API/ROLAC.API/Services/MonthlyStatementService.cs @@ -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> 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 GetByIdAsync(int id) + { + var s = await _db.MonthlyStatements.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + return s is null ? null : Map(s); + } + + public async Task 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, + }; +}