@@ -7,8 +7,11 @@ using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using ROLAC.API.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
@@ -55,6 +58,14 @@ public class ExpenseServiceTests
|
||||
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||
}
|
||||
|
||||
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
|
||||
{
|
||||
var http = new Mock<IHttpContextAccessor>();
|
||||
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, audit);
|
||||
}
|
||||
|
||||
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
||||
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
||||
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
||||
@@ -342,4 +353,93 @@ public class ExpenseServiceTests
|
||||
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||
Assert.NotNull(got);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reject_WritesAuditEntry_WithReason()
|
||||
{
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
|
||||
var audit = new CapturingAuditLogger();
|
||||
await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake");
|
||||
|
||||
var entry = Assert.Single(audit.Entries);
|
||||
Assert.Equal(AuditActions.ExpenseRejected, entry.Action);
|
||||
Assert.Equal(AuditCategories.Business, entry.Category);
|
||||
Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName);
|
||||
Assert.Equal(id.ToString(), entry.EntityId);
|
||||
Assert.Contains("Receipt unclear", entry.Summary);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview()
|
||||
{
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing");
|
||||
|
||||
// Owner fixes the issue and re-submits.
|
||||
await svc.SubmitAsync(id);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal("PendingApproval", e!.Status);
|
||||
Assert.Null(e.ReviewedBy);
|
||||
Assert.Null(e.ReviewedAt);
|
||||
Assert.Null(e.ReviewNotes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnRejected_AsNonFinance_Succeeds()
|
||||
{
|
||||
// A rejected reimbursement can be corrected by its owner before re-submitting.
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt");
|
||||
|
||||
var edit = CloneToUpdate(Reimb());
|
||||
edit.Lines[0].Amount = 77.77m;
|
||||
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal(77.77m, e!.Amount);
|
||||
Assert.Equal("Rejected", e.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds()
|
||||
{
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake");
|
||||
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||
await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false);
|
||||
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback()
|
||||
{
|
||||
var (svc, db, fs) = Build("alice");
|
||||
// Reviewer linked to a member → shows the member's full name.
|
||||
db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" });
|
||||
db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 });
|
||||
// Reviewer with no member → falls back to email.
|
||||
db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var withMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(withMember);
|
||||
await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember);
|
||||
Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName);
|
||||
|
||||
var noMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(noMember);
|
||||
await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission");
|
||||
Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services.Logging;
|
||||
|
||||
namespace ROLAC.API.Tests.TestSupport;
|
||||
|
||||
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
|
||||
public sealed class CapturingAuditLogger : IAuditLogger
|
||||
{
|
||||
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
|
||||
|
||||
public readonly List<Entry> Entries = new();
|
||||
|
||||
public void Write(
|
||||
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
|
||||
string? entityName = null, string? entityId = null, string? summary = null,
|
||||
object? before = null, object? after = null,
|
||||
string? userId = null, string? userEmail = null, string? ipAddress = null)
|
||||
{
|
||||
Entries.Add(new Entry(action, category, entityName, entityId, summary));
|
||||
}
|
||||
}
|
||||
@@ -30,15 +30,17 @@ public class ExpenseListItemDto
|
||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||
public bool HasReceipt { get; set; }
|
||||
public string? CheckNumber { get; set; }
|
||||
// Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date".
|
||||
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
public string? ReviewNotes { get; set; } // reject reason (or approval note)
|
||||
}
|
||||
|
||||
public class ExpenseDto : ExpenseListItemDto
|
||||
{
|
||||
public string? Notes { get; set; }
|
||||
public string? ReviewNotes { get; set; }
|
||||
public string? SubmittedBy { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public List<ExpenseLineItemDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ public static class AuditActions
|
||||
public const string CheckIssued = "CheckIssued";
|
||||
public const string CheckVoided = "CheckVoided";
|
||||
public const string ExpenseApproved = "ExpenseApproved";
|
||||
public const string ExpenseRejected = "ExpenseRejected";
|
||||
public const string StatementFinalized = "StatementFinalized";
|
||||
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
@@ -60,7 +61,7 @@ public static class AuditActions
|
||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
||||
CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ public class ExpenseService : IExpenseService
|
||||
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 reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
|
||||
|
||||
// Line count + first line's category, per expense on this page.
|
||||
var expenseIds = rows.Select(r => r.Id).ToList();
|
||||
@@ -111,12 +112,39 @@ public class ExpenseService : IExpenseService
|
||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||
HasReceipt = e.ReceiptBlobPath != null,
|
||||
CheckNumber = e.CheckNumber,
|
||||
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
|
||||
ReviewedAt = e.ReviewedAt,
|
||||
ReviewNotes = e.ReviewNotes,
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||
}
|
||||
|
||||
// Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
|
||||
// the linked Member's full name when present, otherwise the account email.
|
||||
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
|
||||
{
|
||||
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
|
||||
if (ids.Count == 0) return new Dictionary<string, string>();
|
||||
|
||||
var users = await _db.Users.AsNoTracking()
|
||||
.Where(u => ids.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.Email, u.MemberId })
|
||||
.ToListAsync();
|
||||
|
||||
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
|
||||
var memberNames = await _db.Members.AsNoTracking()
|
||||
.Where(m => memberIds.Contains(m.Id))
|
||||
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
|
||||
|
||||
return users.ToDictionary(
|
||||
u => u.Id,
|
||||
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
|
||||
? name
|
||||
: (u.Email ?? u.Id));
|
||||
}
|
||||
|
||||
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||
@@ -126,6 +154,10 @@ public class ExpenseService : IExpenseService
|
||||
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
|
||||
: null;
|
||||
|
||||
var reviewerName = e.ReviewedBy != null
|
||||
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
|
||||
: null;
|
||||
|
||||
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
|
||||
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);
|
||||
@@ -145,7 +177,8 @@ public class ExpenseService : IExpenseService
|
||||
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,
|
||||
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
|
||||
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
|
||||
Lines = lineDtos,
|
||||
};
|
||||
}
|
||||
@@ -225,8 +258,8 @@ public class ExpenseService : IExpenseService
|
||||
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
||||
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
|
||||
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
|
||||
|
||||
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
|
||||
@@ -258,8 +291,11 @@ public class ExpenseService : IExpenseService
|
||||
{
|
||||
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}'.");
|
||||
// Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
|
||||
if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
|
||||
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
|
||||
// Clear the prior review so the expense returns to a clean pending state.
|
||||
e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -282,6 +318,11 @@ public class ExpenseService : IExpenseService
|
||||
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();
|
||||
|
||||
_audit.Write(
|
||||
AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
|
||||
entityName: nameof(Expense), entityId: e.Id.ToString(),
|
||||
summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
|
||||
}
|
||||
|
||||
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
|
||||
@@ -300,8 +341,8 @@ public class ExpenseService : IExpenseService
|
||||
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
||||
{
|
||||
var e = await RequireAsync(id);
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
||||
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
|
||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
|
||||
throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
|
||||
|
||||
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
||||
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
||||
|
||||
Reference in New Issue
Block a user