From 3b76ff43fc6f58d11e833b509745a70bcef15377 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:40:22 -0700 Subject: [PATCH 01/32] feat(1099): add Form1099 constants (threshold, box codes, W9 statuses) Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Entities/Form1099.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 API/ROLAC.API/Entities/Form1099.cs diff --git a/API/ROLAC.API/Entities/Form1099.cs b/API/ROLAC.API/Entities/Form1099.cs new file mode 100644 index 0000000..dc683a4 --- /dev/null +++ b/API/ROLAC.API/Entities/Form1099.cs @@ -0,0 +1,20 @@ +namespace ROLAC.API.Entities; + +/// Shared 1099 constants. Box codes match Form1099Box.BoxCode seed values. +public static class Form1099 +{ + /// IRS reporting threshold (USD) per box, per recipient, per calendar year. + public const decimal ReportingThreshold = 600m; + + public const string BoxNec1 = "NEC-1"; // Nonemployee compensation + public const string BoxMisc1 = "MISC-1"; // Rents + + public static class W9Status + { + public const string Missing = "Missing"; + public const string Requested = "Requested"; + public const string OnFile = "OnFile"; + public const string Expired = "Expired"; + public static readonly IReadOnlyList All = [Missing, Requested, OnFile, Expired]; + } +} From 89f02d020bf9effe1829752b6e6af1c0892c0530 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:40:30 -0700 Subject: [PATCH 02/32] feat(1099): add Form1099Box catalog entity Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Entities/Form1099Box.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 API/ROLAC.API/Entities/Form1099Box.cs diff --git a/API/ROLAC.API/Entities/Form1099Box.cs b/API/ROLAC.API/Entities/Form1099Box.cs new file mode 100644 index 0000000..58b0a8c --- /dev/null +++ b/API/ROLAC.API/Entities/Form1099Box.cs @@ -0,0 +1,14 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// A 1099 reporting box, e.g. "NEC-1 — Nonemployee compensation". +public class Form1099Box : AuditableEntity, IAuditable +{ + public int Id { get; set; } + public string BoxCode { get; set; } = null!; // "NEC-1", "MISC-1" + public string Name_en { get; set; } = null!; + public string? Name_zh { get; set; } + public string FormType { get; set; } = "1099-NEC"; // "1099-NEC" | "1099-MISC" + public int SortOrder { get; set; } + public bool IsActive { get; set; } = true; +} From 48ae014defcf6db009567031ce18a0fb387b4662 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:40:34 -0700 Subject: [PATCH 03/32] feat(1099): add Payee1099 recipient master entity Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Entities/Payee1099.cs | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 API/ROLAC.API/Entities/Payee1099.cs diff --git a/API/ROLAC.API/Entities/Payee1099.cs b/API/ROLAC.API/Entities/Payee1099.cs new file mode 100644 index 0000000..2238ae1 --- /dev/null +++ b/API/ROLAC.API/Entities/Payee1099.cs @@ -0,0 +1,32 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// A 1099 recipient (independent contractor / vendor). Holds W-9 data and an encrypted TIN. +/// Optionally linked to a Member (e.g. a part-time co-worker paid as a contractor). +/// +public class Payee1099 : SoftDeleteEntity, IAuditable +{ + public int Id { get; set; } + public string LegalName { get; set; } = null!; // name on the W-9 + public string? DisplayName { get; set; } // friendly / DBA + public int? MemberId { get; set; } + public Member? Member { get; set; } + public string TaxClassification { get; set; } = "Individual"; // drives Is1099Tracked default + public bool Is1099Tracked { get; set; } = true; + public string? TinType { get; set; } // "SSN" | "EIN" + public string? TinEncrypted { get; set; } // Data-Protection ciphertext + public string? TinLast4 { get; set; } + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string W9Status { get; set; } = Form1099.W9Status.Missing; + public DateOnly? W9ReceivedDate { get; set; } + public string? W9BlobPath { get; set; } + public bool IsActive { get; set; } = true; + public string? Notes { get; set; } +} From 7809ba974181fa6e19777666effd42ac7695ba23 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:40:40 -0700 Subject: [PATCH 04/32] feat(1099): add Form1099BoxId mapping FKs and Expense.PayeeId Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Entities/Expense.cs | 2 ++ API/ROLAC.API/Entities/ExpenseCategoryGroup.cs | 3 +++ API/ROLAC.API/Entities/ExpenseSubCategory.cs | 3 +++ 3 files changed, 8 insertions(+) diff --git a/API/ROLAC.API/Entities/Expense.cs b/API/ROLAC.API/Entities/Expense.cs index 3597676..34daddb 100644 --- a/API/ROLAC.API/Entities/Expense.cs +++ b/API/ROLAC.API/Entities/Expense.cs @@ -11,6 +11,7 @@ public class Expense : SoftDeleteEntity, IAuditable public string Description { get; set; } = null!; public string? VendorName { get; set; } public int? MemberId { get; set; } + public int? PayeeId { get; set; } // 1099 recipient attribution (header-level) public string? CheckNumber { get; set; } public DateOnly ExpenseDate { get; set; } public string? ReceiptBlobPath { get; set; } @@ -25,5 +26,6 @@ public class Expense : SoftDeleteEntity, IAuditable public Ministry? Ministry { get; set; } public Member? Member { get; set; } + public Payee1099? Payee { get; set; } public List Lines { get; set; } = new(); } diff --git a/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs b/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs index 7349323..ac87791 100644 --- a/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs +++ b/API/ROLAC.API/Entities/ExpenseCategoryGroup.cs @@ -12,5 +12,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable public int? Form990LineId { get; set; } public Form990ExpenseLine? Form990Line { get; set; } + public int? Form1099BoxId { get; set; } // null = not 1099-reportable + public Form1099Box? Form1099Box { get; set; } + public List SubCategories { get; set; } = []; } diff --git a/API/ROLAC.API/Entities/ExpenseSubCategory.cs b/API/ROLAC.API/Entities/ExpenseSubCategory.cs index 0c20a3a..0719d0d 100644 --- a/API/ROLAC.API/Entities/ExpenseSubCategory.cs +++ b/API/ROLAC.API/Entities/ExpenseSubCategory.cs @@ -13,5 +13,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable public int? Form990LineId { get; set; } public Form990ExpenseLine? Form990Line { get; set; } + public int? Form1099BoxId { get; set; } // null = not 1099-reportable + public Form1099Box? Form1099Box { get; set; } + public ExpenseCategoryGroup? Group { get; set; } } From 225e64b992406b19cbf0fe453810d8725ce4179c Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:40:49 -0700 Subject: [PATCH 05/32] feat(1099): configure Payee1099, Form1099Box, and mapping FKs in DbContext Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Data/AppDbContext.cs | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 37eaddf..a23b75b 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -21,6 +21,8 @@ public class AppDbContext : IdentityDbContext public DbSet ExpenseCategoryGroups => Set(); public DbSet ExpenseSubCategories => Set(); public DbSet Form990ExpenseLines => Set(); + public DbSet Payee1099s => Set(); + public DbSet Form1099Boxes => Set(); public DbSet Expenses => Set(); public DbSet ExpenseLines => Set(); public DbSet ExpenseSnapshots => Set(); @@ -218,6 +220,32 @@ public class AppDbContext : IdentityDbContext entity.HasIndex(e => e.LineCode).IsUnique(); }); + // ── Form1099Box (1099 reporting box catalog) ────────────────────────── + builder.Entity(entity => + { + entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired(); + entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Name_zh).HasMaxLength(200); + entity.Property(e => e.FormType).HasMaxLength(20).IsRequired(); + entity.HasIndex(e => e.BoxCode).IsUnique(); + }); + + // ── Payee1099 (1099 recipient master) ──────────────────────────────── + builder.Entity(entity => + { + entity.HasQueryFilter(p => !p.IsDeleted); + entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired(); + entity.Property(e => e.DisplayName).HasMaxLength(200); + entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired(); + entity.Property(e => e.TinType).HasMaxLength(10); + entity.Property(e => e.TinLast4).HasMaxLength(4); + entity.Property(e => e.State).HasMaxLength(2); + entity.Property(e => e.Zip).HasMaxLength(10); + entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing); + entity.HasOne(e => e.Member).WithMany() + .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); + }); + // ── ExpenseCategoryGroup ───────────────────────────────────────────── builder.Entity(entity => { @@ -227,6 +255,8 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasOne(e => e.Form990Line).WithMany() .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.Form1099Box).WithMany() + .HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull); }); // ── ExpenseSubCategory ─────────────────────────────────────────────── @@ -240,6 +270,8 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.Form990Line).WithMany() .HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.Form1099Box).WithMany() + .HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull); }); // ── Expense ────────────────────────────────────────────────────────── @@ -270,6 +302,8 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict); entity.HasOne(e => e.Member).WithMany() .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); + entity.HasOne(e => e.Payee).WithMany() + .HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull); }); // ── ExpenseLine (category breakdown of one Expense) ────────────────── From 89238bba999f387625275ed60d117a18956d0c4e Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:45:02 -0700 Subject: [PATCH 06/32] fix(1099): pin max-lengths on Payee1099/Form1099Box columns to match codebase Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Data/AppDbContext.cs | 13 +++++++++++++ API/ROLAC.API/Entities/Expense.cs | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index a23b75b..b4baba5 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -227,6 +227,8 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.FormType).HasMaxLength(20).IsRequired(); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.HasIndex(e => e.BoxCode).IsUnique(); }); @@ -242,6 +244,17 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.State).HasMaxLength(2); entity.Property(e => e.Zip).HasMaxLength(10); entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing); + entity.Property(e => e.AddressLine1).HasMaxLength(200); + entity.Property(e => e.AddressLine2).HasMaxLength(200); + entity.Property(e => e.City).HasMaxLength(100); + entity.Property(e => e.Email).HasMaxLength(200); + entity.Property(e => e.Phone).HasMaxLength(30); + entity.Property(e => e.Notes).HasMaxLength(500); + entity.Property(e => e.W9BlobPath).HasMaxLength(500); + entity.Property(e => e.TinEncrypted).HasMaxLength(500); + entity.Property(e => e.CreatedBy).HasMaxLength(450); + entity.Property(e => e.UpdatedBy).HasMaxLength(450); + entity.Property(e => e.DeletedBy).HasMaxLength(450); entity.HasOne(e => e.Member).WithMany() .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull); }); diff --git a/API/ROLAC.API/Entities/Expense.cs b/API/ROLAC.API/Entities/Expense.cs index 34daddb..3da2229 100644 --- a/API/ROLAC.API/Entities/Expense.cs +++ b/API/ROLAC.API/Entities/Expense.cs @@ -11,7 +11,7 @@ public class Expense : SoftDeleteEntity, IAuditable public string Description { get; set; } = null!; public string? VendorName { get; set; } public int? MemberId { get; set; } - public int? PayeeId { get; set; } // 1099 recipient attribution (header-level) + public int? PayeeId { get; set; } // 1099 recipient attribution (header-level) public string? CheckNumber { get; set; } public DateOnly ExpenseDate { get; set; } public string? ReceiptBlobPath { get; set; } From 5e2fbe800c12c8c2478eadbd8bbaaa510451f4a7 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:47:49 -0700 Subject: [PATCH 07/32] feat(1099): add ITinProtector with Data Protection encryption + last-4 helper Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API.Tests/ROLAC.API.Tests.csproj | 1 + .../Services/TinProtectorTests.cs | 30 +++++++++++++++++++ API/ROLAC.API/Program.cs | 3 ++ .../Services/Security/ITinProtector.cs | 8 +++++ .../Services/Security/TinProtector.cs | 22 ++++++++++++++ 5 files changed, 64 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/TinProtectorTests.cs create mode 100644 API/ROLAC.API/Services/Security/ITinProtector.cs create mode 100644 API/ROLAC.API/Services/Security/TinProtector.cs diff --git a/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj b/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj index ffa3eaa..59db6a3 100644 --- a/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj +++ b/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/API/ROLAC.API.Tests/Services/TinProtectorTests.cs b/API/ROLAC.API.Tests/Services/TinProtectorTests.cs new file mode 100644 index 0000000..44ba817 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/TinProtectorTests.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.DataProtection; +using ROLAC.API.Services.Security; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class TinProtectorTests +{ + private static TinProtector Build() => + new TinProtector(DataProtectionProvider.Create("ROLAC.Tests")); + + [Fact] + public void Protect_then_Unprotect_round_trips() + { + var p = Build(); + var cipher = p.Protect("123-45-6789"); + Assert.NotEqual("123-45-6789", cipher); + Assert.Equal("123-45-6789", p.Unprotect(cipher)); + } + + [Theory] + [InlineData("123-45-6789", "6789")] + [InlineData("12-3456789", "6789")] + [InlineData("7", "7")] + public void Last4_keeps_only_trailing_digits(string raw, string expected) + => Assert.Equal(expected, TinProtector.Last4(raw)); + + [Fact] + public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null)); +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 8f701c3..4e2bbf5 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -15,6 +15,7 @@ using ROLAC.API.Json; using ROLAC.API.Middleware; using ROLAC.API.Services; using ROLAC.API.Services.Logging; +using ROLAC.API.Services.Security; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; @@ -157,6 +158,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddDataProtection(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/ROLAC.API/Services/Security/ITinProtector.cs b/API/ROLAC.API/Services/Security/ITinProtector.cs new file mode 100644 index 0000000..2996bd3 --- /dev/null +++ b/API/ROLAC.API/Services/Security/ITinProtector.cs @@ -0,0 +1,8 @@ +namespace ROLAC.API.Services.Security; + +/// Reversible protection for taxpayer identification numbers (SSN/EIN). +public interface ITinProtector +{ + string Protect(string plaintext); + string Unprotect(string ciphertext); +} diff --git a/API/ROLAC.API/Services/Security/TinProtector.cs b/API/ROLAC.API/Services/Security/TinProtector.cs new file mode 100644 index 0000000..f29a399 --- /dev/null +++ b/API/ROLAC.API/Services/Security/TinProtector.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.DataProtection; + +namespace ROLAC.API.Services.Security; + +public class TinProtector : ITinProtector +{ + private readonly IDataProtector _protector; + + public TinProtector(IDataProtectionProvider provider) + => _protector = provider.CreateProtector("Payee1099.Tin"); + + public string Protect(string plaintext) => _protector.Protect(plaintext); + public string Unprotect(string ciphertext) => _protector.Unprotect(ciphertext); + + /// Last four digits of a TIN (ignoring dashes/spaces); null/empty in => null. + public static string? Last4(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var digits = new string(raw.Where(char.IsDigit).ToArray()); + return digits.Length <= 4 ? digits : digits[^4..]; + } +} From 9aa64b5f4cde38292d5ede5d120117425ecb55d9 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:53:15 -0700 Subject: [PATCH 08/32] feat(1099): add report and recipient DTOs Co-Authored-By: Claude Opus 4.8 --- .../DTOs/Finance/Form1099ReportDtos.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs diff --git a/API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs b/API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs new file mode 100644 index 0000000..52fca56 --- /dev/null +++ b/API/ROLAC.API/DTOs/Finance/Form1099ReportDtos.cs @@ -0,0 +1,52 @@ +namespace ROLAC.API.DTOs.Finance; + +public class Form1099BoxDto +{ + public int Id { get; set; } + public string BoxCode { get; set; } = ""; + public string Name_en { get; set; } = ""; + public string? Name_zh { get; set; } + public string FormType { get; set; } = ""; + public int SortOrder { get; set; } +} + +public class Form1099RecipientRowDto +{ + public int PayeeId { get; set; } + public string LegalName { get; set; } = ""; + public string? TinLast4 { get; set; } + public string W9Status { get; set; } = ""; + public decimal NecTotal { get; set; } + public decimal RentsTotal { get; set; } + public decimal GrandTotal { get; set; } + public bool MeetsThreshold { get; set; } + public bool W9Missing { get; set; } +} + +public class Form1099SummaryDto +{ + public int TaxYear { get; set; } + public List Rows { get; set; } = []; + public decimal TotalReportable { get; set; } + public int RecipientsAtThreshold { get; set; } + public int RecipientsMissingW9 { get; set; } +} + +public class Form1099PaymentDto +{ + public string PaidDate { get; set; } = ""; + public string Description { get; set; } = ""; + public string CategoryName { get; set; } = ""; + public string BoxCode { get; set; } = ""; + public decimal Amount { get; set; } +} + +public class Form1099RecipientDetailDto +{ + public int PayeeId { get; set; } + public string LegalName { get; set; } = ""; + public string? TinLast4 { get; set; } + public string W9Status { get; set; } = ""; + public int TaxYear { get; set; } + public List Payments { get; set; } = []; +} From 0754ed8d690a9fe344ee205faa57942bf7f8e5c4 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:53:28 -0700 Subject: [PATCH 09/32] feat(1099): add Form1099ReportService cash-basis annual aggregation Co-Authored-By: Claude Opus 4.8 --- .../Services/Form1099ReportServiceTests.cs | 106 ++++++++++++++++++ .../Services/Form1099ReportService.cs | 100 +++++++++++++++++ .../Services/IForm1099ReportService.cs | 9 ++ 3 files changed, 215 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs create mode 100644 API/ROLAC.API/Services/Form1099ReportService.cs create mode 100644 API/ROLAC.API/Services/IForm1099ReportService.cs diff --git a/API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs b/API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs new file mode 100644 index 0000000..90e68a1 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Form1099ReportServiceTests.cs @@ -0,0 +1,106 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using System.Security.Claims; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class Form1099ReportServiceTests +{ + private static AppDbContext NewDb() + { + var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) }; + var accessorMock = new Mock(); + accessorMock.Setup(x => x.HttpContext).Returns(httpContext); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options); + } + + private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId) + { + var db = NewDb(); + db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" }); + var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 }; + var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 }; + db.Form1099Boxes.AddRange(nec, rent); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" }); + db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 }); + db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null }); + db.SaveChanges(); + necSubId = 1; rentSubId = 2; salarySubId = 3; + return db; + } + + private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn) + { + var e = new Expense + { + MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId, + Amount = amount, Description = "x", ExpenseDate = paidOn, + PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero), + Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ], + }; + db.Expenses.Add(e); + db.SaveChanges(); + } + + [Fact] + public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9() + { + var db = Seeded(out var necSub, out var rentSub, out _); + db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" }); + db.SaveChanges(); + AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1)); + AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1)); + + var svc = new Form1099ReportService(db); + var sum = await svc.GetAnnualSummaryAsync(2026); + + var row = Assert.Single(sum.Rows); + Assert.Equal(700m, row.NecTotal); + Assert.Equal(500m, row.RentsTotal); + Assert.Equal(1200m, row.GrandTotal); + Assert.True(row.MeetsThreshold); + Assert.True(row.W9Missing); + Assert.Equal(1, sum.RecipientsAtThreshold); + Assert.Equal(1, sum.RecipientsMissingW9); + } + + [Fact] + public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year() + { + var db = Seeded(out var necSub, out _, out var salarySub); + db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" }); + db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" }); + db.SaveChanges(); + AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked + AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box + AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year + + var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026); + Assert.Empty(sum.Rows); + } + + [Fact] + public async Task Threshold_flag_is_false_below_600() + { + var db = Seeded(out var necSub, out _, out _); + db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" }); + db.SaveChanges(); + AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1)); + + var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026); + var row = Assert.Single(sum.Rows); + Assert.False(row.MeetsThreshold); + Assert.False(row.W9Missing); + Assert.Equal(0, sum.RecipientsAtThreshold); + } +} diff --git a/API/ROLAC.API/Services/Form1099ReportService.cs b/API/ROLAC.API/Services/Form1099ReportService.cs new file mode 100644 index 0000000..51d3e4d --- /dev/null +++ b/API/ROLAC.API/Services/Form1099ReportService.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Finance; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +/// +/// Read-only aggregation producing the year-end 1099 recipient summary. CASH BASIS: +/// only Paid expenses whose PaidAt falls in the tax year, attributed to a tracked payee, +/// on a line whose category maps to a 1099 box (sub ?? group). Unmapped lines are excluded. +/// +public class Form1099ReportService : IForm1099ReportService +{ + private readonly AppDbContext _db; + public Form1099ReportService(AppDbContext db) => _db = db; + + public async Task> GetBoxesAsync() => + await _db.Form1099Boxes.AsNoTracking().Where(b => b.IsActive) + .OrderBy(b => b.SortOrder) + .Select(b => new Form1099BoxDto + { + Id = b.Id, BoxCode = b.BoxCode, Name_en = b.Name_en, + Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder, + }).ToListAsync(); + + private IQueryable ReportableLines(int taxYear) => + from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null + && e.PaidAt.Value.Year == taxYear && e.PayeeId != null) + join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id + join l in _db.ExpenseLines on e.Id equals l.ExpenseId + join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id + join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id + select new PaidLine + { + PayeeId = p.Id, + LegalName = p.LegalName, + TinLast4 = p.TinLast4, + W9Status = p.W9Status, + PaidAt = e.PaidAt!.Value, + Description = e.Description, + CategoryName = grp.Name_en + " / " + sub.Name_en, + Amount = l.Amount, + BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId, + }; + + public async Task GetAnnualSummaryAsync(int taxYear) + { + var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode); + var lines = await ReportableLines(taxYear).Where(x => x.BoxId != null).ToListAsync(); + + var dto = new Form1099SummaryDto { TaxYear = taxYear }; + foreach (var g in lines.GroupBy(x => x.PayeeId)) + { + var first = g.First(); + var nec = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxNec1).Sum(x => x.Amount); + var rents = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxMisc1).Sum(x => x.Amount); + var w9Missing = first.W9Status != Form1099.W9Status.OnFile; + var meets = nec >= Form1099.ReportingThreshold || rents >= Form1099.ReportingThreshold; + dto.Rows.Add(new Form1099RecipientRowDto + { + PayeeId = first.PayeeId, LegalName = first.LegalName, TinLast4 = first.TinLast4, + W9Status = first.W9Status, NecTotal = nec, RentsTotal = rents, + GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing, + }); + } + dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ToList(); + dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal); + dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold); + dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing); + return dto; + } + + public async Task GetRecipientDetailAsync(int payeeId, int taxYear) + { + var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId); + if (payee is null) return null; + var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode); + var lines = await ReportableLines(taxYear).Where(x => x.PayeeId == payeeId && x.BoxId != null).ToListAsync(); + + return new Form1099RecipientDetailDto + { + PayeeId = payee.Id, LegalName = payee.LegalName, TinLast4 = payee.TinLast4, + W9Status = payee.W9Status, TaxYear = taxYear, + Payments = lines.OrderBy(x => x.PaidAt).Select(x => new Form1099PaymentDto + { + PaidDate = DateOnly.FromDateTime(x.PaidAt.Date).ToString("yyyy-MM-dd"), + Description = x.Description, CategoryName = x.CategoryName, + BoxCode = boxes.GetValueOrDefault(x.BoxId!.Value) ?? "", Amount = x.Amount, + }).ToList(), + }; + } + + private sealed class PaidLine + { + public int PayeeId; public string LegalName = ""; public string? TinLast4; public string W9Status = ""; + public DateTimeOffset PaidAt; public string Description = ""; public string CategoryName = ""; + public decimal Amount; public int? BoxId; + } +} diff --git a/API/ROLAC.API/Services/IForm1099ReportService.cs b/API/ROLAC.API/Services/IForm1099ReportService.cs new file mode 100644 index 0000000..64bf88d --- /dev/null +++ b/API/ROLAC.API/Services/IForm1099ReportService.cs @@ -0,0 +1,9 @@ +using ROLAC.API.DTOs.Finance; +namespace ROLAC.API.Services; + +public interface IForm1099ReportService +{ + Task> GetBoxesAsync(); + Task GetAnnualSummaryAsync(int taxYear); + Task GetRecipientDetailAsync(int payeeId, int taxYear); +} From 0767a3fe945f10c9cc2218d062fe6cd8dcf20c7b Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:58:28 -0700 Subject: [PATCH 10/32] refactor(1099): materialize report query for Npgsql safety; deterministic year + ordering Co-Authored-By: Claude Opus 4.8 --- .../Services/Form1099ReportService.cs | 84 +++++++++++++------ 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/API/ROLAC.API/Services/Form1099ReportService.cs b/API/ROLAC.API/Services/Form1099ReportService.cs index 51d3e4d..e237e8e 100644 --- a/API/ROLAC.API/Services/Form1099ReportService.cs +++ b/API/ROLAC.API/Services/Form1099ReportService.cs @@ -24,30 +24,58 @@ public class Form1099ReportService : IForm1099ReportService Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder, }).ToListAsync(); - private IQueryable ReportableLines(int taxYear) => - from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null - && e.PaidAt.Value.Year == taxYear && e.PayeeId != null) - join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id - join l in _db.ExpenseLines on e.Id equals l.ExpenseId - join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id - join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id - select new PaidLine - { - PayeeId = p.Id, - LegalName = p.LegalName, - TinLast4 = p.TinLast4, - W9Status = p.W9Status, - PaidAt = e.PaidAt!.Value, - Description = e.Description, - CategoryName = grp.Name_en + " / " + sub.Name_en, - Amount = l.Amount, - BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId, - }; + /// + /// Pulls the reportable expense lines for the tax year and materializes them (anonymous + /// projection -> ToListAsync -> in-memory map), mirroring Form990ReportService so the SQL + /// translation stays simple on Npgsql. The tax year is a half-open UTC range + /// [Jan 1 taxYear, Jan 1 taxYear+1), deterministic regardless of server timezone and matching + /// how Expense.PaidAt is written (midnight UTC). Unmapped lines (no 1099 box) are dropped here + /// so callers always receive reportable lines. + /// + private async Task> LoadReportableLinesAsync(int taxYear) + { + var start = new DateTimeOffset(new DateTime(taxYear, 1, 1), TimeSpan.Zero); + var end = start.AddYears(1); + var raw = await ( + from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null + && e.PaidAt >= start && e.PaidAt < end && e.PayeeId != null) + join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id + join l in _db.ExpenseLines on e.Id equals l.ExpenseId + join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id + join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id + select new + { + PayeeId = p.Id, + p.LegalName, + p.TinLast4, + p.W9Status, + PaidAt = e.PaidAt!.Value, + e.Description, + GroupName = grp.Name_en, + SubName = sub.Name_en, + l.Amount, + BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId, + }).ToListAsync(); + + return raw.Where(x => x.BoxId != null) + .Select(x => new PaidLine + { + PayeeId = x.PayeeId, + LegalName = x.LegalName, + TinLast4 = x.TinLast4, + W9Status = x.W9Status, + PaidAt = x.PaidAt, + Description = x.Description, + CategoryName = x.GroupName + " / " + x.SubName, + Amount = x.Amount, + BoxId = x.BoxId, + }).ToList(); + } public async Task GetAnnualSummaryAsync(int taxYear) { var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode); - var lines = await ReportableLines(taxYear).Where(x => x.BoxId != null).ToListAsync(); + var lines = await LoadReportableLinesAsync(taxYear); var dto = new Form1099SummaryDto { TaxYear = taxYear }; foreach (var g in lines.GroupBy(x => x.PayeeId)) @@ -64,7 +92,7 @@ public class Form1099ReportService : IForm1099ReportService GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing, }); } - dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ToList(); + dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ThenBy(r => r.LegalName).ToList(); dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal); dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold); dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing); @@ -76,7 +104,7 @@ public class Form1099ReportService : IForm1099ReportService var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId); if (payee is null) return null; var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode); - var lines = await ReportableLines(taxYear).Where(x => x.PayeeId == payeeId && x.BoxId != null).ToListAsync(); + var lines = (await LoadReportableLinesAsync(taxYear)).Where(x => x.PayeeId == payeeId).ToList(); return new Form1099RecipientDetailDto { @@ -93,8 +121,14 @@ public class Form1099ReportService : IForm1099ReportService private sealed class PaidLine { - public int PayeeId; public string LegalName = ""; public string? TinLast4; public string W9Status = ""; - public DateTimeOffset PaidAt; public string Description = ""; public string CategoryName = ""; - public decimal Amount; public int? BoxId; + public int PayeeId { get; set; } + public string LegalName { get; set; } = ""; + public string? TinLast4 { get; set; } + public string W9Status { get; set; } = ""; + public DateTimeOffset PaidAt { get; set; } + public string Description { get; set; } = ""; + public string CategoryName { get; set; } = ""; + public decimal Amount { get; set; } + public int? BoxId { get; set; } } } From 560fb79bf0392a26529e69ca659c37c4908d425d Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:02:33 -0700 Subject: [PATCH 11/32] feat(1099): add recipient DTOs Add Payee1099ListItemDto, Payee1099Dto, and SavePayee1099Request in DTOs/Payee for the 1099 recipient CRUD surface. Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs diff --git a/API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs b/API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs new file mode 100644 index 0000000..481e952 --- /dev/null +++ b/API/ROLAC.API/DTOs/Payee/Payee1099Dtos.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Payee; + +public class Payee1099ListItemDto +{ + public int Id { get; set; } + public string LegalName { get; set; } = ""; + public string? DisplayName { get; set; } + public int? MemberId { get; set; } + public string? MemberName { get; set; } + public string TaxClassification { get; set; } = ""; + public bool Is1099Tracked { get; set; } + public string? TinType { get; set; } + public string? TinLast4 { get; set; } + public string W9Status { get; set; } = ""; + public bool IsActive { get; set; } +} + +public class Payee1099Dto : Payee1099ListItemDto +{ + public string? AddressLine1 { get; set; } + public string? AddressLine2 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + public string? W9ReceivedDate { get; set; } + public bool HasW9Document { get; set; } + public string? Notes { get; set; } +} + +public class SavePayee1099Request +{ + [Required, MaxLength(200)] public string LegalName { get; set; } = ""; + [MaxLength(200)] public string? DisplayName { get; set; } + public int? MemberId { get; set; } + [Required, MaxLength(40)] public string TaxClassification { get; set; } = "Individual"; + public bool Is1099Tracked { get; set; } = true; + [MaxLength(10)] public string? TinType { get; set; } + /// Plain TIN; null = leave unchanged on update. Encrypted server-side. + public string? Tin { get; set; } + [MaxLength(100)] public string? AddressLine1 { get; set; } + [MaxLength(100)] public string? AddressLine2 { get; set; } + [MaxLength(60)] public string? City { get; set; } + [MaxLength(2)] public string? State { get; set; } + [MaxLength(10)] public string? Zip { get; set; } + [MaxLength(120)] public string? Email { get; set; } + [MaxLength(40)] public string? Phone { get; set; } + [MaxLength(20)] public string W9Status { get; set; } = "Missing"; + public DateOnly? W9ReceivedDate { get; set; } + public bool IsActive { get; set; } = true; + public string? Notes { get; set; } +} From 6080946e743022b6a34278e3b8167db8fb7f5bec Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:02:45 -0700 Subject: [PATCH 12/32] feat(1099): add Payee1099Service recipient CRUD with TIN protection Implement IPayee1099Service and Payee1099Service: list/get/create/update/ soft-delete and RevealTin. TIN is encrypted via ITinProtector on write; TinLast4 is the only clear-text fragment stored. Null Tin on update preserves the existing ciphertext. Four xUnit tests cover encrypt-on-create, null-tin-keeps-ciphertext, list-masks-to-last4, and soft-delete hides from list. Co-Authored-By: Claude Opus 4.8 --- .../Services/Payee1099ServiceTests.cs | 79 +++++++++++ API/ROLAC.API/Services/IPayee1099Service.cs | 13 ++ API/ROLAC.API/Services/Payee1099Service.cs | 128 ++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs create mode 100644 API/ROLAC.API/Services/IPayee1099Service.cs create mode 100644 API/ROLAC.API/Services/Payee1099Service.cs diff --git a/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs b/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs new file mode 100644 index 0000000..dc90fe2 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Payee1099ServiceTests.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Moq; +using System.Security.Claims; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Payee; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using ROLAC.API.Services.Logging; +using ROLAC.API.Services.Security; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class Payee1099ServiceTests +{ + private static (Payee1099Service svc, AppDbContext db) Build() + { + var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) }; + var accessorMock = new Mock(); + accessorMock.Setup(x => x.HttpContext).Returns(httpContext); + var db = new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options); + var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests")); + return (new Payee1099Service(db, tin), db); + } + + [Fact] + public async Task Create_encrypts_tin_and_stores_last4_only_in_clear() + { + var (svc, db) = Build(); + var id = await svc.CreateAsync(new SavePayee1099Request + { LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" }); + + var saved = await db.Payee1099s.FindAsync(id); + Assert.NotNull(saved); + Assert.Equal("6789", saved!.TinLast4); + Assert.NotNull(saved.TinEncrypted); + Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!); + Assert.Equal("123-45-6789", await svc.RevealTinAsync(id)); + } + + [Fact] + public async Task Update_with_null_tin_keeps_existing_ciphertext() + { + var (svc, db) = Build(); + var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" }); + var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted; + + await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null }); + + var after = await db.Payee1099s.FindAsync(id); + Assert.Equal("X renamed", after!.LegalName); + Assert.Equal(before, after.TinEncrypted); + Assert.Equal("3333", after.TinLast4); + } + + [Fact] + public async Task List_dto_masks_tin_to_last4() + { + var (svc, _) = Build(); + await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" }); + var list = await svc.GetAllAsync(includeInactive: true); + var item = Assert.Single(list); + Assert.Equal("7777", item.TinLast4); + } + + [Fact] + public async Task Delete_is_soft_and_hides_from_list() + { + var (svc, _) = Build(); + var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" }); + await svc.DeleteAsync(id); + Assert.Empty(await svc.GetAllAsync(includeInactive: true)); + } +} diff --git a/API/ROLAC.API/Services/IPayee1099Service.cs b/API/ROLAC.API/Services/IPayee1099Service.cs new file mode 100644 index 0000000..f608d9f --- /dev/null +++ b/API/ROLAC.API/Services/IPayee1099Service.cs @@ -0,0 +1,13 @@ +using ROLAC.API.DTOs.Payee; +namespace ROLAC.API.Services; + +public interface IPayee1099Service +{ + Task> GetAllAsync(bool includeInactive); + Task GetByIdAsync(int id); + Task CreateAsync(SavePayee1099Request r); + Task UpdateAsync(int id, SavePayee1099Request r); + Task DeleteAsync(int id); + /// Full decrypted TIN. Caller must be authorized (gated at controller). + Task RevealTinAsync(int id); +} diff --git a/API/ROLAC.API/Services/Payee1099Service.cs b/API/ROLAC.API/Services/Payee1099Service.cs new file mode 100644 index 0000000..dd92b1b --- /dev/null +++ b/API/ROLAC.API/Services/Payee1099Service.cs @@ -0,0 +1,128 @@ +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Payee; +using ROLAC.API.Entities; +using ROLAC.API.Services.Security; + +namespace ROLAC.API.Services; + +public class Payee1099Service : IPayee1099Service +{ + private readonly AppDbContext _db; + private readonly ITinProtector _tin; + + public Payee1099Service(AppDbContext db, ITinProtector tin) + { + _db = db; + _tin = tin; + } + + public async Task> GetAllAsync(bool includeInactive) + { + var q = _db.Payee1099s.AsNoTracking().Include(p => p.Member).AsQueryable(); + if (!includeInactive) q = q.Where(p => p.IsActive); + return await q.OrderBy(p => p.LegalName).Select(p => new Payee1099ListItemDto + { + Id = p.Id, + LegalName = p.LegalName, + DisplayName = p.DisplayName, + MemberId = p.MemberId, + MemberName = p.Member != null ? p.Member.FirstName_en + " " + p.Member.LastName_en : null, + TaxClassification = p.TaxClassification, + Is1099Tracked = p.Is1099Tracked, + TinType = p.TinType, + TinLast4 = p.TinLast4, + W9Status = p.W9Status, + IsActive = p.IsActive, + }).ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + var p = await _db.Payee1099s.AsNoTracking().Include(x => x.Member).FirstOrDefaultAsync(x => x.Id == id); + if (p is null) return null; + return new Payee1099Dto + { + Id = p.Id, + LegalName = p.LegalName, + DisplayName = p.DisplayName, + MemberId = p.MemberId, + MemberName = p.Member != null ? $"{p.Member.FirstName_en} {p.Member.LastName_en}" : null, + TaxClassification = p.TaxClassification, + Is1099Tracked = p.Is1099Tracked, + TinType = p.TinType, + TinLast4 = p.TinLast4, + W9Status = p.W9Status, + IsActive = p.IsActive, + AddressLine1 = p.AddressLine1, + AddressLine2 = p.AddressLine2, + City = p.City, + State = p.State, + Zip = p.Zip, + Email = p.Email, + Phone = p.Phone, + W9ReceivedDate = p.W9ReceivedDate?.ToString("yyyy-MM-dd"), + HasW9Document = p.W9BlobPath != null, + Notes = p.Notes, + }; + } + + public async Task CreateAsync(SavePayee1099Request r) + { + var p = new Payee1099(); + Apply(p, r); + _db.Payee1099s.Add(p); + await _db.SaveChangesAsync(); + return p.Id; + } + + public async Task UpdateAsync(int id, SavePayee1099Request r) + { + var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Payee1099 {id} not found."); + Apply(p, r); + await _db.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id) + ?? throw new KeyNotFoundException($"Payee1099 {id} not found."); + p.IsDeleted = true; + p.DeletedAt = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + } + + public async Task RevealTinAsync(int id) + { + var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); + return p?.TinEncrypted is null ? null : _tin.Unprotect(p.TinEncrypted); + } + + // Maps request fields onto the entity. A null/blank Tin leaves the existing ciphertext untouched (update case). + private void Apply(Payee1099 p, SavePayee1099Request r) + { + p.LegalName = r.LegalName; + p.DisplayName = r.DisplayName; + p.MemberId = r.MemberId; + p.TaxClassification = r.TaxClassification; + p.Is1099Tracked = r.Is1099Tracked; + p.TinType = r.TinType; + p.AddressLine1 = r.AddressLine1; + p.AddressLine2 = r.AddressLine2; + p.City = r.City; + p.State = r.State; + p.Zip = r.Zip; + p.Email = r.Email; + p.Phone = r.Phone; + p.W9Status = r.W9Status; + p.W9ReceivedDate = r.W9ReceivedDate; + p.IsActive = r.IsActive; + p.Notes = r.Notes; + if (!string.IsNullOrWhiteSpace(r.Tin)) + { + p.TinEncrypted = _tin.Protect(r.Tin); + p.TinLast4 = TinProtector.Last4(r.Tin); + } + } +} From 0a9b82544dd28206ca710a0913532c7619706f62 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:06:03 -0700 Subject: [PATCH 13/32] feat(1099): register Form1099 permission module and services Add Form1099 const to Modules.cs (after Form990Report) and insert it into the All display-order list. Register IForm1099ReportService and IPayee1099Service in Program.cs beside the existing Form990Report entry. Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Authorization/Modules.cs | 2 ++ API/ROLAC.API/Program.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/API/ROLAC.API/Authorization/Modules.cs b/API/ROLAC.API/Authorization/Modules.cs index fa0ce49..03066d7 100644 --- a/API/ROLAC.API/Authorization/Modules.cs +++ b/API/ROLAC.API/Authorization/Modules.cs @@ -17,6 +17,7 @@ public static class Modules public const string Ministries = "Ministries"; public const string FinanceDashboard = "FinanceDashboard"; public const string Form990Report = "Form990Report"; + public const string Form1099 = "Form1099"; public const string MonthlyStatements = "MonthlyStatements"; public const string ChurchProfile = "ChurchProfile"; public const string Disbursements = "Disbursements"; @@ -39,6 +40,7 @@ public static class Modules Ministries, FinanceDashboard, Form990Report, + Form1099, MonthlyStatements, ChurchProfile, Disbursements, diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 4e2bbf5..dcaa3b2 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -158,6 +158,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddDataProtection(); builder.Services.AddScoped(); builder.Services.AddScoped(); From 7c5348969b5839a0f4138b68c706de7fa44ec5b3 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:06:49 -0700 Subject: [PATCH 14/32] feat(1099): add recipient and report controllers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payee1099Controller (api/payee-1099): CRUD + TIN reveal, class-level Read gate, method-level Write/Delete overrides — mirrors the HasPermission class+method stacking pattern from ExpensesController. Form1099ReportController (api/form1099-report): boxes, annual summary, and per-recipient detail; read-only, no method-level overrides needed. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Form1099ReportController.cs | 25 +++++++++++ .../Controllers/Payee1099Controller.cs | 44 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 API/ROLAC.API/Controllers/Form1099ReportController.cs create mode 100644 API/ROLAC.API/Controllers/Payee1099Controller.cs diff --git a/API/ROLAC.API/Controllers/Form1099ReportController.cs b/API/ROLAC.API/Controllers/Form1099ReportController.cs new file mode 100644 index 0000000..fde93e3 --- /dev/null +++ b/API/ROLAC.API/Controllers/Form1099ReportController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Authorization; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/form1099-report")] +[HasPermission(Modules.Form1099, PermissionActions.Read)] +public class Form1099ReportController : ControllerBase +{ + private readonly IForm1099ReportService _svc; + public Form1099ReportController(IForm1099ReportService svc) => _svc = svc; + + [HttpGet("boxes")] + public async Task Boxes() => Ok(await _svc.GetBoxesAsync()); + + [HttpGet("summary")] + public async Task Summary([FromQuery] int taxYear) + => Ok(await _svc.GetAnnualSummaryAsync(taxYear)); + + [HttpGet("recipient/{payeeId:int}")] + public async Task Recipient(int payeeId, [FromQuery] int taxYear) + => await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound(); +} diff --git a/API/ROLAC.API/Controllers/Payee1099Controller.cs b/API/ROLAC.API/Controllers/Payee1099Controller.cs new file mode 100644 index 0000000..8a4fdfc --- /dev/null +++ b/API/ROLAC.API/Controllers/Payee1099Controller.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.Authorization; +using ROLAC.API.DTOs.Payee; +using ROLAC.API.Services; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/payee-1099")] +[HasPermission(Modules.Form1099, PermissionActions.Read)] +public class Payee1099Controller : ControllerBase +{ + private readonly IPayee1099Service _svc; + public Payee1099Controller(IPayee1099Service svc) => _svc = svc; + + [HttpGet] + public async Task GetAll([FromQuery] bool includeInactive = false) + => Ok(await _svc.GetAllAsync(includeInactive)); + + [HttpGet("{id:int}")] + public async Task GetById(int id) + => await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound(); + + [HttpPost] + [HasPermission(Modules.Form1099, PermissionActions.Write)] + public async Task Create([FromBody] SavePayee1099Request r) + => Ok(new { id = await _svc.CreateAsync(r) }); + + [HttpPut("{id:int}")] + [HasPermission(Modules.Form1099, PermissionActions.Write)] + public async Task Update(int id, [FromBody] SavePayee1099Request r) + { await _svc.UpdateAsync(id, r); return NoContent(); } + + [HttpDelete("{id:int}")] + [HasPermission(Modules.Form1099, PermissionActions.Delete)] + public async Task Delete(int id) + { await _svc.DeleteAsync(id); return NoContent(); } + + // Full TIN reveal is gated on Write (a stronger right than Read). + [HttpGet("{id:int}/tin")] + [HasPermission(Modules.Form1099, PermissionActions.Write)] + public async Task RevealTin(int id) + => Ok(new { tin = await _svc.RevealTinAsync(id) }); +} From 7c63f6c9ba043a1d7054fe8c26088f0bbf42bb31 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:08:01 -0700 Subject: [PATCH 15/32] feat(1099): carry PayeeId through expense create/update/read Add int? PayeeId to CreateExpenseRequest (UpdateExpenseRequest inherits) and to ExpenseListItemDto (so it round-trips to the form). Set e.PayeeId unconditionally in CreateAsync and UpdateAsync so 1099 attribution is independent of VendorPayment vs StaffReimbursement type. Map PayeeId in both DTO projections: the paged-list lambda and GetByIdAsync. Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs | 2 ++ API/ROLAC.API/Services/ExpenseService.cs | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs index 9527125..2584854 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseDtos.cs @@ -35,6 +35,7 @@ public class ExpenseListItemDto public string? ReviewedByName { get; set; } // resolved Member full name, email fallback public DateTimeOffset? ReviewedAt { get; set; } public string? ReviewNotes { get; set; } // reject reason (or approval note) + public int? PayeeId { get; set; } } public class ExpenseDto : ExpenseListItemDto @@ -66,6 +67,7 @@ public class CreateExpenseRequest [MaxLength(50)] public string? CheckNumber { get; set; } [Required] public DateOnly ExpenseDate { get; set; } public string? Notes { get; set; } + public int? PayeeId { get; set; } } public class UpdateExpenseRequest : CreateExpenseRequest { } diff --git a/API/ROLAC.API/Services/ExpenseService.cs b/API/ROLAC.API/Services/ExpenseService.cs index 5c2bfe3..3ce5249 100644 --- a/API/ROLAC.API/Services/ExpenseService.cs +++ b/API/ROLAC.API/Services/ExpenseService.cs @@ -120,6 +120,7 @@ public class ExpenseService : IExpenseService ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null, ReviewedAt = e.ReviewedAt, ReviewNotes = e.ReviewNotes, + PayeeId = e.PayeeId, }; }).ToList(); @@ -211,6 +212,7 @@ public class ExpenseService : IExpenseService CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt, SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt, + PayeeId = e.PayeeId, Lines = lineDtos, }; } @@ -273,6 +275,7 @@ public class ExpenseService : IExpenseService e.VendorName = null; } + e.PayeeId = r.PayeeId; _db.Expenses.Add(e); await _db.SaveChangesAsync(); return e.Id; @@ -294,7 +297,7 @@ public class ExpenseService : IExpenseService throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements."); e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber; - e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; + e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId; if (e.Type == "VendorPayment") e.VendorName = r.VendorName; // Replace the line set wholesale (lines are owned by the header), recompute the total. From 1a8002015a91ae4e8395ed26d1f2e04a9dfa16f4 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:13:44 -0700 Subject: [PATCH 16/32] feat(1099): seed Form1099Box catalog and default subcategory mappings Adds Form1099BoxSeed (NEC-1, MISC-1) and Form1099SubMappingSeed (6 service/rent subcategories), SeedForm1099BoxesAsync method with null-fill idempotency (never clobbers admin edits), and wires it into SeedAsync after SeedForm990ExpenseLinesAsync. Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/Data/DbSeeder.cs | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index e7ec773..0446072 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -137,6 +137,23 @@ public static class DbSeeder ("Other", "Gifts", "24"), ]; + private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed = + [ + (Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1), + (Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2), + ]; + + // Only service/rent subcategories get a box. Everything else stays unmapped (not reportable). + private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed = + [ + ("Personnel", "Honorarium", Form1099.BoxNec1), + ("Personnel", "Contract Labor", Form1099.BoxNec1), + ("Professional Services", "Legal", Form1099.BoxNec1), + ("Professional Services", "Accounting & Audit", Form1099.BoxNec1), + ("Professional Services", "Other Professional", Form1099.BoxNec1), + ("Facility", "Rent", Form1099.BoxMisc1), + ]; + // One-time corrections for subcategories that were mapped to the WRONG line in an earlier // seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad // value — this block does. Idempotent: each row fires only while the subcategory still holds @@ -375,6 +392,25 @@ public static class DbSeeder await db.SaveChangesAsync(); } + public static async Task SeedForm1099BoxesAsync(AppDbContext db) + { + foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed) + if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code)) + db.Form1099Boxes.Add(new Form1099Box + { BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true }); + await db.SaveChangesAsync(); + + var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id); + var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync(); + foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed) + { + var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn); + if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId)) + sub.Form1099BoxId = boxId; + } + await db.SaveChangesAsync(); + } + public static async Task SeedChurchProfileAsync(AppDbContext db) { // Singleton row used by the disbursement module (issuer info + check counter). @@ -454,6 +490,7 @@ public static class DbSeeder await SeedMinistriesAsync(db); await SeedExpenseCategoriesAsync(db); await SeedForm990ExpenseLinesAsync(db); + await SeedForm1099BoxesAsync(db); await SeedChurchProfileAsync(db); await SeedSiteSettingAsync(db); await SeedNotificationSettingAsync(db, config); From 556abba687fdecbf0055766916a3be38b9dabcbc Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:14:57 -0700 Subject: [PATCH 17/32] feat(1099): EF migration for Payee1099, Form1099Box, mapping columns Creates Form1099Boxes and Payee1099s tables; adds Form1099BoxId to ExpenseSubCategories and ExpenseCategoryGroups; adds PayeeId to Expenses. All new columns nullable, all FKs with SetNull, unique index on Form1099Boxes.BoxCode. No data backfill. Co-Authored-By: Claude Opus 4.8 --- ...6_AddForm1099RecipientTracking.Designer.cs | 2676 +++++++++++++++++ ...0626001416_AddForm1099RecipientTracking.cs | 197 ++ .../Migrations/AppDbContextModelSnapshot.cs | 225 ++ 3 files changed, 3098 insertions(+) create mode 100644 API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.Designer.cs create mode 100644 API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.cs diff --git a/API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.Designer.cs b/API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.Designer.cs new file mode 100644 index 0000000..0af6a1f --- /dev/null +++ b/API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.Designer.cs @@ -0,0 +1,2676 @@ +// +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("20260626001416_AddForm1099RecipientTracking")] + partial class AddForm1099RecipientTracking + { + /// + 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("PayeeId") + .HasColumnType("integer"); + + 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("PayeeId"); + + 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("Form1099BoxId") + .HasColumnType("integer"); + + 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("Form1099BoxId"); + + 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("Form1099BoxId") + .HasColumnType("integer"); + + 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("Form1099BoxId"); + + 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.Form1099Box", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoxCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("FormType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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("BoxCode") + .IsUnique(); + + b.ToTable("Form1099Boxes"); + }); + + 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.Payee1099", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Is1099Tracked") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("TaxClassification") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TinEncrypted") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TinLast4") + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("TinType") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("W9BlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("W9ReceivedDate") + .HasColumnType("date"); + + b.Property("W9Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Missing"); + + b.Property("Zip") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Id"); + + b.HasIndex("MemberId"); + + b.ToTable("Payee1099s"); + }); + + 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.HasOne("ROLAC.API.Entities.Payee1099", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Member"); + + b.Navigation("Ministry"); + + b.Navigation("Payee"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => + { + b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box") + .WithMany() + .HasForeignKey("Form1099BoxId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") + .WithMany() + .HasForeignKey("Form990LineId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Form1099Box"); + + 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.Form1099Box", "Form1099Box") + .WithMany() + .HasForeignKey("Form1099BoxId") + .OnDelete(DeleteBehavior.SetNull); + + 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("Form1099Box"); + + 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.Payee1099", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Member"); + }); + + 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/20260626001416_AddForm1099RecipientTracking.cs b/API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.cs new file mode 100644 index 0000000..c3e19a7 --- /dev/null +++ b/API/ROLAC.API/Migrations/20260626001416_AddForm1099RecipientTracking.cs @@ -0,0 +1,197 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + /// + public partial class AddForm1099RecipientTracking : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Form1099BoxId", + table: "ExpenseSubCategories", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "PayeeId", + table: "Expenses", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Form1099BoxId", + table: "ExpenseCategoryGroups", + type: "integer", + nullable: true); + + migrationBuilder.CreateTable( + name: "Form1099Boxes", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + BoxCode = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + Name_en = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Name_zh = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + FormType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + SortOrder = table.Column(type: "integer", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + 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_Form1099Boxes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Payee1099s", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + LegalName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + DisplayName = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + MemberId = table.Column(type: "integer", nullable: true), + TaxClassification = table.Column(type: "character varying(40)", maxLength: 40, nullable: false), + Is1099Tracked = table.Column(type: "boolean", nullable: false), + TinType = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + TinEncrypted = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + TinLast4 = table.Column(type: "character varying(4)", maxLength: 4, nullable: true), + AddressLine1 = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + AddressLine2 = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + City = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + State = table.Column(type: "character varying(2)", maxLength: 2, nullable: true), + Zip = table.Column(type: "character varying(10)", maxLength: 10, nullable: true), + Email = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Phone = table.Column(type: "character varying(30)", maxLength: 30, nullable: true), + W9Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Missing"), + W9ReceivedDate = table.Column(type: "date", nullable: true), + W9BlobPath = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + Notes = 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), + 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_Payee1099s", x => x.Id); + table.ForeignKey( + name: "FK_Payee1099s_Members_MemberId", + column: x => x.MemberId, + principalTable: "Members", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseSubCategories_Form1099BoxId", + table: "ExpenseSubCategories", + column: "Form1099BoxId"); + + migrationBuilder.CreateIndex( + name: "IX_Expenses_PayeeId", + table: "Expenses", + column: "PayeeId"); + + migrationBuilder.CreateIndex( + name: "IX_ExpenseCategoryGroups_Form1099BoxId", + table: "ExpenseCategoryGroups", + column: "Form1099BoxId"); + + migrationBuilder.CreateIndex( + name: "IX_Form1099Boxes_BoxCode", + table: "Form1099Boxes", + column: "BoxCode", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Payee1099s_MemberId", + table: "Payee1099s", + column: "MemberId"); + + migrationBuilder.AddForeignKey( + name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId", + table: "ExpenseCategoryGroups", + column: "Form1099BoxId", + principalTable: "Form1099Boxes", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Expenses_Payee1099s_PayeeId", + table: "Expenses", + column: "PayeeId", + principalTable: "Payee1099s", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId", + table: "ExpenseSubCategories", + column: "Form1099BoxId", + principalTable: "Form1099Boxes", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId", + table: "ExpenseCategoryGroups"); + + migrationBuilder.DropForeignKey( + name: "FK_Expenses_Payee1099s_PayeeId", + table: "Expenses"); + + migrationBuilder.DropForeignKey( + name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId", + table: "ExpenseSubCategories"); + + migrationBuilder.DropTable( + name: "Form1099Boxes"); + + migrationBuilder.DropTable( + name: "Payee1099s"); + + migrationBuilder.DropIndex( + name: "IX_ExpenseSubCategories_Form1099BoxId", + table: "ExpenseSubCategories"); + + migrationBuilder.DropIndex( + name: "IX_Expenses_PayeeId", + table: "Expenses"); + + migrationBuilder.DropIndex( + name: "IX_ExpenseCategoryGroups_Form1099BoxId", + table: "ExpenseCategoryGroups"); + + migrationBuilder.DropColumn( + name: "Form1099BoxId", + table: "ExpenseSubCategories"); + + migrationBuilder.DropColumn( + name: "PayeeId", + table: "Expenses"); + + migrationBuilder.DropColumn( + name: "Form1099BoxId", + table: "ExpenseCategoryGroups"); + } + } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 341dcd8..f98216f 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -598,6 +598,9 @@ namespace ROLAC.API.Migrations .HasMaxLength(450) .HasColumnType("character varying(450)"); + b.Property("PayeeId") + .HasColumnType("integer"); + b.Property("ReceiptBlobPath") .HasMaxLength(500) .HasColumnType("character varying(500)"); @@ -652,6 +655,8 @@ namespace ROLAC.API.Migrations b.HasIndex("MinistryId"); + b.HasIndex("PayeeId"); + b.HasIndex("Status") .HasFilter("\"IsDeleted\" = false"); @@ -674,6 +679,9 @@ namespace ROLAC.API.Migrations .HasMaxLength(450) .HasColumnType("character varying(450)"); + b.Property("Form1099BoxId") + .HasColumnType("integer"); + b.Property("Form990LineId") .HasColumnType("integer"); @@ -702,6 +710,8 @@ namespace ROLAC.API.Migrations b.HasKey("Id"); + b.HasIndex("Form1099BoxId"); + b.HasIndex("Form990LineId"); b.ToTable("ExpenseCategoryGroups"); @@ -900,6 +910,9 @@ namespace ROLAC.API.Migrations .HasMaxLength(450) .HasColumnType("character varying(450)"); + b.Property("Form1099BoxId") + .HasColumnType("integer"); + b.Property("Form990LineId") .HasColumnType("integer"); @@ -931,6 +944,8 @@ namespace ROLAC.API.Migrations b.HasKey("Id"); + b.HasIndex("Form1099BoxId"); + b.HasIndex("Form990LineId"); b.HasIndex("GroupId"); @@ -976,6 +991,63 @@ namespace ROLAC.API.Migrations b.ToTable("FamilyUnits"); }); + modelBuilder.Entity("ROLAC.API.Entities.Form1099Box", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoxCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("FormType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + 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("BoxCode") + .IsUnique(); + + b.ToTable("Form1099Boxes"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b => { b.Property("Id") @@ -1925,6 +1997,128 @@ namespace ROLAC.API.Migrations b.ToTable("OfferingSessions"); }); + modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Is1099Tracked") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LegalName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("TaxClassification") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TinEncrypted") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TinLast4") + .HasMaxLength(4) + .HasColumnType("character varying(4)"); + + b.Property("TinType") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("W9BlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("W9ReceivedDate") + .HasColumnType("date"); + + b.Property("W9Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Missing"); + + b.Property("Zip") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("Id"); + + b.HasIndex("MemberId"); + + b.ToTable("Payee1099s"); + }); + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => { b.Property("Id") @@ -2208,18 +2402,32 @@ namespace ROLAC.API.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("ROLAC.API.Entities.Payee1099", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Member"); b.Navigation("Ministry"); + + b.Navigation("Payee"); }); modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => { + b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box") + .WithMany() + .HasForeignKey("Form1099BoxId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") .WithMany() .HasForeignKey("Form990LineId") .OnDelete(DeleteBehavior.SetNull); + b.Navigation("Form1099Box"); + b.Navigation("Form990Line"); }); @@ -2290,6 +2498,11 @@ namespace ROLAC.API.Migrations modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => { + b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box") + .WithMany() + .HasForeignKey("Form1099BoxId") + .OnDelete(DeleteBehavior.SetNull); + b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line") .WithMany() .HasForeignKey("Form990LineId") @@ -2301,6 +2514,8 @@ namespace ROLAC.API.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.Navigation("Form1099Box"); + b.Navigation("Form990Line"); b.Navigation("Group"); @@ -2380,6 +2595,16 @@ namespace ROLAC.API.Migrations b.Navigation("MessagingGroup"); }); + modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Member"); + }); + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => { b.HasOne("ROLAC.API.Entities.AppUser", "User") From b7eb95056da617f5e41176f9b3cc490cf79e94a8 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:20:52 -0700 Subject: [PATCH 18/32] feat(1099): add I1099FormService with filing CSV export + Copy B PDF Adds I1099FormService and Form1099FormService: an IRIS/accountant filing-data CSV (one row per reportable recipient) and a plain-paper recipient Copy B 1099-NEC PDF rendered via the DevExpress RichEdit/Office API (mirroring CheckPrintService). Includes a CSV-export unit test over a stub report service. Service lives in namespace ROLAC.API.Services (not ...Services.Form1099) to avoid shadowing the ROLAC.API.Entities.Form1099 constants class. Co-Authored-By: Claude Opus 4.8 --- .../Services/Form1099FormServiceTests.cs | 73 ++++++++ .../Services/Form1099/Form1099FormService.cs | 160 ++++++++++++++++++ .../Services/Form1099/I1099FormService.cs | 10 ++ 3 files changed, 243 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs create mode 100644 API/ROLAC.API/Services/Form1099/Form1099FormService.cs create mode 100644 API/ROLAC.API/Services/Form1099/I1099FormService.cs diff --git a/API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs b/API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs new file mode 100644 index 0000000..b4eaf40 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Form1099FormServiceTests.cs @@ -0,0 +1,73 @@ +using System.Globalization; +using System.Text; +using ROLAC.API.DTOs.Finance; +using ROLAC.API.Services; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class Form1099FormServiceTests +{ + /// Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export. + private sealed class StubReportService : IForm1099ReportService + { + private readonly Form1099SummaryDto _summary; + public StubReportService(Form1099SummaryDto summary) => _summary = summary; + + public Task GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary); + public Task> GetBoxesAsync() => throw new NotImplementedException(); + public Task GetRecipientDetailAsync(int payeeId, int taxYear) + => throw new NotImplementedException(); + } + + private static Form1099FormService BuildService(Form1099SummaryDto summary) => + // IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path. + new Form1099FormService(new StubReportService(summary), payees: null!, db: null!); + + [Fact] + public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers() + { + var summary = new Form1099SummaryDto + { + TaxYear = 2026, + Rows = + { + new Form1099RecipientRowDto + { + PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile", + NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true + }, + new Form1099RecipientRowDto + { + PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing", + NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false + }, + } + }; + + var service = BuildService(summary); + var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026); + + Assert.Equal("text/csv", contentType); + Assert.Equal("1099-filing-2026.csv", fileName); + + using var reader = new StreamReader(stream, Encoding.UTF8); + var text = await reader.ReadToEndAsync(); + var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + + // Header + one data line per row. + Assert.Equal(3, lines.Length); + Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]); + + // A value containing a comma is quoted. + Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]); + + // Invariant numeric formatting (period decimal separator) and Y/N threshold flag. + Assert.Contains("1234.50", lines[1]); + Assert.EndsWith(",Y", lines[1]); + Assert.EndsWith(",N", lines[2]); + + // Sanity: the period really is the invariant separator regardless of current culture. + Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/API/ROLAC.API/Services/Form1099/Form1099FormService.cs b/API/ROLAC.API/Services/Form1099/Form1099FormService.cs new file mode 100644 index 0000000..7cc90e0 --- /dev/null +++ b/API/ROLAC.API/Services/Form1099/Form1099FormService.cs @@ -0,0 +1,160 @@ +using System.Globalization; +using System.Text; +using DevExpress.Office; +using DevExpress.XtraRichEdit; +using DevExpress.XtraRichEdit.API.Native; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Payee; +using ROLAC.API.Entities; + +namespace ROLAC.API.Services; + +/// +/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the +/// DevExpress RichEdit/Office API, mirroring CheckPrintService) and a filing-data CSV. +/// +public class Form1099FormService : I1099FormService +{ + private readonly IForm1099ReportService _report; + private readonly IPayee1099Service _payees; + private readonly AppDbContext _db; + + public Form1099FormService(IForm1099ReportService report, IPayee1099Service payees, AppDbContext db) + { + _report = report; + _payees = payees; + _db = db; + } + + public async Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear) + { + var payee = await _payees.GetByIdAsync(payeeId) + ?? throw new InvalidOperationException($"Payee {payeeId} not found."); + + var church = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync() + ?? new ChurchProfile { Name = "Church" }; + + // Box 1 (Nonemployee compensation) = sum of this payee's NEC-1 payments for the year. + var detail = await _report.GetRecipientDetailAsync(payeeId, taxYear); + var box1Nec = detail?.Payments + .Where(payment => payment.BoxCode == Entities.Form1099.BoxNec1) + .Sum(payment => payment.Amount) ?? 0m; + + using var server = new RichEditDocumentServer(); + var document = server.Document; + document.BeginUpdate(); + try + { + document.Unit = DocumentUnit.Inch; + var section = document.Sections[0]; + section.Page.Width = 8.5f; + section.Page.Height = 11f; + section.Margins.Left = section.Margins.Right = 0.8f; + section.Margins.Top = section.Margins.Bottom = 0.8f; + + document.AppendHtmlText(BuildCopyBHtml(church, payee, taxYear, box1Nec)); + } + finally + { + document.EndUpdate(); + } + + var stream = new MemoryStream(); + server.ExportToPdf(stream); + stream.Position = 0; + return (stream, "application/pdf", $"1099-NEC-{payeeId}-{taxYear}.pdf"); + } + + public async Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear) + { + var summary = await _report.GetAnnualSummaryAsync(taxYear); + var builder = new StringBuilder(); + builder.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold"); + foreach (var row in summary.Rows) + { + builder.AppendLine(string.Join(",", + Csv(row.LegalName), Csv(row.TinLast4 ?? ""), Csv(row.W9Status), + row.NecTotal.ToString(CultureInfo.InvariantCulture), + row.RentsTotal.ToString(CultureInfo.InvariantCulture), + row.GrandTotal.ToString(CultureInfo.InvariantCulture), + row.MeetsThreshold ? "Y" : "N")); + } + + var bytes = Encoding.UTF8.GetBytes(builder.ToString()); + return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv"); + + static string Csv(string value) => value.Contains(',') || value.Contains('"') + ? "\"" + value.Replace("\"", "\"\"") + "\"" : value; + } + + private static string BuildCopyBHtml(ChurchProfile church, Payee1099Dto payee, int taxYear, decimal box1Nec) + { + var payerAddress = JoinAddress(church.Address, church.City, church.State, church.ZipCode); + var recipientAddress = JoinAddress( + JoinLines(payee.AddressLine1, payee.AddressLine2), payee.City, payee.State, payee.Zip); + + // ChurchProfile has no payer EIN/TIN field, so the payer-TIN box is labelled but left blank. + var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}"; + + return + "
" + + $"

