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,29 @@
using ROLAC.API.Services.Disbursement;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class AmountToWordsTests
{
[Theory]
[InlineData(0, "Zero and 00/100 Dollars")]
[InlineData(0.05, "Zero and 05/100 Dollars")]
[InlineData(1, "One and 00/100 Dollars")]
[InlineData(19, "Nineteen and 00/100 Dollars")]
[InlineData(20, "Twenty and 00/100 Dollars")]
[InlineData(21, "Twenty-One and 00/100 Dollars")]
[InlineData(100, "One Hundred and 00/100 Dollars")]
[InlineData(115, "One Hundred Fifteen and 00/100 Dollars")]
[InlineData(1234.56, "One Thousand Two Hundred Thirty-Four and 56/100 Dollars")]
[InlineData(1000000, "One Million and 00/100 Dollars")]
public void Convert_FormatsExpectedWords(double amount, string expected)
{
Assert.Equal(expected, AmountToWords.Convert((decimal)amount));
}
[Fact]
public void Convert_RoundsCentsHalfUp()
{
Assert.Equal("One and 00/100 Dollars", AmountToWords.Convert(0.999m));
}
}
@@ -0,0 +1,271 @@
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DisbursementServiceTests
{
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 sealed class FakePrint : ICheckPrintService
{
public CheckPrintModel? LastReceiptModel;
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("pdf")));
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{
LastReceiptModel = model;
return Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("receipt-pdf")));
}
}
private static AppDbContext BuildDb(string userId)
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
}
private static DisbursementService 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 DisbursementService(db, http.Object, fs, new FakePrint());
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
{
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
}
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor,
};
[Fact]
public async Task GroupedWorklist_BundlesSamePayee()
{
var (svc, db, _) = Build();
db.Expenses.AddRange(
Approved("StaffReimbursement", 10m, memberId: 1),
Approved("StaffReimbursement", 15m, memberId: 1),
Approved("VendorPayment", 30m, vendor: "Acme"));
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
Assert.Equal(2, groups.Count);
var member = groups.Single(g => g.PayeeType == "Member");
Assert.Equal(25m, member.TotalAmount);
Assert.Equal(2, member.Lines.Count);
Assert.Equal("John Doe", member.PayeeName);
Assert.Equal("1 Main St", member.Address);
}
[Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{
var (svc, db, _) = Build();
var e1 = Approved("StaffReimbursement", 10m, memberId: 1);
var e2 = Approved("StaffReimbursement", 15m, memberId: 1);
var e3 = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.AddRange(e1, e2, e3);
await db.SaveChangesAsync();
var req = new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees =
[
new() { PayeeType = "Member", MemberId = 1, PayeeName = "John Doe", ExpenseIds = [e1.Id, e2.Id] },
new() { PayeeType = "Vendor", VendorKey = "acme", PayeeName = "Acme", ExpenseIds = [e3.Id] },
],
};
var result = await svc.IssueChecksAsync(req);
Assert.Equal(2, result.Created.Count);
Assert.Equal(new[] { "1001", "1002" }, result.Created.Select(c => c.CheckNumber).ToArray());
Assert.All(await db.Expenses.ToListAsync(), e => Assert.Equal("Paid", e.Status));
var memberCheck = await db.Checks.FirstAsync(c => c.PayeeType == "Member");
Assert.Equal(25m, memberCheck.Amount);
Assert.Equal(1003, (await db.ChurchProfiles.FirstAsync()).NextCheckNumber);
}
[Fact]
public async Task Issue_RejectsNonApprovedExpense()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
e.Status = "Draft";
db.Expenses.Add(e);
await db.SaveChangesAsync();
var req = new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
};
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.IssueChecksAsync(req));
}
[Fact]
public async Task Void_RevertsExpensesToApproved()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
await svc.VoidAsync(checkId, "wrong amount");
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
Assert.Equal("Voided", check.Status);
var reverted = await db.Expenses.FirstAsync(x => x.Id == e.Id);
Assert.Equal("Approved", reverted.Status);
Assert.Null(reverted.CheckNumber);
Assert.Null(reverted.PaidAt);
}
[Fact]
public async Task Acknowledge_StoresSignatureAndTimestamp()
{
var (svc, db, fs) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
Assert.NotNull(check.ReceiptSignedAt);
Assert.Equal("Acme Rep", check.ReceiptSignedName);
Assert.NotNull(check.ReceiptSignatureBlobPath);
Assert.Single(fs.Files);
}
private static (DisbursementService svc, AppDbContext db, FakeStorage fs, FakePrint print) BuildWithPrint(string userId = "fin")
{
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.SaveChanges();
var fs = new FakeStorage();
var print = new FakePrint();
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 DisbursementService(db, http.Object, fs, print), db, fs, print);
}
[Fact]
public async Task ReceiptPdf_NullWhenNotSigned()
{
var (svc, db, _, _) = BuildWithPrint();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var receipt = await svc.RenderReceiptPdfAsync(result.Created[0].CheckId);
Assert.Null(receipt);
}
[Fact]
public async Task ReceiptPdf_AfterSigning_RendersWithSignatureBytes()
{
var (svc, db, _, print) = BuildWithPrint();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
var receipt = await svc.RenderReceiptPdfAsync(checkId);
Assert.NotNull(receipt);
Assert.Equal("receipt-1001.pdf", receipt!.Value.fileName);
Assert.NotNull(print.LastReceiptModel);
Assert.NotNull(print.LastReceiptModel!.SignatureImage);
Assert.Equal("Acme Rep", print.LastReceiptModel.Check.ReceiptSignedName);
}
[Fact]
public async Task Acknowledge_VoidedCheck_Throws()
{
var (svc, db, _) = Build();
var e = Approved("VendorPayment", 30m, vendor: "Acme");
db.Expenses.Add(e);
await db.SaveChangesAsync();
var result = await svc.IssueChecksAsync(new IssueChecksRequest
{
CheckDate = new DateOnly(2026, 6, 20),
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
});
var checkId = result.Created[0].CheckId;
await svc.VoidAsync(checkId, null);
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X"));
}
}
@@ -98,16 +98,24 @@ public class ExpenseServiceTests
}
[Fact]
public async Task Create_Vendor_AsFinance_IsImmediatelyPaid()
public async Task Create_Vendor_AsFinance_IsPendingApproval()
{
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);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
}
[Fact]
public async Task Create_Reimbursement_IsDraft_WithSubmitter()
public async Task Create_Reimbursement_AsFinance_IsPendingApproval()
{
var (svc, db, _) = Build();
var id = await svc.CreateAsync(Reimb(), isFinance: true);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
}
[Fact]
public async Task Create_Reimbursement_AsMember_IsDraft_WithSubmitter()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/church-profile")]
[Authorize(Roles = "finance,super_admin")]
public class ChurchProfileController : ControllerBase
{
private readonly IChurchProfileService _svc;
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
[HttpPut]
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
{
await _svc.UpdateAsync(r);
return NoContent();
}
}
@@ -0,0 +1,95 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/disbursements")]
[Authorize(Roles = "finance,super_admin")]
public class DisbursementsController : ControllerBase
{
private readonly IDisbursementService _svc;
public DisbursementsController(IDisbursementService svc) => _svc = svc;
[HttpGet("approved-unpaid")]
public async Task<IActionResult> GetApprovedUnpaid()
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
[HttpPost("issue")]
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
{
try { return Ok(await _svc.IssueChecksAsync(r)); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("checks")]
public async Task<IActionResult> GetRegister(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
[HttpGet("checks/{id:int}")]
public async Task<IActionResult> GetById(int id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost("checks/{id:int}/void")]
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
{
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("checks/{id:int}/pdf")]
public async Task<IActionResult> GetPdf(int id)
{
var result = await _svc.RenderPdfAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
}
[HttpGet("checks/{id:int}/receipt-pdf")]
public async Task<IActionResult> GetReceiptPdf(int id)
{
var result = await _svc.RenderReceiptPdfAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
}
[HttpPost("checks/{id:int}/acknowledge")]
[RequestSizeLimit(5_242_880)]
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
{
if (signature is null || signature.Length == 0) return BadRequest(new { message = "No signature." });
if (string.IsNullOrWhiteSpace(signedName)) return BadRequest(new { message = "Signed name is required." });
var allowed = new[] { "image/png", "image/jpeg", "image/webp" };
if (!allowed.Contains(signature.ContentType)) return BadRequest(new { message = "Unsupported image type." });
try
{
await using var stream = signature.OpenReadStream();
await _svc.AcknowledgeReceiptAsync(id, stream, signature.FileName, signedName.Trim());
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpGet("checks/{id:int}/signature")]
public async Task<IActionResult> GetSignature(int id)
{
try
{
var result = await _svc.OpenSignatureAsync(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Disbursement;
public class ChurchProfileDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? BankName { get; set; }
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
public int NextCheckNumber { get; set; }
}
public class UpdateChurchProfileRequest
{
[Required, MaxLength(200)] public string Name { get; set; } = "";
[MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; }
[MaxLength(20)] public string? ZipCode { get; set; }
[MaxLength(200)] public string? BankName { get; set; }
[MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
}
@@ -0,0 +1,107 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Disbursement;
// ── Approved-unpaid expenses, grouped by payee (the issue-check worklist) ──────
public class ExpenseLineDto
{
public int ExpenseId { get; set; }
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public string Description { get; set; } = "";
public decimal Amount { get; set; }
public string MinistryName { get; set; } = "";
public string CategoryName { get; set; } = "";
}
public class PayeeGroupDto
{
public string PayeeType { get; set; } = "Vendor"; // Vendor | Member
public int? MemberId { get; set; }
public string? VendorKey { get; set; } // normalized vendor name (grouping key)
public string PayeeName { get; set; } = "";
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public decimal TotalAmount { get; set; }
public List<ExpenseLineDto> Lines { get; set; } = [];
}
// ── Issue checks ──────────────────────────────────────────────────────────────
public class PayeeCheckInstruction
{
[Required] public string PayeeType { get; set; } = "Vendor";
public int? MemberId { get; set; }
public string? VendorKey { get; set; }
[Required, MaxLength(200)] public string PayeeName { get; set; } = "";
[MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; }
[MaxLength(20)] public string? Zip { get; set; }
[MaxLength(50)] public string? CheckNumberOverride { get; set; }
[MaxLength(500)] public string? Memo { get; set; }
[Required, MinLength(1)] public List<int> ExpenseIds { get; set; } = [];
}
public class IssueChecksRequest
{
[Required] public DateOnly CheckDate { get; set; }
[Required, MinLength(1)] public List<PayeeCheckInstruction> Payees { get; set; } = [];
}
public class IssuedCheckDto
{
public int CheckId { get; set; }
public string CheckNumber { get; set; } = "";
public string PayeeName { get; set; } = "";
public decimal Amount { get; set; }
}
public class IssueChecksResultDto
{
public List<IssuedCheckDto> Created { get; set; } = [];
}
// ── Check register / detail ───────────────────────────────────────────────────
public class CheckListItemDto
{
public int Id { get; set; }
public string CheckNumber { get; set; } = "";
public string CheckDate { get; set; } = ""; // yyyy-MM-dd
public decimal Amount { get; set; }
public string PayeeType { get; set; } = "";
public string PayeeName { get; set; } = "";
public string Status { get; set; } = "";
public int LineCount { get; set; }
public bool Signed { get; set; }
public string? ReceiptSignedName { get; set; }
public DateTimeOffset? ReceiptSignedAt { get; set; }
}
public class CheckLineDto
{
public int ExpenseId { get; set; }
public string Description { get; set; } = "";
public decimal Amount { get; set; }
}
public class CheckDetailDto : CheckListItemDto
{
public int? MemberId { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? Memo { get; set; }
public string? VoidReason { get; set; }
public DateTimeOffset? VoidedAt { get; set; }
public DateTimeOffset IssuedAt { get; set; }
public List<CheckLineDto> Lines { get; set; } = [];
}
public class VoidCheckRequest
{
[MaxLength(500)] public string? Reason { get; set; }
}
+74
View File
@@ -19,6 +19,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -210,6 +213,77 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ChurchProfile (singleton settings) ───────────────────────────────
builder.Entity<ChurchProfile>(entity =>
{
entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
entity.Property(e => e.Address).HasMaxLength(500);
entity.Property(e => e.City).HasMaxLength(100);
entity.Property(e => e.State).HasMaxLength(50);
entity.Property(e => e.ZipCode).HasMaxLength(20);
entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
// Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion();
});
// ── Check (disbursement) ─────────────────────────────────────────────
builder.Entity<Check>(entity =>
{
entity.HasQueryFilter(c => !c.IsDeleted);
entity.Property(e => e.CheckNumber).HasMaxLength(50).IsRequired();
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.PayeeType).HasMaxLength(20).IsRequired();
entity.Property(e => e.PayeeName).HasMaxLength(200).IsRequired();
entity.Property(e => e.PayeeAddress).HasMaxLength(500);
entity.Property(e => e.PayeeCity).HasMaxLength(100);
entity.Property(e => e.PayeeState).HasMaxLength(50);
entity.Property(e => e.PayeeZip).HasMaxLength(20);
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Issued");
entity.Property(e => e.Memo).HasMaxLength(500);
entity.Property(e => e.IssuedBy).HasMaxLength(450).IsRequired();
entity.Property(e => e.VoidReason).HasMaxLength(500);
entity.Property(e => e.VoidedBy).HasMaxLength(450);
entity.Property(e => e.ReceiptSignatureBlobPath).HasMaxLength(500);
entity.Property(e => e.ReceiptSignedName).HasMaxLength(200);
entity.Property(e => e.ReceiptCapturedBy).HasMaxLength(450);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
// Unique check number among non-deleted rows.
entity.HasIndex(e => e.CheckNumber).IsUnique().HasFilter("\"IsDeleted\" = false");
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
entity.HasIndex(e => e.CheckDate);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── CheckLine ────────────────────────────────────────────────────────
builder.Entity<CheckLine>(entity =>
{
// Mirror the parent Check's soft-delete filter (required relationship).
entity.HasQueryFilter(l => !l.Check!.IsDeleted);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.CheckId);
entity.HasIndex(e => e.ExpenseId);
entity.HasOne(e => e.Check).WithMany(c => c.Lines)
.HasForeignKey(e => e.CheckId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Expense).WithMany()
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
});
// ── MonthlyStatement ─────────────────────────────────────────────────
builder.Entity<MonthlyStatement>(entity =>
{
+17
View File
@@ -130,6 +130,22 @@ public static class DbSeeder
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db)
{
// Singleton row used by the disbursement module (issuer info + check counter).
if (!await db.ChurchProfiles.AnyAsync())
{
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "River Of Life Christian Church",
City = "Arcadia",
State = "CA",
NextCheckNumber = 1001,
});
await db.SaveChangesAsync();
}
}
/// <summary>
/// Seeds roles and (in Development) the default admin account.
/// Called once on application startup after migrations have been applied.
@@ -146,6 +162,7 @@ public static class DbSeeder
await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db);
await SeedChurchProfileAsync(db);
if (env.IsDevelopment())
await SeedAdminUserAsync(userManager);
+43
View File
@@ -0,0 +1,43 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// A disbursement check issued to a single payee, bundling one or more approved
/// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at
/// issue time so the printed check is reproducible even if member data later changes.
/// </summary>
public class Check : SoftDeleteEntity
{
public int Id { get; set; }
public string CheckNumber { get; set; } = null!;
public DateOnly CheckDate { get; set; }
public decimal Amount { get; set; } // sum of line amounts
public string PayeeType { get; set; } = "Vendor"; // Vendor | Member
public int? MemberId { get; set; } // set when PayeeType == Member
public string PayeeName { get; set; } = null!; // snapshot
public string? PayeeAddress { get; set; } // snapshot
public string? PayeeCity { get; set; }
public string? PayeeState { get; set; }
public string? PayeeZip { get; set; }
public string Status { get; set; } = "Issued"; // Issued | Voided
public string? Memo { get; set; }
public string IssuedBy { get; set; } = null!;
public DateTimeOffset IssuedAt { get; set; }
public string? VoidReason { get; set; }
public DateTimeOffset? VoidedAt { get; set; }
public string? VoidedBy { get; set; }
// Receipt e-signature: payee signs in-app on hand-off. "Signed" is derived as
// ReceiptSignedAt != null.
public string? ReceiptSignatureBlobPath { get; set; }
public string? ReceiptSignedName { get; set; }
public DateTimeOffset? ReceiptSignedAt { get; set; }
public string? ReceiptCapturedBy { get; set; }
public Member? Member { get; set; }
public ICollection<CheckLine> Lines { get; set; } = [];
}
+18
View File
@@ -0,0 +1,18 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// One expense covered by a <see cref="Check"/>. Amount/Description are snapshotted
/// at issue time for the printed ledger stub; ExpenseId links back to the source expense.
/// </summary>
public class CheckLine : AuditableEntity
{
public int Id { get; set; }
public int CheckId { get; set; }
public int ExpenseId { get; set; }
public decimal Amount { get; set; } // snapshot of expense amount
public string Description { get; set; } = null!; // snapshot of expense description
public Check? Check { get; set; }
public Expense? Expense { get; set; }
}
+26
View File
@@ -0,0 +1,26 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
/// running check-number counter used when disbursing checks. Seeded on startup.
/// </summary>
public class ChurchProfile : AuditableEntity
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? ZipCode { get; set; }
public string? BankName { get; set; }
public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; }
/// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary>
public int NextCheckNumber { get; set; } = 1001;
// Npgsql system column used as an optimistic-concurrency token so two simultaneous
// disbursement runs can't allocate the same check number. Mapped via IsRowVersion().
public uint xmin { get; set; }
}
@@ -245,6 +245,259 @@ namespace ROLAC.API.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.Check", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<DateOnly>("CheckDate")
.HasColumnType("date");
b.Property<string>("CheckNumber")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeletedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IssuedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("Memo")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PayeeAddress")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("PayeeCity")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PayeeName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PayeeState")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("PayeeType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("PayeeZip")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ReceiptCapturedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("ReceiptSignatureBlobPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset?>("ReceiptSignedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReceiptSignedName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Issued");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("VoidReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset?>("VoidedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("VoidedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("CheckDate");
b.HasIndex("CheckNumber")
.IsUnique()
.HasFilter("\"IsDeleted\" = false");
b.HasIndex("MemberId");
b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false");
b.ToTable("Checks");
});
modelBuilder.Entity("ROLAC.API.Entities.CheckLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CheckId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("ExpenseId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("CheckId");
b.HasIndex("ExpenseId");
b.ToTable("CheckLines");
});
modelBuilder.Entity("ROLAC.API.Entities.ChurchProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Address")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("BankAccountNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("BankName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("BankRoutingNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("City")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("NextCheckNumber")
.HasColumnType("integer");
b.Property<string>("State")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("ZipCode")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id");
b.ToTable("ChurchProfiles");
});
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.Property<int>("Id")
@@ -1058,6 +1311,35 @@ namespace ROLAC.API.Migrations
.IsRequired();
});
modelBuilder.Entity("ROLAC.API.Entities.Check", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.CheckLine", b =>
{
b.HasOne("ROLAC.API.Entities.Check", "Check")
.WithMany("Lines")
.HasForeignKey("CheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Expense", "Expense")
.WithMany()
.HasForeignKey("ExpenseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Check");
b.Navigation("Expense");
});
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
@@ -1154,6 +1436,11 @@ namespace ROLAC.API.Migrations
b.Navigation("RefreshTokens");
});
modelBuilder.Entity("ROLAC.API.Entities.Check", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.Navigation("SubCategories");
+4
View File
@@ -129,6 +129,10 @@ builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
// ---------------------------------------------------------------------------
// Swagger / MVC
+2 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Document.Processor" Version="24.1.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class ChurchProfileService : IChurchProfileService
{
private readonly AppDbContext _db;
public ChurchProfileService(AppDbContext db) => _db = db;
public async Task<ChurchProfileDto> GetAsync()
{
var p = await GetOrCreateAsync();
return new ChurchProfileDto
{
Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
};
}
public async Task UpdateAsync(UpdateChurchProfileRequest r)
{
var p = await GetOrCreateAsync();
p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
await _db.SaveChangesAsync();
}
private async Task<ChurchProfile> GetOrCreateAsync()
{
var p = await _db.ChurchProfiles.OrderBy(x => x.Id).FirstOrDefaultAsync();
if (p is null)
{
p = new ChurchProfile { Name = "Church", NextCheckNumber = 1001 };
_db.ChurchProfiles.Add(p);
await _db.SaveChangesAsync();
}
return p;
}
}
@@ -0,0 +1,75 @@
namespace ROLAC.API.Services.Disbursement;
/// <summary>
/// Converts a monetary amount to the English words used on a check, e.g.
/// 1234.56 → "One Thousand Two Hundred Thirty-Four and 56/100 Dollars".
/// Pure and dependency-free so it is easily unit-tested.
/// </summary>
public static class AmountToWords
{
private static readonly string[] Ones =
[
"Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen",
"Seventeen", "Eighteen", "Nineteen",
];
private static readonly string[] Tens =
[
"", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety",
];
// index 1.. → 10^(3*index)
private static readonly string[] Scales = ["", "Thousand", "Million", "Billion", "Trillion"];
public static string Convert(decimal amount)
{
if (amount < 0) amount = 0m;
// Round half-up to cents.
amount = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
var dollars = (long)Math.Floor(amount);
var cents = (int)Math.Round((amount - dollars) * 100m, MidpointRounding.AwayFromZero);
var words = dollars == 0 ? "Zero" : ThreeDigitGroupsToWords(dollars);
return $"{words} and {cents:00}/100 Dollars";
}
private static string ThreeDigitGroupsToWords(long n)
{
// Split into groups of three digits, low to high.
var groups = new List<int>();
while (n > 0) { groups.Add((int)(n % 1000)); n /= 1000; }
var parts = new List<string>();
for (var i = groups.Count - 1; i >= 0; i--)
{
if (groups[i] == 0) continue;
var group = HundredsToWords(groups[i]);
var scale = Scales[i];
parts.Add(string.IsNullOrEmpty(scale) ? group : $"{group} {scale}");
}
return string.Join(" ", parts);
}
private static string HundredsToWords(int n)
{
var parts = new List<string>();
if (n >= 100)
{
parts.Add($"{Ones[n / 100]} Hundred");
n %= 100;
}
if (n >= 20)
{
var t = Tens[n / 10];
var o = n % 10;
parts.Add(o == 0 ? t : $"{t}-{Ones[o]}");
}
else if (n > 0)
{
parts.Add(Ones[n]);
}
return string.Join(" ", parts);
}
}
@@ -0,0 +1,245 @@
using System.Globalization;
using DevExpress.Office;
using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native;
namespace ROLAC.API.Services.Disbursement;
/// <summary>
/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API:
/// a check block on top followed by two identical ledger detail stubs. The layout is
/// built programmatically (no external .docx template) and exported to PDF.
/// </summary>
public class CheckPrintService : ICheckPrintService
{
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
{
using var server = new RichEditDocumentServer();
var doc = server.Document;
doc.BeginUpdate();
try
{
doc.Unit = DocumentUnit.Inch;
var section = doc.Sections[0];
section.Page.Width = 8.5f;
section.Page.Height = 11f;
section.Margins.Left = section.Margins.Right = 0.6f;
section.Margins.Top = section.Margins.Bottom = 0.5f;
BuildCheckBlock(doc, model);
BuildStub(doc, model, "PAYMENT ADVICE — DETAIL");
BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY");
}
finally
{
doc.EndUpdate();
}
var ms = new MemoryStream();
server.ExportToPdf(ms);
ms.Position = 0;
return Task.FromResult<Stream>(ms);
}
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{
using var server = new RichEditDocumentServer();
var document = server.Document;
document.BeginUpdate();
try
{
document.Unit = DocumentUnit.Inch;
var section = document.Sections[0];
section.Page.Width = 8.5f;
section.Page.Height = 11f;
section.Margins.Left = section.Margins.Right = 0.8f;
section.Margins.Top = section.Margins.Bottom = 0.8f;
document.AppendHtmlText(BuildReceiptHtml(model));
}
finally
{
document.EndUpdate();
}
var stream = new MemoryStream();
server.ExportToPdf(stream);
stream.Position = 0;
return Task.FromResult<Stream>(stream);
}
private static string BuildReceiptHtml(CheckPrintModel model)
{
var issuer = model.Issuer;
var check = model.Check;
var issuerAddress = Encode(JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode));
var payeeAddress = Encode(JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip));
var detailRows = new System.Text.StringBuilder();
foreach (var line in model.Lines)
{
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
detailRows.Append(
"<tr>" +
$"<td>{Encode(date)}</td>" +
$"<td>{Encode(line.Description)}</td>" +
$"<td align=\"right\">{Encode(FormatCurrency(line.Amount))}</td>" +
"</tr>");
}
var signedOn = check.ReceiptSignedAt.HasValue
? check.ReceiptSignedAt.Value.ToLocalTime().ToString("MM/dd/yyyy HH:mm")
: "";
var signatureBlock = "";
if (model.SignatureImage is { Length: > 0 })
{
var base64 = Convert.ToBase64String(model.SignatureImage);
signatureBlock =
$"<p><img src=\"data:{model.SignatureContentType};base64,{base64}\" width=\"260\" /></p>";
}
return
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
"<h2 style=\"text-align:center;margin:0;\">Disbursement Receipt / 簽收收據</h2>" +
$"<p style=\"text-align:center;margin:4px 0 16px 0;\"><b>{Encode(issuer.Name)}</b><br/>{issuerAddress}</p>" +
"<h3 style=\"margin:8px 0;\">Check Information / 支票資訊</h3>" +
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"5\" width=\"100%\" style=\"border-collapse:collapse;\">" +
$"<tr><td width=\"22%\"><b>Check No. / 支票號碼</b></td><td width=\"28%\">{Encode(check.CheckNumber)}</td>" +
$"<td width=\"22%\"><b>Date / 日期</b></td><td width=\"28%\">{check.CheckDate:MM/dd/yyyy}</td></tr>" +
$"<tr><td><b>Payee / 收款人</b></td><td colspan=\"3\">{Encode(check.PayeeName)} {payeeAddress}</td></tr>" +
$"<tr><td><b>Amount / 金額</b></td><td colspan=\"3\">{Encode(FormatCurrency(check.Amount))} &mdash; {Encode(model.AmountInWords)}</td></tr>" +
$"<tr><td><b>Memo / 摘要</b></td><td colspan=\"3\">{Encode(check.Memo ?? "")}</td></tr>" +
"</table>" +
"<h3 style=\"margin:16px 0 8px 0;\">Disbursement Detail / 撥款明細</h3>" +
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"5\" width=\"100%\" style=\"border-collapse:collapse;\">" +
"<tr><th align=\"left\">Date / 日期</th><th align=\"left\">Description / 說明</th><th align=\"right\">Amount / 金額</th></tr>" +
detailRows +
$"<tr><td></td><td align=\"right\"><b>TOTAL / 合計</b></td><td align=\"right\"><b>{Encode(FormatCurrency(check.Amount))}</b></td></tr>" +
"</table>" +
"<h3 style=\"margin:16px 0 8px 0;\">Acknowledgement of Receipt / 收款簽收</h3>" +
"<p>I acknowledge receipt of the above payment in full. / 本人確認已如數收到上述款項。</p>" +
$"<p><b>Received by / 簽收人:</b> {Encode(check.ReceiptSignedName ?? "")}<br/>" +
$"<b>Date / 簽收日期:</b> {Encode(signedOn)}</p>" +
signatureBlock +
"</div>";
}
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
private static void BuildCheckBlock(Document doc, CheckPrintModel m)
{
var issuer = m.Issuer;
var check = m.Check;
AppendLine(doc, issuer.Name, bold: true, size: 13);
var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode);
if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9);
if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10);
AppendLine(doc, "");
AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11);
var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip);
if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12);
AppendLine(doc, m.AmountInWords, size: 10);
if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); }
AppendLine(doc, "");
AppendLine(doc, "____________________________________", size: 10);
AppendLine(doc, "Authorized Signature", size: 8);
AppendSeparator(doc);
}
private static void BuildStub(Document doc, CheckPrintModel m, string title)
{
var check = m.Check;
AppendLine(doc, title, bold: true, size: 10);
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9);
AppendLine(doc, "");
var rows = m.Lines.Count + 2; // header + lines + total
var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow);
table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single;
table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single;
SetCell(doc, table[0, 0], "Date", bold: true);
SetCell(doc, table[0, 1], "Description", bold: true);
SetCell(doc, table[0, 2], "Amount", bold: true, right: true);
for (var i = 0; i < m.Lines.Count; i++)
{
var line = m.Lines[i];
var r = i + 1;
// CheckLine snapshots description; date comes from the source expense if loaded.
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
SetCell(doc, table[r, 0], date);
SetCell(doc, table[r, 1], line.Description);
SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true);
}
var totalRow = rows - 1;
SetCell(doc, table[totalRow, 0], "");
SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true);
SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true);
AppendLine(doc, "");
if (check.ReceiptSignedAt is { } signedAt)
AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9);
AppendSeparator(doc);
}
// ── low-level helpers ──────────────────────────────────────────────────────
private static void AppendLine(Document doc, string text, bool bold = false, float size = 10)
{
var range = doc.AppendText(text + "\r\n");
var cp = doc.BeginUpdateCharacters(range);
cp.Bold = bold;
cp.FontSize = size;
doc.EndUpdateCharacters(cp);
}
private static void AppendSeparator(Document doc)
{
AppendLine(doc, "");
AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8);
AppendLine(doc, "");
}
private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false)
{
var range = doc.InsertText(cell.ContentRange.Start, text);
var cp = doc.BeginUpdateCharacters(range);
cp.Bold = bold;
cp.FontSize = 9;
doc.EndUpdateCharacters(cp);
if (right)
{
var pp = doc.BeginUpdateParagraphs(range);
pp.Alignment = ParagraphAlignment.Right;
doc.EndUpdateParagraphs(pp);
}
}
private static string FormatCurrency(decimal amount) =>
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
private static string JoinAddress(string? addr, string? city, string? state, string? zip)
{
var cityLine = string.Join(", ",
new[] { city, string.Join(" ", new[] { state, zip }.Where(s => !string.IsNullOrWhiteSpace(s))) }
.Where(s => !string.IsNullOrWhiteSpace(s)));
return string.Join(" ", new[] { addr, cityLine }.Where(s => !string.IsNullOrWhiteSpace(s)));
}
}
@@ -0,0 +1,28 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Services.Disbursement;
/// <summary>Data needed to render one printed check (header + ledger stub lines).</summary>
public class CheckPrintModel
{
public ChurchProfile Issuer { get; set; } = null!;
public Check Check { get; set; } = null!;
public List<CheckLine> Lines { get; set; } = [];
public string AmountInWords { get; set; } = "";
// Captured receipt e-signature, populated only when rendering a signed receipt.
public byte[]? SignatureImage { get; set; }
public string SignatureContentType { get; set; } = "image/png";
}
public interface ICheckPrintService
{
/// <summary>Renders the 8.5"x11" check (check + two ledger stubs) to a PDF stream.</summary>
Task<Stream> RenderPdfAsync(CheckPrintModel model);
/// <summary>
/// Renders a receipt PDF acknowledging a signed check: check info, the disbursement
/// detail lines, and the embedded e-signature image with the signer name and timestamp.
/// </summary>
Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model);
}
@@ -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;
}
}
+10 -6
View File
@@ -76,9 +76,9 @@ public class ExpenseService : IExpenseService
.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 minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}");
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}");
@@ -134,14 +134,18 @@ public class ExpenseService : IExpenseService
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;
// Enters the approval queue: PendingApproval -> Approve -> Pay (issue check).
e.Status = "PendingApproval";
e.SubmittedBy = CurrentUserId; e.SubmittedAt = DateTimeOffset.UtcNow;
e.MemberId = null;
}
else // StaffReimbursement
{
e.Status = "Draft";
// Finance entering on behalf of a member goes straight to the approval queue;
// a member's own self-service entry stays a Draft until they explicitly Submit it.
e.Status = isFinance ? "PendingApproval" : "Draft";
e.SubmittedBy = CurrentUserId;
if (isFinance) e.SubmittedAt = DateTimeOffset.UtcNow;
e.MemberId = isFinance ? r.MemberId : await CallerMemberIdAsync();
e.VendorName = null;
}
@@ -0,0 +1,9 @@
using ROLAC.API.DTOs.Disbursement;
namespace ROLAC.API.Services;
public interface IChurchProfileService
{
Task<ChurchProfileDto> GetAsync();
Task UpdateAsync(UpdateChurchProfileRequest r);
}
@@ -0,0 +1,18 @@
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.DTOs.Shared;
namespace ROLAC.API.Services;
public interface IDisbursementService
{
Task<List<PayeeGroupDto>> GetApprovedUnpaidGroupedAsync();
Task<IssueChecksResultDto> IssueChecksAsync(IssueChecksRequest r);
Task<PagedResult<CheckListItemDto>> GetRegisterAsync(
int page, int pageSize, string? status, string? search, DateOnly? from, DateOnly? to);
Task<CheckDetailDto?> GetByIdAsync(int id);
Task VoidAsync(int id, string? reason);
Task AcknowledgeReceiptAsync(int id, Stream signature, string fileName, string signedName);
Task<(Stream stream, string contentType)?> OpenSignatureAsync(int id);
Task<(Stream stream, string contentType, string fileName)?> RenderPdfAsync(int id);
Task<(Stream stream, string contentType, string fileName)?> RenderReceiptPdfAsync(int id);
}
+4 -1
View File
@@ -5,13 +5,16 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { httpErrorInterceptor } from './core/interceptors/http-error.interceptor';
import { AuthService } from './shared/services/auth.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideAnimations(),
provideHttpClient(withInterceptors([authInterceptor])),
// httpErrorInterceptor is listed first so its catchError runs LAST (outermost),
// i.e. after authInterceptor has handled/retried 401s.
provideHttpClient(withInterceptors([httpErrorInterceptor, authInterceptor])),
{
provide: APP_INITIALIZER,
useFactory: (authService: AuthService) => () => authService.initializeFromRefreshToken(),
+1
View File
@@ -1,2 +1,3 @@
<router-outlet></router-outlet>
<div kendoDialogContainer></div>
<app-toast-container></app-toast-container>
+21
View File
@@ -14,6 +14,9 @@ import { ExpensesPageComponent } from './features/expense/pages/expenses-page/ex
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
import { DisbursementPageComponent } from './features/disbursement/pages/disbursement-page/disbursement-page.component';
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
export const routes: Routes = [
// Public routes
@@ -77,6 +80,24 @@ export const routes: Routes = [
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/disbursements',
component: DisbursementPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/check-register',
component: CheckRegisterPageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
{
path: 'finance/church-profile',
component: ChurchProfilePageComponent,
canActivate: [RoleGuard],
data: { roles: ['finance', 'super_admin'] },
},
]
},
+3 -1
View File
@@ -2,6 +2,7 @@ import { Component, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { DialogModule } from '@progress/kendo-angular-dialog';
import { ToastContainerComponent } from './core/components/toast-container/toast-container.component';
@Component({
selector: 'app-root',
@@ -9,7 +10,8 @@ import { DialogModule } from '@progress/kendo-angular-dialog';
imports: [
CommonModule,
RouterOutlet,
DialogModule
DialogModule,
ToastContainerComponent
],
templateUrl: './app.html',
styleUrls: ['./app.scss', '../styles.scss'],
@@ -0,0 +1,64 @@
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 11000;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 420px;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 10px 14px;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
color: #ffffff;
font-size: 0.875rem;
line-height: 1.35;
cursor: pointer;
animation: toast-in 0.18s ease-out;
}
.toast-message {
flex: 1;
word-break: break-word;
}
.toast-close {
background: transparent;
border: none;
color: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
opacity: 0.85;
}
.toast-error {
background-color: #dc2626;
}
.toast-success {
background-color: #16a34a;
}
.toast-info {
background-color: #2563eb;
}
@keyframes toast-in {
from {
transform: translateY(-6px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-toast-container',
standalone: true,
imports: [CommonModule],
template: `
<div class="toast-stack">
<div *ngFor="let toast of toastService.toasts()"
class="toast"
[class.toast-error]="toast.type === 'error'"
[class.toast-success]="toast.type === 'success'"
[class.toast-info]="toast.type === 'info'"
(click)="toastService.dismiss(toast.id)">
<span class="toast-message">{{ toast.message }}</span>
<button type="button" class="toast-close" aria-label="Dismiss"
(click)="toastService.dismiss(toast.id); $event.stopPropagation()">×</button>
</div>
</div>
`,
styleUrls: ['./toast-container.component.scss'],
})
export class ToastContainerComponent {
constructor(public toastService: ToastService) {}
}
@@ -0,0 +1,94 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, from, map, Observable, of, switchMap, tap, throwError } from 'rxjs';
import { ToastService } from '../services/toast.service';
/**
* Surfaces every failed HTTP response as a toast so errors are never silent.
* 401s are left to the auth interceptor (token refresh / redirect to login).
*
* Must be registered BEFORE the auth interceptor so its catchError runs LAST
* (outermost) — i.e. after auth has had a chance to refresh-and-retry a 401.
*/
export const httpErrorInterceptor: HttpInterceptorFn = (req, next) => {
const toastService = inject(ToastService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Handled by the auth interceptor (silent refresh or redirect to login).
return throwError(() => error);
}
return resolveMessage(error).pipe(
tap(message => toastService.error(message)),
switchMap(() => throwError(() => error)),
);
}),
);
};
/**
* Builds a human-readable message from the error response. The body can be a
* parsed object, a string, or a Blob (when the request used responseType:'blob',
* e.g. PDF / signature downloads) — Blobs must be read asynchronously.
*/
function resolveMessage(error: HttpErrorResponse): Observable<string> {
const body = error.error;
if (body instanceof Blob) {
return from(body.text()).pipe(map(text => parseBodyText(text, error.status)));
}
if (typeof body === 'string') {
return of(parseBodyText(body, error.status));
}
if (body && typeof body === 'object') {
const message = (body as { message?: unknown }).message;
const title = (body as { title?: unknown }).title;
if (typeof message === 'string' && message.length > 0) {
return of(message);
}
if (typeof title === 'string' && title.length > 0) {
return of(title);
}
}
return of(defaultMessage(error.status));
}
function parseBodyText(text: string, status: number): string {
if (text) {
try {
const parsed = JSON.parse(text) as { message?: unknown; title?: unknown };
if (typeof parsed.message === 'string' && parsed.message.length > 0) {
return parsed.message;
}
if (typeof parsed.title === 'string' && parsed.title.length > 0) {
return parsed.title;
}
} catch {
// Not JSON — fall through to the status-based default.
}
}
return defaultMessage(status);
}
function defaultMessage(status: number): string {
switch (status) {
case 0:
return 'Cannot reach the server / 無法連線到伺服器';
case 400:
return 'Invalid request / 請求格式錯誤';
case 403:
return 'You do not have permission for this action / 沒有權限執行此操作';
case 404:
return 'The requested item was not found / 找不到要求的資料';
case 409:
return 'This action conflicts with the current state / 操作與目前狀態衝突';
case 422:
return 'The submitted data is invalid / 提交的資料無效';
case 500:
return 'A server error occurred / 伺服器發生錯誤';
default:
return `Request failed (HTTP ${status}) / 請求失敗 (HTTP ${status})`;
}
}
@@ -0,0 +1,42 @@
import { Injectable, signal } from '@angular/core';
export type ToastType = 'error' | 'success' | 'info';
export interface Toast {
id: number;
type: ToastType;
message: string;
}
/**
* Lightweight, dependency-free toast/notification store. A single
* ToastContainerComponent (mounted in the app root) renders whatever this holds.
*/
@Injectable({ providedIn: 'root' })
export class ToastService {
private counter = 0;
readonly toasts = signal<Toast[]>([]);
error(message: string): void {
this.show('error', message);
}
success(message: string): void {
this.show('success', message);
}
info(message: string): void {
this.show('info', message);
}
dismiss(id: number): void {
this.toasts.update(list => list.filter(toast => toast.id !== id));
}
private show(type: ToastType, message: string): void {
const id = ++this.counter;
this.toasts.update(list => [...list, { id, type, message }]);
const autoDismissMs = type === 'error' ? 8000 : 4000;
setTimeout(() => this.dismiss(id), autoDismissMs);
}
}
@@ -0,0 +1,75 @@
<kendo-dialog title="Issue Checks / 開立支票" [width]="720" (close)="onClose()">
<div class="p-2 flex flex-col gap-4" style="max-height: 70vh; overflow-y: auto;">
<label class="flex flex-col gap-1 w-60">
Check Date / 支票日期
<kendo-datepicker [(ngModel)]="checkDate"></kendo-datepicker>
</label>
<div *ngFor="let f of forms; let i = index" class="border rounded p-3 flex flex-col gap-3"
style="border-color:#e5e7eb;">
<div class="font-semibold">
Check #{{ i + 1 }} — {{ f.group.payeeType }} · Total {{ f.group.totalAmount | currency }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
Payee Name / 收款人
<kendo-textbox [(ngModel)]="f.payeeName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="f.address"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="f.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="f.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="f.zip"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Check # / 支票號碼
<kendo-textbox [(ngModel)]="f.checkNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Memo / 摘要
<kendo-textbox [(ngModel)]="f.memo"></kendo-textbox>
</label>
</div>
<table class="w-full text-sm">
<thead>
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
<th class="py-1">Date</th><th>Description</th><th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let l of f.group.lines" style="border-bottom:1px solid #f3f4f6;">
<td class="py-1">{{ l.expenseDate }}</td>
<td>{{ l.description }}</td>
<td class="text-right">{{ l.amount | currency }}</td>
</tr>
<tr class="font-semibold">
<td></td><td class="text-right pr-2">Total</td>
<td class="text-right">{{ f.group.totalAmount | currency }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="onClose()">Cancel</button>
<button kendoButton themeColor="primary" (click)="confirm()">
Issue {{ forms.length }} Check(s)
</button>
</kendo-dialog-actions>
</kendo-dialog>
@@ -0,0 +1,83 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
import { GridModule } from '@progress/kendo-angular-grid';
import { PayeeGroupDto, IssueChecksRequest, PayeeCheckInstruction } from '../../models/disbursement.model';
interface PayeeForm {
group: PayeeGroupDto;
payeeName: string;
address: string;
city: string;
state: string;
zip: string;
checkNumber: string;
defaultCheckNumber: string;
memo: string;
}
@Component({
selector: 'app-issue-check-dialog',
standalone: true,
imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule, DateInputsModule, GridModule],
templateUrl: './issue-check-dialog.component.html',
})
export class IssueCheckDialogComponent implements OnInit {
/** The payee groups the user selected to pay. */
@Input() groups: PayeeGroupDto[] = [];
/** Next sequential check number from the church profile, used to prefill. */
@Input() nextCheckNumber = 1001;
@Output() save = new EventEmitter<IssueChecksRequest>();
@Output() cancel = new EventEmitter<void>();
checkDate = new Date();
forms: PayeeForm[] = [];
ngOnInit(): void {
let n = this.nextCheckNumber;
this.forms = this.groups.map(g => {
const cn = String(n++);
return {
group: g,
payeeName: g.payeeName,
address: g.address ?? '',
city: g.city ?? '',
state: g.state ?? '',
zip: g.zip ?? '',
checkNumber: cn,
defaultCheckNumber: cn,
memo: '',
};
});
}
confirm(): void {
const d = this.checkDate;
const checkDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payees: PayeeCheckInstruction[] = this.forms.map(f => ({
payeeType: f.group.payeeType,
memberId: f.group.memberId,
vendorKey: f.group.vendorKey,
payeeName: f.payeeName.trim(),
address: f.address.trim() || null,
city: f.city.trim() || null,
state: f.state.trim() || null,
zip: f.zip.trim() || null,
// Only send an override when the user changed the prefilled sequential number,
// so the server's auto-allocation (and counter consumption) stays in sync.
checkNumberOverride: f.checkNumber.trim() !== f.defaultCheckNumber ? f.checkNumber.trim() : null,
memo: f.memo.trim() || null,
expenseIds: f.group.lines.map(l => l.expenseId),
}));
this.save.emit({ checkDate, payees });
}
onClose(): void { this.cancel.emit(); }
}
@@ -0,0 +1,31 @@
<kendo-dialog title="Receipt Acknowledgement / 簽收" [width]="480" (close)="onClose()">
<div class="p-2 flex flex-col gap-3">
<div class="text-sm" style="color:#374151;">
Check #{{ check.checkNumber }} · {{ check.payeeName }} · {{ check.amount | currency }}
</div>
<label class="flex flex-col gap-1">
Signed Name / 簽收人姓名
<kendo-textbox [(ngModel)]="signedName" placeholder="Recipient name"></kendo-textbox>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm">Signature / 簽名</span>
<canvas #pad width="440" height="180"
class="border rounded touch-none"
style="border-color:#9ca3af; background:#fff; touch-action:none;"
(pointerdown)="onDown($event)"
(pointermove)="onMove($event)"
(pointerup)="onUp()"
(pointerleave)="onUp()">
</canvas>
<button kendoButton fillMode="flat" class="self-start" (click)="clear()">Clear / 清除</button>
</div>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="onClose()">Cancel</button>
<button kendoButton themeColor="primary" [disabled]="!hasInk || !signedName.trim() || submitting"
(click)="confirm()">Confirm / 確認簽收</button>
</kendo-dialog-actions>
</kendo-dialog>
@@ -0,0 +1,87 @@
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { CheckListItemDto } from '../../models/disbursement.model';
export interface ReceiptSignResult { signature: Blob; signedName: string; }
@Component({
selector: 'app-receipt-sign-dialog',
standalone: true,
imports: [CommonModule, FormsModule, DialogsModule, ButtonsModule, InputsModule],
templateUrl: './receipt-sign-dialog.component.html',
})
export class ReceiptSignDialogComponent implements AfterViewInit {
@Input() check!: CheckListItemDto;
@Output() save = new EventEmitter<ReceiptSignResult>();
@Output() cancel = new EventEmitter<void>();
@ViewChild('pad', { static: false }) padRef!: ElementRef<HTMLCanvasElement>;
signedName = '';
hasInk = false;
submitting = false;
private ctx!: CanvasRenderingContext2D;
private drawing = false;
ngAfterViewInit(): void {
const canvas = this.padRef.nativeElement;
this.signedName = this.check.payeeName || '';
this.ctx = canvas.getContext('2d')!;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
this.ctx.strokeStyle = '#111827';
this.ctx.lineWidth = 2;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
}
private point(e: PointerEvent): { x: number; y: number } {
const rect = this.padRef.nativeElement.getBoundingClientRect();
return {
x: (e.clientX - rect.left) * (this.padRef.nativeElement.width / rect.width),
y: (e.clientY - rect.top) * (this.padRef.nativeElement.height / rect.height),
};
}
onDown(e: PointerEvent): void {
e.preventDefault();
this.drawing = true;
const p = this.point(e);
this.ctx.beginPath();
this.ctx.moveTo(p.x, p.y);
this.padRef.nativeElement.setPointerCapture(e.pointerId);
}
onMove(e: PointerEvent): void {
if (!this.drawing) return;
const p = this.point(e);
this.ctx.lineTo(p.x, p.y);
this.ctx.stroke();
this.hasInk = true;
}
onUp(): void { this.drawing = false; }
clear(): void {
const canvas = this.padRef.nativeElement;
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hasInk = false;
}
confirm(): void {
if (!this.hasInk || !this.signedName.trim() || this.submitting) return;
this.submitting = true;
this.padRef.nativeElement.toBlob(blob => {
if (!blob) { this.submitting = false; return; }
this.save.emit({ signature: blob, signedName: this.signedName.trim() });
}, 'image/png');
}
onClose(): void { this.cancel.emit(); }
}
@@ -0,0 +1,53 @@
export type PayeeType = 'Vendor' | 'Member';
export type CheckStatus = 'Issued' | 'Voided';
export interface PagedResult<T> {
items: T[]; totalCount: number; page: number; pageSize: number; totalPages: number;
}
export interface ExpenseLineDto {
expenseId: number; expenseDate: string; description: string; amount: number;
ministryName: string; categoryName: string;
}
export interface PayeeGroupDto {
payeeType: PayeeType; memberId: number | null; vendorKey: string | null;
payeeName: string; address: string | null; city: string | null;
state: string | null; zip: string | null;
totalAmount: number; lines: ExpenseLineDto[];
}
export interface PayeeCheckInstruction {
payeeType: PayeeType; memberId: number | null; vendorKey: string | null;
payeeName: string; address: string | null; city: string | null;
state: string | null; zip: string | null;
checkNumberOverride: string | null; memo: string | null; expenseIds: number[];
}
export interface IssueChecksRequest { checkDate: string; payees: PayeeCheckInstruction[]; }
export interface IssuedCheckDto { checkId: number; checkNumber: string; payeeName: string; amount: number; }
export interface IssueChecksResultDto { created: IssuedCheckDto[]; }
export interface CheckListItemDto {
id: number; checkNumber: string; checkDate: string; amount: number;
payeeType: PayeeType; payeeName: string; status: CheckStatus; lineCount: number;
signed: boolean; receiptSignedName: string | null; receiptSignedAt: string | null;
}
export interface CheckLineDto { expenseId: number; description: string; amount: number; }
export interface CheckDetailDto extends CheckListItemDto {
memberId: number | null; address: string | null; city: string | null;
state: string | null; zip: string | null; memo: string | null;
voidReason: string | null; voidedAt: string | null; issuedAt: string;
lines: CheckLineDto[];
}
export interface ChurchProfileDto {
id: number; name: string; address: string | null; city: string | null;
state: string | null; zipCode: string | null; bankName: string | null;
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
}
export type UpdateChurchProfileRequest = Omit<ChurchProfileDto, 'id'>;
@@ -0,0 +1,120 @@
<div class="page">
<header class="page-header">
<h2>Check Register / 支票登記簿</h2>
</header>
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Search
<kendo-textbox placeholder="Check # / payee" [(ngModel)]="filter.search"
(keydown.enter)="applyFilter()"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Status
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
</kendo-dropdownlist>
</label>
<button kendoButton (click)="applyFilter()">Apply</button>
</div>
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
<kendo-grid-column field="checkNumber" title="Check #" [width]="100"></kendo-grid-column>
<kendo-grid-column field="checkDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column field="payeeName" title="Payee"></kendo-grid-column>
<kendo-grid-column field="amount" title="Amount" [width]="120" format="c2"></kendo-grid-column>
<kendo-grid-column title="Lines" [width]="80">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lineCount }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Status" [width]="110">
<ng-template kendoGridCellTemplate let-dataItem>
<span [class]="statusClass(dataItem.status)">{{ dataItem.status }}</span>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Receipt / 簽收" [width]="180">
<ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="dataItem.signed; else notSigned">
<span class="badge-paid">Signed</span>
<div class="text-xs" style="color:#6b7280;">
{{ dataItem.receiptSignedName }} · {{ dataItem.receiptSignedAt | date:'short' }}
</div>
</ng-container>
<ng-template #notSigned><span style="color:#9ca3af;"></span></ng-template>
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="600">
<ng-template kendoGridCellTemplate let-dataItem>
<button kendoButton fillMode="flat" (click)="view(dataItem)">View</button>
<button kendoButton fillMode="flat" themeColor="primary" (click)="print(dataItem)">Print</button>
<button *ngIf="canSign(dataItem)" kendoButton fillMode="flat" themeColor="success"
(click)="openSign(dataItem)">簽收</button>
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" (click)="viewSignature(dataItem)">Signature</button>
<button *ngIf="dataItem.signed" kendoButton fillMode="flat" themeColor="primary"
(click)="printReceipt(dataItem)">收據</button>
<button *ngIf="canVoid(dataItem)" kendoButton fillMode="flat" themeColor="error"
(click)="openVoid(dataItem)">Void</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<!-- Detail dialog -->
<kendo-dialog *ngIf="detail" title="Check #{{ detail.checkNumber }}" [width]="560" (close)="detail = null">
<div class="p-2 flex flex-col gap-2">
<div class="grid grid-cols-2 gap-2 text-sm">
<div><strong>Payee:</strong> {{ detail.payeeName }}</div>
<div><strong>Date:</strong> {{ detail.checkDate }}</div>
<div><strong>Amount:</strong> {{ detail.amount | currency }}</div>
<div><strong>Status:</strong> {{ detail.status }}</div>
<div class="col-span-2" *ngIf="detail.memo"><strong>Memo:</strong> {{ detail.memo }}</div>
<div class="col-span-2" *ngIf="detail.signed">
<strong>Signed:</strong> {{ detail.receiptSignedName }} · {{ detail.receiptSignedAt | date:'short' }}
</div>
<div class="col-span-2" *ngIf="detail.status === 'Voided'">
<strong>Voided:</strong> {{ detail.voidReason }} · {{ detail.voidedAt | date:'short' }}
</div>
</div>
<table class="w-full text-sm mt-2">
<thead>
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
<th class="py-1">Description</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let l of detail.lines" style="border-bottom:1px solid #f3f4f6;">
<td class="py-1">{{ l.description }}</td>
<td class="text-right">{{ l.amount | currency }}</td>
</tr>
</tbody>
</table>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="detail = null">Close</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Void dialog -->
<kendo-dialog *ngIf="voidRow" title="Void Check #{{ voidRow.checkNumber }}" [width]="420" (close)="voidRow = null">
<div class="p-2 flex flex-col gap-2">
<p class="text-sm" style="color:#991b1b;">
Voiding returns the bundled expenses to Approved so they can be re-issued.
/ 作廢將使支出退回「已核准」可重新開立。
</p>
<label class="flex flex-col gap-1">
Reason / 原因
<kendo-textbox [(ngModel)]="voidReason" placeholder="Optional"></kendo-textbox>
</label>
</div>
<kendo-dialog-actions>
<button kendoButton (click)="voidRow = null">Cancel</button>
<button kendoButton themeColor="error" (click)="confirmVoid()">Void</button>
</kendo-dialog-actions>
</kendo-dialog>
<!-- Receipt signature dialog -->
<app-receipt-sign-dialog *ngIf="signRow" [check]="signRow" (save)="onSign($event)" (cancel)="signRow = null">
</app-receipt-sign-dialog>
</div>
@@ -0,0 +1,26 @@
%badge-base {
display: inline-block;
padding: 2px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.badge-approved {
@extend %badge-base;
background-color: #dbeafe;
color: #1e40af;
}
.badge-paid {
@extend %badge-base;
background-color: #d1fae5;
color: #065f46;
}
.badge-rejected {
@extend %badge-base;
background-color: #fee2e2;
color: #991b1b;
}
@@ -0,0 +1,107 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule, PageChangeEvent } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { CHECK_STATUS_OPTIONS } from '../../../../shared/i18n/option-lists';
import { DisbursementApiService, CheckRegisterQuery } from '../../services/disbursement-api.service';
import { ReceiptSignDialogComponent, ReceiptSignResult } from '../../components/receipt-sign-dialog/receipt-sign-dialog.component';
import { CheckListItemDto, CheckDetailDto } from '../../models/disbursement.model';
@Component({
selector: 'app-check-register-page',
standalone: true,
imports: [
CommonModule, FormsModule, GridModule, ButtonsModule, DropDownsModule,
InputsModule, DialogsModule, ReceiptSignDialogComponent,
],
templateUrl: './check-register-page.component.html',
styleUrls: ['./check-register-page.component.scss'],
})
export class CheckRegisterPageComponent implements OnInit {
rows: CheckListItemDto[] = [];
total = 0;
page = 1;
pageSize = 20;
loading = false;
readonly statuses = CHECK_STATUS_OPTIONS;
filter: CheckRegisterQuery = {};
detail: CheckDetailDto | null = null;
signRow: CheckListItemDto | null = null;
voidRow: CheckListItemDto | null = null;
voidReason = '';
constructor(private api: DisbursementApiService) {}
ngOnInit(): void { this.load(); }
load(): void {
this.loading = true;
this.api.getRegister({ ...this.filter, page: this.page, pageSize: this.pageSize }).subscribe({
next: r => { this.rows = r.items; this.total = r.totalCount; this.loading = false; },
error: () => (this.loading = false),
});
}
get skip(): number { return (this.page - 1) * this.pageSize; }
applyFilter(): void { this.page = 1; this.load(); }
onPageChange(e: PageChangeEvent): void { this.page = Math.floor(e.skip / this.pageSize) + 1; this.load(); }
view(row: CheckListItemDto): void {
this.api.getCheck(row.id).subscribe(d => (this.detail = d));
}
print(row: CheckListItemDto): void {
this.api.downloadCheckPdf(row.id).subscribe(blob => this.openBlob(blob));
}
viewSignature(row: CheckListItemDto): void {
this.api.getSignature(row.id).subscribe(blob => this.openBlob(blob));
}
printReceipt(row: CheckListItemDto): void {
this.api.downloadReceiptPdf(row.id).subscribe(blob => this.openBlob(blob));
}
openSign(row: CheckListItemDto): void { this.signRow = row; }
onSign(result: ReceiptSignResult): void {
if (!this.signRow) return;
this.api.acknowledge(this.signRow.id, result.signature, result.signedName).subscribe({
next: () => { this.signRow = null; this.load(); },
error: () => {
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
},
});
}
openVoid(row: CheckListItemDto): void { this.voidRow = row; this.voidReason = ''; }
confirmVoid(): void {
if (!this.voidRow) return;
this.api.voidCheck(this.voidRow.id, this.voidReason || null).subscribe({
next: () => { this.voidRow = null; this.load(); },
error: () => {
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
},
});
}
canSign(row: CheckListItemDto): boolean { return row.status === 'Issued' && !row.signed; }
canVoid(row: CheckListItemDto): boolean { return row.status === 'Issued'; }
statusClass(status: string): string {
return ({ Issued: 'badge-approved', Voided: 'badge-rejected' } as Record<string, string>)[status] ?? '';
}
private openBlob(blob: Blob): void {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
}
@@ -0,0 +1,53 @@
<div class="page">
<header class="page-header">
<h2>Church Profile / 教會資料</h2>
</header>
<div *ngIf="model" class="max-w-3xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2">
Church Name / 教會名稱
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
</label>
<label class="flex flex-col gap-1 md:col-span-2">
Address / 地址
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
City / 城市
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
State / 州
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Zip / 郵遞區號
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
</label>
</div>
<label class="flex flex-col gap-1">
Bank Name / 銀行名稱
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Bank Account # / 銀行帳號
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Routing # / 路由號碼
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Next Check # / 下一張支票號碼
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
</label>
</div>
<div class="flex items-center gap-3 mt-4">
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
</div>
</div>
</div>
@@ -0,0 +1,39 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { ChurchProfileDto } from '../../models/disbursement.model';
@Component({
selector: 'app-church-profile-page',
standalone: true,
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
templateUrl: './church-profile-page.component.html',
})
export class ChurchProfilePageComponent implements OnInit {
model: ChurchProfileDto | null = null;
saving = false;
savedMsg = '';
constructor(private api: DisbursementApiService) {}
ngOnInit(): void {
this.api.getChurchProfile().subscribe(p => (this.model = p));
}
save(): void {
if (!this.model || this.saving) return;
this.saving = true;
this.savedMsg = '';
const { id, ...req } = this.model;
this.api.updateChurchProfile(req).subscribe({
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
error: () => {
// Error message is shown globally by httpErrorInterceptor.
this.saving = false;
},
});
}
}
@@ -0,0 +1,59 @@
<div class="page">
<header class="page-header flex items-center justify-between">
<h2>Disbursement Management / 支票開立</h2>
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
Issue Checks ({{ selectedCount }})
</button>
</header>
<p class="text-sm mb-3" style="color:#6b7280;">
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
/ 已核准待付款支出,依收款人彙整,每位收款人開立一張支票。
</p>
<kendo-grid
[kendoGridBinding]="rows"
[loading]="loading"
kendoGridSelectBy="key"
[(selectedKeys)]="selectedKeys"
[selectable]="{ checkboxOnly: true, mode: 'multiple' }"
[sortable]="true">
<kendo-grid-checkbox-column [width]="44" [showSelectAll]="true"></kendo-grid-checkbox-column>
<kendo-grid-column field="payeeName" title="Payee / 收款人"></kendo-grid-column>
<kendo-grid-column field="payeeType" title="Type" [width]="120"></kendo-grid-column>
<kendo-grid-column title="# Expenses" [width]="120">
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.lines.length }}</ng-template>
</kendo-grid-column>
<kendo-grid-column field="totalAmount" title="Total" [width]="140" format="c2"></kendo-grid-column>
<ng-template kendoGridDetailTemplate let-dataItem>
<table class="w-full text-sm">
<thead>
<tr class="text-left" style="border-bottom:1px solid #e5e7eb;">
<th class="py-1">Date</th><th>Description</th><th>Ministry</th>
<th>Category</th><th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let l of dataItem.lines" style="border-bottom:1px solid #f3f4f6;">
<td class="py-1">{{ l.expenseDate }}</td>
<td>{{ l.description }}</td>
<td>{{ l.ministryName }}</td>
<td>{{ l.categoryName }}</td>
<td class="text-right">{{ l.amount | currency }}</td>
</tr>
</tbody>
</table>
</ng-template>
</kendo-grid>
<app-issue-check-dialog
*ngIf="issueDialogGroups"
[groups]="issueDialogGroups"
[nextCheckNumber]="nextCheckNumber"
(save)="onIssue($event)"
(cancel)="issueDialogGroups = null">
</app-issue-check-dialog>
</div>
@@ -0,0 +1,3 @@
.page-header {
margin-bottom: 0.5rem;
}
@@ -0,0 +1,78 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DisbursementApiService } from '../../services/disbursement-api.service';
import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component';
import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model';
interface PayeeRow extends PayeeGroupDto { key: string; }
@Component({
selector: 'app-disbursement-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent],
templateUrl: './disbursement-page.component.html',
styleUrls: ['./disbursement-page.component.scss'],
})
export class DisbursementPageComponent implements OnInit {
rows: PayeeRow[] = [];
selectedKeys: string[] = [];
loading = false;
nextCheckNumber = 1001;
issueDialogGroups: PayeeGroupDto[] | null = null;
constructor(private api: DisbursementApiService) {}
ngOnInit(): void {
this.api.getChurchProfile().subscribe(p => (this.nextCheckNumber = p.nextCheckNumber));
this.load();
}
load(): void {
this.loading = true;
this.selectedKeys = [];
this.api.getApprovedUnpaid().subscribe({
next: groups => {
this.rows = groups.map(g => ({ ...g, key: this.keyOf(g) }));
this.loading = false;
},
error: () => (this.loading = false),
});
}
private keyOf(g: PayeeGroupDto): string {
return `${g.payeeType}:${g.payeeType === 'Member' ? g.memberId : g.vendorKey}`;
}
get selectedCount(): number { return this.selectedKeys.length; }
openIssue(): void {
const selected = this.rows.filter(r => this.selectedKeys.includes(r.key));
if (selected.length === 0) return;
this.issueDialogGroups = selected;
}
onIssue(req: IssueChecksRequest): void {
this.api.issue(req).subscribe({
next: result => {
this.issueDialogGroups = null;
result.created.forEach(c => this.openPdf(c.checkId));
this.load();
},
error: () => {
// Error message is shown globally by httpErrorInterceptor; keep the dialog open to retry.
},
});
}
private openPdf(checkId: number): void {
this.api.downloadCheckPdf(checkId).subscribe(blob => {
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 60_000);
});
}
}
@@ -0,0 +1,78 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ApiConfigService } from '../../../core/services/api-config.service';
import {
PagedResult, PayeeGroupDto, IssueChecksRequest, IssueChecksResultDto,
CheckListItemDto, CheckDetailDto, ChurchProfileDto, UpdateChurchProfileRequest,
} from '../models/disbursement.model';
export interface CheckRegisterQuery {
page?: number; pageSize?: number; status?: string; search?: string; from?: string; to?: string;
}
@Injectable({ providedIn: 'root' })
export class DisbursementApiService {
private readonly endpoint: string;
private readonly profileEndpoint: string;
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
this.endpoint = apiConfig.getApiUrl('disbursements');
this.profileEndpoint = apiConfig.getApiUrl('church-profile');
}
private toParams(q: Record<string, unknown>): HttpParams {
let p = new HttpParams();
for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '') p = p.set(k, String(v));
return p;
}
getApprovedUnpaid(): Observable<PayeeGroupDto[]> {
return this.http.get<PayeeGroupDto[]>(`${this.endpoint}/approved-unpaid`);
}
issue(req: IssueChecksRequest): Observable<IssueChecksResultDto> {
return this.http.post<IssueChecksResultDto>(`${this.endpoint}/issue`, req);
}
getRegister(q: CheckRegisterQuery): Observable<PagedResult<CheckListItemDto>> {
return this.http.get<PagedResult<CheckListItemDto>>(`${this.endpoint}/checks`, { params: this.toParams(q as Record<string, unknown>) });
}
getCheck(id: number): Observable<CheckDetailDto> {
return this.http.get<CheckDetailDto>(`${this.endpoint}/checks/${id}`);
}
voidCheck(id: number, reason: string | null): Observable<void> {
return this.http.post<void>(`${this.endpoint}/checks/${id}/void`, { reason });
}
/** Blob fetch via HttpClient so the auth interceptor attaches the JWT (a raw window.open would 401). */
downloadCheckPdf(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/checks/${id}/pdf`, { responseType: 'blob' });
}
/** Signed receipt PDF (check info + disbursement detail + e-signature). */
downloadReceiptPdf(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/checks/${id}/receipt-pdf`, { responseType: 'blob' });
}
acknowledge(id: number, signature: Blob, signedName: string): Observable<void> {
const form = new FormData();
form.append('signature', signature, `check-${id}-signature.png`);
form.append('signedName', signedName);
return this.http.post<void>(`${this.endpoint}/checks/${id}/acknowledge`, form);
}
getSignature(id: number): Observable<Blob> {
return this.http.get(`${this.endpoint}/checks/${id}/signature`, { responseType: 'blob' });
}
getChurchProfile(): Observable<ChurchProfileDto> {
return this.http.get<ChurchProfileDto>(this.profileEndpoint);
}
updateChurchProfile(r: UpdateChurchProfileRequest): Observable<void> {
return this.http.put<void>(this.profileEndpoint, r);
}
}
@@ -12,6 +12,7 @@ import { MemberApiService } from '../../../members/services/member-api.service';
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
import {
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
ExpenseListItemDto,
} from '../../models/expense.model';
export interface ExpenseFormResult { request: CreateExpenseRequest; receipt: File | null; }
@@ -29,6 +30,8 @@ export class ExpenseFormDialogComponent implements OnInit {
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
@Input() allowMemberPick = false;
@Input() title = 'New Expense';
/** When set, the dialog prefills from this row for editing instead of starting blank. */
@Input() expense: ExpenseListItemDto | null = null;
@Output() save = new EventEmitter<ExpenseFormResult>();
@Output() cancel = new EventEmitter<void>();
@@ -59,7 +62,30 @@ export class ExpenseFormDialogComponent implements OnInit {
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
this.catApi.getAll(false).subscribe(g => (this.groups = g));
this.catApi.getAll(false).subscribe(groups => {
this.groups = groups;
// Populate the sub-category list for the prefilled group so its value displays on edit.
if (this.expense) {
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
}
});
if (this.expense) this.prefill(this.expense);
}
private prefill(expense: ExpenseListItemDto): void {
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
const [year, month, day] = expense.expenseDate.split('-').map(Number);
this.form = {
ministryId: expense.ministryId,
categoryGroupId: expense.categoryGroupId,
subCategoryId: expense.subCategoryId,
amount: expense.amount,
description: expense.description,
vendorName: expense.vendorName ?? '',
checkNumber: expense.checkNumber ?? '',
memberId: expense.memberId,
expenseDate: new Date(year, month - 1, day),
};
}
onGroupChange(groupId: number | null): void {
@@ -11,21 +11,18 @@
<h3>Groups / 組別</h3>
<button kendoButton themeColor="primary" (click)="openNewGroup()">+ New Group</button>
</div>
<kendo-grid [data]="groups" [loading]="loading">
<div class="hint-text-sm">Click a row to view its subcategories · right-click for actions</div>
<kendo-grid class="clickable-rows" [data]="groups" [loading]="loading"
[rowClass]="groupRowClass"
(cellClick)="onGroupCellClick($event)">
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="70">
<ng-template kendoGridCellTemplate let-g>{{ g.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="180">
<ng-template kendoGridCellTemplate let-g>
<button kendoButton fillMode="flat" (click)="selectGroup(g)" [themeColor]="selectedGroup?.id === g.id ? 'primary' : 'base'">Select</button>
<button kendoButton fillMode="flat" (click)="openEditGroup(g)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="g.isActive" (click)="deactivateGroup(g)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-contextmenu #groupMenu [items]="groupMenuItems" (select)="onGroupMenuSelect($event)"></kendo-contextmenu>
</div>
<!-- Right: Subcategories of selected group -->
@@ -35,20 +32,17 @@
<button kendoButton themeColor="primary" [disabled]="!selectedGroup" (click)="openNewSub()">+ New Subcategory</button>
</div>
<div *ngIf="!selectedGroup" class="hint-text">Select a group on the left to view its subcategories.</div>
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading">
<div *ngIf="selectedGroup" class="hint-text-sm">Right-click a row for actions</div>
<kendo-grid *ngIf="selectedGroup" [data]="subCategories" [loading]="loading"
(cellClick)="onSubCellClick($event)">
<kendo-grid-column field="sortOrder" title="#" [width]="50"></kendo-grid-column>
<kendo-grid-column field="name_en" title="Name (EN)"></kendo-grid-column>
<kendo-grid-column field="name_zh" title="名稱 (中)"></kendo-grid-column>
<kendo-grid-column field="isActive" title="Active" [width]="70">
<ng-template kendoGridCellTemplate let-s>{{ s.isActive ? 'Yes' : 'No' }}</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="150">
<ng-template kendoGridCellTemplate let-s>
<button kendoButton fillMode="flat" (click)="openEditSub(s)">Edit</button>
<button kendoButton fillMode="flat" *ngIf="s.isActive" (click)="deactivateSub(s)">Deactivate</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<kendo-contextmenu #subMenu [items]="subMenuItems" (select)="onSubMenuSelect($event)"></kendo-contextmenu>
</div>
</div>
@@ -28,3 +28,18 @@
color: #888;
font-style: italic;
}
.hint-text-sm {
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #999;
}
// Group grid: rows are clickable to select.
.clickable-rows ::ng-deep .k-grid-content tr {
cursor: pointer;
}
::ng-deep .k-grid .k-table-row.selected-row > td {
background-color: rgba(0, 105, 217, 0.12);
}
@@ -1,17 +1,18 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GridModule } from '@progress/kendo-angular-grid';
import { GridModule, CellClickEvent, RowClassArgs } from '@progress/kendo-angular-grid';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DialogsModule } from '@progress/kendo-angular-dialog';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
@Component({
selector: 'app-expense-categories-page',
standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule],
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, InputsModule, ContextMenuModule],
templateUrl: './expense-categories-page.component.html',
styleUrls: ['./expense-categories-page.component.scss'],
})
@@ -20,6 +21,13 @@ export class ExpenseCategoriesPageComponent implements OnInit {
selectedGroup: ExpenseCategoryGroupDto | null = null;
loading = false;
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
groupMenuItems: { text: string }[] = [];
subMenuItems: { text: string }[] = [];
private contextGroup: ExpenseCategoryGroupDto | null = null;
private contextSub: ExpenseSubCategoryDto | null = null;
groupDialogOpen = false;
editingGroupId: number | null = null;
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true };
@@ -47,6 +55,50 @@ export class ExpenseCategoriesPageComponent implements OnInit {
selectGroup(g: ExpenseCategoryGroupDto): void { this.selectedGroup = g; }
get subCategories(): ExpenseSubCategoryDto[] { return this.selectedGroup?.subCategories ?? []; }
// Highlight the currently selected group row.
groupRowClass = (context: RowClassArgs): Record<string, boolean> => {
return { 'selected-row': this.selectedGroup?.id === context.dataItem.id };
};
// Left-click selects the group (reveals its subcategories); right-click opens the actions menu.
onGroupCellClick(event: CellClickEvent): void {
if (event.type === 'contextmenu') {
event.originalEvent.preventDefault();
this.contextGroup = event.dataItem;
this.groupMenuItems = this.buildMenuItems(event.dataItem.isActive);
this.groupMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
} else {
this.selectGroup(event.dataItem);
}
}
onGroupMenuSelect(event: ContextMenuSelectEvent): void {
if (!this.contextGroup) return;
if (event.item.text === 'Edit') this.openEditGroup(this.contextGroup);
else if (event.item.text === 'Deactivate') this.deactivateGroup(this.contextGroup);
}
// Subcategory rows have no selection behaviour; only the right-click actions menu.
onSubCellClick(event: CellClickEvent): void {
if (event.type !== 'contextmenu') return;
event.originalEvent.preventDefault();
this.contextSub = event.dataItem;
this.subMenuItems = this.buildMenuItems(event.dataItem.isActive);
this.subMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY });
}
onSubMenuSelect(event: ContextMenuSelectEvent): void {
if (!this.contextSub) return;
if (event.item.text === 'Edit') this.openEditSub(this.contextSub);
else if (event.item.text === 'Deactivate') this.deactivateSub(this.contextSub);
}
private buildMenuItems(isActive: boolean): { text: string }[] {
const items: { text: string }[] = [{ text: 'Edit' }];
if (isActive) items.push({ text: 'Deactivate' });
return items;
}
openNewGroup(): void {
this.editingGroupId = null;
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true };
@@ -7,33 +7,22 @@
<div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1">
Search
<kendo-textbox placeholder="Search description / vendor / member / check #"
[(ngModel)]="filter.search"
<kendo-textbox placeholder="Search description / vendor / member / check #" [(ngModel)]="filter.search"
(keydown.enter)="applyFilter()">
</kendo-textbox>
</label>
<label class="flex flex-col gap-1">
Ministry
<kendo-dropdownlist
[data]="ministries"
textField="label"
valueField="id"
[valuePrimitive]="true"
[(ngModel)]="filter.ministryId"
[defaultItem]="{ id: null, label: 'All Ministries/全部事工' }">
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
[(ngModel)]="filter.ministryId" [defaultItem]="{ id: null, label: 'All Ministries/全部事工' }">
</kendo-dropdownlist>
</label>
<label class="flex flex-col gap-1">
Status
<kendo-dropdownlist
[data]="statuses"
textField="label"
valueField="value"
[valuePrimitive]="true"
[(ngModel)]="filter.status"
[defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
<kendo-dropdownlist [data]="statuses" textField="label" valueField="value" [valuePrimitive]="true"
[(ngModel)]="filter.status" [defaultItem]="{ value: null, label: 'All Status/全部狀態' }">
</kendo-dropdownlist>
</label>
@@ -46,28 +35,23 @@
</div>
<!-- Main grid -->
<kendo-grid
[data]="{ data: rows, total: total }"
[loading]="loading"
[pageable]="true"
[skip]="skip"
[pageSize]="pageSize"
(pageChange)="onPageChange($event)">
<kendo-grid [data]="{ data: rows, total: total }" [loading]="loading" [pageable]="true" [skip]="skip"
[pageSize]="pageSize" (pageChange)="onPageChange($event)">
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
<kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column>
<!-- <kendo-grid-column field="type" title="Type" [width]="140"></kendo-grid-column> -->
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
<kendo-grid-column field="ministryName" title="Ministry" [width]="140"></kendo-grid-column>
<kendo-grid-column field="ministryName" title="Ministry" [width]="280"></kendo-grid-column>
<kendo-grid-column title="Category" [width]="180">
<kendo-grid-column title="Category" [width]="360">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.categoryGroupName }} / {{ dataItem.subCategoryName }}
</ng-template>
</kendo-grid-column>
<kendo-grid-column field="description" title="Description"></kendo-grid-column>
<kendo-grid-column title="Payee" [width]="150">
<ng-template kendoGridCellTemplate let-dataItem>
{{ dataItem.vendorName || dataItem.memberName || '—' }}
@@ -88,7 +72,7 @@
</ng-template>
</kendo-grid-column>
<kendo-grid-column title="Actions" [width]="240">
<kendo-grid-column title="Actions" [width]="100">
<ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="canApproveOrReject(dataItem)">
<button kendoButton themeColor="success" fillMode="flat" (click)="approve(dataItem)">Approve</button>
@@ -96,30 +80,21 @@
</ng-container>
<button *ngIf="canPay(dataItem)" kendoButton themeColor="primary" fillMode="flat"
(click)="openPay(dataItem)">Pay</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat"
(click)="openReceipt(dataItem.id)" class="receipt-link">Receipt</button>
<button *ngIf="dataItem.hasReceipt" kendoButton fillMode="flat" (click)="openReceipt(dataItem.id)"
class="receipt-link">Receipt</button>
</ng-template>
</kendo-grid-column>
</kendo-grid>
<!-- Vendor Payment dialog -->
<app-expense-form-dialog
*ngIf="vendorDialogOpen"
mode="vendor"
title="Vendor Payment"
(save)="onVendorSave($event)"
<app-expense-form-dialog *ngIf="vendorDialogOpen" mode="vendor" title="Vendor Payment" (save)="onVendorSave($event)"
(cancel)="vendorDialogOpen = false">
</app-expense-form-dialog>
<!-- Reimbursement (on behalf) dialog -->
<app-expense-form-dialog
*ngIf="reimbDialogOpen"
mode="reimbursement"
[allowMemberPick]="true"
title="Reimbursement (on behalf)"
(save)="onReimbSave($event)"
(cancel)="reimbDialogOpen = false">
<app-expense-form-dialog *ngIf="reimbDialogOpen" mode="reimbursement" [allowMemberPick]="true"
title="Reimbursement (on behalf)" (save)="onReimbSave($event)" (cancel)="reimbDialogOpen = false">
</app-expense-form-dialog>
<!-- Mark Paid dialog -->
@@ -46,7 +46,7 @@ export class ExpensesPageComponent implements OnInit {
rejectRow: ExpenseListItemDto | null = null;
rejectNotes = '';
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) {}
constructor(private api: ExpenseApiService, private ministryApi: MinistryApiService) { }
ngOnInit(): void {
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
@@ -124,7 +124,11 @@ export class ExpensesPageComponent implements OnInit {
}
canApproveOrReject(row: ExpenseListItemDto): boolean { return row.status === 'PendingApproval'; }
canPay(row: ExpenseListItemDto): boolean { return row.status === 'Approved'; }
canPay(row: ExpenseListItemDto): boolean {
return false;
// row.status === 'Approved';
//should be pay by disbursement
}
statusClass(status: string): string {
return ({
@@ -22,6 +22,7 @@
<kendo-grid-column title="Actions" [width]="200">
<ng-template kendoGridCellTemplate let-dataItem>
<ng-container *ngIf="canEdit(dataItem)">
<button kendoButton fillMode="flat" (click)="openEdit(dataItem)">Edit</button>
<button kendoButton themeColor="primary" fillMode="flat" (click)="submit(dataItem)">Submit</button>
<button kendoButton fillMode="flat" (click)="remove(dataItem)">Delete</button>
</ng-container>
@@ -34,8 +35,9 @@
<app-expense-form-dialog
*ngIf="dialogOpen"
mode="reimbursement"
title="New Reimbursement"
[expense]="editRow"
[title]="editRow ? 'Edit Reimbursement' : 'New Reimbursement'"
(save)="onSave($event)"
(cancel)="dialogOpen=false">
(cancel)="closeDialog()">
</app-expense-form-dialog>
</div>
@@ -18,6 +18,7 @@ export class MyReimbursementsPageComponent implements OnInit {
rows: ExpenseListItemDto[] = [];
loading = false;
dialogOpen = false;
editRow: ExpenseListItemDto | null = null;
constructor(private api: ExpenseApiService) {}
@@ -31,14 +32,23 @@ export class MyReimbursementsPageComponent implements OnInit {
});
}
openNew(): void { this.dialogOpen = true; }
openNew(): void { this.editRow = null; this.dialogOpen = true; }
openEdit(row: ExpenseListItemDto): void { this.editRow = row; this.dialogOpen = true; }
closeDialog(): void { this.dialogOpen = false; this.editRow = null; }
onSave(result: ExpenseFormResult): void {
this.api.create(result.request).pipe(
switchMap(created => result.receipt
? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created)))
: of(created)),
).subscribe(() => { this.dialogOpen = false; this.load(); });
if (this.editRow) {
const id = this.editRow.id;
this.api.update(id, result.request).pipe(
switchMap(() => result.receipt ? this.api.uploadReceipt(id, result.receipt) : of(void 0)),
).subscribe(() => { this.closeDialog(); this.load(); });
} else {
this.api.create(result.request).pipe(
switchMap(created => result.receipt
? this.api.uploadReceipt(created.id, result.receipt).pipe(switchMap(() => of(created)))
: of(created)),
).subscribe(() => { this.closeDialog(); this.load(); });
}
}
submit(row: ExpenseListItemDto): void { this.api.submit(row.id).subscribe(() => this.load()); }
@@ -96,7 +96,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
{ text: 'Giving Types', icon: categorizeIcon, path: '/user-portal/finance/giving-categories' },
{ text: 'Expenses', icon: moneyExchangeIcon, path: '/user-portal/finance/expenses' },
{ text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories' },
{ text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements' },
{ text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register' },
{ text: 'Monthly Statement', icon: fileReportIcon, path: '/user-portal/finance/monthly-statement' },
{ text: 'Church Profile', icon: buildingsOutlineIcon, path: '/user-portal/finance/church-profile' },
];
public personalNavItems: NavItem[] = [
@@ -226,7 +229,10 @@ export class UserPortalComponent implements OnInit, OnDestroy {
'reimbursements': 'My Reimbursements',
'finance/expenses': 'Expenses',
'finance/expense-categories': 'Expense Categories',
'finance/disbursements': 'Disbursement Management',
'finance/check-register': 'Check Register',
'finance/monthly-statement': 'Monthly Statement',
'finance/church-profile': 'Church Profile',
};
return titles[page] ?? 'Dashboard';
}
+5
View File
@@ -22,6 +22,11 @@ export const EXPENSE_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Rejected', label: 'Rejected/已拒絕' },
];
export const CHECK_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Issued', label: 'Issued/已開立' },
{ value: 'Voided', label: 'Voided/已作廢' },
];
export const MEMBER_STATUS_OPTIONS: readonly BilingualOption[] = [
{ value: 'Member', label: 'Member/會友' },
{ value: 'Visitor', label: 'Visitor/訪客' },