Compare commits
69 Commits
9f91683633
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b949dff9b | |||
| 773d38d838 | |||
| d987ddea0e | |||
| a4ded78442 | |||
| 831b868d9d | |||
| 771889a99a | |||
| 4d396601f7 | |||
| d29de83116 | |||
| ad276c01f3 | |||
| fb95bf0048 | |||
| d8e6f3ed61 | |||
| 402826ee3d | |||
| 82096e7e6f | |||
| 6ffaaf37ac | |||
| d1747b510e | |||
| bf247726e1 | |||
| 8cb6245560 | |||
| b7eb95056d | |||
| 556abba687 | |||
| 1a8002015a | |||
| 7c63f6c9ba | |||
| 7c5348969b | |||
| 0a9b82544d | |||
| 6080946e74 | |||
| 560fb79bf0 | |||
| 0767a3fe94 | |||
| 0754ed8d69 | |||
| 9aa64b5f4c | |||
| 5e2fbe800c | |||
| 89238bba99 | |||
| 225e64b992 | |||
| 7809ba9741 | |||
| 48ae014def | |||
| 89f02d020b | |||
| 3b76ff43fc | |||
| a0b96b056a | |||
| 93374c3c0a | |||
| 55543af5e1 | |||
| d32eea3523 | |||
| 099303995b | |||
| 44a7dcf089 | |||
| a8f5547c3c | |||
| 41dce076d6 | |||
| 315d85ddcc | |||
| bc827e8b60 | |||
| 8922bb69de | |||
| 4877fec1da | |||
| 73c52ded88 | |||
| f1de8d7ab7 | |||
| 5957d0f45e | |||
| c5405a95c3 | |||
| 5d03e42302 | |||
| d4c20df34f | |||
| 73077295a4 | |||
| c5b1a9372a | |||
| ece2676e38 | |||
| 26259c252d | |||
| 120240ad0c | |||
| ece9938bfb | |||
| a16e21dbfd | |||
| 75905e7036 | |||
| bcaa3e2f25 | |||
| 5448a9ff85 | |||
| bdccb79029 | |||
| a89e936f4d | |||
| fa3e75a333 | |||
| 8bdb942a49 | |||
| 609ce6a439 | |||
| 46a4298a71 |
@@ -22,6 +22,7 @@
|
|||||||
<PackageReference Include="Moq" Version="4.20.72" />
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ChurchAiConfigProviderTests
|
||||||
|
{
|
||||||
|
private static AppDbContext NewDb() =>
|
||||||
|
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_returns_defaults_when_no_profile_row()
|
||||||
|
{
|
||||||
|
using var db = NewDb(); // empty DB, no ChurchProfile
|
||||||
|
|
||||||
|
var cfg = await new ChurchAiConfigProvider(db).GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal("Claude", cfg.Provider);
|
||||||
|
Assert.Equal("claude-haiku-4-5-20251001", cfg.ClaudeModel);
|
||||||
|
Assert.Equal("gemini-2.5-flash-lite", cfg.GeminiModel);
|
||||||
|
Assert.Null(cfg.ClaudeApiKey);
|
||||||
|
Assert.Null(cfg.GeminiApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ChurchProfileServiceTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateChurchProfileRequest Req(
|
||||||
|
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
|
||||||
|
string? claudeModel = "m", string? geminiModel = "m") =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
|
||||||
|
ClaudeModel = claudeModel, GeminiModel = geminiModel,
|
||||||
|
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_masks_stored_api_keys()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile
|
||||||
|
{
|
||||||
|
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var dto = await new ChurchProfileService(db).GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
|
||||||
|
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_blank_key_keeps_existing()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
|
||||||
|
|
||||||
|
var p = await db.ChurchProfiles.FirstAsync();
|
||||||
|
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_nonblank_key_replaces()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
|
||||||
|
|
||||||
|
var p = await db.ChurchProfiles.FirstAsync();
|
||||||
|
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_sets_provider_and_models()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await new ChurchProfileService(db).UpdateAsync(
|
||||||
|
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
|
||||||
|
|
||||||
|
var p = await db.ChurchProfiles.FirstAsync();
|
||||||
|
Assert.Equal("Gemini", p.AiProvider);
|
||||||
|
Assert.Equal("claude-x", p.ClaudeModel);
|
||||||
|
Assert.Equal("gemini-y", p.GeminiModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ public class DbSeederForm990Tests
|
|||||||
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
|
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
|
||||||
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
|
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
|
||||||
Assert.Equal("Program", worship.DefaultFunctionalClass);
|
Assert.Equal("Program", worship.DefaultFunctionalClass);
|
||||||
|
|
||||||
|
// Activity/shepherding ministries are an attribution axis only; they default to Program
|
||||||
|
// so adding them never distorts the 990 functional columns.
|
||||||
|
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
|
||||||
|
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
|
||||||
|
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
|
||||||
|
Assert.Equal("Program", specialEvents.DefaultFunctionalClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -77,4 +84,59 @@ public class DbSeederForm990Tests
|
|||||||
.FirstAsync(s => s.Name_en == "Accounting & Audit");
|
.FirstAsync(s => s.Name_en == "Accounting & Audit");
|
||||||
Assert.Equal("11c", audit.Form990Line!.LineCode);
|
Assert.Equal("11c", audit.Form990Line!.LineCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
async Task<string> CodeOf(string subEn) =>
|
||||||
|
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||||
|
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
|
||||||
|
|
||||||
|
// Newly mapped subcategories that previously fell through to line 24.
|
||||||
|
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
|
||||||
|
Assert.Equal("13", await CodeOf("Rental"));
|
||||||
|
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
|
||||||
|
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
|
||||||
|
Assert.Equal("13", await CodeOf("Craft Supplies"));
|
||||||
|
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
|
||||||
|
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
|
||||||
|
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
|
||||||
|
Assert.Equal("24", await CodeOf("Gifts"));
|
||||||
|
// Visitation is a travel/program cost, not a grant to an individual.
|
||||||
|
Assert.Equal("17", await CodeOf("Visit Expenses"));
|
||||||
|
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
|
||||||
|
Assert.Equal("2", await CodeOf("Missionary Support"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
|
||||||
|
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
|
||||||
|
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
|
||||||
|
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
|
||||||
|
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
|
||||||
|
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
|
||||||
|
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
|
||||||
|
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
|
||||||
|
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||||
|
|
||||||
|
await db.Entry(visit).ReloadAsync();
|
||||||
|
await db.Entry(missionary).ReloadAsync();
|
||||||
|
await db.Entry(transfer).ReloadAsync();
|
||||||
|
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
|
||||||
|
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
|
||||||
|
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
|
|||||||
var db = BuildDb(userId);
|
var db = BuildDb(userId);
|
||||||
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
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.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();
|
db.SaveChanges();
|
||||||
var fs = new FakeStorage();
|
var fs = new FakeStorage();
|
||||||
return (SvcAs(db, fs, userId), db, fs);
|
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()
|
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
||||||
{
|
{
|
||||||
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
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,
|
MemberId = memberId, VendorName = vendor,
|
||||||
|
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
|
||||||
};
|
};
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
|
|||||||
Assert.Equal("1 Main St", member.Address);
|
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]
|
[Fact]
|
||||||
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseAiServiceFactoryTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseAiServiceFactory Build(AppDbContext db)
|
||||||
|
{
|
||||||
|
var cfg = new ChurchAiConfigProvider(db);
|
||||||
|
var claude = new ClaudeExpenseAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
|
||||||
|
var gemini = new GeminiExpenseAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
|
||||||
|
return new ExpenseAiServiceFactory(cfg, claude, gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Claude_by_default()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<ClaudeExpenseAiService>(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Gemini_when_selected()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<GeminiExpenseAiService>(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseCategoryAiServiceFactoryTests
|
||||||
|
{
|
||||||
|
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
|
||||||
|
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
|
||||||
|
// interceptor the app uses so seeded entities save cleanly.
|
||||||
|
private static AppDbContext NewDb()
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
|
||||||
|
};
|
||||||
|
var httpContextAccessor = new Mock<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseCategoryAiServiceFactory Build(AppDbContext db)
|
||||||
|
{
|
||||||
|
var cfg = new ChurchAiConfigProvider(db);
|
||||||
|
var claude = new ClaudeExpenseCategoryAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseCategoryAiService>.Instance);
|
||||||
|
var gemini = new GeminiExpenseCategoryAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<GeminiExpenseCategoryAiService>.Instance);
|
||||||
|
return new ExpenseCategoryAiServiceFactory(cfg, claude, gemini);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Claude_by_default()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<ClaudeExpenseCategoryAiService>(svc);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_Gemini_when_selected()
|
||||||
|
{
|
||||||
|
using var db = NewDb();
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = await Build(db).ResolveAsync();
|
||||||
|
|
||||||
|
Assert.IsType<GeminiExpenseCategoryAiService>(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
|
|||||||
using ROLAC.API.Data.Interceptors;
|
using ROLAC.API.Data.Interceptors;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
using ROLAC.API.Services.Storage;
|
using ROLAC.API.Services.Storage;
|
||||||
|
using ROLAC.API.Tests.TestSupport;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace ROLAC.API.Tests.Services;
|
namespace ROLAC.API.Tests.Services;
|
||||||
@@ -55,6 +58,14 @@ public class ExpenseServiceTests
|
|||||||
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
|
||||||
|
{
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new ExpenseService(db, http.Object, fs, audit);
|
||||||
|
}
|
||||||
|
|
||||||
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
|
||||||
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
||||||
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
||||||
@@ -67,14 +78,20 @@ public class ExpenseServiceTests
|
|||||||
|
|
||||||
private static CreateExpenseRequest Reimb() => new()
|
private static CreateExpenseRequest Reimb() => new()
|
||||||
{
|
{
|
||||||
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
|
Type = "StaffReimbursement", MinistryId = 1,
|
||||||
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
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()
|
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
||||||
{
|
{
|
||||||
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
|
Type = r.Type, MinistryId = r.MinistryId,
|
||||||
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
|
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,
|
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
||||||
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||||
};
|
};
|
||||||
@@ -207,7 +224,7 @@ public class ExpenseServiceTests
|
|||||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||||
|
|
||||||
var edit = CloneToUpdate(Reimb());
|
var edit = CloneToUpdate(Reimb());
|
||||||
edit.Amount = 99.99m;
|
edit.Lines[0].Amount = 99.99m;
|
||||||
await svc.UpdateAsync(id, edit, isFinance: false);
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
var e = await db.Expenses.FindAsync(id);
|
var e = await db.Expenses.FindAsync(id);
|
||||||
@@ -260,13 +277,70 @@ public class ExpenseServiceTests
|
|||||||
|
|
||||||
var id = await svc.CreateAsync(new CreateExpenseRequest
|
var id = await svc.CreateAsync(new CreateExpenseRequest
|
||||||
{
|
{
|
||||||
Type = "VendorPayment", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
|
Type = "VendorPayment", MinistryId = 1,
|
||||||
Amount = 50m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
|
||||||
FunctionalClass = "ManagementGeneral",
|
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
|
||||||
}, isFinance: true);
|
}, isFinance: true);
|
||||||
|
|
||||||
var dto = await svc.GetByIdAsync(id);
|
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]
|
[Fact]
|
||||||
@@ -279,4 +353,93 @@ public class ExpenseServiceTests
|
|||||||
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||||
Assert.NotNull(got);
|
Assert.NotNull(got);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_WritesAuditEntry_WithReason()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
|
||||||
|
var audit = new CapturingAuditLogger();
|
||||||
|
await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake");
|
||||||
|
|
||||||
|
var entry = Assert.Single(audit.Entries);
|
||||||
|
Assert.Equal(AuditActions.ExpenseRejected, entry.Action);
|
||||||
|
Assert.Equal(AuditCategories.Business, entry.Category);
|
||||||
|
Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName);
|
||||||
|
Assert.Equal(id.ToString(), entry.EntityId);
|
||||||
|
Assert.Contains("Receipt unclear", entry.Summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing");
|
||||||
|
|
||||||
|
// Owner fixes the issue and re-submits.
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("PendingApproval", e!.Status);
|
||||||
|
Assert.Null(e.ReviewedBy);
|
||||||
|
Assert.Null(e.ReviewedAt);
|
||||||
|
Assert.Null(e.ReviewNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_OwnRejected_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
// A rejected reimbursement can be corrected by its owner before re-submitting.
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt");
|
||||||
|
|
||||||
|
var edit = CloneToUpdate(Reimb());
|
||||||
|
edit.Lines[0].Amount = 77.77m;
|
||||||
|
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal(77.77m, e!.Amount);
|
||||||
|
Assert.Equal("Rejected", e.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake");
|
||||||
|
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||||
|
await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false);
|
||||||
|
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build("alice");
|
||||||
|
// Reviewer linked to a member → shows the member's full name.
|
||||||
|
db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" });
|
||||||
|
db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 });
|
||||||
|
// Reviewer with no member → falls back to email.
|
||||||
|
db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var withMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(withMember);
|
||||||
|
await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember);
|
||||||
|
Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName);
|
||||||
|
|
||||||
|
var noMember = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(noMember);
|
||||||
|
await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission");
|
||||||
|
Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb(string userId)
|
||||||
|
{
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(mock.Object))).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1")
|
||||||
|
{
|
||||||
|
var db = BuildDb(userId);
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
|
||||||
|
db.SaveChanges();
|
||||||
|
|
||||||
|
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return (new ExpenseSnapshotService(db, http.Object), db);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateExpenseSnapshotRequest Rent() => new()
|
||||||
|
{
|
||||||
|
Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X",
|
||||||
|
CheckNumber = "1001",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } },
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_PersistsHeaderAndLines_StampsCreator()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("creator-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
var saved = await db.ExpenseSnapshots.FindAsync(id);
|
||||||
|
Assert.Equal("Monthly Rent", saved!.Name);
|
||||||
|
Assert.Equal("creator-1", saved.CreatedBy);
|
||||||
|
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithNoLines_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent(); r.Lines.Clear();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WithInvalidFunctionalClass_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var r = Rent();
|
||||||
|
r.Lines[0].FunctionalClass = "NotAClass";
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("creator-1");
|
||||||
|
db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" });
|
||||||
|
db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.Equal(1200m, dto!.TotalAmount);
|
||||||
|
Assert.Equal(1, dto.LineCount);
|
||||||
|
Assert.Equal("Rent", dto.Lines.Single().SubCategoryName);
|
||||||
|
Assert.Equal("Joy Wong", dto.CreatedByName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAll_ReturnsNewestFirst()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var first = await svc.CreateAsync(Rent());
|
||||||
|
var second = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
var all = await svc.GetAllAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, all.Count);
|
||||||
|
Assert.Equal(second, all[0].Id);
|
||||||
|
Assert.Equal(first, all[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_RenamesAndReplacesLines()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
Name = "Monthly Internet", MinistryId = 1, Description = "ISP",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } },
|
||||||
|
});
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal("Monthly Internet", dto!.Name);
|
||||||
|
Assert.Equal(80m, dto.TotalAmount);
|
||||||
|
Assert.Equal("Internet", dto.Lines.Single().SubCategoryName);
|
||||||
|
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_MissingId_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
Name = "x", MinistryId = 1, Description = "x",
|
||||||
|
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_SoftDeletes_HidesFromQueries()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build();
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
|
||||||
|
Assert.Empty(await svc.GetAllAsync());
|
||||||
|
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_StampsDeletedBy()
|
||||||
|
{
|
||||||
|
var (svc, db) = Build("deleter-1");
|
||||||
|
var id = await svc.CreateAsync(Rent());
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id);
|
||||||
|
Assert.Equal("deleter-1", row.DeletedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
|
||||||
|
private sealed class StubReportService : IForm1099ReportService
|
||||||
|
{
|
||||||
|
private readonly Form1099SummaryDto _summary;
|
||||||
|
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
|
||||||
|
|
||||||
|
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
|
||||||
|
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
|
||||||
|
public Task<Form1099RecipientDetailDto?> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IHttpContextAccessor>();
|
||||||
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,9 +36,9 @@ public class Form990ReportServiceTests
|
|||||||
|
|
||||||
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
|
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),
|
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]
|
[Fact]
|
||||||
@@ -82,4 +82,31 @@ public class Form990ReportServiceTests
|
|||||||
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
|
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
|
||||||
Assert.Equal(100m, stmt.GrandTotal);
|
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.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 = 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.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, 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, 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 = "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();
|
await db.SaveChangesAsync();
|
||||||
var svc = Build(db);
|
var svc = Build(db);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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 ROLAC.API.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class Payee1099ServiceTests
|
||||||
|
{
|
||||||
|
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
|
||||||
|
private sealed class FakeStorage : IFileStorage
|
||||||
|
{
|
||||||
|
public Dictionary<string, byte[]> Files = new();
|
||||||
|
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
|
||||||
|
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
|
||||||
|
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
|
||||||
|
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
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<IHttpContextAccessor>();
|
||||||
|
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
|
||||||
|
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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, new FakeStorage()), 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveW9_records_document_and_round_trips_bytes()
|
||||||
|
{
|
||||||
|
var (svc, _) = Build();
|
||||||
|
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
|
||||||
|
|
||||||
|
var bytes = new byte[] { 1, 2, 3, 4, 5 };
|
||||||
|
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.True(dto!.HasW9Document);
|
||||||
|
|
||||||
|
var opened = await svc.OpenW9Async(id);
|
||||||
|
Assert.NotNull(opened);
|
||||||
|
Assert.Equal("application/pdf", opened!.Value.contentType);
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await opened.Value.stream.CopyToAsync(ms);
|
||||||
|
Assert.Equal(bytes, ms.ToArray());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ROLAC.API.Entities.Logging;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.TestSupport;
|
||||||
|
|
||||||
|
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
|
||||||
|
public sealed class CapturingAuditLogger : IAuditLogger
|
||||||
|
{
|
||||||
|
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
|
||||||
|
|
||||||
|
public readonly List<Entry> Entries = new();
|
||||||
|
|
||||||
|
public void Write(
|
||||||
|
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
|
||||||
|
string? entityName = null, string? entityId = null, string? summary = null,
|
||||||
|
object? before = null, object? after = null,
|
||||||
|
string? userId = null, string? userEmail = null, string? ipAddress = null)
|
||||||
|
{
|
||||||
|
Entries.Add(new Entry(action, category, entityName, entityId, summary));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ public static class Modules
|
|||||||
public const string Ministries = "Ministries";
|
public const string Ministries = "Ministries";
|
||||||
public const string FinanceDashboard = "FinanceDashboard";
|
public const string FinanceDashboard = "FinanceDashboard";
|
||||||
public const string Form990Report = "Form990Report";
|
public const string Form990Report = "Form990Report";
|
||||||
|
public const string Form1099 = "Form1099";
|
||||||
public const string MonthlyStatements = "MonthlyStatements";
|
public const string MonthlyStatements = "MonthlyStatements";
|
||||||
public const string ChurchProfile = "ChurchProfile";
|
public const string ChurchProfile = "ChurchProfile";
|
||||||
public const string Disbursements = "Disbursements";
|
public const string Disbursements = "Disbursements";
|
||||||
@@ -39,6 +40,7 @@ public static class Modules
|
|||||||
Ministries,
|
Ministries,
|
||||||
FinanceDashboard,
|
FinanceDashboard,
|
||||||
Form990Report,
|
Form990Report,
|
||||||
|
Form1099,
|
||||||
MonthlyStatements,
|
MonthlyStatements,
|
||||||
ChurchProfile,
|
ChurchProfile,
|
||||||
Disbursements,
|
Disbursements,
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-ai")]
|
||||||
|
[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any
|
||||||
|
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
|
||||||
|
public class ExpenseAiController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseAiServiceFactory _factory;
|
||||||
|
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
|
||||||
|
|
||||||
|
[HttpPost("assist")]
|
||||||
|
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Text))
|
||||||
|
return BadRequest("Text is required.");
|
||||||
|
|
||||||
|
var svc = await _factory.ResolveAsync(ct);
|
||||||
|
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
|
||||||
|
return Ok(suggestion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using ROLAC.API.Authorization;
|
using ROLAC.API.Authorization;
|
||||||
using ROLAC.API.DTOs.Expense;
|
using ROLAC.API.DTOs.Expense;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
namespace ROLAC.API.Controllers;
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
@@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers;
|
|||||||
public class ExpenseCategoriesController : ControllerBase
|
public class ExpenseCategoriesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseCategoryService _svc;
|
private readonly IExpenseCategoryService _svc;
|
||||||
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
|
private readonly IExpenseCategoryAiServiceFactory _aiFactory;
|
||||||
|
public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_aiFactory = aiFactory;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
// Suggest an English name + Form 990 line for a category being defined. Write-gated: category
|
||||||
|
// editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint.
|
||||||
|
[HttpPost("ai-suggest")]
|
||||||
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en))
|
||||||
|
return BadRequest("A name is required.");
|
||||||
|
|
||||||
|
var svc = await _aiFactory.ResolveAsync(ct);
|
||||||
|
return Ok(await svc.SuggestAsync(r, ct));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("groups")]
|
[HttpPost("groups")]
|
||||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||||
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires
|
||||||
|
// Expenses:Write (super_admin bypasses), matching who can create vendor payments.
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-snapshots")]
|
||||||
|
[Authorize]
|
||||||
|
public class ExpenseSnapshotsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseSnapshotService _svc;
|
||||||
|
private readonly IPermissionService _perms;
|
||||||
|
public ExpenseSnapshotsController(IExpenseSnapshotService svc, IPermissionService perms)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_perms = perms;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
|
||||||
|
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
|
||||||
|
private async Task<bool> CanManageAsync() =>
|
||||||
|
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
return Ok(await _svc.GetAllAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
if (!await CanManageAsync()) return Forbid();
|
||||||
|
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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;
|
||||||
|
private readonly I1099FormService _form;
|
||||||
|
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
|
||||||
|
{
|
||||||
|
_svc = svc;
|
||||||
|
_form = form;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("boxes")]
|
||||||
|
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
|
||||||
|
|
||||||
|
[HttpGet("summary")]
|
||||||
|
public async Task<IActionResult> Summary([FromQuery] int taxYear)
|
||||||
|
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
|
||||||
|
|
||||||
|
[HttpGet("recipient/{payeeId:int}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> ExportCsv([FromQuery] int taxYear)
|
||||||
|
{
|
||||||
|
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
|
||||||
|
return File(stream, contentType, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
|
||||||
|
=> Ok(new { id = await _svc.CreateAsync(r) });
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
|
||||||
|
{ await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> RevealTin(int id)
|
||||||
|
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
|
||||||
|
|
||||||
|
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
|
||||||
|
[HttpPost("{id:int}/w9")]
|
||||||
|
[HasPermission(Modules.Form1099, PermissionActions.Write)]
|
||||||
|
[RequestSizeLimit(10_485_760)]
|
||||||
|
public async Task<IActionResult> UploadW9(int id, IFormFile file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||||
|
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
|
||||||
|
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
await _svc.SaveW9Async(id, stream, file.FileName);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
|
||||||
|
[HttpGet("{id:int}/w9")]
|
||||||
|
public async Task<IActionResult> GetW9(int id)
|
||||||
|
{
|
||||||
|
var result = await _svc.OpenW9Async(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,13 @@ public class ChurchProfileDto
|
|||||||
public string? BankName { get; set; }
|
public string? BankName { get; set; }
|
||||||
public string? BankAccountNumber { get; set; }
|
public string? BankAccountNumber { get; set; }
|
||||||
public string? BankRoutingNumber { get; set; }
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
public string? PayerEin { get; set; }
|
||||||
public int NextCheckNumber { get; set; }
|
public int NextCheckNumber { get; set; }
|
||||||
|
public string AiProvider { get; set; } = "Claude";
|
||||||
|
public string? ClaudeModel { get; set; }
|
||||||
|
public string? ClaudeApiKeyMasked { get; set; }
|
||||||
|
public string? GeminiModel { get; set; }
|
||||||
|
public string? GeminiApiKeyMasked { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateChurchProfileRequest
|
public class UpdateChurchProfileRequest
|
||||||
@@ -33,5 +39,11 @@ public class UpdateChurchProfileRequest
|
|||||||
[MaxLength(200)] public string? BankName { get; set; }
|
[MaxLength(200)] public string? BankName { get; set; }
|
||||||
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||||
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||||
|
[MaxLength(20)] public string? PayerEin { get; set; }
|
||||||
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
||||||
|
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
|
||||||
|
[MaxLength(100)] public string? ClaudeModel { get; set; }
|
||||||
|
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
|
||||||
|
[MaxLength(100)] public string? GeminiModel { get; set; }
|
||||||
|
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
/// <summary>Request body for the expense AI assist endpoint.</summary>
|
||||||
|
public class ExpenseAiAssistRequest
|
||||||
|
{
|
||||||
|
/// <summary>The user's free-text expense description (typically Chinese).</summary>
|
||||||
|
[Required] public string Text { get; set; } = "";
|
||||||
|
/// <summary>The expense amount, used as a hint when classifying the category.</summary>
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI suggestion for an expense: an English translation of the description plus a proposed
|
||||||
|
/// major category (大項) and sub-category (系項). Category ids are null when the model could
|
||||||
|
/// not confidently classify or returned an id outside the live catalog.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseAiSuggestion
|
||||||
|
{
|
||||||
|
public string? EnglishDescription { get; set; }
|
||||||
|
/// <summary>Typo-corrected, refined Traditional Chinese description.</summary>
|
||||||
|
public string? ChineseDescription { get; set; }
|
||||||
|
public int? GroupId { get; set; }
|
||||||
|
public int? SubCategoryId { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested group, e.g. "Consumables / 消耗品".</summary>
|
||||||
|
public string? GroupLabel { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested sub-category, e.g. "Batteries / 電池".</summary>
|
||||||
|
public string? SubLabel { get; set; }
|
||||||
|
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the expense-category AI assist endpoint: refine the name, translate to English,
|
||||||
|
/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited.
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseCategoryAiRequest
|
||||||
|
{
|
||||||
|
/// <summary>The user-typed Chinese name (the primary input).</summary>
|
||||||
|
public string Name_zh { get; set; } = "";
|
||||||
|
/// <summary>The English name, if already typed (extra context for the model).</summary>
|
||||||
|
public string? Name_en { get; set; }
|
||||||
|
/// <summary>"group" (大項) or "sub" (小項); selects the prompt framing.</summary>
|
||||||
|
public string Level { get; set; } = "group";
|
||||||
|
/// <summary>For a sub-category: the parent group's bilingual name, used for context.</summary>
|
||||||
|
public string? ParentGroupName { get; set; }
|
||||||
|
/// <summary>For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice.</summary>
|
||||||
|
public int? ParentForm990LineId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a
|
||||||
|
/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog.
|
||||||
|
/// </summary>
|
||||||
|
public class CategoryAiSuggestion
|
||||||
|
{
|
||||||
|
/// <summary>Typo-corrected, refined Traditional Chinese name.</summary>
|
||||||
|
public string? ChineseName { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
/// <summary>Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地".</summary>
|
||||||
|
public string? Form990LineLabel { get; set; }
|
||||||
|
/// <summary>Model self-reported confidence in the mapping, 0..1.</summary>
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ public class ExpenseSubCategoryDto
|
|||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public string? Form990LineCode { get; set; }
|
public string? Form990LineCode { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
|
public string? Form1099BoxCode { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExpenseCategoryGroupDto
|
public class ExpenseCategoryGroupDto
|
||||||
@@ -22,6 +24,8 @@ public class ExpenseCategoryGroupDto
|
|||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public string? Form990LineCode { get; set; }
|
public string? Form990LineCode { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
|
public string? Form1099BoxCode { get; set; }
|
||||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ public class CreateExpenseGroupRequest
|
|||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||||
{
|
{
|
||||||
@@ -44,6 +49,7 @@ public class CreateExpenseSubCategoryRequest
|
|||||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
|
public int? Form1099BoxId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,52 +1,73 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
namespace ROLAC.API.DTOs.Expense;
|
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 class ExpenseListItemDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Type { get; set; } = "";
|
public string Type { get; set; } = "";
|
||||||
public string Status { 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 string Description { get; set; } = "";
|
||||||
public int MinistryId { get; set; }
|
public int MinistryId { get; set; }
|
||||||
public string MinistryName { get; set; } = "";
|
public string MinistryName { get; set; } = "";
|
||||||
public int CategoryGroupId { get; set; }
|
public int LineCount { get; set; }
|
||||||
public string CategoryGroupName { get; set; } = "";
|
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
|
||||||
public int SubCategoryId { get; set; }
|
|
||||||
public string SubCategoryName { get; set; } = "";
|
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
public string? MemberName { get; set; }
|
public string? MemberName { get; set; } // legal name "FirstName_en LastName_en" (used on the printed check)
|
||||||
|
public string? MemberNickName { get; set; } // "NickName LastName_en"; null when the member has no distinct nickname
|
||||||
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
public bool HasReceipt { get; set; }
|
public bool HasReceipt { get; set; }
|
||||||
public string? CheckNumber { get; set; }
|
public string? CheckNumber { get; set; }
|
||||||
public string? FunctionalClass { get; set; }
|
// Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date".
|
||||||
|
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
|
public class ExpenseDto : ExpenseListItemDto
|
||||||
{
|
{
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? ReviewNotes { get; set; }
|
|
||||||
public string? SubmittedBy { get; set; }
|
public string? SubmittedBy { get; set; }
|
||||||
public DateTimeOffset? SubmittedAt { get; set; }
|
public DateTimeOffset? SubmittedAt { get; set; }
|
||||||
public DateTimeOffset? ReviewedAt { get; set; }
|
|
||||||
public DateTimeOffset? PaidAt { 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
|
public class CreateExpenseRequest
|
||||||
{
|
{
|
||||||
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
||||||
[Required] public int MinistryId { get; set; }
|
[Required] public int MinistryId { get; set; }
|
||||||
[Required] public int CategoryGroupId { get; set; }
|
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||||
[Required] public int SubCategoryId { get; set; }
|
|
||||||
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
|
||||||
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||||
[MaxLength(200)] public string? VendorName { get; set; }
|
[MaxLength(200)] public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
||||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
[Required] public DateOnly ExpenseDate { get; set; }
|
[Required] public DateOnly ExpenseDate { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
[MaxLength(20)] public string? FunctionalClass { get; set; }
|
public int? PayeeId { get; set; }
|
||||||
}
|
}
|
||||||
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
public class UpdateExpenseRequest : CreateExpenseRequest { }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotLineDto
|
||||||
|
{
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public string CategoryGroupName { get; set; } = "";
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string SubCategoryName { get; set; } = "";
|
||||||
|
public string? FunctionalClass { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public int MinistryId { get; set; }
|
||||||
|
public string MinistryName { get; set; } = "";
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; } // sum of line amounts (list hint)
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public string? CreatedByName { get; set; } // resolved Member full name, email fallback
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public List<ExpenseSnapshotLineDto> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateExpenseSnapshotRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(150)] public string Name { get; set; } = "";
|
||||||
|
[Required] public int MinistryId { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||||
|
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? VendorName { get; set; }
|
||||||
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
public class UpdateExpenseSnapshotRequest : CreateExpenseSnapshotRequest { }
|
||||||
@@ -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<Form1099RecipientRowDto> 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<Form1099PaymentDto> Payments { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
/// <summary>Plain TIN; null = leave unchanged on update. Encrypted server-side.</summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -21,7 +21,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||||
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
|
||||||
|
public DbSet<Payee1099> Payee1099s => Set<Payee1099>();
|
||||||
|
public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
|
||||||
public DbSet<Expense> Expenses => Set<Expense>();
|
public DbSet<Expense> Expenses => Set<Expense>();
|
||||||
|
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||||
|
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
|
||||||
|
public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
|
||||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||||
public DbSet<Check> Checks => Set<Check>();
|
public DbSet<Check> Checks => Set<Check>();
|
||||||
@@ -215,6 +220,45 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.HasIndex(e => e.LineCode).IsUnique();
|
entity.HasIndex(e => e.LineCode).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Form1099Box (1099 reporting box catalog) ──────────────────────────
|
||||||
|
builder.Entity<Form1099Box>(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.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => e.BoxCode).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Payee1099 (1099 recipient master) ────────────────────────────────
|
||||||
|
builder.Entity<Payee1099>(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.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);
|
||||||
|
});
|
||||||
|
|
||||||
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||||
builder.Entity<ExpenseCategoryGroup>(entity =>
|
builder.Entity<ExpenseCategoryGroup>(entity =>
|
||||||
{
|
{
|
||||||
@@ -224,6 +268,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
entity.HasOne(e => e.Form990Line).WithMany()
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||||
@@ -237,6 +283,8 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.Form990Line).WithMany()
|
entity.HasOne(e => e.Form990Line).WithMany()
|
||||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||||
|
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Expense ──────────────────────────────────────────────────────────
|
// ── Expense ──────────────────────────────────────────────────────────
|
||||||
@@ -246,7 +294,6 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
|
|
||||||
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
|
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
|
||||||
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
|
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.Amount).HasColumnType("decimal(18,2)");
|
||||||
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
||||||
entity.Property(e => e.VendorName).HasMaxLength(200);
|
entity.Property(e => e.VendorName).HasMaxLength(200);
|
||||||
@@ -266,12 +313,73 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
|
|
||||||
entity.HasOne(e => e.Ministry).WithMany()
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
.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) ──────────────────
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSnapshot (reusable vendor-payment template) ───────────────
|
||||||
|
builder.Entity<ExpenseSnapshot>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(s => !s.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.Name).HasMaxLength(150).IsRequired();
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
||||||
|
entity.Property(e => e.VendorName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CheckNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.CreatedAt);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSnapshotLine (category breakdown of one snapshot) ─────────
|
||||||
|
builder.Entity<ExpenseSnapshotLine>(entity =>
|
||||||
|
{
|
||||||
|
// Mirror the parent snapshot's soft-delete filter (required relationship).
|
||||||
|
entity.HasQueryFilter(l => !l.Snapshot!.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.SnapshotId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Snapshot).WithMany(x => x.Lines)
|
||||||
|
.HasForeignKey(e => e.SnapshotId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(e => e.CategoryGroup).WithMany()
|
entity.HasOne(e => e.CategoryGroup).WithMany()
|
||||||
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.SubCategory).WithMany()
|
entity.HasOne(e => e.SubCategory).WithMany()
|
||||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
entity.HasOne(e => e.Member).WithMany()
|
|
||||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||||
@@ -285,12 +393,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.PayerEin).HasMaxLength(20);
|
||||||
entity.Property(e => e.NameZh).HasMaxLength(200);
|
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||||
entity.Property(e => e.Phone).HasMaxLength(50);
|
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||||
entity.Property(e => e.Email).HasMaxLength(200);
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
entity.Property(e => e.Website).HasMaxLength(300);
|
entity.Property(e => e.Website).HasMaxLength(300);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
|
||||||
|
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
|
||||||
|
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
|
||||||
|
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
|
||||||
// Optimistic-concurrency token for safe check-number allocation.
|
// Optimistic-concurrency token for safe check-number allocation.
|
||||||
entity.Property(e => e.xmin).IsRowVersion();
|
entity.Property(e => e.xmin).IsRowVersion();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public static class DbSeeder
|
|||||||
("Hospitality", "招待", 8),
|
("Hospitality", "招待", 8),
|
||||||
("Children", "兒牧", 9),
|
("Children", "兒牧", 9),
|
||||||
("Catering", "餐飲", 10),
|
("Catering", "餐飲", 10),
|
||||||
|
("Cell Groups", "小組牧養", 11),
|
||||||
|
("Special Events", "特別活動", 12),
|
||||||
];
|
];
|
||||||
|
|
||||||
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
||||||
@@ -38,11 +40,11 @@ public static class DbSeeder
|
|||||||
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
|
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
|
||||||
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
||||||
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
||||||
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]),
|
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
|
||||||
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
|
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
|
||||||
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
|
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
|
||||||
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
||||||
("Other", "其他", 10, [("Miscellaneous","雜支")]),
|
("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
|
||||||
("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
||||||
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
|
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
|
||||||
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
|
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
|
||||||
@@ -91,23 +93,36 @@ public static class DbSeeder
|
|||||||
("Facility", "Utilities", "16"),
|
("Facility", "Utilities", "16"),
|
||||||
("Facility", "Property Insurance", "23"),
|
("Facility", "Property Insurance", "23"),
|
||||||
("Facility", "Decoration", "24"),
|
("Facility", "Decoration", "24"),
|
||||||
|
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
|
||||||
|
("Facility", "Repairs & Maintenance", "16"),
|
||||||
("Training", "Course Fees", "19"),
|
("Training", "Course Fees", "19"),
|
||||||
("Training", "Conference", "19"),
|
("Training", "Conference", "19"),
|
||||||
("Training", "Books", "24"),
|
("Training", "Books", "24"),
|
||||||
("Training", "Travel", "17"),
|
("Training", "Travel", "17"),
|
||||||
("Missions", "Travel", "17"),
|
("Missions", "Travel", "17"),
|
||||||
("Missions", "Offering Transfer", "1"),
|
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
|
||||||
("Missions", "Missionary Support", "1"),
|
("Missions", "Offering Transfer", "2"),
|
||||||
|
("Missions", "Missionary Support", "2"),
|
||||||
("Missions", "Foreign Missions Support", "3"),
|
("Missions", "Foreign Missions Support", "3"),
|
||||||
("Benevolence", "Emergency Aid", "2"),
|
("Benevolence", "Emergency Aid", "2"),
|
||||||
("Benevolence", "Condolence Gifts", "2"),
|
("Benevolence", "Condolence Gifts", "2"),
|
||||||
("Benevolence", "Visit Expenses", "2"),
|
// Visitation is the church's own travel/program cost, not a grant to an individual.
|
||||||
|
("Benevolence", "Visit Expenses", "17"),
|
||||||
("Consumables", "Office Supplies", "13"),
|
("Consumables", "Office Supplies", "13"),
|
||||||
|
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
|
||||||
|
("Consumables", "Batteries", "13"),
|
||||||
|
("Consumables", "Accessories", "13"),
|
||||||
|
("Consumables", "Cleaning Supplies", "13"),
|
||||||
|
// IRS line 13 covers equipment rental and maintenance.
|
||||||
|
("Equipment", "Rental", "13"),
|
||||||
|
("Equipment", "Maintenance & Repair", "13"),
|
||||||
("Printing", "Bulletins", "13"),
|
("Printing", "Bulletins", "13"),
|
||||||
("Printing", "Order of Service", "13"),
|
("Printing", "Order of Service", "13"),
|
||||||
("Printing", "Posters", "12"),
|
("Printing", "Posters", "12"),
|
||||||
("Printing", "Advertising & Promotion", "12"),
|
("Printing", "Advertising & Promotion", "12"),
|
||||||
("Materials", "Curriculum Printing", "13"),
|
("Materials", "Curriculum Printing", "13"),
|
||||||
|
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
|
||||||
|
("Materials", "Craft Supplies", "13"),
|
||||||
("Professional Services", "Legal", "11b"),
|
("Professional Services", "Legal", "11b"),
|
||||||
("Professional Services", "Accounting & Audit", "11c"),
|
("Professional Services", "Accounting & Audit", "11c"),
|
||||||
("Professional Services", "Other Professional", "11g"),
|
("Professional Services", "Other Professional", "11g"),
|
||||||
@@ -115,6 +130,39 @@ public static class DbSeeder
|
|||||||
("Information Technology", "Website & Hosting", "14"),
|
("Information Technology", "Website & Hosting", "14"),
|
||||||
("Information Technology", "Internet & Telecom", "14"),
|
("Information Technology", "Internet & Telecom", "14"),
|
||||||
("Finance & Banking", "Interest", "20"),
|
("Finance & Banking", "Interest", "20"),
|
||||||
|
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
|
||||||
|
("Finance & Banking", "Bank & Processing Fees", "13"),
|
||||||
|
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
|
||||||
|
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
|
||||||
|
("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
|
||||||
|
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
|
||||||
|
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
|
||||||
|
[
|
||||||
|
("Benevolence", "Visit Expenses", "2", "17"),
|
||||||
|
("Missions", "Missionary Support", "1", "2"),
|
||||||
|
("Missions", "Offering Transfer", "1", "2"),
|
||||||
];
|
];
|
||||||
|
|
||||||
private static readonly (string Name, string Description)[] Roles =
|
private static readonly (string Name, string Description)[] Roles =
|
||||||
@@ -159,6 +207,11 @@ public static class DbSeeder
|
|||||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||||
("finance", Modules.Disbursements, true, true, true, true),
|
("finance", Modules.Disbursements, true, true, true, true),
|
||||||
("finance", Modules.Form990Report, true, false, false, false),
|
("finance", Modules.Form990Report, true, false, false, false),
|
||||||
|
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
|
||||||
|
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
|
||||||
|
("finance", Modules.Form1099, true, true, true, false),
|
||||||
|
("pastor", Modules.Form1099, true, false, false, false),
|
||||||
|
("board_member", Modules.Form1099, true, false, false, false),
|
||||||
|
|
||||||
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
// Logs — read-only. System logs are technical (pastor only); audit logs have
|
||||||
// governance value, so finance and board members can read them too.
|
// governance value, so finance and board members can read them too.
|
||||||
@@ -329,6 +382,37 @@ public static class DbSeeder
|
|||||||
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
|
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
|
||||||
sub.Form990LineId = lineId;
|
sub.Form990LineId = lineId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
|
||||||
|
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
|
||||||
|
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
|
||||||
|
{
|
||||||
|
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||||
|
if (sub is null) continue;
|
||||||
|
if (linesByCode.TryGetValue(oldCode, out var oldId)
|
||||||
|
&& linesByCode.TryGetValue(newCode, out var newId)
|
||||||
|
&& sub.Form990LineId == oldId)
|
||||||
|
sub.Form990LineId = newId;
|
||||||
|
}
|
||||||
|
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();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +495,7 @@ public static class DbSeeder
|
|||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(db);
|
await SeedExpenseCategoriesAsync(db);
|
||||||
await SeedForm990ExpenseLinesAsync(db);
|
await SeedForm990ExpenseLinesAsync(db);
|
||||||
|
await SeedForm1099BoxesAsync(db);
|
||||||
await SeedChurchProfileAsync(db);
|
await SeedChurchProfileAsync(db);
|
||||||
await SeedSiteSettingAsync(db);
|
await SeedSiteSettingAsync(db);
|
||||||
await SeedNotificationSettingAsync(db, config);
|
await SeedNotificationSettingAsync(db, config);
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ rows AS (
|
|||||||
mi."Id" AS ministry_id,
|
mi."Id" AS ministry_id,
|
||||||
gp."Id" AS group_id,
|
gp."Id" AS group_id,
|
||||||
sc."Id" AS sub_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.is_reimb,
|
||||||
sp.vendor,
|
sp.vendor,
|
||||||
sp.descr,
|
sp.descr,
|
||||||
@@ -172,13 +174,14 @@ rows AS (
|
|||||||
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
||||||
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
||||||
)
|
)
|
||||||
|
, ins_exp AS (
|
||||||
INSERT INTO "Expenses"
|
INSERT INTO "Expenses"
|
||||||
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount",
|
("Id","MinistryId","Type","Status","Amount",
|
||||||
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
|
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
|
||||||
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
|
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
|
||||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
||||||
SELECT
|
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,
|
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
|
||||||
r.status,
|
r.status,
|
||||||
r.amount,
|
r.amount,
|
||||||
@@ -196,6 +199,15 @@ SELECT
|
|||||||
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
|
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
|
||||||
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
|
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
|
||||||
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
|
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;
|
FROM rows r;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
|||||||
public string? BankAccountNumber { get; set; }
|
public string? BankAccountNumber { get; set; }
|
||||||
public string? BankRoutingNumber { get; set; }
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Payer EIN printed on Form 1099-NEC Copy B; the church's own public business identifier.</summary>
|
||||||
|
public string? PayerEin { get; set; }
|
||||||
|
|
||||||
|
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
|
||||||
|
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
|
||||||
|
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
|
||||||
|
public string? ClaudeApiKey { get; set; } // secret, stored plaintext
|
||||||
|
public string? GeminiModel { get; set; } = "gemini-2.5-flash-lite";
|
||||||
|
public string? GeminiApiKey { get; set; } // secret, stored plaintext
|
||||||
|
|
||||||
/// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary>
|
/// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary>
|
||||||
public int NextCheckNumber { get; set; } = 1001;
|
public int NextCheckNumber { get; set; } = 1001;
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ public class Expense : SoftDeleteEntity, IAuditable
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int MinistryId { 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 Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
|
||||||
public string Status { get; set; } = "Draft"; // see state machine
|
public string Status { get; set; } = "Draft"; // see state machine
|
||||||
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
|
public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
|
||||||
public decimal Amount { get; set; }
|
|
||||||
public string Description { get; set; } = null!;
|
public string Description { get; set; } = null!;
|
||||||
public string? VendorName { get; set; }
|
public string? VendorName { get; set; }
|
||||||
public int? MemberId { get; set; }
|
public int? MemberId { get; set; }
|
||||||
|
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
|
||||||
public string? CheckNumber { get; set; }
|
public string? CheckNumber { get; set; }
|
||||||
public DateOnly ExpenseDate { get; set; }
|
public DateOnly ExpenseDate { get; set; }
|
||||||
public string? ReceiptBlobPath { get; set; }
|
public string? ReceiptBlobPath { get; set; }
|
||||||
@@ -27,7 +25,7 @@ public class Expense : SoftDeleteEntity, IAuditable
|
|||||||
public string? PaidBy { get; set; }
|
public string? PaidBy { get; set; }
|
||||||
|
|
||||||
public Ministry? Ministry { get; set; }
|
public Ministry? Ministry { get; set; }
|
||||||
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
|
||||||
public ExpenseSubCategory? SubCategory { get; set; }
|
|
||||||
public Member? Member { get; set; }
|
public Member? Member { get; set; }
|
||||||
|
public Payee1099? Payee { get; set; }
|
||||||
|
public List<ExpenseLine> Lines { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,8 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
|||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public Form990ExpenseLine? Form990Line { get; set; }
|
public Form990ExpenseLine? Form990Line { get; set; }
|
||||||
|
|
||||||
|
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||||
|
public Form1099Box? Form1099Box { get; set; }
|
||||||
|
|
||||||
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A reusable template of a vendor payment. Lets finance save a recurring fixed expense
|
||||||
|
/// (rent, internet, a fixed catered-meal cost) and re-apply it later, pre-filling everything
|
||||||
|
/// except the ExpenseDate. Shared church-wide; the creator is the auditable CreatedBy.
|
||||||
|
/// Lines are wholly owned by the header (replaced as a set on update, like ExpenseLine).
|
||||||
|
/// </summary>
|
||||||
|
public class ExpenseSnapshot : SoftDeleteEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!; // user label, e.g. "Monthly Rent — Landlord X"
|
||||||
|
public int MinistryId { get; set; }
|
||||||
|
public string Description { get; set; } = null!;
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public Ministry? Ministry { get; set; }
|
||||||
|
public List<ExpenseSnapshotLine> Lines { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>One category line of an <see cref="ExpenseSnapshot"/>, mirroring <see cref="ExpenseLine"/>.</summary>
|
||||||
|
public class ExpenseSnapshotLine : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int SnapshotId { get; set; }
|
||||||
|
public int CategoryGroupId { get; set; }
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public ExpenseSnapshot? Snapshot { get; set; }
|
||||||
|
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
||||||
|
public ExpenseSubCategory? SubCategory { get; set; }
|
||||||
|
}
|
||||||
@@ -13,5 +13,8 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
|
|||||||
public int? Form990LineId { get; set; }
|
public int? Form990LineId { get; set; }
|
||||||
public Form990ExpenseLine? Form990Line { 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; }
|
public ExpenseCategoryGroup? Group { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>Shared 1099 constants. Box codes match Form1099Box.BoxCode seed values.</summary>
|
||||||
|
public static class Form1099
|
||||||
|
{
|
||||||
|
/// <summary>IRS reporting threshold (USD) per box, per recipient, per calendar year.</summary>
|
||||||
|
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<string> All = [Missing, Requested, OnFile, Expired];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>A 1099 reporting box, e.g. "NEC-1 — Nonemployee compensation".</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ namespace ROLAC.API.Entities;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
|
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
|
||||||
/// Ministry.DefaultFunctionalClass and Expense.FunctionalClass.
|
/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FunctionalClasses
|
public static class FunctionalClasses
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public static class AuditActions
|
|||||||
public const string CheckIssued = "CheckIssued";
|
public const string CheckIssued = "CheckIssued";
|
||||||
public const string CheckVoided = "CheckVoided";
|
public const string CheckVoided = "CheckVoided";
|
||||||
public const string ExpenseApproved = "ExpenseApproved";
|
public const string ExpenseApproved = "ExpenseApproved";
|
||||||
|
public const string ExpenseRejected = "ExpenseRejected";
|
||||||
public const string StatementFinalized = "StatementFinalized";
|
public const string StatementFinalized = "StatementFinalized";
|
||||||
|
|
||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
@@ -60,7 +61,7 @@ public static class AuditActions
|
|||||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||||
PasswordChanged, UserDeactivated, PermissionChanged,
|
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||||
InvitationCreated, InvitationAccepted, CheckIssued,
|
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||||
CheckVoided, ExpenseApproved, StatementFinalized,
|
CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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; }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChurchAiSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AiProvider",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Claude");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClaudeApiKey",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClaudeModel",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: "claude-haiku-4-5-20251001");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "GeminiApiKey",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(500)",
|
||||||
|
maxLength: 500,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "GeminiModel",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(100)",
|
||||||
|
maxLength: 100,
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: "gemini-2.5-flash-lite");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AiProvider",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClaudeApiKey",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClaudeModel",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GeminiApiKey",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "GeminiModel",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExpenseSnapshots : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseSnapshots",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Name = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
MinistryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||||
|
VendorName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExpenseSnapshots", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshots_Ministries_MinistryId",
|
||||||
|
column: x => x.MinistryId,
|
||||||
|
principalTable: "Ministries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseSnapshotLines",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
SnapshotId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CategoryGroupId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SubCategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
FunctionalClass = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExpenseSnapshotLines", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshotLines_ExpenseCategoryGroups_CategoryGroupId",
|
||||||
|
column: x => x.CategoryGroupId,
|
||||||
|
principalTable: "ExpenseCategoryGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshotLines_ExpenseSnapshots_SnapshotId",
|
||||||
|
column: x => x.SnapshotId,
|
||||||
|
principalTable: "ExpenseSnapshots",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSnapshotLines_ExpenseSubCategories_SubCategoryId",
|
||||||
|
column: x => x.SubCategoryId,
|
||||||
|
principalTable: "ExpenseSubCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshotLines_CategoryGroupId",
|
||||||
|
table: "ExpenseSnapshotLines",
|
||||||
|
column: "CategoryGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshotLines_SnapshotId",
|
||||||
|
table: "ExpenseSnapshotLines",
|
||||||
|
column: "SnapshotId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshotLines_SubCategoryId",
|
||||||
|
table: "ExpenseSnapshotLines",
|
||||||
|
column: "SubCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshots_CreatedAt",
|
||||||
|
table: "ExpenseSnapshots",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSnapshots_MinistryId",
|
||||||
|
table: "ExpenseSnapshots",
|
||||||
|
column: "MinistryId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseSnapshotLines");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseSnapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2676
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddForm1099RecipientTracking : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Form1099BoxId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PayeeId",
|
||||||
|
table: "Expenses",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Form1099BoxId",
|
||||||
|
table: "ExpenseCategoryGroups",
|
||||||
|
type: "integer",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Form1099Boxes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
BoxCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
FormType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(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<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
LegalName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
MemberId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
TaxClassification = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
|
||||||
|
Is1099Tracked = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
TinType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
|
||||||
|
TinEncrypted = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
TinLast4 = table.Column<string>(type: "character varying(4)", maxLength: 4, nullable: true),
|
||||||
|
AddressLine1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
AddressLine2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
State = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
|
||||||
|
Zip = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||||
|
W9Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Missing"),
|
||||||
|
W9ReceivedDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
W9BlobPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2680
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPayerEinToChurchProfile : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PayerEin",
|
||||||
|
table: "ChurchProfiles",
|
||||||
|
type: "character varying(20)",
|
||||||
|
maxLength: 20,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PayerEin",
|
||||||
|
table: "ChurchProfiles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -439,6 +439,13 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("AiProvider")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Claude");
|
||||||
|
|
||||||
b.Property<string>("BankAccountNumber")
|
b.Property<string>("BankAccountNumber")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -455,6 +462,16 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
.HasColumnType("character varying(100)");
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaudeApiKey")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("ClaudeModel")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasDefaultValue("claude-haiku-4-5-20251001");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -467,6 +484,16 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("GeminiApiKey")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("GeminiModel")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasDefaultValue("gemini-2.5-flash-lite");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
@@ -479,6 +506,10 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Property<int>("NextCheckNumber")
|
b.Property<int>("NextCheckNumber")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PayerEin")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
b.Property<string>("Phone")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -525,9 +556,6 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Property<decimal>("Amount")
|
b.Property<decimal>("Amount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("CategoryGroupId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<string>("CheckNumber")
|
b.Property<string>("CheckNumber")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -555,10 +583,6 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Property<DateOnly>("ExpenseDate")
|
b.Property<DateOnly>("ExpenseDate")
|
||||||
.HasColumnType("date");
|
.HasColumnType("date");
|
||||||
|
|
||||||
b.Property<string>("FunctionalClass")
|
|
||||||
.HasMaxLength(20)
|
|
||||||
.HasColumnType("character varying(20)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
@@ -578,6 +602,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("PayeeId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<string>("ReceiptBlobPath")
|
b.Property<string>("ReceiptBlobPath")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
@@ -600,9 +627,6 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasColumnType("character varying(30)")
|
.HasColumnType("character varying(30)")
|
||||||
.HasDefaultValue("Draft");
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
b.Property<int>("SubCategoryId")
|
|
||||||
.HasColumnType("integer");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -629,19 +653,17 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CategoryGroupId");
|
|
||||||
|
|
||||||
b.HasIndex("ExpenseDate");
|
b.HasIndex("ExpenseDate");
|
||||||
|
|
||||||
b.HasIndex("MemberId");
|
b.HasIndex("MemberId");
|
||||||
|
|
||||||
b.HasIndex("MinistryId");
|
b.HasIndex("MinistryId");
|
||||||
|
|
||||||
|
b.HasIndex("PayeeId");
|
||||||
|
|
||||||
b.HasIndex("Status")
|
b.HasIndex("Status")
|
||||||
.HasFilter("\"IsDeleted\" = false");
|
.HasFilter("\"IsDeleted\" = false");
|
||||||
|
|
||||||
b.HasIndex("SubCategoryId");
|
|
||||||
|
|
||||||
b.ToTable("Expenses");
|
b.ToTable("Expenses");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -661,6 +683,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("Form1099BoxId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("Form990LineId")
|
b.Property<int?>("Form990LineId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -689,11 +714,190 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Form1099BoxId");
|
||||||
|
|
||||||
b.HasIndex("Form990LineId");
|
b.HasIndex("Form990LineId");
|
||||||
|
|
||||||
b.ToTable("ExpenseCategoryGroups");
|
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.ExpenseSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("CheckNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("MinistryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("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<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<string>("FunctionalClass")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<int>("SnapshotId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
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("SnapshotId");
|
||||||
|
|
||||||
|
b.HasIndex("SubCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("ExpenseSnapshotLines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -710,6 +914,9 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int?>("Form1099BoxId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<int?>("Form990LineId")
|
b.Property<int?>("Form990LineId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -741,6 +948,8 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Form1099BoxId");
|
||||||
|
|
||||||
b.HasIndex("Form990LineId");
|
b.HasIndex("Form990LineId");
|
||||||
|
|
||||||
b.HasIndex("GroupId");
|
b.HasIndex("GroupId");
|
||||||
@@ -786,6 +995,63 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("FamilyUnits");
|
b.ToTable("FamilyUnits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Form1099Box", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("BoxCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("FormType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Name_zh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.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("BoxCode")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Form1099Boxes");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1735,6 +2001,128 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("OfferingSessions");
|
b.ToTable("OfferingSessions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("AddressLine1")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("AddressLine2")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<bool>("Is1099Tracked")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("LegalName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("MemberId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.HasMaxLength(2)
|
||||||
|
.HasColumnType("character varying(2)");
|
||||||
|
|
||||||
|
b.Property<string>("TaxClassification")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(40)
|
||||||
|
.HasColumnType("character varying(40)");
|
||||||
|
|
||||||
|
b.Property<string>("TinEncrypted")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("TinLast4")
|
||||||
|
.HasMaxLength(4)
|
||||||
|
.HasColumnType("character varying(4)");
|
||||||
|
|
||||||
|
b.Property<string>("TinType")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("W9BlobPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("W9ReceivedDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("W9Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Missing");
|
||||||
|
|
||||||
|
b.Property<string>("Zip")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MemberId");
|
||||||
|
|
||||||
|
b.ToTable("Payee1099s");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2007,12 +2395,6 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
|
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")
|
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("MemberId")
|
.HasForeignKey("MemberId")
|
||||||
@@ -2024,6 +2406,49 @@ namespace ROLAC.API.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.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")
|
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("SubCategoryId")
|
.HasForeignKey("SubCategoryId")
|
||||||
@@ -2032,25 +2457,56 @@ namespace ROLAC.API.Migrations
|
|||||||
|
|
||||||
b.Navigation("CategoryGroup");
|
b.Navigation("CategoryGroup");
|
||||||
|
|
||||||
b.Navigation("Member");
|
b.Navigation("Expense");
|
||||||
|
|
||||||
b.Navigation("Ministry");
|
|
||||||
|
|
||||||
b.Navigation("SubCategory");
|
b.Navigation("SubCategory");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
b.HasOne("ROLAC.API.Entities.Ministry", "Ministry")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("Form990LineId")
|
.HasForeignKey("MinistryId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("Form990Line");
|
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 =>
|
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")
|
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("Form990LineId")
|
.HasForeignKey("Form990LineId")
|
||||||
@@ -2062,6 +2518,8 @@ namespace ROLAC.API.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Form1099Box");
|
||||||
|
|
||||||
b.Navigation("Form990Line");
|
b.Navigation("Form990Line");
|
||||||
|
|
||||||
b.Navigation("Group");
|
b.Navigation("Group");
|
||||||
@@ -2141,6 +2599,16 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("MessagingGroup");
|
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 =>
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
@@ -2184,11 +2652,21 @@ namespace ROLAC.API.Migrations
|
|||||||
b.Navigation("Lines");
|
b.Navigation("Lines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("SubCategories");
|
b.Navigation("SubCategories");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Lines");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Givings");
|
b.Navigation("Givings");
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ using ROLAC.API.Json;
|
|||||||
using ROLAC.API.Middleware;
|
using ROLAC.API.Middleware;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
using ROLAC.API.Services.Logging;
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
var config = builder.Configuration;
|
var config = builder.Configuration;
|
||||||
@@ -153,14 +154,23 @@ builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
|
|||||||
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
||||||
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
||||||
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
||||||
|
builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
|
||||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||||
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
|
||||||
|
builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
|
||||||
|
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
|
||||||
|
builder.Services.AddScoped<I1099FormService, Form1099FormService>();
|
||||||
|
builder.Services.AddDataProtection();
|
||||||
|
builder.Services.AddScoped<ITinProtector, TinProtector>();
|
||||||
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||||
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
|
// Pre-printed check-stock field coordinates; tune in appsettings.json without recompiling.
|
||||||
|
builder.Services.Configure<ROLAC.API.Services.Disbursement.CheckPrintLayoutOptions>(
|
||||||
|
config.GetSection("CheckPrint:Layout"));
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||||
|
|
||||||
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
||||||
@@ -179,6 +189,23 @@ builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationSer
|
|||||||
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
|
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
|
||||||
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
||||||
|
|
||||||
|
// ── AI assist (expense translation + category suggestion) ──────────────────
|
||||||
|
// Backend proxy so the API key stays server-side. Provider + model + key come from the
|
||||||
|
// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude
|
||||||
|
// or Gemini per request based on ChurchProfile.AiProvider.
|
||||||
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
|
||||||
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
|
||||||
|
ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
|
||||||
|
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
|
||||||
|
|
||||||
|
// Category-mapping AI (define a 大項/小項: refine name + translate + suggest Form 990 line).
|
||||||
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseCategoryAiService>();
|
||||||
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseCategoryAiService>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseCategoryAiServiceFactory,
|
||||||
|
ROLAC.API.Services.Ai.ExpenseCategoryAiServiceFactory>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configurable role-based permissions (RBAC matrix)
|
// Configurable role-based permissions (RBAC matrix)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults).</summary>
|
||||||
|
public sealed record ChurchAiConfig(
|
||||||
|
string Provider,
|
||||||
|
string ClaudeModel, string? ClaudeApiKey,
|
||||||
|
string GeminiModel, string? GeminiApiKey);
|
||||||
|
|
||||||
|
/// <summary>Reads the church's AI settings from the database for the current request.</summary>
|
||||||
|
public interface IChurchAiConfigProvider
|
||||||
|
{
|
||||||
|
Task<ChurchAiConfig> GetAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads AI settings from the singleton <c>ChurchProfile</c> row, substituting default model names
|
||||||
|
/// for any blank field so a freshly migrated install still names a valid model. The API keys are
|
||||||
|
/// passed through as-is (null when unset → the calling service treats AI as disabled).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChurchAiConfigProvider : IChurchAiConfigProvider
|
||||||
|
{
|
||||||
|
private const string DefaultClaudeModel = "claude-haiku-4-5-20251001";
|
||||||
|
private const string DefaultGeminiModel = "gemini-2.5-flash-lite";
|
||||||
|
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public ChurchAiConfigProvider(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<ChurchAiConfig> GetAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var p = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
var provider = string.IsNullOrWhiteSpace(p?.AiProvider) ? "Claude" : p.AiProvider;
|
||||||
|
var claudeModel = string.IsNullOrWhiteSpace(p?.ClaudeModel) ? DefaultClaudeModel : p!.ClaudeModel!;
|
||||||
|
var geminiModel = string.IsNullOrWhiteSpace(p?.GeminiModel) ? DefaultGeminiModel : p!.GeminiModel!;
|
||||||
|
|
||||||
|
return new ChurchAiConfig(provider, claudeModel, p?.ClaudeApiKey, geminiModel, p?.GeminiApiKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates and classifies an expense via the Anthropic Claude Messages API. It forces a single
|
||||||
|
/// tool call (<c>tool_choice</c> → <c>classify_expense</c>) whose <c>input_schema</c> matches our
|
||||||
|
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
|
||||||
|
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
|
||||||
|
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
|
||||||
|
/// model can be swapped (e.g. to a cheaper model) without code changes.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://api.anthropic.com/v1";
|
||||||
|
private const string AnthropicVersion = "2023-06-01";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ILogger<ClaudeExpenseAiService> _logger;
|
||||||
|
|
||||||
|
public ClaudeExpenseAiService(
|
||||||
|
HttpClient http,
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<ClaudeExpenseAiService> logger)
|
||||||
|
: base(db)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = cfg.ClaudeModel,
|
||||||
|
max_tokens = 1024,
|
||||||
|
tools = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = "classify_expense",
|
||||||
|
description = "Record the English translation and the chosen expense category ids for the expense.",
|
||||||
|
input_schema = new
|
||||||
|
{
|
||||||
|
type = "object",
|
||||||
|
properties = new
|
||||||
|
{
|
||||||
|
chineseDescription = new { type = "string" },
|
||||||
|
englishDescription = new { type = "string" },
|
||||||
|
groupId = new { type = "integer" },
|
||||||
|
subCategoryId = new { type = "integer" },
|
||||||
|
confidence = new { type = "number" },
|
||||||
|
},
|
||||||
|
required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool_choice = new { type = "tool", name = "classify_expense" },
|
||||||
|
messages = new[]
|
||||||
|
{
|
||||||
|
new { role = "user", content = prompt },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"{BaseUrl}/messages";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
|
||||||
|
request.Headers.Add("anthropic-version", AnthropicVersion);
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The forced tool call lands in content[] as a tool_use block; its `input` is our object.
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
|
||||||
|
foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray())
|
||||||
|
{
|
||||||
|
if (block.GetProperty("type").GetString() != "tool_use") continue;
|
||||||
|
|
||||||
|
var parsed = block.GetProperty("input").Deserialize<ClaudeAnswer>(
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (parsed is null) return null;
|
||||||
|
|
||||||
|
return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Claude response contained no tool_use block.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Claude expense AI assist failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shape of the classify_expense tool input the model fills in.</summary>
|
||||||
|
private sealed class ClaudeAnswer
|
||||||
|
{
|
||||||
|
public string? EnglishDescription { get; set; }
|
||||||
|
public string? ChineseDescription { get; set; }
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refines, translates, and maps an expense category to a Form 990 line via the Anthropic Claude
|
||||||
|
/// Messages API. It forces a single tool call (<c>tool_choice</c> → <c>map_category</c>) whose
|
||||||
|
/// <c>input_schema</c> matches our answer shape, so the model returns structured JSON in a
|
||||||
|
/// <c>tool_use</c> block. The catalog, prompt, and id validation come from
|
||||||
|
/// <see cref="ExpenseCategoryAiServiceBase"/>; this class only owns the Claude HTTP call + parse.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClaudeExpenseCategoryAiService : ExpenseCategoryAiServiceBase
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://api.anthropic.com/v1";
|
||||||
|
private const string AnthropicVersion = "2023-06-01";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ILogger<ClaudeExpenseCategoryAiService> _logger;
|
||||||
|
|
||||||
|
public ClaudeExpenseCategoryAiService(
|
||||||
|
HttpClient http,
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<ClaudeExpenseCategoryAiService> logger)
|
||||||
|
: base(db)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Claude API key is not configured; category AI assist is disabled.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = cfg.ClaudeModel,
|
||||||
|
max_tokens = 1024,
|
||||||
|
tools = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
name = "map_category",
|
||||||
|
description = "Record the refined Chinese name, English translation, and chosen Form 990 line id for the expense category.",
|
||||||
|
input_schema = new
|
||||||
|
{
|
||||||
|
type = "object",
|
||||||
|
properties = new
|
||||||
|
{
|
||||||
|
chineseName = new { type = "string" },
|
||||||
|
englishName = new { type = "string" },
|
||||||
|
form990LineId = new { type = "integer" },
|
||||||
|
confidence = new { type = "number" },
|
||||||
|
},
|
||||||
|
required = new[] { "chineseName", "englishName", "form990LineId", "confidence" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tool_choice = new { type = "tool", name = "map_category" },
|
||||||
|
messages = new[]
|
||||||
|
{
|
||||||
|
new { role = "user", content = prompt },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"{BaseUrl}/messages";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
|
||||||
|
request.Headers.Add("anthropic-version", AnthropicVersion);
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The forced tool call lands in content[] as a tool_use block; its `input` is our object.
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
|
||||||
|
foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray())
|
||||||
|
{
|
||||||
|
if (block.GetProperty("type").GetString() != "tool_use") continue;
|
||||||
|
|
||||||
|
var parsed = block.GetProperty("input").Deserialize<ClaudeAnswer>(
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (parsed is null) return null;
|
||||||
|
|
||||||
|
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Claude response contained no tool_use block.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Claude category AI assist failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shape of the map_category tool input the model fills in.</summary>
|
||||||
|
private sealed class ClaudeAnswer
|
||||||
|
{
|
||||||
|
public string? ChineseName { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider-independent expense-AI logic: loads the active category catalog, builds the
|
||||||
|
/// classification prompt, and validates the model's chosen ids against that catalog. Concrete
|
||||||
|
/// providers (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus
|
||||||
|
/// response parsing — so the catalog/prompt/validation code lives in exactly one place.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ExpenseAiServiceBase : IExpenseAiService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
protected ExpenseAiServiceBase(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
/// <summary>One sub-category in the catalog passed to the model.</summary>
|
||||||
|
protected sealed record CatalogSub(int Id, string NameEn, string? NameZh);
|
||||||
|
|
||||||
|
/// <summary>One major category (with its sub-categories) in the catalog passed to the model.</summary>
|
||||||
|
protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList<CatalogSub> Subs);
|
||||||
|
|
||||||
|
/// <summary>The model's raw answer, before its ids are validated against the catalog.</summary>
|
||||||
|
protected sealed record ModelAnswer(
|
||||||
|
string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence);
|
||||||
|
|
||||||
|
public async Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var catalog = await LoadCatalogAsync(ct);
|
||||||
|
var prompt = BuildPrompt(chineseText, amount, catalog);
|
||||||
|
|
||||||
|
var answer = await CallModelAsync(prompt, ct);
|
||||||
|
if (answer is null) return new ExpenseAiSuggestion();
|
||||||
|
|
||||||
|
return BuildSuggestion(answer, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
|
||||||
|
/// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);
|
||||||
|
|
||||||
|
private async Task<List<CatalogGroup>> LoadCatalogAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _db.ExpenseCategoryGroups
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(group => group.IsActive)
|
||||||
|
.OrderBy(group => group.SortOrder)
|
||||||
|
.Select(group => new CatalogGroup(
|
||||||
|
group.Id,
|
||||||
|
group.Name_en,
|
||||||
|
group.Name_zh,
|
||||||
|
group.SubCategories
|
||||||
|
.Where(sub => sub.IsActive)
|
||||||
|
.OrderBy(sub => sub.SortOrder)
|
||||||
|
.Select(sub => new CatalogSub(sub.Id, sub.Name_en, sub.Name_zh))
|
||||||
|
.ToList()))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrompt(string chineseText, decimal amount, List<CatalogGroup> catalog)
|
||||||
|
{
|
||||||
|
var catalogJson = JsonSerializer.Serialize(catalog);
|
||||||
|
return
|
||||||
|
"You are a bookkeeping assistant for a church. Given an expense description (often in " +
|
||||||
|
"Traditional Chinese) and its amount, do three things:\n" +
|
||||||
|
"1. Correct any typos in the description and refine it into natural Traditional Chinese — " +
|
||||||
|
"return it as chineseDescription.\n" +
|
||||||
|
"2. Translate that into concise, natural accounting English (a short noun phrase, not a " +
|
||||||
|
"full sentence) — return it as englishDescription.\n" +
|
||||||
|
"3. Choose the single best matching major category (group) and sub-category from the catalog " +
|
||||||
|
"below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " +
|
||||||
|
"subCategoryId must belong to that groupId. If nothing fits well, choose the closest " +
|
||||||
|
"\"Other / 其他\" option and lower your confidence.\n\n" +
|
||||||
|
$"Expense description: {chineseText}\n" +
|
||||||
|
$"Amount: {amount}\n\n" +
|
||||||
|
$"Category catalog (JSON; each group has an Id, English/Chinese names, and its Subs):\n{catalogJson}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogGroup> catalog)
|
||||||
|
{
|
||||||
|
var suggestion = new ExpenseAiSuggestion
|
||||||
|
{
|
||||||
|
EnglishDescription = string.IsNullOrWhiteSpace(answer.EnglishDescription)
|
||||||
|
? null
|
||||||
|
: answer.EnglishDescription.Trim(),
|
||||||
|
ChineseDescription = string.IsNullOrWhiteSpace(answer.ChineseDescription)
|
||||||
|
? null
|
||||||
|
: answer.ChineseDescription.Trim(),
|
||||||
|
Confidence = answer.Confidence,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-validate the returned ids against the catalog; drop anything that doesn't line up
|
||||||
|
// (defends against a hallucinated id, or a sub-category that doesn't belong to the group).
|
||||||
|
var group = catalog.FirstOrDefault(candidate => candidate.Id == answer.GroupId);
|
||||||
|
if (group is not null)
|
||||||
|
{
|
||||||
|
suggestion.GroupId = group.Id;
|
||||||
|
suggestion.GroupLabel = Label(group.NameEn, group.NameZh);
|
||||||
|
|
||||||
|
var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == answer.SubCategoryId);
|
||||||
|
if (sub is not null)
|
||||||
|
{
|
||||||
|
suggestion.SubCategoryId = sub.Id;
|
||||||
|
suggestion.SubLabel = Label(sub.NameEn, sub.NameZh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirror the frontend's bilingual() convention: "English / 中文" (or just English).</summary>
|
||||||
|
private static string Label(string nameEn, string? nameZh)
|
||||||
|
=> string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
|
||||||
|
public interface IExpenseAiServiceFactory
|
||||||
|
{
|
||||||
|
Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory
|
||||||
|
{
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ClaudeExpenseAiService _claude;
|
||||||
|
private readonly GeminiExpenseAiService _gemini;
|
||||||
|
|
||||||
|
public ExpenseAiServiceFactory(
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
ClaudeExpenseAiService claude,
|
||||||
|
GeminiExpenseAiService gemini)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_claude = claude;
|
||||||
|
_gemini = gemini;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provider-independent category-AI logic: loads the active Form 990 line catalog, builds the
|
||||||
|
/// mapping prompt, and validates the model's chosen line id against that catalog. Concrete providers
|
||||||
|
/// (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus response parsing —
|
||||||
|
/// so the catalog/prompt/validation code lives in exactly one place. Mirrors
|
||||||
|
/// <see cref="ExpenseAiServiceBase"/>, which does the same for the expense-entry classification task.
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ExpenseCategoryAiServiceBase : IExpenseCategoryAiService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
|
protected ExpenseCategoryAiServiceBase(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
/// <summary>One Form 990 line in the catalog passed to the model.</summary>
|
||||||
|
protected sealed record CatalogLine(int Id, string LineCode, string NameEn, string? NameZh);
|
||||||
|
|
||||||
|
/// <summary>The model's raw answer, before its line id is validated against the catalog.</summary>
|
||||||
|
protected sealed record ModelAnswer(string? ChineseName, string? EnglishName, int? Form990LineId, double Confidence);
|
||||||
|
|
||||||
|
public async Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var catalog = await LoadCatalogAsync(ct);
|
||||||
|
var prompt = BuildPrompt(request, catalog);
|
||||||
|
|
||||||
|
var answer = await CallModelAsync(prompt, ct);
|
||||||
|
if (answer is null) return new CategoryAiSuggestion();
|
||||||
|
|
||||||
|
return BuildSuggestion(answer, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
|
||||||
|
/// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);
|
||||||
|
|
||||||
|
private async Task<List<CatalogLine>> LoadCatalogAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await _db.Form990ExpenseLines
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(line => line.IsActive)
|
||||||
|
.OrderBy(line => line.SortOrder)
|
||||||
|
.Select(line => new CatalogLine(line.Id, line.LineCode, line.Name_en, line.Name_zh))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrompt(ExpenseCategoryAiRequest request, List<CatalogLine> catalog)
|
||||||
|
{
|
||||||
|
var catalogJson = JsonSerializer.Serialize(catalog);
|
||||||
|
var levelLabel = request.Level.Equals("sub", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "sub-category (小項)"
|
||||||
|
: "major category (大項)";
|
||||||
|
|
||||||
|
var context = new StringBuilder();
|
||||||
|
context.Append($"This is an expense {levelLabel} in a church's bookkeeping chart of accounts.\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name_zh))
|
||||||
|
context.Append($"Chinese name entered: {request.Name_zh}\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name_en))
|
||||||
|
context.Append($"English name entered: {request.Name_en}\n");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ParentGroupName))
|
||||||
|
context.Append($"It belongs under the parent major category: {request.ParentGroupName}\n");
|
||||||
|
if (request.ParentForm990LineId is int parentLineId)
|
||||||
|
context.Append(
|
||||||
|
$"The parent major category is mapped to Form 990 line id {parentLineId}; prefer a consistent " +
|
||||||
|
"choice unless a more specific line clearly fits this sub-category.\n");
|
||||||
|
|
||||||
|
return
|
||||||
|
"You are a bookkeeping assistant for a church mapping its expense categories to the IRS Form 990 " +
|
||||||
|
"Part IX (Statement of Functional Expenses) lines. Given an expense category name (often in " +
|
||||||
|
"Traditional Chinese), do three things:\n" +
|
||||||
|
"1. Correct any typos in the name and refine it into natural Traditional Chinese — return it as " +
|
||||||
|
"chineseName.\n" +
|
||||||
|
"2. Translate that into a concise, natural accounting English noun phrase (not a full sentence) — " +
|
||||||
|
"return it as englishName.\n" +
|
||||||
|
"3. Choose the single best matching Form 990 line from the catalog below. You MUST pick a " +
|
||||||
|
"form990LineId that appears in the catalog. If nothing fits well, choose the closest general line " +
|
||||||
|
"(e.g. an \"Other expenses\" line) and lower your confidence.\n\n" +
|
||||||
|
context +
|
||||||
|
"\n" +
|
||||||
|
$"Form 990 line catalog (JSON; each line has an Id, LineCode, and English/Chinese names):\n{catalogJson}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CategoryAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogLine> catalog)
|
||||||
|
{
|
||||||
|
var suggestion = new CategoryAiSuggestion
|
||||||
|
{
|
||||||
|
ChineseName = string.IsNullOrWhiteSpace(answer.ChineseName) ? null : answer.ChineseName.Trim(),
|
||||||
|
EnglishName = string.IsNullOrWhiteSpace(answer.EnglishName) ? null : answer.EnglishName.Trim(),
|
||||||
|
Confidence = answer.Confidence,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-validate the returned id against the catalog; drop a hallucinated id rather than returning it.
|
||||||
|
var line = catalog.FirstOrDefault(candidate => candidate.Id == answer.Form990LineId);
|
||||||
|
if (line is not null)
|
||||||
|
{
|
||||||
|
suggestion.Form990LineId = line.Id;
|
||||||
|
suggestion.Form990LineLabel = Label(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirror the frontend dropdown label: "code — English / 中文" (or just "code — English").</summary>
|
||||||
|
private static string Label(CatalogLine line)
|
||||||
|
=> string.IsNullOrWhiteSpace(line.NameZh)
|
||||||
|
? $"{line.LineCode} — {line.NameEn}"
|
||||||
|
: $"{line.LineCode} — {line.NameEn} / {line.NameZh}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>Selects the active category-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
|
||||||
|
public interface IExpenseCategoryAiServiceFactory
|
||||||
|
{
|
||||||
|
Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExpenseCategoryAiServiceFactory : IExpenseCategoryAiServiceFactory
|
||||||
|
{
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ClaudeExpenseCategoryAiService _claude;
|
||||||
|
private readonly GeminiExpenseCategoryAiService _gemini;
|
||||||
|
|
||||||
|
public ExpenseCategoryAiServiceFactory(
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
ClaudeExpenseCategoryAiService claude,
|
||||||
|
GeminiExpenseCategoryAiService gemini)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_claude = claude;
|
||||||
|
_gemini = gemini;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Translates and classifies an expense via the Google Gemini <c>generateContent</c> API, using
|
||||||
|
/// Gemini's structured-output mode (<c>responseSchema</c>). The catalog, prompt, and id validation
|
||||||
|
/// come from <see cref="ExpenseAiServiceBase"/>; this class only owns the Gemini HTTP call + parse.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ILogger<GeminiExpenseAiService> _logger;
|
||||||
|
|
||||||
|
public GeminiExpenseAiService(
|
||||||
|
HttpClient http,
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<GeminiExpenseAiService> logger)
|
||||||
|
: base(db)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
contents = new[]
|
||||||
|
{
|
||||||
|
new { parts = new[] { new { text = prompt } } },
|
||||||
|
},
|
||||||
|
generationConfig = new
|
||||||
|
{
|
||||||
|
responseMimeType = "application/json",
|
||||||
|
responseSchema = new
|
||||||
|
{
|
||||||
|
type = "object",
|
||||||
|
properties = new
|
||||||
|
{
|
||||||
|
chineseDescription = new { type = "string" },
|
||||||
|
englishDescription = new { type = "string" },
|
||||||
|
groupId = new { type = "integer" },
|
||||||
|
subCategoryId = new { type = "integer" },
|
||||||
|
confidence = new { type = "number" },
|
||||||
|
},
|
||||||
|
required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
|
||||||
|
var text = doc.RootElement
|
||||||
|
.GetProperty("candidates")[0]
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetProperty("parts")[0]
|
||||||
|
.GetProperty("text")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini response contained no text part.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
|
||||||
|
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (parsed is null) return null;
|
||||||
|
|
||||||
|
return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Gemini expense AI assist failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
|
||||||
|
private sealed class GeminiAnswer
|
||||||
|
{
|
||||||
|
public string? EnglishDescription { get; set; }
|
||||||
|
public string? ChineseDescription { get; set; }
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public int SubCategoryId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refines, translates, and maps an expense category to a Form 990 line via the Google Gemini
|
||||||
|
/// <c>generateContent</c> API, using Gemini's structured-output mode (<c>responseSchema</c>). The
|
||||||
|
/// catalog, prompt, and id validation come from <see cref="ExpenseCategoryAiServiceBase"/>; this class
|
||||||
|
/// only owns the Gemini HTTP call + parse.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GeminiExpenseCategoryAiService : ExpenseCategoryAiServiceBase
|
||||||
|
{
|
||||||
|
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IChurchAiConfigProvider _config;
|
||||||
|
private readonly ILogger<GeminiExpenseCategoryAiService> _logger;
|
||||||
|
|
||||||
|
public GeminiExpenseCategoryAiService(
|
||||||
|
HttpClient http,
|
||||||
|
IChurchAiConfigProvider config,
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<GeminiExpenseCategoryAiService> logger)
|
||||||
|
: base(db)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini API key is not configured; category AI assist is disabled.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
contents = new[]
|
||||||
|
{
|
||||||
|
new { parts = new[] { new { text = prompt } } },
|
||||||
|
},
|
||||||
|
generationConfig = new
|
||||||
|
{
|
||||||
|
responseMimeType = "application/json",
|
||||||
|
responseSchema = new
|
||||||
|
{
|
||||||
|
type = "object",
|
||||||
|
properties = new
|
||||||
|
{
|
||||||
|
chineseName = new { type = "string" },
|
||||||
|
englishName = new { type = "string" },
|
||||||
|
form990LineId = new { type = "integer" },
|
||||||
|
confidence = new { type = "number" },
|
||||||
|
},
|
||||||
|
required = new[] { "chineseName", "englishName", "form990LineId", "confidence" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
|
||||||
|
|
||||||
|
using var response = await _http.SendAsync(request, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
|
||||||
|
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
|
||||||
|
var text = doc.RootElement
|
||||||
|
.GetProperty("candidates")[0]
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetProperty("parts")[0]
|
||||||
|
.GetProperty("text")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Gemini response contained no text part.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
|
||||||
|
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
if (parsed is null) return null;
|
||||||
|
|
||||||
|
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Gemini category AI assist failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
|
||||||
|
private sealed class GeminiAnswer
|
||||||
|
{
|
||||||
|
public string? ChineseName { get; set; }
|
||||||
|
public string? EnglishName { get; set; }
|
||||||
|
public int? Form990LineId { get; set; }
|
||||||
|
public double Confidence { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>AI assistance for expense entry: translate a description and suggest a category.</summary>
|
||||||
|
public interface IExpenseAiService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Translate <paramref name="chineseText"/> to concise accounting English and suggest the best
|
||||||
|
/// major/sub category from the live catalog, using <paramref name="amount"/> as a hint.
|
||||||
|
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
|
||||||
|
/// </summary>
|
||||||
|
Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI assistance for defining an expense category (大項/小項): refine the Chinese name, translate it
|
||||||
|
/// to English, and suggest the matching IRS Form 990 Part IX line.
|
||||||
|
/// </summary>
|
||||||
|
public interface IExpenseCategoryAiService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Refine the entered name, translate it to concise accounting English, and choose the best Form 990
|
||||||
|
/// line from the live catalog (biased by the group/sub context in <paramref name="request"/>).
|
||||||
|
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
|
||||||
|
/// </summary>
|
||||||
|
Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -18,7 +18,12 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
||||||
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
BankRoutingNumber = p.BankRoutingNumber, PayerEin = p.PayerEin, NextCheckNumber = p.NextCheckNumber,
|
||||||
|
AiProvider = p.AiProvider,
|
||||||
|
ClaudeModel = p.ClaudeModel,
|
||||||
|
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
|
||||||
|
GeminiModel = p.GeminiModel,
|
||||||
|
GeminiApiKeyMasked = Mask(p.GeminiApiKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +33,13 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
||||||
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
||||||
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
p.BankRoutingNumber = r.BankRoutingNumber; p.PayerEin = r.PayerEin; p.NextCheckNumber = r.NextCheckNumber;
|
||||||
|
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
|
||||||
|
p.ClaudeModel = r.ClaudeModel;
|
||||||
|
p.GeminiModel = r.GeminiModel;
|
||||||
|
// Leave-unchanged semantics: only overwrite a stored key when a new value is supplied.
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,4 +54,12 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
|
||||||
|
private static string Mask(string? key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key)) return "";
|
||||||
|
if (key.Length <= 4) return new string('•', key.Length);
|
||||||
|
return new string('•', 6) + key[^4..];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace ROLAC.API.Services.Disbursement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Field coordinates (in inches) for printing onto pre-printed three-stub check stock.
|
||||||
|
/// Every position is bound from the "CheckPrint:Layout" section of appsettings.json so alignment
|
||||||
|
/// can be tuned and reloaded without recompiling. Positions are expressed as an X (absolute from the
|
||||||
|
/// page's left edge) plus a Y offset within the field's stub; the per-stub origins below place the
|
||||||
|
/// three stacked regions (check + two identical receipt copies) down the 8.5"x11" page.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CheckPrintLayoutOptions
|
||||||
|
{
|
||||||
|
// Calibration nudge (inches): a TextBox renders its text inset down-and-right from the box
|
||||||
|
// origin by a fixed internal margin + line leading, so configured X/Y don't match the ink.
|
||||||
|
// These values are subtracted from every position so configured X/Y == actual printed position.
|
||||||
|
// To recalibrate: set both to 0, print, measure how far the ink sits past a known X/Y, and put
|
||||||
|
// those differences here (defaults are the measured inset for this stock).
|
||||||
|
public float TextInsetX { get; set; } = 0.13f;
|
||||||
|
public float TextInsetY { get; set; } = 0.15f;
|
||||||
|
|
||||||
|
// Stub origins — top edge of each region, inches from the top of the page.
|
||||||
|
public float CheckOriginY { get; set; } = 0f;
|
||||||
|
public float Receipt1OriginY { get; set; } = 3.67f;
|
||||||
|
public float Receipt2OriginY { get; set; } = 7.33f;
|
||||||
|
|
||||||
|
// Check stub fields (offset within the check stub).
|
||||||
|
public FieldPos Payee { get; set; } = new() { X = 1.25f, OffsetY = 1.75f, FontSize = 11, Bold = true };
|
||||||
|
public FieldPos AmountNumeric { get; set; } = new() { X = 6.50f, OffsetY = 1.75f, FontSize = 11, Bold = true };
|
||||||
|
public FieldPos AmountWords { get; set; } = new() { X = 0.60f, OffsetY = 2.20f, FontSize = 10 };
|
||||||
|
public FieldPos Memo { get; set; } = new() { X = 0.60f, OffsetY = 2.90f, FontSize = 9 };
|
||||||
|
public FieldPos CheckDate { get; set; } = new() { X = 6.50f, OffsetY = 1.25f, FontSize = 10 };
|
||||||
|
|
||||||
|
// Receipt stub fields (offset within a receipt stub — shared by both identical copies).
|
||||||
|
public FieldPos ReceiptPayee { get; set; } = new() { X = 1.00f, OffsetY = 0.30f, FontSize = 10, Bold = true };
|
||||||
|
public FieldPos ReceiptAmount { get; set; } = new() { X = 6.50f, OffsetY = 0.30f, FontSize = 10, Bold = true };
|
||||||
|
public FieldPos ReceiptMemo { get; set; } = new() { X = 1.00f, OffsetY = 0.60f, FontSize = 9 };
|
||||||
|
public FieldPos ReceiptDate { get; set; } = new() { X = 6.50f, OffsetY = 0.60f, FontSize = 9 };
|
||||||
|
|
||||||
|
// Voucher detail grid (offsets within a receipt stub).
|
||||||
|
public VoucherGridOptions Grid { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One printable field: where it sits and how it renders.</summary>
|
||||||
|
public sealed class FieldPos
|
||||||
|
{
|
||||||
|
/// <summary>Absolute X from the page's left edge, in inches.</summary>
|
||||||
|
public float X { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Y offset within the field's stub, in inches (added to the stub origin).</summary>
|
||||||
|
public float OffsetY { get; set; }
|
||||||
|
|
||||||
|
public float FontSize { get; set; } = 10;
|
||||||
|
public bool Bold { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-column voucher detail grid on each receipt stub: 6 rows per column, 12 expense lines max,
|
||||||
|
/// filled column-major (lines 1-6 left, 7-12 right). All offsets are within the receipt stub.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class VoucherGridOptions
|
||||||
|
{
|
||||||
|
/// <summary>Left edge of the left column block, in inches from the page's left edge.</summary>
|
||||||
|
public float OriginX { get; set; } = 0.60f;
|
||||||
|
|
||||||
|
/// <summary>Y offset (within the stub) of the first data row.</summary>
|
||||||
|
public float OffsetY { get; set; } = 1.10f;
|
||||||
|
|
||||||
|
public float RowHeight { get; set; } = 0.22f;
|
||||||
|
|
||||||
|
/// <summary>Horizontal gap between the left and right column blocks, in inches.</summary>
|
||||||
|
public float ColumnGap { get; set; } = 0.30f;
|
||||||
|
|
||||||
|
public float DateWidth { get; set; } = 0.85f;
|
||||||
|
public float DescWidth { get; set; } = 2.10f;
|
||||||
|
public float AmountWidth { get; set; } = 0.80f;
|
||||||
|
|
||||||
|
/// <summary>Draw our own Date/Description/Amount headers. Set false if the stock pre-prints them.</summary>
|
||||||
|
public bool ShowGridHeaders { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Y offset (within the stub) of the header row.</summary>
|
||||||
|
public float HeaderOffsetY { get; set; } = 0.88f;
|
||||||
|
|
||||||
|
/// <summary>Y offset (within the stub) of the "...and N more lines" overflow hint.</summary>
|
||||||
|
public float OverflowOffsetY { get; set; } = 2.55f;
|
||||||
|
|
||||||
|
public float FontSize { get; set; } = 8.5f;
|
||||||
|
}
|
||||||
@@ -1,17 +1,28 @@
|
|||||||
|
using System.Drawing;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DevExpress.Office;
|
using DevExpress.Office;
|
||||||
using DevExpress.XtraRichEdit;
|
using DevExpress.XtraRichEdit;
|
||||||
using DevExpress.XtraRichEdit.API.Native;
|
using DevExpress.XtraRichEdit.API.Native;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Disbursement;
|
namespace ROLAC.API.Services.Disbursement;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API:
|
/// Renders a check onto pre-printed 8.5"x11" three-stub stock using the DevExpress Office
|
||||||
/// a check block on top followed by two identical ledger detail stubs. The layout is
|
/// (RichEdit) API. Fields are placed as absolutely-positioned floating TextBoxes so they align to
|
||||||
/// built programmatically (no external .docx template) and exported to PDF.
|
/// the boxes printed on the stock; every coordinate comes from <see cref="CheckPrintLayoutOptions"/>
|
||||||
|
/// (bound from "CheckPrint:Layout" in appsettings.json) so alignment can be tuned without recompiling.
|
||||||
|
/// The page is one check stub on top followed by two identical receipt copies.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class CheckPrintService : ICheckPrintService
|
public class CheckPrintService : ICheckPrintService
|
||||||
{
|
{
|
||||||
|
private readonly CheckPrintLayoutOptions _layout;
|
||||||
|
|
||||||
|
public CheckPrintService(IOptions<CheckPrintLayoutOptions> layout)
|
||||||
|
{
|
||||||
|
_layout = layout.Value;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
|
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
|
||||||
{
|
{
|
||||||
using var server = new RichEditDocumentServer();
|
using var server = new RichEditDocumentServer();
|
||||||
@@ -26,9 +37,13 @@ public class CheckPrintService : ICheckPrintService
|
|||||||
section.Margins.Left = section.Margins.Right = 0.6f;
|
section.Margins.Left = section.Margins.Right = 0.6f;
|
||||||
section.Margins.Top = section.Margins.Bottom = 0.5f;
|
section.Margins.Top = section.Margins.Bottom = 0.5f;
|
||||||
|
|
||||||
BuildCheckBlock(doc, model);
|
// Floating TextBoxes must anchor to a paragraph; everything is positioned relative to
|
||||||
BuildStub(doc, model, "PAYMENT ADVICE — DETAIL");
|
// the page, so a single empty anchor paragraph at the document start is enough.
|
||||||
BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY");
|
var anchor = doc.Paragraphs[0];
|
||||||
|
|
||||||
|
BuildCheckStub(doc, anchor, model);
|
||||||
|
BuildReceiptStub(doc, anchor, model, _layout.Receipt1OriginY);
|
||||||
|
BuildReceiptStub(doc, anchor, model, _layout.Receipt2OriginY);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -41,6 +56,125 @@ public class CheckPrintService : ICheckPrintService
|
|||||||
return Task.FromResult<Stream>(ms);
|
return Task.FromResult<Stream>(ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── check page builders (absolute positioning) ─────────────────────────────
|
||||||
|
|
||||||
|
private void BuildCheckStub(Document doc, Paragraph anchor, CheckPrintModel model)
|
||||||
|
{
|
||||||
|
var check = model.Check;
|
||||||
|
var originY = _layout.CheckOriginY;
|
||||||
|
|
||||||
|
PlaceField(doc, anchor, _layout.Payee, originY, 4.5f, check.PayeeName);
|
||||||
|
PlaceField(doc, anchor, _layout.AmountNumeric, originY, 1.6f, FormatCurrency(check.Amount,13), rightAlign: false);
|
||||||
|
PlaceField(doc, anchor, _layout.AmountWords, originY, 6.0f, model.AmountInWords);
|
||||||
|
PlaceField(doc, anchor, _layout.CheckDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(check.Memo))
|
||||||
|
PlaceField(doc, anchor, _layout.Memo, originY, 4.5f, check.Memo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildReceiptStub(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
|
||||||
|
{
|
||||||
|
var check = model.Check;
|
||||||
|
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptPayee, originY, 4.5f, "Pay To The Order Of: " + check.PayeeName);
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptAmount, originY, 1.6f, "Amount: " + FormatCurrency(check.Amount), rightAlign: true);
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
|
||||||
|
if (!string.IsNullOrWhiteSpace(check.Memo))
|
||||||
|
PlaceField(doc, anchor, _layout.ReceiptMemo, originY, 4.5f, check.Memo);
|
||||||
|
|
||||||
|
BuildVoucherGrid(doc, anchor, model, originY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Two-column voucher detail grid: up to 12 expense lines (6 per column), filled column-major.
|
||||||
|
/// Beyond 12 lines, prints the first 12 plus an overflow hint so the receipt total stays honest.
|
||||||
|
/// </summary>
|
||||||
|
private void BuildVoucherGrid(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
|
||||||
|
{
|
||||||
|
var grid = _layout.Grid;
|
||||||
|
var blockWidth = grid.DateWidth + grid.DescWidth + grid.AmountWidth;
|
||||||
|
const int rowsPerColumn = 6;
|
||||||
|
const int maxLines = rowsPerColumn * 2;
|
||||||
|
|
||||||
|
float ColumnX(int column) => grid.OriginX + column * (blockWidth + grid.ColumnGap);
|
||||||
|
|
||||||
|
void PlaceRow(float x, float y, string date, string description, string amount, bool bold)
|
||||||
|
{
|
||||||
|
PlaceText(doc, anchor, x, y, grid.DateWidth, date, grid.FontSize, bold, rightAlign: false);
|
||||||
|
PlaceText(doc, anchor, x + grid.DateWidth, y, grid.DescWidth, description, grid.FontSize, bold, rightAlign: false);
|
||||||
|
PlaceText(doc, anchor, x + grid.DateWidth + grid.DescWidth, y, grid.AmountWidth, amount, grid.FontSize, bold, rightAlign: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid.ShowGridHeaders)
|
||||||
|
{
|
||||||
|
var headerY = originY + grid.HeaderOffsetY;
|
||||||
|
for (var column = 0; column < 2; column++)
|
||||||
|
PlaceRow(ColumnX(column), headerY, "Date", "Description", "Amount", bold: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var printed = Math.Min(model.Lines.Count, maxLines);
|
||||||
|
for (var i = 0; i < printed; i++)
|
||||||
|
{
|
||||||
|
var line = model.Lines[i];
|
||||||
|
var column = i < rowsPerColumn ? 0 : 1;
|
||||||
|
var row = i % rowsPerColumn;
|
||||||
|
var y = originY + grid.OffsetY + row * grid.RowHeight;
|
||||||
|
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
|
||||||
|
PlaceRow(ColumnX(column), y, date, line.Description, FormatCurrency(line.Amount), bold: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.Lines.Count > maxLines)
|
||||||
|
{
|
||||||
|
var remaining = model.Lines.Count - maxLines;
|
||||||
|
var remainingTotal = model.Lines.Skip(maxLines).Sum(line => line.Amount);
|
||||||
|
var hint = $"…and {remaining} more line(s) ({FormatCurrency(remainingTotal)})";
|
||||||
|
PlaceText(doc, anchor, grid.OriginX, originY + grid.OverflowOffsetY,
|
||||||
|
blockWidth * 2 + grid.ColumnGap, hint, grid.FontSize, bold: false, rightAlign: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Places one configured field's value at its stub-relative position.</summary>
|
||||||
|
private void PlaceField(Document doc, Paragraph anchor, FieldPos field, float stubOriginY,
|
||||||
|
float width, string text, bool rightAlign = false)
|
||||||
|
{
|
||||||
|
PlaceText(doc, anchor, field.X, stubOriginY + field.OffsetY, width, text,
|
||||||
|
field.FontSize, field.Bold, rightAlign);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inserts a borderless, fill-less floating TextBox positioned absolutely relative to the page
|
||||||
|
/// (X from the left edge, Y from the top edge) and writes <paramref name="text"/> into it.
|
||||||
|
/// </summary>
|
||||||
|
private void PlaceText(Document doc, Paragraph anchor, float x, float y, float width,
|
||||||
|
string text, float fontSize, bool bold, bool rightAlign)
|
||||||
|
{
|
||||||
|
var shape = doc.Shapes.InsertTextBox(anchor.Range.Start);
|
||||||
|
shape.HorizontalAlignment = ShapeHorizontalAlignment.None;
|
||||||
|
shape.VerticalAlignment = ShapeVerticalAlignment.None;
|
||||||
|
shape.RelativeHorizontalPosition = ShapeRelativeHorizontalPosition.Page;
|
||||||
|
shape.RelativeVerticalPosition = ShapeRelativeVerticalPosition.Page;
|
||||||
|
// Pull the box up-and-left by the calibration inset so the text inside lands exactly on the
|
||||||
|
// configured (x, y) rather than down-and-right of it.
|
||||||
|
shape.Offset = new PointF(x - _layout.TextInsetX, y - _layout.TextInsetY);
|
||||||
|
shape.Size = new SizeF(width, Math.Max(0.2f, fontSize / 72f + 0.08f));
|
||||||
|
shape.Fill.SetNoFill();
|
||||||
|
shape.Line.Fill.SetNoFill();
|
||||||
|
|
||||||
|
var body = shape.ShapeFormat.TextBox.Document;
|
||||||
|
var range = body.InsertText(body.Range.Start, text);
|
||||||
|
|
||||||
|
var characters = body.BeginUpdateCharacters(range);
|
||||||
|
characters.FontSize = fontSize;
|
||||||
|
characters.Bold = bold;
|
||||||
|
body.EndUpdateCharacters(characters);
|
||||||
|
|
||||||
|
if (rightAlign)
|
||||||
|
{
|
||||||
|
var paragraphs = body.BeginUpdateParagraphs(range);
|
||||||
|
paragraphs.Alignment = ParagraphAlignment.Right;
|
||||||
|
body.EndUpdateParagraphs(paragraphs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
|
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
|
||||||
{
|
{
|
||||||
using var server = new RichEditDocumentServer();
|
using var server = new RichEditDocumentServer();
|
||||||
@@ -131,110 +265,15 @@ public class CheckPrintService : ICheckPrintService
|
|||||||
|
|
||||||
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
|
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
|
||||||
|
|
||||||
private static void BuildCheckBlock(Document doc, CheckPrintModel m)
|
|
||||||
{
|
|
||||||
var issuer = m.Issuer;
|
|
||||||
var check = m.Check;
|
|
||||||
|
|
||||||
AppendLine(doc, issuer.Name, bold: true, size: 13);
|
|
||||||
var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode);
|
|
||||||
if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9);
|
|
||||||
if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9);
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10);
|
|
||||||
AppendLine(doc, "");
|
|
||||||
|
|
||||||
AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11);
|
|
||||||
var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip);
|
|
||||||
if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9);
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12);
|
|
||||||
AppendLine(doc, m.AmountInWords, size: 10);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); }
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, "____________________________________", size: 10);
|
|
||||||
AppendLine(doc, "Authorized Signature", size: 8);
|
|
||||||
AppendSeparator(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void BuildStub(Document doc, CheckPrintModel m, string title)
|
|
||||||
{
|
|
||||||
var check = m.Check;
|
|
||||||
AppendLine(doc, title, bold: true, size: 10);
|
|
||||||
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9);
|
|
||||||
AppendLine(doc, "");
|
|
||||||
|
|
||||||
var rows = m.Lines.Count + 2; // header + lines + total
|
|
||||||
var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow);
|
|
||||||
table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single;
|
|
||||||
table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single;
|
|
||||||
|
|
||||||
SetCell(doc, table[0, 0], "Date", bold: true);
|
|
||||||
SetCell(doc, table[0, 1], "Description", bold: true);
|
|
||||||
SetCell(doc, table[0, 2], "Amount", bold: true, right: true);
|
|
||||||
|
|
||||||
for (var i = 0; i < m.Lines.Count; i++)
|
|
||||||
{
|
|
||||||
var line = m.Lines[i];
|
|
||||||
var r = i + 1;
|
|
||||||
// CheckLine snapshots description; date comes from the source expense if loaded.
|
|
||||||
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
|
|
||||||
SetCell(doc, table[r, 0], date);
|
|
||||||
SetCell(doc, table[r, 1], line.Description);
|
|
||||||
SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalRow = rows - 1;
|
|
||||||
SetCell(doc, table[totalRow, 0], "");
|
|
||||||
SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true);
|
|
||||||
SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true);
|
|
||||||
|
|
||||||
AppendLine(doc, "");
|
|
||||||
if (check.ReceiptSignedAt is { } signedAt)
|
|
||||||
AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9);
|
|
||||||
AppendSeparator(doc);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── low-level helpers ──────────────────────────────────────────────────────
|
// ── low-level helpers ──────────────────────────────────────────────────────
|
||||||
|
private static string FormatCurrency(decimal amount, int paddingAstric = 0)
|
||||||
private static void AppendLine(Document doc, string text, bool bold = false, float size = 10)
|
|
||||||
{
|
{
|
||||||
var range = doc.AppendText(text + "\r\n");
|
var c = (CultureInfo)CultureInfo.GetCultureInfo("en-US").Clone();
|
||||||
var cp = doc.BeginUpdateCharacters(range);
|
c.NumberFormat.CurrencySymbol = "";
|
||||||
cp.Bold = bold;
|
string formatedAmount = amount.ToString("#,##0.00", CultureInfo.GetCultureInfo("en-US"));
|
||||||
cp.FontSize = size;
|
return paddingAstric > 0 ? formatedAmount.PadLeft(paddingAstric, '*') : formatedAmount;
|
||||||
doc.EndUpdateCharacters(cp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AppendSeparator(Document doc)
|
|
||||||
{
|
|
||||||
AppendLine(doc, "");
|
|
||||||
AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8);
|
|
||||||
AppendLine(doc, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false)
|
|
||||||
{
|
|
||||||
var range = doc.InsertText(cell.ContentRange.Start, text);
|
|
||||||
var cp = doc.BeginUpdateCharacters(range);
|
|
||||||
cp.Bold = bold;
|
|
||||||
cp.FontSize = 9;
|
|
||||||
doc.EndUpdateCharacters(cp);
|
|
||||||
if (right)
|
|
||||||
{
|
|
||||||
var pp = doc.BeginUpdateParagraphs(range);
|
|
||||||
pp.Alignment = ParagraphAlignment.Right;
|
|
||||||
doc.EndUpdateParagraphs(pp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCurrency(decimal amount) =>
|
|
||||||
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
|
|
||||||
|
|
||||||
private static string JoinAddress(string? addr, string? city, string? state, string? zip)
|
private static string JoinAddress(string? addr, string? city, string? state, string? zip)
|
||||||
{
|
{
|
||||||
var cityLine = string.Join(", ",
|
var cityLine = string.Join(", ",
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService
|
|||||||
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
|
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);
|
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>();
|
var groups = new Dictionary<string, PayeeGroupDto>();
|
||||||
foreach (var e in rows)
|
foreach (var e in rows)
|
||||||
{
|
{
|
||||||
@@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService
|
|||||||
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||||
Description = e.Description, Amount = e.Amount,
|
Description = e.Description, Amount = e.Amount,
|
||||||
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||||
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
|
CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
|
||||||
});
|
});
|
||||||
g.TotalAmount += e.Amount;
|
g.TotalAmount += e.Amount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,25 +25,32 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
|
||||||
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
|
.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
|
return groups.Select(g => new ExpenseCategoryGroupDto
|
||||||
{
|
{
|
||||||
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
|
||||||
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
SortOrder = g.SortOrder, IsActive = g.IsActive,
|
||||||
Form990LineId = g.Form990LineId,
|
Form990LineId = g.Form990LineId,
|
||||||
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
|
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
|
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,
|
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
|
||||||
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
SortOrder = s.SortOrder, IsActive = s.IsActive,
|
||||||
Form990LineId = s.Form990LineId,
|
Form990LineId = s.Form990LineId,
|
||||||
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
|
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(),
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
|
public async Task<int> 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);
|
_db.ExpenseCategoryGroups.Add(g);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return g.Id;
|
return g.Id;
|
||||||
@@ -53,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
var g = await _db.ExpenseCategoryGroups.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
|
?? 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();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
|
||||||
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
|
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);
|
_db.ExpenseSubCategories.Add(s);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return s.Id;
|
return s.Id;
|
||||||
@@ -79,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
|
|||||||
{
|
{
|
||||||
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
var s = await _db.ExpenseSubCategories.FindAsync(id)
|
||||||
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
|
?? 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();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService
|
|||||||
{
|
{
|
||||||
var query = _db.Expenses.AsNoTracking().AsQueryable();
|
var query = _db.Expenses.AsNoTracking().AsQueryable();
|
||||||
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
|
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
|
||||||
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value);
|
// Category filters now match against any line of the expense.
|
||||||
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value);
|
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
|
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
|
||||||
// request the Paid+Approved set in one call.
|
// request the Paid+Approved set in one call.
|
||||||
if (!string.IsNullOrWhiteSpace(statuses))
|
if (!string.IsNullOrWhiteSpace(statuses))
|
||||||
@@ -81,60 +82,173 @@ public class ExpenseService : IExpenseService
|
|||||||
|
|
||||||
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
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 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 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))
|
var memberNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
|
||||||
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}");
|
.Select(m => new { m.Id, m.FirstName_en, m.LastName_en, m.NickName })
|
||||||
|
.ToDictionaryAsync(
|
||||||
|
m => m.Id,
|
||||||
|
m => new MemberPayeeName($"{m.FirstName_en} {m.LastName_en}",
|
||||||
|
BuildNickPayeeName(m.NickName, m.FirstName_en, m.LastName_en)));
|
||||||
|
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
|
||||||
|
|
||||||
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 =>
|
||||||
|
{
|
||||||
|
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,
|
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||||
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
|
||||||
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""),
|
LineCount = ls?.Count ?? 0,
|
||||||
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""),
|
PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
|
||||||
VendorName = e.VendorName, MemberId = e.MemberId,
|
VendorName = e.VendorName, MemberId = e.MemberId,
|
||||||
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null,
|
MemberName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Legal : null,
|
||||||
|
MemberNickName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Nick : null,
|
||||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
|
||||||
HasReceipt = e.ReceiptBlobPath != null,
|
HasReceipt = e.ReceiptBlobPath != null,
|
||||||
CheckNumber = e.CheckNumber,
|
CheckNumber = e.CheckNumber,
|
||||||
FunctionalClass = e.FunctionalClass,
|
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
|
||||||
|
ReviewedAt = e.ReviewedAt,
|
||||||
|
ReviewNotes = e.ReviewNotes,
|
||||||
|
PayeeId = e.PayeeId,
|
||||||
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
|
||||||
|
// the linked Member's full name when present, otherwise the account email.
|
||||||
|
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return new Dictionary<string, string>();
|
||||||
|
|
||||||
|
var users = await _db.Users.AsNoTracking()
|
||||||
|
.Where(u => ids.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, u.Email, u.MemberId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
|
||||||
|
var memberNames = await _db.Members.AsNoTracking()
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
|
||||||
|
|
||||||
|
return users.ToDictionary(
|
||||||
|
u => u.Id,
|
||||||
|
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
|
||||||
|
? name
|
||||||
|
: (u.Email ?? u.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member payee names carried to the frontend: the legal name (printed on the check) and an
|
||||||
|
// optional friendly "NickName LastName" line shown above it.
|
||||||
|
private sealed record MemberPayeeName(string Legal, string? Nick);
|
||||||
|
|
||||||
|
// Build the friendly "NickName LastName" payee line, or null when the member has no distinct
|
||||||
|
// nickname (mirrors the frontend memberDisplayName rule: a nickname equal to the first name is not shown).
|
||||||
|
private static string? BuildNickPayeeName(string? nickName, string firstNameEn, string lastNameEn)
|
||||||
|
{
|
||||||
|
bool hasDistinctNickName = !string.IsNullOrWhiteSpace(nickName) && nickName != firstNameEn;
|
||||||
|
if (!hasDistinctNickName)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $"{nickName} {lastNameEn}";
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
public async Task<ExpenseDto?> GetByIdAsync(int id)
|
||||||
{
|
{
|
||||||
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
if (e is null) return null;
|
if (e is null) return null;
|
||||||
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
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() ?? "";
|
string? memberName = null;
|
||||||
var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? "";
|
string? memberNickName = null;
|
||||||
string? memName = e.MemberId != null
|
if (e.MemberId != null)
|
||||||
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync()
|
{
|
||||||
|
var member = await _db.Members.AsNoTracking()
|
||||||
|
.Where(m => m.Id == e.MemberId)
|
||||||
|
.Select(m => new { m.FirstName_en, m.LastName_en, m.NickName })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (member != null)
|
||||||
|
{
|
||||||
|
memberName = $"{member.FirstName_en} {member.LastName_en}";
|
||||||
|
memberNickName = BuildNickPayeeName(member.NickName, member.FirstName_en, member.LastName_en);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewerName = e.ReviewedBy != null
|
||||||
|
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
|
||||||
: null;
|
: 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
|
return new ExpenseDto
|
||||||
{
|
{
|
||||||
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
|
||||||
MinistryId = e.MinistryId, MinistryName = minName,
|
MinistryId = e.MinistryId, MinistryName = minName,
|
||||||
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName,
|
LineCount = lineDtos.Count,
|
||||||
SubCategoryId = e.SubCategoryId, SubCategoryName = subName,
|
PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
|
||||||
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName,
|
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memberName, MemberNickName = memberNickName,
|
||||||
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
|
||||||
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
|
||||||
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt,
|
ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
|
||||||
FunctionalClass = e.FunctionalClass,
|
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
|
||||||
|
PayeeId = e.PayeeId,
|
||||||
|
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)
|
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
|
||||||
{
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
var e = new Expense
|
var e = new Expense
|
||||||
{
|
{
|
||||||
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId,
|
MinistryId = r.MinistryId,
|
||||||
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName,
|
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,
|
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||||
FunctionalClass = r.FunctionalClass,
|
Lines = BuildLines(r.Lines),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (r.Type == "VendorPayment")
|
if (r.Type == "VendorPayment")
|
||||||
@@ -161,6 +275,7 @@ public class ExpenseService : IExpenseService
|
|||||||
e.VendorName = null;
|
e.VendorName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
e.PayeeId = r.PayeeId;
|
||||||
_db.Expenses.Add(e);
|
_db.Expenses.Add(e);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
return e.Id;
|
return e.Id;
|
||||||
@@ -174,16 +289,21 @@ public class ExpenseService : IExpenseService
|
|||||||
|
|
||||||
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
|
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
|
||||||
{
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
|
// 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.");
|
?? throw new KeyNotFoundException($"Expense {id} not found.");
|
||||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
|
||||||
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements.");
|
throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
|
||||||
|
|
||||||
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId;
|
e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
||||||
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
|
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId;
|
||||||
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.FunctionalClass = r.FunctionalClass;
|
|
||||||
if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
|
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();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +326,11 @@ public class ExpenseService : IExpenseService
|
|||||||
{
|
{
|
||||||
var e = await RequireAsync(id);
|
var e = await RequireAsync(id);
|
||||||
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
|
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
|
||||||
if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
|
// Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
|
||||||
|
if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
|
||||||
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
|
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
|
||||||
|
// Clear the prior review so the expense returns to a clean pending state.
|
||||||
|
e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +353,11 @@ public class ExpenseService : IExpenseService
|
|||||||
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
|
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
|
||||||
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
|
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_audit.Write(
|
||||||
|
AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
|
||||||
|
entityName: nameof(Expense), entityId: e.Id.ToString(),
|
||||||
|
summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
|
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
|
||||||
@@ -248,8 +376,8 @@ public class ExpenseService : IExpenseService
|
|||||||
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
|
||||||
{
|
{
|
||||||
var e = await RequireAsync(id);
|
var e = await RequireAsync(id);
|
||||||
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval")))
|
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
|
||||||
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements.");
|
throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
|
||||||
|
|
||||||
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
var safe = Path.GetFileName(fileName).Replace(' ', '_');
|
||||||
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class ExpenseSnapshotService : IExpenseSnapshotService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http)
|
||||||
|
{ _db = db; _http = http; }
|
||||||
|
|
||||||
|
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
|
||||||
|
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
|
||||||
|
// then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production.
|
||||||
|
private string CurrentUserId =>
|
||||||
|
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? _http.HttpContext?.User.FindFirstValue("sub")
|
||||||
|
?? "system";
|
||||||
|
|
||||||
|
public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
|
||||||
|
{
|
||||||
|
var snaps = await _db.ExpenseSnapshots.AsNoTracking()
|
||||||
|
.OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (snaps.Count == 0) return new();
|
||||||
|
|
||||||
|
var ids = snaps.Select(s => s.Id).ToList();
|
||||||
|
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
|
||||||
|
.Where(l => ids.Contains(l.SnapshotId)).ToListAsync();
|
||||||
|
var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
|
||||||
|
var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy));
|
||||||
|
|
||||||
|
return snaps.Select(s =>
|
||||||
|
{
|
||||||
|
linesBySnapshot.TryGetValue(s.Id, out var ls);
|
||||||
|
return new ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId,
|
||||||
|
MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""),
|
||||||
|
Description = s.Description, VendorName = s.VendorName,
|
||||||
|
CheckNumber = s.CheckNumber, Notes = s.Notes,
|
||||||
|
TotalAmount = ls?.Sum(l => l.Amount) ?? 0,
|
||||||
|
LineCount = ls?.Count ?? 0,
|
||||||
|
CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy),
|
||||||
|
CreatedAt = s.CreatedAt,
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExpenseSnapshotDto?> GetByIdAsync(int id)
|
||||||
|
{
|
||||||
|
var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (s is null) return null;
|
||||||
|
|
||||||
|
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
|
||||||
|
.Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync();
|
||||||
|
var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
|
||||||
|
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
|
||||||
|
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en);
|
||||||
|
var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy);
|
||||||
|
|
||||||
|
return new ExpenseSnapshotDto
|
||||||
|
{
|
||||||
|
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName,
|
||||||
|
Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes,
|
||||||
|
TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count,
|
||||||
|
CreatedByName = creatorName, CreatedAt = s.CreatedAt,
|
||||||
|
Lines = lines.Select(l => new ExpenseSnapshotLineDto
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
|
||||||
|
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
|
||||||
|
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> CreateAsync(CreateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
|
var s = new ExpenseSnapshot
|
||||||
|
{
|
||||||
|
Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description,
|
||||||
|
VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes,
|
||||||
|
Lines = BuildLines(r.Lines),
|
||||||
|
};
|
||||||
|
_db.ExpenseSnapshots.Add(s);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
return s.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r)
|
||||||
|
{
|
||||||
|
ValidateLines(r.Lines);
|
||||||
|
var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
|
||||||
|
|
||||||
|
s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description;
|
||||||
|
s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes;
|
||||||
|
|
||||||
|
_db.ExpenseSnapshotLines.RemoveRange(s.Lines);
|
||||||
|
s.Lines = BuildLines(r.Lines);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteAsync(int id)
|
||||||
|
{
|
||||||
|
var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
|
||||||
|
s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateLines(List<ExpenseLineInput> lines)
|
||||||
|
{
|
||||||
|
if (lines is null || lines.Count == 0)
|
||||||
|
throw new InvalidOperationException("A snapshot must have at least one line.");
|
||||||
|
foreach (var l in lines)
|
||||||
|
{
|
||||||
|
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
|
||||||
|
throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
|
||||||
|
if (l.Amount <= 0)
|
||||||
|
throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
|
||||||
|
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
|
||||||
|
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ExpenseSnapshotLine> BuildLines(List<ExpenseLineInput> inputs) =>
|
||||||
|
inputs.Select(l => new ExpenseSnapshotLine
|
||||||
|
{
|
||||||
|
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||||
|
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked
|
||||||
|
// Member's full name when present, otherwise the account email. Mirrors ExpenseService.
|
||||||
|
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
|
||||||
|
{
|
||||||
|
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
|
||||||
|
if (ids.Count == 0) return new();
|
||||||
|
|
||||||
|
var users = await _db.Users.AsNoTracking()
|
||||||
|
.Where(u => ids.Contains(u.Id))
|
||||||
|
.Select(u => new { u.Id, u.Email, u.MemberId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
|
||||||
|
var memberNames = await _db.Members.AsNoTracking()
|
||||||
|
.Where(m => memberIds.Contains(m.Id))
|
||||||
|
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
|
||||||
|
|
||||||
|
return users.ToDictionary(
|
||||||
|
u => u.Id,
|
||||||
|
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
|
||||||
|
? name
|
||||||
|
: (u.Email ?? u.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,16 +54,23 @@ public class FinanceDashboardService : IFinanceDashboardService
|
|||||||
{
|
{
|
||||||
var q = PaidApproved(from, to);
|
var q = PaidApproved(from, to);
|
||||||
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
|
||||||
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.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;
|
List<(int Id, decimal Amount)> grouped;
|
||||||
if (categoryGroupId.HasValue)
|
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(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||||
.Select(x => (x.Id, x.Amount)).ToList();
|
.Select(x => (x.Id, x.Amount)).ToList();
|
||||||
else if (ministryId.HasValue)
|
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(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
|
||||||
.Select(x => (x.Id, x.Amount)).ToList();
|
.Select(x => (x.Id, x.Amount)).ToList();
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the
|
||||||
|
/// DevExpress RichEdit/Office API, mirroring <c>CheckPrintService</c>) and a filing-data CSV.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
|
||||||
|
var payerEin = string.IsNullOrWhiteSpace(church.PayerEin) ? "" : church.PayerEin;
|
||||||
|
var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}";
|
||||||
|
|
||||||
|
return
|
||||||
|
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
|
||||||
|
$"<h2 style=\"text-align:center;margin:0;\">Form 1099-NEC — Copy B (For Recipient)</h2>" +
|
||||||
|
$"<p style=\"text-align:center;margin:4px 0 16px 0;\"><b>Tax Year {taxYear}</b><br/>Nonemployee Compensation</p>" +
|
||||||
|
|
||||||
|
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"6\" width=\"100%\" style=\"border-collapse:collapse;\">" +
|
||||||
|
|
||||||
|
"<tr><td width=\"50%\" valign=\"top\">" +
|
||||||
|
"<b>PAYER’s name, address</b><br/>" +
|
||||||
|
$"{Encode(church.Name)}<br/>{payerAddress}" +
|
||||||
|
"</td>" +
|
||||||
|
"<td width=\"50%\" valign=\"top\">" +
|
||||||
|
$"<b>PAYER’s TIN (EIN)</b><br/>{Encode(payerEin)}" +
|
||||||
|
"</td></tr>" +
|
||||||
|
|
||||||
|
"<tr><td valign=\"top\">" +
|
||||||
|
"<b>RECIPIENT’s name, address</b><br/>" +
|
||||||
|
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
|
||||||
|
"</td>" +
|
||||||
|
"<td valign=\"top\">" +
|
||||||
|
$"<b>RECIPIENT’s TIN</b><br/>{Encode(maskedTin)}" +
|
||||||
|
"</td></tr>" +
|
||||||
|
|
||||||
|
"<tr><td colspan=\"2\">" +
|
||||||
|
"<b>Box 1 — Nonemployee compensation</b><br/>" +
|
||||||
|
$"<span style=\"font-size:14pt;\"><b>{Encode(FormatCurrency(box1Nec))}</b></span>" +
|
||||||
|
"</td></tr>" +
|
||||||
|
|
||||||
|
"</table>" +
|
||||||
|
|
||||||
|
"<p style=\"font-size:8pt;color:#555;margin-top:12px;\">" +
|
||||||
|
"This is important tax information and is being furnished to the recipient. " +
|
||||||
|
"Recipient’s taxpayer identification number is shown masked for security." +
|
||||||
|
"</p>" +
|
||||||
|
"</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (<br/>) 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("<br/>", lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface I1099FormService
|
||||||
|
{
|
||||||
|
/// <summary>Recipient Copy B 1099-NEC PDF for one payee/year (plain paper).</summary>
|
||||||
|
Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear);
|
||||||
|
|
||||||
|
/// <summary>Filing-data CSV (one row per reportable recipient) for IRIS/accountant.</summary>
|
||||||
|
Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class Form1099ReportService : IForm1099ReportService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
public Form1099ReportService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<List<Form1099BoxDto>> 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();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<List<PaidLine>> 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<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear)
|
||||||
|
{
|
||||||
|
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
|
||||||
|
var lines = await LoadReportableLinesAsync(taxYear);
|
||||||
|
|
||||||
|
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).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);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Form1099RecipientDetailDto?> 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 LoadReportableLinesAsync(taxYear)).Where(x => x.PayeeId == payeeId).ToList();
|
||||||
|
|
||||||
|
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 { 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ namespace ROLAC.API.Services;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
|
/// Read-only aggregation that produces the IRS Form 990 Part IX Statement of Functional
|
||||||
/// Expenses. Expense scope matches FinanceDashboardService: Paid + Approved only.
|
/// 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>
|
/// </summary>
|
||||||
public class Form990ReportService : IForm990ReportService
|
public class Form990ReportService : IForm990ReportService
|
||||||
{
|
{
|
||||||
@@ -40,13 +40,14 @@ public class Form990ReportService : IForm990ReportService
|
|||||||
|
|
||||||
var rows = await (
|
var rows = await (
|
||||||
from e in expenses
|
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 m in _db.Ministries on e.MinistryId equals m.Id
|
||||||
join sub in _db.ExpenseSubCategories on e.SubCategoryId equals sub.Id
|
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
|
||||||
join grp in _db.ExpenseCategoryGroups on e.CategoryGroupId equals grp.Id
|
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
|
||||||
select new
|
select new
|
||||||
{
|
{
|
||||||
e.Amount,
|
l.Amount,
|
||||||
e.FunctionalClass,
|
l.FunctionalClass,
|
||||||
MinistryDefault = m.DefaultFunctionalClass,
|
MinistryDefault = m.DefaultFunctionalClass,
|
||||||
SubLineId = sub.Form990LineId,
|
SubLineId = sub.Form990LineId,
|
||||||
GroupLineId = grp.Form990LineId,
|
GroupLineId = grp.Form990LineId,
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IExpenseSnapshotService
|
||||||
|
{
|
||||||
|
Task<List<ExpenseSnapshotDto>> GetAllAsync();
|
||||||
|
Task<ExpenseSnapshotDto?> GetByIdAsync(int id);
|
||||||
|
Task<int> CreateAsync(CreateExpenseSnapshotRequest r);
|
||||||
|
Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r);
|
||||||
|
Task DeleteAsync(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using ROLAC.API.DTOs.Finance;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IForm1099ReportService
|
||||||
|
{
|
||||||
|
Task<List<Form1099BoxDto>> GetBoxesAsync();
|
||||||
|
Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear);
|
||||||
|
Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface IPayee1099Service
|
||||||
|
{
|
||||||
|
Task<List<Payee1099ListItemDto>> GetAllAsync(bool includeInactive);
|
||||||
|
Task<Payee1099Dto?> GetByIdAsync(int id);
|
||||||
|
Task<int> CreateAsync(SavePayee1099Request r);
|
||||||
|
Task UpdateAsync(int id, SavePayee1099Request r);
|
||||||
|
Task DeleteAsync(int id);
|
||||||
|
/// <summary>Full decrypted TIN. Caller must be authorized (gated at controller).</summary>
|
||||||
|
Task<string?> RevealTinAsync(int id);
|
||||||
|
/// <summary>Stores the uploaded W-9 blob and records its path. Throws KeyNotFoundException if the payee is missing.</summary>
|
||||||
|
Task SaveW9Async(int id, Stream content, string fileName);
|
||||||
|
/// <summary>Opens the stored W-9 blob; null when none is attached.</summary>
|
||||||
|
Task<(Stream stream, string contentType)?> OpenW9Async(int id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Payee;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Security;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class Payee1099Service : IPayee1099Service
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly ITinProtector _tin;
|
||||||
|
private readonly IFileStorage _storage;
|
||||||
|
|
||||||
|
public Payee1099Service(AppDbContext db, ITinProtector tin, IFileStorage storage)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_tin = tin;
|
||||||
|
_storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Payee1099ListItemDto>> 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<Payee1099Dto?> 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<int> 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<string?> RevealTinAsync(int id)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
return p?.TinEncrypted is null ? null : _tin.Unprotect(p.TinEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveW9Async(int id, Stream content, string fileName)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.FirstOrDefaultAsync(x => x.Id == id)
|
||||||
|
?? throw new KeyNotFoundException($"Payee1099 {id} not found.");
|
||||||
|
|
||||||
|
// Mirror the expense-receipt blob convention: a stable per-record path under a feature folder,
|
||||||
|
// preserving the original extension. Re-uploads overwrite the prior blob.
|
||||||
|
var ext = Path.GetExtension(fileName);
|
||||||
|
var path = $"finance/w9/{p.Id}{ext}";
|
||||||
|
if (p.W9BlobPath != null && p.W9BlobPath != path)
|
||||||
|
await _storage.DeleteAsync(p.W9BlobPath);
|
||||||
|
var saved = await _storage.SaveAsync(content, path);
|
||||||
|
p.W9BlobPath = saved;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(Stream stream, string contentType)?> OpenW9Async(int id)
|
||||||
|
{
|
||||||
|
var p = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
|
||||||
|
if (p?.W9BlobPath is null) return null;
|
||||||
|
var stream = await _storage.OpenReadAsync(p.W9BlobPath);
|
||||||
|
if (stream is null) return null;
|
||||||
|
var ext = Path.GetExtension(p.W9BlobPath).ToLowerInvariant();
|
||||||
|
var contentType = ext switch
|
||||||
|
{
|
||||||
|
".png" => "image/png", ".webp" => "image/webp", ".pdf" => "application/pdf",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
};
|
||||||
|
return (stream, contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ROLAC.API.Services.Security;
|
||||||
|
|
||||||
|
/// <summary>Reversible protection for taxpayer identification numbers (SSN/EIN).</summary>
|
||||||
|
public interface ITinProtector
|
||||||
|
{
|
||||||
|
string Protect(string plaintext);
|
||||||
|
string Unprotect(string ciphertext);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
/// <summary>Last four digits of a TIN (ignoring dashes/spaces); null/empty in => null.</summary>
|
||||||
|
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..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,5 +41,52 @@
|
|||||||
"Line": {
|
"Line": {
|
||||||
"ChannelAccessToken": "",
|
"ChannelAccessToken": "",
|
||||||
"ChannelSecret": ""
|
"ChannelSecret": ""
|
||||||
|
},
|
||||||
|
"Gemini": {
|
||||||
|
"ApiKey": "",
|
||||||
|
"Model": "gemini-2.5-flash-lite",
|
||||||
|
"BaseUrl": "https://generativelanguage.googleapis.com/v1beta"
|
||||||
|
},
|
||||||
|
"Claude": {
|
||||||
|
"ApiKey": "",
|
||||||
|
"Model": "claude-haiku-4-5-20251001",
|
||||||
|
"BaseUrl": "https://api.anthropic.com/v1",
|
||||||
|
"AnthropicVersion": "2023-06-01"
|
||||||
|
},
|
||||||
|
"Ai": {
|
||||||
|
"Provider": "Claude"
|
||||||
|
},
|
||||||
|
"CheckPrint": {
|
||||||
|
"//": "Field coordinates (inches) for pre-printed three-stub check stock. X = from page left edge; OffsetY = within the field's stub, added to the stub origin. Tune to match your stock, then restart — no recompile.",
|
||||||
|
"Layout": {
|
||||||
|
"//cal": "TextInset* compensates for the fixed inset the TextBox adds around its text so configured X/Y == actual ink. To recalibrate, set both to 0, print, measure the drift past a known X/Y, and enter the differences here.",
|
||||||
|
"TextInsetX": 0.13,
|
||||||
|
"TextInsetY": 0.15,
|
||||||
|
"CheckOriginY": 0.0,
|
||||||
|
"Receipt1OriginY": 3.67,
|
||||||
|
"Receipt2OriginY": 7.33,
|
||||||
|
"Payee": { "X": 1.1, "OffsetY": 1.35, "FontSize": 11, "Bold": true },
|
||||||
|
"AmountNumeric": { "X": 7.0, "OffsetY": 1.35, "FontSize": 12, "Bold": true },
|
||||||
|
"AmountWords": { "X": 0.30, "OffsetY": 1.67, "FontSize": 10 },
|
||||||
|
"Memo": { "X": 0.60, "OffsetY": 2.85, "FontSize": 9 },
|
||||||
|
"CheckDate": { "X": 7.00, "OffsetY": 0.90, "FontSize": 10 },
|
||||||
|
"ReceiptPayee": { "X": 1.00, "OffsetY": 0.30, "FontSize": 10, "Bold": true },
|
||||||
|
"ReceiptAmount": { "X": 6.50, "OffsetY": 0.30, "FontSize": 10, "Bold": true },
|
||||||
|
"ReceiptMemo": { "X": 1.00, "OffsetY": 0.60, "FontSize": 9 },
|
||||||
|
"ReceiptDate": { "X": 6.50, "OffsetY": 0.60, "FontSize": 9 },
|
||||||
|
"Grid": {
|
||||||
|
"OriginX": 0.60,
|
||||||
|
"OffsetY": 1.10,
|
||||||
|
"RowHeight": 0.22,
|
||||||
|
"ColumnGap": 0.30,
|
||||||
|
"DateWidth": 0.85,
|
||||||
|
"DescWidth": 2.10,
|
||||||
|
"AmountWidth": 0.80,
|
||||||
|
"ShowGridHeaders": true,
|
||||||
|
"HeaderOffsetY": 0.88,
|
||||||
|
"OverflowOffsetY": 2.55,
|
||||||
|
"FontSize": 8.5
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GivingsPageComponent } from './features/giving/pages/givings-page/givin
|
|||||||
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
|
import { OfferingSessionPageComponent } from './features/giving/pages/offering-session-page/offering-session-page.component';
|
||||||
import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component';
|
import { ExpenseCategoriesPageComponent } from './features/expense/pages/expense-categories-page/expense-categories-page.component';
|
||||||
import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component';
|
import { ExpensesPageComponent } from './features/expense/pages/expenses-page/expenses-page.component';
|
||||||
|
import { ExpenseSnapshotsPageComponent } from './features/expense/pages/expense-snapshots-page/expense-snapshots-page.component';
|
||||||
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
|
import { MyReimbursementsPageComponent } from './features/expense/pages/my-reimbursements-page/my-reimbursements-page.component';
|
||||||
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
|
import { MonthlyStatementPageComponent } from './features/expense/pages/monthly-statement-page/monthly-statement-page.component';
|
||||||
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
|
import { FinanceDashboardPageComponent } from './features/finance-dashboard/pages/finance-dashboard-page/finance-dashboard-page.component';
|
||||||
@@ -21,6 +22,8 @@ import { DisbursementPageComponent } from './features/disbursement/pages/disburs
|
|||||||
import { CheckRegisterPageComponent } from './features/disbursement/pages/check-register-page/check-register-page.component';
|
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 { 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 { 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 { 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 { 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';
|
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
|
||||||
@@ -162,6 +165,17 @@ export const routes: Routes = [
|
|||||||
title: 'Expenses', titleZh: '支出', section: 'Finance',
|
title: 'Expenses', titleZh: '支出', section: 'Finance',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/expense-snapshots',
|
||||||
|
component: ExpenseSnapshotsPageComponent,
|
||||||
|
canActivate: [PermissionGuard],
|
||||||
|
data: {
|
||||||
|
// Snapshots are a write-only management surface (the API gates every action on
|
||||||
|
// Expenses:Write), so require write — a read-only user has nothing to do here.
|
||||||
|
permission: { module: PermissionModules.Expenses, action: 'write' },
|
||||||
|
title: 'Expense Snapshots', titleZh: '費用範本', section: 'Finance',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'finance/expense-categories',
|
path: 'finance/expense-categories',
|
||||||
component: ExpenseCategoriesPageComponent,
|
component: ExpenseCategoriesPageComponent,
|
||||||
@@ -216,6 +230,24 @@ export const routes: Routes = [
|
|||||||
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
|
title: 'Form 990 — Functional Expenses', titleZh: 'Form 990 功能性費用表', section: 'Finance',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'finance/payee-1099',
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const PermissionModules = {
|
|||||||
SystemLogs: 'SystemLogs',
|
SystemLogs: 'SystemLogs',
|
||||||
AuditLogs: 'AuditLogs',
|
AuditLogs: 'AuditLogs',
|
||||||
Settings: 'Settings',
|
Settings: 'Settings',
|
||||||
|
Form1099: 'Form1099',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ export interface ChurchProfileDto {
|
|||||||
id: number; name: string; nameZh: string | null; phone: string | null;
|
id: number; name: string; nameZh: string | null; phone: string | null;
|
||||||
email: string | null; website: string | null; address: string | null; city: string | null;
|
email: string | null; website: string | null; address: string | null; city: string | null;
|
||||||
state: string | null; zipCode: string | null; bankName: string | null;
|
state: string | null; zipCode: string | null; bankName: string | null;
|
||||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
bankAccountNumber: string | null; bankRoutingNumber: string | null; payerEin: string | null; nextCheckNumber: number;
|
||||||
|
aiProvider: string;
|
||||||
|
claudeModel: string | null; claudeApiKeyMasked: string | null;
|
||||||
|
geminiModel: string | null; geminiApiKeyMasked: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateChurchProfileRequest = Omit<ChurchProfileDto, 'id'>;
|
export type UpdateChurchProfileRequest =
|
||||||
|
Omit<ChurchProfileDto, 'id' | 'claudeApiKeyMasked' | 'geminiApiKeyMasked'>
|
||||||
|
& { claudeApiKey: string | null; geminiApiKey: string | null };
|
||||||
|
|||||||
+51
@@ -55,6 +55,10 @@
|
|||||||
Routing # / 路由號碼
|
Routing # / 路由號碼
|
||||||
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Payer EIN / 雇主識別號 (EIN)
|
||||||
|
<kendo-textbox [(ngModel)]="model.payerEin" placeholder="XX-XXXXXXX"></kendo-textbox>
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
Next Check # / 下一張支票號碼
|
Next Check # / 下一張支票號碼
|
||||||
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||||
@@ -82,5 +86,52 @@
|
|||||||
<app-notification-settings-tab></app-notification-settings-tab>
|
<app-notification-settings-tab></app-notification-settings-tab>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</kendo-tabstrip-tab>
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 4: AI Settings (Settings permission) ───────────────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="AI Settings / AI 設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<div *ngIf="model" class="max-w-3xl pt-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
AI Provider / AI 供應商
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="aiProviders" textField="text" valueField="value" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="model.aiProvider"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Claude Model / Claude 模型
|
||||||
|
<kendo-textbox [(ngModel)]="model.claudeModel"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Claude API Key / Claude 金鑰
|
||||||
|
<input kendoTextBox type="password" autocomplete="new-password"
|
||||||
|
[(ngModel)]="claudeApiKeyInput"
|
||||||
|
[placeholder]="model.claudeApiKeyMasked || 'Enter key / 輸入金鑰'" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Gemini Model / Gemini 模型
|
||||||
|
<kendo-textbox [(ngModel)]="model.geminiModel"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Gemini API Key / Gemini 金鑰
|
||||||
|
<input kendoTextBox type="password" autocomplete="new-password"
|
||||||
|
[(ngModel)]="geminiApiKeyInput"
|
||||||
|
[placeholder]="model.geminiApiKeyMasked || 'Enter key / 輸入金鑰'" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="md:col-span-2 text-sm" style="color:#6b7280;">
|
||||||
|
Leave a key blank to keep the saved one. / 金鑰留空表示沿用已儲存的設定。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mt-4">
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
</kendo-tabstrip>
|
</kendo-tabstrip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+26
-4
@@ -4,8 +4,9 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { LayoutModule } from '@progress/kendo-angular-layout';
|
import { LayoutModule } from '@progress/kendo-angular-layout';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||||
import { ChurchProfileDto } from '../../models/disbursement.model';
|
import { ChurchProfileDto, UpdateChurchProfileRequest } from '../../models/disbursement.model';
|
||||||
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
import { PermissionModules } from '../../../../core/models/permission.model';
|
import { PermissionModules } from '../../../../core/models/permission.model';
|
||||||
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
|
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
|
||||||
@@ -15,7 +16,7 @@ import { NotificationSettingsTabComponent } from '../../../settings/components/n
|
|||||||
selector: 'app-church-profile-page',
|
selector: 'app-church-profile-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
|
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, DropDownsModule,
|
||||||
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
|
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './church-profile-page.component.html',
|
templateUrl: './church-profile-page.component.html',
|
||||||
@@ -25,6 +26,15 @@ export class ChurchProfilePageComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
savedMsg = '';
|
savedMsg = '';
|
||||||
|
|
||||||
|
/** Bound to the password inputs; blank means "keep the saved key". Reset after each save. */
|
||||||
|
claudeApiKeyInput = '';
|
||||||
|
geminiApiKeyInput = '';
|
||||||
|
|
||||||
|
readonly aiProviders = [
|
||||||
|
{ text: 'Claude', value: 'Claude' },
|
||||||
|
{ text: 'Gemini', value: 'Gemini' },
|
||||||
|
];
|
||||||
|
|
||||||
/** Settings module gates the Site / Notification tabs. */
|
/** Settings module gates the Site / Notification tabs. */
|
||||||
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
|
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
|
||||||
|
|
||||||
@@ -38,9 +48,21 @@ export class ChurchProfilePageComponent implements OnInit {
|
|||||||
if (!this.model || this.saving) return;
|
if (!this.model || this.saving) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.savedMsg = '';
|
this.savedMsg = '';
|
||||||
const { id, ...req } = this.model;
|
const { id, claudeApiKeyMasked, geminiApiKeyMasked, ...rest } = this.model;
|
||||||
|
const req: UpdateChurchProfileRequest = {
|
||||||
|
...rest,
|
||||||
|
claudeApiKey: this.claudeApiKeyInput.trim() || null,
|
||||||
|
geminiApiKey: this.geminiApiKeyInput.trim() || null,
|
||||||
|
};
|
||||||
this.api.updateChurchProfile(req).subscribe({
|
this.api.updateChurchProfile(req).subscribe({
|
||||||
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
|
next: () => {
|
||||||
|
this.saving = false;
|
||||||
|
this.savedMsg = 'Saved / 已儲存';
|
||||||
|
// Clear the key inputs and reload so the masked placeholders reflect the new keys.
|
||||||
|
this.claudeApiKeyInput = '';
|
||||||
|
this.geminiApiKeyInput = '';
|
||||||
|
this.api.getChurchProfile().subscribe(p => (this.model = p));
|
||||||
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
// Error message is shown globally by httpErrorInterceptor.
|
// Error message is shown globally by httpErrorInterceptor.
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
|||||||
+189
-74
@@ -1,5 +1,22 @@
|
|||||||
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="560" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
<kendo-dialog [title]="title" (close)="cancel.emit()" [width]="showReceiptPanel ? 1200 : 760" [maxWidth]="'95vw'"
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
[maxHeight]="'90vh'">
|
||||||
|
<!-- Two columns on desktop: form on the left, receipt preview on the right. Stacks on mobile. -->
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
|
||||||
|
<!-- Snapshot tools (vendor mode): quick-load a saved template, or save the current form -->
|
||||||
|
<div *ngIf="showSnapshotTools" class="md:col-span-2 flex flex-wrap items-end gap-2 rounded border border-gray-200 bg-gray-50 p-2">
|
||||||
|
<label class="flex flex-1 min-w-[14rem] flex-col gap-1">範本 / Load from snapshot
|
||||||
|
<kendo-dropdownlist [data]="snapshots" textField="name" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="selectedSnapshotId" (valueChange)="applySnapshot($event)"
|
||||||
|
[defaultItem]="{ id: null, name: '-- 選擇範本 / Select a saved snapshot --' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="!isValid" (click)="openSnapshotPrompt()"
|
||||||
|
title="Save the current form as a reusable snapshot / 儲存為範本">💾 存為範本 / Save as snapshot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
<!-- Continuous entry: keep member/ministry/category/date after each save (on-behalf reimbursement only) -->
|
||||||
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="showContinueEntry" class="flex items-center gap-2 md:col-span-2">
|
||||||
@@ -7,91 +24,141 @@
|
|||||||
<span>連續登打 / Continuous Entry</span>
|
<span>連續登打 / Continuous Entry</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<!-- Description (with AI assist: translate to English + suggest a category) -->
|
||||||
|
<div class="flex flex-col gap-1 md:col-span-2">
|
||||||
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<label class="flex flex-1 flex-col gap-1">Description
|
||||||
|
<kendo-textbox [(ngModel)]="form.description"
|
||||||
|
placeholder="Brief description of expense / 費用說明(可輸入中文)"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="!form.description.trim() || aiLoading" (click)="requestAiAssist()"
|
||||||
|
title="Translate to English and suggest a category / 翻譯並建議分類">
|
||||||
|
{{ aiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suggestion card: the "suggest & confirm" step — user applies or dismisses -->
|
||||||
|
<div *ngIf="hasAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
|
||||||
|
<div *ngIf="aiSuggestedDescription" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">說明 / Description:</span>
|
||||||
|
<span class="font-medium">{{ aiSuggestedDescription }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="aiSuggestion?.groupLabel" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">分類 / Category:</span>
|
||||||
|
<span class="font-medium">{{ aiSuggestion?.groupLabel }}<span *ngIf="aiSuggestion?.subLabel"> → {{
|
||||||
|
aiSuggestion?.subLabel }}</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">信心 / Confidence: {{ (aiSuggestion?.confidence ?? 0) * 100 | number:'1.0-0'
|
||||||
|
}}%</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyAiSuggestion()">套用 /
|
||||||
|
Apply</button>
|
||||||
|
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissAiSuggestion()">忽略 /
|
||||||
|
Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Member picker (finance creating on behalf of a member) -->
|
<!-- Member picker (finance creating on behalf of a member) -->
|
||||||
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
|
<label *ngIf="allowMemberPick" class="flex flex-col gap-1 md:col-span-2">Member
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist [data]="memberResults" textField="displayName" valueField="id" [valuePrimitive]="true"
|
||||||
[data]="memberResults"
|
[filterable]="true" (filterChange)="onMemberFilter($event)" [(ngModel)]="form.memberId"
|
||||||
textField="displayName"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[filterable]="true"
|
|
||||||
(filterChange)="onMemberFilter($event)"
|
|
||||||
[(ngModel)]="form.memberId"
|
|
||||||
placeholder="Search member by name">
|
placeholder="Search member by name">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Ministry -->
|
<!-- Ministry -->
|
||||||
<label class="flex flex-col gap-1">Ministry
|
<label class="flex flex-col gap-1">Ministry
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist [data]="ministries" textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
[data]="ministries"
|
[(ngModel)]="form.ministryId" [defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
|
||||||
textField="label"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[(ngModel)]="form.ministryId"
|
|
||||||
[defaultItem]="{ id: null, label: '-- Select ministry --/請選擇事工' }">
|
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Category Group -->
|
|
||||||
<label class="flex flex-col gap-1">Category Group
|
|
||||||
<kendo-dropdownlist
|
|
||||||
[data]="groups"
|
|
||||||
textField="label"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[(ngModel)]="form.categoryGroupId"
|
|
||||||
(valueChange)="onGroupChange($event)"
|
|
||||||
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
|
|
||||||
</kendo-dropdownlist>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Sub-Category -->
|
|
||||||
<label class="flex flex-col gap-1">Sub-Category
|
|
||||||
<kendo-dropdownlist
|
|
||||||
[data]="subs"
|
|
||||||
textField="label"
|
|
||||||
valueField="id"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[(ngModel)]="form.subCategoryId"
|
|
||||||
[defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
|
|
||||||
[disabled]="!form.categoryGroupId">
|
|
||||||
</kendo-dropdownlist>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Functional Class override -->
|
|
||||||
<label class="flex flex-col gap-1">
|
|
||||||
<span>Functional Class / 功能別</span>
|
|
||||||
<kendo-dropdownlist
|
|
||||||
[data]="functionalClassOptions"
|
|
||||||
textField="label"
|
|
||||||
valueField="value"
|
|
||||||
[valuePrimitive]="true"
|
|
||||||
[defaultItem]="{ value: null, label: '(Inherit ministry / 沿用事工)' }"
|
|
||||||
[(ngModel)]="form.functionalClass">
|
|
||||||
</kendo-dropdownlist>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Amount -->
|
|
||||||
<label class="flex flex-col gap-1">Amount
|
|
||||||
<kendo-numerictextbox
|
|
||||||
[(ngModel)]="form.amount"
|
|
||||||
[min]="0"
|
|
||||||
[format]="'c2'">
|
|
||||||
</kendo-numerictextbox>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<!-- Expense Date -->
|
<!-- Expense Date -->
|
||||||
<label class="flex flex-col gap-1">Expense Date
|
<label class="flex flex-col gap-1">Expense Date
|
||||||
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
|
<kendo-datepicker [(ngModel)]="form.expenseDate"></kendo-datepicker>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Category lines: one invoice can span several categories -->
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">Description
|
<div class="md:col-span-2 flex flex-col gap-2">
|
||||||
<kendo-textbox [(ngModel)]="form.description" placeholder="Brief description of expense"></kendo-textbox>
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">明細 / Line Items</span>
|
||||||
|
<span class="text-sm text-gray-600">合計 / Total: {{ totalAmount | currency }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngFor="let line of lines; let i = index" class="flex flex-col gap-3 rounded border border-gray-200 p-3">
|
||||||
|
<!-- Line header: number + remove -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-600">明細 {{ i + 1 }} / Item {{ i + 1 }}</span>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" size="small" type="button"
|
||||||
|
[disabled]="!line.description.trim() || line.aiLoading" (click)="requestLineAiAssist(line)"
|
||||||
|
title="Translate this line and suggest a category / 翻譯並建議此列分類">
|
||||||
|
{{ line.aiLoading ? '思考中… / Thinking…' : '✨ AI 建議此列' }}
|
||||||
|
</button>
|
||||||
|
<button kendoButton fillMode="flat" themeColor="error" size="small" [disabled]="lines.length === 1"
|
||||||
|
(click)="removeLine(i)" title="Remove line / 刪除此列">✕ 刪除</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 1: Category Group + Sub-Category -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1">Category Group
|
||||||
|
<kendo-dropdownlist [data]="groups" textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="line.categoryGroupId" (valueChange)="onLineGroupChange(line, $event)"
|
||||||
|
[defaultItem]="{ id: null, label: '-- Select group --/請選擇大類' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Vendor mode: vendor name + check number -->
|
<label class="flex flex-col gap-1">Sub-Category
|
||||||
|
<kendo-dropdownlist [data]="line.subs" textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="line.subCategoryId" [defaultItem]="{ id: null, label: '-- Select sub-category --/請選擇子項' }"
|
||||||
|
[disabled]="!line.categoryGroupId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Description (optional) + Amount -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<label class="flex flex-col gap-1"><span>Description / 說明 <span class="text-gray-400">(Optional)</span></span>
|
||||||
|
<kendo-textbox [(ngModel)]="line.description" placeholder="e.g. 點心、文具…"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1">Amount
|
||||||
|
<kendo-numerictextbox [(ngModel)]="line.amount" [min]="0" [format]="'c2'"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-line suggestion card: the "suggest & confirm" step for this line -->
|
||||||
|
<div *ngIf="hasLineSuggestion(line)"
|
||||||
|
class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
|
||||||
|
<div *ngIf="lineSuggestedDescription(line)" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">說明 / Description:</span>
|
||||||
|
<span class="font-medium">{{ lineSuggestedDescription(line) }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="line.aiSuggestion?.groupLabel" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">分類 / Category:</span>
|
||||||
|
<span class="font-medium">{{ line.aiSuggestion?.groupLabel }}<span *ngIf="line.aiSuggestion?.subLabel"> →
|
||||||
|
{{ line.aiSuggestion?.subLabel }}</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">信心 / Confidence: {{ (line.aiSuggestion?.confidence ?? 0) * 100 |
|
||||||
|
number:'1.0-0' }}%</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" size="small" type="button"
|
||||||
|
(click)="applyLineAiSuggestion(line)">套用 / Apply</button>
|
||||||
|
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissLineAiSuggestion(line)">忽略
|
||||||
|
/ Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button kendoButton fillMode="outline" (click)="addLine()">+ 新增一列 / Add Line</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Vendor mode: vendor name + check number + optional 1099 recipient -->
|
||||||
<ng-container *ngIf="mode === 'vendor'">
|
<ng-container *ngIf="mode === 'vendor'">
|
||||||
<label class="flex flex-col gap-1">Vendor Name
|
<label class="flex flex-col gap-1">Vendor Name
|
||||||
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="form.vendorName" placeholder="Payee / vendor name"></kendo-textbox>
|
||||||
@@ -99,6 +166,12 @@
|
|||||||
<label class="flex flex-col gap-1">Check #
|
<label class="flex flex-col gap-1">Check #
|
||||||
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="form.checkNumber" placeholder="Check number (optional)"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 md:col-span-2">1099 Recipient / 1099 收款人 <span class="text-gray-400 font-normal">(optional)</span>
|
||||||
|
<kendo-dropdownlist [data]="payees" textField="legalName" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, legalName: '— none —' }"
|
||||||
|
[(ngModel)]="form.payeeId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<!-- Reimbursement mode: receipt file input -->
|
<!-- Reimbursement mode: receipt file input -->
|
||||||
@@ -109,20 +182,62 @@
|
|||||||
from bubbling up to the host, where it would collide with this component's
|
from bubbling up to the host, where it would collide with this component's
|
||||||
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
|
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
|
||||||
-->
|
-->
|
||||||
<input
|
<input #receiptInput type="file" accept="image/*,application/pdf" (change)="onFileSelected($event)"
|
||||||
#receiptInput
|
|
||||||
type="file"
|
|
||||||
accept="image/*,application/pdf"
|
|
||||||
(change)="onFileSelected($event)"
|
|
||||||
(cancel)="$event.stopPropagation()"
|
(cancel)="$event.stopPropagation()"
|
||||||
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
|
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
|
||||||
</label>
|
</label>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- /left form column -->
|
||||||
|
|
||||||
|
<!-- Right: receipt preview (shown once a file is selected or an existing receipt is loaded) -->
|
||||||
|
<div *ngIf="showReceiptPanel" class="md:w-[34rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">收據預覽 / Receipt</span>
|
||||||
|
<!-- Zoom controls (image only; PDF uses the browser viewer's own zoom) -->
|
||||||
|
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()" [disabled]="receiptZoom <= minZoom"
|
||||||
|
title="縮小 / Zoom out">−</button>
|
||||||
|
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()" [disabled]="receiptZoom >= maxZoom"
|
||||||
|
title="放大 / Zoom in">+</button>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset">⟲</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable so a zoomed-in image can be panned for comparison -->
|
||||||
|
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50"
|
||||||
|
style="max-height: 72vh;">
|
||||||
|
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100"
|
||||||
|
class="block max-w-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
|
||||||
|
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- /two-column body -->
|
||||||
|
|
||||||
<kendo-dialog-actions>
|
<kendo-dialog-actions>
|
||||||
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
<button kendoButton (click)="cancel.emit()">Cancel</button>
|
||||||
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
<button kendoButton themeColor="primary" [disabled]="!isValid" (click)="emitSave()">Save</button>
|
||||||
</kendo-dialog-actions>
|
</kendo-dialog-actions>
|
||||||
</kendo-dialog>
|
</kendo-dialog>
|
||||||
|
|
||||||
|
<!-- Save-as-snapshot name prompt -->
|
||||||
|
<kendo-dialog *ngIf="showSnapshotNamePrompt" title="存為範本 / Save as Snapshot" [width]="420" [maxWidth]="'95vw'"
|
||||||
|
(close)="cancelSnapshotPrompt()">
|
||||||
|
<div class="flex flex-col gap-2 p-2">
|
||||||
|
<label class="flex flex-col gap-1">名稱 / Name
|
||||||
|
<kendo-textbox [(ngModel)]="snapshotName" placeholder="e.g. Monthly Rent — Landlord X"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500">費用日期不會存入範本 / The Expense Date is not saved in a snapshot.</p>
|
||||||
|
</div>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelSnapshotPrompt()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!snapshotName.trim() || snapshotSaving"
|
||||||
|
(click)="saveSnapshot()">{{ snapshotSaving ? '儲存中… / Saving…' : 'Save' }}</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
+318
-40
@@ -1,6 +1,7 @@
|
|||||||
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
|
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
@@ -8,11 +9,17 @@ import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
|||||||
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
import { DateInputsModule } from '@progress/kendo-angular-dateinputs';
|
||||||
import { MinistryApiService } from '../../services/ministry-api.service';
|
import { MinistryApiService } from '../../services/ministry-api.service';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
|
import { ExpenseSnapshotApiService } from '../../services/expense-snapshot-api.service';
|
||||||
|
import { ExpenseSnapshotDto, CreateExpenseSnapshotRequest } from '../../models/expense-snapshot.model';
|
||||||
|
import { ExpenseAiService } from '../../services/expense-ai.service';
|
||||||
import { MemberApiService } from '../../../members/services/member-api.service';
|
import { MemberApiService } from '../../../members/services/member-api.service';
|
||||||
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
import { MemberListItemDto, memberDisplayName } from '../../../members/models/member.model';
|
||||||
|
import { Payee1099ApiService } from '../../../payee1099/services/payee1099-api.service';
|
||||||
|
import { Payee1099ListItem } from '../../../payee1099/models/payee1099.model';
|
||||||
import {
|
import {
|
||||||
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
MinistryDto, ExpenseCategoryGroupDto, ExpenseSubCategoryDto, ExpenseType, CreateExpenseRequest,
|
||||||
ExpenseListItemDto, FunctionalClass,
|
ExpenseDto, FunctionalClass, ExpenseAiSuggestion,
|
||||||
} from '../../models/expense.model';
|
} from '../../models/expense.model';
|
||||||
|
|
||||||
export interface ExpenseFormResult {
|
export interface ExpenseFormResult {
|
||||||
@@ -25,18 +32,33 @@ export interface ExpenseFormResult {
|
|||||||
/** Flattened member item with a single displayName field for the dropdown. */
|
/** Flattened member item with a single displayName field for the dropdown. */
|
||||||
interface MemberOption { id: number; displayName: string; }
|
interface MemberOption { id: number; displayName: string; }
|
||||||
|
|
||||||
|
/** One editable category line. `subs` holds the sub-category list for this row's chosen group. */
|
||||||
|
interface ExpenseLineForm {
|
||||||
|
categoryGroupId: number | null;
|
||||||
|
subCategoryId: number | null;
|
||||||
|
amount: number;
|
||||||
|
description: string;
|
||||||
|
/** Functional class is no longer exposed in the form (too complex for volunteers); it stays
|
||||||
|
* null = inherit the ministry default. Kept here so existing overrides survive an edit. */
|
||||||
|
functionalClass: FunctionalClass | null;
|
||||||
|
subs: ExpenseSubCategoryDto[];
|
||||||
|
/** Per-line AI assist state (suggest & confirm), independent of the header assist. */
|
||||||
|
aiLoading?: boolean;
|
||||||
|
aiSuggestion?: ExpenseAiSuggestion | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expense-form-dialog',
|
selector: 'app-expense-form-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
|
imports: [CommonModule, FormsModule, InputsModule, ButtonsModule, DialogsModule, DropDownsModule, DateInputsModule],
|
||||||
templateUrl: './expense-form-dialog.component.html',
|
templateUrl: './expense-form-dialog.component.html',
|
||||||
})
|
})
|
||||||
export class ExpenseFormDialogComponent implements OnInit {
|
export class ExpenseFormDialogComponent implements OnInit, OnDestroy {
|
||||||
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
|
@Input() mode: 'vendor' | 'reimbursement' = 'reimbursement';
|
||||||
@Input() allowMemberPick = false;
|
@Input() allowMemberPick = false;
|
||||||
@Input() title = 'New Expense';
|
@Input() title = 'New Expense';
|
||||||
/** When set, the dialog prefills from this row for editing instead of starting blank. */
|
/** When set, the dialog prefills from this expense (with its lines) for editing. */
|
||||||
@Input() expense: ExpenseListItemDto | null = null;
|
@Input() expense: ExpenseDto | null = null;
|
||||||
@Output() save = new EventEmitter<ExpenseFormResult>();
|
@Output() save = new EventEmitter<ExpenseFormResult>();
|
||||||
@Output() cancel = new EventEmitter<void>();
|
@Output() cancel = new EventEmitter<void>();
|
||||||
|
|
||||||
@@ -45,19 +67,27 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
ministries: MinistryDto[] = [];
|
ministries: MinistryDto[] = [];
|
||||||
groups: ExpenseCategoryGroupDto[] = [];
|
groups: ExpenseCategoryGroupDto[] = [];
|
||||||
subs: ExpenseSubCategoryDto[] = [];
|
|
||||||
|
payees: Payee1099ListItem[] = [];
|
||||||
|
|
||||||
|
/** Saved snapshots (vendor mode only) for the "Load from snapshot" picker. */
|
||||||
|
snapshots: ExpenseSnapshotDto[] = [];
|
||||||
|
/** Picker binding; reset to null after each apply so the same snapshot can be re-picked. */
|
||||||
|
selectedSnapshotId: number | null = null;
|
||||||
|
|
||||||
|
/** "Save as snapshot" name-prompt state. */
|
||||||
|
showSnapshotNamePrompt = false;
|
||||||
|
snapshotName = '';
|
||||||
|
snapshotSaving = false;
|
||||||
|
|
||||||
|
/** Snapshot tools (load/save) are a vendor-payment feature only. */
|
||||||
|
get showSnapshotTools(): boolean { return this.mode === 'vendor'; }
|
||||||
|
|
||||||
memberResults: MemberOption[] = [];
|
memberResults: MemberOption[] = [];
|
||||||
|
|
||||||
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
/** Continuous-entry toggle: keep member/ministry/category/date and the dialog open after each save. */
|
||||||
continueEntry = false;
|
continueEntry = false;
|
||||||
|
|
||||||
readonly functionalClassOptions: { value: FunctionalClass; label: string }[] = [
|
|
||||||
{ value: 'Program', label: 'Program / 事工服務' },
|
|
||||||
{ value: 'ManagementGeneral', label: 'Management & General / 管理' },
|
|
||||||
{ value: 'Fundraising', label: 'Fundraising / 募款' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */
|
/** The on-behalf reimbursement create flow is the only place continuous entry applies. */
|
||||||
get showContinueEntry(): boolean {
|
get showContinueEntry(): boolean {
|
||||||
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
|
return this.mode === 'reimbursement' && this.allowMemberPick && !this.expense;
|
||||||
@@ -65,56 +95,213 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
form = {
|
form = {
|
||||||
ministryId: null as number | null,
|
ministryId: null as number | null,
|
||||||
categoryGroupId: null as number | null,
|
|
||||||
subCategoryId: null as number | null,
|
|
||||||
amount: 0,
|
|
||||||
description: '',
|
description: '',
|
||||||
vendorName: '',
|
vendorName: '',
|
||||||
checkNumber: '',
|
checkNumber: '',
|
||||||
memberId: null as number | null,
|
memberId: null as number | null,
|
||||||
|
payeeId: null as number | null,
|
||||||
expenseDate: new Date(),
|
expenseDate: new Date(),
|
||||||
functionalClass: null as FunctionalClass | null,
|
|
||||||
};
|
};
|
||||||
|
/** At least one line always; "+ Add line" appends, each line is independently removable down to one. */
|
||||||
|
lines: ExpenseLineForm[] = [this.emptyLine()];
|
||||||
receipt: File | null = null;
|
receipt: File | null = null;
|
||||||
|
|
||||||
|
// ── Receipt preview (right panel) ────────────────────────────────────────
|
||||||
|
/** Blob URL for an image receipt, bound directly to <img [src]>. */
|
||||||
|
receiptImageUrl: string | null = null;
|
||||||
|
/** Sanitized blob URL for a PDF receipt, bound to <iframe [src]>. */
|
||||||
|
receiptPdfUrl: SafeResourceUrl | null = null;
|
||||||
|
/** Raw object URL kept so it can be revoked. */
|
||||||
|
private receiptObjectUrl: string | null = null;
|
||||||
|
|
||||||
|
/** Image zoom factor (1 = fit panel width); lets volunteers blow up a receipt to compare. */
|
||||||
|
receiptZoom = 1;
|
||||||
|
readonly minZoom = 0.5;
|
||||||
|
readonly maxZoom = 5;
|
||||||
|
|
||||||
|
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
|
||||||
|
|
||||||
|
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
|
||||||
|
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
|
||||||
|
resetZoom(): void { this.receiptZoom = 1; }
|
||||||
|
|
||||||
|
// ── AI assist (translate description + suggest category) ────────────────────
|
||||||
|
/** True while an assist request is in flight (disables the button, shows a spinner label). */
|
||||||
|
aiLoading = false;
|
||||||
|
/** The latest suggestion awaiting the user's Apply/Dismiss decision; null when none is shown. */
|
||||||
|
aiSuggestion: ExpenseAiSuggestion | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private ministryApi: MinistryApiService,
|
private ministryApi: MinistryApiService,
|
||||||
private catApi: ExpenseCategoryApiService,
|
private catApi: ExpenseCategoryApiService,
|
||||||
private memberApi: MemberApiService,
|
private memberApi: MemberApiService,
|
||||||
|
private expenseApi: ExpenseApiService,
|
||||||
|
private snapshotApi: ExpenseSnapshotApiService,
|
||||||
|
private aiApi: ExpenseAiService,
|
||||||
|
private payeeApi: Payee1099ApiService,
|
||||||
|
private sanitizer: DomSanitizer,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
this.ministryApi.getAll().subscribe(m => (this.ministries = m));
|
||||||
|
this.payeeApi.getAll(false).subscribe(list => (this.payees = list));
|
||||||
|
if (this.showSnapshotTools) this.loadSnapshots();
|
||||||
this.catApi.getAll(false).subscribe(groups => {
|
this.catApi.getAll(false).subscribe(groups => {
|
||||||
this.groups = groups;
|
this.groups = groups;
|
||||||
// Populate the sub-category list for the prefilled group so its value displays on edit.
|
// Populate each line's sub-category list once the catalog is loaded (edit mode).
|
||||||
if (this.expense) {
|
if (this.expense) this.hydrateLineSubs();
|
||||||
this.subs = this.groups.find(group => group.id === this.expense!.categoryGroupId)?.subCategories ?? [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (this.expense) this.prefill(this.expense);
|
if (this.expense) {
|
||||||
|
this.prefill(this.expense);
|
||||||
|
// Edit mode: load the existing receipt into the preview panel.
|
||||||
|
if (this.expense.hasReceipt) {
|
||||||
|
this.expenseApi.downloadReceipt(this.expense.id)
|
||||||
|
.subscribe(blob => this.setPreview(blob, blob.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private prefill(expense: ExpenseListItemDto): void {
|
ngOnDestroy(): void { this.clearPreview(); }
|
||||||
|
|
||||||
|
private emptyLine(): ExpenseLineForm {
|
||||||
|
return { categoryGroupId: null, subCategoryId: null, amount: 0, description: '', functionalClass: null, subs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefill(expense: ExpenseDto): void {
|
||||||
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
|
// expenseDate is a "yyyy-MM-dd" string; build a local Date to avoid a timezone day-shift.
|
||||||
const [year, month, day] = expense.expenseDate.split('-').map(Number);
|
const [year, month, day] = expense.expenseDate.split('-').map(Number);
|
||||||
this.form = {
|
this.form = {
|
||||||
ministryId: expense.ministryId,
|
ministryId: expense.ministryId,
|
||||||
categoryGroupId: expense.categoryGroupId,
|
|
||||||
subCategoryId: expense.subCategoryId,
|
|
||||||
amount: expense.amount,
|
|
||||||
description: expense.description,
|
description: expense.description,
|
||||||
vendorName: expense.vendorName ?? '',
|
vendorName: expense.vendorName ?? '',
|
||||||
checkNumber: expense.checkNumber ?? '',
|
checkNumber: expense.checkNumber ?? '',
|
||||||
memberId: expense.memberId,
|
memberId: expense.memberId,
|
||||||
|
payeeId: expense.payeeId ?? null,
|
||||||
expenseDate: new Date(year, month - 1, day),
|
expenseDate: new Date(year, month - 1, day),
|
||||||
functionalClass: expense.functionalClass ?? null,
|
|
||||||
};
|
};
|
||||||
|
this.lines = (expense.lines ?? []).map(l => ({
|
||||||
|
categoryGroupId: l.categoryGroupId,
|
||||||
|
subCategoryId: l.subCategoryId,
|
||||||
|
amount: l.amount,
|
||||||
|
description: l.description ?? '',
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
subs: [],
|
||||||
|
}));
|
||||||
|
if (this.lines.length === 0) this.lines = [this.emptyLine()];
|
||||||
}
|
}
|
||||||
|
|
||||||
onGroupChange(groupId: number | null): void {
|
/** Fill each line's sub-category list from its chosen group (used after the catalog loads on edit). */
|
||||||
this.form.subCategoryId = null;
|
private hydrateLineSubs(): void {
|
||||||
this.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
|
for (const line of this.lines) {
|
||||||
|
line.subs = this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLineGroupChange(line: ExpenseLineForm, groupId: number | null): void {
|
||||||
|
line.subCategoryId = null;
|
||||||
|
line.subs = this.groups.find(g => g.id === groupId)?.subCategories ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ask the backend AI to translate the description and suggest a category; show it for confirmation. */
|
||||||
|
requestAiAssist(): void {
|
||||||
|
const text = this.form.description.trim();
|
||||||
|
if (!text || this.aiLoading) return;
|
||||||
|
this.aiLoading = true;
|
||||||
|
this.aiSuggestion = null;
|
||||||
|
this.aiApi.assist(text, this.totalAmount).subscribe({
|
||||||
|
next: suggestion => { this.aiSuggestion = suggestion; this.aiLoading = false; },
|
||||||
|
error: () => { this.aiLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a suggestion offers at least a translation or a category to apply. */
|
||||||
|
get hasAiSuggestion(): boolean {
|
||||||
|
const s = this.aiSuggestion;
|
||||||
|
return !!s && (!!this.aiSuggestedDescription || s.groupId != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Combine a suggestion's two halves into the "English / 中文" string that Apply writes. */
|
||||||
|
private combineDescription(suggestion: ExpenseAiSuggestion | null | undefined): string {
|
||||||
|
if (!suggestion) return '';
|
||||||
|
const en = suggestion.englishDescription?.trim() ?? '';
|
||||||
|
const zh = suggestion.chineseDescription?.trim() ?? '';
|
||||||
|
if (en && zh) return `${en} / ${zh}`;
|
||||||
|
return en || zh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The description that the header Apply will write. */
|
||||||
|
get aiSuggestedDescription(): string {
|
||||||
|
return this.combineDescription(this.aiSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the suggestion: set the description to "English / 中文" and set the first line's
|
||||||
|
* category/sub. Most expenses are single-line; multi-line users adjust the rest by hand.
|
||||||
|
*/
|
||||||
|
applyAiSuggestion(): void {
|
||||||
|
const suggestion = this.aiSuggestion;
|
||||||
|
if (!suggestion) return;
|
||||||
|
if (this.aiSuggestedDescription) this.form.description = this.aiSuggestedDescription;
|
||||||
|
if (suggestion.groupId != null) {
|
||||||
|
const firstLine = this.lines[0];
|
||||||
|
firstLine.categoryGroupId = suggestion.groupId;
|
||||||
|
// Populate the sub-category list for the chosen group, then select the suggested sub.
|
||||||
|
this.onLineGroupChange(firstLine, suggestion.groupId);
|
||||||
|
if (suggestion.subCategoryId != null) firstLine.subCategoryId = suggestion.subCategoryId;
|
||||||
|
}
|
||||||
|
this.aiSuggestion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissAiSuggestion(): void { this.aiSuggestion = null; }
|
||||||
|
|
||||||
|
// ── Per-line AI assist ──────────────────────────────────────────────────────
|
||||||
|
/** Ask the AI to translate this line's own note and suggest its category, using the line's amount. */
|
||||||
|
requestLineAiAssist(line: ExpenseLineForm): void {
|
||||||
|
const text = line.description.trim();
|
||||||
|
if (!text || line.aiLoading) return;
|
||||||
|
line.aiLoading = true;
|
||||||
|
line.aiSuggestion = null;
|
||||||
|
this.aiApi.assist(text, line.amount).subscribe({
|
||||||
|
next: suggestion => { line.aiSuggestion = suggestion; line.aiLoading = false; },
|
||||||
|
error: () => { line.aiLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The description that this line's Apply will write: "English / 中文". */
|
||||||
|
lineSuggestedDescription(line: ExpenseLineForm): string {
|
||||||
|
return this.combineDescription(line.aiSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once a line suggestion offers a translation or a category to apply. */
|
||||||
|
hasLineSuggestion(line: ExpenseLineForm): boolean {
|
||||||
|
return !!line.aiSuggestion && (!!this.lineSuggestedDescription(line) || line.aiSuggestion.groupId != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply this line's suggestion to itself: set its description (bilingual) and category/sub. */
|
||||||
|
applyLineAiSuggestion(line: ExpenseLineForm): void {
|
||||||
|
const suggestion = line.aiSuggestion;
|
||||||
|
if (!suggestion) return;
|
||||||
|
const description = this.lineSuggestedDescription(line);
|
||||||
|
if (description) line.description = description;
|
||||||
|
if (suggestion.groupId != null) {
|
||||||
|
line.categoryGroupId = suggestion.groupId;
|
||||||
|
// Populate the sub-category list for the chosen group, then select the suggested sub.
|
||||||
|
this.onLineGroupChange(line, suggestion.groupId);
|
||||||
|
if (suggestion.subCategoryId != null) line.subCategoryId = suggestion.subCategoryId;
|
||||||
|
}
|
||||||
|
line.aiSuggestion = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissLineAiSuggestion(line: ExpenseLineForm): void { line.aiSuggestion = null; }
|
||||||
|
|
||||||
|
addLine(): void { this.lines.push(this.emptyLine()); }
|
||||||
|
|
||||||
|
removeLine(index: number): void {
|
||||||
|
if (this.lines.length > 1) this.lines.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalAmount(): number {
|
||||||
|
return this.lines.reduce((sum, l) => sum + (l.amount || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMemberFilter(term: string): void {
|
onMemberFilter(term: string): void {
|
||||||
@@ -130,12 +317,99 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
|
|
||||||
onFileSelected(event: Event): void {
|
onFileSelected(event: Event): void {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
this.receipt = input.files?.[0] ?? null;
|
const file = input.files?.[0] ?? null;
|
||||||
|
this.receipt = file;
|
||||||
|
if (file) this.setPreview(file, file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a newly-selected file or a fetched existing receipt in the right-hand preview panel. */
|
||||||
|
private setPreview(blob: Blob, contentType: string): void {
|
||||||
|
this.clearPreview();
|
||||||
|
this.receiptZoom = 1;
|
||||||
|
this.receiptObjectUrl = URL.createObjectURL(blob);
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
this.receiptImageUrl = this.receiptObjectUrl;
|
||||||
|
} else {
|
||||||
|
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPreview(): void {
|
||||||
|
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
|
||||||
|
this.receiptImageUrl = null;
|
||||||
|
this.receiptPdfUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadSnapshots(): void {
|
||||||
|
this.snapshotApi.getAll().subscribe(list => (this.snapshots = list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a saved snapshot: prefill header + lines, but keep today's Expense Date. */
|
||||||
|
applySnapshot(snapshotId: number | null): void {
|
||||||
|
if (snapshotId == null) return;
|
||||||
|
this.snapshotApi.getById(snapshotId).subscribe(s => {
|
||||||
|
this.form.ministryId = s.ministryId;
|
||||||
|
this.form.description = s.description;
|
||||||
|
this.form.vendorName = s.vendorName ?? '';
|
||||||
|
this.form.checkNumber = s.checkNumber ?? '';
|
||||||
|
// Expense Date is intentionally NOT taken from the snapshot — leave it as-is (today).
|
||||||
|
this.lines = s.lines.map(line => ({
|
||||||
|
categoryGroupId: line.categoryGroupId,
|
||||||
|
subCategoryId: line.subCategoryId,
|
||||||
|
amount: line.amount,
|
||||||
|
description: line.description ?? '',
|
||||||
|
functionalClass: line.functionalClass,
|
||||||
|
subs: this.groups.find(g => g.id === line.categoryGroupId)?.subCategories ?? [],
|
||||||
|
}));
|
||||||
|
if (this.lines.length === 0) this.lines = [this.emptyLine()];
|
||||||
|
this.selectedSnapshotId = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the name prompt for saving the current form as a snapshot (requires a valid form). */
|
||||||
|
openSnapshotPrompt(): void {
|
||||||
|
if (!this.isValid) return;
|
||||||
|
this.snapshotName = '';
|
||||||
|
this.showSnapshotNamePrompt = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSnapshotPrompt(): void { this.showSnapshotNamePrompt = false; }
|
||||||
|
|
||||||
|
/** Save the current header + lines as a named snapshot (Expense Date is not stored). */
|
||||||
|
saveSnapshot(): void {
|
||||||
|
const name = this.snapshotName.trim();
|
||||||
|
if (!name || this.snapshotSaving) return;
|
||||||
|
const request: CreateExpenseSnapshotRequest = {
|
||||||
|
name,
|
||||||
|
ministryId: this.form.ministryId!,
|
||||||
|
lines: this.lines.map(l => ({
|
||||||
|
categoryGroupId: l.categoryGroupId!,
|
||||||
|
subCategoryId: l.subCategoryId!,
|
||||||
|
amount: l.amount,
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
description: l.description.trim() || null,
|
||||||
|
})),
|
||||||
|
description: this.form.description.trim(),
|
||||||
|
vendorName: this.form.vendorName || null,
|
||||||
|
checkNumber: this.form.checkNumber || null,
|
||||||
|
notes: null,
|
||||||
|
};
|
||||||
|
this.snapshotSaving = true;
|
||||||
|
this.snapshotApi.create(request).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.snapshotSaving = false;
|
||||||
|
this.showSnapshotNamePrompt = false;
|
||||||
|
this.loadSnapshots();
|
||||||
|
},
|
||||||
|
error: () => { this.snapshotSaving = false; },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get isValid(): boolean {
|
get isValid(): boolean {
|
||||||
return !!this.form.ministryId && !!this.form.categoryGroupId && !!this.form.subCategoryId
|
return !!this.form.ministryId
|
||||||
&& this.form.amount > 0 && this.form.description.trim().length > 0;
|
&& this.form.description.trim().length > 0
|
||||||
|
&& this.lines.length > 0
|
||||||
|
&& this.lines.every(l => !!l.categoryGroupId && !!l.subCategoryId && l.amount > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
emitSave(): void {
|
emitSave(): void {
|
||||||
@@ -145,16 +419,20 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
const request: CreateExpenseRequest = {
|
const request: CreateExpenseRequest = {
|
||||||
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
|
type: (this.mode === 'vendor' ? 'VendorPayment' : 'StaffReimbursement') as ExpenseType,
|
||||||
ministryId: this.form.ministryId!,
|
ministryId: this.form.ministryId!,
|
||||||
categoryGroupId: this.form.categoryGroupId!,
|
lines: this.lines.map(l => ({
|
||||||
subCategoryId: this.form.subCategoryId!,
|
categoryGroupId: l.categoryGroupId!,
|
||||||
amount: this.form.amount,
|
subCategoryId: l.subCategoryId!,
|
||||||
|
amount: l.amount,
|
||||||
|
functionalClass: l.functionalClass,
|
||||||
|
description: l.description.trim() || null,
|
||||||
|
})),
|
||||||
description: this.form.description.trim(),
|
description: this.form.description.trim(),
|
||||||
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
|
vendorName: this.mode === 'vendor' ? (this.form.vendorName || null) : null,
|
||||||
memberId: this.allowMemberPick ? this.form.memberId : null,
|
memberId: this.allowMemberPick ? this.form.memberId : null,
|
||||||
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
checkNumber: this.mode === 'vendor' ? (this.form.checkNumber || null) : null,
|
||||||
expenseDate,
|
expenseDate,
|
||||||
notes: null,
|
notes: null,
|
||||||
functionalClass: this.form.functionalClass,
|
payeeId: this.form.payeeId,
|
||||||
};
|
};
|
||||||
// The request and receipt are snapshotted here, so resetting the form right
|
// The request and receipt are snapshotted here, so resetting the form right
|
||||||
// after emitting is safe even though the parent saves asynchronously.
|
// after emitting is safe even though the parent saves asynchronously.
|
||||||
@@ -163,14 +441,14 @@ export class ExpenseFormDialogComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear only the per-entry fields, keeping Member, Ministry, Category Group,
|
* Clear only the per-entry fields, keeping Member, Ministry and Expense Date so the
|
||||||
* Sub-Category and Expense Date (plus the loaded sub-category list) so the
|
* user can immediately log the next reimbursement. Lines reset to a single blank row.
|
||||||
* user can immediately log the next reimbursement.
|
|
||||||
*/
|
*/
|
||||||
private resetForNext(): void {
|
private resetForNext(): void {
|
||||||
this.form.amount = 0;
|
this.lines = [this.emptyLine()];
|
||||||
this.form.description = '';
|
this.form.description = '';
|
||||||
this.receipt = null;
|
this.receipt = null;
|
||||||
|
this.clearPreview();
|
||||||
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
|
if (this.receiptInput) this.receiptInput.nativeElement.value = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
<kendo-dialog title="Review Expense / 審核支出" (close)="cancel.emit()"
|
||||||
|
[width]="showReceiptPanel ? 1100 : 620" [maxWidth]="'95vw'" [maxHeight]="'90vh'">
|
||||||
|
|
||||||
|
<div *ngIf="loading" class="p-6 text-center text-gray-500">Loading… / 載入中…</div>
|
||||||
|
|
||||||
|
<div *ngIf="!loading && expense" class="flex flex-col gap-4 md:flex-row">
|
||||||
|
|
||||||
|
<!-- Left: read-only expense detail -->
|
||||||
|
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div class="text-gray-500">Date / 日期</div><div>{{ expense.expenseDate }}</div>
|
||||||
|
<div class="text-gray-500">Ministry / 事工</div><div>{{ expense.ministryName }}</div>
|
||||||
|
<div class="text-gray-500">Payee / 收款人</div>
|
||||||
|
<div>
|
||||||
|
<ng-container *ngIf="expense.vendorName; else memberPayee">{{ expense.vendorName }}</ng-container>
|
||||||
|
<ng-template #memberPayee>
|
||||||
|
<ng-container *ngIf="expense.memberName; else dash">
|
||||||
|
<div *ngIf="expense.memberNickName">{{ expense.memberNickName }}</div>
|
||||||
|
<div [class.text-gray-500]="expense.memberNickName" [class.text-xs]="expense.memberNickName">{{ expense.memberName }}</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #dash>—</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500">Description / 說明</div><div>{{ expense.description }}</div>
|
||||||
|
<div class="text-gray-500">Status / 狀態</div><div>{{ expense.status }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Line items -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="font-semibold text-sm">明細 / Line Items</div>
|
||||||
|
<table class="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-gray-500 border-b">
|
||||||
|
<th class="py-1">Category / 類別</th>
|
||||||
|
<th class="py-1">Description / 說明</th>
|
||||||
|
<th class="py-1 text-right">Amount / 金額</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let line of expense.lines" class="border-b border-gray-100">
|
||||||
|
<td class="py-1">{{ line.categoryGroupName }}<span *ngIf="line.subCategoryName"> / {{ line.subCategoryName }}</span></td>
|
||||||
|
<td class="py-1 text-gray-600">{{ line.description || '—' }}</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{{ line.amount | currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="font-semibold">
|
||||||
|
<td class="py-1" colspan="2">Total / 合計</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{{ expense.amount | currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reject reason capture (shown after clicking Reject) -->
|
||||||
|
<div *ngIf="rejecting" class="flex flex-col gap-2 rounded border border-red-200 bg-red-50 p-3">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">Reject Reason / 拒絕原因
|
||||||
|
<kendo-dropdownlist [data]="rejectReasons" textField="label" valueField="value" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="rejectReason" [defaultItem]="{ value: null, label: '-- Select reason --/請選擇原因' }">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label *ngIf="isOtherReason" class="flex flex-col gap-1 text-sm">Detail / 說明
|
||||||
|
<kendo-textbox [(ngModel)]="rejectOther" placeholder="Please enter the reason / 請輸入原因"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: receipt preview -->
|
||||||
|
<div class="md:w-[30rem] md:shrink-0 md:border-l md:pl-4 flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">收據預覽 / Receipt</span>
|
||||||
|
<div *ngIf="receiptImageUrl" class="flex items-center gap-1">
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomOut()"
|
||||||
|
[disabled]="receiptZoom <= minZoom" title="縮小 / Zoom out">−</button>
|
||||||
|
<span class="w-12 text-center text-sm tabular-nums">{{ receiptZoom * 100 | number:'1.0-0' }}%</span>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="zoomIn()"
|
||||||
|
[disabled]="receiptZoom >= maxZoom" title="放大 / Zoom in">+</button>
|
||||||
|
<button kendoButton size="small" fillMode="flat" (click)="resetZoom()" title="重設 / Reset">⟲</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="receiptImageUrl" class="overflow-auto rounded border border-gray-200 bg-gray-50" style="max-height: 72vh;">
|
||||||
|
<img [src]="receiptImageUrl" alt="Receipt preview" [style.width.%]="receiptZoom * 100" class="block max-w-none" />
|
||||||
|
</div>
|
||||||
|
<iframe *ngIf="receiptPdfUrl" [src]="receiptPdfUrl" title="Receipt PDF"
|
||||||
|
class="w-full rounded border border-gray-200" style="height: 72vh;"></iframe>
|
||||||
|
|
||||||
|
<div *ngIf="!showReceiptPanel" class="rounded border border-dashed border-gray-300 p-6 text-center text-gray-400 text-sm">
|
||||||
|
No receipt attached / 無收據
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<!-- Decision row -->
|
||||||
|
<ng-container *ngIf="!rejecting">
|
||||||
|
<button kendoButton (click)="cancel.emit()">Cancel / 取消</button>
|
||||||
|
<button kendoButton themeColor="error" fillMode="flat" (click)="startReject()">Reject / 拒絕</button>
|
||||||
|
<button kendoButton themeColor="success" (click)="confirmApprove()">Approve / 核准</button>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Reject confirmation row -->
|
||||||
|
<ng-container *ngIf="rejecting">
|
||||||
|
<button kendoButton (click)="cancelReject()">Back / 返回</button>
|
||||||
|
<button kendoButton themeColor="error" [disabled]="!canConfirmReject" (click)="confirmReject()">Confirm Reject / 確認拒絕</button>
|
||||||
|
</ng-container>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { DialogsModule } from '@progress/kendo-angular-dialog';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { ExpenseApiService } from '../../services/expense-api.service';
|
||||||
|
import { ExpenseDto } from '../../models/expense.model';
|
||||||
|
import { EXPENSE_REJECT_REASON_OPTIONS } from '../../../../shared/i18n/option-lists';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval review dialog: shows the full expense detail and a receipt preview side by side,
|
||||||
|
* then lets a reviewer Approve or Reject (with a templated or free-text reason). The parent
|
||||||
|
* performs the actual api.approve / api.reject call from the emitted events.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'app-expense-review-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonsModule, DialogsModule, DropDownsModule, InputsModule],
|
||||||
|
templateUrl: './expense-review-dialog.component.html',
|
||||||
|
})
|
||||||
|
export class ExpenseReviewDialogComponent implements OnInit, OnDestroy {
|
||||||
|
/** Expense to review; the full detail (with lines) is fetched on open. */
|
||||||
|
@Input() expenseId!: number;
|
||||||
|
@Output() approve = new EventEmitter<void>();
|
||||||
|
@Output() reject = new EventEmitter<string>(); // emits the composed reviewNotes
|
||||||
|
@Output() cancel = new EventEmitter<void>();
|
||||||
|
|
||||||
|
expense: ExpenseDto | null = null;
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
readonly rejectReasons = EXPENSE_REJECT_REASON_OPTIONS;
|
||||||
|
/** false = the Approve/Reject choice; true = the reject reason is being collected. */
|
||||||
|
rejecting = false;
|
||||||
|
rejectReason: string | null = null;
|
||||||
|
rejectOther = '';
|
||||||
|
|
||||||
|
// ── Receipt preview (mirrors expense-form-dialog) ───────────────────────
|
||||||
|
receiptImageUrl: string | null = null;
|
||||||
|
receiptPdfUrl: SafeResourceUrl | null = null;
|
||||||
|
private receiptObjectUrl: string | null = null;
|
||||||
|
receiptZoom = 1;
|
||||||
|
readonly minZoom = 0.5;
|
||||||
|
readonly maxZoom = 5;
|
||||||
|
|
||||||
|
get showReceiptPanel(): boolean { return !!(this.receiptImageUrl || this.receiptPdfUrl); }
|
||||||
|
|
||||||
|
zoomIn(): void { this.receiptZoom = Math.min(this.maxZoom, +(this.receiptZoom + 0.25).toFixed(2)); }
|
||||||
|
zoomOut(): void { this.receiptZoom = Math.max(this.minZoom, +(this.receiptZoom - 0.25).toFixed(2)); }
|
||||||
|
resetZoom(): void { this.receiptZoom = 1; }
|
||||||
|
|
||||||
|
constructor(private api: ExpenseApiService, private sanitizer: DomSanitizer) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.api.getById(this.expenseId).subscribe({
|
||||||
|
next: dto => {
|
||||||
|
this.expense = dto;
|
||||||
|
this.loading = false;
|
||||||
|
if (dto.hasReceipt) {
|
||||||
|
this.api.downloadReceipt(dto.id).subscribe(blob => this.setPreview(blob, blob.type));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => { this.loading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void { this.clearPreview(); }
|
||||||
|
|
||||||
|
private setPreview(blob: Blob, contentType: string): void {
|
||||||
|
this.clearPreview();
|
||||||
|
this.receiptZoom = 1;
|
||||||
|
this.receiptObjectUrl = URL.createObjectURL(blob);
|
||||||
|
if (contentType.startsWith('image/')) {
|
||||||
|
this.receiptImageUrl = this.receiptObjectUrl;
|
||||||
|
} else {
|
||||||
|
this.receiptPdfUrl = this.sanitizer.bypassSecurityTrustResourceUrl(this.receiptObjectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPreview(): void {
|
||||||
|
if (this.receiptObjectUrl) { URL.revokeObjectURL(this.receiptObjectUrl); this.receiptObjectUrl = null; }
|
||||||
|
this.receiptImageUrl = null;
|
||||||
|
this.receiptPdfUrl = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startReject(): void { this.rejecting = true; this.rejectReason = null; this.rejectOther = ''; }
|
||||||
|
cancelReject(): void { this.rejecting = false; }
|
||||||
|
|
||||||
|
get isOtherReason(): boolean { return this.rejectReason === 'Other'; }
|
||||||
|
|
||||||
|
/** The reason text actually sent: the template value, or the free text when "Other" is chosen. */
|
||||||
|
get composedReviewNotes(): string {
|
||||||
|
return this.isOtherReason ? this.rejectOther.trim() : (this.rejectReason ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
get canConfirmReject(): boolean { return this.composedReviewNotes.length > 0; }
|
||||||
|
|
||||||
|
confirmApprove(): void { this.approve.emit(); }
|
||||||
|
|
||||||
|
confirmReject(): void {
|
||||||
|
if (!this.canConfirmReject) return;
|
||||||
|
this.reject.emit(this.composedReviewNotes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ExpenseLineInput, FunctionalClass } from './expense.model';
|
||||||
|
|
||||||
|
export interface ExpenseSnapshotLineDto {
|
||||||
|
categoryGroupId: number; categoryGroupName: string;
|
||||||
|
subCategoryId: number; subCategoryName: string;
|
||||||
|
functionalClass: FunctionalClass | null; amount: number; description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseSnapshotDto {
|
||||||
|
id: number; name: string; ministryId: number; ministryName: string;
|
||||||
|
description: string; vendorName: string | null; checkNumber: string | null; notes: string | null;
|
||||||
|
totalAmount: number; lineCount: number;
|
||||||
|
createdByName: string | null; createdAt: string;
|
||||||
|
lines: ExpenseSnapshotLineDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateExpenseSnapshotRequest {
|
||||||
|
name: string; ministryId: number; lines: ExpenseLineInput[];
|
||||||
|
description: string; vendorName: string | null; checkNumber: string | null; notes: string | null;
|
||||||
|
}
|
||||||
|
export type UpdateExpenseSnapshotRequest = CreateExpenseSnapshotRequest;
|
||||||
@@ -8,28 +8,70 @@ export interface PagedResult<T> {
|
|||||||
|
|
||||||
export interface MinistryDto { id: number; name_en: string; name_zh: string | null; sortOrder: number; isActive: boolean; label?: string; }
|
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 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; }
|
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; }
|
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 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 UpdateExpenseSubCategoryRequest extends CreateExpenseSubCategoryRequest { isActive: boolean; }
|
||||||
|
|
||||||
|
export interface ExpenseLineItemDto {
|
||||||
|
id: number; categoryGroupId: number; categoryGroupName: string;
|
||||||
|
subCategoryId: number; subCategoryName: string;
|
||||||
|
functionalClass: FunctionalClass | null; amount: number; description: string | null;
|
||||||
|
}
|
||||||
export interface ExpenseListItemDto {
|
export interface ExpenseListItemDto {
|
||||||
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
id: number; type: ExpenseType; status: ExpenseStatus; amount: number; description: string;
|
||||||
ministryId: number; ministryName: string; categoryGroupId: number; categoryGroupName: string;
|
ministryId: number; ministryName: string; lineCount: number; primaryCategoryName: string;
|
||||||
subCategoryId: number; subCategoryName: string; vendorName: string | null;
|
vendorName: string | null;
|
||||||
memberId: number | null; memberName: string | null; expenseDate: string; hasReceipt: boolean;
|
memberId: number | null; memberName: string | null; memberNickName: string | null;
|
||||||
checkNumber: string | null; functionalClass: FunctionalClass | null;
|
expenseDate: string; hasReceipt: boolean;
|
||||||
|
checkNumber: string | null;
|
||||||
|
reviewedByName: string | null; reviewedAt: string | null; reviewNotes: string | null;
|
||||||
|
payeeId: number | null;
|
||||||
}
|
}
|
||||||
export interface ExpenseDto extends ExpenseListItemDto {
|
export interface ExpenseDto extends ExpenseListItemDto {
|
||||||
notes: string | null; reviewNotes: string | null;
|
notes: string | null;
|
||||||
submittedBy: string | null; submittedAt: string | null; reviewedAt: string | null; paidAt: string | null;
|
submittedBy: string | null; submittedAt: string | null; paidAt: string | null;
|
||||||
|
lines: ExpenseLineItemDto[];
|
||||||
|
}
|
||||||
|
/** AI assist suggestion: English translation + a proposed major/sub category (null when unclassified). */
|
||||||
|
export interface ExpenseAiSuggestion {
|
||||||
|
englishDescription: string | null;
|
||||||
|
chineseDescription: string | null;
|
||||||
|
groupId: number | null;
|
||||||
|
subCategoryId: number | null;
|
||||||
|
groupLabel: string | null;
|
||||||
|
subLabel: string | null;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request to AI-assist defining an expense category (大項/小項). */
|
||||||
|
export interface ExpenseCategoryAiRequest {
|
||||||
|
name_zh: string;
|
||||||
|
name_en?: string | null;
|
||||||
|
level: 'group' | 'sub';
|
||||||
|
parentGroupName?: string | null;
|
||||||
|
parentForm990LineId?: number | null;
|
||||||
|
}
|
||||||
|
/** AI suggestion for a category: refined Chinese name, English translation, and a Form 990 line. */
|
||||||
|
export interface CategoryAiSuggestion {
|
||||||
|
chineseName: string | null;
|
||||||
|
englishName: string | null;
|
||||||
|
form990LineId: number | null;
|
||||||
|
form990LineLabel: string | null;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpenseLineInput {
|
||||||
|
categoryGroupId: number; subCategoryId: number; amount: number;
|
||||||
|
functionalClass: FunctionalClass | null; description: string | null;
|
||||||
}
|
}
|
||||||
export interface CreateExpenseRequest {
|
export interface CreateExpenseRequest {
|
||||||
type: ExpenseType; ministryId: number; categoryGroupId: number; subCategoryId: number;
|
type: ExpenseType; ministryId: number; lines: ExpenseLineInput[];
|
||||||
amount: number; description: string; vendorName: string | null; memberId: number | null;
|
description: string; vendorName: string | null; memberId: number | null;
|
||||||
checkNumber: string | null; expenseDate: string; notes: string | null; functionalClass: FunctionalClass | null;
|
checkNumber: string | null; expenseDate: string; notes: string | null;
|
||||||
|
payeeId: number | null;
|
||||||
}
|
}
|
||||||
export type UpdateExpenseRequest = CreateExpenseRequest;
|
export type UpdateExpenseRequest = CreateExpenseRequest;
|
||||||
export interface RejectExpenseRequest { reviewNotes: string | null; }
|
export interface RejectExpenseRequest { reviewNotes: string | null; }
|
||||||
|
|||||||
+86
-8
@@ -53,15 +53,45 @@
|
|||||||
Name (EN) *
|
Name (EN) *
|
||||||
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="groupForm.name_en"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line -->
|
||||||
名稱 (中)
|
<div class="flex flex-col gap-1 md:col-span-2">
|
||||||
<kendo-textbox [(ngModel)]="groupForm.name_zh"></kendo-textbox>
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<label class="flex flex-1 flex-col gap-1">名稱 (中)
|
||||||
|
<kendo-textbox [(ngModel)]="groupForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="(!groupForm.name_zh.trim() && !groupForm.name_en.trim()) || groupAiLoading"
|
||||||
|
(click)="requestGroupAiSuggest()"
|
||||||
|
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
|
||||||
|
{{ groupAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="groupAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
|
||||||
|
<div *ngIf="groupAiSuggestion.englishName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">English:</span>
|
||||||
|
<span class="font-medium">{{ groupAiSuggestion.englishName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="groupAiSuggestion.chineseName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">中文:</span>
|
||||||
|
<span class="font-medium">{{ groupAiSuggestion.chineseName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="groupAiSuggestion.form990LineLabel" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">990 Line:</span>
|
||||||
|
<span class="font-medium">{{ groupAiSuggestion.form990LineLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">信心 / Confidence: {{ groupAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" size="small" type="button" (click)="applyGroupAiSuggestion()">套用 / Apply</button>
|
||||||
|
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissGroupAiSuggestion()">忽略 / Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="groupForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
<span>Form 990 Line / 990 行</span>
|
<span>Form 990 Line / 990 行</span>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
[data]="form990Lines"
|
[data]="form990Lines"
|
||||||
@@ -70,6 +100,15 @@
|
|||||||
[(ngModel)]="groupForm.form990LineId">
|
[(ngModel)]="groupForm.form990LineId">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>1099 Box / 1099 框</span>
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="form1099Boxes"
|
||||||
|
textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, label: '— none —' }"
|
||||||
|
[(ngModel)]="groupForm.form1099BoxId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="editingGroupId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
<input type="checkbox" [(ngModel)]="groupForm.isActive" /> Active
|
||||||
</label>
|
</label>
|
||||||
@@ -90,15 +129,45 @@
|
|||||||
Name (EN) *
|
Name (EN) *
|
||||||
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="subForm.name_en"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<!-- Chinese name with AI assist: refine 中文 + translate to English + suggest a 990 line (biased by parent group) -->
|
||||||
名稱 (中)
|
<div class="flex flex-col gap-1 md:col-span-2">
|
||||||
<kendo-textbox [(ngModel)]="subForm.name_zh"></kendo-textbox>
|
<div class="flex items-end justify-between gap-2">
|
||||||
|
<label class="flex flex-1 flex-col gap-1">名稱 (中)
|
||||||
|
<kendo-textbox [(ngModel)]="subForm.name_zh" placeholder="可輸入中文,AI 幫忙翻譯"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
|
<button kendoButton fillMode="outline" themeColor="primary" type="button"
|
||||||
|
[disabled]="(!subForm.name_zh.trim() && !subForm.name_en.trim()) || subAiLoading"
|
||||||
|
(click)="requestSubAiSuggest()"
|
||||||
|
title="翻譯英文並建議 990 Line / Translate + suggest 990 line">
|
||||||
|
{{ subAiLoading ? '思考中… / Thinking…' : '✨ AI 建議' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="subAiSuggestion" class="rounded border border-blue-200 bg-blue-50 p-3 flex flex-col gap-2 text-sm">
|
||||||
|
<div class="font-semibold text-blue-800">AI 建議 / Suggestion</div>
|
||||||
|
<div *ngIf="subAiSuggestion.englishName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">English:</span>
|
||||||
|
<span class="font-medium">{{ subAiSuggestion.englishName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="subAiSuggestion.chineseName" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">中文:</span>
|
||||||
|
<span class="font-medium">{{ subAiSuggestion.chineseName }}</span>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="subAiSuggestion.form990LineLabel" class="flex gap-2">
|
||||||
|
<span class="text-gray-500 shrink-0">990 Line:</span>
|
||||||
|
<span class="font-medium">{{ subAiSuggestion.form990LineLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">信心 / Confidence: {{ subAiSuggestion.confidence * 100 | number:'1.0-0' }}%</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button kendoButton themeColor="primary" size="small" type="button" (click)="applySubAiSuggestion()">套用 / Apply</button>
|
||||||
|
<button kendoButton fillMode="flat" size="small" type="button" (click)="dismissSubAiSuggestion()">忽略 / Dismiss</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
Sort order
|
Sort order
|
||||||
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
<kendo-numerictextbox [(ngModel)]="subForm.sortOrder" [format]="'n0'" [decimals]="0" [min]="0"></kendo-numerictextbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
<span>Form 990 Line / 990 行</span>
|
<span>Form 990 Line / 990 行</span>
|
||||||
<kendo-dropdownlist
|
<kendo-dropdownlist
|
||||||
[data]="form990Lines"
|
[data]="form990Lines"
|
||||||
@@ -107,6 +176,15 @@
|
|||||||
[(ngModel)]="subForm.form990LineId">
|
[(ngModel)]="subForm.form990LineId">
|
||||||
</kendo-dropdownlist>
|
</kendo-dropdownlist>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span>1099 Box / 1099 框</span>
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="form1099Boxes"
|
||||||
|
textField="label" valueField="id" [valuePrimitive]="true"
|
||||||
|
[defaultItem]="{ id: null, label: '— none —' }"
|
||||||
|
[(ngModel)]="subForm.form1099BoxId">
|
||||||
|
</kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
<label *ngIf="editingSubId != null" class="flex items-center gap-2 md:col-span-2">
|
||||||
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
<input type="checkbox" [(ngModel)]="subForm.isActive" /> Active
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
+68
-9
@@ -8,8 +8,9 @@ import { InputsModule } from '@progress/kendo-angular-inputs';
|
|||||||
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
import { ContextMenuModule, ContextMenuComponent, ContextMenuSelectEvent } from '@progress/kendo-angular-menu';
|
||||||
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
import { ExpenseCategoryApiService } from '../../services/expense-category-api.service';
|
||||||
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto } from '../../models/expense.model';
|
import { ExpenseCategoryGroupDto, ExpenseSubCategoryDto, CategoryAiSuggestion } from '../../models/expense.model';
|
||||||
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
import { Form990ExpenseLineDto } from '../../../finance-report/models/form990-report.model';
|
||||||
|
import { Form1099Box } from '../../../payee1099/models/payee1099.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-expense-categories-page',
|
selector: 'app-expense-categories-page',
|
||||||
@@ -23,6 +24,7 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
selectedGroup: ExpenseCategoryGroupDto | null = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
form990Lines: Form990ExpenseLineDto[] = [];
|
form990Lines: Form990ExpenseLineDto[] = [];
|
||||||
|
form1099Boxes: (Form1099Box & { label: string })[] = [];
|
||||||
|
|
||||||
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
@ViewChild('groupMenu') groupMenu!: ContextMenuComponent;
|
||||||
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
@ViewChild('subMenu') subMenu!: ContextMenuComponent;
|
||||||
@@ -33,17 +35,22 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
|
|
||||||
groupDialogOpen = false;
|
groupDialogOpen = false;
|
||||||
editingGroupId: number | null = null;
|
editingGroupId: number | null = null;
|
||||||
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
groupForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
|
||||||
|
groupAiLoading = false;
|
||||||
|
groupAiSuggestion: CategoryAiSuggestion | null = null;
|
||||||
|
|
||||||
subDialogOpen = false;
|
subDialogOpen = false;
|
||||||
editingSubId: number | null = null;
|
editingSubId: number | null = null;
|
||||||
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null };
|
subForm = { name_en: '', name_zh: '', sortOrder: 0, isActive: true, form990LineId: null as number | null, form1099BoxId: null as number | null };
|
||||||
|
subAiLoading = false;
|
||||||
|
subAiSuggestion: CategoryAiSuggestion | null = null;
|
||||||
|
|
||||||
constructor(private api: ExpenseCategoryApiService) {}
|
constructor(private api: ExpenseCategoryApiService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.load();
|
this.load();
|
||||||
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
|
this.api.getForm990Lines().subscribe(lines => { this.form990Lines = lines; });
|
||||||
|
this.api.getForm1099Boxes().subscribe(boxes => { this.form1099Boxes = boxes; });
|
||||||
}
|
}
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
@@ -107,16 +114,39 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
|
|
||||||
openNewGroup(): void {
|
openNewGroup(): void {
|
||||||
this.editingGroupId = null;
|
this.editingGroupId = null;
|
||||||
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null };
|
this.groupForm = { name_en: '', name_zh: '', sortOrder: this.groups.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
|
||||||
|
this.resetGroupAi();
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
openEditGroup(g: ExpenseCategoryGroupDto): void {
|
||||||
this.editingGroupId = g.id;
|
this.editingGroupId = g.id;
|
||||||
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId };
|
this.groupForm = { name_en: g.name_en, name_zh: g.name_zh ?? '', sortOrder: g.sortOrder, isActive: g.isActive, form990LineId: g.form990LineId, form1099BoxId: g.form1099BoxId };
|
||||||
|
this.resetGroupAi();
|
||||||
this.groupDialogOpen = true;
|
this.groupDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group AI assist: refine the Chinese name, translate to English, suggest a Form 990 line ──
|
||||||
|
private resetGroupAi(): void { this.groupAiLoading = false; this.groupAiSuggestion = null; }
|
||||||
|
requestGroupAiSuggest(): void {
|
||||||
|
if (this.groupAiLoading) return;
|
||||||
|
this.groupAiLoading = true;
|
||||||
|
this.groupAiSuggestion = null;
|
||||||
|
this.api.aiSuggest({ name_zh: this.groupForm.name_zh.trim(), name_en: this.groupForm.name_en.trim() || null, level: 'group' }).subscribe({
|
||||||
|
next: s => { this.groupAiSuggestion = s; this.groupAiLoading = false; },
|
||||||
|
error: () => { this.groupAiLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applyGroupAiSuggestion(): void {
|
||||||
|
const s = this.groupAiSuggestion;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.chineseName) this.groupForm.name_zh = s.chineseName;
|
||||||
|
if (s.englishName) this.groupForm.name_en = s.englishName;
|
||||||
|
if (s.form990LineId != null) this.groupForm.form990LineId = s.form990LineId;
|
||||||
|
this.groupAiSuggestion = null;
|
||||||
|
}
|
||||||
|
dismissGroupAiSuggestion(): void { this.groupAiSuggestion = null; }
|
||||||
saveGroup(): void {
|
saveGroup(): void {
|
||||||
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId };
|
const body = { name_en: this.groupForm.name_en, name_zh: this.groupForm.name_zh || null, sortOrder: this.groupForm.sortOrder, form990LineId: this.groupForm.form990LineId, form1099BoxId: this.groupForm.form1099BoxId };
|
||||||
const done = () => { this.groupDialogOpen = false; this.load(); };
|
const done = () => { this.groupDialogOpen = false; this.load(); };
|
||||||
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
if (this.editingGroupId == null) this.api.createGroup(body).subscribe(done);
|
||||||
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
else this.api.updateGroup(this.editingGroupId, { ...body, isActive: this.groupForm.isActive }).subscribe(done);
|
||||||
@@ -129,17 +159,46 @@ export class ExpenseCategoriesPageComponent implements OnInit {
|
|||||||
openNewSub(): void {
|
openNewSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
this.editingSubId = null;
|
this.editingSubId = null;
|
||||||
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null };
|
this.subForm = { name_en: '', name_zh: '', sortOrder: this.subCategories.length + 1, isActive: true, form990LineId: null, form1099BoxId: null };
|
||||||
|
this.resetSubAi();
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
openEditSub(s: ExpenseSubCategoryDto): void {
|
openEditSub(s: ExpenseSubCategoryDto): void {
|
||||||
this.editingSubId = s.id;
|
this.editingSubId = s.id;
|
||||||
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId };
|
this.subForm = { name_en: s.name_en, name_zh: s.name_zh ?? '', sortOrder: s.sortOrder, isActive: s.isActive, form990LineId: s.form990LineId, form1099BoxId: s.form1099BoxId };
|
||||||
|
this.resetSubAi();
|
||||||
this.subDialogOpen = true;
|
this.subDialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Subcategory AI assist: same as group, biased by the selected parent group + its 990 line ──
|
||||||
|
private resetSubAi(): void { this.subAiLoading = false; this.subAiSuggestion = null; }
|
||||||
|
requestSubAiSuggest(): void {
|
||||||
|
if (this.subAiLoading || !this.selectedGroup) return;
|
||||||
|
this.subAiLoading = true;
|
||||||
|
this.subAiSuggestion = null;
|
||||||
|
this.api.aiSuggest({
|
||||||
|
name_zh: this.subForm.name_zh.trim(),
|
||||||
|
name_en: this.subForm.name_en.trim() || null,
|
||||||
|
level: 'sub',
|
||||||
|
parentGroupName: this.selectedGroup.label ?? this.selectedGroup.name_en,
|
||||||
|
parentForm990LineId: this.selectedGroup.form990LineId,
|
||||||
|
}).subscribe({
|
||||||
|
next: s => { this.subAiSuggestion = s; this.subAiLoading = false; },
|
||||||
|
error: () => { this.subAiLoading = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
applySubAiSuggestion(): void {
|
||||||
|
const s = this.subAiSuggestion;
|
||||||
|
if (!s) return;
|
||||||
|
if (s.chineseName) this.subForm.name_zh = s.chineseName;
|
||||||
|
if (s.englishName) this.subForm.name_en = s.englishName;
|
||||||
|
if (s.form990LineId != null) this.subForm.form990LineId = s.form990LineId;
|
||||||
|
this.subAiSuggestion = null;
|
||||||
|
}
|
||||||
|
dismissSubAiSuggestion(): void { this.subAiSuggestion = null; }
|
||||||
saveSub(): void {
|
saveSub(): void {
|
||||||
if (!this.selectedGroup) return;
|
if (!this.selectedGroup) return;
|
||||||
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId };
|
const body = { groupId: this.selectedGroup.id, name_en: this.subForm.name_en, name_zh: this.subForm.name_zh || null, sortOrder: this.subForm.sortOrder, form990LineId: this.subForm.form990LineId, form1099BoxId: this.subForm.form1099BoxId };
|
||||||
const done = () => { this.subDialogOpen = false; this.load(); };
|
const done = () => { this.subDialogOpen = false; this.load(); };
|
||||||
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
if (this.editingSubId == null) this.api.createSub(body).subscribe(done);
|
||||||
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
else this.api.updateSub(this.editingSubId, { ...body, isActive: this.subForm.isActive }).subscribe(done);
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<div class="page">
|
||||||
|
<p class="mb-4 text-sm text-gray-600">
|
||||||
|
儲存常用的固定費用(房租、網路、餐費…)為範本,下次可快速套用。費用日期不會儲存。<br>
|
||||||
|
Save recurring fixed expenses as snapshots to quickly re-use them. The Expense Date is never saved.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Desktop: grid -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
<kendo-grid [data]="rows" [loading]="loading">
|
||||||
|
<kendo-grid-column field="name" title="Snapshot / 範本" [width]="240"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="vendorName" title="Vendor / 廠商" [width]="180">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>{{ dataItem.vendorName || '—' }}</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column field="ministryName" title="Ministry / 事工"></kendo-grid-column>
|
||||||
|
<kendo-grid-column field="totalAmount" title="Amount / 金額" [width]="120" format="c2"></kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Created by / 建立者" [width]="200">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
{{ dataItem.createdByName || '—' }}<br>
|
||||||
|
<span class="text-xs text-gray-500">{{ dataItem.createdAt | date:'yyyy-MM-dd' }}</span>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
<kendo-grid-column title="Actions" [width]="160">
|
||||||
|
<ng-template kendoGridCellTemplate let-dataItem>
|
||||||
|
<button kendoButton fillMode="flat" (click)="openRename(dataItem)">Rename</button>
|
||||||
|
<button kendoButton fillMode="flat" themeColor="error" (click)="openDelete(dataItem)">Delete</button>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-grid-column>
|
||||||
|
</kendo-grid>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: card list -->
|
||||||
|
<div class="md:hidden flex flex-col gap-3">
|
||||||
|
<div *ngFor="let row of rows" class="rounded border border-gray-200 p-3 flex flex-col gap-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-semibold">{{ row.name }}</span>
|
||||||
|
<span class="tabular-nums">{{ row.totalAmount | currency }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ row.vendorName || '—' }} · {{ row.ministryName }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ row.createdByName || '—' }} · {{ row.createdAt | date:'yyyy-MM-dd' }}</div>
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button kendoButton size="small" fillMode="outline" (click)="openRename(row)">Rename</button>
|
||||||
|
<button kendoButton size="small" fillMode="outline" themeColor="error" (click)="openDelete(row)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p *ngIf="!loading && rows.length === 0" class="text-sm text-gray-500">尚無範本 / No snapshots yet.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rename dialog -->
|
||||||
|
<kendo-dialog *ngIf="renameRow" title="重新命名 / Rename Snapshot" [width]="420" [maxWidth]="'95vw'" (close)="cancelRename()">
|
||||||
|
<label class="flex flex-col gap-1 p-2">名稱 / Name
|
||||||
|
<kendo-textbox [(ngModel)]="renameValue"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelRename()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="!renameValue.trim() || renameSaving"
|
||||||
|
(click)="confirmRename()">{{ renameSaving ? 'Saving…' : 'Save' }}</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirm dialog -->
|
||||||
|
<kendo-dialog *ngIf="deleteRow" title="刪除 / Delete Snapshot" [width]="420" [maxWidth]="'95vw'" (close)="cancelDelete()">
|
||||||
|
<p class="p-2">確定刪除「{{ deleteRow.name }}」? / Delete "{{ deleteRow.name }}"?</p>
|
||||||
|
<kendo-dialog-actions>
|
||||||
|
<button kendoButton (click)="cancelDelete()">Cancel</button>
|
||||||
|
<button kendoButton themeColor="error" (click)="confirmDelete()">Delete</button>
|
||||||
|
</kendo-dialog-actions>
|
||||||
|
</kendo-dialog>
|
||||||
|
</div>
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
.page {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user