Form 1099-NEC — Copy B (For Recipient)

" + + $"

Tax Year {taxYear}
Nonemployee Compensation

" + + + "" + + + "" + + "" + + + "" + + "" + + + "" + + + "
" + + "PAYER’s name, address
" + + $"{Encode(church.Name)}
{payerAddress}" + + "
" + + "PAYER’s TIN (EIN)
 " + + "
" + + "RECIPIENT’s name, address
" + + $"{Encode(payee.LegalName)}
{recipientAddress}" + + "
" + + $"RECIPIENT’s TIN
{Encode(maskedTin)}" + + "
" + + "Box 1 — Nonemployee compensation
" + + $"{Encode(FormatCurrency(box1Nec))}" + + "
" + + + "

" + + "This is important tax information and is being furnished to the recipient. " + + "Recipient’s taxpayer identification number is shown masked for security." + + "

" + + "
"; + } + + private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); + + private static string FormatCurrency(decimal amount) => + amount.ToString("C2", CultureInfo.GetCultureInfo("en-US")); + + private static string? JoinLines(string? line1, string? line2) + { + var parts = new[] { line1, line2 }.Where(part => !string.IsNullOrWhiteSpace(part)); + var joined = string.Join(", ", parts); + return string.IsNullOrWhiteSpace(joined) ? null : joined; + } + + // Builds an HTML address block; each text part is HTML-encoded and the line break (
) is literal. + private static string JoinAddress(string? address, string? city, string? state, string? zip) + { + var cityLine = string.Join(", ", + new[] { city, string.Join(" ", new[] { state, zip }.Where(part => !string.IsNullOrWhiteSpace(part))) } + .Where(part => !string.IsNullOrWhiteSpace(part))); + var lines = new[] { address, cityLine } + .Where(part => !string.IsNullOrWhiteSpace(part)) + .Select(Encode); + return string.Join("
", lines); + } +} diff --git a/API/ROLAC.API/Services/Form1099/I1099FormService.cs b/API/ROLAC.API/Services/Form1099/I1099FormService.cs new file mode 100644 index 0000000..91e96b9 --- /dev/null +++ b/API/ROLAC.API/Services/Form1099/I1099FormService.cs @@ -0,0 +1,10 @@ +namespace ROLAC.API.Services; + +public interface I1099FormService +{ + /// Recipient Copy B 1099-NEC PDF for one payee/year (plain paper). + Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear); + + /// Filing-data CSV (one row per reportable recipient) for IRIS/accountant. + Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear); +} From 8cb6245560883e96a24b785b7ba5999a15280284 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:21:14 -0700 Subject: [PATCH 19/32] feat(1099): add 1099 Copy B + filing CSV download endpoints Injects I1099FormService into Form1099ReportController and adds two Read-gated GET endpoints: recipient/{payeeId}/copy-b (Copy B PDF) and export-csv (filing-data CSV). Registers Form1099FormService in DI. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/Form1099ReportController.cs | 21 ++++++++++++++++++- API/ROLAC.API/Program.cs | 1 + 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/API/ROLAC.API/Controllers/Form1099ReportController.cs b/API/ROLAC.API/Controllers/Form1099ReportController.cs index fde93e3..c83617e 100644 --- a/API/ROLAC.API/Controllers/Form1099ReportController.cs +++ b/API/ROLAC.API/Controllers/Form1099ReportController.cs @@ -10,7 +10,12 @@ namespace ROLAC.API.Controllers; public class Form1099ReportController : ControllerBase { private readonly IForm1099ReportService _svc; - public Form1099ReportController(IForm1099ReportService svc) => _svc = svc; + private readonly I1099FormService _form; + public Form1099ReportController(IForm1099ReportService svc, I1099FormService form) + { + _svc = svc; + _form = form; + } [HttpGet("boxes")] public async Task Boxes() => Ok(await _svc.GetBoxesAsync()); @@ -22,4 +27,18 @@ public class Form1099ReportController : ControllerBase [HttpGet("recipient/{payeeId:int}")] public async Task Recipient(int payeeId, [FromQuery] int taxYear) => await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound(); + + [HttpGet("recipient/{payeeId:int}/copy-b")] + public async Task CopyB(int payeeId, [FromQuery] int taxYear) + { + var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear); + return File(stream, contentType, fileName); + } + + [HttpGet("export-csv")] + public async Task ExportCsv([FromQuery] int taxYear) + { + var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear); + return File(stream, contentType, fileName); + } } diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index dcaa3b2..ee7da4e 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -160,6 +160,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddDataProtection(); builder.Services.AddScoped(); builder.Services.AddScoped(); From bf247726e1d3860161266d1af741e2687fa2d31d Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:26:57 -0700 Subject: [PATCH 20/32] feat(1099): frontend models, API services, and permission module entry Co-Authored-By: Claude Opus 4.8 --- APP/src/app/core/models/permission.model.ts | 1 + .../payee1099/models/payee1099.model.ts | 43 +++++++++++++++++++ .../services/form1099-report-api.service.ts | 40 +++++++++++++++++ .../services/payee1099-api.service.ts | 42 ++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 APP/src/app/features/payee1099/models/payee1099.model.ts create mode 100644 APP/src/app/features/payee1099/services/form1099-report-api.service.ts create mode 100644 APP/src/app/features/payee1099/services/payee1099-api.service.ts diff --git a/APP/src/app/core/models/permission.model.ts b/APP/src/app/core/models/permission.model.ts index 2f9ac40..d8e22d5 100644 --- a/APP/src/app/core/models/permission.model.ts +++ b/APP/src/app/core/models/permission.model.ts @@ -33,6 +33,7 @@ export const PermissionModules = { SystemLogs: 'SystemLogs', AuditLogs: 'AuditLogs', Settings: 'Settings', + Form1099: 'Form1099', } as const; /** A required permission, used in route data and the *appHasPermission directive. */ diff --git a/APP/src/app/features/payee1099/models/payee1099.model.ts b/APP/src/app/features/payee1099/models/payee1099.model.ts new file mode 100644 index 0000000..0a61ca2 --- /dev/null +++ b/APP/src/app/features/payee1099/models/payee1099.model.ts @@ -0,0 +1,43 @@ +export interface Payee1099ListItem { + id: number; legalName: string; displayName?: string; + memberId?: number; memberName?: string; taxClassification: string; + is1099Tracked: boolean; tinType?: string; tinLast4?: string; + w9Status: string; isActive: boolean; +} + +export interface Payee1099 extends Payee1099ListItem { + addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string; + email?: string; phone?: string; w9ReceivedDate?: string; hasW9Document: boolean; notes?: string; +} + +export interface SavePayee1099Request { + legalName: string; displayName?: string; memberId?: number | null; + taxClassification: string; is1099Tracked: boolean; + tinType?: string; tin?: string | null; + addressLine1?: string; addressLine2?: string; city?: string; state?: string; zip?: string; + email?: string; phone?: string; w9Status: string; w9ReceivedDate?: string | null; + isActive: boolean; notes?: string; +} + +export interface Form1099Box { + id: number; boxCode: string; name_en: string; name_zh?: string; formType: string; sortOrder: number; +} + +export interface Form1099RecipientRow { + payeeId: number; legalName: string; tinLast4?: string; w9Status: string; + necTotal: number; rentsTotal: number; grandTotal: number; meetsThreshold: boolean; w9Missing: boolean; +} + +export interface Form1099Summary { + taxYear: number; rows: Form1099RecipientRow[]; + totalReportable: number; recipientsAtThreshold: number; recipientsMissingW9: number; +} + +export interface Form1099Payment { + paidDate: string; description: string; categoryName: string; boxCode: string; amount: number; +} + +export interface Form1099RecipientDetail { + payeeId: number; legalName: string; tinLast4?: string; w9Status: string; + taxYear: number; payments: Form1099Payment[]; +} diff --git a/APP/src/app/features/payee1099/services/form1099-report-api.service.ts b/APP/src/app/features/payee1099/services/form1099-report-api.service.ts new file mode 100644 index 0000000..d7e692c --- /dev/null +++ b/APP/src/app/features/payee1099/services/form1099-report-api.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + Form1099Box, Form1099Summary, Form1099RecipientDetail, +} from '../models/payee1099.model'; + +@Injectable({ providedIn: 'root' }) +export class Form1099ReportApiService { + private readonly endpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('form1099-report'); + } + + getBoxes(): Observable { + return this.http.get(`${this.endpoint}/boxes`); + } + + getSummary(taxYear: number): Observable { + return this.http.get(`${this.endpoint}/summary`, { + params: { taxYear: String(taxYear) }, + }); + } + + getRecipient(payeeId: number, taxYear: number): Observable { + return this.http.get(`${this.endpoint}/recipient/${payeeId}`, { + params: { taxYear: String(taxYear) }, + }); + } + + copyBUrl(payeeId: number, taxYear: number): string { + return `${this.endpoint}/recipient/${payeeId}/copy-b?taxYear=${taxYear}`; + } + + exportCsvUrl(taxYear: number): string { + return `${this.endpoint}/export-csv?taxYear=${taxYear}`; + } +} diff --git a/APP/src/app/features/payee1099/services/payee1099-api.service.ts b/APP/src/app/features/payee1099/services/payee1099-api.service.ts new file mode 100644 index 0000000..6b0468e --- /dev/null +++ b/APP/src/app/features/payee1099/services/payee1099-api.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { ApiConfigService } from '../../../core/services/api-config.service'; +import { + Payee1099ListItem, Payee1099, SavePayee1099Request, +} from '../models/payee1099.model'; + +@Injectable({ providedIn: 'root' }) +export class Payee1099ApiService { + private readonly endpoint: string; + + constructor(private http: HttpClient, apiConfig: ApiConfigService) { + this.endpoint = apiConfig.getApiUrl('payee-1099'); + } + + getAll(includeInactive = false): Observable { + return this.http.get(this.endpoint, { + params: { includeInactive: String(includeInactive) }, + }); + } + + getById(id: number): Observable { + return this.http.get(`${this.endpoint}/${id}`); + } + + create(req: SavePayee1099Request): Observable<{ id: number }> { + return this.http.post<{ id: number }>(this.endpoint, req); + } + + update(id: number, req: SavePayee1099Request): Observable { + return this.http.put(`${this.endpoint}/${id}`, req); + } + + delete(id: number): Observable { + return this.http.delete(`${this.endpoint}/${id}`); + } + + revealTin(id: number): Observable<{ tin: string | null }> { + return this.http.get<{ tin: string | null }>(`${this.endpoint}/${id}/tin`); + } +} From d1747b510e40372b487f5a4e7ceda2b4addf9ce4 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:31:48 -0700 Subject: [PATCH 21/32] feat(1099): 1099 recipients master page with nav + route Co-Authored-By: Claude Opus 4.8 --- APP/src/app/app.routes.ts | 9 + .../payee-1099-page.component.html | 165 ++++++++++++ .../payee-1099-page.component.scss | 55 ++++ .../payee-1099-page.component.ts | 234 ++++++++++++++++++ .../user-portal/user-portal.component.ts | 2 + 5 files changed, 465 insertions(+) create mode 100644 APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html create mode 100644 APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss create mode 100644 APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index 7695999..e07e601 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -228,6 +228,15 @@ export const routes: Routes = [ title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance', }, }, + { + path: 'finance/payee-1099', + loadComponent: () => import('./features/payee1099/pages/payee-1099-page/payee-1099-page.component').then(m => m.Payee1099PageComponent), + canActivate: [PermissionGuard], + data: { + permission: { module: PermissionModules.Form1099, action: 'read' }, + title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance', + }, + }, ] }, diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html new file mode 100644 index 0000000..4288e8f --- /dev/null +++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.html @@ -0,0 +1,165 @@ +
+ + + + + +
Click a name to edit · right-click a row for actions / 點選名稱編輯 · 右鍵顯示動作
+ + + + + +
+
+
+
{{ r.legalName }}
+ {{ r.w9Status }} +
+
{{ r.displayName }}
+
Member / 會友{{ r.memberName || '—' }}
+
Tax Class{{ r.taxClassification }}
+
TIN{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}
+
1099 Tracked{{ r.is1099Tracked ? 'Yes' : 'No' }}
+
Active{{ r.isActive ? 'Yes' : 'No' }}
+
+
+ + + +
+ + + + + + + + + + + + + + + +
+ + +
+ + + + + + + + + + + +
+ + + + +
+ +
diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss new file mode 100644 index 0000000..f823388 --- /dev/null +++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.scss @@ -0,0 +1,55 @@ +.hint-text-sm { + margin-bottom: 0.5rem; + font-size: 0.8rem; + color: #999; +} + +.inactive-toggle { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.85rem; +} + +.legal-name { + font-weight: 600; +} + +.display-name { + color: #777; +} + +// Grid rows are clickable to open the editor. +.clickable-rows ::ng-deep .k-grid-content tr { + cursor: pointer; +} + +// W-9 status badges. +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; +} + +.badge-onfile { + background-color: #dcfce7; + color: #166534; +} + +.badge-requested { + background-color: #fef9c3; + color: #854d0e; +} + +.badge-missing { + background-color: #fee2e2; + color: #991b1b; +} + +.badge-expired { + background-color: #fed7aa; + color: #9a3412; +} diff --git a/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts new file mode 100644 index 0000000..229b0e3 --- /dev/null +++ b/APP/src/app/features/payee1099/pages/payee-1099-page/payee-1099-page.component.ts @@ -0,0 +1,234 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { DateInputsModule } from '@progress/kendo-angular-dateinputs'; +import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu'; +import { Payee1099ApiService } from '../../services/payee1099-api.service'; +import { Payee1099ListItem, Payee1099, SavePayee1099Request } from '../../models/payee1099.model'; +import { MemberApiService } from '../../../members/services/member-api.service'; +import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model'; +import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive'; +import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive'; + +/** Flattened member item with a single displayName field for the picker. */ +interface MemberOption { id: number; displayName: string; } + +/** Editable form model for the New/Edit dialog. */ +interface Payee1099Form { + legalName: string; + displayName: string; + memberId: number | null; + taxClassification: string; + is1099Tracked: boolean; + tinType: string; + tin: string; + addressLine1: string; + addressLine2: string; + city: string; + state: string; + zip: string; + email: string; + phone: string; + w9Status: string; + w9ReceivedDate: Date | null; + isActive: boolean; + notes: string; +} + +@Component({ + selector: 'app-payee-1099-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, + InputsModule, DropDownsModule, DateInputsModule, ContextMenuModule, + PageHeaderActionsDirective, HasPermissionDirective, + ], + templateUrl: './payee-1099-page.component.html', + styleUrls: ['./payee-1099-page.component.scss'], +}) +export class Payee1099PageComponent implements OnInit { + recipients: Payee1099ListItem[] = []; + loading = false; + includeInactive = false; + + readonly taxClassifications = ['Individual', 'SoleProprietor', 'Partnership', 'CCorp', 'SCorp', 'LLC', 'Other']; + readonly tinTypes = ['SSN', 'EIN']; + readonly w9Statuses = ['Missing', 'Requested', 'OnFile', 'Expired']; + + /** Member picker options, filled on demand from the members search. */ + memberResults: MemberOption[] = []; + + @ViewChild('rowMenu') rowMenu!: ContextMenuComponent; + rowMenuItems: { text: string }[] = []; + private contextRow: Payee1099ListItem | null = null; + + dialogOpen = false; + editingId: number | null = null; + /** Last-4 of the existing TIN (edit mode), so the TIN box can show a masked placeholder. */ + editingTinLast4: string | null = null; + form: Payee1099Form = this.blankForm(); + + constructor( + private api: Payee1099ApiService, + private memberApi: MemberApiService, + ) {} + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.api.getAll(this.includeInactive).subscribe({ + next: (rows) => { + this.recipients = rows; + this.loading = false; + }, + error: () => { this.loading = false; }, + }); + } + + private blankForm(): Payee1099Form { + return { + legalName: '', displayName: '', memberId: null, + taxClassification: 'Individual', is1099Tracked: true, + tinType: 'SSN', tin: '', + addressLine1: '', addressLine2: '', city: '', state: '', zip: '', + email: '', phone: '', + w9Status: 'Missing', w9ReceivedDate: null, + isActive: true, notes: '', + }; + } + + // ── Member picker (server-side search, same source as the expense form) ────── + onMemberFilter(term: string): void { + if (!term || term.length < 1) { this.memberResults = []; return; } + this.memberApi.getPaged({ search: term, pageSize: 10 }).subscribe((result) => { + this.memberResults = result.items.map((member: MemberListItemDto) => ({ + id: member.id, + displayName: memberDisplayName(member), + })); + }); + } + + // ── Row interaction: primary click opens the editor; right-click shows actions ── + onCellClick(event: CellClickEvent): void { + if (event.type === 'contextmenu') { + event.originalEvent.preventDefault(); + this.contextRow = event.dataItem; + this.rowMenuItems = this.buildMenuItems(event.dataItem.isActive); + this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY }); + } else { + this.openEdit(event.dataItem); + } + } + + onRowMenuSelect(event: ContextMenuSelectEvent): void { + if (!this.contextRow) return; + if (event.item.text === 'Edit') this.openEdit(this.contextRow); + else if (event.item.text === 'Deactivate') this.deactivate(this.contextRow); + } + + private buildMenuItems(isActive: boolean): { text: string }[] { + const items: { text: string }[] = [{ text: 'Edit' }]; + if (isActive) items.push({ text: 'Deactivate' }); + return items; + } + + // ── Dialog open ────────────────────────────────────────────────────────────── + openNew(): void { + this.editingId = null; + this.editingTinLast4 = null; + this.form = this.blankForm(); + this.dialogOpen = true; + } + + openEdit(row: Payee1099ListItem): void { + this.editingId = row.id; + this.dialogOpen = true; + // Load the full record so the dialog can prefill the address/contact/notes fields. + this.api.getById(row.id).subscribe((payee: Payee1099) => { + this.editingTinLast4 = payee.tinLast4 ?? null; + this.form = { + legalName: payee.legalName, + displayName: payee.displayName ?? '', + memberId: payee.memberId ?? null, + taxClassification: payee.taxClassification, + is1099Tracked: payee.is1099Tracked, + tinType: payee.tinType ?? 'SSN', + tin: '', + addressLine1: payee.addressLine1 ?? '', + addressLine2: payee.addressLine2 ?? '', + city: payee.city ?? '', + state: payee.state ?? '', + zip: payee.zip ?? '', + email: payee.email ?? '', + phone: payee.phone ?? '', + w9Status: payee.w9Status, + w9ReceivedDate: this.parseDateOnly(payee.w9ReceivedDate), + isActive: payee.isActive, + notes: payee.notes ?? '', + }; + // Seed the picker with the linked member so its name shows even before a search. + if (payee.memberId != null && payee.memberName) { + this.memberResults = [{ id: payee.memberId, displayName: payee.memberName }]; + } + }); + } + + // ── Save ───────────────────────────────────────────────────────────────────── + save(): void { + if (!this.form.legalName.trim()) return; + const typedTin = this.form.tin.trim(); + const request: SavePayee1099Request = { + legalName: this.form.legalName.trim(), + displayName: this.form.displayName.trim() || undefined, + memberId: this.form.memberId ?? null, + taxClassification: this.form.taxClassification, + is1099Tracked: this.form.is1099Tracked, + tinType: this.form.tinType, + // Send the typed TIN when present. On edit a blank leaves the stored value + // unchanged (null = no change); on new a blank simply means no TIN yet. + tin: typedTin || null, + addressLine1: this.form.addressLine1.trim() || undefined, + addressLine2: this.form.addressLine2.trim() || undefined, + city: this.form.city.trim() || undefined, + state: this.form.state.trim() || undefined, + zip: this.form.zip.trim() || undefined, + email: this.form.email.trim() || undefined, + phone: this.form.phone.trim() || undefined, + w9Status: this.form.w9Status, + w9ReceivedDate: this.toDateOnly(this.form.w9ReceivedDate), + isActive: this.form.isActive, + notes: this.form.notes.trim() || undefined, + }; + const done = () => { this.dialogOpen = false; this.load(); }; + if (this.editingId == null) this.api.create(request).subscribe(done); + else this.api.update(this.editingId, request).subscribe(done); + } + + deactivate(row: Payee1099ListItem): void { + if (!confirm(`Deactivate "${row.legalName}"?`)) return; + this.api.delete(row.id).subscribe(() => this.load()); + } + + // ── Date-only helpers: build/parse "yyyy-MM-dd" from LOCAL components ───────── + private parseDateOnly(value: string | undefined | null): Date | null { + if (!value) return null; + const [year, month, day] = value.split('-').map(Number); + return new Date(year, month - 1, day); + } + + private toDateOnly(date: Date | null): string | null { + if (!date) return null; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } +} 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 1d1c991..7f3ac55 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -138,6 +138,8 @@ export class UserPortalComponent implements OnInit, OnDestroy { permission: { module: PermissionModules.Disbursements, action: 'read' } }, { text: 'Check Register', icon: walletOutlineIcon, path: '/user-portal/finance/check-register', permission: { module: PermissionModules.Disbursements, action: 'read' } }, + { text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099', + permission: { module: PermissionModules.Form1099, action: 'read' } }, ], }, { From 6ffaaf37aceebb17d7a526dfd0735728ed8ce9e6 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:38:47 -0700 Subject: [PATCH 22/32] feat(1099): add authenticated blob downloads to report API service Add downloadCsv/downloadCopyB returning Blob via HttpClient so the auth interceptor attaches the bearer token (raw window.open would 401). Remove the now-unused copyBUrl/exportCsvUrl raw-URL builders. Co-Authored-By: Claude Opus 4.8 --- .../services/form1099-report-api.service.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/APP/src/app/features/payee1099/services/form1099-report-api.service.ts b/APP/src/app/features/payee1099/services/form1099-report-api.service.ts index d7e692c..8a9a94f 100644 --- a/APP/src/app/features/payee1099/services/form1099-report-api.service.ts +++ b/APP/src/app/features/payee1099/services/form1099-report-api.service.ts @@ -30,11 +30,19 @@ export class Form1099ReportApiService { }); } - copyBUrl(payeeId: number, taxYear: number): string { - return `${this.endpoint}/recipient/${payeeId}/copy-b?taxYear=${taxYear}`; + // Authenticated blob downloads: routed through HttpClient so the auth + // interceptor attaches the bearer token (a raw window.open would 401). + downloadCsv(taxYear: number): Observable { + return this.http.get(`${this.endpoint}/export-csv`, { + params: { taxYear: String(taxYear) }, + responseType: 'blob', + }); } - exportCsvUrl(taxYear: number): string { - return `${this.endpoint}/export-csv?taxYear=${taxYear}`; + downloadCopyB(payeeId: number, taxYear: number): Observable { + return this.http.get(`${this.endpoint}/recipient/${payeeId}/copy-b`, { + params: { taxYear: String(taxYear) }, + responseType: 'blob', + }); } } From 82096e7e6f2250c1fd7ed339faceadb1a34d613f Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:39:20 -0700 Subject: [PATCH 23/32] feat(1099): 1099 year-end report page with drill-in, CSV, Copy B Add Form1099ReportPageComponent (year selector, summary chips with a prominent missing-W-9 flag, desktop grid + mobile cards, recipient detail dialog). Per-row Copy B PDF via right-click context menu and a header Export filing CSV action, both downloaded as auth-correct blobs. Wire the eager route + sidebar nav item, gated on Form1099:read. Also convert the neighboring finance/payee-1099 route from lazy loadComponent to an eager component import so both 1099 routes match the surrounding convention. Co-Authored-By: Claude Opus 4.8 --- APP/src/app/app.routes.ts | 13 +- .../form1099-report-page.component.html | 119 +++++++++++++++++ .../form1099-report-page.component.scss | 100 ++++++++++++++ .../form1099-report-page.component.ts | 122 ++++++++++++++++++ .../user-portal/user-portal.component.ts | 2 + 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html create mode 100644 APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss create mode 100644 APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts diff --git a/APP/src/app/app.routes.ts b/APP/src/app/app.routes.ts index e07e601..81a079e 100644 --- a/APP/src/app/app.routes.ts +++ b/APP/src/app/app.routes.ts @@ -22,6 +22,8 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component'; import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; import { Form990ReportPageComponent } from './features/finance-report/pages/form990-report-page/form990-report-page.component'; +import { Form1099ReportPageComponent } from './features/finance-report/pages/form1099-report-page/form1099-report-page.component'; +import { Payee1099PageComponent } from './features/payee1099/pages/payee-1099-page/payee-1099-page.component'; import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component'; import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component'; import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component'; @@ -230,13 +232,22 @@ export const routes: Routes = [ }, { path: 'finance/payee-1099', - loadComponent: () => import('./features/payee1099/pages/payee-1099-page/payee-1099-page.component').then(m => m.Payee1099PageComponent), + component: Payee1099PageComponent, canActivate: [PermissionGuard], data: { permission: { module: PermissionModules.Form1099, action: 'read' }, title: '1099 Recipients', titleZh: '1099 收款人', section: 'Finance', }, }, + { + path: 'finance/form1099-report', + component: Form1099ReportPageComponent, + canActivate: [PermissionGuard], + data: { + permission: { module: PermissionModules.Form1099, action: 'read' }, + title: '1099 Year-End Report', titleZh: '1099 年度報表', section: 'Finance', + }, + }, ] }, diff --git a/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html new file mode 100644 index 0000000..8ff7580 --- /dev/null +++ b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.html @@ -0,0 +1,119 @@ +
+ + + + + +
+ + +
+ + +
+
+
Total Reportable / 應申報總額
+
{{ summary.totalReportable | currency }}
+
+
+
Recipients ≥ $600 / 達門檻收款人
+
{{ summary.recipientsAtThreshold }}
+
+
+
Missing W-9 / 缺少 W-9
+
{{ summary.recipientsMissingW9 }}
+
+
+ +
Click a name for payment detail · right-click a row for Copy B / 點選名稱檢視明細 · 右鍵下載 Copy B
+ + + + + +
+
+
+
{{ r.legalName }}
+ + {{ r.w9Status }} + +
+
TIN{{ r.tinLast4 ? '***-**-' + r.tinLast4 : '—' }}
+
NEC / 非雇員報酬{{ r.necTotal | currency }}
+
Rents / 租金{{ r.rentsTotal | currency }}
+
Total / 總計{{ r.grandTotal | currency }}
+
+ Threshold / 門檻 + ≥ $600 +
+
+
+ + + + +
Loading… / 載入中…
+ + +
+
{{ detail.legalName }}
+
+ TIN {{ detail.tinLast4 ? '***-**-' + detail.tinLast4 : '—' }} + {{ detail.w9Status }} + Year / 年度 {{ detail.taxYear }} +
+
+ + + + + + + + +
+ + + + +
+ +
diff --git a/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss new file mode 100644 index 0000000..6f9da0f --- /dev/null +++ b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.scss @@ -0,0 +1,100 @@ +.hint-text-sm { + margin-bottom: 0.5rem; + font-size: 0.8rem; + color: #999; +} + +.legal-name { + font-weight: 600; +} + +// Grid rows are clickable to open the recipient detail. +.clickable-rows ::ng-deep .k-grid-content tr { + cursor: pointer; +} + +// Summary chips. +.summary-chip { + flex: 1 1 200px; + min-width: 180px; + padding: 0.75rem 1rem; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + background-color: #f9fafb; +} + +.summary-label { + font-size: 0.75rem; + color: #6b7280; +} + +.summary-value { + font-size: 1.5rem; + font-weight: 700; + color: #111827; +} + +// Missing-W-9 chip is a governance flag — make it stand out. +.summary-chip-flag { + border-color: #fca5a5; + background-color: #fef2f2; + + .summary-value { + color: #991b1b; + } +} + +// Recipient detail header. +.detail-header { + margin-bottom: 0.75rem; +} + +.detail-name { + font-size: 1.1rem; + font-weight: 700; +} + +.detail-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + margin-top: 0.25rem; + font-size: 0.85rem; + color: #555; +} + +// Status / threshold badges. +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.2; +} + +.badge-onfile { + background-color: #dcfce7; + color: #166534; +} + +.badge-requested { + background-color: #fef9c3; + color: #854d0e; +} + +.badge-missing { + background-color: #fee2e2; + color: #991b1b; +} + +.badge-expired { + background-color: #fed7aa; + color: #9a3412; +} + +.badge-threshold { + background-color: #dbeafe; + color: #1e40af; +} diff --git a/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts new file mode 100644 index 0000000..bc63424 --- /dev/null +++ b/APP/src/app/features/finance-report/pages/form1099-report-page/form1099-report-page.component.ts @@ -0,0 +1,122 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { GridModule, CellClickEvent } from '@progress/kendo-angular-grid'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { DropDownsModule } from '@progress/kendo-angular-dropdowns'; +import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu'; +import { Form1099ReportApiService } from '../../../payee1099/services/form1099-report-api.service'; +import { + Form1099Summary, Form1099RecipientRow, Form1099RecipientDetail, +} from '../../../payee1099/models/payee1099.model'; +import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive'; + +@Component({ + selector: 'app-form1099-report-page', + standalone: true, + imports: [ + CommonModule, FormsModule, GridModule, ButtonsModule, DialogsModule, + DropDownsModule, ContextMenuModule, PageHeaderActionsDirective, + ], + templateUrl: './form1099-report-page.component.html', + styleUrls: ['./form1099-report-page.component.scss'], +}) +export class Form1099ReportPageComponent implements OnInit { + /** Recent years offered in the selector: current year and the prior four. */ + readonly years: number[] = []; + taxYear: number = new Date().getFullYear(); + + summary: Form1099Summary | null = null; + loading = false; + + // Per-row "Copy B" action, surfaced through a right-click context menu (matches + // the recipients page convention of putting row actions in a context menu). + @ViewChild('rowMenu') rowMenu!: ContextMenuComponent; + rowMenuItems: { text: string }[] = []; + private contextRow: Form1099RecipientRow | null = null; + + detail: Form1099RecipientDetail | null = null; + detailLoading = false; + + constructor(private api: Form1099ReportApiService) { + const currentYear = new Date().getFullYear(); + for (let offset = 0; offset < 5; offset++) { + this.years.push(currentYear - offset); + } + } + + ngOnInit(): void { + this.load(); + } + + load(): void { + this.loading = true; + this.api.getSummary(this.taxYear).subscribe({ + next: (summary) => { + this.summary = summary; + this.loading = false; + }, + error: () => { this.loading = false; }, + }); + } + + // ── Row interaction: primary click opens the detail; right-click shows actions ── + onCellClick(event: CellClickEvent): void { + if (event.type === 'contextmenu') { + event.originalEvent.preventDefault(); + this.contextRow = event.dataItem; + this.rowMenuItems = [{ text: 'Copy B PDF' }]; + this.rowMenu.show({ left: event.originalEvent.pageX, top: event.originalEvent.pageY }); + } else { + this.openDetail(event.dataItem); + } + } + + onRowMenuSelect(event: ContextMenuSelectEvent): void { + if (!this.contextRow) return; + if (event.item.text === 'Copy B PDF') this.copyB(this.contextRow); + } + + openDetail(row: Form1099RecipientRow): void { + this.detail = null; + this.detailLoading = true; + this.api.getRecipient(row.payeeId, this.taxYear).subscribe({ + next: (detail) => { + this.detail = detail; + this.detailLoading = false; + }, + error: () => { this.detailLoading = false; }, + }); + } + + closeDetail(): void { + this.detail = null; + this.detailLoading = false; + } + + // ── Downloads: fetched as blobs so the auth interceptor attaches the token ────── + exportCsv(): void { + this.api.downloadCsv(this.taxYear).subscribe((blob) => { + this.saveBlob(blob, `1099-filing-${this.taxYear}.csv`); + }); + } + + copyB(row: Form1099RecipientRow): void { + this.api.downloadCopyB(row.payeeId, this.taxYear).subscribe((blob) => { + this.saveBlob(blob, `1099-NEC-${row.payeeId}-${this.taxYear}.pdf`); + }); + } + + /** Trigger a browser save of a downloaded blob via a temporary anchor. */ + private saveBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + } +} 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 7f3ac55..dd73e28 100644 --- a/APP/src/app/portals/user-portal/user-portal.component.ts +++ b/APP/src/app/portals/user-portal/user-portal.component.ts @@ -140,6 +140,8 @@ export class UserPortalComponent implements OnInit, OnDestroy { permission: { module: PermissionModules.Disbursements, action: 'read' } }, { text: '1099 Recipients', icon: fileReportIcon, path: '/user-portal/finance/payee-1099', permission: { module: PermissionModules.Form1099, action: 'read' } }, + { text: '1099 Report', icon: fileReportIcon, path: '/user-portal/finance/form1099-report', + permission: { module: PermissionModules.Form1099, action: 'read' } }, ], }, { From 402826ee3df6dc62b9fb7095fb06db2d42d3fb22 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:46:31 -0700 Subject: [PATCH 24/32] feat(1099): round-trip Form1099BoxId through expense category DTOs/service Mirror Form990LineId: add Form1099BoxId + Form1099BoxCode to all four category DTOs (response + request, group + sub); load a boxCodes lookup dictionary in GetAllAsync and project it; set/copy the field in CreateGroupAsync, UpdateGroupAsync, CreateSubCategoryAsync, and UpdateSubCategoryAsync. All 4 category-service unit tests pass. Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs | 6 ++++++ API/ROLAC.API/Services/ExpenseCategoryService.cs | 15 +++++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs index 78e1250..6efcc5b 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseCategoryDtos.cs @@ -11,6 +11,8 @@ public class ExpenseSubCategoryDto public bool IsActive { get; set; } public int? Form990LineId { get; set; } public string? Form990LineCode { get; set; } + public int? Form1099BoxId { get; set; } + public string? Form1099BoxCode { get; set; } } public class ExpenseCategoryGroupDto @@ -22,6 +24,8 @@ public class ExpenseCategoryGroupDto public bool IsActive { get; set; } public int? Form990LineId { get; set; } public string? Form990LineCode { get; set; } + public int? Form1099BoxId { get; set; } + public string? Form1099BoxCode { get; set; } public List SubCategories { get; set; } = []; } @@ -31,6 +35,7 @@ public class CreateExpenseGroupRequest [MaxLength(200)] public string? Name_zh { get; set; } public int SortOrder { get; set; } public int? Form990LineId { get; set; } + public int? Form1099BoxId { get; set; } } public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest { @@ -44,6 +49,7 @@ public class CreateExpenseSubCategoryRequest [MaxLength(200)] public string? Name_zh { get; set; } public int SortOrder { get; set; } public int? Form990LineId { get; set; } + public int? Form1099BoxId { get; set; } } public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest { diff --git a/API/ROLAC.API/Services/ExpenseCategoryService.cs b/API/ROLAC.API/Services/ExpenseCategoryService.cs index ae6bb87..90967b0 100644 --- a/API/ROLAC.API/Services/ExpenseCategoryService.cs +++ b/API/ROLAC.API/Services/ExpenseCategoryService.cs @@ -25,25 +25,32 @@ public class ExpenseCategoryService : IExpenseCategoryService var lineCodes = await _db.Form990ExpenseLines.AsNoTracking() .ToDictionaryAsync(l => l.Id, l => l.LineCode); + var boxCodes = await _db.Form1099Boxes.AsNoTracking() + .ToDictionaryAsync(b => b.Id, b => b.BoxCode); + return groups.Select(g => new ExpenseCategoryGroupDto { Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, SortOrder = g.SortOrder, IsActive = g.IsActive, Form990LineId = g.Form990LineId, Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null, + Form1099BoxId = g.Form1099BoxId, + Form1099BoxCode = g.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(g.Form1099BoxId.Value) : null, SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto { Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh, SortOrder = s.SortOrder, IsActive = s.IsActive, Form990LineId = s.Form990LineId, Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null, + Form1099BoxId = s.Form1099BoxId, + Form1099BoxCode = s.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(s.Form1099BoxId.Value) : null, }).ToList(), }).ToList(); } public async Task CreateGroupAsync(CreateExpenseGroupRequest r) { - var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId }; + var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId }; _db.ExpenseCategoryGroups.Add(g); await _db.SaveChangesAsync(); return g.Id; @@ -53,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService { var g = await _db.ExpenseCategoryGroups.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); - g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; + g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; g.Form1099BoxId = r.Form1099BoxId; await _db.SaveChangesAsync(); } @@ -69,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService { var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); - var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId }; + var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId }; _db.ExpenseSubCategories.Add(s); await _db.SaveChangesAsync(); return s.Id; @@ -79,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService { var s = await _db.ExpenseSubCategories.FindAsync(id) ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); - s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; + s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; s.Form1099BoxId = r.Form1099BoxId; await _db.SaveChangesAsync(); } From d8e6f3ed6111df4b7ce7a0e11b86adf8207dac5d Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 17:50:12 -0700 Subject: [PATCH 25/32] feat(1099): add 1099 box dropdowns to category admin page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the 990-line dropdown in both the group and subcategory edit dialogs: add form1099BoxId to the frontend group/subcategory DTOs and request interfaces, load boxes via a new getForm1099Boxes() method on ExpenseCategoryApiService (same label pattern as getForm990Lines: "boxCode — name_en / name_zh"), wire form1099BoxId into all open/edit/save paths, and render a side-by-side "1099 Box / 1099 框" Kendo DropdownList with [valuePrimitive]="true" and "— none —" default. Co-Authored-By: Claude Opus 4.8 --- .../features/expense/models/expense.model.ts | 10 +++++---- .../expense-categories-page.component.html | 22 +++++++++++++++++-- .../expense-categories-page.component.ts | 19 +++++++++------- .../services/expense-category-api.service.ts | 6 +++++ 4 files changed, 43 insertions(+), 14 deletions(-) diff --git a/APP/src/app/features/expense/models/expense.model.ts b/APP/src/app/features/expense/models/expense.model.ts index 48cc39d..a0136ed 100644 --- a/APP/src/app/features/expense/models/expense.model.ts +++ b/APP/src/app/features/expense/models/expense.model.ts @@ -8,11 +8,11 @@ export interface PagedResult { export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; } -export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; } -export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; } -export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; } +export interface ExpenseSubCategoryDto { id: number; groupId: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; } +export interface ExpenseCategoryGroupDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; subCategories: ExpenseSubCategoryDto[]; label?: string; form990LineId: number | null; form990LineCode: string | null; form1099BoxId: number | null; form1099BoxCode: string | null; } +export interface CreateExpenseGroupRequest { name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; } export interface UpdateExpenseGroupRequest extends CreateExpenseGroupRequest { isActive: boolean; } -export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; } +export interface CreateExpenseSubCategoryRequest { groupId: number; name_en: string; name_zh: string | null; sortOrder: number; form990LineId: number | null; form1099BoxId: number | null; } export interface UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; } export interface ExpenseLineItemDto { @@ -28,6 +28,7 @@ export interface ExpenseListItemDto { expenseDate: string; hasReceipt: boolean; checkNumber: string | null; reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null; + payeeId: number | null; } export interface ExpenseDto extends ExpenseListItemDto { notes: string | null; @@ -70,6 +71,7 @@ export interface CreateExpenseRequest { type: ExpenseType; ministryId: number; lines: ExpenseLineInput[]; description: string; vendorName: string | null; memberId: number | null; checkNumber: string | null; expenseDate: string; notes: string | null; + payeeId: number | null; } export type UpdateExpenseRequest = CreateExpenseRequest; export interface RejectExpenseRequest { reviewNotes: string | null; } diff --git a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html index 458050b..d89e260 100644 --- a/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html +++ b/APP/src/app/features/expense/pages/expense-categories-page/expense-categories-page.component.html @@ -91,7 +91,7 @@ Sort order - - + +
+ W-9 Document / W-9 文件 + + Upload W-9 / 上傳 W-9 + +
+ +