Files
ROLAC/API/ROLAC.API/Services/ExpenseService.cs
T
Chris Chen 62592c29ae
ci-cd-vm / ci-cd (push) Successful in 4m2s
Add audit logs.
2026-06-23 12:13:47 -07:00

277 lines
14 KiB
C#

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.Entities.Logging;
using ROLAC.API.Services.Logging;
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;
private readonly IAuditLogger _audit;
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; _audit = audit; }
// 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<PagedResult<ExpenseListItemDto>> 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<PagedResult<ExpenseListItemDto>> 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<PagedResult<ExpenseListItemDto>> ProjectPagedAsync(IQueryable<Expense> 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<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<ExpenseDto?> 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<int> 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
{
// Distinguish the two flows by whether a member was explicitly picked, NOT by role:
// - On-behalf: finance picks a member (Expenses page) -> straight into the approval queue.
// - Self-service: no member picked ("My Reimbursements") -> link to the caller's own member
// so the Payee shows their legal name, and keep it a Draft until they explicitly Submit.
// A finance/super_admin user files their own reimbursements through the self-service flow too,
// so keying off the role alone would leave their entries with a null member (empty Payee).
var isOnBehalf = isFinance && r.MemberId.HasValue;
e.Status = isOnBehalf ? "PendingApproval" : "Draft";
e.SubmittedBy = CurrentUserId;
if (isOnBehalf) e.SubmittedAt = DateTimeOffset.UtcNow;
e.MemberId = isOnBehalf ? r.MemberId : await CallerMemberIdAsync();
e.VendorName = null;
}
_db.Expenses.Add(e);
await _db.SaveChangesAsync();
return e.Id;
}
private async Task<int?> 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<Expense> 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();
_audit.Write(
AuditActions.ExpenseApproved, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} approved: {e.Description} — {e.Amount:C}");
}
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);
}
}