From c5405a95c396d5adecced1745b93b698dbd510b5 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 14:55:50 -0700 Subject: [PATCH 01/12] feat(expense-snapshot): add ExpenseSnapshot + ExpenseSnapshotLine entities --- API/ROLAC.API/Entities/ExpenseSnapshot.cs | 22 +++++++++++++++++++ API/ROLAC.API/Entities/ExpenseSnapshotLine.cs | 18 +++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 API/ROLAC.API/Entities/ExpenseSnapshot.cs create mode 100644 API/ROLAC.API/Entities/ExpenseSnapshotLine.cs 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; } +} From 5957d0f45eed73a2088f7dba804cabc59f2f03ed Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 14:58:18 -0700 Subject: [PATCH 02/12] feat(expense-snapshot): register snapshot tables + EF migration --- API/ROLAC.API/Data/AppDbContext.cs | 43 + ...0625215722_AddExpenseSnapshots.Designer.cs | 2451 +++++++++++++++++ .../20260625215722_AddExpenseSnapshots.cs | 122 + .../Migrations/AppDbContextModelSnapshot.cs | 165 ++ 4 files changed, 2781 insertions(+) create mode 100644 API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.Designer.cs create mode 100644 API/ROLAC.API/Migrations/20260625215722_AddExpenseSnapshots.cs 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/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"); From f1de8d7ab786cb8aa67eec0e4ade9a7bd1b47ef5 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 14:58:53 -0700 Subject: [PATCH 03/12] feat(expense-snapshot): add snapshot DTOs --- .../DTOs/Expense/ExpenseSnapshotDtos.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 API/ROLAC.API/DTOs/Expense/ExpenseSnapshotDtos.cs 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 { } From 73c52ded888d5563e7627b01f463147df8e132db Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 15:00:36 -0700 Subject: [PATCH 04/12] feat(expense-snapshot): snapshot service with creator-name resolution + tests --- .../Services/ExpenseSnapshotServiceTests.cs | 141 ++++++++++++++++ .../Services/ExpenseSnapshotService.cs | 151 ++++++++++++++++++ .../Services/IExpenseSnapshotService.cs | 11 ++ 3 files changed, 303 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs create mode 100644 API/ROLAC.API/Services/ExpenseSnapshotService.cs create mode 100644 API/ROLAC.API/Services/IExpenseSnapshotService.cs diff --git a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs new file mode 100644 index 0000000..d8918b7 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs @@ -0,0 +1,141 @@ +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(); + return (new ExpenseSnapshotService(db), 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 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)); + } +} diff --git a/API/ROLAC.API/Services/ExpenseSnapshotService.cs b/API/ROLAC.API/Services/ExpenseSnapshotService.cs new file mode 100644 index 0000000..cd85877 --- /dev/null +++ b/API/ROLAC.API/Services/ExpenseSnapshotService.cs @@ -0,0 +1,151 @@ +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; + public ExpenseSnapshotService(AppDbContext db) => _db = db; + + 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; + 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."); + } + } + + 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); +} From 4877fec1daf158febf21ec70e23cc0dbdc97cba0 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 15:01:38 -0700 Subject: [PATCH 05/12] feat(expense-snapshot): REST controller + DI registration --- .../Controllers/ExpenseSnapshotsController.cs | 68 +++++++++++++++++++ API/ROLAC.API/Program.cs | 1 + 2 files changed, 69 insertions(+) create mode 100644 API/ROLAC.API/Controllers/ExpenseSnapshotsController.cs 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/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(); From 8922bb69de282c9cc4e9e85528b57325f501f180 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 15:07:55 -0700 Subject: [PATCH 06/12] fix(expense-snapshot): validate functional class + stamp DeletedBy on soft delete Co-Authored-By: Claude Opus 4.8 --- .../Services/ExpenseSnapshotServiceTests.cs | 26 ++++++++++++++++++- .../Services/ExpenseSnapshotService.cs | 18 +++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs index d8918b7..a0b0e0e 100644 --- a/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs +++ b/API/ROLAC.API.Tests/Services/ExpenseSnapshotServiceTests.cs @@ -32,7 +32,12 @@ public class ExpenseSnapshotServiceTests db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" }); db.SaveChanges(); - return (new ExpenseSnapshotService(db), db); + + 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() @@ -62,6 +67,15 @@ public class ExpenseSnapshotServiceTests 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() { @@ -138,4 +152,14 @@ public class ExpenseSnapshotServiceTests 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/Services/ExpenseSnapshotService.cs b/API/ROLAC.API/Services/ExpenseSnapshotService.cs index cd85877..3df66bb 100644 --- a/API/ROLAC.API/Services/ExpenseSnapshotService.cs +++ b/API/ROLAC.API/Services/ExpenseSnapshotService.cs @@ -1,3 +1,5 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; @@ -8,7 +10,17 @@ namespace ROLAC.API.Services; public class ExpenseSnapshotService : IExpenseSnapshotService { private readonly AppDbContext _db; - public ExpenseSnapshotService(AppDbContext db) => _db = 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() { @@ -101,7 +113,7 @@ public class ExpenseSnapshotService : IExpenseSnapshotService { 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.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId; await _db.SaveChangesAsync(); } @@ -115,6 +127,8 @@ public class ExpenseSnapshotService : IExpenseSnapshotService 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}'."); } } From bc827e8b60231140ed6602a423ee802ebe126800 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 15:11:32 -0700 Subject: [PATCH 07/12] feat(expense-snapshot): frontend model + api service with tests Co-Authored-By: Claude Opus 4.8 --- .../expense/models/expense-snapshot.model.ts | 21 ++++++++ .../expense-snapshot-api.service.spec.ts | 52 +++++++++++++++++++ .../services/expense-snapshot-api.service.ts | 30 +++++++++++ 3 files changed, 103 insertions(+) create mode 100644 APP/src/app/features/expense/models/expense-snapshot.model.ts create mode 100644 APP/src/app/features/expense/services/expense-snapshot-api.service.spec.ts create mode 100644 APP/src/app/features/expense/services/expense-snapshot-api.service.ts 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/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}`); + } +} From 315d85ddcc37900879df178e16c36b7570cf8f37 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 15:13:09 -0700 Subject: [PATCH 08/12] feat(expense-snapshot): load/save snapshot in vendor payment form Co-Authored-By: Claude Opus 4.8 --- .../expense-form-dialog.component.html | 29 +++++++ .../expense-form-dialog.component.ts | 82 +++++++++++++++++++ 2 files changed, 111 insertions(+) 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..d87d6ef 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 @@
+ +
+ + +
+
- -
- -
-
From 099303995b0da22359cc9ded209c709d1370dae5 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 15:21:11 -0700 Subject: [PATCH 12/12] fix(expense-snapshot): gate page on Expenses:write to match the write-only API The snapshot management page backs an API that gates every action on Expenses:Write, so a read-only user reaching it via a read-gated nav/route would hit a silent 403 and a blank page. Require write for both. Co-Authored-By: Claude Opus 4.8 --- APP/src/app/app.routes.ts | 4 +++- APP/src/app/portals/user-portal/user-portal.component.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 3ae8f7f..7695999 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -168,7 +168,9 @@ export const routes: Routes = [ component: ExpenseSnapshotsPageComponent, canActivate: [PermissionGuard], data: { - permission: { module: PermissionModules.Expenses, action: 'read' }, + // 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', }, }, 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 e7a2353..1d1c991 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -133,7 +133,7 @@ export class UserPortalComponent implements OnInit, OnDestroy { { 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: 'read' } }, + 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',