364 lines
17 KiB
C#
364 lines
17 KiB
C#
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;
|
|
}
|
|
}
|