This commit is contained in:
Chris Chen
2026-06-20 17:51:33 -07:00
parent f55807fa7d
commit 3558c67fd7
55 changed files with 3140 additions and 85 deletions
@@ -0,0 +1,363 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities;
using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services;
public class DisbursementService : IDisbursementService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage;
private readonly ICheckPrintService _print;
public DisbursementService(AppDbContext db, IHttpContextAccessor http,
IFileStorage storage, ICheckPrintService print)
{ _db = db; _http = http; _storage = storage; _print = print; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
// is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens).
private string CurrentUserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _http.HttpContext?.User.FindFirstValue("sub")
?? "system";
// ── Worklist: approved-unpaid expenses grouped by payee ──────────────────────
public async Task<List<PayeeGroupDto>> GetApprovedUnpaidGroupedAsync()
{
var rows = await _db.Expenses.AsNoTracking().Where(e => e.Status == "Approved").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 memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);
var groups = new Dictionary<string, PayeeGroupDto>();
foreach (var e in rows)
{
PayeeGroupDto g;
if (e.Type == "VendorPayment")
{
var vname = (e.VendorName ?? "").Trim();
var vkey = vname.ToLowerInvariant();
var key = "V:" + vkey;
if (!groups.TryGetValue(key, out g!))
{
g = new PayeeGroupDto { PayeeType = "Vendor", VendorKey = vkey, PayeeName = vname };
groups[key] = g;
}
}
else
{
var mid = e.MemberId ?? 0;
var key = "M:" + mid;
if (!groups.TryGetValue(key, out g!))
{
members.TryGetValue(mid, out var mem);
g = new PayeeGroupDto
{
PayeeType = "Member", MemberId = e.MemberId,
PayeeName = mem != null ? $"{mem.FirstName_en} {mem.LastName_en}" : "(Unknown member)",
Address = mem?.Address, City = mem?.City, State = mem?.State, Zip = mem?.ZipCode,
};
groups[key] = g;
}
}
g.Lines.Add(new ExpenseLineDto
{
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
Description = e.Description, Amount = e.Amount,
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
});
g.TotalAmount += e.Amount;
}
return groups.Values.OrderBy(g => g.PayeeName).ToList();
}
// ── Issue checks (one per payee group) ──────────────────────────────────────
public async Task<IssueChecksResultDto> IssueChecksAsync(IssueChecksRequest r)
{
var result = new IssueChecksResultDto();
await using var tx = await _db.Database.BeginTransactionAsync();
var profile = await _db.ChurchProfiles.OrderBy(x => x.Id).FirstOrDefaultAsync();
if (profile is null)
{
profile = new ChurchProfile { Name = "Church", NextCheckNumber = 1001 };
_db.ChurchProfiles.Add(profile);
}
foreach (var p in r.Payees)
{
var expenses = await _db.Expenses.Where(e => p.ExpenseIds.Contains(e.Id)).ToListAsync();
if (expenses.Count != p.ExpenseIds.Distinct().Count())
throw new KeyNotFoundException("One or more selected expenses no longer exist.");
foreach (var e in expenses)
if (e.Status != "Approved")
throw new InvalidOperationException($"Expense {e.Id} is not Approved (status '{e.Status}').");
// Guard against double-payment: none of these may already sit on an issued check.
var alreadyLinked = await (
from l in _db.CheckLines
join c in _db.Checks on l.CheckId equals c.Id
where p.ExpenseIds.Contains(l.ExpenseId) && c.Status == "Issued"
select l.Id).AnyAsync();
if (alreadyLinked)
throw new InvalidOperationException("One or more selected expenses are already on an issued check.");
string checkNumber;
if (!string.IsNullOrWhiteSpace(p.CheckNumberOverride))
checkNumber = p.CheckNumberOverride.Trim();
else
{
checkNumber = profile.NextCheckNumber.ToString();
profile.NextCheckNumber++;
}
var amount = expenses.Sum(e => e.Amount);
var paidAt = new DateTimeOffset(r.CheckDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
var check = new Check
{
CheckNumber = checkNumber, CheckDate = r.CheckDate, Amount = amount,
PayeeType = p.PayeeType, MemberId = p.PayeeType == "Member" ? p.MemberId : null,
PayeeName = p.PayeeName, PayeeAddress = p.Address, PayeeCity = p.City,
PayeeState = p.State, PayeeZip = p.Zip,
Status = "Issued", Memo = p.Memo, IssuedBy = CurrentUserId, IssuedAt = DateTimeOffset.UtcNow,
};
foreach (var e in expenses)
{
check.Lines.Add(new CheckLine { ExpenseId = e.Id, Amount = e.Amount, Description = e.Description });
e.Status = "Paid"; e.CheckNumber = checkNumber; e.PaidBy = CurrentUserId; e.PaidAt = paidAt;
}
_db.Checks.Add(check);
try
{
await _db.SaveChangesAsync(); // assigns check.Id and consumes the number
}
catch (DbUpdateConcurrencyException)
{
throw new InvalidOperationException("Check numbering changed concurrently. Please retry.");
}
catch (DbUpdateException)
{
// The unique index on CheckNumber rejected a duplicate (e.g. an overridden number
// that already exists, including a previously voided check that kept its number).
throw new InvalidOperationException(
$"Check number '{checkNumber}' is already in use. Choose a different number.");
}
result.Created.Add(new IssuedCheckDto
{ CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });
}
await tx.CommitAsync();
return result;
}
// ── Check register ──────────────────────────────────────────────────────────
public async Task<PagedResult<CheckListItemDto>> GetRegisterAsync(
int page, int pageSize, string? status, string? search, DateOnly? from, DateOnly? to)
{
var q = _db.Checks.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(status)) q = q.Where(c => c.Status == status);
if (from.HasValue) q = q.Where(c => c.CheckDate >= from.Value);
if (to.HasValue) q = q.Where(c => c.CheckDate <= to.Value);
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
q = q.Where(c => c.CheckNumber.ToLower().Contains(s) || c.PayeeName.ToLower().Contains(s));
}
var total = await q.CountAsync();
var rows = await q.OrderByDescending(c => c.IssuedAt).ThenByDescending(c => c.Id)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
var ids = rows.Select(c => c.Id).ToList();
var counts = await _db.CheckLines.AsNoTracking().Where(l => ids.Contains(l.CheckId))
.GroupBy(l => l.CheckId).Select(g => new { g.Key, C = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.C);
var items = rows.Select(c => ToListItem(c, counts.GetValueOrDefault(c.Id))).ToList();
return new PagedResult<CheckListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
}
public async Task<CheckDetailDto?> GetByIdAsync(int id)
{
var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return null;
var lines = await _db.CheckLines.AsNoTracking().Where(l => l.CheckId == id).OrderBy(l => l.Id).ToListAsync();
var dto = new CheckDetailDto
{
MemberId = c.MemberId, Address = c.PayeeAddress, City = c.PayeeCity, State = c.PayeeState,
Zip = c.PayeeZip, Memo = c.Memo, VoidReason = c.VoidReason, VoidedAt = c.VoidedAt,
IssuedAt = c.IssuedAt,
Lines = lines.Select(l => new CheckLineDto
{ ExpenseId = l.ExpenseId, Description = l.Description, Amount = l.Amount }).ToList(),
};
CopyListFields(c, dto, lines.Count);
return dto;
}
// ── Void (revert expenses to Approved) ──────────────────────────────────────
public async Task VoidAsync(int id, string? reason)
{
await using var tx = await _db.Database.BeginTransactionAsync();
var c = await _db.Checks.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Check {id} not found.");
if (c.Status != "Issued") throw new InvalidOperationException($"Cannot void a check with status '{c.Status}'.");
c.Status = "Voided"; c.VoidReason = reason; c.VoidedAt = DateTimeOffset.UtcNow; c.VoidedBy = CurrentUserId;
var lines = await _db.CheckLines.Where(l => l.CheckId == id).ToListAsync();
var expIds = lines.Select(l => l.ExpenseId).ToList();
var exps = await _db.Expenses.Where(e => expIds.Contains(e.Id)).ToListAsync();
foreach (var e in exps)
{
e.Status = "Approved"; e.CheckNumber = null; e.PaidAt = null; e.PaidBy = null;
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
}
// ── Receipt e-signature ─────────────────────────────────────────────────────
public async Task AcknowledgeReceiptAsync(int id, Stream signature, string fileName, string signedName)
{
var c = await _db.Checks.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Check {id} not found.");
if (c.Status != "Issued") throw new InvalidOperationException("Cannot sign a voided check.");
if (c.ReceiptSignedAt is not null) throw new InvalidOperationException("This check has already been signed.");
var ext = Path.GetExtension(fileName);
if (string.IsNullOrWhiteSpace(ext)) ext = ".png";
var path = $"finance/check-signatures/{c.CheckDate.Year}/{c.CheckDate.Month}/{c.Id}{ext}";
var saved = await _storage.SaveAsync(signature, path);
c.ReceiptSignatureBlobPath = saved;
c.ReceiptSignedName = signedName;
c.ReceiptSignedAt = DateTimeOffset.UtcNow;
c.ReceiptCapturedBy = CurrentUserId;
await _db.SaveChangesAsync();
}
public async Task<(Stream stream, string contentType)?> OpenSignatureAsync(int id)
{
var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Check {id} not found.");
if (c.ReceiptSignatureBlobPath is null) return null;
var stream = await _storage.OpenReadAsync(c.ReceiptSignatureBlobPath);
if (stream is null) return null;
return (stream, SignatureContentType(c.ReceiptSignatureBlobPath));
}
// ── Render PDF ──────────────────────────────────────────────────────────────
public async Task<(Stream stream, string contentType, string fileName)?> RenderPdfAsync(int id)
{
var c = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (c is null) return null;
var lines = await _db.CheckLines.AsNoTracking().Where(l => l.CheckId == id).OrderBy(l => l.Id).ToListAsync();
var expIds = lines.Select(l => l.ExpenseId).ToList();
var exps = await _db.Expenses.AsNoTracking().IgnoreQueryFilters()
.Where(e => expIds.Contains(e.Id)).ToDictionaryAsync(e => e.Id);
foreach (var l in lines) l.Expense = exps.GetValueOrDefault(l.ExpenseId);
var profile = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
?? new ChurchProfile { Name = "Church" };
var model = new CheckPrintModel
{
Issuer = profile, Check = c, Lines = lines, AmountInWords = AmountToWords.Convert(c.Amount),
};
var stream = await _print.RenderPdfAsync(model);
return (stream, "application/pdf", $"check-{c.CheckNumber}.pdf");
}
// ── Render signed receipt PDF ────────────────────────────────────────────────
public async Task<(Stream stream, string contentType, string fileName)?> RenderReceiptPdfAsync(int id)
{
var check = await _db.Checks.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (check is null)
{
return null;
}
// A receipt only exists once the payee has signed.
if (check.ReceiptSignedAt is null || check.ReceiptSignatureBlobPath is null)
{
return null;
}
var lines = await _db.CheckLines.AsNoTracking()
.Where(line => line.CheckId == id).OrderBy(line => line.Id).ToListAsync();
var expenseIds = lines.Select(line => line.ExpenseId).ToList();
var expenses = await _db.Expenses.AsNoTracking().IgnoreQueryFilters()
.Where(expense => expenseIds.Contains(expense.Id)).ToDictionaryAsync(expense => expense.Id);
foreach (var line in lines)
{
line.Expense = expenses.GetValueOrDefault(line.ExpenseId);
}
var profile = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
?? new ChurchProfile { Name = "Church" };
byte[]? signatureBytes = null;
var signatureStream = await _storage.OpenReadAsync(check.ReceiptSignatureBlobPath);
if (signatureStream is not null)
{
await using (signatureStream)
{
using var buffer = new MemoryStream();
await signatureStream.CopyToAsync(buffer);
signatureBytes = buffer.ToArray();
}
}
var model = new CheckPrintModel
{
Issuer = profile,
Check = check,
Lines = lines,
AmountInWords = AmountToWords.Convert(check.Amount),
SignatureImage = signatureBytes,
SignatureContentType = SignatureContentType(check.ReceiptSignatureBlobPath),
};
var stream = await _print.RenderReceiptPdfAsync(model);
return (stream, "application/pdf", $"receipt-{check.CheckNumber}.pdf");
}
private static string SignatureContentType(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
_ => "image/png",
};
}
// ── helpers ─────────────────────────────────────────────────────────────────
private static CheckListItemDto ToListItem(Check c, int lineCount)
{
var dto = new CheckListItemDto();
CopyListFields(c, dto, lineCount);
return dto;
}
private static void CopyListFields(Check c, CheckListItemDto dto, int lineCount)
{
dto.Id = c.Id; dto.CheckNumber = c.CheckNumber; dto.CheckDate = c.CheckDate.ToString("yyyy-MM-dd");
dto.Amount = c.Amount; dto.PayeeType = c.PayeeType; dto.PayeeName = c.PayeeName; dto.Status = c.Status;
dto.LineCount = lineCount; dto.Signed = c.ReceiptSignedAt is not null;
dto.ReceiptSignedName = c.ReceiptSignedName; dto.ReceiptSignedAt = c.ReceiptSignedAt;
}
}