From d5e1732505e730f5f5966f00a304a144062c9613 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Wed, 24 Jun 2026 19:17:51 -0700 Subject: [PATCH] feat(seed): seed Form 990 line catalog and default subcategory mappings --- .../Services/DbSeederForm990Tests.cs | 20 ++++ API/ROLAC.API/Data/DbSeeder.cs | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs b/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs index 46c4640..6b8932e 100644 --- a/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs +++ b/API/ROLAC.API.Tests/Services/DbSeederForm990Tests.cs @@ -45,4 +45,24 @@ public class DbSeederForm990Tests Assert.Single(groups, g => g.Name_en == "Professional Services"); } + + [Fact] + public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories() + { + using var db = BuildDb(); + await DbSeeder.SeedExpenseCategoriesAsync(db); + await DbSeeder.SeedForm990ExpenseLinesAsync(db); + await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent + + Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7")); + Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24")); + + var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line) + .FirstAsync(s => s.Name_en == "Salary & Wages"); + Assert.Equal("7", salary.Form990Line!.LineCode); + + var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line) + .FirstAsync(s => s.Name_en == "Accounting & Audit"); + Assert.Equal("11c", audit.Form990Line!.LineCode); + } } diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index 73b12f8..316b7f5 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -49,6 +49,74 @@ public static class DbSeeder ("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]), ]; + // (LineCode, Name_en, Name_zh, Sort) + private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed = + [ + ("1", "Grants to domestic organizations", "對國內機構之捐贈", 1), + ("2", "Grants to domestic individuals", "對國內個人之捐贈", 2), + ("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3), + ("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4), + ("7", "Other salaries and wages", "薪資", 5), + ("8", "Pension plan accruals and contributions", "退休金提撥", 6), + ("9", "Other employee benefits", "員工福利", 7), + ("10", "Payroll taxes", "薪資稅", 8), + ("11b", "Legal fees", "法律服務費", 9), + ("11c", "Accounting fees", "會計與審計費", 10), + ("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11), + ("12", "Advertising and promotion", "廣告與推廣", 12), + ("13", "Office expenses", "辦公費用", 13), + ("14", "Information technology", "資訊科技", 14), + ("16", "Occupancy", "場地佔用", 15), + ("17", "Travel", "差旅", 16), + ("19", "Conferences, conventions, and meetings", "會議與研習", 17), + ("20", "Interest", "利息", 18), + ("22", "Depreciation", "折舊", 19), + ("23", "Insurance", "保險", 20), + ("24", "Other expenses", "其他費用", 21), + ]; + + // (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping. + private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed = + [ + ("Personnel", "Officer / Key Employee Compensation", "5"), + ("Personnel", "Salary & Wages", "7"), + ("Personnel", "Payroll Taxes", "10"), + ("Personnel", "Employee Benefits", "9"), + ("Personnel", "Retirement / Pension","8"), + ("Personnel", "Workers Compensation","9"), + ("Personnel", "Honorarium", "11g"), + ("Personnel", "Contract Labor", "11g"), + ("Personnel", "Staff Training", "19"), + ("Facility", "Rent", "16"), + ("Facility", "Utilities", "16"), + ("Facility", "Property Insurance", "23"), + ("Facility", "Decoration", "24"), + ("Training", "Course Fees", "19"), + ("Training", "Conference", "19"), + ("Training", "Books", "24"), + ("Training", "Travel", "17"), + ("Missions", "Travel", "17"), + ("Missions", "Offering Transfer", "1"), + ("Missions", "Missionary Support", "1"), + ("Missions", "Foreign Missions Support", "3"), + ("Benevolence", "Emergency Aid", "2"), + ("Benevolence", "Condolence Gifts", "2"), + ("Benevolence", "Visit Expenses", "2"), + ("Consumables", "Office Supplies", "13"), + ("Printing", "Bulletins", "13"), + ("Printing", "Order of Service", "13"), + ("Printing", "Posters", "12"), + ("Printing", "Advertising & Promotion", "12"), + ("Materials", "Curriculum Printing", "13"), + ("Professional Services", "Legal", "11b"), + ("Professional Services", "Accounting & Audit", "11c"), + ("Professional Services", "Other Professional", "11g"), + ("Information Technology", "Software & Subscriptions", "14"), + ("Information Technology", "Website & Hosting", "14"), + ("Information Technology", "Internet & Telecom", "14"), + ("Finance & Banking", "Interest", "20"), + ]; + private static readonly (string Name, string Description)[] Roles = [ ("super_admin", "System administrator — full access"), @@ -227,6 +295,34 @@ public static class DbSeeder await db.SaveChangesAsync(); } + public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db) + { + foreach (var (code, en, zh, sort) in Form990LineSeed) + { + if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code)) + db.Form990ExpenseLines.Add(new Form990ExpenseLine + { LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true }); + } + await db.SaveChangesAsync(); + + var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id); + var fallbackId = linesByCode["24"]; + + // Every group defaults to line 24 (safety net); precise mapping lives on subcategories. + foreach (var group in await db.ExpenseCategoryGroups.ToListAsync()) + group.Form990LineId ??= fallbackId; + + // Subcategory default mappings — only set when not already mapped (never clobber an admin edit). + var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync(); + foreach (var (groupEn, subEn, code) in Form990SubMappingSeed) + { + var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn); + if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId)) + sub.Form990LineId = lineId; + } + await db.SaveChangesAsync(); + } + public static async Task SeedChurchProfileAsync(AppDbContext db) { // Singleton row used by the disbursement module (issuer info + check counter). @@ -305,6 +401,7 @@ public static class DbSeeder await SeedGivingCategoriesAsync(db); await SeedMinistriesAsync(db); await SeedExpenseCategoriesAsync(db); + await SeedForm990ExpenseLinesAsync(db); await SeedChurchProfileAsync(db); await SeedSiteSettingAsync(db); await SeedNotificationSettingAsync(db, config);