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; } // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false), // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it), // then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production. private string CurrentUserId => _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? _http.HttpContext?.User.FindFirstValue("sub") ?? "system"; public async Task> GetPagedAsync( int page, int pageSize, string? search, int? ministryId, int? categoryGroupId, string? status, DateOnly? from, DateOnly? to, int? subCategoryId = null, string? statuses = null) { 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 (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value); // `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard // request the Paid+Approved set in one call. if (!string.IsNullOrWhiteSpace(statuses)) { var set = statuses.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); query = query.Where(e => set.Contains(e.Status)); } else 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.CheckNumber != null && e.CheckNumber.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} / {m.Name_zh}"); var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}"); var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}"); 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, CheckNumber = e.CheckNumber, }).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."); // Enters the approval queue: PendingApproval -> Approve -> Pay (issue check). e.Status = "PendingApproval"; e.SubmittedBy = CurrentUserId; e.SubmittedAt = DateTimeOffset.UtcNow; e.MemberId = null; } else // StaffReimbursement { // Finance entering on behalf of a member goes straight to the approval queue; // a member's own self-service entry stays a Draft until they explicitly Submit it. e.Status = isFinance ? "PendingApproval" : "Draft"; e.SubmittedBy = CurrentUserId; if (isFinance) e.SubmittedAt = DateTimeOffset.UtcNow; 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); } }