diff --git a/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs new file mode 100644 index 0000000..0a985e3 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseServiceTests.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore; +using System.Security.Claims; +using System.Text; +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 ROLAC.API.Services.Storage; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ExpenseServiceTests +{ + private sealed class FakeStorage : IFileStorage + { + public Dictionary Files = new(); + public Task SaveAsync(Stream c, string p, CancellationToken ct = default) + { using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); } + public Task OpenReadAsync(string p, CancellationToken ct = default) + => Task.FromResult(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null); + public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; } + } + + private static AppDbContext BuildDb(string userId) + { + 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 new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); + } + + private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1") + { + var db = BuildDb(userId); + db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" }); + db.SaveChanges(); + var fs = new FakeStorage(); + return (SvcAs(db, fs, userId), db, fs); + } + + private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId) + { + var http = new Mock(); + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; + http.Setup(x => x.HttpContext).Returns(ctx); + return new ExpenseService(db, http.Object, fs); + } + + private static CreateExpenseRequest Reimb() => new() + { + Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, + Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28), + }; + + private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new() + { + Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, + SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description, + VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber, + ExpenseDate = r.ExpenseDate, Notes = r.Notes, + }; + + [Fact] + public async Task Create_Vendor_AsFinance_IsImmediatelyPaid() + { + var (svc, db, _) = Build(); + var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051"; + var id = await svc.CreateAsync(r, isFinance: true); + Assert.Equal("Paid", (await db.Expenses.FindAsync(id))!.Status); + } + + [Fact] + public async Task Create_Reimbursement_IsDraft_WithSubmitter() + { + var (svc, db, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + var e = await db.Expenses.FindAsync(id); + Assert.Equal("Draft", e!.Status); + Assert.Equal("alice", e.SubmittedBy); + } + + [Fact] + public async Task StateMachine_HappyPath_Submit_Approve_Pay() + { + var (svc, db, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); + await svc.ApproveAsync(id); + Assert.Equal("Approved", (await db.Expenses.FindAsync(id))!.Status); + await svc.PayAsync(id, "3001", new DateOnly(2026, 6, 1)); + var paid = await db.Expenses.FindAsync(id); + Assert.Equal("Paid", paid!.Status); + Assert.Equal("3001", paid.CheckNumber); + } + + [Fact] + public async Task Approve_FromDraft_Throws() + { + var (svc, _, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await Assert.ThrowsAsync(() => svc.ApproveAsync(id)); + } + + [Fact] + public async Task Reject_RecordsNotes_AndStatus() + { + var (svc, db, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.SubmitAsync(id); + await svc.RejectAsync(id, "Missing receipt"); + var e = await db.Expenses.FindAsync(id); + Assert.Equal("Rejected", e!.Status); + Assert.Equal("Missing receipt", e.ReviewNotes); + } + + [Fact] + public async Task Update_OthersDraft_AsNonFinance_Throws() + { + var (aliceSvc, db, fs) = Build("alice"); + var id = await aliceSvc.CreateAsync(Reimb(), isFinance: false); + var bobSvc = SvcAs(db, fs, "bob"); + await Assert.ThrowsAsync(() => + bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false)); + } + + [Fact] + public async Task SoftDelete_HidesFromQueries() + { + var (svc, db, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + await svc.DeleteAsync(id, isFinance: true); + Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id)); + } + + [Fact] + public async Task Receipt_SaveThenOpen_RoundTrips() + { + var (svc, _, _) = Build("alice"); + var id = await svc.CreateAsync(Reimb(), isFinance: false); + using var input = new MemoryStream(Encoding.UTF8.GetBytes("img")); + await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false); + var got = await svc.OpenReceiptAsync(id, isFinance: true); + Assert.NotNull(got); + } +} diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs new file mode 100644 index 0000000..dac185d --- /dev/null +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -0,0 +1,240 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.DTOs.Shared; +using ROLAC.API.Entities; +using ROLAC.API.Services.Storage; + +namespace ROLAC.API.Services; + +public class ExpenseService : IExpenseService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + private readonly IFileStorage _storage; + + public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage) + { _db = db; _http = http; _storage = storage; } + + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system"; + + public async Task> GetPagedAsync( + int page, int pageSize, string? search, int? ministryId, + int? categoryGroupId, string? status, DateOnly? from, DateOnly? to) + { + var query = _db.Expenses.AsNoTracking().AsQueryable(); + if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value); + if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value); + if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status); + if (from.HasValue) query = query.Where(e => e.ExpenseDate >= from.Value); + if (to.HasValue) query = query.Where(e => e.ExpenseDate <= to.Value); + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.Trim().ToLower(); var term = search.Trim(); + query = query.Where(e => + e.Description.ToLower().Contains(s) || + (e.VendorName != null && e.VendorName.ToLower().Contains(s)) || + (e.Member != null && ( + (e.Member.FirstName_en + " " + e.Member.LastName_en).ToLower().Contains(s) || + (e.Member.FirstName_zh != null && e.Member.FirstName_zh.Contains(term)) || + (e.Member.LastName_zh != null && e.Member.LastName_zh.Contains(term))))); + } + return await ProjectPagedAsync(query, page, pageSize); + } + + public async Task> GetMineAsync(string userId, string? status, int page, int pageSize) + { + var query = _db.Expenses.AsNoTracking().Where(e => e.SubmittedBy == userId); + if (!string.IsNullOrWhiteSpace(status)) query = query.Where(e => e.Status == status); + return await ProjectPagedAsync(query, page, pageSize); + } + + private async Task> ProjectPagedAsync(IQueryable query, int page, int pageSize) + { + var total = await query.CountAsync(); + var rows = await query + .OrderByDescending(e => e.ExpenseDate).ThenByDescending(e => e.Id) + .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); + + var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => m.Name_en); + var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en); + var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en); + var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); + var memNames = 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(e => new ExpenseListItemDto + { + Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, + MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), + CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), + SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""), + VendorName = e.VendorName, MemberId = e.MemberId, + MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null, + ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), + HasReceipt = e.ReceiptBlobPath != null, + }).ToList(); + + return new PagedResult { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; + } + + public async Task GetByIdAsync(int id) + { + var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (e is null) return null; + var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? ""; + var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? ""; + var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? ""; + string? memName = e.MemberId != null + ? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync() + : null; + return new ExpenseDto + { + Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, + MinistryId = e.MinistryId, MinistryName = minName, + CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName, + SubCategoryId = e.SubCategoryId, SubCategoryName = subName, + VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName, + ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, + CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, + SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt, + }; + } + + public async Task CreateAsync(CreateExpenseRequest r, bool isFinance) + { + var e = new Expense + { + MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId, + Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName, + CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes, + }; + + if (r.Type == "VendorPayment") + { + if (!isFinance) throw new InvalidOperationException("Only finance can create vendor payments."); + e.Status = "Paid"; + e.PaidAt = DateTimeOffset.UtcNow; e.PaidBy = CurrentUserId; + e.MemberId = null; + } + else // StaffReimbursement + { + e.Status = "Draft"; + e.SubmittedBy = CurrentUserId; + e.MemberId = isFinance ? r.MemberId : await CallerMemberIdAsync(); + e.VendorName = null; + } + + _db.Expenses.Add(e); + await _db.SaveChangesAsync(); + return e.Id; + } + + private async Task CallerMemberIdAsync() + { + var uid = CurrentUserId; + return await _db.Users.Where(u => u.Id == uid).Select(u => u.MemberId).FirstOrDefaultAsync(); + } + + public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance) + { + // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. + var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Expense {id} not found."); + if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft")) + throw new InvalidOperationException("You can only edit your own draft reimbursements."); + + e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId; + e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; + e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; + if (e.Type == "VendorPayment") e.VendorName = r.VendorName; + await _db.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id, bool isFinance) + { + var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Expense {id} not found."); + if (!isFinance && !(e.SubmittedBy == CurrentUserId && e.Status == "Draft")) + throw new InvalidOperationException("You can only delete your own draft reimbursements."); + e.IsDeleted = true; e.DeletedAt = DateTimeOffset.UtcNow; e.DeletedBy = CurrentUserId; + await _db.SaveChangesAsync(); + } + + // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. + private async Task RequireAsync(int id) => + await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Expense {id} not found."); + + public async Task SubmitAsync(int id) + { + var e = await RequireAsync(id); + if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement."); + if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'."); + e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + + public async Task ApproveAsync(int id) + { + var e = await RequireAsync(id); + if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'."); + e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + + public async Task RejectAsync(int id, string? reviewNotes) + { + var e = await RequireAsync(id); + if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'."); + e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes; + await _db.SaveChangesAsync(); + } + + public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt) + { + var e = await RequireAsync(id); + if (e.Status != "Approved") throw new InvalidOperationException($"Cannot mark paid from status '{e.Status}'."); + e.Status = "Paid"; + if (!string.IsNullOrWhiteSpace(checkNumber)) e.CheckNumber = checkNumber; + e.PaidBy = CurrentUserId; + e.PaidAt = paidAt.HasValue + ? new DateTimeOffset(paidAt.Value.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero) + : DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + + public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance) + { + var e = await RequireAsync(id); + if (!isFinance && e.SubmittedBy != CurrentUserId) + throw new InvalidOperationException("You can only attach receipts to your own reimbursements."); + + var safe = Path.GetFileName(fileName).Replace(' ', '_'); + var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}"; + if (e.ReceiptBlobPath != null && e.ReceiptBlobPath != path) + await _storage.DeleteAsync(e.ReceiptBlobPath); + var saved = await _storage.SaveAsync(content, path); + e.ReceiptBlobPath = saved; + await _db.SaveChangesAsync(); + } + + public async Task<(Stream stream, string contentType)?> OpenReceiptAsync(int id, bool isFinance) + { + var e = await RequireAsync(id); + if (!isFinance && e.SubmittedBy != CurrentUserId) + throw new InvalidOperationException("Not authorized to view this receipt."); + if (e.ReceiptBlobPath is null) return null; + var stream = await _storage.OpenReadAsync(e.ReceiptBlobPath); + if (stream is null) return null; + var ext = Path.GetExtension(e.ReceiptBlobPath).ToLowerInvariant(); + var contentType = ext switch + { + ".png" => "image/png", ".webp" => "image/webp", ".pdf" => "application/pdf", + _ => "image/jpeg", + }; + return (stream, contentType); + } +} diff --git a/API/ROLAC.API/Services/IExpenseService.cs b/API/ROLAC.API/Services/IExpenseService.cs new file mode 100644 index 0000000..89e068f --- /dev/null +++ b/API/ROLAC.API/Services/IExpenseService.cs @@ -0,0 +1,21 @@ +using ROLAC.API.DTOs.Expense; +using ROLAC.API.DTOs.Shared; +namespace ROLAC.API.Services; + +public interface IExpenseService +{ + Task> GetPagedAsync( + int page, int pageSize, string? search, int? ministryId, + int? categoryGroupId, string? status, DateOnly? from, DateOnly? to); + Task> GetMineAsync(string userId, string? status, int page, int pageSize); + Task GetByIdAsync(int id); + Task CreateAsync(CreateExpenseRequest r, bool isFinance); + Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance); + Task DeleteAsync(int id, bool isFinance); + Task SubmitAsync(int id); + Task ApproveAsync(int id); + Task RejectAsync(int id, string? reviewNotes); + Task PayAsync(int id, string? checkNumber, DateOnly? paidAt); + Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance); + Task<(Stream stream, string contentType)?> OpenReceiptAsync(int id, bool isFinance); +}