update detail.
ci-cd-vm / ci-cd (push) Successful in 4m27s

This commit is contained in:
Chris Chen
2026-06-25 09:33:49 -07:00
parent 609ce6a439
commit 8bdb942a49
23 changed files with 698 additions and 271 deletions
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.SaveChanges();
var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs);
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor,
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
};
[Fact]
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
Assert.Equal("1 Main St", member.Address);
}
[Fact]
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
{
var (svc, db, _) = Build();
db.Expenses.Add(new Expense
{
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
},
});
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
Assert.Equal("Multiple / 多類別", line.CategoryName);
}
[Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{
@@ -67,14 +67,20 @@ public class ExpenseServiceTests
private static CreateExpenseRequest Reimb() => new()
{
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
Type = "StaffReimbursement", MinistryId = 1,
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
};
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
{
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
Type = r.Type, MinistryId = r.MinistryId,
Lines = r.Lines.Select(l => new ExpenseLineInput
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
}).ToList(),
Description = r.Description,
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
};
@@ -207,7 +213,7 @@ public class ExpenseServiceTests
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb());
edit.Amount = 99.99m;
edit.Lines[0].Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
@@ -260,13 +266,70 @@ public class ExpenseServiceTests
var id = await svc.CreateAsync(new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
FunctionalClass = "ManagementGeneral",
Type = "VendorPayment", MinistryId = 1,
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
}, isFinance: true);
var dto = await svc.GetByIdAsync(id);
Assert.Equal("ManagementGeneral", dto!.FunctionalClass);
Assert.Equal("ManagementGeneral", dto!.Lines.Single().FunctionalClass);
}
[Fact]
public async Task Create_MultiLine_SetsHeaderTotal_AndRoundTripsLines()
{
var (svc, db, _) = Build("u1");
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
await db.SaveChangesAsync();
var r = new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1, VendorName = "Costco",
Description = "Mixed invoice", ExpenseDate = new DateOnly(2026, 5, 1),
Lines =
{
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 12.50m },
},
};
var id = await svc.CreateAsync(r, isFinance: true);
Assert.Equal(42.50m, (await db.Expenses.FindAsync(id))!.Amount);
var dto = await svc.GetByIdAsync(id);
Assert.Equal(2, dto!.Lines.Count);
Assert.Equal(42.50m, dto.Amount);
Assert.Equal(2, dto.LineCount);
}
[Fact]
public async Task Create_WithNoLines_Throws()
{
var (svc, _, _) = Build("u1");
var r = Reimb(); r.Lines.Clear();
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r, isFinance: false));
}
[Fact]
public async Task Update_ReplacesLines_AndRecomputesTotal()
{
var (svc, db, _) = Build("alice");
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Reimb(), isFinance: false);
var edit = CloneToUpdate(Reimb());
edit.Lines = new()
{
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 10m },
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 5m },
};
await svc.UpdateAsync(id, edit, isFinance: false);
Assert.Equal(15m, (await db.Expenses.FindAsync(id))!.Amount);
Assert.Equal(2, await db.ExpenseLines.CountAsync(l => l.ExpenseId == id));
}
[Fact]
@@ -36,9 +36,9 @@ public class Form990ReportServiceTests
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
{
MinistryId = min, CategoryGroupId = 1, SubCategoryId = sub, Type = "VendorPayment",
MinistryId = min, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
FunctionalClass = fc,
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } },
};
[Fact]
@@ -82,4 +82,31 @@ public class Form990ReportServiceTests
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
Assert.Equal(100m, stmt.GrandTotal);
}
[Fact]
public async Task Statement_SplitsOneExpenseAcrossLines()
{
// One invoice with two lines of different categories must land on two different 990 lines.
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(new Expense
{
MinistryId = 2, Type = "VendorPayment", Status = "Paid", Amount = 70m,
Description = "mixed", ExpenseDate = new DateOnly(2026, 5, 10),
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m }, // sub→line 7
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 2, Amount = 20m }, // sub unmapped→group fallback line 24
},
});
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
Assert.Equal(50m, stmt.Rows.Single(r => r.LineCode == "7").Program); // ministry 2 default = Program
Assert.Equal(20m, stmt.Rows.Single(r => r.LineCode == "24").Program);
Assert.Equal(70m, stmt.GrandTotal);
Assert.Equal(1, stmt.UnmappedExpenseCount); // one unmapped line
}
}
@@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
db.Expenses.Add(new Expense { MinistryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 300m } } });
db.Expenses.Add(new Expense { MinistryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 999m } } });
await db.SaveChangesAsync();
var svc = Build(db);
+26 -10
View File
@@ -1,26 +1,35 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
public class ExpenseLineItemDto
{
public int Id { get; set; }
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public string? FunctionalClass { get; set; }
public decimal Amount { get; set; }
public string? Description { get; set; }
}
public class ExpenseListItemDto
{
public int Id { get; set; }
public string Type { get; set; } = "";
public string Status { get; set; } = "";
public decimal Amount { get; set; }
public decimal Amount { get; set; } // header total = sum of line amounts
public string Description { get; set; } = "";
public int MinistryId { get; set; }
public string MinistryName { get; set; } = "";
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public int LineCount { get; set; }
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
public string? VendorName { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; }
public string? CheckNumber { get; set; }
public string? FunctionalClass { get; set; }
}
public class ExpenseDto : ExpenseListItemDto
@@ -31,22 +40,29 @@ public class ExpenseDto : ExpenseListItemDto
public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public DateTimeOffset? PaidAt { get; set; }
public List<ExpenseLineItemDto> Lines { get; set; } = new();
}
public class ExpenseLineInput
{
[Required] public int CategoryGroupId { get; set; }
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
[MaxLength(500)] public string? Description { get; set; }
}
public class CreateExpenseRequest
{
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
[Required] public int MinistryId { get; set; }
[Required] public int CategoryGroupId { get; set; }
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
[Required, MaxLength(500)] public string Description { get; set; } = "";
[MaxLength(200)] public string? VendorName { get; set; }
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
[MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
}
public class UpdateExpenseRequest : CreateExpenseRequest { }
+21 -3
View File
@@ -22,6 +22,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>();
@@ -246,7 +247,6 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.VendorName).HasMaxLength(200);
@@ -266,12 +266,30 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasOne(e => e.Ministry).WithMany()
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
builder.Entity<ExpenseLine>(entity =>
{
// Mirror the parent Expense's soft-delete filter (required relationship).
entity.HasQueryFilter(l => !l.Expense!.IsDeleted);
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.ExpenseId);
entity.HasOne(e => e.Expense).WithMany(x => x.Lines)
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CategoryGroup).WithMany()
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.SubCategory).WithMany()
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
});
// ── ChurchProfile (singleton settings) ───────────────────────────────
+14 -2
View File
@@ -157,6 +157,8 @@ rows AS (
mi."Id" AS ministry_id,
gp."Id" AS group_id,
sc."Id" AS sub_id,
-- pre-allocate the expense id so the matching ExpenseLine can reference it
nextval(pg_get_serial_sequence('"Expenses"','Id')) AS new_id,
sp.is_reimb,
sp.vendor,
sp.descr,
@@ -172,13 +174,14 @@ rows AS (
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
)
, ins_exp AS (
INSERT INTO "Expenses"
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount",
("Id","MinistryId","Type","Status","Amount",
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
SELECT
r.ministry_id, r.group_id, r.sub_id,
r.new_id, r.ministry_id,
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
r.status,
r.amount,
@@ -196,6 +199,15 @@ SELECT
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
FROM rows r
)
-- one line per mock expense (single-category), mirroring the migrated production shape
INSERT INTO "ExpenseLines"
("ExpenseId","CategoryGroupId","SubCategoryId","FunctionalClass","Amount","Description",
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
SELECT
r.new_id, r.group_id, r.sub_id, NULL, r.amount, NULL,
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata'
FROM rows r;
COMMIT;
+4 -8
View File
@@ -5,12 +5,9 @@ public class Expense : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public int MinistryId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
public string Status { get; set; } = "Draft"; // see state machine
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; }
public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
public string Description { get; set; } = null!;
public string? VendorName { get; set; }
public int? MemberId { get; set; }
@@ -26,8 +23,7 @@ public class Expense : SoftDeleteEntity, IAuditable
public DateTimeOffset? PaidAt { get; set; }
public string? PaidBy { get; set; }
public Ministry? Ministry { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { get; set; }
public Member? Member { get; set; }
public Ministry? Ministry { get; set; }
public Member? Member { get; set; }
public List<ExpenseLine> Lines { get; set; } = new();
}
+23
View File
@@ -0,0 +1,23 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// One category line of an <see cref="Expense"/>. A single invoice/payment can span
/// multiple expense categories, so the category / amount / functional-class axis lives
/// here per line; the Expense header keeps payment-level info and a denormalized total.
/// Lines are wholly owned by the header (replaced as a set on update, like CheckLine).
/// </summary>
public class ExpenseLine : AuditableEntity
{
public int Id { get; set; }
public int ExpenseId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; }
public string? Description { get; set; } // optional per-line note (header description is authoritative for check printing)
public Expense? Expense { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { get; set; }
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace ROLAC.API.Entities;
/// <summary>
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass.
/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
/// </summary>
public static class FunctionalClasses
{
@@ -525,9 +525,6 @@ namespace ROLAC.API.Migrations
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<string>("CheckNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -555,10 +552,6 @@ namespace ROLAC.API.Migrations
b.Property<DateOnly>("ExpenseDate")
.HasColumnType("date");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
@@ -600,9 +593,6 @@ namespace ROLAC.API.Migrations
.HasColumnType("character varying(30)")
.HasDefaultValue("Draft");
b.Property<int>("SubCategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("timestamp with time zone");
@@ -629,8 +619,6 @@ namespace ROLAC.API.Migrations
b.HasKey("Id");
b.HasIndex("CategoryGroupId");
b.HasIndex("ExpenseDate");
b.HasIndex("MemberId");
@@ -640,8 +628,6 @@ namespace ROLAC.API.Migrations
b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false");
b.HasIndex("SubCategoryId");
b.ToTable("Expenses");
});
@@ -694,6 +680,61 @@ namespace ROLAC.API.Migrations
b.ToTable("ExpenseCategoryGroups");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("ExpenseId")
.HasColumnType("integer");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("SubCategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("CategoryGroupId");
b.HasIndex("ExpenseId");
b.HasIndex("SubCategoryId");
b.ToTable("ExpenseLines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{
b.Property<int>("Id")
@@ -2007,12 +2048,6 @@ namespace ROLAC.API.Migrations
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
.WithMany()
.HasForeignKey("CategoryGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
@@ -2024,19 +2059,9 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
.WithMany()
.HasForeignKey("SubCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CategoryGroup");
b.Navigation("Member");
b.Navigation("Ministry");
b.Navigation("SubCategory");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
@@ -2049,6 +2074,33 @@ namespace ROLAC.API.Migrations
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.ExpenseSubCategory", b =>
{
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
@@ -2184,6 +2236,11 @@ namespace ROLAC.API.Migrations
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.Navigation("SubCategories");
+14 -1
View File
@@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);
// Category label per expense: the single line's category, or "Multiple" when it spans several.
var expenseIds = rows.Select(r => r.Id).ToList();
var lineGroups = await _db.ExpenseLines.AsNoTracking()
.Where(l => expenseIds.Contains(l.ExpenseId))
.OrderBy(l => l.Id)
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
.ToListAsync();
var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary(
g => g.Key,
g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1
? "Multiple / 多類別"
: grpNames.GetValueOrDefault(g.First().CategoryGroupId, ""));
var groups = new Dictionary<string, PayeeGroupDto>();
foreach (var e in rows)
{
@@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
Description = e.Description, Amount = e.Amount,
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
});
g.TotalAmount += e.Amount;
}
+78 -26
View File
@@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService
{
var query = _db.Expenses.AsNoTracking().AsQueryable();
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value);
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
// Category filters now match against any line of the expense.
if (categoryGroupId.HasValue) query = query.Where(e => e.Lines.Any(l => l.CategoryGroupId == categoryGroupId.Value));
if (subCategoryId.HasValue) query = query.Where(e => e.Lines.Any(l => l.SubCategoryId == subCategoryId.Value));
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
// request the Paid+Approved set in one call.
if (!string.IsNullOrWhiteSpace(statuses))
@@ -81,23 +82,36 @@ public class ExpenseService : IExpenseService
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}");
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
var items = rows.Select(e => new ExpenseListItemDto
// Line count + first line's category, per expense on this page.
var expenseIds = rows.Select(r => r.Id).ToList();
var lineRows = await _db.ExpenseLines.AsNoTracking()
.Where(l => expenseIds.Contains(l.ExpenseId))
.OrderBy(l => l.Id)
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
.ToListAsync();
var linesByExpense = lineRows.GroupBy(l => l.ExpenseId)
.ToDictionary(g => g.Key, g => g.ToList());
var items = rows.Select(e =>
{
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""),
VendorName = e.VendorName, MemberId = e.MemberId,
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber,
FunctionalClass = e.FunctionalClass,
linesByExpense.TryGetValue(e.Id, out var ls);
var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
return new ExpenseListItemDto
{
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
LineCount = ls?.Count ?? 0,
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
VendorName = e.VendorName, MemberId = e.MemberId,
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber,
};
}).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
@@ -108,33 +122,66 @@ public class ExpenseService : IExpenseService
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return null;
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? "";
var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? "";
string? memName = e.MemberId != null
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
: null;
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en);
var lineDtos = lines.Select(l => new ExpenseLineItemDto
{
Id = l.Id, CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
return new ExpenseDto
{
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minName,
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName,
SubCategoryId = e.SubCategoryId, SubCategoryName = subName,
LineCount = lineDtos.Count,
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
FunctionalClass = e.FunctionalClass,
Lines = lineDtos,
};
}
// Lines are the source of truth: ≥1 line, each with a category/subcategory and a positive amount.
private static void ValidateLines(List<ExpenseLineInput> lines)
{
if (lines is null || lines.Count == 0)
throw new InvalidOperationException("An expense must have at least one line.");
foreach (var l in lines)
{
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
throw new InvalidOperationException("Each expense line needs a category group and subcategory.");
if (l.Amount <= 0)
throw new InvalidOperationException("Each expense line amount must be greater than zero.");
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
}
}
private static List<ExpenseLine> BuildLines(List<ExpenseLineInput> inputs) =>
inputs.Select(l => new ExpenseLine
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
{
ValidateLines(r.Lines);
var e = new Expense
{
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId,
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName,
MinistryId = r.MinistryId,
Type = r.Type, Amount = r.Lines.Sum(l => l.Amount), Description = r.Description, VendorName = r.VendorName,
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
FunctionalClass = r.FunctionalClass,
Lines = BuildLines(r.Lines),
};
if (r.Type == "VendorPayment")
@@ -174,16 +221,21 @@ public class ExpenseService : IExpenseService
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
{
ValidateLines(r.Lines);
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id)
var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.FunctionalClass = r.FunctionalClass;
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
// Replace the line set wholesale (lines are owned by the header), recompute the total.
_db.ExpenseLines.RemoveRange(e.Lines);
e.Lines = BuildLines(r.Lines);
e.Amount = r.Lines.Sum(l => l.Amount);
await _db.SaveChangesAsync();
}
@@ -53,17 +53,24 @@ public class FinanceDashboardService : IFinanceDashboardService
DateOnly? from, DateOnly? to, int? ministryId, int? categoryGroupId)
{
var q = PaidApproved(from, to);
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value);
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
// Group by the deepest level whose parent id is supplied.
// Lines belonging to the scoped (Paid+Approved, optionally ministry-filtered) expenses.
var scopedLines = from l in _db.ExpenseLines
join e in q on l.ExpenseId equals e.Id
select l;
// Group by the deepest level whose parent id is supplied. Category levels aggregate
// over LINES (line amounts); the ministry level uses the header total to avoid
// double-counting a multi-line expense across its lines.
List<(int Id, decimal Amount)> grouped;
if (categoryGroupId.HasValue)
grouped = (await q.GroupBy(e => e.SubCategoryId)
grouped = (await scopedLines.Where(l => l.CategoryGroupId == categoryGroupId.Value)
.GroupBy(l => l.SubCategoryId)
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
.Select(x => (x.Id, x.Amount)).ToList();
else if (ministryId.HasValue)
grouped = (await q.GroupBy(e => e.CategoryGroupId)
grouped = (await scopedLines.GroupBy(l => l.CategoryGroupId)
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
.Select(x => (x.Id, x.Amount)).ToList();
else
@@ -8,7 +8,7 @@ namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
/// Single function per expense (direct-charge); no cost splitting.
/// Each expense line is categorized independently, so one invoice can span multiple lines.
/// </summary>
public class Form990ReportService : IForm990ReportService
{
@@ -40,13 +40,14 @@ public class Form990ReportService : IForm990ReportService
var rows = await (
from e in expenses
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
join m in _db.Ministries on e.MinistryId equals m.Id
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new
{
e.Amount,
e.FunctionalClass,
l.Amount,
l.FunctionalClass,
MinistryDefault = m.DefaultFunctionalClass,
SubLineId = sub.Form990LineId,
GroupLineId = grp.Form990LineId,