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);