Compare commits
150 Commits
5dfca873dd
..
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 | |||
| 9f91683633 | |||
| 5aaac3246d | |||
| 677cb8f054 | |||
| f79dab163d | |||
| 4438c351e2 | |||
| 1a03a1cbba | |||
| 3f61e9ceaf | |||
| b41297f972 | |||
| a5de2dbbb1 | |||
| 1fa36ae62f | |||
| 1353b5571f | |||
| 4e83f27703 | |||
| d5e1732505 | |||
| ae757bee3d | |||
| 6e04b64466 | |||
| f70a7b5a58 | |||
| b6b110254a | |||
| d3e6b5aed5 | |||
| ac84097254 | |||
| 971bf165cc | |||
| f1faa0d435 | |||
| 9dbb1d38d8 | |||
| e908e35530 | |||
| b51f22cfba | |||
| 764464e785 | |||
| cfd344f48c | |||
| 4dc7ff7df7 | |||
| e9aad74df6 | |||
| e768f53ccc | |||
| b0e2e112fc | |||
| 28eba8a3ea | |||
| 7eb6a4db78 | |||
| 7dc03f3bc0 | |||
| 8d91bbeb31 | |||
| 182f8bf74c | |||
| a88567fea6 | |||
| e53cea7a82 | |||
| e88ea7917f | |||
| 99585a1c0e | |||
| d327a5146c | |||
| 4276ca890b | |||
| 3a121f6085 | |||
| 5a25b33258 | |||
| b0deb62c82 | |||
| a2ecc895de | |||
| 1e6ddddf1f | |||
| c54adf1eda | |||
| 5e0348de1d | |||
| 8f18166dbf | |||
| 8f1af536ed | |||
| 180dea60c1 | |||
| 9df391b42c | |||
| 4225b49e58 | |||
| 5a915ebdd1 | |||
| fd71f5a107 | |||
| 9405914d88 | |||
| 39432ac588 | |||
| 4c22cfaf19 | |||
| c8bc7103ba | |||
| 3eeb314dc2 | |||
| 0ddb34dd20 | |||
| 444cc70b56 | |||
| 85bf329d93 | |||
| 3544b6ee78 | |||
| 0e90f19377 | |||
| f9c4d7edb2 | |||
| b7372dec1f | |||
| 21e9823008 | |||
| 583408032d | |||
| ea0ea233a8 | |||
| 7356d0e810 | |||
| b1e3e23325 | |||
| a298d0ee1c | |||
| 249ae1164d | |||
| c6e3f1db64 | |||
| bd722933dc | |||
| f6277aa339 | |||
| 2e226e60f5 | |||
| 68649223d9 | |||
| 9d7c224ad2 | |||
| 47aec287aa |
@@ -50,3 +50,36 @@ jobs:
|
||||
docker compose up -d
|
||||
sleep 5
|
||||
curl -fsS http://localhost:8080/api/health
|
||||
|
||||
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||
- name: Notify Rocket.Chat
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||
run: |
|
||||
if [ "$JOB_STATUS" = "success" ]; then
|
||||
STATUS_TEXT="✅ Build succeeded"
|
||||
COLOR="#2ecc71"
|
||||
else
|
||||
STATUS_TEXT="❌ Build failed"
|
||||
COLOR="#e74c3c"
|
||||
fi
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "$REPO — $STATUS_TEXT",
|
||||
"title_link": "$COMMIT_URL",
|
||||
"color": "$COLOR",
|
||||
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -52,3 +52,41 @@ jobs:
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
curl -fsS https://manage.rolac.org/api/health
|
||||
|
||||
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
|
||||
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
|
||||
notify:
|
||||
needs: [test, build-push, deploy]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify Rocket.Chat
|
||||
env:
|
||||
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
|
||||
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
|
||||
run: |
|
||||
if [ "$BUILD_FAILED" = "true" ]; then
|
||||
STATUS_TEXT="❌ Build failed"
|
||||
COLOR="#e74c3c"
|
||||
else
|
||||
STATUS_TEXT="✅ Build succeeded"
|
||||
COLOR="#2ecc71"
|
||||
fi
|
||||
SHORT_SHA="${SHA:0:7}"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
|
||||
{
|
||||
"attachments": [
|
||||
{
|
||||
"title": "$REPO — $STATUS_TEXT",
|
||||
"title_link": "$COMMIT_URL",
|
||||
"color": "$COLOR",
|
||||
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -34,7 +34,8 @@ public class AuthServiceTests
|
||||
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||
AppUser? findResult = null,
|
||||
bool passwordOk = true,
|
||||
IList<string>? roles = null)
|
||||
IList<string>? roles = null,
|
||||
IdentityResult? changePasswordResult = null)
|
||||
{
|
||||
var store = new Mock<IUserStore<AppUser>>();
|
||||
// Remaining ctor params are all optional; Moq passes them via reflection.
|
||||
@@ -53,6 +54,9 @@ public class AuthServiceTests
|
||||
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
mgr.Setup(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
|
||||
|
||||
return mgr;
|
||||
}
|
||||
@@ -165,6 +169,48 @@ public class AuthServiceTests
|
||||
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_LinkedMember_ReturnsMemberInfo()
|
||||
{
|
||||
var db = BuildDb();
|
||||
db.Members.Add(new Member
|
||||
{
|
||||
Id = 7,
|
||||
NickName = "Johnny",
|
||||
FirstName_en = "John",
|
||||
LastName_en = "Chen",
|
||||
LastName_zh = "陳",
|
||||
CreatedBy = "seed",
|
||||
UpdatedBy = "seed",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, db);
|
||||
|
||||
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||
|
||||
Assert.NotNull(response.User.MemberInfo);
|
||||
Assert.Equal(7, response.User.MemberInfo!.Id);
|
||||
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
|
||||
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
|
||||
|
||||
Assert.Null(response.User.MemberInfo);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Refresh tests
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -266,4 +312,85 @@ public class AuthServiceTests
|
||||
var token = db.RefreshTokens.Single();
|
||||
Assert.NotNull(token.RevokedAt);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Change password tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_ValidRequest_Succeeds()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_UnknownUser_Fails()
|
||||
{
|
||||
var um = BuildUserManager(findResult: null);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
um.Verify(m => m.ChangePasswordAsync(
|
||||
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
|
||||
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
|
||||
var ts = BuildTokenService();
|
||||
var sut = BuildSut(um, ts, BuildDb());
|
||||
|
||||
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
|
||||
{
|
||||
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
|
||||
var um = BuildUserManager(findResult: user);
|
||||
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
|
||||
var db = BuildDb();
|
||||
|
||||
// Current session token (raw "current-raw" => "hash:current-raw")
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:current-raw",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
||||
});
|
||||
// Another active session on a different device
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
UserId = "u1",
|
||||
TokenHash = "hash:other-device",
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTime.UtcNow.AddHours(-2),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sut = BuildSut(um, ts, db);
|
||||
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
|
||||
|
||||
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
|
||||
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
|
||||
Assert.Null(current.RevokedAt); // current session preserved
|
||||
Assert.NotNull(other.RevokedAt); // other session revoked
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class DbSeederForm990Tests
|
||||
{
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
|
||||
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 ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
|
||||
db.ExpenseCategoryGroups.Add(fnb);
|
||||
await db.SaveChangesAsync();
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
|
||||
|
||||
var groups = await db.ExpenseCategoryGroups.ToListAsync();
|
||||
Assert.Contains(groups, g => g.Name_en == "Professional Services");
|
||||
Assert.Contains(groups, g => g.Name_en == "Information Technology");
|
||||
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
|
||||
|
||||
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
|
||||
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
|
||||
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
|
||||
|
||||
Assert.Single(groups, g => g.Name_en == "Professional Services");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
await DbSeeder.SeedMinistriesAsync(db);
|
||||
|
||||
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
|
||||
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
|
||||
Assert.Equal("ManagementGeneral", admin.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]
|
||||
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
await DbSeeder.SeedExpenseCategoriesAsync(db);
|
||||
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
|
||||
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
|
||||
|
||||
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
|
||||
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
|
||||
|
||||
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||
.FirstAsync(s => s.Name_en == "Salary & Wages");
|
||||
Assert.Equal("7", salary.Form990Line!.LineCode);
|
||||
|
||||
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
|
||||
.FirstAsync(s => s.Name_en == "Accounting & Audit");
|
||||
Assert.Equal("11c", audit.Form990Line!.LineCode);
|
||||
}
|
||||
|
||||
[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);
|
||||
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
||||
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
|
||||
db.SaveChanges();
|
||||
var fs = new FakeStorage();
|
||||
return (SvcAs(db, fs, userId), db, fs);
|
||||
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
|
||||
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
|
||||
{
|
||||
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
|
||||
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
||||
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
|
||||
MemberId = memberId, VendorName = vendor,
|
||||
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
|
||||
Assert.Equal("1 Main St", member.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
|
||||
{
|
||||
var (svc, db, _) = Build();
|
||||
db.Expenses.Add(new Expense
|
||||
{
|
||||
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
|
||||
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
|
||||
Lines =
|
||||
{
|
||||
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
|
||||
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var groups = await svc.GetApprovedUnpaidGroupedAsync();
|
||||
|
||||
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
|
||||
Assert.Equal("Multiple / 多類別", line.CategoryName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -58,4 +58,23 @@ public class ExpenseCategoryServiceTests
|
||||
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAndGet_RoundTrips_Form990LineId()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 1, LineCode = "24", Name_en = "Other" });
|
||||
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = new ExpenseCategoryService(db);
|
||||
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 1 });
|
||||
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
|
||||
|
||||
var all = await svc.GetAllAsync(includeInactive: true);
|
||||
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
|
||||
Assert.Equal(7, sub.Form990LineId);
|
||||
Assert.Equal("7", sub.Form990LineCode);
|
||||
Assert.Equal(1, all.Single(g => g.Id == gid).Form990LineId);
|
||||
Assert.Equal("24", all.Single(g => g.Id == gid).Form990LineCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Logging;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Storage;
|
||||
using ROLAC.API.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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),
|
||||
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
|
||||
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
|
||||
@@ -67,14 +78,20 @@ public class ExpenseServiceTests
|
||||
|
||||
private static CreateExpenseRequest Reimb() => new()
|
||||
{
|
||||
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1,
|
||||
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
||||
Type = "StaffReimbursement", MinistryId = 1,
|
||||
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
|
||||
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
|
||||
};
|
||||
|
||||
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
|
||||
{
|
||||
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId,
|
||||
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description,
|
||||
Type = r.Type, MinistryId = r.MinistryId,
|
||||
Lines = r.Lines.Select(l => new ExpenseLineInput
|
||||
{
|
||||
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
|
||||
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
|
||||
}).ToList(),
|
||||
Description = r.Description,
|
||||
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
||||
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||
};
|
||||
@@ -197,6 +214,48 @@ public class ExpenseServiceTests
|
||||
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||
{
|
||||
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||
|
||||
var edit = CloneToUpdate(Reimb());
|
||||
edit.Lines[0].Amount = 99.99m;
|
||||
await svc.UpdateAsync(id, edit, isFinance: false);
|
||||
|
||||
var e = await db.Expenses.FindAsync(id);
|
||||
Assert.Equal(99.99m, e!.Amount);
|
||||
Assert.Equal("PendingApproval", e.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_OwnApproved_AsNonFinance_Throws()
|
||||
{
|
||||
// Once approved, the owner can no longer edit.
|
||||
var (svc, db, fs) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
await SvcAs(db, fs, "finance").ApproveAsync(id);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
|
||||
{
|
||||
var (svc, db, _) = Build("alice");
|
||||
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||
await svc.SubmitAsync(id);
|
||||
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
||||
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SoftDelete_HidesFromQueries()
|
||||
{
|
||||
@@ -206,6 +265,84 @@ public class ExpenseServiceTests
|
||||
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PersistsFunctionalClass_AndGetReturnsIt()
|
||||
{
|
||||
var db = BuildDb("u1");
|
||||
db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" });
|
||||
db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
|
||||
db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = SvcAs(db, new FakeStorage(), "u1");
|
||||
|
||||
var id = await svc.CreateAsync(new CreateExpenseRequest
|
||||
{
|
||||
Type = "VendorPayment", MinistryId = 1,
|
||||
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
|
||||
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
|
||||
}, isFinance: true);
|
||||
|
||||
var dto = await svc.GetByIdAsync(id);
|
||||
Assert.Equal("ManagementGeneral", dto!.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]
|
||||
public async Task Receipt_SaveThenOpen_RoundTrips()
|
||||
{
|
||||
@@ -216,4 +353,93 @@ public class ExpenseServiceTests
|
||||
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
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 Form990ReportServiceTests
|
||||
{
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
|
||||
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 ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
private static async Task SeedAsync(AppDbContext db)
|
||||
{
|
||||
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
|
||||
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
|
||||
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
|
||||
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
|
||||
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
|
||||
{
|
||||
MinistryId = min, Type = "VendorPayment",
|
||||
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
|
||||
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
await SeedAsync(db);
|
||||
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
|
||||
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
|
||||
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
|
||||
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
|
||||
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
|
||||
await db.SaveChangesAsync();
|
||||
var svc = new Form990ReportService(db);
|
||||
|
||||
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
|
||||
|
||||
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
|
||||
Assert.Equal(110m, line7.Program);
|
||||
Assert.Equal(40m, line7.ManagementGeneral);
|
||||
Assert.Equal(150m, line7.Total);
|
||||
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
|
||||
Assert.Equal(25m, line24.Program);
|
||||
Assert.Equal(1, stmt.UnmappedExpenseCount);
|
||||
Assert.Equal(175m, stmt.GrandTotal);
|
||||
Assert.Equal(135m, stmt.ProgramTotal);
|
||||
Assert.Equal(40m, stmt.ManagementGeneralTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Statement_RespectsDateRange()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
await SeedAsync(db);
|
||||
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
|
||||
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
|
||||
db.Expenses.Add(older);
|
||||
await db.SaveChangesAsync();
|
||||
var svc = new Form990ReportService(db);
|
||||
|
||||
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
|
||||
Assert.Equal(100m, stmt.GrandTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Statement_SplitsOneExpenseAcrossLines()
|
||||
{
|
||||
// One invoice with two lines of different categories must land on two different 990 lines.
|
||||
using var db = BuildDb();
|
||||
await SeedAsync(db);
|
||||
db.Expenses.Add(new Expense
|
||||
{
|
||||
MinistryId = 2, Type = "VendorPayment", Status = "Paid", Amount = 70m,
|
||||
Description = "mixed", ExpenseDate = new DateOnly(2026, 5, 10),
|
||||
Lines =
|
||||
{
|
||||
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m }, // sub→line 7
|
||||
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 2, Amount = 20m }, // sub unmapped→group fallback line 24
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var svc = new Form990ReportService(db);
|
||||
|
||||
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
|
||||
|
||||
Assert.Equal(50m, stmt.Rows.Single(r => r.LineCode == "7").Program); // ministry 2 default = Program
|
||||
Assert.Equal(20m, stmt.Rows.Single(r => r.LineCode == "24").Program);
|
||||
Assert.Equal(70m, stmt.GrandTotal);
|
||||
Assert.Equal(1, stmt.UnmappedExpenseCount); // one unmapped line
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services;
|
||||
|
||||
public class MealAttendanceServiceTests
|
||||
{
|
||||
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
|
||||
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
|
||||
// service tests do) so those columns are stamped automatically on SaveChanges.
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
|
||||
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 ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MealAttendanceService(db);
|
||||
var date = new DateOnly(2026, 5, 31);
|
||||
|
||||
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
|
||||
|
||||
Assert.Equal("2026-05-31", result.Date);
|
||||
Assert.Equal(40, result.Adult);
|
||||
Assert.Equal(12, result.Youth);
|
||||
Assert.Equal(8, result.Kid);
|
||||
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MealAttendanceService(db);
|
||||
var date = new DateOnly(2026, 5, 31);
|
||||
await svc.SetCountsAsync(date, 40, 12, 8);
|
||||
|
||||
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
|
||||
|
||||
Assert.Equal(50, result.Adult);
|
||||
Assert.Equal(0, result.Youth); // negative clamped to zero
|
||||
Assert.Equal(0, result.Kid);
|
||||
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.Data.Interceptors;
|
||||
using ROLAC.API.DTOs.Ministry;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
using Xunit;
|
||||
@@ -41,4 +42,19 @@ public class MinistryServiceTests
|
||||
Assert.Equal("A", active[0].Name_en);
|
||||
Assert.Equal(3, all.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var svc = new MinistryService(db);
|
||||
var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" });
|
||||
|
||||
var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
|
||||
Assert.Equal("Program", afterCreate.DefaultFunctionalClass);
|
||||
|
||||
await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" });
|
||||
var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
|
||||
Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests
|
||||
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
|
||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
|
||||
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 300m } } });
|
||||
db.Expenses.Add(new Expense { MinistryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 999m } } });
|
||||
await db.SaveChangesAsync();
|
||||
var svc = Build(db);
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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.Entities;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class EmailServiceTests
|
||||
{
|
||||
// Records every email it is asked to send; can be told to throw for a given address.
|
||||
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
|
||||
{
|
||||
public List<OutboundEmail> Sent { get; } = new();
|
||||
public string? FailForAddress { get; set; }
|
||||
|
||||
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||
{
|
||||
if (email.ToAddress == FailForAddress)
|
||||
throw new InvalidOperationException("smtp rejected");
|
||||
Sent.Add(email);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
|
||||
{
|
||||
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 CurrentUserAccessor(mock.Object);
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
|
||||
{
|
||||
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
return member.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db, "member@example.com");
|
||||
var dispatcher = new FakeSmtpDispatcher();
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var message = new EmailMessage(
|
||||
MemberIds: new[] { memberId },
|
||||
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
|
||||
Subject: "Hi", HtmlBody: "<p>Body</p>");
|
||||
|
||||
var result = await service.SendAsync(message);
|
||||
|
||||
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
|
||||
Assert.Equal(0, result.FailedCount);
|
||||
Assert.Equal(2, dispatcher.Sent.Count);
|
||||
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_SkipsMembersWithNoEmail()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db, null);
|
||||
var dispatcher = new FakeSmtpDispatcher();
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var result = await service.SendAsync(new EmailMessage(
|
||||
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Empty(dispatcher.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
|
||||
var service = new EmailService(db, dispatcher, BuildAccessor());
|
||||
|
||||
var result = await service.SendAsync(new EmailMessage(
|
||||
Array.Empty<int>(),
|
||||
new[] { "bad@example.com", "good@example.com" },
|
||||
"Hi", "<p>Body</p>"));
|
||||
|
||||
Assert.Equal(1, result.SentCount);
|
||||
Assert.Equal(1, result.FailedCount);
|
||||
Assert.Single(result.Failures);
|
||||
Assert.Equal("bad@example.com", result.Failures[0].Target);
|
||||
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class LineMessageChannelTests
|
||||
{
|
||||
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
|
||||
private sealed class StubSettings : INotificationSettingsService
|
||||
{
|
||||
public SmtpOptions GetSmtp() => new();
|
||||
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
|
||||
public void Reload() { }
|
||||
}
|
||||
|
||||
// Captures the outgoing request and returns a canned response.
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastBody { get; private set; }
|
||||
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public string ResponseBody { get; set; } = "{}";
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
|
||||
}
|
||||
}
|
||||
|
||||
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
||||
{
|
||||
var http = new HttpClient(handler);
|
||||
return new LineMessageChannel(http, new StubSettings());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var channel = BuildChannel(handler);
|
||||
|
||||
var result = await channel.PushToUserAsync("U123", "hello");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
|
||||
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
|
||||
{
|
||||
var handler = new CapturingHandler();
|
||||
var channel = BuildChannel(handler);
|
||||
|
||||
await channel.ReplyAsync("RTOKEN", "hi back");
|
||||
|
||||
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
|
||||
{
|
||||
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
|
||||
var channel = BuildChannel(handler);
|
||||
|
||||
var result = await channel.PushToUserAsync("U123", "hello");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("429", result.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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.Entities;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class LineNotificationServiceTests
|
||||
{
|
||||
// Records pushes; can be told to fail every call.
|
||||
private sealed class FakeMessageChannel : IMessageChannel
|
||||
{
|
||||
public List<(string Target, string Text)> UserPushes { get; } = new();
|
||||
public List<(string Target, string Text)> GroupPushes { get; } = new();
|
||||
public bool Fail { get; set; }
|
||||
|
||||
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||
{
|
||||
UserPushes.Add((externalId, text));
|
||||
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
||||
}
|
||||
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
|
||||
{
|
||||
GroupPushes.Add((externalId, text));
|
||||
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
|
||||
}
|
||||
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
|
||||
=> Task.FromResult(new MessageSendResult(true, null));
|
||||
}
|
||||
|
||||
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
|
||||
{
|
||||
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 CurrentUserAccessor(mock.Object);
|
||||
}
|
||||
|
||||
private static AppDbContext BuildDb()
|
||||
{
|
||||
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
|
||||
return new AppDbContext(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.AddInterceptors(interceptor)
|
||||
.Options);
|
||||
}
|
||||
|
||||
private static async Task<int> SeedMemberAsync(AppDbContext db)
|
||||
{
|
||||
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
|
||||
db.Members.Add(member);
|
||||
await db.SaveChangesAsync();
|
||||
return member.Id;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
|
||||
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
||||
|
||||
var stored = await db.LineBindingCodes.SingleAsync();
|
||||
Assert.Equal(code, stored.Code);
|
||||
Assert.Null(stored.ConsumedAt);
|
||||
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
var code = await service.GenerateLineBindingCodeAsync(memberId);
|
||||
|
||||
var result = await service.TryBindMemberAsync("U999", code);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal(memberId, result.MemberId);
|
||||
var binding = await db.MemberChannelBindings.SingleAsync();
|
||||
Assert.Equal("U999", binding.ExternalId);
|
||||
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.LineBindingCodes.Add(new LineBindingCode
|
||||
{
|
||||
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
|
||||
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
|
||||
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
|
||||
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
|
||||
|
||||
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
|
||||
|
||||
var binding = await db.MemberChannelBindings.SingleAsync();
|
||||
Assert.Equal("U-NEW", binding.ExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel());
|
||||
|
||||
await service.RegisterGroupAsync("G1");
|
||||
await service.RegisterGroupAsync("G1"); // second call must not duplicate
|
||||
Assert.Equal(1, await db.MessagingGroups.CountAsync());
|
||||
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
|
||||
|
||||
await service.DeactivateGroupAsync("G1");
|
||||
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
||||
});
|
||||
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
|
||||
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
|
||||
db.MessagingGroups.AddRange(activeGroup, deadGroup);
|
||||
await db.SaveChangesAsync();
|
||||
var channel = new FakeMessageChannel();
|
||||
var service = new LineNotificationService(db, channel);
|
||||
|
||||
var result = await service.SendLineAsync("notice", new[] { memberId },
|
||||
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
|
||||
|
||||
Assert.Equal(2, result.SentCount); // member + active group only
|
||||
Assert.Single(channel.UserPushes);
|
||||
Assert.Single(channel.GroupPushes); // inactive group skipped
|
||||
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
|
||||
|
||||
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Equal(1, result.FailedCount);
|
||||
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendLineAsync_SkipsSoftDeletedMembers()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var memberId = await SeedMemberAsync(db);
|
||||
db.MemberChannelBindings.Add(new MemberChannelBinding
|
||||
{
|
||||
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Soft-delete the member.
|
||||
var member = await db.Members.FirstAsync(m => m.Id == memberId);
|
||||
member.IsDeleted = true;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var channel = new FakeMessageChannel();
|
||||
var service = new LineNotificationService(db, channel);
|
||||
|
||||
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
|
||||
|
||||
Assert.Equal(0, result.SentCount);
|
||||
Assert.Empty(channel.UserPushes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
using Xunit;
|
||||
|
||||
namespace ROLAC.API.Tests.Services.Notifications;
|
||||
|
||||
public class LineSignatureTests
|
||||
{
|
||||
private const string Secret = "test-channel-secret";
|
||||
|
||||
private static string Sign(string body)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
|
||||
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsTrue_ForMatchingSignature()
|
||||
{
|
||||
var body = """{"events":[]}""";
|
||||
var signature = Sign(body);
|
||||
|
||||
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsFalse_ForTamperedBody()
|
||||
{
|
||||
var signature = Sign("""{"events":[]}""");
|
||||
|
||||
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
|
||||
{
|
||||
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
|
||||
|
||||
Assert.False(LineSignature.IsValid(Secret, body, null));
|
||||
Assert.False(LineSignature.IsValid(Secret, body, ""));
|
||||
}
|
||||
}
|
||||
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
|
||||
Assert.Equal("PP-456", line.PayPalTransactionId);
|
||||
Assert.Equal("C-789", line.CheckNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
|
||||
{
|
||||
using var db = BuildDb();
|
||||
var catId = await SeedCategoryAsync(db);
|
||||
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||
|
||||
var withDate = new DateOnly(2026, 5, 31);
|
||||
var withoutDate = new DateOnly(2026, 5, 24);
|
||||
await svc.CreateAsync(BuildRequest(catId, withDate));
|
||||
await svc.CreateAsync(BuildRequest(catId, withoutDate));
|
||||
db.MealAttendances.Add(new MealAttendance
|
||||
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var page = await svc.GetPagedAsync(1, 20, null, null);
|
||||
|
||||
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
|
||||
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
|
||||
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
|
||||
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ public static class Modules
|
||||
public const string OfferingSessions = "OfferingSessions";
|
||||
public const string Ministries = "Ministries";
|
||||
public const string FinanceDashboard = "FinanceDashboard";
|
||||
public const string Form990Report = "Form990Report";
|
||||
public const string Form1099 = "Form1099";
|
||||
public const string MonthlyStatements = "MonthlyStatements";
|
||||
public const string ChurchProfile = "ChurchProfile";
|
||||
public const string Disbursements = "Disbursements";
|
||||
@@ -23,6 +25,7 @@ public static class Modules
|
||||
public const string Permissions = "Permissions";
|
||||
public const string SystemLogs = "SystemLogs";
|
||||
public const string AuditLogs = "AuditLogs";
|
||||
public const string Settings = "Settings";
|
||||
|
||||
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
@@ -36,6 +39,8 @@ public static class Modules
|
||||
OfferingSessions,
|
||||
Ministries,
|
||||
FinanceDashboard,
|
||||
Form990Report,
|
||||
Form1099,
|
||||
MonthlyStatements,
|
||||
ChurchProfile,
|
||||
Disbursements,
|
||||
@@ -43,6 +48,7 @@ public static class Modules
|
||||
Permissions,
|
||||
SystemLogs,
|
||||
AuditLogs,
|
||||
Settings,
|
||||
];
|
||||
|
||||
public static bool IsValid(string module) => All.Contains(module);
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Auth;
|
||||
using ROLAC.API.DTOs.Invitations;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
@@ -16,13 +17,16 @@ public class AuthController : ControllerBase
|
||||
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IInvitationService _invitations;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly IWebHostEnvironment _env;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
IAuthService authService, IInvitationService invitations,
|
||||
UserManager<AppUser> userManager, IWebHostEnvironment env)
|
||||
{
|
||||
_authService = authService;
|
||||
_invitations = invitations;
|
||||
_userManager = userManager;
|
||||
_env = env;
|
||||
}
|
||||
@@ -154,6 +158,77 @@ public class AuthController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Changes the current user's password. Requires the correct current password and a
|
||||
/// new password meeting the configured policy. On success the user's *other* sessions
|
||||
/// are revoked while the current session stays active.
|
||||
/// </summary>
|
||||
[HttpPost("change-password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
return Unauthorized();
|
||||
|
||||
var currentRefresh = Request.Cookies[CookieName];
|
||||
var result = await _authService.ChangePasswordAsync(
|
||||
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
|
||||
|
||||
if (!result.Succeeded)
|
||||
return BadRequest(new
|
||||
{
|
||||
message = string.Join(" ", result.Errors.Select(error => error.Description)),
|
||||
});
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/auth/invitation/validate?token=...
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an invitation token can still be used. Anonymous so the public
|
||||
/// "set your password" page can decide what to show before the member types anything.
|
||||
/// </summary>
|
||||
[HttpGet("invitation/validate")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
|
||||
=> Ok(await _invitations.ValidateAsync(token));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/auth/accept-invitation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Consumes an invitation: sets the account password and, on success, logs the member in
|
||||
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
|
||||
/// </summary>
|
||||
[HttpPost("accept-invitation")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
|
||||
{
|
||||
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
|
||||
if (user is null)
|
||||
return BadRequest(new { message = error });
|
||||
|
||||
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var device = Request.Headers.UserAgent.FirstOrDefault();
|
||||
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
|
||||
SetRefreshCookie(raw);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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.DTOs.Expense;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Ai;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
@@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers;
|
||||
public class ExpenseCategoriesController : ControllerBase
|
||||
{
|
||||
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]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> 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")]
|
||||
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
|
||||
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,21 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/form990-report")]
|
||||
[HasPermission(Modules.Form990Report, PermissionActions.Read)]
|
||||
public class Form990ReportController : ControllerBase
|
||||
{
|
||||
private readonly IForm990ReportService _svc;
|
||||
public Form990ReportController(IForm990ReportService svc) => _svc = svc;
|
||||
|
||||
[HttpGet("lines")]
|
||||
public async Task<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
|
||||
|
||||
[HttpGet("functional-expenses")]
|
||||
public async Task<IActionResult> FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
|
||||
=> Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Invitations;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for generating and e-mailing first-login invitation links.
|
||||
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
|
||||
/// refresh-token cookie and stay anonymous.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/invitations")]
|
||||
[Authorize]
|
||||
public class InvitationsController : ControllerBase
|
||||
{
|
||||
private readonly IInvitationService _invitations;
|
||||
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
|
||||
|
||||
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
|
||||
{
|
||||
try { return Ok(await _invitations.CreateAsync(request)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
|
||||
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
|
||||
[HttpPost("send")]
|
||||
[HasPermission(Modules.Users, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
|
||||
{
|
||||
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.Notifications;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
|
||||
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
|
||||
/// returns 400 only on signature failure.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/line")]
|
||||
[AllowAnonymous]
|
||||
public sealed class LineWebhookController : ControllerBase
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private readonly ILineNotificationService _line;
|
||||
private readonly IMessageChannel _channel;
|
||||
private readonly INotificationSettingsService _settings;
|
||||
|
||||
public LineWebhookController(
|
||||
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
|
||||
{
|
||||
_line = line;
|
||||
_channel = channel;
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
[HttpPost("webhook")]
|
||||
[RequestSizeLimit(262_144)]
|
||||
public async Task<IActionResult> Webhook(CancellationToken ct)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
|
||||
var rawBody = await reader.ReadToEndAsync(ct);
|
||||
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
||||
|
||||
if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
||||
return BadRequest();
|
||||
|
||||
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
||||
if (payload?.Events is not null)
|
||||
foreach (var evt in payload.Events)
|
||||
await DispatchAsync(evt, ct);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
|
||||
{
|
||||
switch (evt.Type)
|
||||
{
|
||||
case "follow":
|
||||
if (evt.ReplyToken is not null)
|
||||
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
|
||||
break;
|
||||
|
||||
case "message":
|
||||
if (evt.Message?.Type == "text"
|
||||
&& evt.Source?.UserId is { } userId
|
||||
&& evt.Message.Text is { } text)
|
||||
{
|
||||
var result = await _line.TryBindMemberAsync(userId, text, ct);
|
||||
if (evt.ReplyToken is not null)
|
||||
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "join":
|
||||
if (evt.Source?.GroupId is { } joinGroupId)
|
||||
{
|
||||
await _line.RegisterGroupAsync(joinGroupId, ct);
|
||||
if (evt.ReplyToken is not null)
|
||||
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
|
||||
}
|
||||
break;
|
||||
|
||||
case "leave":
|
||||
if (evt.Source?.GroupId is { } leaveGroupId)
|
||||
await _line.DeactivateGroupAsync(leaveGroupId, ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.DTOs.MealAttendance;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
@@ -16,11 +17,17 @@ public class MealAttendanceController : ControllerBase
|
||||
[HttpGet("today")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetToday()
|
||||
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
|
||||
=> Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
|
||||
|
||||
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
|
||||
=> Ok(await _svc.GetRangeAsync(from, to));
|
||||
|
||||
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
|
||||
[HttpPut("{date}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
|
||||
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Ministry;
|
||||
using ROLAC.API.Services;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
@@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase
|
||||
public MinistriesController(IMinistryService svc) => _svc = svc;
|
||||
|
||||
[HttpGet]
|
||||
[HasPermission(Modules.Ministries, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||
|
||||
[HttpPost]
|
||||
[HasPermission(Modules.Ministries, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateMinistryRequest request)
|
||||
{
|
||||
var id = await _svc.CreateAsync(request);
|
||||
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[HasPermission(Modules.Ministries, PermissionActions.Write)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateMinistryRequest request)
|
||||
{
|
||||
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[HasPermission(Modules.Ministries, PermissionActions.Delete)]
|
||||
public async Task<IActionResult> Deactivate(int id)
|
||||
{
|
||||
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Notifications;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
|
||||
/// management, send history, and manual send — the manual send endpoints are the only way to fire
|
||||
/// a message before a UI exists; programmatic callers use the services directly.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/notifications")]
|
||||
[Authorize]
|
||||
public sealed class NotificationsController : ControllerBase
|
||||
{
|
||||
private readonly IEmailService _email;
|
||||
private readonly ILineNotificationService _line;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly CurrentUserAccessor _currentUser;
|
||||
|
||||
public NotificationsController(
|
||||
IEmailService email, ILineNotificationService line,
|
||||
AppDbContext db, CurrentUserAccessor currentUser)
|
||||
{
|
||||
_email = email;
|
||||
_line = line;
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
[HttpPost("members/{id:int}/line-binding-code")]
|
||||
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
|
||||
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
|
||||
|
||||
[HttpGet("groups")]
|
||||
public async Task<IActionResult> Groups(CancellationToken ct)
|
||||
=> Ok(await _db.MessagingGroups
|
||||
.OrderBy(g => g.Id)
|
||||
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
|
||||
.ToListAsync(ct));
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
|
||||
{
|
||||
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
|
||||
if (group is null) return NotFound();
|
||||
|
||||
group.Name = request.Name;
|
||||
group.IsActive = request.IsActive;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
public async Task<IActionResult> History(
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
|
||||
{
|
||||
var size = Math.Clamp(pageSize, 1, 200);
|
||||
var skip = (Math.Max(page, 1) - 1) * size;
|
||||
|
||||
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
|
||||
var total = await query.CountAsync(ct);
|
||||
var items = await query
|
||||
.Skip(skip).Take(size)
|
||||
.Select(l => new
|
||||
{
|
||||
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
|
||||
l.Status, l.Error, l.SentByUserId, l.SentAt,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(new { total, items });
|
||||
}
|
||||
|
||||
[HttpPost("send-line")]
|
||||
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
|
||||
=> Ok(await _line.SendLineAsync(
|
||||
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
|
||||
_currentUser.UserIdOrSystem, ct));
|
||||
|
||||
[HttpPost("send-email")]
|
||||
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||
=> Ok(await _email.SendAsync(new EmailMessage(
|
||||
MemberIds: request.MemberIds ?? [],
|
||||
Addresses: request.Addresses ?? [],
|
||||
Subject: request.Subject,
|
||||
HtmlBody: request.HtmlBody,
|
||||
Attachments: null,
|
||||
SentByUserId: _currentUser.UserIdOrSystem), ct));
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
|
||||
NickName = request.NickName,
|
||||
FirstName_zh = request.FirstName_zh,
|
||||
LastName_zh = request.LastName_zh,
|
||||
Entity = request.Entity,
|
||||
PhoneCell = request.PhoneCell,
|
||||
Status = "Visitor",
|
||||
Country = "USA",
|
||||
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
|
||||
{
|
||||
Id = id, NickName = request.NickName,
|
||||
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
||||
Entity = request.Entity,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ROLAC.API.Authorization;
|
||||
using ROLAC.API.DTOs.Settings;
|
||||
using ROLAC.API.Services;
|
||||
using ROLAC.API.Services.Logging;
|
||||
using ROLAC.API.Services.Notifications;
|
||||
|
||||
namespace ROLAC.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
|
||||
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/settings")]
|
||||
[Authorize]
|
||||
public class SettingsController : ControllerBase
|
||||
{
|
||||
private readonly ISettingsService _settings;
|
||||
private readonly IEmailService _email;
|
||||
private readonly ILineNotificationService _line;
|
||||
private readonly CurrentUserAccessor _currentUser;
|
||||
|
||||
public SettingsController(
|
||||
ISettingsService settings,
|
||||
IEmailService email,
|
||||
ILineNotificationService line,
|
||||
CurrentUserAccessor currentUser)
|
||||
{
|
||||
_settings = settings;
|
||||
_email = email;
|
||||
_line = line;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
|
||||
// ── Site settings ────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("site")]
|
||||
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
|
||||
|
||||
[HttpPut("site")]
|
||||
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
|
||||
{
|
||||
await _settings.UpdateSiteAsync(request);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ── Notification settings ──────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("notification")]
|
||||
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||
public async Task<IActionResult> GetNotification()
|
||||
{
|
||||
var dto = await _settings.GetNotificationAsync();
|
||||
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpPut("notification")]
|
||||
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
|
||||
{
|
||||
await _settings.UpdateNotificationAsync(request);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("notification/test-email")]
|
||||
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
|
||||
{
|
||||
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
|
||||
if (string.IsNullOrWhiteSpace(to))
|
||||
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
|
||||
|
||||
var result = await _email.SendAsync(new EmailMessage(
|
||||
MemberIds: Array.Empty<int>(),
|
||||
Addresses: new[] { to },
|
||||
Subject: "ROLAC test email / 測試郵件",
|
||||
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
|
||||
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
|
||||
SentByUserId: _currentUser.UserIdOrSystem), ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("notification/test-line")]
|
||||
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.MemberId is null && request.GroupId is null)
|
||||
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
|
||||
|
||||
var result = await _line.SendLineAsync(
|
||||
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
|
||||
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
|
||||
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
|
||||
sentByUserId: _currentUser.UserIdOrSystem,
|
||||
ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ROLAC.API.DTOs.Auth;
|
||||
|
||||
public class ChangePasswordRequest
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(128)]
|
||||
public string CurrentPassword { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[MinLength(8)]
|
||||
[MaxLength(128)]
|
||||
public string NewPassword { get; set; } = null!;
|
||||
}
|
||||
@@ -25,4 +25,22 @@ public class UserInfo
|
||||
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
|
||||
/// </summary>
|
||||
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The church member linked to this login account, or null for admin-only
|
||||
/// accounts (no MemberId) and accounts whose member record was deleted.
|
||||
/// Lets the SPA greet the user by their real name.
|
||||
/// </summary>
|
||||
public MemberInfo? MemberInfo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
|
||||
public class MemberInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string? NickName { get; set; }
|
||||
public string FirstName_en { get; set; } = "";
|
||||
public string LastName_en { get; set; } = "";
|
||||
public string? FirstName_zh { get; set; }
|
||||
public string? LastName_zh { get; set; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ public class ChurchProfileDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? NameZh { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
@@ -12,12 +16,22 @@ public class ChurchProfileDto
|
||||
public string? BankName { get; set; }
|
||||
public string? BankAccountNumber { get; set; }
|
||||
public string? BankRoutingNumber { get; set; }
|
||||
public string? PayerEin { 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
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||
[MaxLength(200)] public string? NameZh { get; set; }
|
||||
[MaxLength(50)] public string? Phone { get; set; }
|
||||
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||
[MaxLength(300)] public string? Website { get; set; }
|
||||
[MaxLength(500)] public string? Address { get; set; }
|
||||
[MaxLength(100)] public string? City { get; set; }
|
||||
[MaxLength(50)] public string? State { get; set; }
|
||||
@@ -25,5 +39,11 @@ public class UpdateChurchProfileRequest
|
||||
[MaxLength(200)] public string? BankName { get; set; }
|
||||
[MaxLength(50)] public string? BankAccountNumber { 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; }
|
||||
[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; }
|
||||
}
|
||||
@@ -9,6 +9,10 @@ public class ExpenseSubCategoryDto
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public string? Form990LineCode { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
public string? Form1099BoxCode { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseCategoryGroupDto
|
||||
@@ -18,6 +22,10 @@ public class ExpenseCategoryGroupDto
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public string? Form990LineCode { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
public string? Form1099BoxCode { get; set; }
|
||||
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -26,6 +34,8 @@ public class CreateExpenseGroupRequest
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
}
|
||||
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||
{
|
||||
@@ -38,6 +48,8 @@ public class CreateExpenseSubCategoryRequest
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public int? Form1099BoxId { get; set; }
|
||||
}
|
||||
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||
{
|
||||
|
||||
@@ -1,50 +1,73 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Expense;
|
||||
|
||||
public class ExpenseLineItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CategoryGroupId { get; set; }
|
||||
public string CategoryGroupName { get; set; } = "";
|
||||
public int SubCategoryId { get; set; }
|
||||
public string SubCategoryName { get; set; } = "";
|
||||
public string? FunctionalClass { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class ExpenseListItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Type { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
public decimal Amount { get; set; }
|
||||
public decimal Amount { get; set; } // header total = sum of line amounts
|
||||
public string Description { get; set; } = "";
|
||||
public int MinistryId { get; set; }
|
||||
public string MinistryName { get; set; } = "";
|
||||
public int CategoryGroupId { get; set; }
|
||||
public string CategoryGroupName { get; set; } = "";
|
||||
public int SubCategoryId { get; set; }
|
||||
public string SubCategoryName { get; set; } = "";
|
||||
public int LineCount { get; set; }
|
||||
public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
|
||||
public string? VendorName { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public string? 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 bool HasReceipt { get; set; }
|
||||
public string? CheckNumber { 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 string? Notes { get; set; }
|
||||
public string? ReviewNotes { get; set; }
|
||||
public string? SubmittedBy { get; set; }
|
||||
public DateTimeOffset? SubmittedAt { get; set; }
|
||||
public DateTimeOffset? ReviewedAt { get; set; }
|
||||
public DateTimeOffset? PaidAt { get; set; }
|
||||
public List<ExpenseLineItemDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ExpenseLineInput
|
||||
{
|
||||
[Required] public int CategoryGroupId { get; set; }
|
||||
[Required] public int SubCategoryId { get; set; }
|
||||
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
||||
[MaxLength(20)] public string? FunctionalClass { get; set; }
|
||||
[MaxLength(500)] public string? Description { get; set; }
|
||||
}
|
||||
|
||||
public class CreateExpenseRequest
|
||||
{
|
||||
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
|
||||
[Required] public int MinistryId { get; set; }
|
||||
[Required] public int CategoryGroupId { get; set; }
|
||||
[Required] public int SubCategoryId { get; set; }
|
||||
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
|
||||
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
|
||||
[Required, MaxLength(500)] public string Description { get; set; } = "";
|
||||
[MaxLength(200)] public string? VendorName { get; set; }
|
||||
public int? MemberId { get; set; } // ignored for self-service (server uses caller)
|
||||
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||
[Required] public DateOnly ExpenseDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? PayeeId { get; set; }
|
||||
}
|
||||
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,35 @@
|
||||
namespace ROLAC.API.DTOs.Finance;
|
||||
|
||||
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
|
||||
public class FunctionalExpenseRowDto
|
||||
{
|
||||
public string LineCode { get; set; } = "";
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public decimal Program { get; set; }
|
||||
public decimal ManagementGeneral { get; set; }
|
||||
public decimal Fundraising { get; set; }
|
||||
public decimal Total { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
|
||||
public class FunctionalExpenseStatementDto
|
||||
{
|
||||
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
|
||||
public decimal ProgramTotal { get; set; }
|
||||
public decimal ManagementGeneralTotal { get; set; }
|
||||
public decimal FundraisingTotal { get; set; }
|
||||
public decimal GrandTotal { get; set; }
|
||||
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
|
||||
public int UnmappedExpenseCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single IRS Form 990 expense line from the catalog (used to populate mapping dropdowns).</summary>
|
||||
public class Form990ExpenseLineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string LineCode { get; set; } = "";
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
|
||||
public string? NickName { get; set; }
|
||||
public string FirstName_en { get; set; } = "";
|
||||
public string LastName_en { get; set; } = "";
|
||||
public string? Entity { get; set; } // company / business name (公司行號), if any
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
|
||||
public decimal Difference { get; set; }
|
||||
public int LineCount { get; set; }
|
||||
public bool HasProof { get; set; }
|
||||
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
|
||||
[MaxLength(100)] public string? NickName { get; set; }
|
||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||
[MaxLength(200)] public string? Entity { get; set; }
|
||||
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace ROLAC.API.DTOs.Invitations;
|
||||
|
||||
/// <summary>
|
||||
/// Admin request to generate a first-login invitation link for a member. If the member has no
|
||||
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
|
||||
/// </summary>
|
||||
public class CreateInvitationRequest
|
||||
{
|
||||
[Required]
|
||||
public int MemberId { get; set; }
|
||||
|
||||
/// <summary>Optional override for the login email when the member has none on file.</summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
|
||||
public List<string>? Roles { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
|
||||
public class CreateInvitationResult
|
||||
{
|
||||
public string Token { get; set; } = null!;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
|
||||
public class SendInvitationRequest
|
||||
{
|
||||
[Required]
|
||||
public int MemberId { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Link { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>Public result describing whether an invitation token can still be used.</summary>
|
||||
public class ValidateInvitationResult
|
||||
{
|
||||
public bool Valid { get; set; }
|
||||
public bool Expired { get; set; }
|
||||
public string? MemberName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Public request to consume an invitation and set the account password.</summary>
|
||||
public class AcceptInvitationRequest
|
||||
{
|
||||
[Required]
|
||||
public string Token { get; set; } = null!;
|
||||
|
||||
[Required]
|
||||
[StringLength(128, MinimumLength = 8)]
|
||||
public string NewPassword { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ROLAC.API.DTOs.MealAttendance;
|
||||
|
||||
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
|
||||
public class SetAttendanceRequest
|
||||
{
|
||||
public int Adult { get; set; }
|
||||
public int Youth { get; set; }
|
||||
public int Kid { get; set; }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public class CreateMemberRequest
|
||||
[MaxLength(100)] public string? NickName { get; set; }
|
||||
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||
[MaxLength(200)] public string? Entity { get; set; }
|
||||
[MaxLength(10)] public string? Gender { get; set; }
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public DateOnly? BaptismDate { get; set; }
|
||||
|
||||
@@ -8,6 +8,7 @@ public class MemberListItemDto
|
||||
public string? NickName { get; set; }
|
||||
public string? FirstName_zh { get; set; }
|
||||
public string? LastName_zh { get; set; }
|
||||
public string? Entity { get; set; }
|
||||
public string Status { get; set; } = "";
|
||||
public string? Email { get; set; }
|
||||
public string? PhoneCell { get; set; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Ministry;
|
||||
|
||||
public class CreateMinistryRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
[MaxLength(500)] public string? Description_en { get; set; }
|
||||
[MaxLength(500)] public string? Description_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
|
||||
}
|
||||
@@ -5,6 +5,9 @@ public class MinistryDto
|
||||
public int Id { get; set; }
|
||||
public string Name_en { get; set; } = "";
|
||||
public string? Name_zh { get; set; }
|
||||
public string? Description_en { get; set; }
|
||||
public string? Description_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string DefaultFunctionalClass { get; set; } = "Program";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Ministry;
|
||||
|
||||
public class UpdateMinistryRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||
[MaxLength(500)] public string? Description_en { get; set; }
|
||||
[MaxLength(500)] public string? Description_zh { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ROLAC.API.DTOs.Notifications;
|
||||
|
||||
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
|
||||
public sealed class LineWebhookPayload
|
||||
{
|
||||
public List<LineWebhookEvent>? Events { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LineWebhookEvent
|
||||
{
|
||||
public string? Type { get; set; } // follow | message | join | leave | ...
|
||||
public string? ReplyToken { get; set; }
|
||||
public LineWebhookSource? Source { get; set; }
|
||||
public LineWebhookMessage? Message { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LineWebhookSource
|
||||
{
|
||||
public string? Type { get; set; } // user | group | room
|
||||
public string? UserId { get; set; }
|
||||
public string? GroupId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class LineWebhookMessage
|
||||
{
|
||||
public string? Type { get; set; } // text | image | ...
|
||||
public string? Text { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ROLAC.API.DTOs.Notifications;
|
||||
|
||||
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
|
||||
|
||||
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
|
||||
|
||||
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace ROLAC.API.DTOs.Settings;
|
||||
|
||||
// ── Site settings ──────────────────────────────────────────────────────────
|
||||
|
||||
public class SiteSettingDto
|
||||
{
|
||||
public string SiteTitle { get; set; } = "";
|
||||
public string? SiteTitleZh { get; set; }
|
||||
public string DefaultLanguage { get; set; } = "en";
|
||||
public string TimeZone { get; set; } = "";
|
||||
public string DateFormat { get; set; } = "";
|
||||
public string Currency { get; set; } = "";
|
||||
}
|
||||
|
||||
public class UpdateSiteSettingRequest
|
||||
{
|
||||
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
|
||||
[MaxLength(200)] public string? SiteTitleZh { get; set; }
|
||||
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
|
||||
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
|
||||
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
|
||||
[Required, MaxLength(10)] public string Currency { get; set; } = "";
|
||||
}
|
||||
|
||||
// ── Notification settings ──────────────────────────────────────────────────
|
||||
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
|
||||
// shows a write-only field where a blank value on update means "keep the stored secret".
|
||||
|
||||
public class NotificationSettingDto
|
||||
{
|
||||
public bool EnableEmail { get; set; }
|
||||
public string SmtpHost { get; set; } = "";
|
||||
public int SmtpPort { get; set; }
|
||||
public bool SmtpUseSsl { get; set; }
|
||||
public string SmtpUser { get; set; } = "";
|
||||
public string FromAddress { get; set; } = "";
|
||||
public string FromName { get; set; } = "";
|
||||
public bool HasSmtpPassword { get; set; }
|
||||
|
||||
public bool EnableLine { get; set; }
|
||||
public bool HasLineChannelAccessToken { get; set; }
|
||||
public bool HasLineChannelSecret { get; set; }
|
||||
|
||||
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
|
||||
public string WebhookUrl { get; set; } = "";
|
||||
}
|
||||
|
||||
public class UpdateNotificationSettingRequest
|
||||
{
|
||||
public bool EnableEmail { get; set; }
|
||||
[MaxLength(200)] public string SmtpHost { get; set; } = "";
|
||||
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
|
||||
public bool SmtpUseSsl { get; set; } = true;
|
||||
[MaxLength(200)] public string SmtpUser { get; set; } = "";
|
||||
[MaxLength(200)] public string? FromAddress { get; set; }
|
||||
[MaxLength(200)] public string? FromName { get; set; }
|
||||
/// <summary>Blank = keep the stored password unchanged.</summary>
|
||||
[MaxLength(500)] public string? SmtpPassword { get; set; }
|
||||
|
||||
public bool EnableLine { get; set; }
|
||||
/// <summary>Blank = keep the stored token unchanged.</summary>
|
||||
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
|
||||
/// <summary>Blank = keep the stored secret unchanged.</summary>
|
||||
[MaxLength(200)] public string? LineChannelSecret { get; set; }
|
||||
}
|
||||
|
||||
// ── Test-send requests ─────────────────────────────────────────────────────
|
||||
|
||||
public class TestEmailRequest
|
||||
{
|
||||
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
|
||||
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
|
||||
}
|
||||
|
||||
public class TestLineRequest
|
||||
{
|
||||
public int? MemberId { get; set; }
|
||||
public int? GroupId { get; set; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ROLAC.API.Data.Logging;
|
||||
using ROLAC.API.Entities;
|
||||
using ROLAC.API.Entities.Notifications;
|
||||
|
||||
namespace ROLAC.API.Data;
|
||||
|
||||
@@ -10,6 +11,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<UserInvitation> UserInvitations => Set<UserInvitation>();
|
||||
public DbSet<Member> Members => Set<Member>();
|
||||
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||
@@ -18,7 +20,13 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<Ministry> Ministries => Set<Ministry>();
|
||||
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||
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<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
|
||||
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
|
||||
public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
|
||||
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||
public DbSet<Check> Checks => Set<Check>();
|
||||
@@ -26,6 +34,14 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
|
||||
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
|
||||
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
|
||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||
|
||||
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
@@ -47,6 +63,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Ignore(e => e.IsActive);
|
||||
});
|
||||
|
||||
// ── UserInvitation (single-use, expiring first-login links) ─────────
|
||||
builder.Entity<UserInvitation>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||||
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||||
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
|
||||
entity.HasIndex(e => e.UserId);
|
||||
entity.HasOne(e => e.User).WithMany()
|
||||
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
entity.Ignore(e => e.IsExpired);
|
||||
entity.Ignore(e => e.IsUsed);
|
||||
entity.Ignore(e => e.IsRevoked);
|
||||
entity.Ignore(e => e.IsActive);
|
||||
});
|
||||
|
||||
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||
builder.Entity<AppUser>(entity =>
|
||||
{
|
||||
@@ -91,6 +124,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.NickName).HasMaxLength(100);
|
||||
entity.Property(e => e.FirstName_zh).HasMaxLength(100);
|
||||
entity.Property(e => e.LastName_zh).HasMaxLength(100);
|
||||
entity.Property(e => e.Entity).HasMaxLength(200);
|
||||
entity.Property(e => e.Gender).HasMaxLength(10);
|
||||
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
||||
entity.Property(e => e.Email).HasMaxLength(200);
|
||||
@@ -172,6 +206,57 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
{
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program");
|
||||
});
|
||||
|
||||
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
|
||||
builder.Entity<Form990ExpenseLine>(entity =>
|
||||
{
|
||||
entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired();
|
||||
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
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 ─────────────────────────────────────────────
|
||||
@@ -181,6 +266,10 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasOne(e => e.Form990Line).WithMany()
|
||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||
@@ -192,6 +281,10 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
||||
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Form990Line).WithMany()
|
||||
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.Form1099Box).WithMany()
|
||||
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── Expense ──────────────────────────────────────────────────────────
|
||||
@@ -220,12 +313,73 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
|
||||
entity.HasOne(e => e.Ministry).WithMany()
|
||||
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
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()
|
||||
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.SubCategory).WithMany()
|
||||
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── ChurchProfile (singleton settings) ───────────────────────────────
|
||||
@@ -239,12 +393,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||
entity.Property(e => e.BankAccountNumber).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.Phone).HasMaxLength(50);
|
||||
entity.Property(e => e.Email).HasMaxLength(200);
|
||||
entity.Property(e => e.Website).HasMaxLength(300);
|
||||
entity.Property(e => e.CreatedBy).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.
|
||||
entity.Property(e => e.xmin).IsRowVersion();
|
||||
});
|
||||
|
||||
// ── SiteSetting (singleton presentation/locale settings) ─────────────
|
||||
builder.Entity<SiteSetting>(entity =>
|
||||
{
|
||||
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
|
||||
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
|
||||
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
|
||||
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
});
|
||||
|
||||
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
|
||||
builder.Entity<NotificationSetting>(entity =>
|
||||
{
|
||||
entity.Property(e => e.SmtpHost).HasMaxLength(200);
|
||||
entity.Property(e => e.SmtpUser).HasMaxLength(200);
|
||||
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
|
||||
entity.Property(e => e.FromAddress).HasMaxLength(200);
|
||||
entity.Property(e => e.FromName).HasMaxLength(200);
|
||||
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
|
||||
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
|
||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||
});
|
||||
|
||||
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||
builder.Entity<Check>(entity =>
|
||||
{
|
||||
@@ -326,6 +517,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
||||
});
|
||||
|
||||
// ── Notifications (email + Line) ─────────────────────────────────────
|
||||
builder.Entity<MemberChannelBinding>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
|
||||
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<LineBindingCode>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
|
||||
entity.HasIndex(e => e.Code);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessagingGroup>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
|
||||
entity.Property(e => e.Name).HasMaxLength(200);
|
||||
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<NotificationLog>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.Subject).HasMaxLength(300);
|
||||
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
|
||||
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
|
||||
entity.HasIndex(e => e.SentAt);
|
||||
entity.HasIndex(e => e.Channel);
|
||||
entity.HasOne(e => e.Member).WithMany()
|
||||
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(e => e.MessagingGroup).WithMany()
|
||||
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
|
||||
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
|
||||
// context, so business code can't write logs through the audited context.
|
||||
|
||||
@@ -28,6 +28,8 @@ public static class DbSeeder
|
||||
("Hospitality", "招待", 8),
|
||||
("Children", "兒牧", 9),
|
||||
("Catering", "餐飲", 10),
|
||||
("Cell Groups", "小組牧養", 11),
|
||||
("Special Events", "特別活動", 12),
|
||||
];
|
||||
|
||||
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
||||
@@ -35,15 +37,132 @@ public static class DbSeeder
|
||||
[
|
||||
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
||||
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
||||
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]),
|
||||
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
|
||||
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
|
||||
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
||||
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]),
|
||||
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]),
|
||||
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]),
|
||||
("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
|
||||
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
|
||||
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
|
||||
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
|
||||
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
|
||||
("Other", "其他", 10, [("Miscellaneous","雜支")]),
|
||||
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
|
||||
("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","外包勞務")]),
|
||||
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
|
||||
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
|
||||
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
|
||||
];
|
||||
|
||||
// (LineCode, Name_en, Name_zh, Sort)
|
||||
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
|
||||
[
|
||||
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
|
||||
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
|
||||
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
|
||||
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
|
||||
("7", "Other salaries and wages", "薪資", 5),
|
||||
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
|
||||
("9", "Other employee benefits", "員工福利", 7),
|
||||
("10", "Payroll taxes", "薪資稅", 8),
|
||||
("11b", "Legal fees", "法律服務費", 9),
|
||||
("11c", "Accounting fees", "會計與審計費", 10),
|
||||
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
|
||||
("12", "Advertising and promotion", "廣告與推廣", 12),
|
||||
("13", "Office expenses", "辦公費用", 13),
|
||||
("14", "Information technology", "資訊科技", 14),
|
||||
("16", "Occupancy", "場地佔用", 15),
|
||||
("17", "Travel", "差旅", 16),
|
||||
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
|
||||
("20", "Interest", "利息", 18),
|
||||
("22", "Depreciation", "折舊", 19),
|
||||
("23", "Insurance", "保險", 20),
|
||||
("24", "Other expenses", "其他費用", 21),
|
||||
];
|
||||
|
||||
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
|
||||
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
|
||||
[
|
||||
("Personnel", "Officer / Key Employee Compensation", "5"),
|
||||
("Personnel", "Salary & Wages", "7"),
|
||||
("Personnel", "Payroll Taxes", "10"),
|
||||
("Personnel", "Employee Benefits", "9"),
|
||||
("Personnel", "Retirement / Pension","8"),
|
||||
("Personnel", "Workers Compensation","9"),
|
||||
("Personnel", "Honorarium", "11g"),
|
||||
("Personnel", "Contract Labor", "11g"),
|
||||
("Personnel", "Staff Training", "19"),
|
||||
("Facility", "Rent", "16"),
|
||||
("Facility", "Utilities", "16"),
|
||||
("Facility", "Property Insurance", "23"),
|
||||
("Facility", "Decoration", "24"),
|
||||
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
|
||||
("Facility", "Repairs & Maintenance", "16"),
|
||||
("Training", "Course Fees", "19"),
|
||||
("Training", "Conference", "19"),
|
||||
("Training", "Books", "24"),
|
||||
("Training", "Travel", "17"),
|
||||
("Missions", "Travel", "17"),
|
||||
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
|
||||
("Missions", "Offering Transfer", "2"),
|
||||
("Missions", "Missionary Support", "2"),
|
||||
("Missions", "Foreign Missions Support", "3"),
|
||||
("Benevolence", "Emergency Aid", "2"),
|
||||
("Benevolence", "Condolence Gifts", "2"),
|
||||
// Visitation is the church's own travel/program cost, not a grant to an individual.
|
||||
("Benevolence", "Visit Expenses", "17"),
|
||||
("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", "Order of Service", "13"),
|
||||
("Printing", "Posters", "12"),
|
||||
("Printing", "Advertising & Promotion", "12"),
|
||||
("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", "Accounting & Audit", "11c"),
|
||||
("Professional Services", "Other Professional", "11g"),
|
||||
("Information Technology", "Software & Subscriptions", "14"),
|
||||
("Information Technology", "Website & Hosting", "14"),
|
||||
("Information Technology", "Internet & Telecom", "14"),
|
||||
("Finance & Banking", "Interest", "20"),
|
||||
// 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 =
|
||||
@@ -87,6 +206,12 @@ public static class DbSeeder
|
||||
("finance", Modules.MonthlyStatements, true, true, false, true),
|
||||
("finance", Modules.ChurchProfile, true, true, false, false),
|
||||
("finance", Modules.Disbursements, true, true, true, true),
|
||||
("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
|
||||
// governance value, so finance and board members can read them too.
|
||||
@@ -94,6 +219,24 @@ public static class DbSeeder
|
||||
("pastor", Modules.AuditLogs, true, false, false, false),
|
||||
("finance", Modules.AuditLogs, true, false, false, false),
|
||||
("board_member", Modules.AuditLogs, true, false, false, false),
|
||||
("pastor", Modules.Form990Report, true, false, false, false),
|
||||
("board_member", Modules.Form990Report, true, false, false, false),
|
||||
|
||||
// Ministries — secretary maintains the list; coworker_chair edits; ministry
|
||||
// leaders and pastor read.
|
||||
("secretary", Modules.Ministries, true, true, true, false),
|
||||
("coworker_chair", Modules.Ministries, true, true, false, false),
|
||||
("ministry_leader", Modules.Ministries, true, false, false, false),
|
||||
("pastor", Modules.Ministries, true, false, false, false),
|
||||
|
||||
// Meal attendance — secretary and coworkers record; finance and pastor read.
|
||||
("secretary", Modules.MealAttendance, true, true, false, false),
|
||||
("coworker", Modules.MealAttendance, true, true, false, false),
|
||||
("finance", Modules.MealAttendance, true, false, false, false),
|
||||
("pastor", Modules.MealAttendance, true, false, false, false),
|
||||
|
||||
// Users, Permissions, and Settings are intentionally super_admin-only:
|
||||
// super_admin bypasses all checks, so no seed rows are needed here.
|
||||
];
|
||||
|
||||
public static async Task SeedRolePermissionsAsync(AppDbContext db)
|
||||
@@ -163,13 +306,35 @@ public static class DbSeeder
|
||||
foreach (var (en, zh, sort) in MinistrySeed)
|
||||
{
|
||||
if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
|
||||
db.Ministries.Add(new Ministry { Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
|
||||
db.Ministries.Add(new Ministry
|
||||
{
|
||||
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
|
||||
DefaultFunctionalClass = en == "Administration"
|
||||
? FunctionalClasses.ManagementGeneral
|
||||
: FunctionalClasses.Program,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
||||
{
|
||||
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
|
||||
// only fires while the old name still exists. (New installs never hit this.)
|
||||
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
|
||||
{
|
||||
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
|
||||
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
|
||||
};
|
||||
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
|
||||
{
|
||||
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
|
||||
if (grp is null) continue;
|
||||
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
|
||||
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
||||
{
|
||||
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
||||
@@ -192,6 +357,65 @@ public static class DbSeeder
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
|
||||
{
|
||||
foreach (var (code, en, zh, sort) in Form990LineSeed)
|
||||
{
|
||||
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
|
||||
db.Form990ExpenseLines.Add(new Form990ExpenseLine
|
||||
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
|
||||
var fallbackId = linesByCode["24"];
|
||||
|
||||
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
|
||||
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
|
||||
group.Form990LineId ??= fallbackId;
|
||||
|
||||
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
|
||||
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
|
||||
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
|
||||
{
|
||||
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
|
||||
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
|
||||
sub.Form990LineId = lineId;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||
{
|
||||
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||
@@ -208,6 +432,50 @@ public static class DbSeeder
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SeedSiteSettingAsync(AppDbContext db)
|
||||
{
|
||||
// Singleton row holding site-wide presentation/locale settings.
|
||||
if (!await db.SiteSettings.AnyAsync())
|
||||
{
|
||||
db.SiteSettings.Add(new SiteSetting
|
||||
{
|
||||
SiteTitle = "River Of Life Christian Church",
|
||||
SiteTitleZh = "生命河靈糧堂",
|
||||
DefaultLanguage = "en",
|
||||
TimeZone = "America/Los_Angeles",
|
||||
DateFormat = "yyyy-MM-dd",
|
||||
Currency = "USD",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
|
||||
{
|
||||
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
|
||||
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
|
||||
if (!await db.NotificationSettings.AnyAsync())
|
||||
{
|
||||
var smtp = config.GetSection("Smtp");
|
||||
var line = config.GetSection("Line");
|
||||
db.NotificationSettings.Add(new NotificationSetting
|
||||
{
|
||||
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
|
||||
SmtpHost = smtp["Host"] ?? "",
|
||||
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
|
||||
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
|
||||
SmtpUser = smtp["User"] ?? "",
|
||||
SmtpPassword = smtp["Password"] ?? "",
|
||||
FromAddress = smtp["FromAddress"] ?? "",
|
||||
FromName = smtp["FromName"] ?? "",
|
||||
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
|
||||
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
|
||||
LineChannelSecret = line["ChannelSecret"] ?? "",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds roles and (in Development) the default admin account.
|
||||
/// Called once on application startup after migrations have been applied.
|
||||
@@ -217,6 +485,7 @@ public static class DbSeeder
|
||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
||||
var env = services.GetRequiredService<IWebHostEnvironment>();
|
||||
var config = services.GetRequiredService<IConfiguration>();
|
||||
|
||||
await SeedRolesAsync(roleManager);
|
||||
|
||||
@@ -225,7 +494,11 @@ public static class DbSeeder
|
||||
await SeedGivingCategoriesAsync(db);
|
||||
await SeedMinistriesAsync(db);
|
||||
await SeedExpenseCategoriesAsync(db);
|
||||
await SeedForm990ExpenseLinesAsync(db);
|
||||
await SeedForm1099BoxesAsync(db);
|
||||
await SeedChurchProfileAsync(db);
|
||||
await SeedSiteSettingAsync(db);
|
||||
await SeedNotificationSettingAsync(db, config);
|
||||
|
||||
if (env.IsDevelopment())
|
||||
await SeedAdminUserAsync(userManager);
|
||||
|
||||
@@ -157,6 +157,8 @@ rows AS (
|
||||
mi."Id" AS ministry_id,
|
||||
gp."Id" AS group_id,
|
||||
sc."Id" AS sub_id,
|
||||
-- pre-allocate the expense id so the matching ExpenseLine can reference it
|
||||
nextval(pg_get_serial_sequence('"Expenses"','Id')) AS new_id,
|
||||
sp.is_reimb,
|
||||
sp.vendor,
|
||||
sp.descr,
|
||||
@@ -172,13 +174,14 @@ rows AS (
|
||||
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
||||
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
||||
)
|
||||
, ins_exp AS (
|
||||
INSERT INTO "Expenses"
|
||||
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount",
|
||||
("Id","MinistryId","Type","Status","Amount",
|
||||
"Description","VendorName","MemberId","CheckNumber","ExpenseDate",
|
||||
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
|
||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
||||
SELECT
|
||||
r.ministry_id, r.group_id, r.sub_id,
|
||||
r.new_id, r.ministry_id,
|
||||
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
|
||||
r.status,
|
||||
r.amount,
|
||||
@@ -196,6 +199,15 @@ SELECT
|
||||
CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
|
||||
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
|
||||
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
|
||||
FROM rows r
|
||||
)
|
||||
-- one line per mock expense (single-category), mirroring the migrated production shape
|
||||
INSERT INTO "ExpenseLines"
|
||||
("ExpenseId","CategoryGroupId","SubCategoryId","FunctionalClass","Amount","Description",
|
||||
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
|
||||
SELECT
|
||||
r.new_id, r.group_id, r.sub_id, NULL, r.amount, NULL,
|
||||
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata'
|
||||
FROM rows r;
|
||||
|
||||
COMMIT;
|
||||
|
||||
@@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public string? NameZh { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? State { get; set; }
|
||||
@@ -17,6 +21,16 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
||||
public string? BankAccountNumber { 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>
|
||||
public int NextCheckNumber { get; set; } = 1001;
|
||||
|
||||
|
||||
@@ -5,14 +5,13 @@ public class Expense : SoftDeleteEntity, IAuditable
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int MinistryId { get; set; }
|
||||
public int CategoryGroupId { get; set; }
|
||||
public int SubCategoryId { get; set; }
|
||||
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
|
||||
public string Status { get; set; } = "Draft"; // see state machine
|
||||
public decimal Amount { get; set; }
|
||||
public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
|
||||
public string Description { get; set; } = null!;
|
||||
public string? VendorName { get; set; }
|
||||
public int? MemberId { get; set; }
|
||||
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
|
||||
public string? CheckNumber { get; set; }
|
||||
public DateOnly ExpenseDate { get; set; }
|
||||
public string? ReceiptBlobPath { get; set; }
|
||||
@@ -26,7 +25,7 @@ public class Expense : SoftDeleteEntity, IAuditable
|
||||
public string? PaidBy { get; set; }
|
||||
|
||||
public Ministry? Ministry { get; set; }
|
||||
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
||||
public ExpenseSubCategory? SubCategory { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public Payee1099? Payee { get; set; }
|
||||
public List<ExpenseLine> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -9,5 +9,11 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public int? Form990LineId { get; set; }
|
||||
public Form990ExpenseLine? Form990Line { get; set; }
|
||||
|
||||
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||
public Form1099Box? Form1099Box { get; set; }
|
||||
|
||||
public List<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; }
|
||||
}
|
||||
@@ -10,5 +10,11 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public int? Form990LineId { get; set; }
|
||||
public Form990ExpenseLine? Form990Line { get; set; }
|
||||
|
||||
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
|
||||
public Form1099Box? Form1099Box { get; set; }
|
||||
|
||||
public ExpenseCategoryGroup? Group { get; set; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ROLAC.API.Entities.Base;
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages".</summary>
|
||||
public class Form990ExpenseLine : AuditableEntity, IAuditable
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string LineCode { get; set; } = null!; // "7", "11b", "16", "24"
|
||||
public string Name_en { get; set; } = null!;
|
||||
public string? Name_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
|
||||
/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
|
||||
/// </summary>
|
||||
public static class FunctionalClasses
|
||||
{
|
||||
public const string Program = "Program";
|
||||
public const string ManagementGeneral = "ManagementGeneral";
|
||||
public const string Fundraising = "Fundraising";
|
||||
|
||||
public static readonly IReadOnlyList<string> All = [Program, ManagementGeneral, Fundraising];
|
||||
|
||||
/// <summary>Returns the value if valid, otherwise Program (the safe default).</summary>
|
||||
public static string Normalize(string? value) =>
|
||||
value is not null && All.Contains(value) ? value : Program;
|
||||
}
|
||||
@@ -45,18 +45,23 @@ public static class AuditActions
|
||||
public const string Logout = "Logout";
|
||||
public const string LoginFailed = "LoginFailed";
|
||||
public const string RoleChanged = "RoleChanged";
|
||||
public const string PasswordChanged = "PasswordChanged";
|
||||
public const string UserDeactivated = "UserDeactivated";
|
||||
public const string PermissionChanged = "PermissionChanged";
|
||||
public const string InvitationCreated = "InvitationCreated";
|
||||
public const string InvitationAccepted = "InvitationAccepted";
|
||||
public const string CheckIssued = "CheckIssued";
|
||||
public const string CheckVoided = "CheckVoided";
|
||||
public const string ExpenseApproved = "ExpenseApproved";
|
||||
public const string ExpenseRejected = "ExpenseRejected";
|
||||
public const string StatementFinalized = "StatementFinalized";
|
||||
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[
|
||||
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
|
||||
UserDeactivated, PermissionChanged, CheckIssued, CheckVoided,
|
||||
ExpenseApproved, StatementFinalized,
|
||||
PasswordChanged, UserDeactivated, PermissionChanged,
|
||||
InvitationCreated, InvitationAccepted, CheckIssued,
|
||||
CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
|
||||
public string? NickName { get; set; }
|
||||
public string? FirstName_zh { get; set; }
|
||||
public string? LastName_zh { get; set; }
|
||||
public string? Entity { get; set; } // company / business name (公司行號) — used for company-check offerings
|
||||
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
|
||||
public DateOnly? DateOfBirth { get; set; }
|
||||
public DateOnly? BaptismDate { get; set; }
|
||||
|
||||
@@ -11,4 +11,5 @@ public class Ministry : IAuditable
|
||||
public string? Description_zh { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string DefaultFunctionalClass { get; set; } = "Program";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ROLAC.API.Entities.Base;
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton (Id == 1) holding the editable SMTP + Line notification settings. This row — not the
|
||||
/// "Smtp"/"Line" appsettings sections — is the runtime source of truth; those sections only seed
|
||||
/// this row once on first startup. Read at send time via <c>INotificationSettingsService</c> so
|
||||
/// edits apply without restarting the API.
|
||||
///
|
||||
/// Secrets (<see cref="SmtpPassword"/>, <see cref="LineChannelAccessToken"/>,
|
||||
/// <see cref="LineChannelSecret"/>) are stored plaintext and protected by RBAC (the <c>Settings</c>
|
||||
/// module / super_admin) per the project decision for this small single-VM internal app.
|
||||
/// </summary>
|
||||
public class NotificationSetting : AuditableEntity, IAuditable
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
// ── Email (SMTP) ─────────────────────────────────────────────────────────
|
||||
public bool EnableEmail { get; set; }
|
||||
public string SmtpHost { get; set; } = "";
|
||||
public int SmtpPort { get; set; } = 587;
|
||||
public bool SmtpUseSsl { get; set; } = true; // true → STARTTLS
|
||||
public string SmtpUser { get; set; } = "";
|
||||
public string SmtpPassword { get; set; } = "";
|
||||
public string FromAddress { get; set; } = "";
|
||||
public string FromName { get; set; } = "";
|
||||
|
||||
// ── Line ─────────────────────────────────────────────────────────────────
|
||||
public bool EnableLine { get; set; }
|
||||
public string LineChannelAccessToken { get; set; } = "";
|
||||
public string LineChannelSecret { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>A short-lived code a member types to the Line bot to complete account binding.</summary>
|
||||
public class LineBindingCode
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = null!;
|
||||
public int MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? ConsumedAt { get; set; } // null = unused
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future
|
||||
/// channels don't require changes to Member.
|
||||
/// </summary>
|
||||
public class MemberChannelBinding
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public string Channel { get; set; } = null!; // "line"
|
||||
public string ExternalId { get; set; } = null!; // Line userId
|
||||
public DateTime BoundAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>A Line group the bot was added to. Named by an admin after the join event.</summary>
|
||||
public class MessagingGroup
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Channel { get; set; } = null!; // "line"
|
||||
public string ExternalId { get; set; } = null!; // Line groupId
|
||||
public string? Name { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime RegisteredAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>An append-only audit row for every email or Line send (success or failure).</summary>
|
||||
public class NotificationLog
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Channel { get; set; } = null!; // "email" | "line"
|
||||
public string TargetType { get; set; } = null!; // "email" | "user" | "group"
|
||||
public string TargetExternalId { get; set; } = null!; // email address OR Line id
|
||||
public string? Subject { get; set; } // email only
|
||||
public int? MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
public int? MessagingGroupId { get; set; }
|
||||
public MessagingGroup? MessagingGroup { get; set; }
|
||||
public string Body { get; set; } = null!;
|
||||
public string Status { get; set; } = null!; // "sent" | "failed"
|
||||
public string? Error { get; set; }
|
||||
public string SentByUserId { get; set; } = null!;
|
||||
public DateTime SentAt { get; set; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using ROLAC.API.Entities.Base;
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the
|
||||
/// Church Profile → Site Settings tab (gated by the <c>Settings</c> permission module).
|
||||
/// Seeded with sensible defaults on startup.
|
||||
/// </summary>
|
||||
public class SiteSetting : AuditableEntity, IAuditable
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string SiteTitle { get; set; } = "";
|
||||
public string? SiteTitleZh { get; set; }
|
||||
public string DefaultLanguage { get; set; } = "en"; // "en" | "zh"
|
||||
public string TimeZone { get; set; } = "America/Los_Angeles";
|
||||
public string DateFormat { get; set; } = "yyyy-MM-dd";
|
||||
public string Currency { get; set; } = "USD";
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ROLAC.API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A single-use, expiring invitation that lets a member set their own password and log in for
|
||||
/// the first time — without an admin-generated temporary password. The raw token is e-mailed /
|
||||
/// copied to the member; only its SHA-256 hash is stored here (same scheme as RefreshToken).
|
||||
/// </summary>
|
||||
public class UserInvitation
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string UserId { get; set; } = null!;
|
||||
public AppUser User { get; set; } = null!;
|
||||
|
||||
/// <summary>SHA-256 hex of the raw invitation token. Never store raw tokens.</summary>
|
||||
public string TokenHash { get; set; } = null!;
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Id of the admin who generated the link.</summary>
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
|
||||
/// <summary>Set when the member consumes the link to set their password (single-use).</summary>
|
||||
public DateTime? UsedAt { get; set; }
|
||||
|
||||
/// <summary>Set when superseded by a newer invitation for the same user (re-issue).</summary>
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
|
||||
// Computed helpers — NOT mapped to DB columns (ignored in OnModelCreating)
|
||||
public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
|
||||
public bool IsUsed => UsedAt.HasValue;
|
||||
public bool IsRevoked => RevokedAt.HasValue;
|
||||
public bool IsActive => !IsUsed && !IsRevoked && !IsExpired;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class AttendanceHub : Hub
|
||||
// Push the current counts to a client the moment it connects.
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var counts = await _svc.GetOrCreateAsync(_svc.Today);
|
||||
var counts = await _svc.GetOrCreateAsync(_svc.ServiceDay);
|
||||
await Clients.Caller.SendAsync("ReceiveCounts", counts);
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -26,14 +26,14 @@ public class AttendanceHub : Hub
|
||||
// Apply a batched delta for one age group, then broadcast the new totals to everyone.
|
||||
public async Task Increment(string category, int delta)
|
||||
{
|
||||
var counts = await _svc.IncrementAsync(_svc.Today, category, delta);
|
||||
var counts = await _svc.IncrementAsync(_svc.ServiceDay, category, delta);
|
||||
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||
}
|
||||
|
||||
// Overwrite one age group with an absolute value, then broadcast the new totals to everyone.
|
||||
public async Task SetCount(string category, int value)
|
||||
{
|
||||
var counts = await _svc.SetAsync(_svc.Today, category, value);
|
||||
var counts = await _svc.SetAsync(_svc.ServiceDay, category, value);
|
||||
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ROLAC.API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNotifications : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LineBindingCodes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
MemberId = table.Column<int>(type: "integer", nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
ConsumedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LineBindingCodes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LineBindingCodes_Members_MemberId",
|
||||
column: x => x.MemberId,
|
||||
principalTable: "Members",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MemberChannelBindings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
MemberId = table.Column<int>(type: "integer", nullable: false),
|
||||
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
BoundAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MemberChannelBindings", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MemberChannelBindings_Members_MemberId",
|
||||
column: x => x.MemberId,
|
||||
principalTable: "Members",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MessagingGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
RegisteredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MessagingGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NotificationLogs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
TargetType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
TargetExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Subject = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
MemberId = table.Column<int>(type: "integer", nullable: true),
|
||||
MessagingGroupId = table.Column<int>(type: "integer", nullable: true),
|
||||
Body = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
Error = table.Column<string>(type: "text", nullable: true),
|
||||
SentByUserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NotificationLogs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationLogs_Members_MemberId",
|
||||
column: x => x.MemberId,
|
||||
principalTable: "Members",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_NotificationLogs_MessagingGroups_MessagingGroupId",
|
||||
column: x => x.MessagingGroupId,
|
||||
principalTable: "MessagingGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LineBindingCodes_Code",
|
||||
table: "LineBindingCodes",
|
||||
column: "Code");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LineBindingCodes_MemberId",
|
||||
table: "LineBindingCodes",
|
||||
column: "MemberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MemberChannelBindings_Channel_ExternalId",
|
||||
table: "MemberChannelBindings",
|
||||
columns: new[] { "Channel", "ExternalId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MemberChannelBindings_MemberId_Channel",
|
||||
table: "MemberChannelBindings",
|
||||
columns: new[] { "MemberId", "Channel" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MessagingGroups_Channel_ExternalId",
|
||||
table: "MessagingGroups",
|
||||
columns: new[] { "Channel", "ExternalId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_Channel",
|
||||
table: "NotificationLogs",
|
||||
column: "Channel");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_MemberId",
|
||||
table: "NotificationLogs",
|
||||
column: "MemberId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_MessagingGroupId",
|
||||
table: "NotificationLogs",
|
||||
column: "MessagingGroupId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NotificationLogs_SentAt",
|
||||
table: "NotificationLogs",
|
||||
column: "SentAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LineBindingCodes");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MemberChannelBindings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NotificationLogs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MessagingGroups");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ROLAC.API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserInvitations : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserInvitations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||
UsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
RevokedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserInvitations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserInvitations_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserInvitations_TokenHash",
|
||||
table: "UserInvitations",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserInvitations_UserId",
|
||||
table: "UserInvitations",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserInvitations");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2202
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ROLAC.API.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddForm990FunctionalExpenses : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DefaultFunctionalClass",
|
||||
table: "Ministries",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: false,
|
||||
defaultValue: "Program");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Form990LineId",
|
||||
table: "ExpenseSubCategories",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FunctionalClass",
|
||||
table: "Expenses",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Form990LineId",
|
||||
table: "ExpenseCategoryGroups",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Form990ExpenseLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
LineCode = 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),
|
||||
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_Form990ExpenseLines", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseSubCategories_Form990LineId",
|
||||
table: "ExpenseSubCategories",
|
||||
column: "Form990LineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExpenseCategoryGroups_Form990LineId",
|
||||
table: "ExpenseCategoryGroups",
|
||||
column: "Form990LineId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Form990ExpenseLines_LineCode",
|
||||
table: "Form990ExpenseLines",
|
||||
column: "LineCode",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
|
||||
table: "ExpenseCategoryGroups",
|
||||
column: "Form990LineId",
|
||||
principalTable: "Form990ExpenseLines",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
|
||||
table: "ExpenseSubCategories",
|
||||
column: "Form990LineId",
|
||||
principalTable: "Form990ExpenseLines",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
|
||||
table: "ExpenseCategoryGroups");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
|
||||
table: "ExpenseSubCategories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Form990ExpenseLines");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExpenseSubCategories_Form990LineId",
|
||||
table: "ExpenseSubCategories");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ExpenseCategoryGroups_Form990LineId",
|
||||
table: "ExpenseCategoryGroups");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DefaultFunctionalClass",
|
||||
table: "Ministries");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Form990LineId",
|
||||
table: "ExpenseSubCategories");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FunctionalClass",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Form990LineId",
|
||||
table: "ExpenseCategoryGroups");
|
||||
}
|
||||
}
|
||||
}
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user