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.Entities.Logging; using ROLAC.API.Services.Disbursement; using ROLAC.API.Services.Logging; 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; private readonly IAuditLogger _audit; public DisbursementService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, ICheckPrintService print, IAuditLogger audit) { _db = db; _http = http; _storage = storage; _print = print; _audit = audit; } // 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> 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); // Category label per expense: the single line's category, or "Multiple" when it spans several. var expenseIds = rows.Select(r => r.Id).ToList(); var lineGroups = await _db.ExpenseLines.AsNoTracking() .Where(l => expenseIds.Contains(l.ExpenseId)) .OrderBy(l => l.Id) .Select(l => new { l.ExpenseId, l.CategoryGroupId }) .ToListAsync(); var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary( g => g.Key, g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1 ? "Multiple / 多類別" : grpNames.GetValueOrDefault(g.First().CategoryGroupId, "")); var groups = new Dictionary(); 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 = categoryByExpense.GetValueOrDefault(e.Id, ""), }); g.TotalAmount += e.Amount; } return groups.Values.OrderBy(g => g.PayeeName).ToList(); } // ── Issue checks (one per payee group) ────────────────────────────────────── public async Task 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 }); _audit.Write( AuditActions.CheckIssued, AuditCategories.Business, LogLevelEnum.Information, entityName: nameof(Check), entityId: check.Id.ToString(), summary: $"Check #{checkNumber} issued to {p.PayeeName} — {amount:C}"); } await tx.CommitAsync(); return result; } // ── Check register ────────────────────────────────────────────────────────── public async Task> 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 { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; } public async Task 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(); _audit.Write( AuditActions.CheckVoided, AuditCategories.Business, LogLevelEnum.Warning, entityName: nameof(Check), entityId: c.Id.ToString(), summary: $"Check #{c.CheckNumber} voided ({reason})"); } // ── 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; } }