WIP
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))} — {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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user