diff --git a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs new file mode 100644 index 0000000..a0b0e0e --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs @@ -0,0 +1,165 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using ROLAC.API.Services.Logging; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ExpenseSnapshotServiceTests +{ + private static AppDbContext BuildDb(string userId) + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(mock.Object))).Options); + } + + private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1") + { + var db = BuildDb(userId); + db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" }); + db.SaveChanges(); + + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var http = new Mock(); + http.Setup(x => x.HttpContext).Returns(ctx); + return (new ExpenseSnapshotService(db, http.Object), db); + } + + private static CreateExpenseSnapshotRequest Rent() => new() + { + Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X", + CheckNumber = "1001", + Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } }, + }; + + [Fact] + public async Task Create_PersistsHeaderAndLines_StampsCreator() + { + var (svc, db) = Build("creator-1"); + var id = await svc.CreateAsync(Rent()); + + var saved = await db.ExpenseSnapshots.FindAsync(id); + Assert.Equal("Monthly Rent", saved!.Name); + Assert.Equal("creator-1", saved.CreatedBy); + Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id)); + } + + [Fact] + public async Task Create_WithNoLines_Throws() + { + var (svc, _) = Build(); + var r = Rent(); r.Lines.Clear(); + await Assert.ThrowsAsync(() => svc.CreateAsync(r)); + } + + [Fact] + public async Task Create_WithInvalidFunctionalClass_Throws() + { + var (svc, _) = Build(); + var r = Rent(); + r.Lines[0].FunctionalClass = "NotAClass"; + await Assert.ThrowsAsync(() => svc.CreateAsync(r)); + } + + [Fact] + public async Task GetById_ReturnsLines_TotalsAndCreatorName() + { + var (svc, db) = Build("creator-1"); + db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" }); + db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 }); + await db.SaveChangesAsync(); + + var id = await svc.CreateAsync(Rent()); + var dto = await svc.GetByIdAsync(id); + + Assert.NotNull(dto); + Assert.Equal(1200m, dto!.TotalAmount); + Assert.Equal(1, dto.LineCount); + Assert.Equal("Rent", dto.Lines.Single().SubCategoryName); + Assert.Equal("Joy Wong", dto.CreatedByName); + } + + [Fact] + public async Task GetAll_ReturnsNewestFirst() + { + var (svc, _) = Build(); + var first = await svc.CreateAsync(Rent()); + var second = await svc.CreateAsync(Rent()); + + var all = await svc.GetAllAsync(); + + Assert.Equal(2, all.Count); + Assert.Equal(second, all[0].Id); + Assert.Equal(first, all[1].Id); + } + + [Fact] + public async Task Update_RenamesAndReplacesLines() + { + var (svc, db) = Build(); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" }); + await db.SaveChangesAsync(); + + var id = await svc.CreateAsync(Rent()); + await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest + { + Name = "Monthly Internet", MinistryId = 1, Description = "ISP", + Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } }, + }); + + var dto = await svc.GetByIdAsync(id); + Assert.Equal("Monthly Internet", dto!.Name); + Assert.Equal(80m, dto.TotalAmount); + Assert.Equal("Internet", dto.Lines.Single().SubCategoryName); + Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id)); + } + + [Fact] + public async Task Update_MissingId_Throws() + { + var (svc, _) = Build(); + await Assert.ThrowsAsync(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest + { + Name = "x", MinistryId = 1, Description = "x", + Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } }, + })); + } + + [Fact] + public async Task Delete_SoftDeletes_HidesFromQueries() + { + var (svc, db) = Build(); + var id = await svc.CreateAsync(Rent()); + + await svc.DeleteAsync(id); + + Assert.Empty(await svc.GetAllAsync()); + Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id)); + } + + [Fact] + public async Task Delete_StampsDeletedBy() + { + var (svc, db) = Build("deleter-1"); + var id = await svc.CreateAsync(Rent()); + await svc.DeleteAsync(id); + var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id); + Assert.Equal("deleter-1", row.DeletedBy); + } +} diff --git a/API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs b/API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs new file mode 100644 index 0000000..61b24e8 --- /dev/null +++ b/API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Authorization; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires +// Expenses:Write (super_admin bypasses), matching who can create vendor payments. +[ApiController] +[Route("api/expense-snapshots")] +[Authorize] +public class ExpenseSnapshotsController : ControllerBase +{ + private readonly IExpenseSnapshotService _svc; + private readonly IPermissionService _perms; + public ExpenseSnapshotsController(IExpenseSnapshotService svc, IPermissionService perms) + { + _svc = svc; + _perms = perms; + } + + private List Roles() => User.FindAll("role").Select(claim => claim.Value).ToList(); + private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole); + private async Task CanManageAsync() => + IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write); + + [HttpGet] + public async Task GetAll() + { + if (!await CanManageAsync()) return Forbid(); + return Ok(await _svc.GetAllAsync()); + } + + [HttpGet("{id:int}")] + public async Task GetById(int id) + { + if (!await CanManageAsync()) return Forbid(); + var dto = await _svc.GetByIdAsync(id); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost] + public async Task Create([FromBody] CreateExpenseSnapshotRequest r) + { + if (!await CanManageAsync()) return Forbid(); + try { return Ok(new { id = await _svc.CreateAsync(r) }); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpPut("{id:int}")] + public async Task Update(int id, [FromBody] UpdateExpenseSnapshotRequest r) + { + if (!await CanManageAsync()) return Forbid(); + try { await _svc.UpdateAsync(id, r); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + if (!await CanManageAsync()) return Forbid(); + try { await _svc.DeleteAsync(id); return NoContent(); } + catch (KeyNotFoundException) { return NotFound(); } + } +} diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs new file mode 100644 index 0000000..fc64f5c --- /dev/null +++ b/API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs @@ -0,0 +1,42 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Expense; + +public class ExpenseSnapshotLineDto +{ + public int CategoryGroupId { get; set; } + public string CategoryGroupName { get; set; } = ""; + public int SubCategoryId { get; set; } + public string SubCategoryName { get; set; } = ""; + public string? FunctionalClass { get; set; } + public decimal Amount { get; set; } + public string? Description { get; set; } +} + +public class ExpenseSnapshotDto +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public int MinistryId { get; set; } + public string MinistryName { get; set; } = ""; + public string Description { get; set; } = ""; + public string? VendorName { get; set; } + public string? CheckNumber { get; set; } + public string? Notes { get; set; } + public decimal TotalAmount { get; set; } // sum of line amounts (list hint) + public int LineCount { get; set; } + public string? CreatedByName { get; set; } // resolved Member full name, email fallback + public DateTimeOffset CreatedAt { get; set; } + public List Lines { get; set; } = new(); +} + +public class CreateExpenseSnapshotRequest +{ + [Required, MaxLength(150)] public string Name { get; set; } = ""; + [Required] public int MinistryId { get; set; } + [Required, MinLength(1)] public List Lines { get; set; } = new(); + [Required, MaxLength(500)] public string Description { get; set; } = ""; + [MaxLength(200)] public string? VendorName { get; set; } + [MaxLength(50)] public string? CheckNumber { get; set; } + public string? Notes { get; set; } +} +public class UpdateExpenseSnapshotRequest : CreateExpenseSnapshotRequest { } diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index ea7c1bb..37eaddf 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -23,6 +23,8 @@ public class AppDbContext : IdentityDbContext public DbSet Form990ExpenseLines => Set(); public DbSet Expenses => Set(); public DbSet ExpenseLines => Set(); + public DbSet ExpenseSnapshots => Set(); + public DbSet ExpenseSnapshotLines => Set(); public DbSet MonthlyStatements => Set(); public DbSet ChurchProfiles => Set(); public DbSet Checks => Set(); @@ -292,6 +294,47 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict); }); + // ── ExpenseSnapshot (reusable vendor-payment template) ─────────────── + builder.Entity(entity => + { + entity.HasQueryFilter(s => !s.IsDeleted); + + entity.Property(e => e.Name).HasMaxLength(150).IsRequired(); + entity.Property(e => e.Description).HasMaxLength(500).IsRequired(); + entity.Property(e => e.VendorName).HasMaxLength(200); + entity.Property(e => e.CheckNumber).HasMaxLength(50); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.Property(e => e.DeletedBy).HasMaxLength(450); + + entity.HasIndex(e => e.CreatedAt); + + entity.HasOne(e => e.Ministry).WithMany() + .HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict); + }); + + // ── ExpenseSnapshotLine (category breakdown of one snapshot) ───────── + builder.Entity(entity => + { + // Mirror the parent snapshot's soft-delete filter (required relationship). + entity.HasQueryFilter(l => !l.Snapshot!.IsDeleted); + + entity.Property(e => e.FunctionalClass).HasMaxLength(20); + entity.Property(e => e.Amount).HasColumnType("decimal(18,2)"); + entity.Property(e => e.Description).HasMaxLength(500); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + + entity.HasIndex(e => e.SnapshotId); + + entity.HasOne(e => e.Snapshot).WithMany(x => x.Lines) + .HasForeignKey(e => e.SnapshotId).OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.CategoryGroup).WithMany() + .HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(e => e.SubCategory).WithMany() + .HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict); + }); + // ── ChurchProfile (singleton settings) ─────────────────────────────── builder.Entity(entity => { diff --git a/API/ROLAC.API/Entities/ExpenseSnapshot.cs b/API/ROLAC.API/Entities/ExpenseSnapshot.cs new file mode 100644 index 0000000..5d28cde --- /dev/null +++ b/API/ROLAC.API/Entities/ExpenseSnapshot.cs @@ -0,0 +1,22 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// A reusable template of a vendor payment. Lets finance save a recurring fixed expense +/// (rent, internet, a fixed catered-meal cost) and re-apply it later, pre-filling everything +/// except the ExpenseDate. Shared church-wide; the creator is the auditable CreatedBy. +/// Lines are wholly owned by the header (replaced as a set on update, like ExpenseLine). +/// +public class ExpenseSnapshot : SoftDeleteEntity +{ + public int Id { get; set; } + public string Name { get; set; } = null!; // user label, e.g. "Monthly Rent — Landlord X" + public int MinistryId { get; set; } + public string Description { get; set; } = null!; + public string? VendorName { get; set; } + public string? CheckNumber { get; set; } + public string? Notes { get; set; } + + public Ministry? Ministry { get; set; } + public List Lines { get; set; } = new(); +} diff --git a/API/ROLAC.API/Entities/ExpenseSnapshotLine.cs b/API/ROLAC.API/Entities/ExpenseSnapshotLine.cs new file mode 100644 index 0000000..9724bae --- /dev/null +++ b/API/ROLAC.API/Entities/ExpenseSnapshotLine.cs @@ -0,0 +1,18 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// One category line of an , mirroring . +public class ExpenseSnapshotLine : AuditableEntity +{ + public int Id { get; set; } + public int SnapshotId { get; set; } + public int CategoryGroupId { get; set; } + public int SubCategoryId { get; set; } + public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass + public decimal Amount { get; set; } + public string? Description { get; set; } + + public ExpenseSnapshot? Snapshot { get; set; } + public ExpenseCategoryGroup? CategoryGroup { get; set; } + public ExpenseSubCategory? SubCategory { get; set; } +} diff --git a/API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.Designer.cs b/API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.Designer.cs new file mode 100644 index 0000000..2511107 --- /dev/null +++ b/API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.Designer.cs @@ -0,0 +1,2451 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ROLAC.API.Data; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260625215722_AddExpenseSnapshots")] + partial class AddExpenseSnapshots + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LanguagePreference") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .IsUnique() + .HasFilter("\"MemberId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckDate") + .HasColumnType("date"); + + b.Property("CheckNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PayeeAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PayeeCity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayeeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PayeeState") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PayeeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PayeeZip") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ReceiptCapturedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ReceiptSignatureBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptSignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReceiptSignedName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Issued"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VoidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AiProvider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Claude"); + + b.Property("BankAccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BankRoutingNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClaudeApiKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ClaudeModel") + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasDefaultValue("claude-haiku-4-5-20251001"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("GeminiApiKey") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("GeminiModel") + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasDefaultValue("gemini-2.5-flash-lite"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NameZh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextCheckNumber") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("State") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Website") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ZipCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.ToTable("ChurchProfiles"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseDate") + .HasColumnType("date"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("MinistryId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PaidBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ReceiptBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNotes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("Draft"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VendorName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ExpenseDate"); + + b.HasIndex("MemberId"); + + b.HasIndex("MinistryId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Form990LineId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Form990LineId"); + + b.ToTable("ExpenseCategoryGroups"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryGroupId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseId") + .HasColumnType("integer"); + + b.Property("FunctionalClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryGroupId"); + + b.HasIndex("ExpenseId"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("ExpenseLines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MinistryId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VendorName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("MinistryId"); + + b.ToTable("ExpenseSnapshots"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryGroupId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FunctionalClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SnapshotId") + .HasColumnType("integer"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryGroupId"); + + b.HasIndex("SnapshotId"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("ExpenseSnapshotLines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Form990LineId") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Form990LineId"); + + b.HasIndex("GroupId"); + + b.ToTable("ExpenseSubCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.FamilyUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("FamilyName_en") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FamilyName_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FamilyUnits"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LineCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("LineCode") + .IsUnique(); + + b.ToTable("Form990ExpenseLines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GivingCategoryId") + .HasColumnType("integer"); + + b.Property("GivingDate") + .HasColumnType("date"); + + b.Property("IsAnonymous") + .HasColumnType("boolean"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OfferingSessionId") + .HasColumnType("integer"); + + b.Property("PayPalTransactionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZelleReferenceCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("GivingCategoryId"); + + b.HasIndex("GivingDate"); + + b.HasIndex("OfferingSessionId") + .HasFilter("\"OfferingSessionId\" IS NOT NULL"); + + b.HasIndex("MemberId", "GivingDate"); + + b.ToTable("Givings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description_en") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Description_zh") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("GivingCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Logging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Changes") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Summary") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Category", "Timestamp"); + + b.HasIndex("EntityName", "EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Logging.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Timestamp", "Level"); + + b.ToTable("SystemLogs", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdultCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("AttendanceDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("KidCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("YouthCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AttendanceDate") + .IsUnique(); + + b.ToTable("MealAttendances"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Member", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaptismChurch") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaptismDate") + .HasColumnType("date"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasDefaultValue("USA"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Entity") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FamilyUnitId") + .HasColumnType("integer"); + + b.Property("FirstName_en") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FirstName_zh") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinDate") + .HasColumnType("date"); + + b.Property("LanguagePreference") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("LastName_en") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName_zh") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PhoneCell") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PhoneHome") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PhotoBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("State") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Member"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZipCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("FamilyUnitId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Ministry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DefaultFunctionalClass") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Program"); + + b.Property("Description_en") + .HasColumnType("text"); + + b.Property("Description_zh") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Ministries"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.MonthlyStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BankStatementBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CalculatedClosingBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Difference") + .HasColumnType("decimal(18,2)"); + + b.Property("FinalizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinalizedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsFinalized") + .HasColumnType("boolean"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpeningBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalExpenses") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalGiving") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalOtherIncome") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Month") + .IsUnique(); + + b.ToTable("MonthlyStatements"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("EnableEmail") + .HasColumnType("boolean"); + + b.Property("EnableLine") + .HasColumnType("boolean"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FromName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LineChannelAccessToken") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LineChannelSecret") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SmtpHost") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SmtpPassword") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SmtpPort") + .HasColumnType("integer"); + + b.Property("SmtpUseSsl") + .HasColumnType("boolean"); + + b.Property("SmtpUser") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("MemberId"); + + b.ToTable("LineBindingCodes"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Channel", "ExternalId") + .IsUnique(); + + b.HasIndex("MemberId", "Channel") + .IsUnique(); + + b.ToTable("MemberChannelBindings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.MessagingGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RegisteredAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Channel", "ExternalId") + .IsUnique(); + + b.ToTable("MessagingGroups"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("MessagingGroupId") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentByUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Subject") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TargetExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Channel"); + + b.HasIndex("MemberId"); + + b.HasIndex("MessagingGroupId"); + + b.HasIndex("SentAt"); + + b.ToTable("NotificationLogs"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CashTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Difference") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ProofPdfPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReconciledBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SessionDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Draft"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SystemTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("SessionDate") + .IsUnique(); + + b.ToTable("OfferingSessions"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ReplacedByHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanApprove") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanRead") + .HasColumnType("boolean"); + + b.Property("CanWrite") + .HasColumnType("boolean"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("RoleId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module") + .IsUnique(); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("DateFormat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DefaultLanguage") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("SiteTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SiteTitleZh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("UserInvitations"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .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.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.Ministry", "Ministry") + .WithMany() + .HasForeignKey("MinistryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Member"); + + b.Navigation("Ministry"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => + { + b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") + .WithMany() + .HasForeignKey("Form990LineId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Form990Line"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b => + { + b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup") + .WithMany() + .HasForeignKey("CategoryGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Expense", "Expense") + .WithMany("Lines") + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CategoryGroup"); + + b.Navigation("Expense"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b => + { + b.HasOne("ROLAC.API.Entities.Ministry", "Ministry") + .WithMany() + .HasForeignKey("MinistryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ministry"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b => + { + b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup") + .WithMany() + .HasForeignKey("CategoryGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.ExpenseSnapshot", "Snapshot") + .WithMany("Lines") + .HasForeignKey("SnapshotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CategoryGroup"); + + b.Navigation("Snapshot"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => + { + b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") + .WithMany() + .HasForeignKey("Form990LineId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group") + .WithMany("SubCategories") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Form990Line"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory") + .WithMany() + .HasForeignKey("GivingCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession") + .WithMany("Givings") + .HasForeignKey("OfferingSessionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GivingCategory"); + + b.Navigation("Member"); + + b.Navigation("OfferingSession"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Member", b => + { + b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit") + .WithMany() + .HasForeignKey("FamilyUnitId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("FamilyUnit"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Member"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Member"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.Notifications.MessagingGroup", "MessagingGroup") + .WithMany() + .HasForeignKey("MessagingGroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Member"); + + b.Navigation("MessagingGroup"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.UserInvitation", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => + { + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Navigation("Givings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.cs b/API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.cs new file mode 100644 index 0000000..901390b --- /dev/null +++ b/API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.cs @@ -0,0 +1,122 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + /// + public partial class AddExpenseSnapshots : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ExpenseSnapshots", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(150)", maxLength: 150, nullable: false), + MinistryId = table.Column(type: "integer", nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + VendorName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + CheckNumber = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Notes = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ExpenseSnapshots", x => x.Id); + table.ForeignKey( + name: "FK_ExpenseSnapshots_Ministries_MinistryId", + column: x => x.MinistryId, + principalTable: "Ministries", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "ExpenseSnapshotLines", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + SnapshotId = table.Column(type: "integer", nullable: false), + CategoryGroupId = table.Column(type: "integer", nullable: false), + SubCategoryId = table.Column(type: "integer", nullable: false), + FunctionalClass = table.Column(type: "character varying(20)", maxLength: 20, nullable: true), + Amount = table.Column(type: "numeric(18,2)", nullable: false), + Description = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(450)", maxLength: 450, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ExpenseSnapshotLines", x => x.Id); + table.ForeignKey( + name: "FK_ExpenseSnapshotLines_ExpenseCategoryGroups_CategoryGroupId", + column: x => x.CategoryGroupId, + principalTable: "ExpenseCategoryGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ExpenseSnapshotLines_ExpenseSnapshots_SnapshotId", + column: x => x.SnapshotId, + principalTable: "ExpenseSnapshots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ExpenseSnapshotLines_ExpenseSubCategories_SubCategoryId", + column: x => x.SubCategoryId, + principalTable: "ExpenseSubCategories", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseSnapshotLines_CategoryGroupId", + table: "ExpenseSnapshotLines", + column: "CategoryGroupId"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseSnapshotLines_SnapshotId", + table: "ExpenseSnapshotLines", + column: "SnapshotId"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseSnapshotLines_SubCategoryId", + table: "ExpenseSnapshotLines", + column: "SubCategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseSnapshots_CreatedAt", + table: "ExpenseSnapshots", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseSnapshots_MinistryId", + table: "ExpenseSnapshots", + column: "MinistryId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ExpenseSnapshotLines"); + + migrationBuilder.DropTable( + name: "ExpenseSnapshots"); + } + } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index ecf2ffa..341dcd8 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -762,6 +762,128 @@ namespace ROLAC.API.Migrations b.ToTable("ExpenseLines"); }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MinistryId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VendorName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("MinistryId"); + + b.ToTable("ExpenseSnapshots"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryGroupId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FunctionalClass") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("SnapshotId") + .HasColumnType("integer"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryGroupId"); + + b.HasIndex("SnapshotId"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("ExpenseSnapshotLines"); + }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => { b.Property("Id") @@ -2128,6 +2250,44 @@ namespace ROLAC.API.Migrations b.Navigation("SubCategory"); }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b => + { + b.HasOne("ROLAC.API.Entities.Ministry", "Ministry") + .WithMany() + .HasForeignKey("MinistryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ministry"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b => + { + b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup") + .WithMany() + .HasForeignKey("CategoryGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.ExpenseSnapshot", "Snapshot") + .WithMany("Lines") + .HasForeignKey("SnapshotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CategoryGroup"); + + b.Navigation("Snapshot"); + + b.Navigation("SubCategory"); + }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => { b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") @@ -2273,6 +2433,11 @@ namespace ROLAC.API.Migrations b.Navigation("SubCategories"); }); + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b => + { + b.Navigation("Lines"); + }); + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => { b.Navigation("Givings"); diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index ccc04c8..8f701c3 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -153,6 +153,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/ROLAC.API/Services/ExpenseSnapshotService.cs b/API/ROLAC.API/Services/ExpenseSnapshotService.cs new file mode 100644 index 0000000..3df66bb --- /dev/null +++ b/API/ROLAC.API/Services/ExpenseSnapshotService.cs @@ -0,0 +1,165 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +public class ExpenseSnapshotService : IExpenseSnapshotService +{ + private readonly AppDbContext _db; + private readonly IHttpContextAccessor _http; + public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http) + { _db = db; _http = http; } + + // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false), + // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it), + // then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production. + private string CurrentUserId => + _http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? _http.HttpContext?.User.FindFirstValue("sub") + ?? "system"; + + public async Task> GetAllAsync() + { + var snaps = await _db.ExpenseSnapshots.AsNoTracking() + .OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id) + .ToListAsync(); + if (snaps.Count == 0) return new(); + + var ids = snaps.Select(s => s.Id).ToList(); + var lines = await _db.ExpenseSnapshotLines.AsNoTracking() + .Where(l => ids.Contains(l.SnapshotId)).ToListAsync(); + var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList()); + + var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}"); + var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy)); + + return snaps.Select(s => + { + linesBySnapshot.TryGetValue(s.Id, out var ls); + return new ExpenseSnapshotDto + { + Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, + MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""), + Description = s.Description, VendorName = s.VendorName, + CheckNumber = s.CheckNumber, Notes = s.Notes, + TotalAmount = ls?.Sum(l => l.Amount) ?? 0, + LineCount = ls?.Count ?? 0, + CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy), + CreatedAt = s.CreatedAt, + }; + }).ToList(); + } + + public async Task GetByIdAsync(int id) + { + var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + if (s is null) return null; + + var lines = await _db.ExpenseSnapshotLines.AsNoTracking() + .Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync(); + var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? ""; + var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en); + var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en); + var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy); + + return new ExpenseSnapshotDto + { + Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName, + Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes, + TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count, + CreatedByName = creatorName, CreatedAt = s.CreatedAt, + Lines = lines.Select(l => new ExpenseSnapshotLineDto + { + CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""), + SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""), + FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description, + }).ToList(), + }; + } + + public async Task CreateAsync(CreateExpenseSnapshotRequest r) + { + ValidateLines(r.Lines); + var s = new ExpenseSnapshot + { + Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description, + VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes, + Lines = BuildLines(r.Lines), + }; + _db.ExpenseSnapshots.Add(s); + await _db.SaveChangesAsync(); + return s.Id; + } + + public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r) + { + ValidateLines(r.Lines); + var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Snapshot {id} not found."); + + s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description; + s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes; + + _db.ExpenseSnapshotLines.RemoveRange(s.Lines); + s.Lines = BuildLines(r.Lines); + await _db.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Snapshot {id} not found."); + s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId; + await _db.SaveChangesAsync(); + } + + private static void ValidateLines(List lines) + { + if (lines is null || lines.Count == 0) + throw new InvalidOperationException("A snapshot must have at least one line."); + foreach (var l in lines) + { + if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0) + throw new InvalidOperationException("Each snapshot line needs a category group and subcategory."); + if (l.Amount <= 0) + throw new InvalidOperationException("Each snapshot line amount must be greater than zero."); + if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass)) + throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'."); + } + } + + private static List BuildLines(List inputs) => + inputs.Select(l => new ExpenseSnapshotLine + { + CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId, + FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description, + }).ToList(); + + // Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked + // Member's full name when present, otherwise the account email. Mirrors ExpenseService. + private async Task> ResolveUserNamesAsync(IEnumerable userIds) + { + var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList(); + if (ids.Count == 0) return new(); + + var users = await _db.Users.AsNoTracking() + .Where(u => ids.Contains(u.Id)) + .Select(u => new { u.Id, u.Email, u.MemberId }) + .ToListAsync(); + + var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet(); + var memberNames = await _db.Members.AsNoTracking() + .Where(m => memberIds.Contains(m.Id)) + .ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim()); + + return users.ToDictionary( + u => u.Id, + u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0 + ? name + : (u.Email ?? u.Id)); + } +} diff --git a/API/ROLAC.API/Services/IExpenseSnapshotService.cs b/API/ROLAC.API/Services/IExpenseSnapshotService.cs new file mode 100644 index 0000000..db87623 --- /dev/null +++ b/API/ROLAC.API/Services/IExpenseSnapshotService.cs @@ -0,0 +1,11 @@ +using ROLAC.API.DTOs.Expense; +namespace ROLAC.API.Services; + +public interface IExpenseSnapshotService +{ + Task> GetAllAsync(); + Task GetByIdAsync(int id); + Task CreateAsync(CreateExpenseSnapshotRequest r); + Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r); + Task DeleteAsync(int id); +} diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 3835603..7695999 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -14,6 +14,7 @@ import { GivingsPageComponent } from './features/giving/pages/givings-page/givin import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component'; import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component'; import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component'; +import { ExpenseSnapshotsPageComponent } from './features/expense/pages/expense-snapshots-page/expense-snapshots-page.component'; 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'; @@ -162,6 +163,17 @@ export const routes: Routes = [ title: 'Expenses', titleZh: '支出', section: 'Finance', }, }, + { + path: 'finance/expense-snapshots', + component: ExpenseSnapshotsPageComponent, + canActivate: [PermissionGuard], + data: { + // Snapshots are a write-only management surface (the API gates every action on + // Expenses:Write), so require write — a read-only user has nothing to do here. + permission: { module: PermissionModules.Expenses, action: 'write' }, + title: 'Expense Snapshots', titleZh: '費用範本', section: 'Finance', + }, + }, { path: 'finance/expense-categories', component: ExpenseCategoriesPageComponent, diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html index 5884e11..1d9ea19 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html @@ -5,6 +5,19 @@
+ +
+ + +
+
- -
- -
-
@@ -210,4 +218,20 @@ + + + + +
+ +

費用日期不會存入範本 / The Expense Date is not saved in a snapshot.

+
+ + + +
\ No newline at end of file diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts index 3703f88..2f33e04 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.ts @@ -10,6 +10,8 @@ import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; import { MinistryApiService } from '../../services/ministry-api.service'; import { ExpenseCategoryApiService } from '../../services/expense-category-api.service'; import { ExpenseApiService } from '../../services/expense-api.service'; +import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service'; +import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model'; import { ExpenseAiService } from '../../services/expense-ai.service'; import { MemberApiService } from '../../../members/services/member-api.service'; import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; @@ -64,6 +66,19 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy { ministries: MinistryDto[] = []; groups: ExpenseCategoryGroupDto[] = []; + /** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */ + snapshots: ExpenseSnapshotDto[] = []; + /** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */ + selectedSnapshotId: number | null = null; + + /** "Save as snapshot" name-prompt state. */ + showSnapshotNamePrompt = false; + snapshotName = ''; + snapshotSaving = false; + + /** Snapshot tools (load/save) are a vendor-payment feature only. */ + get showSnapshotTools(): boolean { return this.mode === 'vendor'; } + memberResults: MemberOption[] = []; /** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */ @@ -116,12 +131,14 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy { private catApi: ExpenseCategoryApiService, private memberApi: MemberApiService, private expenseApi: ExpenseApiService, + private snapshotApi: ExpenseSnapshotApiService, private aiApi: ExpenseAiService, private sanitizer: DomSanitizer, ) {} ngOnInit(): void { this.ministryApi.getAll().subscribe(m => (this.ministries = m)); + if (this.showSnapshotTools) this.loadSnapshots(); this.catApi.getAll(false).subscribe(groups => { this.groups = groups; // Populate each line's sub-category list once the catalog is loaded (edit mode). @@ -315,6 +332,71 @@ export class ExpenseFormDialogComponent implements OnInit, OnDestroy { this.receiptPdfUrl = null; } + private loadSnapshots(): void { + this.snapshotApi.getAll().subscribe(list => (this.snapshots = list)); + } + + /** Apply a saved snapshot: prefill header + lines, but keep today's Expense Date. */ + applySnapshot(snapshotId: number | null): void { + if (snapshotId == null) return; + this.snapshotApi.getById(snapshotId).subscribe(s => { + this.form.ministryId = s.ministryId; + this.form.description = s.description; + this.form.vendorName = s.vendorName ?? ''; + this.form.checkNumber = s.checkNumber ?? ''; + // Expense Date is intentionally NOT taken from the snapshot — leave it as-is (today). + this.lines = s.lines.map(line => ({ + categoryGroupId: line.categoryGroupId, + subCategoryId: line.subCategoryId, + amount: line.amount, + description: line.description ?? '', + functionalClass: line.functionalClass, + subs: this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [], + })); + if (this.lines.length === 0) this.lines = [this.emptyLine()]; + this.selectedSnapshotId = null; + }); + } + + /** Open the name prompt for saving the current form as a snapshot (requires a valid form). */ + openSnapshotPrompt(): void { + if (!this.isValid) return; + this.snapshotName = ''; + this.showSnapshotNamePrompt = true; + } + + cancelSnapshotPrompt(): void { this.showSnapshotNamePrompt = false; } + + /** Save the current header + lines as a named snapshot (Expense Date is not stored). */ + saveSnapshot(): void { + const name = this.snapshotName.trim(); + if (!name || this.snapshotSaving) return; + const request: CreateExpenseSnapshotRequest = { + name, + ministryId: this.form.ministryId!, + lines: this.lines.map(l => ({ + categoryGroupId: l.categoryGroupId!, + subCategoryId: l.subCategoryId!, + amount: l.amount, + functionalClass: l.functionalClass, + description: l.description.trim() || null, + })), + description: this.form.description.trim(), + vendorName: this.form.vendorName || null, + checkNumber: this.form.checkNumber || null, + notes: null, + }; + this.snapshotSaving = true; + this.snapshotApi.create(request).subscribe({ + next: () => { + this.snapshotSaving = false; + this.showSnapshotNamePrompt = false; + this.loadSnapshots(); + }, + error: () => { this.snapshotSaving = false; }, + }); + } + get isValid(): boolean { return !!this.form.ministryId && this.form.description.trim().length > 0 diff --git a/APP/src/app/features/expense/models/expense-snapshot.model.ts b/APP/src/app/features/expense/models/expense-snapshot.model.ts new file mode 100644 index 0000000..1f9abaa --- /dev/null +++ b/APP/src/app/features/expense/models/expense-snapshot.model.ts @@ -0,0 +1,21 @@ +import { ExpenseLineInput, FunctionalClass } from './expense.model'; + +export interface ExpenseSnapshotLineDto { + categoryGroupId: number; categoryGroupName: string; + subCategoryId: number; subCategoryName: string; + functionalClass: FunctionalClass | null; amount: number; description: string | null; +} + +export interface ExpenseSnapshotDto { + id: number; name: string; ministryId: number; ministryName: string; + description: string; vendorName: string | null; checkNumber: string | null; notes: string | null; + totalAmount: number; lineCount: number; + createdByName: string | null; createdAt: string; + lines: ExpenseSnapshotLineDto[]; +} + +export interface CreateExpenseSnapshotRequest { + name: string; ministryId: number; lines: ExpenseLineInput[]; + description: string; vendorName: string | null; checkNumber: string | null; notes: string | null; +} +export type UpdateExpenseSnapshotRequest = CreateExpenseSnapshotRequest; diff --git a/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html b/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html new file mode 100644 index 0000000..e13083d --- /dev/null +++ b/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.html @@ -0,0 +1,68 @@ +
+

+ 儲存常用的固定費用(房租、網路、餐費…)為範本,下次可快速套用。費用日期不會儲存。
+ Save recurring fixed expenses as snapshots to quickly re-use them. The Expense Date is never saved. +

+ + + + + +
+
+
+ {{ row.name }} + {{ row.totalAmount | currency }} +
+
{{ row.vendorName || '—' }} · {{ row.ministryName }}
+
{{ row.createdByName || '—' }} · {{ row.createdAt | date:'yyyy-MM-dd' }}
+
+ + +
+
+

尚無範本 / No snapshots yet.

+
+ + + + + + + + + + + + +

確定刪除「{{ deleteRow.name }}」? / Delete "{{ deleteRow.name }}"?

+ + + + +
+
diff --git a/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss b/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss new file mode 100644 index 0000000..12beaad --- /dev/null +++ b/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.scss @@ -0,0 +1,3 @@ +.page { + padding: 0.5rem 0; +} diff --git a/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts b/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts new file mode 100644 index 0000000..16bd681 --- /dev/null +++ b/APP/src/app/features/expense/pages/expense-snapshots-page/expense-snapshots-page.component.ts @@ -0,0 +1,84 @@ +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 { InputsModule } from '@progress/kendo-angular-inputs'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service'; +import { ExpenseSnapshotDto } from '../../models/expense-snapshot.model'; +import { switchMap } from 'rxjs'; + +@Component({ + selector: 'app-expense-snapshots-page', + standalone: true, + imports: [CommonModule, FormsModule, GridModule, ButtonsModule, InputsModule, DialogsModule], + templateUrl: './expense-snapshots-page.component.html', + styleUrls: ['./expense-snapshots-page.component.scss'], +}) +export class ExpenseSnapshotsPageComponent implements OnInit { + rows: ExpenseSnapshotDto[] = []; + loading = false; + + /** Row being renamed (drives the rename dialog); null when closed. */ + renameRow: ExpenseSnapshotDto | null = null; + renameValue = ''; + renameSaving = false; + + /** Row pending delete confirmation. */ + deleteRow: ExpenseSnapshotDto | null = null; + + constructor(private api: ExpenseSnapshotApiService) {} + + ngOnInit(): void { this.load(); } + + load(): void { + this.loading = true; + this.api.getAll().subscribe({ + next: list => { this.rows = list; this.loading = false; }, + error: () => { this.loading = false; }, + }); + } + + openRename(row: ExpenseSnapshotDto): void { + this.renameRow = row; + this.renameValue = row.name; + } + cancelRename(): void { this.renameRow = null; } + + confirmRename(): void { + const row = this.renameRow; + const name = this.renameValue.trim(); + if (!row || !name || this.renameSaving) return; + this.renameSaving = true; + // Fetch the full snapshot, swap the name, PUT it back (lines/fields preserved). + this.api.getById(row.id).pipe( + switchMap(full => this.api.update(row.id, { + name, + ministryId: full.ministryId, + description: full.description, + vendorName: full.vendorName, + checkNumber: full.checkNumber, + notes: full.notes, + lines: full.lines.map(l => ({ + categoryGroupId: l.categoryGroupId, + subCategoryId: l.subCategoryId, + amount: l.amount, + functionalClass: l.functionalClass, + description: l.description, + })), + })), + ).subscribe({ + next: () => { this.renameSaving = false; this.renameRow = null; this.load(); }, + error: () => { this.renameSaving = false; }, + }); + } + + openDelete(row: ExpenseSnapshotDto): void { this.deleteRow = row; } + cancelDelete(): void { this.deleteRow = null; } + + confirmDelete(): void { + if (!this.deleteRow) return; + this.api.delete(this.deleteRow.id).subscribe(() => { this.deleteRow = null; this.load(); }); + } +} diff --git a/APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts b/APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts new file mode 100644 index 0000000..eca110e --- /dev/null +++ b/APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ExpenseSnapshotApiService } from './expense-snapshot-api.service'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { CreateExpenseSnapshotRequest } from '../models/expense-snapshot.model'; + +describe('ExpenseSnapshotApiService', () => { + let service: ExpenseSnapshotApiService; + let httpMock: HttpTestingController; + const base = 'http://test/api/expense-snapshots'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + ExpenseSnapshotApiService, + { provide: ApiConfigService, useValue: { getApiUrl: () => base } }, + ], + }); + service = TestBed.inject(ExpenseSnapshotApiService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('getAll() GETs the collection endpoint', () => { + service.getAll().subscribe(); + const req = httpMock.expectOne(base); + expect(req.request.method).toBe('GET'); + req.flush([]); + }); + + it('create() POSTs the request body', () => { + const body: CreateExpenseSnapshotRequest = { + name: 'Rent', ministryId: 1, description: 'Office rent', + vendorName: 'Landlord X', checkNumber: null, notes: null, + lines: [{ categoryGroupId: 1, subCategoryId: 1, amount: 1200, functionalClass: null, description: null }], + }; + service.create(body).subscribe(); + const req = httpMock.expectOne(base); + expect(req.request.method).toBe('POST'); + expect(req.request.body.name).toBe('Rent'); + req.flush({ id: 7 }); + }); + + it('delete() DELETEs by id', () => { + service.delete(9).subscribe(); + const req = httpMock.expectOne(`${base}/9`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); +}); diff --git a/APP/src/app/features/expense/services/expense-snapshot-api.service.ts b/APP/src/app/features/expense/services/expense-snapshot-api.service.ts new file mode 100644 index 0000000..bcf3154 --- /dev/null +++ b/APP/src/app/features/expense/services/expense-snapshot-api.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + ExpenseSnapshotDto, CreateExpenseSnapshotRequest, UpdateExpenseSnapshotRequest, +} from '../models/expense-snapshot.model'; + +@Injectable({ providedIn: 'root' }) +export class ExpenseSnapshotApiService { + private readonly endpoint: string; + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('expense-snapshots'); + } + getAll(): Observable { + return this.http.get(this.endpoint); + } + getById(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + create(r: CreateExpenseSnapshotRequest): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, r); + } + update(id: number, r: UpdateExpenseSnapshotRequest): Observable { + return this.http.put(`${this.endpoint}/${id}`, r); + } + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } +} diff --git a/APP/src/app/portals/user-portal/user-portal.component.ts b/APP/src/app/portals/user-portal/user-portal.component.ts index 4399781..1d1c991 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -132,6 +132,8 @@ export class UserPortalComponent implements OnInit, OnDestroy { permission: { module: PermissionModules.Expenses, action: 'read' } }, { text: 'Expense Categories', icon: categorizeIcon, path: '/user-portal/finance/expense-categories', permission: { module: PermissionModules.ExpenseCategories, action: 'read' } }, + { text: 'Expense Snapshots', icon: categorizeIcon, path: '/user-portal/finance/expense-snapshots', + permission: { module: PermissionModules.Expenses, action: 'write' } }, { text: 'Disbursements', icon: banknoteOutlineIcon, path: '/user-portal/finance/disbursements', permission: { module: PermissionModules.Disbursements, action: 'read' } }, { text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register',