feat(expense): add ExpenseService with state machine + receipt storage + tests
TDD: wrote 8 tests first (red), then implemented IExpenseService + ExpenseService covering CRUD, Draft→PendingApproval→Approved→Paid state machine, soft-delete, per-owner access guards, and receipt blob round-trip via IFileStorage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, byte[]> Files = new();
|
||||||
|
public Task<string> 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<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<Stream?>(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<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InvalidOperationException>(() => 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<InvalidOperationException>(() =>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<PagedResult<ExpenseListItemDto>> 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<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);
|
||||||
|
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<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.");
|
||||||
|
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<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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.DTOs.Shared;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IExpenseService
|
||||||
|
{
|
||||||
|
Task<PagedResult<ExpenseListItemDto>> GetPagedAsync(
|
||||||
|
int page, int pageSize, string? search, int? ministryId,
|
||||||
|
int? categoryGroupId, string? status, DateOnly? from, DateOnly? to);
|
||||||
|
Task<PagedResult<ExpenseListItemDto>> GetMineAsync(string userId, string? status, int page, int pageSize);
|
||||||
|
Task<ExpenseDto?> GetByIdAsync(int id);
|
||||||
|
Task<int> 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user