Compare commits
98 Commits
60405ef0aa
...
ddced87dc6
| Author | SHA1 | Date | |
|---|---|---|---|
| ddced87dc6 | |||
| 7ab8e9703b | |||
| aaaae09bd2 | |||
| 8061a60fe5 | |||
| 87425b3276 | |||
| 2af169fa60 | |||
| 3558c67fd7 | |||
| f55807fa7d | |||
| b6c50a38aa | |||
| caed5091f0 | |||
| 769597d769 | |||
| 241870fe48 | |||
| e817801e14 | |||
| aef5454202 | |||
| 9a94e3b09e | |||
| fba0b63214 | |||
| e5296e79dc | |||
| 61e34d343a | |||
| 126e640731 | |||
| 4e15e9f630 | |||
| 4bee06addb | |||
| a99755a5db | |||
| fef3b76a31 | |||
| e37aade69f | |||
| fe50ea3d30 | |||
| 95fa37ebdf | |||
| e1f99158aa | |||
| 95008788f3 | |||
| f5ff03260b | |||
| aa77f2051a | |||
| 4704d33b4a | |||
| 18b9707e44 | |||
| 1bb9da16d4 | |||
| 3188064335 | |||
| 04b05617b8 | |||
| 9933c180b7 | |||
| 86d9879a6d | |||
| d9289008f6 | |||
| 015f689d9b | |||
| 15cdfe6f92 | |||
| e7bf07c2ad | |||
| ac65c68e18 | |||
| cf929557fe | |||
| cc58d06723 | |||
| b3eb9d297a | |||
| f6f06d841c | |||
| 50e518095e | |||
| fdd0d7c8e1 | |||
| 0639d1fe83 | |||
| a2d394029a | |||
| 5f8676f962 | |||
| 48885dba83 | |||
| af21e50d9f | |||
| a573179714 | |||
| 66640d1fd0 | |||
| 001db35cef | |||
| 81a0b5a038 | |||
| 7260e5c115 | |||
| 91247a7c69 | |||
| 4a2b142061 | |||
| b5a15dd9f2 | |||
| 86041c0d05 | |||
| e04776460d | |||
| 586551aec0 | |||
| 8ff93e3698 | |||
| 2b6f29e775 | |||
| 81efaedbc2 | |||
| cb15d30980 | |||
| 798dfa3fe0 | |||
| 8b52572fad | |||
| 577ae1aabe | |||
| e20964ae0d | |||
| 999f8a80f9 | |||
| 3974cec967 | |||
| 82b9744024 | |||
| a525c71baa | |||
| d79b1faa8f | |||
| e83fa4c2e9 | |||
| bc67146d86 | |||
| a18d44bd0a | |||
| 6c3292861a | |||
| 3a5b5721e4 | |||
| 07e0270599 | |||
| 32e47e4566 | |||
| d2eac52a47 | |||
| 8249b3fe3e | |||
| 3ab0998793 | |||
| 0986233d9b | |||
| bfffdee2a8 | |||
| 97743f6974 | |||
| 34344cbf83 | |||
| cd5413125d | |||
| 820ca6981c | |||
| f703519838 | |||
| 5041873c2b | |||
| 61c6697c87 | |||
| 5d556b882d | |||
| adad5cb7e9 |
@@ -0,0 +1,72 @@
|
|||||||
|
name: ci-cd-nas
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Runs on the DEV PC runner (label `builder`): Docker Desktop + .NET SDK.
|
||||||
|
# DS220+ (Celeron J4025 / 2GB RAM) cannot build these images, so all the heavy
|
||||||
|
# work (test, dotnet publish, ng build) happens here, then images are pushed
|
||||||
|
# to the Gitea registry on the NAS.
|
||||||
|
build-push:
|
||||||
|
# Label is registered on the dev PC as `windows:host`; runs-on matches the
|
||||||
|
# label NAME (before the colon). `:host` means it runs directly on the PC,
|
||||||
|
# using its installed Docker Desktop + .NET SDK (no container).
|
||||||
|
runs-on: windows
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
# Git Bash (bundled with Git for Windows) — needed for `$REGISTRY` and
|
||||||
|
# the heredoc-style multi-line steps below.
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY: git.golife.love/chrischen
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Test API
|
||||||
|
run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
|
||||||
|
|
||||||
|
- name: Registry login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build images
|
||||||
|
run: |
|
||||||
|
docker build -t "$REGISTRY/rolac-api:latest" -t "$REGISTRY/rolac-api:${{ github.sha }}" ./API
|
||||||
|
docker build -t "$REGISTRY/rolac-app:latest" -t "$REGISTRY/rolac-app:${{ github.sha }}" ./APP
|
||||||
|
|
||||||
|
- name: Push images
|
||||||
|
run: |
|
||||||
|
docker push --all-tags "$REGISTRY/rolac-api"
|
||||||
|
docker push --all-tags "$REGISTRY/rolac-app"
|
||||||
|
|
||||||
|
# Runs on the NAS runner (label `nas`): host Docker socket mounted and
|
||||||
|
# /volume1/docker/rolac bind-mounted at the same path. Deploy ONLY — it just
|
||||||
|
# pulls the freshly-built images and (re)starts the stack. No building here.
|
||||||
|
deploy:
|
||||||
|
needs: build-push
|
||||||
|
runs-on: nas
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: sh
|
||||||
|
env:
|
||||||
|
DEPLOY_DIR: /volume1/docker/rolac
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Registry login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.golife.love -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Sync compose + nginx to deploy dir
|
||||||
|
run: |
|
||||||
|
mkdir -p "$DEPLOY_DIR/nginx/conf.d" "$DEPLOY_DIR/data/api-storage"
|
||||||
|
cp deploy/nas/docker-compose.yml "$DEPLOY_DIR/docker-compose.yml"
|
||||||
|
cp deploy/nas/nginx/conf.d/rolac.conf "$DEPLOY_DIR/nginx/conf.d/rolac.conf"
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
cd "$DEPLOY_DIR"
|
||||||
|
export TAG=${{ github.sha }}
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
sleep 5
|
||||||
|
curl -fsS http://localhost:8080/api/health
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: ci-cd
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [azure-deploy]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with: { dotnet-version: '8.0.x' }
|
||||||
|
- run: dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release
|
||||||
|
|
||||||
|
build-push:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.golife.love
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./API
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.golife.love/chrischen/rolac-api:latest
|
||||||
|
git.golife.love/chrischen/rolac-api:${{ github.sha }}
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./APP
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.golife.love/chrischen/rolac-app:latest
|
||||||
|
git.golife.love/chrischen/rolac-app:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build-push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.VM_HOST }}
|
||||||
|
username: ${{ secrets.VM_USER }}
|
||||||
|
key: ${{ secrets.VM_SSH_KEY }}
|
||||||
|
script: |
|
||||||
|
cd /opt/rolac/deploy
|
||||||
|
export TAG=${{ github.sha }}
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
curl -fsS https://app.rolac.org/api/health
|
||||||
@@ -92,3 +92,5 @@ logs/
|
|||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
/.claude
|
/.claude
|
||||||
|
/API/ROLAC.API/bin-verify
|
||||||
|
API/ROLAC.API/App_Data/
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/appsettings.Development.json
|
||||||
|
**/appsettings.*.local.json
|
||||||
|
ROLAC.API.Tests/
|
||||||
|
.git
|
||||||
|
*.user
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# ---- build ----
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ROLAC.API/ROLAC.API.csproj ROLAC.API/
|
||||||
|
RUN dotnet restore ROLAC.API/ROLAC.API.csproj
|
||||||
|
COPY ROLAC.API/ ROLAC.API/
|
||||||
|
RUN dotnet publish ROLAC.API/ROLAC.API.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
# ---- runtime ----
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
ENV ASPNETCORE_ENVIRONMENT=Production \
|
||||||
|
ASPNETCORE_HTTP_PORTS=8080
|
||||||
|
# curl: used by the HEALTHCHECK (not present in the base image)
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
# storage dir created + owned for the non-root app user
|
||||||
|
RUN mkdir -p /app/App_Data/storage && chown -R app:app /app/App_Data
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
|
||||||
|
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||||
|
ENTRYPOINT ["dotnet", "ROLAC.API.dll"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Data;
|
||||||
|
|
||||||
|
public class AuditInterceptorTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb(AuditSaveChangesInterceptor interceptor)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options;
|
||||||
|
return new AppDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditSaveChangesInterceptor BuildInterceptor(string userId = "user-1")
|
||||||
|
{
|
||||||
|
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 AuditSaveChangesInterceptor(mock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Added_SetsCreatedAtAndCreatedBy()
|
||||||
|
{
|
||||||
|
var interceptor = BuildInterceptor("user-42");
|
||||||
|
using var db = BuildDb(interceptor);
|
||||||
|
|
||||||
|
var member = new Member { FirstName_en = "A", LastName_en = "B" };
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Equal("user-42", member.CreatedBy);
|
||||||
|
Assert.Equal("user-42", member.UpdatedBy);
|
||||||
|
Assert.True(member.CreatedAt > DateTimeOffset.UtcNow.AddSeconds(-5));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Modified_UpdatesUpdatedAtAndUpdatedBy()
|
||||||
|
{
|
||||||
|
var interceptor = BuildInterceptor("user-1");
|
||||||
|
using var db = BuildDb(interceptor);
|
||||||
|
|
||||||
|
var member = new Member { FirstName_en = "A", LastName_en = "B" };
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
member.NickName = "Nick";
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Equal("user-1", member.UpdatedBy);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ROLAC.API.Services.Disbursement;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class AmountToWordsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, "Zero and 00/100 Dollars")]
|
||||||
|
[InlineData(0.05, "Zero and 05/100 Dollars")]
|
||||||
|
[InlineData(1, "One and 00/100 Dollars")]
|
||||||
|
[InlineData(19, "Nineteen and 00/100 Dollars")]
|
||||||
|
[InlineData(20, "Twenty and 00/100 Dollars")]
|
||||||
|
[InlineData(21, "Twenty-One and 00/100 Dollars")]
|
||||||
|
[InlineData(100, "One Hundred and 00/100 Dollars")]
|
||||||
|
[InlineData(115, "One Hundred Fifteen and 00/100 Dollars")]
|
||||||
|
[InlineData(1234.56, "One Thousand Two Hundred Thirty-Four and 56/100 Dollars")]
|
||||||
|
[InlineData(1000000, "One Million and 00/100 Dollars")]
|
||||||
|
public void Convert_FormatsExpectedWords(double amount, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, AmountToWords.Convert((decimal)amount));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Convert_RoundsCentsHalfUp()
|
||||||
|
{
|
||||||
|
Assert.Equal("One and 00/100 Dollars", AmountToWords.Convert(0.999m));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
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.Disbursement;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class DisbursementServiceTests
|
||||||
|
{
|
||||||
|
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 sealed class FakePrint : ICheckPrintService
|
||||||
|
{
|
||||||
|
public CheckPrintModel? LastReceiptModel;
|
||||||
|
|
||||||
|
public Task<Stream> RenderPdfAsync(CheckPrintModel model)
|
||||||
|
=> Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("pdf")));
|
||||||
|
|
||||||
|
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
|
||||||
|
{
|
||||||
|
LastReceiptModel = model;
|
||||||
|
return Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("receipt-pdf")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext BuildDb(string userId)
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||||
|
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
||||||
|
{
|
||||||
|
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 DisbursementService(db, http.Object, fs, new FakePrint());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
|
||||||
|
{
|
||||||
|
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.SaveChanges();
|
||||||
|
var fs = new FakeStorage();
|
||||||
|
return (SvcAs(db, fs, userId), db, fs);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
MemberId = memberId, VendorName = vendor,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GroupedWorklist_BundlesSamePayee()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
db.Expenses.AddRange(
|
||||||
|
Approved("StaffReimbursement", 10m, memberId: 1),
|
||||||
|
Approved("StaffReimbursement", 15m, memberId: 1),
|
||||||
|
Approved("VendorPayment", 30m, vendor: "Acme"));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var groups = await svc.GetApprovedUnpaidGroupedAsync();
|
||||||
|
|
||||||
|
Assert.Equal(2, groups.Count);
|
||||||
|
var member = groups.Single(g => g.PayeeType == "Member");
|
||||||
|
Assert.Equal(25m, member.TotalAmount);
|
||||||
|
Assert.Equal(2, member.Lines.Count);
|
||||||
|
Assert.Equal("John Doe", member.PayeeName);
|
||||||
|
Assert.Equal("1 Main St", member.Address);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
var e1 = Approved("StaffReimbursement", 10m, memberId: 1);
|
||||||
|
var e2 = Approved("StaffReimbursement", 15m, memberId: 1);
|
||||||
|
var e3 = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
db.Expenses.AddRange(e1, e2, e3);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var req = new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees =
|
||||||
|
[
|
||||||
|
new() { PayeeType = "Member", MemberId = 1, PayeeName = "John Doe", ExpenseIds = [e1.Id, e2.Id] },
|
||||||
|
new() { PayeeType = "Vendor", VendorKey = "acme", PayeeName = "Acme", ExpenseIds = [e3.Id] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await svc.IssueChecksAsync(req);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Created.Count);
|
||||||
|
Assert.Equal(new[] { "1001", "1002" }, result.Created.Select(c => c.CheckNumber).ToArray());
|
||||||
|
Assert.All(await db.Expenses.ToListAsync(), e => Assert.Equal("Paid", e.Status));
|
||||||
|
var memberCheck = await db.Checks.FirstAsync(c => c.PayeeType == "Member");
|
||||||
|
Assert.Equal(25m, memberCheck.Amount);
|
||||||
|
Assert.Equal(1003, (await db.ChurchProfiles.FirstAsync()).NextCheckNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Issue_RejectsNonApprovedExpense()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
e.Status = "Draft";
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var req = new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||||
|
};
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.IssueChecksAsync(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Void_RevertsExpensesToApproved()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||||
|
});
|
||||||
|
var checkId = result.Created[0].CheckId;
|
||||||
|
|
||||||
|
await svc.VoidAsync(checkId, "wrong amount");
|
||||||
|
|
||||||
|
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
|
||||||
|
Assert.Equal("Voided", check.Status);
|
||||||
|
var reverted = await db.Expenses.FirstAsync(x => x.Id == e.Id);
|
||||||
|
Assert.Equal("Approved", reverted.Status);
|
||||||
|
Assert.Null(reverted.CheckNumber);
|
||||||
|
Assert.Null(reverted.PaidAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_StoresSignatureAndTimestamp()
|
||||||
|
{
|
||||||
|
var (svc, db, fs) = Build();
|
||||||
|
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||||
|
});
|
||||||
|
var checkId = result.Created[0].CheckId;
|
||||||
|
|
||||||
|
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
|
||||||
|
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
|
||||||
|
|
||||||
|
var check = await db.Checks.FirstAsync(c => c.Id == checkId);
|
||||||
|
Assert.NotNull(check.ReceiptSignedAt);
|
||||||
|
Assert.Equal("Acme Rep", check.ReceiptSignedName);
|
||||||
|
Assert.NotNull(check.ReceiptSignatureBlobPath);
|
||||||
|
Assert.Single(fs.Files);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (DisbursementService svc, AppDbContext db, FakeStorage fs, FakePrint print) BuildWithPrint(string userId = "fin")
|
||||||
|
{
|
||||||
|
var db = BuildDb(userId);
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
|
||||||
|
db.SaveChanges();
|
||||||
|
var fs = new FakeStorage();
|
||||||
|
var print = new FakePrint();
|
||||||
|
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 DisbursementService(db, http.Object, fs, print), db, fs, print);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiptPdf_NullWhenNotSigned()
|
||||||
|
{
|
||||||
|
var (svc, db, _, _) = BuildWithPrint();
|
||||||
|
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
var receipt = await svc.RenderReceiptPdfAsync(result.Created[0].CheckId);
|
||||||
|
|
||||||
|
Assert.Null(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReceiptPdf_AfterSigning_RendersWithSignatureBytes()
|
||||||
|
{
|
||||||
|
var (svc, db, _, print) = BuildWithPrint();
|
||||||
|
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||||
|
});
|
||||||
|
var checkId = result.Created[0].CheckId;
|
||||||
|
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png-bytes"));
|
||||||
|
await svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "Acme Rep");
|
||||||
|
|
||||||
|
var receipt = await svc.RenderReceiptPdfAsync(checkId);
|
||||||
|
|
||||||
|
Assert.NotNull(receipt);
|
||||||
|
Assert.Equal("receipt-1001.pdf", receipt!.Value.fileName);
|
||||||
|
Assert.NotNull(print.LastReceiptModel);
|
||||||
|
Assert.NotNull(print.LastReceiptModel!.SignatureImage);
|
||||||
|
Assert.Equal("Acme Rep", print.LastReceiptModel.Check.ReceiptSignedName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acknowledge_VoidedCheck_Throws()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
var e = Approved("VendorPayment", 30m, vendor: "Acme");
|
||||||
|
db.Expenses.Add(e);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var result = await svc.IssueChecksAsync(new IssueChecksRequest
|
||||||
|
{
|
||||||
|
CheckDate = new DateOnly(2026, 6, 20),
|
||||||
|
Payees = [new() { PayeeType = "Vendor", PayeeName = "Acme", ExpenseIds = [e.Id] }],
|
||||||
|
});
|
||||||
|
var checkId = result.Created[0].CheckId;
|
||||||
|
await svc.VoidAsync(checkId, null);
|
||||||
|
|
||||||
|
using var img = new MemoryStream(Encoding.UTF8.GetBytes("png"));
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => svc.AcknowledgeReceiptAsync(checkId, img, "sig.png", "X"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseCategoryServiceTests
|
||||||
|
{
|
||||||
|
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(mock.Object)).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAll_NestsSubcategories_AndExcludesInactiveByDefault()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new ExpenseCategoryService(db);
|
||||||
|
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Equipment" });
|
||||||
|
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Purchase" });
|
||||||
|
await svc.DeactivateSubCategoryAsync(sid);
|
||||||
|
|
||||||
|
var active = await svc.GetAllAsync(includeInactive: false);
|
||||||
|
var all = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
|
||||||
|
Assert.Single(active);
|
||||||
|
Assert.Empty(active[0].SubCategories);
|
||||||
|
Assert.Single(all[0].SubCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateGroup_SetsInactive()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new ExpenseCategoryService(db);
|
||||||
|
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Other" });
|
||||||
|
await svc.DeactivateGroupAsync(gid);
|
||||||
|
Assert.Empty(await svc.GetAllAsync(includeInactive: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateGroup_Throws_WhenMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new ExpenseCategoryService(db);
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||||
|
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
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.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class ExpenseServiceTests
|
||||||
|
{
|
||||||
|
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 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(mock.Object)).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
|
||||||
|
{
|
||||||
|
var db = BuildDb(userId);
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
|
||||||
|
db.SaveChanges();
|
||||||
|
var fs = new FakeStorage();
|
||||||
|
return (SvcAs(db, fs, userId), db, fs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
var http = new Mock<IHttpContextAccessor>();
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
|
||||||
|
http.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new ExpenseService(db, http.Object, fs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateExpenseRequest Reimb() => new()
|
||||||
|
{
|
||||||
|
Type = "StaffReimbursement", MinistryId = 1, 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,
|
||||||
|
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
|
||||||
|
ExpenseDate = r.ExpenseDate, Notes = r.Notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Reimbursement_ResolvesUserId_FromSubClaim()
|
||||||
|
{
|
||||||
|
// Regression: the real JWT exposes the user id as "sub", not ClaimTypes.NameIdentifier.
|
||||||
|
// SubmittedBy must be the sub value (not "system"), or the self-ownership guard breaks.
|
||||||
|
var db = BuildDb("ignored");
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Purchase" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = SvcWithSubClaim(db, new FakeStorage(), "user-guid-123");
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("user-guid-123", e!.SubmittedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Vendor_AsFinance_IsPendingApproval()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
var r = Reimb(); r.Type = "VendorPayment"; r.VendorName = "ABC"; r.CheckNumber = "2051";
|
||||||
|
var id = await svc.CreateAsync(r, isFinance: true);
|
||||||
|
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Reimbursement_AsFinance_OnBehalf_IsPendingApproval_AndLinksPickedMember()
|
||||||
|
{
|
||||||
|
// Finance entering on behalf of a member (member explicitly picked) goes straight to the
|
||||||
|
// approval queue and links the picked member.
|
||||||
|
var (svc, db, _) = Build();
|
||||||
|
db.Members.Add(new Member { Id = 9, FirstName_en = "Pat", LastName_en = "Vendor" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var r = Reimb(); r.MemberId = 9;
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(r, isFinance: true);
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("PendingApproval", e!.Status);
|
||||||
|
Assert.Equal(9, e.MemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Reimbursement_AsFinance_SelfService_LinksCallerMember_AndIsDraft()
|
||||||
|
{
|
||||||
|
// Regression: a finance/super_admin user filing their OWN reimbursement via "My Reimbursements"
|
||||||
|
// sends no MemberId. The entry must link to the caller's own member (so the Payee shows their
|
||||||
|
// legal name) and stay a Draft until they explicitly Submit — not jump to PendingApproval with
|
||||||
|
// a null member.
|
||||||
|
var (svc, db, _) = Build("u1");
|
||||||
|
db.Members.Add(new Member { Id = 7, FirstName_en = "Grace", LastName_en = "Lee" });
|
||||||
|
db.Users.Add(new AppUser { Id = "u1", MemberId = 7 });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: true); // no MemberId on the request
|
||||||
|
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal(7, e!.MemberId);
|
||||||
|
Assert.Equal("Draft", e.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Reimbursement_AsMember_IsDraft_WithSubmitter()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("Draft", e!.Status);
|
||||||
|
Assert.Equal("alice", e.SubmittedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StateMachine_HappyPath_Submit_Approve_Pay()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
await svc.ApproveAsync(id);
|
||||||
|
Assert.Equal("Approved", (await db.Expenses.FindAsync(id))!.Status);
|
||||||
|
await svc.PayAsync(id, "3001", new DateOnly(2026, 6, 1));
|
||||||
|
var paid = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("Paid", paid!.Status);
|
||||||
|
Assert.Equal("3001", paid.CheckNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_FromDraft_Throws()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.ApproveAsync(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reject_RecordsNotes_AndStatus()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.SubmitAsync(id);
|
||||||
|
await svc.RejectAsync(id, "Missing receipt");
|
||||||
|
var e = await db.Expenses.FindAsync(id);
|
||||||
|
Assert.Equal("Rejected", e!.Status);
|
||||||
|
Assert.Equal("Missing receipt", e.ReviewNotes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_OthersDraft_AsNonFinance_Throws()
|
||||||
|
{
|
||||||
|
var (aliceSvc, db, fs) = Build("alice");
|
||||||
|
var id = await aliceSvc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
var bobSvc = SvcAs(db, fs, "bob");
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SoftDelete_HidesFromQueries()
|
||||||
|
{
|
||||||
|
var (svc, db, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
await svc.DeleteAsync(id, isFinance: true);
|
||||||
|
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Receipt_SaveThenOpen_RoundTrips()
|
||||||
|
{
|
||||||
|
var (svc, _, _) = Build("alice");
|
||||||
|
var id = await svc.CreateAsync(Reimb(), isFinance: false);
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
|
||||||
|
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
|
||||||
|
var got = await svc.OpenReceiptAsync(id, isFinance: true);
|
||||||
|
Assert.NotNull(got);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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.DTOs.Giving;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class GivingCategoryServiceTests
|
||||||
|
{
|
||||||
|
private static IHttpContextAccessor 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 mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
|
{
|
||||||
|
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
||||||
|
return new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ReturnsId_AndDefaultsActive()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new GivingCategoryService(db);
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Tithe", Name_zh = "什一" });
|
||||||
|
|
||||||
|
var saved = await db.GivingCategories.FindAsync(id);
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
Assert.True(saved!.IsActive);
|
||||||
|
Assert.Equal("Tithe", saved.Name_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_ExcludesInactive_ByDefault()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new GivingCategoryService(db);
|
||||||
|
var id1 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Active" });
|
||||||
|
var id2 = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Gone" });
|
||||||
|
await svc.DeactivateAsync(id2);
|
||||||
|
|
||||||
|
var active = await svc.GetAllAsync(includeInactive: false);
|
||||||
|
var all = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
|
||||||
|
Assert.Single(active);
|
||||||
|
Assert.Equal(2, all.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_SetsIsActiveFalse()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new GivingCategoryService(db);
|
||||||
|
var id = await svc.CreateAsync(new CreateGivingCategoryRequest { Name_en = "Temp" });
|
||||||
|
|
||||||
|
await svc.DeactivateAsync(id);
|
||||||
|
|
||||||
|
var saved = await db.GivingCategories.FindAsync(id);
|
||||||
|
Assert.False(saved!.IsActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_Throws_WhenMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new GivingCategoryService(db);
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||||
|
svc.UpdateAsync(999, new UpdateGivingCategoryRequest { Name_en = "X" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_Throws_WhenMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new GivingCategoryService(db);
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync(999));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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.DTOs.Giving;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class GivingServiceTests
|
||||||
|
{
|
||||||
|
private static IHttpContextAccessor 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 mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
|
{
|
||||||
|
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
||||||
|
return new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> SeedCategoryAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
|
||||||
|
db.GivingCategories.Add(c);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return c.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_PersistsGiving()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(new CreateGivingRequest
|
||||||
|
{
|
||||||
|
GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash",
|
||||||
|
GivingDate = new DateOnly(2026, 5, 31), IsAnonymous = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
var saved = await db.Givings.FindAsync(id);
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
Assert.Equal(100m, saved!.Amount);
|
||||||
|
Assert.Null(saved.OfferingSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_FiltersByCategory()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
await svc.CreateAsync(new CreateGivingRequest { GivingCategoryId = catId, Amount = 10m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) });
|
||||||
|
|
||||||
|
var page = await svc.GetPagedAsync(1, 20, null, catId, null, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, page.TotalCount);
|
||||||
|
Assert.Equal("Tithe", page.Items[0].CategoryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_Throws_WhenGivingBelongsToSubmittedSession()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var session = new OfferingSession { SessionDate = new DateOnly(2026,5,31), Status = "Submitted" };
|
||||||
|
db.OfferingSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
|
||||||
|
GivingDate = new DateOnly(2026,5,31), OfferingSessionId = session.Id };
|
||||||
|
db.Givings.Add(giving);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.UpdateAsync(giving.Id, new UpdateGivingRequest
|
||||||
|
{ GivingCategoryId = catId, Amount = 999m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026,5,31) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_Throws_WhenMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(999));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Anonymous_NullsProvidedMemberId()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(new CreateGivingRequest
|
||||||
|
{
|
||||||
|
GivingCategoryId = catId, Amount = 25m, PaymentMethod = "Cash",
|
||||||
|
GivingDate = new DateOnly(2026, 5, 31),
|
||||||
|
IsAnonymous = true, MemberId = 12345, // provided, but must be stripped
|
||||||
|
});
|
||||||
|
|
||||||
|
var saved = await db.Givings.FindAsync(id);
|
||||||
|
Assert.True(saved!.IsAnonymous);
|
||||||
|
Assert.Null(saved.MemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_Throws_WhenGivingBelongsToSubmittedSession()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var session = new OfferingSession { SessionDate = new DateOnly(2026, 5, 31), Status = "Submitted" };
|
||||||
|
db.OfferingSessions.Add(session);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var giving = new Giving { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash",
|
||||||
|
GivingDate = new DateOnly(2026, 5, 31), OfferingSessionId = session.Id };
|
||||||
|
db.Givings.Add(giving);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.DeleteAsync(giving.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_MatchesByMemberName()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var member = new Member { FirstName_en = "Grace", LastName_en = "Lee" };
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new GivingService(db);
|
||||||
|
await svc.CreateAsync(new CreateGivingRequest
|
||||||
|
{
|
||||||
|
GivingCategoryId = catId, Amount = 75m, PaymentMethod = "Cash",
|
||||||
|
GivingDate = new DateOnly(2026, 5, 31), MemberId = member.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
var page = await svc.GetPagedAsync(1, 20, "grace", null, null, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, page.TotalCount);
|
||||||
|
Assert.Equal(member.Id, page.Items[0].MemberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class LocalDiskFileStorageTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "rolac-test-" + Guid.NewGuid());
|
||||||
|
|
||||||
|
private LocalDiskFileStorage Build()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:LocalRoot"] = _root })
|
||||||
|
.Build();
|
||||||
|
return new LocalDiskFileStorage(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveThenOpen_RoundTrips()
|
||||||
|
{
|
||||||
|
var fs = Build();
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("hello"));
|
||||||
|
var path = await fs.SaveAsync(input, "finance/receipts/2026/5/1-r.txt");
|
||||||
|
|
||||||
|
await using var read = await fs.OpenReadAsync(path);
|
||||||
|
Assert.NotNull(read);
|
||||||
|
using var sr = new StreamReader(read!);
|
||||||
|
Assert.Equal("hello", await sr.ReadToEndAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenRead_ReturnsNull_WhenMissing()
|
||||||
|
{
|
||||||
|
var fs = Build();
|
||||||
|
Assert.Null(await fs.OpenReadAsync("finance/receipts/none.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_RejectsPathTraversal()
|
||||||
|
{
|
||||||
|
var fs = Build();
|
||||||
|
using var input = new MemoryStream(Encoding.UTF8.GetBytes("x"));
|
||||||
|
await Assert.ThrowsAsync<ArgumentException>(() => fs.SaveAsync(input, "../escape.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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.Members;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class MemberServiceTests
|
||||||
|
{
|
||||||
|
private static IHttpContextAccessor 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 mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an InMemory AppDbContext that includes the AuditSaveChangesInterceptor
|
||||||
|
/// so that CreatedBy/UpdatedBy are stamped on save (required by InMemory null checks).
|
||||||
|
/// </summary>
|
||||||
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
|
{
|
||||||
|
var accessor = BuildAccessor(userId);
|
||||||
|
var interceptor = new AuditSaveChangesInterceptor(accessor);
|
||||||
|
return new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ReturnsNewId()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
var request = new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" };
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(request);
|
||||||
|
|
||||||
|
Assert.True(id > 0);
|
||||||
|
var saved = await db.Members.FindAsync(id);
|
||||||
|
Assert.NotNull(saved);
|
||||||
|
Assert.Equal("Chris", saved.FirstName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_SavesNickName()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
var request = new CreateMemberRequest
|
||||||
|
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" };
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(request);
|
||||||
|
var saved = await db.Members.FindAsync(id);
|
||||||
|
|
||||||
|
Assert.Equal("Chris", saved!.NickName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetById ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsDto_WhenExists()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
var id = await svc.CreateAsync(
|
||||||
|
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
Assert.Equal(id, dto.Id);
|
||||||
|
Assert.Equal("A", dto.FirstName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsNull_WhenNotFound()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(9999);
|
||||||
|
|
||||||
|
Assert.Null(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetPaged ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_FiltersOnSearch()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Chris", LastName_en = "Chen" });
|
||||||
|
await svc.CreateAsync(new CreateMemberRequest { FirstName_en = "Alice", LastName_en = "Wang" });
|
||||||
|
|
||||||
|
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal("Chris", result.Items[0].FirstName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_FiltersOnStatus()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
await svc.CreateAsync(new CreateMemberRequest
|
||||||
|
{ FirstName_en = "A", LastName_en = "A", Status = "Member" });
|
||||||
|
await svc.CreateAsync(new CreateMemberRequest
|
||||||
|
{ FirstName_en = "B", LastName_en = "B", Status = "Visitor" });
|
||||||
|
|
||||||
|
var result = await svc.GetPagedAsync(1, 20, null, "Visitor", null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
Assert.Equal("Visitor", result.Items[0].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPagedAsync_SearchesNickName()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
await svc.CreateAsync(new CreateMemberRequest
|
||||||
|
{ FirstName_en = "Yuan", LastName_en = "Chen", NickName = "Chris" });
|
||||||
|
|
||||||
|
var result = await svc.GetPagedAsync(1, 20, "Chris", null, null);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.TotalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_PersistsChanges()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
var id = await svc.CreateAsync(
|
||||||
|
new CreateMemberRequest { FirstName_en = "Old", LastName_en = "Name" });
|
||||||
|
|
||||||
|
await svc.UpdateAsync(id, new UpdateMemberRequest
|
||||||
|
{ FirstName_en = "New", LastName_en = "Name", Country = "USA",
|
||||||
|
Status = "Member", LanguagePreference = "en" });
|
||||||
|
|
||||||
|
var saved = await db.Members.FindAsync(id);
|
||||||
|
Assert.Equal("New", saved!.FirstName_en);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ThrowsKeyNotFound_WhenMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() =>
|
||||||
|
svc.UpdateAsync(9999, new UpdateMemberRequest
|
||||||
|
{ FirstName_en = "X", LastName_en = "Y", Country = "USA",
|
||||||
|
Status = "Member", LanguagePreference = "en" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete (soft) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_SoftDeletesMember()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor("deleter-id"));
|
||||||
|
var id = await svc.CreateAsync(
|
||||||
|
new CreateMemberRequest { FirstName_en = "A", LastName_en = "B" });
|
||||||
|
|
||||||
|
await svc.DeleteAsync(id);
|
||||||
|
|
||||||
|
// Query-filtered view returns null
|
||||||
|
var filtered = await db.Members.FindAsync(id);
|
||||||
|
Assert.Null(filtered);
|
||||||
|
|
||||||
|
// Raw view shows IsDeleted = true
|
||||||
|
var raw = await db.Members.IgnoreQueryFilters()
|
||||||
|
.FirstAsync(m => m.Id == id);
|
||||||
|
Assert.True(raw.IsDeleted);
|
||||||
|
Assert.Equal("deleter-id", raw.DeletedBy);
|
||||||
|
Assert.NotNull(raw.DeletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_ThrowsKeyNotFound_WhenMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = new MemberService(db, BuildAccessor());
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeleteAsync(9999));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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 MinistryServiceTests
|
||||||
|
{
|
||||||
|
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(mock.Object)).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_OrdersBySortOrder_AndExcludesInactive()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
db.Ministries.AddRange(
|
||||||
|
new Ministry { Name_en = "B", SortOrder = 2, IsActive = true },
|
||||||
|
new Ministry { Name_en = "A", SortOrder = 1, IsActive = true },
|
||||||
|
new Ministry { Name_en = "Z", SortOrder = 3, IsActive = false });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = new MinistryService(db);
|
||||||
|
|
||||||
|
var active = await svc.GetAllAsync(includeInactive: false);
|
||||||
|
var all = await svc.GetAllAsync(includeInactive: true);
|
||||||
|
|
||||||
|
Assert.Equal(2, active.Count);
|
||||||
|
Assert.Equal("A", active[0].Name_en);
|
||||||
|
Assert.Equal(3, all.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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.DTOs.Expense;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class MonthlyStatementServiceTests
|
||||||
|
{
|
||||||
|
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(mock.Object)).Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MonthlyStatementService Build(AppDbContext db)
|
||||||
|
{
|
||||||
|
var mock = new Mock<IHttpContextAccessor>();
|
||||||
|
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
|
||||||
|
mock.Setup(x => x.HttpContext).Returns(ctx);
|
||||||
|
return new MonthlyStatementService(db, mock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_ComputesGivingAndPaidExpenses_ForMonthOnly()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
db.GivingCategories.Add(new GivingCategory { Id = 1, Name_en = "Tithe" });
|
||||||
|
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin" });
|
||||||
|
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
|
||||||
|
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) });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var svc = Build(db);
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest
|
||||||
|
{ Year = 2026, Month = 5, OpeningBalance = 2000m, TotalOtherIncome = 100m, BankStatementBalance = 2800m });
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
Assert.Equal(1000m, dto!.TotalGiving);
|
||||||
|
Assert.Equal(300m, dto.TotalExpenses);
|
||||||
|
Assert.Equal(2800m, dto.CalculatedClosingBalance);
|
||||||
|
Assert.Equal(0m, dto.Difference);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_Duplicate_Throws()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = Build(db);
|
||||||
|
await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Update_AfterFinalize_Throws()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var svc = Build(db);
|
||||||
|
var id = await svc.CreateAsync(new CreateMonthlyStatementRequest { Year = 2026, Month = 5 });
|
||||||
|
await svc.FinalizeAsync(id);
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.UpdateAsync(id, new UpdateMonthlyStatementRequest { OpeningBalance = 1m }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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.DTOs.Giving;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Storage;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class OfferingSessionServiceTests
|
||||||
|
{
|
||||||
|
// Proof storage is not exercised by these tests; a no-op keeps the service constructible.
|
||||||
|
private sealed class NoOpFileStorage : IFileStorage
|
||||||
|
{
|
||||||
|
public Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(relativePath);
|
||||||
|
public Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<Stream?>(null);
|
||||||
|
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IHttpContextAccessor 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 mock.Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppDbContext BuildDb(string userId = "test-user")
|
||||||
|
{
|
||||||
|
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId));
|
||||||
|
return new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.AddInterceptors(interceptor)
|
||||||
|
.Options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> SeedCategoryAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
var c = new GivingCategory { Name_en = "Tithe", IsActive = true };
|
||||||
|
db.GivingCategories.Add(c);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return c.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateOfferingSessionRequest BuildRequest(int catId, DateOnly date) => new()
|
||||||
|
{
|
||||||
|
SessionDate = date, CashTotal = 150m, CheckTotal = 0m,
|
||||||
|
Givings =
|
||||||
|
[
|
||||||
|
new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Cash" },
|
||||||
|
new() { GivingCategoryId = catId, Amount = 50m, PaymentMethod = "Cash", IsAnonymous = true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_RecomputesSystemTotalAndDifference_ServerSide()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
|
var saved = await db.OfferingSessions.FindAsync(id);
|
||||||
|
Assert.Equal("Submitted", saved!.Status);
|
||||||
|
Assert.Equal(150m, saved.SystemTotal);
|
||||||
|
Assert.Equal(0m, saved.Difference);
|
||||||
|
Assert.NotNull(saved.SubmittedAt);
|
||||||
|
Assert.Equal(2, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_LinesGetSessionDateAndAnonymousNullsMember()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
|
||||||
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
|
var lines = await db.Givings.Where(g => g.OfferingSessionId == id).ToListAsync();
|
||||||
|
Assert.All(lines, l => Assert.Equal(new DateOnly(2026,5,31), l.GivingDate));
|
||||||
|
Assert.Contains(lines, l => l.IsAnonymous && l.MemberId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_OnDuplicateSessionDate()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReplaceAsync_Throws_WhenSessionIsSubmitted()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.ReplaceAsync(id, BuildRequest(catId, new DateOnly(2026, 5, 31))));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReopenThenReplace_SwapsLinesAndResubmits()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
var id = await svc.CreateAsync(BuildRequest(catId, new DateOnly(2026, 5, 31)));
|
||||||
|
|
||||||
|
await svc.ReopenAsync(id);
|
||||||
|
var reopened = await db.OfferingSessions.FindAsync(id);
|
||||||
|
Assert.Equal("Draft", reopened!.Status);
|
||||||
|
|
||||||
|
var newReq = new CreateOfferingSessionRequest
|
||||||
|
{
|
||||||
|
SessionDate = new DateOnly(2026,5,31), CashTotal = 200m, CheckTotal = 0m,
|
||||||
|
Givings = [ new() { GivingCategoryId = catId, Amount = 200m, PaymentMethod = "Cash" } ],
|
||||||
|
};
|
||||||
|
await svc.ReplaceAsync(id, newReq);
|
||||||
|
|
||||||
|
var after = await db.OfferingSessions.FindAsync(id);
|
||||||
|
Assert.Equal("Submitted", after!.Status);
|
||||||
|
Assert.Equal(200m, after.SystemTotal);
|
||||||
|
Assert.Equal(1, await db.Givings.CountAsync(g => g.OfferingSessionId == id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsCheckZelleAndPayPalRefs()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var catId = await SeedCategoryAsync(db);
|
||||||
|
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
|
||||||
|
var req = new CreateOfferingSessionRequest
|
||||||
|
{
|
||||||
|
SessionDate = new DateOnly(2026, 6, 7), CashTotal = 0m, CheckTotal = 100m,
|
||||||
|
Givings = [ new() { GivingCategoryId = catId, Amount = 100m, PaymentMethod = "Zelle",
|
||||||
|
ZelleReferenceCode = "Z-123", PayPalTransactionId = "PP-456", CheckNumber = "C-789" } ],
|
||||||
|
};
|
||||||
|
var id = await svc.CreateAsync(req);
|
||||||
|
|
||||||
|
var dto = await svc.GetByIdAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(dto);
|
||||||
|
var line = Assert.Single(dto!.Givings);
|
||||||
|
Assert.Equal("Z-123", line.ZelleReferenceCode);
|
||||||
|
Assert.Equal("PP-456", line.PayPalTransactionId);
|
||||||
|
Assert.Equal("C-789", line.CheckNumber);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Users;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class UserManagementServiceTests
|
||||||
|
{
|
||||||
|
private static AppDbContext BuildDb() =>
|
||||||
|
new(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options);
|
||||||
|
|
||||||
|
private static Mock<UserManager<AppUser>> BuildUserManager(
|
||||||
|
AppUser? findResult = null,
|
||||||
|
bool createOk = true,
|
||||||
|
IList<string>? roles = null)
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<AppUser>>();
|
||||||
|
#pragma warning disable CS8625
|
||||||
|
var mgr = new Mock<UserManager<AppUser>>(
|
||||||
|
store.Object, null, null, null, null, null, null, null, null);
|
||||||
|
#pragma warning restore CS8625
|
||||||
|
mgr.Setup(m => m.FindByIdAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(findResult);
|
||||||
|
mgr.Setup(m => m.FindByEmailAsync(It.IsAny<string>()))
|
||||||
|
.ReturnsAsync((AppUser?)null);
|
||||||
|
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(createOk ? IdentityResult.Success
|
||||||
|
: IdentityResult.Failed(new IdentityError { Description = "fail" }));
|
||||||
|
mgr.Setup(m => m.AddToRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.GetRolesAsync(It.IsAny<AppUser>()))
|
||||||
|
.ReturnsAsync(roles ?? new List<string> { "member" });
|
||||||
|
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.RemoveFromRolesAsync(It.IsAny<AppUser>(), It.IsAny<IEnumerable<string>>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
mgr.Setup(m => m.GeneratePasswordResetTokenAsync(It.IsAny<AppUser>()))
|
||||||
|
.ReturnsAsync("reset-token");
|
||||||
|
mgr.Setup(m => m.ResetPasswordAsync(It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
return mgr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CreateAsync ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ReturnsTempPassword()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
// Seed a Member so MemberId validation passes
|
||||||
|
// Note: InMemory DB requires audit fields — we set them directly
|
||||||
|
var member = new Member
|
||||||
|
{
|
||||||
|
FirstName_en = "A", LastName_en = "B",
|
||||||
|
CreatedBy = "system", UpdatedBy = "system",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var mgr = BuildUserManager();
|
||||||
|
// Capture the AppUser passed to CreateAsync
|
||||||
|
AppUser? created = null;
|
||||||
|
mgr.Setup(m => m.CreateAsync(It.IsAny<AppUser>(), It.IsAny<string>()))
|
||||||
|
.Callback<AppUser, string>((u, _) => { created = u; u.Id = Guid.NewGuid().ToString(); })
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
// Mock Users queryable to return empty (no existing user for this member)
|
||||||
|
mgr.Setup(m => m.Users)
|
||||||
|
.Returns(new List<AppUser>().AsQueryable());
|
||||||
|
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
var result = await svc.CreateAsync(new CreateUserRequest
|
||||||
|
{
|
||||||
|
MemberId = member.Id,
|
||||||
|
Email = "test@rolac.org",
|
||||||
|
Roles = ["member"],
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrEmpty(result.TempPassword));
|
||||||
|
Assert.Equal(12, result.TempPassword.Length);
|
||||||
|
Assert.NotNull(created);
|
||||||
|
Assert.Equal(member.Id, created!.MemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_WhenMemberNotFound()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var mgr = BuildUserManager();
|
||||||
|
mgr.Setup(m => m.Users)
|
||||||
|
.Returns(new List<AppUser>().AsQueryable());
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.CreateAsync(new CreateUserRequest
|
||||||
|
{ MemberId = 9999, Email = "x@y.com", Roles = ["member"] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_Throws_WhenMemberAlreadyHasUser()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var member = new Member
|
||||||
|
{
|
||||||
|
FirstName_en = "A", LastName_en = "B",
|
||||||
|
CreatedBy = "system", UpdatedBy = "system",
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Members.Add(member);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var existingUser = new AppUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
UserName = "existing@test.com",
|
||||||
|
Email = "existing@test.com",
|
||||||
|
MemberId = member.Id,
|
||||||
|
};
|
||||||
|
db.Users.Add(existingUser);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var mgr = BuildUserManager();
|
||||||
|
// The service checks _userManager.Users — we need to return the existing user
|
||||||
|
mgr.Setup(m => m.Users)
|
||||||
|
.Returns(new List<AppUser> { existingUser }.AsQueryable());
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
svc.CreateAsync(new CreateUserRequest
|
||||||
|
{ MemberId = member.Id, Email = "new@test.com", Roles = ["member"] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DeactivateAsync ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_SetsIsActiveFalse()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var user = new AppUser
|
||||||
|
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
|
||||||
|
var mgr = BuildUserManager(findResult: user);
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await svc.DeactivateAsync("u1");
|
||||||
|
|
||||||
|
Assert.False(user.IsActive);
|
||||||
|
Assert.Equal(DateTimeOffset.MaxValue, user.LockoutEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeactivateAsync_ThrowsKeyNotFound_WhenUserMissing()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var mgr = BuildUserManager(findResult: null);
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ResetPasswordAsync ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetPasswordAsync_ReturnsNewTempPassword()
|
||||||
|
{
|
||||||
|
using var db = BuildDb();
|
||||||
|
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
|
||||||
|
var mgr = BuildUserManager(findResult: user);
|
||||||
|
var svc = new UserManagementService(mgr.Object, db);
|
||||||
|
|
||||||
|
var pwd = await svc.ResetPasswordAsync("u1");
|
||||||
|
|
||||||
|
Assert.Equal(12, pwd.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,32 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/auth/me (dev-only diagnostic — remove before production)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the claims ASP.NET Core parsed from the Bearer token.
|
||||||
|
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates
|
||||||
|
/// fine; if you then get 403 on /api/users the role claim isn't matching.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("me")]
|
||||||
|
[Authorize] // no role restriction — just needs a valid JWT
|
||||||
|
public IActionResult GetMe()
|
||||||
|
{
|
||||||
|
var claims = User.Claims
|
||||||
|
.Select(c => new { c.Type, c.Value })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
isAuthenticated = User.Identity?.IsAuthenticated,
|
||||||
|
authenticationType = User.Identity?.AuthenticationType,
|
||||||
|
name = User.Identity?.Name,
|
||||||
|
claims,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// POST /api/auth/logout
|
// POST /api/auth/logout
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/church-profile")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class ChurchProfileController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IChurchProfileService _svc;
|
||||||
|
public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
|
||||||
|
|
||||||
|
[HttpPut]
|
||||||
|
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
|
||||||
|
{
|
||||||
|
await _svc.UpdateAsync(r);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Disbursement;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/disbursements")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class DisbursementsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IDisbursementService _svc;
|
||||||
|
public DisbursementsController(IDisbursementService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet("approved-unpaid")]
|
||||||
|
public async Task<IActionResult> GetApprovedUnpaid()
|
||||||
|
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync());
|
||||||
|
|
||||||
|
[HttpPost("issue")]
|
||||||
|
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
|
||||||
|
{
|
||||||
|
try { return Ok(await _svc.IssueChecksAsync(r)); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("checks")]
|
||||||
|
public async Task<IActionResult> GetRegister(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
|
||||||
|
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
|
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
|
||||||
|
|
||||||
|
[HttpGet("checks/{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("checks/{id:int}/void")]
|
||||||
|
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
|
||||||
|
{
|
||||||
|
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("checks/{id:int}/pdf")]
|
||||||
|
public async Task<IActionResult> GetPdf(int id)
|
||||||
|
{
|
||||||
|
var result = await _svc.RenderPdfAsync(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("checks/{id:int}/receipt-pdf")]
|
||||||
|
public async Task<IActionResult> GetReceiptPdf(int id)
|
||||||
|
{
|
||||||
|
var result = await _svc.RenderReceiptPdfAsync(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType, result.Value.fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("checks/{id:int}/acknowledge")]
|
||||||
|
[RequestSizeLimit(5_242_880)]
|
||||||
|
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
|
||||||
|
{
|
||||||
|
if (signature is null || signature.Length == 0) return BadRequest(new { message = "No signature." });
|
||||||
|
if (string.IsNullOrWhiteSpace(signedName)) return BadRequest(new { message = "Signed name is required." });
|
||||||
|
var allowed = new[] { "image/png", "image/jpeg", "image/webp" };
|
||||||
|
if (!allowed.Contains(signature.ContentType)) return BadRequest(new { message = "Unsupported image type." });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = signature.OpenReadStream();
|
||||||
|
await _svc.AcknowledgeReceiptAsync(id, stream, signature.FileName, signedName.Trim());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("checks/{id:int}/signature")]
|
||||||
|
public async Task<IActionResult> GetSignature(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _svc.OpenSignatureAsync(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expense-categories")]
|
||||||
|
[Authorize] // read (GetAll) is open to any authenticated user — the member self-service
|
||||||
|
// reimbursement form needs the category list. Write actions are finance-only below.
|
||||||
|
public class ExpenseCategoriesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseCategoryService _svc;
|
||||||
|
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpPost("groups")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
|
||||||
|
=> Ok(new { id = await _svc.CreateGroupAsync(r) });
|
||||||
|
|
||||||
|
[HttpPut("groups/{id:int}")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
|
||||||
|
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
|
[HttpDelete("groups/{id:int}")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> DeactivateGroup(int id)
|
||||||
|
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
|
[HttpPost("subcategories")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
|
||||||
|
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
|
[HttpPut("subcategories/{id:int}")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
|
||||||
|
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
|
||||||
|
[HttpDelete("subcategories/{id:int}")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> DeactivateSub(int id)
|
||||||
|
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/expenses")]
|
||||||
|
[Authorize]
|
||||||
|
public class ExpensesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IExpenseService _svc;
|
||||||
|
public ExpensesController(IExpenseService svc) => _svc = svc;
|
||||||
|
|
||||||
|
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin");
|
||||||
|
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor");
|
||||||
|
|
||||||
|
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
|
||||||
|
private string CurrentUserId() =>
|
||||||
|
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "";
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetPaged(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? search = null,
|
||||||
|
[FromQuery] int? ministryId = null, [FromQuery] int? categoryGroupId = null,
|
||||||
|
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
|
||||||
|
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
|
||||||
|
{
|
||||||
|
if (!CanViewAll()) return Forbid();
|
||||||
|
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine")]
|
||||||
|
public async Task<IActionResult> GetMine([FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
return Ok(await _svc.GetMineAsync(CurrentUserId(), status, page, pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
if (dto is null) return NotFound();
|
||||||
|
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid();
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
|
||||||
|
{
|
||||||
|
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
|
||||||
|
{
|
||||||
|
try { await _svc.UpdateAsync(id, r, IsFinance()); 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)
|
||||||
|
{
|
||||||
|
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/submit")]
|
||||||
|
public async Task<IActionResult> Submit(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.SubmitAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/approve")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> Approve(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.ApproveAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/reject")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
|
||||||
|
{
|
||||||
|
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/pay")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
|
||||||
|
{
|
||||||
|
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/receipt")]
|
||||||
|
[RequestSizeLimit(10_485_760)]
|
||||||
|
public async Task<IActionResult> UploadReceipt(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.SaveReceiptAsync(id, stream, file.FileName, IsFinance());
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/receipt")]
|
||||||
|
public async Task<IActionResult> GetReceipt(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _svc.OpenReceiptAsync(id, IsFinance());
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException) { return Forbid(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/finance-dashboard")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class FinanceDashboardController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IFinanceDashboardService _svc;
|
||||||
|
public FinanceDashboardController(IFinanceDashboardService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet("summary")]
|
||||||
|
public async Task<IActionResult> Summary()
|
||||||
|
=> Ok(await _svc.GetSummaryAsync());
|
||||||
|
|
||||||
|
[HttpGet("income-expense")]
|
||||||
|
public async Task<IActionResult> IncomeExpense([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
|
||||||
|
=> Ok(await _svc.GetIncomeExpenseAsync(from, to));
|
||||||
|
|
||||||
|
[HttpGet("expense-breakdown")]
|
||||||
|
public async Task<IActionResult> ExpenseBreakdown(
|
||||||
|
[FromQuery] DateOnly? from, [FromQuery] DateOnly? to,
|
||||||
|
[FromQuery] int? ministryId, [FromQuery] int? categoryGroupId)
|
||||||
|
=> Ok(await _svc.GetExpenseBreakdownAsync(from, to, ministryId, categoryGroupId));
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Giving;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/giving-categories")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class GivingCategoriesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGivingCategoryService _svc;
|
||||||
|
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
|
||||||
|
{
|
||||||
|
var id = await _svc.CreateAsync(request);
|
||||||
|
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
|
||||||
|
{
|
||||||
|
try { await _svc.UpdateAsync(id, request); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> Deactivate(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.DeactivateAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Giving;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/givings")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class GivingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IGivingService _svc;
|
||||||
|
public GivingsController(IGivingService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetPaged(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null, [FromQuery] int? categoryId = null,
|
||||||
|
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
|
||||||
|
{
|
||||||
|
var id = await _svc.CreateAsync(request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
|
||||||
|
{
|
||||||
|
try { await _svc.UpdateAsync(id, request); 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)
|
||||||
|
{
|
||||||
|
try { await _svc.DeleteAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/meal-attendance")]
|
||||||
|
public class MealAttendanceController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMealAttendanceService _svc;
|
||||||
|
|
||||||
|
public MealAttendanceController(IMealAttendanceService svc) => _svc = svc;
|
||||||
|
|
||||||
|
/// <summary>Today's live counts. Public — feeds the volunteer counter page on first load.</summary>
|
||||||
|
[HttpGet("today")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetToday()
|
||||||
|
=> Ok(await _svc.GetOrCreateAsync(_svc.Today));
|
||||||
|
|
||||||
|
/// <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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Members;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/members")]
|
||||||
|
[Authorize]
|
||||||
|
public class MembersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMemberService _members;
|
||||||
|
public MembersController(IMemberService members) => _members = members;
|
||||||
|
|
||||||
|
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||||||
|
public async Task<IActionResult> GetPaged(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null,
|
||||||
|
[FromQuery] string? status = null,
|
||||||
|
[FromQuery] bool? hasUser = null)
|
||||||
|
=> Ok(await _members.GetPagedAsync(page, pageSize, search, status, hasUser));
|
||||||
|
|
||||||
|
/// <summary>GET /api/members/{id}</summary>
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
[Authorize(Roles = "super_admin,secretary,pastor")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var dto = await _members.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/members</summary>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "super_admin,secretary")]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
|
||||||
|
{
|
||||||
|
var id = await _members.CreateAsync(request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT /api/members/{id}</summary>
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
[Authorize(Roles = "super_admin,secretary")]
|
||||||
|
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
|
||||||
|
{
|
||||||
|
try { await _members.UpdateAsync(id, request); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DELETE /api/members/{id} — soft delete</summary>
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
[Authorize(Roles = "super_admin,secretary")]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
try { await _members.DeleteAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/ministries")]
|
||||||
|
[Authorize]
|
||||||
|
public class MinistriesController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMinistryService _svc;
|
||||||
|
public MinistriesController(IMinistryService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
|
||||||
|
=> Ok(await _svc.GetAllAsync(includeInactive));
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Expense;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/monthly-statements")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class MonthlyStatementsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMonthlyStatementService _svc;
|
||||||
|
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll([FromQuery] int? year = null)
|
||||||
|
=> Ok(await _svc.GetAllAsync(year));
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
|
||||||
|
{
|
||||||
|
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] UpdateMonthlyStatementRequest r)
|
||||||
|
{
|
||||||
|
try { await _svc.UpdateAsync(id, r); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/finalize")]
|
||||||
|
public async Task<IActionResult> Finalize(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.FinalizeAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using ROLAC.API.DTOs.Giving;
|
||||||
|
using ROLAC.API.DTOs.Members;
|
||||||
|
using ROLAC.API.Hubs;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anonymous endpoints powering the mobile Sunday offering-entry page. The page
|
||||||
|
/// has no login yet, so it cannot reach the auth-gated members/categories/
|
||||||
|
/// offering-sessions APIs — these expose just what it needs (active categories,
|
||||||
|
/// a name-only member typeahead, and append-one-line).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/offering-entry")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public class OfferingEntryController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOfferingSessionService _sessions;
|
||||||
|
private readonly IGivingCategoryService _categories;
|
||||||
|
private readonly IMemberService _members;
|
||||||
|
private readonly IHubContext<OfferingEntryHub> _hub;
|
||||||
|
|
||||||
|
public OfferingEntryController(
|
||||||
|
IOfferingSessionService sessions,
|
||||||
|
IGivingCategoryService categories,
|
||||||
|
IMemberService members,
|
||||||
|
IHubContext<OfferingEntryHub> hub)
|
||||||
|
{
|
||||||
|
_sessions = sessions;
|
||||||
|
_categories = categories;
|
||||||
|
_members = members;
|
||||||
|
_hub = hub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the page in one round-trip: active categories + today's session state.
|
||||||
|
[HttpGet("bootstrap")]
|
||||||
|
public async Task<IActionResult> Bootstrap([FromQuery] DateOnly date)
|
||||||
|
=> Ok(new OfferingEntryBootstrapDto
|
||||||
|
{
|
||||||
|
SessionDate = date.ToString("yyyy-MM-dd"),
|
||||||
|
Categories = await _categories.GetAllAsync(false),
|
||||||
|
Summary = await _sessions.GetEntrySummaryAsync(date),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Name-only member suggestions for the giver typeahead.
|
||||||
|
[HttpGet("members")]
|
||||||
|
public async Task<IActionResult> SearchMembers([FromQuery] string? search, [FromQuery] int take = 10)
|
||||||
|
=> Ok(await _sessions.SearchMembersForEntryAsync(search, Math.Clamp(take, 1, 25)));
|
||||||
|
|
||||||
|
// Quick-add a giver who isn't on file yet (created as a Visitor). Reuses the
|
||||||
|
// member service directly — role checks live on MembersController, so this
|
||||||
|
// anonymous path is the intended public entry point for the mobile page.
|
||||||
|
[HttpPost("members")]
|
||||||
|
public async Task<IActionResult> QuickAddMember([FromBody] QuickAddMemberRequest request)
|
||||||
|
{
|
||||||
|
var id = await _members.CreateAsync(new CreateMemberRequest
|
||||||
|
{
|
||||||
|
FirstName_en = request.FirstName_en,
|
||||||
|
LastName_en = request.LastName_en,
|
||||||
|
NickName = request.NickName,
|
||||||
|
FirstName_zh = request.FirstName_zh,
|
||||||
|
LastName_zh = request.LastName_zh,
|
||||||
|
PhoneCell = request.PhoneCell,
|
||||||
|
Status = "Visitor",
|
||||||
|
Country = "USA",
|
||||||
|
LanguagePreference = "en",
|
||||||
|
});
|
||||||
|
return Ok(new MemberTypeaheadDto
|
||||||
|
{
|
||||||
|
Id = id, NickName = request.NickName,
|
||||||
|
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append one offering line to the date's session (find-or-create), then
|
||||||
|
// broadcast it to everyone viewing that date.
|
||||||
|
[HttpPost("lines")]
|
||||||
|
public async Task<IActionResult> AppendLine([FromBody] AppendOfferingLineRequest request)
|
||||||
|
{
|
||||||
|
var result = await _sessions.AppendLineAsync(request.Date, request.Line);
|
||||||
|
await _hub.Clients.Group(result.SessionDate).SendAsync("LineAdded", result);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Paper-proof PDF for the date's session (merged client-side) ──────────
|
||||||
|
// Date-keyed so the anonymous page (which has no session id) can attach the
|
||||||
|
// count sheet / envelope photos. Mirrors OfferingSessionsController's proof
|
||||||
|
// validation; the desktop session page reviews/deletes the result.
|
||||||
|
|
||||||
|
[HttpGet("proof")]
|
||||||
|
public async Task<IActionResult> GetProof([FromQuery] DateOnly date)
|
||||||
|
{
|
||||||
|
var result = await _sessions.OpenProofForDateAsync(date);
|
||||||
|
if (result is null) return NoContent(); // no session/proof yet — client merges nothing
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("proof")]
|
||||||
|
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||||
|
public async Task<IActionResult> UploadProof([FromForm] DateOnly date, IFormFile file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||||
|
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
await _sessions.SaveProofForDateAsync(date, stream, file.FileName);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Giving;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/offering-sessions")]
|
||||||
|
[Authorize(Roles = "finance,super_admin")]
|
||||||
|
public class OfferingSessionsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IOfferingSessionService _svc;
|
||||||
|
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetPaged(
|
||||||
|
[FromQuery] int page = 1, [FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
|
||||||
|
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
|
||||||
|
|
||||||
|
[HttpGet("check-date")]
|
||||||
|
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
|
||||||
|
=> Ok(new { exists = await _svc.DateExistsAsync(date) });
|
||||||
|
|
||||||
|
[HttpGet("{id:int}")]
|
||||||
|
public async Task<IActionResult> GetById(int id)
|
||||||
|
{
|
||||||
|
var dto = await _svc.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = await _svc.CreateAsync(request);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id }, new { id });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/reopen")]
|
||||||
|
public async Task<IActionResult> Reopen(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.ReopenAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("{id:int}")]
|
||||||
|
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
|
||||||
|
{
|
||||||
|
try { await _svc.ReplaceAsync(id, request); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Paper-proof PDF (merged client-side, one file per session) ───────────
|
||||||
|
|
||||||
|
[HttpPost("{id:int}/proof")]
|
||||||
|
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
|
||||||
|
public async Task<IActionResult> UploadProof(int id, IFormFile file)
|
||||||
|
{
|
||||||
|
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
|
||||||
|
if (file.ContentType != "application/pdf") return BadRequest(new { message = "Proof must be a PDF." });
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var stream = file.OpenReadStream();
|
||||||
|
await _svc.SaveProofAsync(id, stream, file.FileName);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:int}/proof")]
|
||||||
|
public async Task<IActionResult> GetProof(int id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _svc.OpenProofAsync(id);
|
||||||
|
if (result is null) return NotFound();
|
||||||
|
return File(result.Value.stream, result.Value.contentType);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}/proof")]
|
||||||
|
public async Task<IActionResult> DeleteProof(int id)
|
||||||
|
{
|
||||||
|
try { await _svc.DeleteProofAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.DTOs.Users;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/users")]
|
||||||
|
[Authorize(Roles = "super_admin")]
|
||||||
|
public class UsersController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IUserManagementService _users;
|
||||||
|
public UsersController(IUserManagementService users) => _users = users;
|
||||||
|
|
||||||
|
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetPaged(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? search = null)
|
||||||
|
=> Ok(await _users.GetPagedAsync(page, pageSize, search));
|
||||||
|
|
||||||
|
/// <summary>GET /api/users/{id}</summary>
|
||||||
|
[HttpGet("{id}")]
|
||||||
|
public async Task<IActionResult> GetById(string id)
|
||||||
|
{
|
||||||
|
var dto = await _users.GetByIdAsync(id);
|
||||||
|
return dto is null ? NotFound() : Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /api/users — creates account for a Member, returns { userId, tempPassword }.
|
||||||
|
/// TempPassword is returned ONCE — show it to the admin and never log it.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _users.CreateAsync(request);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
|
||||||
|
[HttpPut("{id}")]
|
||||||
|
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
|
||||||
|
{
|
||||||
|
try { await _users.UpdateAsync(id, request); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Deactivate(string id)
|
||||||
|
{
|
||||||
|
try { await _users.DeactivateAsync(id); return NoContent(); }
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
|
||||||
|
[HttpPost("{id}/reset-password")]
|
||||||
|
public async Task<IActionResult> ResetPassword(string id)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pwd = await _users.ResetPasswordAsync(id);
|
||||||
|
return Ok(new { tempPassword = pwd });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Disbursement;
|
||||||
|
|
||||||
|
public class ChurchProfileDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = "";
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? ZipCode { get; set; }
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
public string? BankAccountNumber { get; set; }
|
||||||
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
public int NextCheckNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateChurchProfileRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||||
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
|
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||||
|
[MaxLength(200)] public string? BankName { get; set; }
|
||||||
|
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||||
|
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||||
|
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Disbursement;
|
||||||
|
|
||||||
|
// ── Approved-unpaid expenses, grouped by payee (the issue-check worklist) ──────
|
||||||
|
|
||||||
|
public class ExpenseLineDto
|
||||||
|
{
|
||||||
|
public int ExpenseId { get; set; }
|
||||||
|
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string MinistryName { get; set; } = "";
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PayeeGroupDto
|
||||||
|
{
|
||||||
|
public string PayeeType { get; set; } = "Vendor"; // Vendor | Member
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? VendorKey { get; set; } // normalized vendor name (grouping key)
|
||||||
|
public string PayeeName { get; set; } = "";
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public List<ExpenseLineDto> Lines { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Issue checks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class PayeeCheckInstruction
|
||||||
|
{
|
||||||
|
[Required] public string PayeeType { get; set; } = "Vendor";
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? VendorKey { get; set; }
|
||||||
|
[Required, MaxLength(200)] public string PayeeName { get; set; } = "";
|
||||||
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
|
[MaxLength(20)] public string? Zip { get; set; }
|
||||||
|
[MaxLength(50)] public string? CheckNumberOverride { get; set; }
|
||||||
|
[MaxLength(500)] public string? Memo { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<int> ExpenseIds { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IssueChecksRequest
|
||||||
|
{
|
||||||
|
[Required] public DateOnly CheckDate { get; set; }
|
||||||
|
[Required, MinLength(1)] public List<PayeeCheckInstruction> Payees { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IssuedCheckDto
|
||||||
|
{
|
||||||
|
public int CheckId { get; set; }
|
||||||
|
public string CheckNumber { get; set; } = "";
|
||||||
|
public string PayeeName { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class IssueChecksResultDto
|
||||||
|
{
|
||||||
|
public List<IssuedCheckDto> Created { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Check register / detail ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class CheckListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string CheckNumber { get; set; } = "";
|
||||||
|
public string CheckDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string PayeeType { get; set; } = "";
|
||||||
|
public string PayeeName { get; set; } = "";
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public bool Signed { get; set; }
|
||||||
|
public string? ReceiptSignedName { get; set; }
|
||||||
|
public DateTimeOffset? ReceiptSignedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CheckLineDto
|
||||||
|
{
|
||||||
|
public int ExpenseId { get; set; }
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CheckDetailDto : CheckListItemDto
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
public string? VoidReason { get; set; }
|
||||||
|
public DateTimeOffset? VoidedAt { get; set; }
|
||||||
|
public DateTimeOffset IssuedAt { get; set; }
|
||||||
|
public List<CheckLineDto> Lines { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VoidCheckRequest
|
||||||
|
{
|
||||||
|
[MaxLength(500)] public string? Reason { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseSubCategoryDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpenseCategoryGroupDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
|
||||||
|
{
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateExpenseSubCategoryRequest
|
||||||
|
{
|
||||||
|
[Required] public int GroupId { get; set; }
|
||||||
|
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
|
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
|
||||||
|
{
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class ExpenseListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Type { get; set; } = "";
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
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 string? VendorName { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public bool HasReceipt { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public 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 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, 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 class UpdateExpenseRequest : CreateExpenseRequest { }
|
||||||
|
|
||||||
|
public class RejectExpenseRequest
|
||||||
|
{
|
||||||
|
[MaxLength(500)] public string? ReviewNotes { get; set; }
|
||||||
|
}
|
||||||
|
public class PayExpenseRequest
|
||||||
|
{
|
||||||
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
|
public DateOnly? PaidAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Expense;
|
||||||
|
|
||||||
|
public class MonthlyStatementDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int Year { get; set; }
|
||||||
|
public int Month { get; set; }
|
||||||
|
public decimal OpeningBalance { get; set; }
|
||||||
|
public decimal TotalGiving { get; set; }
|
||||||
|
public decimal TotalOtherIncome { get; set; }
|
||||||
|
public decimal TotalExpenses { get; set; }
|
||||||
|
public decimal CalculatedClosingBalance { get; set; }
|
||||||
|
public decimal BankStatementBalance { get; set; }
|
||||||
|
public decimal Difference { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsFinalized { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateMonthlyStatementRequest
|
||||||
|
{
|
||||||
|
[Range(2000, 2100)] public int Year { get; set; }
|
||||||
|
[Range(1, 12)] public int Month { get; set; }
|
||||||
|
public decimal OpeningBalance { get; set; }
|
||||||
|
public decimal TotalOtherIncome { get; set; }
|
||||||
|
public decimal BankStatementBalance { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
public class UpdateMonthlyStatementRequest
|
||||||
|
{
|
||||||
|
public decimal OpeningBalance { get; set; }
|
||||||
|
public decimal TotalOtherIncome { get; set; }
|
||||||
|
public decimal BankStatementBalance { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Finance;
|
||||||
|
|
||||||
|
/// <summary>All-time finance position for the dashboard balance card.</summary>
|
||||||
|
public class FinanceSummaryDto
|
||||||
|
{
|
||||||
|
public decimal TotalIncome { get; set; } // all-time sum of Giving.Amount
|
||||||
|
public decimal TotalExpenses { get; set; } // all-time Paid+Approved expenses
|
||||||
|
public decimal Balance { get; set; } // TotalIncome - TotalExpenses
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Income vs expense totals for a date range (the income/expense pie).</summary>
|
||||||
|
public class IncomeExpenseDto
|
||||||
|
{
|
||||||
|
public decimal Income { get; set; } // Givings in [from,to]
|
||||||
|
public decimal Expense { get; set; } // Paid+Approved expenses in [from,to]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One slice of the expense drill-down pie. Id is a ministry / group / sub-category id by level.</summary>
|
||||||
|
public class BreakdownSliceDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
// Body of POST /api/offering-entry/lines — one offering line plus the date of the
|
||||||
|
// session it belongs to (find-or-create that day's session, append the line).
|
||||||
|
public class AppendOfferingLineRequest
|
||||||
|
{
|
||||||
|
[Required] public DateOnly Date { get; set; }
|
||||||
|
[Required] public OfferingGivingLineRequest Line { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class CreateGivingCategoryRequest
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class CreateGivingRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
[Required] public int GivingCategoryId { get; set; }
|
||||||
|
[Range(0.01, 9999999)] public decimal Amount { get; set; }
|
||||||
|
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
|
||||||
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
|
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
|
||||||
|
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
|
||||||
|
public DateOnly GivingDate { get; set; }
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
[MaxLength(500)] public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class CreateOfferingSessionRequest
|
||||||
|
{
|
||||||
|
[Required] public DateOnly SessionDate { get; set; }
|
||||||
|
public decimal CashTotal { get; set; }
|
||||||
|
public decimal CheckTotal { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public List<OfferingGivingLineRequest> Givings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class GivingCategoryDto
|
||||||
|
{
|
||||||
|
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 bool IsActive { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class GivingDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public int GivingCategoryId { get; set; }
|
||||||
|
public int? OfferingSessionId { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string PaymentMethod { get; set; } = "";
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? ZelleReferenceCode { get; set; }
|
||||||
|
public string? PayPalTransactionId { get; set; }
|
||||||
|
public DateOnly GivingDate { get; set; }
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class GivingListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public int GivingCategoryId { get; set; }
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string PaymentMethod { get; set; } = "";
|
||||||
|
public string GivingDate { get; set; } = ""; // ISO yyyy-MM-dd
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
public int? OfferingSessionId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
// Minimal member fields exposed to the anonymous mobile offering-entry page —
|
||||||
|
// just enough for the giver typeahead to render a display name (matches the
|
||||||
|
// Angular memberDisplayName helper: NickName ?? FirstName_en, plus LastName_en).
|
||||||
|
public class MemberTypeaheadDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string FirstName_en { get; set; } = "";
|
||||||
|
public string LastName_en { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
// One-shot payload that seeds the mobile offering-entry page: the active giving
|
||||||
|
// categories for the Type dropdown and the current state of today's session.
|
||||||
|
public class OfferingEntryBootstrapDto
|
||||||
|
{
|
||||||
|
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public List<GivingCategoryDto> Categories { get; set; } = [];
|
||||||
|
public OfferingEntrySummaryDto Summary { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
// Returned from POST /api/offering-entry/lines and broadcast over the
|
||||||
|
// OfferingEntryHub: the line just added plus the session's new running totals,
|
||||||
|
// so every connected client (other phones + the desktop page) can update live.
|
||||||
|
public class OfferingEntryLineAddedDto
|
||||||
|
{
|
||||||
|
public int SessionId { get; set; }
|
||||||
|
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public decimal SystemTotal { get; set; }
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public OfferingGivingLineDto Line { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
// A day's offering session as the mobile page sees it: the running total/line
|
||||||
|
// count plus the lines already recorded. SessionId is null when no session
|
||||||
|
// exists for the date yet (nothing entered today).
|
||||||
|
public class OfferingEntrySummaryDto
|
||||||
|
{
|
||||||
|
public int? SessionId { get; set; }
|
||||||
|
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public string? Status { get; set; } // null when no session yet
|
||||||
|
public decimal SystemTotal { get; set; }
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public bool HasProof { get; set; } // a merged paper-proof PDF is attached to this session
|
||||||
|
public List<OfferingGivingLineDto> Lines { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class OfferingGivingLineDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberName { get; set; }
|
||||||
|
public int GivingCategoryId { get; set; }
|
||||||
|
public string CategoryName { get; set; } = "";
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string PaymentMethod { get; set; } = "";
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? ZelleReferenceCode { get; set; }
|
||||||
|
public string? PayPalTransactionId { get; set; }
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class OfferingGivingLineRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
[Required] public int GivingCategoryId { get; set; }
|
||||||
|
[Range(0.01, 9999999)] public decimal Amount { get; set; }
|
||||||
|
[Required, MaxLength(20)] public string PaymentMethod { get; set; } = "Cash";
|
||||||
|
[MaxLength(50)] public string? CheckNumber { get; set; }
|
||||||
|
[MaxLength(100)] public string? ZelleReferenceCode { get; set; }
|
||||||
|
[MaxLength(100)] public string? PayPalTransactionId { get; set; }
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
[MaxLength(500)] public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class OfferingSessionDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateOnly SessionDate{ get; set; }
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public decimal CashTotal { get; set; }
|
||||||
|
public decimal CheckTotal { get; set; }
|
||||||
|
public decimal SystemTotal { get; set; }
|
||||||
|
public decimal Difference { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool HasProof { get; set; }
|
||||||
|
public List<OfferingGivingLineDto> Givings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class OfferingSessionListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string SessionDate { get; set; } = ""; // yyyy-MM-dd
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public decimal CashTotal { get; set; }
|
||||||
|
public decimal CheckTotal { get; set; }
|
||||||
|
public decimal SystemTotal { get; set; }
|
||||||
|
public decimal Difference { get; set; }
|
||||||
|
public int LineCount { get; set; }
|
||||||
|
public bool HasProof { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
// Minimal member fields the mobile offering-entry page collects when a giver
|
||||||
|
// isn't on file yet. Creates a Visitor; the rest of the profile can be filled
|
||||||
|
// in later from the admin Members page.
|
||||||
|
public class QuickAddMemberRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||||
|
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||||
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class UpdateGivingCategoryRequest
|
||||||
|
{
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Giving;
|
||||||
|
|
||||||
|
public class UpdateGivingRequest : CreateGivingRequest { }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ROLAC.API.DTOs.MealAttendance;
|
||||||
|
|
||||||
|
/// <summary>The current head-count for one Sunday, broadcast over SignalR.</summary>
|
||||||
|
public class AttendanceCountsDto
|
||||||
|
{
|
||||||
|
public string Date { get; set; } = ""; // yyyy-MM-dd (local)
|
||||||
|
public int Adult { get; set; }
|
||||||
|
public int Youth { get; set; }
|
||||||
|
public int Kid { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Members;
|
||||||
|
|
||||||
|
public class CreateMemberRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(100)] public string FirstName_en { get; set; } = "";
|
||||||
|
[Required, MaxLength(100)] public string LastName_en { get; set; } = "";
|
||||||
|
[MaxLength(100)] public string? NickName { get; set; }
|
||||||
|
[MaxLength(100)] public string? FirstName_zh { get; set; }
|
||||||
|
[MaxLength(100)] public string? LastName_zh { get; set; }
|
||||||
|
[MaxLength(10)] public string? Gender { get; set; }
|
||||||
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
[MaxLength(200)] public string? BaptismChurch { get; set; }
|
||||||
|
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
|
[MaxLength(30)] public string? PhoneCell { get; set; }
|
||||||
|
[MaxLength(30)] public string? PhoneHome { get; set; }
|
||||||
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
|
[MaxLength(20)] public string? ZipCode { get; set; }
|
||||||
|
[MaxLength(100)] public string Country { get; set; } = "USA";
|
||||||
|
[MaxLength(20)] public string Status { get; set; } = "Member";
|
||||||
|
[MaxLength(10)] public string LanguagePreference { get; set; } = "en";
|
||||||
|
public DateOnly? JoinDate { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int? FamilyUnitId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Members;
|
||||||
|
|
||||||
|
public class MemberDto : MemberListItemDto
|
||||||
|
{
|
||||||
|
public string? Gender { get; set; }
|
||||||
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
public string? BaptismChurch { get; set; }
|
||||||
|
public string? PhoneHome { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? ZipCode { get; set; }
|
||||||
|
public string Country { get; set; } = "USA";
|
||||||
|
public string? PhotoBlobPath { get; set; }
|
||||||
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int? FamilyUnitId { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Members;
|
||||||
|
|
||||||
|
public class MemberListItemDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string FirstName_en { get; set; } = "";
|
||||||
|
public string LastName_en { get; set; } = "";
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string? FirstName_zh { get; set; }
|
||||||
|
public string? LastName_zh { get; set; }
|
||||||
|
public string Status { get; set; } = "";
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? PhoneCell { get; set; }
|
||||||
|
public DateOnly? JoinDate { get; set; }
|
||||||
|
public string? LinkedUserId { get; set; } // null = no user account
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Members;
|
||||||
|
public class UpdateMemberRequest : CreateMemberRequest { }
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Ministry;
|
||||||
|
|
||||||
|
public class MinistryDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name_en { get; set; } = "";
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Shared;
|
||||||
|
|
||||||
|
public class PagedResult<T>
|
||||||
|
{
|
||||||
|
public List<T> Items { get; set; } = [];
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public int Page { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
public int TotalPages => (int)Math.Ceiling((double)TotalCount / PageSize);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Users;
|
||||||
|
|
||||||
|
public class CreateUserRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
[Required, EmailAddress] public string Email { get; set; } = "";
|
||||||
|
[Required, MinLength(1)] public List<string> Roles { get; set; } = [];
|
||||||
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Users;
|
||||||
|
|
||||||
|
public class CreateUserResult
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = "";
|
||||||
|
public string TempPassword { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Users;
|
||||||
|
|
||||||
|
public class UpdateUserRequest
|
||||||
|
{
|
||||||
|
[Required, EmailAddress] public string Email { get; set; } = "";
|
||||||
|
[Required] public List<string> Roles { get; set; } = [];
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Users;
|
||||||
|
public class UserDto : UserListItemDto { }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ROLAC.API.DTOs.Users;
|
||||||
|
|
||||||
|
public class UserListItemDto
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = "";
|
||||||
|
public string Email { get; set; } = "";
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? MemberDisplayName { get; set; }
|
||||||
|
public List<string> Roles { get; set; } = [];
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
public DateTime? LastLoginAt { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -9,43 +9,307 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
public DbSet<Member> Members => Set<Member>();
|
||||||
|
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
|
||||||
|
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
|
||||||
|
public DbSet<OfferingSession> OfferingSessions => Set<OfferingSession>();
|
||||||
|
public DbSet<Giving> Givings => Set<Giving>();
|
||||||
|
public DbSet<Ministry> Ministries => Set<Ministry>();
|
||||||
|
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
|
||||||
|
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
|
||||||
|
public DbSet<Expense> Expenses => Set<Expense>();
|
||||||
|
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
|
||||||
|
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
|
||||||
|
public DbSet<Check> Checks => Set<Check>();
|
||||||
|
public DbSet<CheckLine> CheckLines => Set<CheckLine>();
|
||||||
|
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
|
// ── RefreshToken (unchanged) ────────────────────────────────────────
|
||||||
builder.Entity<RefreshToken>(entity =>
|
builder.Entity<RefreshToken>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(e => e.Id);
|
entity.HasKey(e => e.Id);
|
||||||
|
|
||||||
// Unique index on hash — enables fast lookup and prevents duplicate tokens
|
|
||||||
entity.HasIndex(e => e.TokenHash).IsUnique();
|
entity.HasIndex(e => e.TokenHash).IsUnique();
|
||||||
|
|
||||||
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
|
||||||
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
|
||||||
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
|
entity.Property(e => e.DeviceInfo).HasMaxLength(200);
|
||||||
entity.Property(e => e.IpAddress).HasMaxLength(45);
|
entity.Property(e => e.IpAddress).HasMaxLength(45);
|
||||||
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
|
entity.Property(e => e.ReplacedByHash).HasMaxLength(64);
|
||||||
|
entity.HasOne(e => e.User).WithMany(u => u.RefreshTokens)
|
||||||
entity.HasOne(e => e.User)
|
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||||
.WithMany(u => u.RefreshTokens)
|
|
||||||
.HasForeignKey(e => e.UserId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
|
|
||||||
// Computed properties are not DB columns
|
|
||||||
entity.Ignore(e => e.IsExpired);
|
entity.Ignore(e => e.IsExpired);
|
||||||
entity.Ignore(e => e.IsRevoked);
|
entity.Ignore(e => e.IsRevoked);
|
||||||
entity.Ignore(e => e.IsActive);
|
entity.Ignore(e => e.IsActive);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── AppUser (unchanged + new unique index on MemberId) ──────────────
|
||||||
builder.Entity<AppUser>(entity =>
|
builder.Entity<AppUser>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
|
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
|
||||||
|
// Nullable unique: one member ↔ one user account, but member can have no account
|
||||||
|
entity.HasIndex(e => e.MemberId).IsUnique()
|
||||||
|
.HasFilter("\"MemberId\" IS NOT NULL");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── AppRole (unchanged) ─────────────────────────────────────────────
|
||||||
builder.Entity<AppRole>(entity =>
|
builder.Entity<AppRole>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(e => e.Description).HasMaxLength(500);
|
entity.Property(e => e.Description).HasMaxLength(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── FamilyUnit ──────────────────────────────────────────────────────
|
||||||
|
builder.Entity<FamilyUnit>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.FamilyName_en).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FamilyName_zh).HasMaxLength(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Member ──────────────────────────────────────────────────────────
|
||||||
|
builder.Entity<Member>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(m => !m.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.FirstName_en).HasMaxLength(100).IsRequired();
|
||||||
|
entity.Property(e => e.LastName_en).HasMaxLength(100).IsRequired();
|
||||||
|
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.Gender).HasMaxLength(10);
|
||||||
|
entity.Property(e => e.BaptismChurch).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.PhoneCell).HasMaxLength(30);
|
||||||
|
entity.Property(e => e.PhoneHome).HasMaxLength(30);
|
||||||
|
entity.Property(e => e.Address).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.City).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.State).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.ZipCode).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Country).HasMaxLength(100).HasDefaultValue("USA");
|
||||||
|
entity.Property(e => e.PhotoBlobPath).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Member");
|
||||||
|
entity.Property(e => e.LanguagePreference).HasMaxLength(10).HasDefaultValue("en");
|
||||||
|
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.Status).HasFilter("\"IsDeleted\" = false");
|
||||||
|
entity.HasIndex(e => e.Email).HasFilter("\"Email\" IS NOT NULL");
|
||||||
|
entity.HasIndex(e => e.FamilyUnitId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.FamilyUnit).WithMany()
|
||||||
|
.HasForeignKey(e => e.FamilyUnitId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── GivingCategory ───────────────────────────────────────────────────
|
||||||
|
builder.Entity<GivingCategory>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Description_en).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.Description_zh).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── OfferingSession ──────────────────────────────────────────────────
|
||||||
|
builder.Entity<OfferingSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Draft");
|
||||||
|
entity.Property(e => e.CashTotal).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.CheckTotal).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.SystemTotal).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.ReconciledBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.ProofPdfPath).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => e.SessionDate).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Giving ───────────────────────────────────────────────────────────
|
||||||
|
builder.Entity<Giving>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.PaymentMethod).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.CheckNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.ZelleReferenceCode).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.PayPalTransactionId).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.Notes).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => new { e.MemberId, e.GivingDate });
|
||||||
|
entity.HasIndex(e => e.OfferingSessionId).HasFilter("\"OfferingSessionId\" IS NOT NULL");
|
||||||
|
entity.HasIndex(e => e.GivingDate);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.GivingCategory).WithMany()
|
||||||
|
.HasForeignKey(e => e.GivingCategoryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
entity.HasOne(e => e.OfferingSession).WithMany(s => s.Givings)
|
||||||
|
.HasForeignKey(e => e.OfferingSessionId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Ministry ─────────────────────────────────────────────────────────
|
||||||
|
builder.Entity<Ministry>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Name_zh).HasMaxLength(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseCategoryGroup ─────────────────────────────────────────────
|
||||||
|
builder.Entity<ExpenseCategoryGroup>(entity =>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── ExpenseSubCategory ───────────────────────────────────────────────
|
||||||
|
builder.Entity<ExpenseSubCategory>(entity =>
|
||||||
|
{
|
||||||
|
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.HasOne(e => e.Group).WithMany(g => g.SubCategories)
|
||||||
|
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Expense ──────────────────────────────────────────────────────────
|
||||||
|
builder.Entity<Expense>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(e => !e.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.Type).HasMaxLength(30).IsRequired();
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(30).HasDefaultValue("Draft");
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
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.ReceiptBlobPath).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.ReviewNotes).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.SubmittedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.ReviewedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.PaidBy).HasMaxLength(450);
|
||||||
|
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.MinistryId);
|
||||||
|
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
|
||||||
|
entity.HasIndex(e => e.ExpenseDate);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Ministry).WithMany()
|
||||||
|
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
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) ───────────────────────────────
|
||||||
|
builder.Entity<ChurchProfile>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.Address).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.City).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.State).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.ZipCode).HasMaxLength(20);
|
||||||
|
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.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
// Optimistic-concurrency token for safe check-number allocation.
|
||||||
|
entity.Property(e => e.xmin).IsRowVersion();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||||
|
builder.Entity<Check>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasQueryFilter(c => !c.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.CheckNumber).HasMaxLength(50).IsRequired();
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.PayeeType).HasMaxLength(20).IsRequired();
|
||||||
|
entity.Property(e => e.PayeeName).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.PayeeAddress).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.PayeeCity).HasMaxLength(100);
|
||||||
|
entity.Property(e => e.PayeeState).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.PayeeZip).HasMaxLength(20);
|
||||||
|
entity.Property(e => e.Status).HasMaxLength(20).HasDefaultValue("Issued");
|
||||||
|
entity.Property(e => e.Memo).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.IssuedBy).HasMaxLength(450).IsRequired();
|
||||||
|
entity.Property(e => e.VoidReason).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.VoidedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.ReceiptSignatureBlobPath).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.ReceiptSignedName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.ReceiptCapturedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.DeletedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
// Unique check number among non-deleted rows.
|
||||||
|
entity.HasIndex(e => e.CheckNumber).IsUnique().HasFilter("\"IsDeleted\" = false");
|
||||||
|
entity.HasIndex(e => e.Status).HasFilter("\"IsDeleted\" = false");
|
||||||
|
entity.HasIndex(e => e.CheckDate);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Member).WithMany()
|
||||||
|
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── CheckLine ────────────────────────────────────────────────────────
|
||||||
|
builder.Entity<CheckLine>(entity =>
|
||||||
|
{
|
||||||
|
// Mirror the parent Check's soft-delete filter (required relationship).
|
||||||
|
entity.HasQueryFilter(l => !l.Check!.IsDeleted);
|
||||||
|
|
||||||
|
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
|
||||||
|
entity.HasIndex(e => e.CheckId);
|
||||||
|
entity.HasIndex(e => e.ExpenseId);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Check).WithMany(c => c.Lines)
|
||||||
|
.HasForeignKey(e => e.CheckId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(e => e.Expense).WithMany()
|
||||||
|
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MealAttendance (one shared row per Sunday) ───────────────────────
|
||||||
|
builder.Entity<MealAttendance>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.AdultCount).HasDefaultValue(0);
|
||||||
|
entity.Property(e => e.YouthCount).HasDefaultValue(0);
|
||||||
|
entity.Property(e => e.KidCount).HasDefaultValue(0);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => e.AttendanceDate).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── MonthlyStatement ─────────────────────────────────────────────────
|
||||||
|
builder.Entity<MonthlyStatement>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.OpeningBalance).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.TotalGiving).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.TotalOtherIncome).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.TotalExpenses).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.CalculatedClosingBalance).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.BankStatementBalance).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.Difference).HasColumnType("decimal(18,2)");
|
||||||
|
entity.Property(e => e.FinalizedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,50 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
namespace ROLAC.API.Data;
|
namespace ROLAC.API.Data;
|
||||||
|
|
||||||
public static class DbSeeder
|
public static class DbSeeder
|
||||||
{
|
{
|
||||||
|
private static readonly (string En, string Zh, int Sort)[] GivingCategorySeed =
|
||||||
|
[
|
||||||
|
("Tithe", "什一奉獻", 1),
|
||||||
|
("General Offering", "一般奉獻", 2),
|
||||||
|
("Special Offering", "特別奉獻", 3),
|
||||||
|
("Building Fund", "建堂基金", 4),
|
||||||
|
("Mission", "宣教奉獻", 5),
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string En, string Zh, int Sort)[] MinistrySeed =
|
||||||
|
[
|
||||||
|
("Administration", "行政", 1),
|
||||||
|
("Preaching", "講道", 2),
|
||||||
|
("Emcee", "司會", 3),
|
||||||
|
("Worship", "敬拜", 4),
|
||||||
|
("PPT/Media", "PPT/影音", 5),
|
||||||
|
("Sound", "音控", 6),
|
||||||
|
("Facility", "場地組", 7),
|
||||||
|
("Hospitality", "招待", 8),
|
||||||
|
("Children", "兒牧", 9),
|
||||||
|
("Catering", "餐飲", 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
|
||||||
|
private static readonly (string En, string Zh, int Sort, (string En, string Zh)[] Subs)[] ExpenseCategorySeed =
|
||||||
|
[
|
||||||
|
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
|
||||||
|
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
|
||||||
|
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]),
|
||||||
|
("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","差旅")]),
|
||||||
|
("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","外包勞務")]),
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly (string Name, string Description)[] Roles =
|
private static readonly (string Name, string Description)[] Roles =
|
||||||
[
|
[
|
||||||
("super_admin", "System administrator — full access"),
|
("super_admin", "System administrator — full access"),
|
||||||
@@ -37,6 +77,75 @@ public static class DbSeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedGivingCategoriesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
foreach (var (en, zh, sort) in GivingCategorySeed)
|
||||||
|
{
|
||||||
|
if (!await db.GivingCategories.AnyAsync(c => c.Name_en == en))
|
||||||
|
{
|
||||||
|
db.GivingCategories.Add(new GivingCategory
|
||||||
|
{
|
||||||
|
Name_en = en,
|
||||||
|
Name_zh = zh,
|
||||||
|
SortOrder = sort,
|
||||||
|
IsActive = true,
|
||||||
|
// Audit fields are stamped by AuditSaveChangesInterceptor on save.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedMinistriesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
|
||||||
|
{
|
||||||
|
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
|
||||||
|
if (group is null)
|
||||||
|
{
|
||||||
|
group = new ExpenseCategoryGroup { Name_en = gEn, Name_zh = gZh, SortOrder = gSort, IsActive = true };
|
||||||
|
db.ExpenseCategoryGroups.Add(group);
|
||||||
|
await db.SaveChangesAsync(); // assign group.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
var sub = 1;
|
||||||
|
foreach (var (sEn, sZh) in subs)
|
||||||
|
{
|
||||||
|
if (!await db.ExpenseSubCategories.AnyAsync(s => s.GroupId == group.Id && s.Name_en == sEn))
|
||||||
|
db.ExpenseSubCategories.Add(new ExpenseSubCategory
|
||||||
|
{ GroupId = group.Id, Name_en = sEn, Name_zh = sZh, SortOrder = sub, IsActive = true });
|
||||||
|
sub++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedChurchProfileAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
// Singleton row used by the disbursement module (issuer info + check counter).
|
||||||
|
if (!await db.ChurchProfiles.AnyAsync())
|
||||||
|
{
|
||||||
|
db.ChurchProfiles.Add(new ChurchProfile
|
||||||
|
{
|
||||||
|
Name = "River Of Life Christian Church",
|
||||||
|
City = "Arcadia",
|
||||||
|
State = "CA",
|
||||||
|
NextCheckNumber = 1001,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds roles and (in Development) the default admin account.
|
/// Seeds roles and (in Development) the default admin account.
|
||||||
/// Called once on application startup after migrations have been applied.
|
/// Called once on application startup after migrations have been applied.
|
||||||
@@ -49,6 +158,12 @@ public static class DbSeeder
|
|||||||
|
|
||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
|
var db = services.GetRequiredService<AppDbContext>();
|
||||||
|
await SeedGivingCategoriesAsync(db);
|
||||||
|
await SeedMinistriesAsync(db);
|
||||||
|
await SeedExpenseCategoriesAsync(db);
|
||||||
|
await SeedChurchProfileAsync(db);
|
||||||
|
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
await SeedAdminUserAsync(userManager);
|
await SeedAdminUserAsync(userManager);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Data.Interceptors;
|
||||||
|
|
||||||
|
public class AuditSaveChangesInterceptor : SaveChangesInterceptor
|
||||||
|
{
|
||||||
|
private readonly IHttpContextAccessor _http;
|
||||||
|
|
||||||
|
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http;
|
||||||
|
|
||||||
|
public override InterceptionResult<int> SavingChanges(
|
||||||
|
DbContextEventData eventData, InterceptionResult<int> result)
|
||||||
|
{
|
||||||
|
Stamp(eventData.Context);
|
||||||
|
return base.SavingChanges(eventData, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||||
|
DbContextEventData eventData, InterceptionResult<int> result,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Stamp(eventData.Context);
|
||||||
|
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Stamp(DbContext? db)
|
||||||
|
{
|
||||||
|
if (db is null) return;
|
||||||
|
|
||||||
|
var userId = _http.HttpContext?.User
|
||||||
|
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
foreach (var entry in db.ChangeTracker.Entries())
|
||||||
|
{
|
||||||
|
if (entry.Entity is not AuditableEntity audit) continue;
|
||||||
|
|
||||||
|
if (entry.State == EntityState.Added)
|
||||||
|
{
|
||||||
|
audit.CreatedAt = now;
|
||||||
|
audit.CreatedBy = userId;
|
||||||
|
audit.UpdatedAt = now;
|
||||||
|
audit.UpdatedBy = userId;
|
||||||
|
}
|
||||||
|
else if (entry.State == EntityState.Modified)
|
||||||
|
{
|
||||||
|
audit.UpdatedAt = now;
|
||||||
|
audit.UpdatedBy = userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- ROLAC — Finance mock data (奉獻 / 支出) — ONE-OFF dev seed script
|
||||||
|
-- ============================================================================
|
||||||
|
-- Populates ~12 months of Givings (offering sessions) and Expenses so the
|
||||||
|
-- Finance Dashboard has trends, pie charts, and breakdowns to render.
|
||||||
|
--
|
||||||
|
-- WHAT IT CREATES
|
||||||
|
-- • ~8 mock Members (mixed EN/ZH names) — so some givings link to a person.
|
||||||
|
-- • 52 weekly OfferingSessions (one per Sunday, last 12 months), each with
|
||||||
|
-- ~8 Giving lines across all giving categories + payment methods. About
|
||||||
|
-- half the lines are linked to a member, the rest are anonymous.
|
||||||
|
-- • ~12 months of Expenses across every ministry / category group, mostly
|
||||||
|
-- Paid/Approved (so they count in the dashboard) with a few Submitted.
|
||||||
|
--
|
||||||
|
-- ALL rows are stamped CreatedBy = 'mockdata'. Re-running this script first
|
||||||
|
-- DELETEs the previous mock rows, so it is SAFE TO RUN MANY TIMES.
|
||||||
|
--
|
||||||
|
-- PREREQUISITE: reference data must already be seeded (run the API once so
|
||||||
|
-- DbSeeder fills GivingCategories / Ministries / ExpenseCategoryGroups /
|
||||||
|
-- ExpenseSubCategories). This script looks those up by Name_en.
|
||||||
|
--
|
||||||
|
-- HOW TO RUN (dev DB = PostgreSQL "ChurchCRM" on 192.168.68.55:49154):
|
||||||
|
-- psql "Host=192.168.68.55;Port=49154;Database=ChurchCRM;Username=chris;Password=1124" -f MockFinanceData.sql
|
||||||
|
-- …or just paste it into pgAdmin / DBeaver against the ChurchCRM database.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 0. Clean up any previous mock data (order respects FKs)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DELETE FROM "Givings" WHERE "CreatedBy" = 'mockdata';
|
||||||
|
DELETE FROM "OfferingSessions" WHERE "CreatedBy" = 'mockdata';
|
||||||
|
DELETE FROM "Expenses" WHERE "CreatedBy" = 'mockdata';
|
||||||
|
DELETE FROM "Members" WHERE "CreatedBy" = 'mockdata';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1. Mock members (mixed EN/ZH) — some givings & reimbursements link to these
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
INSERT INTO "Members"
|
||||||
|
("FirstName_en","LastName_en","FirstName_zh","LastName_zh","Gender","Email",
|
||||||
|
"Status","LanguagePreference","Country",
|
||||||
|
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
|
||||||
|
VALUES
|
||||||
|
('Wei', 'Chen', '偉', '陳', 'M', 'mock.wei@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('Mei', 'Lin', '美', '林', 'F', 'mock.mei@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('David', 'Wang', '大衛', '王', 'M', 'mock.david@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('Grace', 'Liu', '恩典', '劉', 'F', 'mock.grace@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('Samuel', 'Huang', '撒母耳','黃','M', 'mock.samuel@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('Esther', 'Wu', '以斯帖','吳','F', 'mock.esther@example.com', 'Member','zh','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('Joshua', 'Tsai', '約書亞','蔡','M', 'mock.joshua@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false),
|
||||||
|
('Hannah', 'Hsu', '漢娜', '許', 'F', 'mock.hannah@example.com', 'Member','en','USA', now(),'mockdata',now(),'mockdata',false);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2. Weekly offering sessions — last 52 Sundays
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- last_sunday = the most recent Sunday on/before today (dow: Sunday = 0)
|
||||||
|
INSERT INTO "OfferingSessions"
|
||||||
|
("SessionDate","Status","CashTotal","CheckTotal","SystemTotal","Difference",
|
||||||
|
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
|
||||||
|
SELECT w.d, 'Reconciled', 0, 0, 0, 0,
|
||||||
|
w.d::timestamptz, 'mockdata', w.d::timestamptz, 'mockdata'
|
||||||
|
FROM (
|
||||||
|
SELECT ((current_date - (extract(dow from current_date))::int) - (g * 7)) AS d
|
||||||
|
FROM generate_series(0, 51) AS g
|
||||||
|
) w
|
||||||
|
WHERE NOT EXISTS ( -- skip dates that already have a session
|
||||||
|
SELECT 1 FROM "OfferingSessions" os WHERE os."SessionDate" = w.d
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 3. Giving lines — ~8 per mock session, spread over categories & methods
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Line "specs": n, category (by Name_en), payment method, link-to-member?,
|
||||||
|
-- amount floor, amount range.
|
||||||
|
WITH specs(n, cat_name, method, link, amin, arange) AS (
|
||||||
|
VALUES
|
||||||
|
(1, 'Tithe', 'Cash', true, 100, 900),
|
||||||
|
(2, 'Tithe', 'Check', true, 100, 900),
|
||||||
|
(3, 'Tithe', 'Zelle', false, 80, 600),
|
||||||
|
(4, 'General Offering', 'Cash', false, 20, 180),
|
||||||
|
(5, 'General Offering', 'PayPal', true, 20, 180),
|
||||||
|
(6, 'Special Offering', 'Check', true, 50, 450),
|
||||||
|
(7, 'Building Fund', 'Zelle', true, 100, 900),
|
||||||
|
(8, 'Mission', 'Cash', false, 30, 270)
|
||||||
|
)
|
||||||
|
INSERT INTO "Givings"
|
||||||
|
("MemberId","GivingCategoryId","OfferingSessionId","Amount","PaymentMethod",
|
||||||
|
"CheckNumber","ZelleReferenceCode","PayPalTransactionId","GivingDate",
|
||||||
|
"IsAnonymous","Notes","CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
|
||||||
|
SELECT
|
||||||
|
CASE WHEN sp.link
|
||||||
|
THEN (SELECT m."Id" FROM "Members" m WHERE m."IsDeleted" = false ORDER BY random() LIMIT 1)
|
||||||
|
END,
|
||||||
|
gc."Id",
|
||||||
|
s."Id",
|
||||||
|
round((sp.amin + random() * sp.arange))::numeric(18,2),
|
||||||
|
sp.method,
|
||||||
|
CASE WHEN sp.method = 'Check' THEN (1000 + (random() * 8999)::int)::text END,
|
||||||
|
CASE WHEN sp.method = 'Zelle' THEN 'ZL' || to_char(s."SessionDate",'YYYYMMDD') || sp.n END,
|
||||||
|
CASE WHEN sp.method = 'PayPal' THEN 'PP-' || substr(md5(random()::text), 1, 10) END,
|
||||||
|
s."SessionDate",
|
||||||
|
(NOT sp.link), -- anonymous when not linked to a member
|
||||||
|
NULL,
|
||||||
|
s."SessionDate"::timestamptz, 'mockdata', s."SessionDate"::timestamptz, 'mockdata'
|
||||||
|
FROM "OfferingSessions" s
|
||||||
|
CROSS JOIN specs sp
|
||||||
|
JOIN "GivingCategories" gc ON gc."Name_en" = sp.cat_name
|
||||||
|
WHERE s."CreatedBy" = 'mockdata';
|
||||||
|
|
||||||
|
-- Roll the giving lines up into each session's Cash / Check / System totals.
|
||||||
|
UPDATE "OfferingSessions" os SET
|
||||||
|
"CashTotal" = COALESCE(sub.cash, 0),
|
||||||
|
"CheckTotal" = COALESCE(sub.chk, 0),
|
||||||
|
"SystemTotal" = COALESCE(sub.sys, 0),
|
||||||
|
"Difference" = 0
|
||||||
|
FROM (
|
||||||
|
SELECT "OfferingSessionId" sid,
|
||||||
|
SUM("Amount") FILTER (WHERE "PaymentMethod" = 'Cash') AS cash,
|
||||||
|
SUM("Amount") FILTER (WHERE "PaymentMethod" = 'Check') AS chk,
|
||||||
|
SUM("Amount") AS sys
|
||||||
|
FROM "Givings"
|
||||||
|
WHERE "CreatedBy" = 'mockdata'
|
||||||
|
GROUP BY "OfferingSessionId"
|
||||||
|
) sub
|
||||||
|
WHERE os."Id" = sub.sid;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 4. Expenses — recurring monthly spend across ministries & category groups
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Spec: ministry, category group, sub-category (all by Name_en),
|
||||||
|
-- is_reimbursement?, vendor name, description, amount floor, range.
|
||||||
|
WITH specs(ministry, grp, sub, is_reimb, vendor, descr, amin, arange) AS (
|
||||||
|
VALUES
|
||||||
|
('Facility', 'Facility', 'Rent', false, 'Arcadia Property Mgmt', 'Monthly facility rent / 場地月租', 2000, 400),
|
||||||
|
('Facility', 'Facility', 'Utilities', false, 'SoCal Edison', 'Electricity & water / 水電費', 250, 250),
|
||||||
|
('Worship', 'Equipment', 'Maintenance & Repair', false, 'Guitar Center Service', 'Instrument/sound maintenance / 樂器維修', 80, 220),
|
||||||
|
('Sound', 'Equipment', 'Rental', false, 'AV Rentals LA', 'AV equipment rental / 影音設備租借', 150, 350),
|
||||||
|
('PPT/Media', 'Consumables', 'Accessories', false, 'B&H Photo', 'Cables & adapters / 線材配件', 40, 160),
|
||||||
|
('Catering', 'Food & Beverage','Catering', false, '85C Bakery', 'Sunday fellowship meal / 主日愛筵', 200, 300),
|
||||||
|
('Catering', 'Food & Beverage','Food Ingredients', true, NULL, 'Groceries for kitchen / 廚房食材', 80, 220),
|
||||||
|
('Children', 'Materials', 'Craft Supplies', true, NULL, 'Sunday school crafts / 兒主手工材料', 40, 160),
|
||||||
|
('Children', 'Materials', 'Printing', false, 'FedEx Office', 'Children lesson printing / 兒童教材印刷', 50, 150),
|
||||||
|
('Administration','Printing', 'Bulletins', false, 'FedEx Office', 'Weekly bulletin printing / 週報印刷', 60, 120),
|
||||||
|
('Administration','Consumables', 'Office Supplies', true, NULL, 'Office supplies / 辦公文具', 30, 120),
|
||||||
|
('Hospitality', 'Consumables', 'Cleaning Supplies', true, NULL, 'Cleaning supplies / 清潔用品', 30, 90),
|
||||||
|
('Preaching', 'Personnel', 'Honorarium', true, NULL, 'Guest speaker honorarium / 講員酬庸', 100, 300),
|
||||||
|
('Administration','Missions', 'Missionary Support', false, 'OMF International', 'Monthly missionary support / 宣教士月支援', 200, 300)
|
||||||
|
),
|
||||||
|
-- 12 months back from the current month
|
||||||
|
months AS (
|
||||||
|
SELECT (date_trunc('month', current_date) - (g || ' month')::interval)::date AS m0
|
||||||
|
FROM generate_series(0, 11) AS g
|
||||||
|
),
|
||||||
|
rows AS (
|
||||||
|
SELECT
|
||||||
|
mi."Id" AS ministry_id,
|
||||||
|
gp."Id" AS group_id,
|
||||||
|
sc."Id" AS sub_id,
|
||||||
|
sp.is_reimb,
|
||||||
|
sp.vendor,
|
||||||
|
sp.descr,
|
||||||
|
-- expense date: a day within that month, never in the future
|
||||||
|
LEAST(mo.m0 + (random() * 27)::int, current_date) AS expense_date,
|
||||||
|
round((sp.amin + random() * sp.arange))::numeric(18,2) AS amount,
|
||||||
|
-- weighted status: 7 Paid / 2 Approved / 1 Submitted
|
||||||
|
(ARRAY['Paid','Paid','Paid','Paid','Paid','Paid','Paid','Approved','Approved','Submitted'])
|
||||||
|
[1 + (random() * 9)::int] AS status
|
||||||
|
FROM specs sp
|
||||||
|
CROSS JOIN months mo
|
||||||
|
JOIN "Ministries" mi ON mi."Name_en" = sp.ministry
|
||||||
|
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
|
||||||
|
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
|
||||||
|
)
|
||||||
|
INSERT INTO "Expenses"
|
||||||
|
("MinistryId","CategoryGroupId","SubCategoryId","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,
|
||||||
|
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
|
||||||
|
r.status,
|
||||||
|
r.amount,
|
||||||
|
r.descr,
|
||||||
|
CASE WHEN r.is_reimb THEN NULL ELSE r.vendor END,
|
||||||
|
CASE WHEN r.is_reimb
|
||||||
|
THEN (SELECT m."Id" FROM "Members" m WHERE m."IsDeleted" = false ORDER BY random() LIMIT 1)
|
||||||
|
END,
|
||||||
|
CASE WHEN NOT r.is_reimb AND r.status = 'Paid' THEN (4000 + (random() * 5999)::int)::text END,
|
||||||
|
r.expense_date,
|
||||||
|
NULL,
|
||||||
|
'mockdata', r.expense_date::timestamptz,
|
||||||
|
CASE WHEN r.status IN ('Approved','Paid') THEN 'mockdata' END,
|
||||||
|
CASE WHEN r.status IN ('Approved','Paid') THEN r.expense_date::timestamptz END,
|
||||||
|
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;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Quick verification (run after commit)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
SELECT 'members' AS kind, count(*)::text AS value FROM "Members" WHERE "CreatedBy" = 'mockdata'
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'sessions', count(*)::text FROM "OfferingSessions" WHERE "CreatedBy" = 'mockdata'
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'givings', count(*)::text FROM "Givings" WHERE "CreatedBy" = 'mockdata'
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'giving $', COALESCE(sum("Amount"), 0)::numeric(18,2)::text FROM "Givings" WHERE "CreatedBy" = 'mockdata'
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'expenses', count(*)::text FROM "Expenses" WHERE "CreatedBy" = 'mockdata'
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'expense $ (paid+appr)', COALESCE(sum("Amount"), 0)::numeric(18,2)::text
|
||||||
|
FROM "Expenses" WHERE "CreatedBy" = 'mockdata' AND "Status" IN ('Paid','Approved');
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
public abstract class AuditableEntity
|
||||||
|
{
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
public string CreatedBy { get; set; } = null!; // FK → AspNetUsers.Id
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; }
|
||||||
|
public string UpdatedBy { get; set; } = null!; // FK → AspNetUsers.Id
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
public abstract class SoftDeleteEntity : AuditableEntity
|
||||||
|
{
|
||||||
|
public bool IsDeleted { get; set; } = false;
|
||||||
|
public DateTimeOffset? DeletedAt { get; set; }
|
||||||
|
public string? DeletedBy { get; set; } // FK → AspNetUsers.Id
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A disbursement check issued to a single payee, bundling one or more approved
|
||||||
|
/// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at
|
||||||
|
/// issue time so the printed check is reproducible even if member data later changes.
|
||||||
|
/// </summary>
|
||||||
|
public class Check : SoftDeleteEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string CheckNumber { get; set; } = null!;
|
||||||
|
public DateOnly CheckDate { get; set; }
|
||||||
|
public decimal Amount { get; set; } // sum of line amounts
|
||||||
|
|
||||||
|
public string PayeeType { get; set; } = "Vendor"; // Vendor | Member
|
||||||
|
public int? MemberId { get; set; } // set when PayeeType == Member
|
||||||
|
public string PayeeName { get; set; } = null!; // snapshot
|
||||||
|
public string? PayeeAddress { get; set; } // snapshot
|
||||||
|
public string? PayeeCity { get; set; }
|
||||||
|
public string? PayeeState { get; set; }
|
||||||
|
public string? PayeeZip { get; set; }
|
||||||
|
|
||||||
|
public string Status { get; set; } = "Issued"; // Issued | Voided
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
|
||||||
|
public string IssuedBy { get; set; } = null!;
|
||||||
|
public DateTimeOffset IssuedAt { get; set; }
|
||||||
|
|
||||||
|
public string? VoidReason { get; set; }
|
||||||
|
public DateTimeOffset? VoidedAt { get; set; }
|
||||||
|
public string? VoidedBy { get; set; }
|
||||||
|
|
||||||
|
// Receipt e-signature: payee signs in-app on hand-off. "Signed" is derived as
|
||||||
|
// ReceiptSignedAt != null.
|
||||||
|
public string? ReceiptSignatureBlobPath { get; set; }
|
||||||
|
public string? ReceiptSignedName { get; set; }
|
||||||
|
public DateTimeOffset? ReceiptSignedAt { get; set; }
|
||||||
|
public string? ReceiptCapturedBy { get; set; }
|
||||||
|
|
||||||
|
public Member? Member { get; set; }
|
||||||
|
public ICollection<CheckLine> Lines { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One expense covered by a <see cref="Check"/>. Amount/Description are snapshotted
|
||||||
|
/// at issue time for the printed ledger stub; ExpenseId links back to the source expense.
|
||||||
|
/// </summary>
|
||||||
|
public class CheckLine : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int CheckId { get; set; }
|
||||||
|
public int ExpenseId { get; set; }
|
||||||
|
public decimal Amount { get; set; } // snapshot of expense amount
|
||||||
|
public string Description { get; set; } = null!; // snapshot of expense description
|
||||||
|
|
||||||
|
public Check? Check { get; set; }
|
||||||
|
public Expense? Expense { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
|
||||||
|
/// running check-number counter used when disbursing checks. Seeded on startup.
|
||||||
|
/// </summary>
|
||||||
|
public class ChurchProfile : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? ZipCode { get; set; }
|
||||||
|
public string? BankName { get; set; }
|
||||||
|
public string? BankAccountNumber { get; set; }
|
||||||
|
public string? BankRoutingNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary>
|
||||||
|
public int NextCheckNumber { get; set; } = 1001;
|
||||||
|
|
||||||
|
// Npgsql system column used as an optimistic-concurrency token so two simultaneous
|
||||||
|
// disbursement runs can't allocate the same check number. Mapped via IsRowVersion().
|
||||||
|
public uint xmin { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class Expense : SoftDeleteEntity
|
||||||
|
{
|
||||||
|
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 string Description { get; set; } = null!;
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public DateOnly ExpenseDate { get; set; }
|
||||||
|
public string? ReceiptBlobPath { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public string? SubmittedBy { get; set; }
|
||||||
|
public DateTimeOffset? SubmittedAt { get; set; }
|
||||||
|
public string? ReviewedBy { get; set; }
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
|
public string? ReviewNotes { get; set; }
|
||||||
|
public DateTimeOffset? PaidAt { get; set; }
|
||||||
|
public string? PaidBy { get; set; }
|
||||||
|
|
||||||
|
public Ministry? Ministry { get; set; }
|
||||||
|
public ExpenseCategoryGroup? CategoryGroup { get; set; }
|
||||||
|
public ExpenseSubCategory? SubCategory { get; set; }
|
||||||
|
public Member? Member { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class ExpenseCategoryGroup : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name_en { get; set; } = null!;
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public List<ExpenseSubCategory> SubCategories { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class ExpenseSubCategory : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
public string Name_en { get; set; } = null!;
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
public ExpenseCategoryGroup? Group { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class FamilyUnit : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? FamilyName_en { get; set; }
|
||||||
|
public string? FamilyName_zh { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class Giving : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public int GivingCategoryId { get; set; }
|
||||||
|
public int? OfferingSessionId { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string PaymentMethod { get; set; } = "Cash"; // Cash|Check|Zelle|PayPal|Other
|
||||||
|
public string? CheckNumber { get; set; }
|
||||||
|
public string? ZelleReferenceCode { get; set; }
|
||||||
|
public string? PayPalTransactionId{ get; set; }
|
||||||
|
public DateOnly GivingDate { get; set; }
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public Member? Member { get; set; }
|
||||||
|
public GivingCategory? GivingCategory { get; set; }
|
||||||
|
public OfferingSession? OfferingSession { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class GivingCategory : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name_en { get; set; } = null!;
|
||||||
|
public string? Name_zh { get; set; }
|
||||||
|
public string? Description_en { get; set; }
|
||||||
|
public string? Description_zh { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per Sunday holding the live shared head-count for the three
|
||||||
|
/// age groups. Volunteers increment these concurrently from the public
|
||||||
|
/// counter page; the columns are updated with atomic SQL increments.
|
||||||
|
/// </summary>
|
||||||
|
public class MealAttendance : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateOnly AttendanceDate { get; set; }
|
||||||
|
public int AdultCount { get; set; }
|
||||||
|
public int YouthCount { get; set; }
|
||||||
|
public int KidCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class Member : SoftDeleteEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string FirstName_en { get; set; } = null!;
|
||||||
|
public string LastName_en { get; set; } = null!;
|
||||||
|
public string? NickName { get; set; }
|
||||||
|
public string? FirstName_zh { get; set; }
|
||||||
|
public string? LastName_zh { get; set; }
|
||||||
|
public string? Gender { get; set; } // 'M' | 'F' | 'Other'
|
||||||
|
public DateOnly? DateOfBirth { get; set; }
|
||||||
|
public DateOnly? BaptismDate { get; set; }
|
||||||
|
public string? BaptismChurch { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? PhoneCell { get; set; }
|
||||||
|
public string? PhoneHome { get; set; }
|
||||||
|
public string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? ZipCode { get; set; }
|
||||||
|
public string Country { get; set; } = "USA";
|
||||||
|
public string? PhotoBlobPath { get; set; }
|
||||||
|
public string Status { get; set; } = "Member"; // Member|Visitor|Inactive|Former
|
||||||
|
public string LanguagePreference { get; set; } = "en";
|
||||||
|
public DateOnly? JoinDate { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int? FamilyUnitId { get; set; }
|
||||||
|
public FamilyUnit? FamilyUnit { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class Ministry
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name_en { get; set; } = null!;
|
||||||
|
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; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class MonthlyStatement : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int Year { get; set; }
|
||||||
|
public int Month { get; set; }
|
||||||
|
public decimal OpeningBalance { get; set; }
|
||||||
|
public decimal TotalGiving { get; set; }
|
||||||
|
public decimal TotalOtherIncome { get; set; }
|
||||||
|
public decimal TotalExpenses { get; set; }
|
||||||
|
public decimal CalculatedClosingBalance { get; set; }
|
||||||
|
public decimal BankStatementBalance { get; set; }
|
||||||
|
public decimal Difference { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsFinalized { get; set; }
|
||||||
|
public DateTimeOffset? FinalizedAt { get; set; }
|
||||||
|
public string? FinalizedBy { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
public class OfferingSession : AuditableEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateOnly SessionDate { get; set; }
|
||||||
|
public string Status { get; set; } = "Draft"; // Draft | Submitted | Reconciled
|
||||||
|
public decimal CashTotal { get; set; }
|
||||||
|
public decimal CheckTotal { get; set; }
|
||||||
|
public decimal SystemTotal { get; set; }
|
||||||
|
public decimal Difference { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public string? ProofPdfPath { get; set; } // merged paper-proof PDF (relative storage path)
|
||||||
|
public DateTimeOffset? SubmittedAt { get; set; }
|
||||||
|
public string? SubmittedBy { get; set; }
|
||||||
|
public DateTimeOffset? ReconciledAt { get; set; }
|
||||||
|
public string? ReconciledBy { get; set; }
|
||||||
|
|
||||||
|
public List<Giving> Givings { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real-time hub backing the public Sunday attendance counter. Anonymous
|
||||||
|
/// (no [Authorize]) so volunteers can use it without logging in. Every
|
||||||
|
/// increment is broadcast to all connected clients so multiple people can
|
||||||
|
/// count the same Sunday together and see each other's changes instantly.
|
||||||
|
/// </summary>
|
||||||
|
public class AttendanceHub : Hub
|
||||||
|
{
|
||||||
|
private readonly IMealAttendanceService _svc;
|
||||||
|
|
||||||
|
public AttendanceHub(IMealAttendanceService svc) => _svc = svc;
|
||||||
|
|
||||||
|
// Push the current counts to a client the moment it connects.
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
var counts = await _svc.GetOrCreateAsync(_svc.Today);
|
||||||
|
await Clients.Caller.SendAsync("ReceiveCounts", counts);
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
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);
|
||||||
|
await Clients.All.SendAsync("ReceiveCounts", counts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Hubs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Real-time hub backing the mobile Sunday offering-entry page. Anonymous
|
||||||
|
/// (no [Authorize]) so volunteers can enter givings on their phones without
|
||||||
|
/// logging in. Clients join a group named after the session date (yyyy-MM-dd);
|
||||||
|
/// when a line is appended, the controller broadcasts "LineAdded" to that
|
||||||
|
/// group so every phone and the desktop Sunday Offering Entry page updating
|
||||||
|
/// the same date see the new line instantly. The hub itself holds no business
|
||||||
|
/// logic — broadcasting is done from the controller via IHubContext.
|
||||||
|
/// </summary>
|
||||||
|
public class OfferingEntryHub : Hub
|
||||||
|
{
|
||||||
|
public Task JoinDate(string date)
|
||||||
|
=> Groups.AddToGroupAsync(Context.ConnectionId, date);
|
||||||
|
|
||||||
|
public Task LeaveDate(string date)
|
||||||
|
=> Groups.RemoveFromGroupAsync(Context.ConnectionId, date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Json;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads <see cref="DateOnly"/> from either "yyyy-MM-dd" or any ISO 8601 date-time
|
||||||
|
/// (the date portion is taken). Writes as "yyyy-MM-dd". Lets JS clients send a Date
|
||||||
|
/// without first formatting it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TolerantDateOnlyConverter : JsonConverter<DateOnly>
|
||||||
|
{
|
||||||
|
private const string Format = "yyyy-MM-dd";
|
||||||
|
|
||||||
|
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var s = reader.GetString();
|
||||||
|
if (string.IsNullOrEmpty(s))
|
||||||
|
throw new JsonException("Expected a date string for DateOnly.");
|
||||||
|
|
||||||
|
if (DateOnly.TryParseExact(s, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d))
|
||||||
|
return d;
|
||||||
|
|
||||||
|
if (DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto))
|
||||||
|
return DateOnly.FromDateTime(dto.DateTime);
|
||||||
|
|
||||||
|
throw new JsonException($"Unable to parse '{s}' as DateOnly.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
|
||||||
|
=> writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260527205155_AddMemberAndFamilyUnit")]
|
||||||
|
partial class AddMemberAndFamilyUnit
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.11")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.AppRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("LanguagePreference")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("MemberId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MemberId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MemberId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.FamilyUnit", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FamilyName_en")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FamilyName_zh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("FamilyUnits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("BaptismChurch")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("BaptismDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Country")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasDefaultValue("USA");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("DateOfBirth")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("FamilyUnitId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName_zh")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Gender")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("JoinDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("LanguagePreference")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("LastName_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("LastName_zh")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("NickName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneCell")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneHome")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("PhotoBlobPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Member");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ZipCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.HasFilter("\"Email\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyUnitId");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasFilter("\"IsDeleted\" = false");
|
||||||
|
|
||||||
|
b.ToTable("Members");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceInfo")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("character varying(45)");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyUnitId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("FamilyUnit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMemberAndFamilyUnit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FamilyUnits",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FamilyName_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
FamilyName_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "text", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FamilyUnits", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Members",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
FirstName_en = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
LastName_en = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
NickName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
FirstName_zh = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
LastName_zh = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
Gender = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
|
||||||
|
DateOfBirth = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
BaptismDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
BaptismChurch = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
PhoneCell = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||||
|
PhoneHome = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
|
||||||
|
Address = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
State = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
ZipCode = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
|
||||||
|
Country = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false, defaultValue: "USA"),
|
||||||
|
PhotoBlobPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Member"),
|
||||||
|
LanguagePreference = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false, defaultValue: "en"),
|
||||||
|
JoinDate = table.Column<DateOnly>(type: "date", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
FamilyUnitId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Members", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Members_FamilyUnits_FamilyUnitId",
|
||||||
|
column: x => x.FamilyUnitId,
|
||||||
|
principalTable: "FamilyUnits",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AspNetUsers_MemberId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
column: "MemberId",
|
||||||
|
unique: true,
|
||||||
|
filter: "\"MemberId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Members_Email",
|
||||||
|
table: "Members",
|
||||||
|
column: "Email",
|
||||||
|
filter: "\"Email\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Members_FamilyUnitId",
|
||||||
|
table: "Members",
|
||||||
|
column: "FamilyUnitId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Members_Status",
|
||||||
|
table: "Members",
|
||||||
|
column: "Status",
|
||||||
|
filter: "\"IsDeleted\" = false");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Members");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FamilyUnits");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AspNetUsers_MemberId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,792 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260528232422_AddGivingModule")]
|
||||||
|
partial class AddGivingModule
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.11")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RoleId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.AppRole", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("LanguagePreference")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLoginAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("MemberId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("MemberId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasFilter("\"MemberId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.FamilyUnit", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("FamilyName_en")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FamilyName_zh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("FamilyUnits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("CheckNumber")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<int>("GivingCategoryId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("GivingDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAnonymous")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int?>("MemberId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int?>("OfferingSessionId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("PayPalTransactionId")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("PaymentMethod")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ZelleReferenceCode")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GivingCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("GivingDate");
|
||||||
|
|
||||||
|
b.HasIndex("OfferingSessionId")
|
||||||
|
.HasFilter("\"OfferingSessionId\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("MemberId", "GivingDate");
|
||||||
|
|
||||||
|
b.ToTable("Givings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Description_en")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Description_zh")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Name_zh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("GivingCategories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("Address")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("BaptismChurch")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("BaptismDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Country")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasDefaultValue("USA");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("DateOfBirth")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int?>("FamilyUnitId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName_zh")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Gender")
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("JoinDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("LanguagePreference")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("LastName_en")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("LastName_zh")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("NickName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneCell")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneHome")
|
||||||
|
.HasMaxLength(30)
|
||||||
|
.HasColumnType("character varying(30)");
|
||||||
|
|
||||||
|
b.Property<string>("PhotoBlobPath")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Member");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ZipCode")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.HasFilter("\"Email\" IS NOT NULL");
|
||||||
|
|
||||||
|
b.HasIndex("FamilyUnitId");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasFilter("\"IsDeleted\" = false");
|
||||||
|
|
||||||
|
b.ToTable("Members");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("CashTotal")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("CheckTotal")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Difference")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ReconciledAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ReconciledBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("SessionDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)")
|
||||||
|
.HasDefaultValue("Draft");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("SubmittedBy")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SystemTotal")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionDate")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("OfferingSessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceInfo")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("IpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("character varying(45)");
|
||||||
|
|
||||||
|
b.Property<string>("ReplacedByHash")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RevokedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("GivingCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.Member", "Member")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("MemberId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession")
|
||||||
|
.WithMany("Givings")
|
||||||
|
.HasForeignKey("OfferingSessionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("GivingCategory");
|
||||||
|
|
||||||
|
b.Navigation("Member");
|
||||||
|
|
||||||
|
b.Navigation("OfferingSession");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.Member", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FamilyUnitId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("FamilyUnit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ROLAC.API.Entities.AppUser", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Givings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGivingModule : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "GivingCategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
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),
|
||||||
|
Description_en = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
Description_zh = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", 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_GivingCategories", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "OfferingSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
SessionDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Draft"),
|
||||||
|
CashTotal = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
CheckTotal = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
SystemTotal = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Difference = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SubmittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
SubmittedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
|
||||||
|
ReconciledAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ReconciledBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_OfferingSessions", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Givings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
MemberId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
GivingCategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
OfferingSessionId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
PaymentMethod = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
ZelleReferenceCode = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
PayPalTransactionId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
GivingDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
IsAnonymous = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Givings", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Givings_GivingCategories_GivingCategoryId",
|
||||||
|
column: x => x.GivingCategoryId,
|
||||||
|
principalTable: "GivingCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Givings_Members_MemberId",
|
||||||
|
column: x => x.MemberId,
|
||||||
|
principalTable: "Members",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Givings_OfferingSessions_OfferingSessionId",
|
||||||
|
column: x => x.OfferingSessionId,
|
||||||
|
principalTable: "OfferingSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Givings_GivingCategoryId",
|
||||||
|
table: "Givings",
|
||||||
|
column: "GivingCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Givings_GivingDate",
|
||||||
|
table: "Givings",
|
||||||
|
column: "GivingDate");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Givings_MemberId_GivingDate",
|
||||||
|
table: "Givings",
|
||||||
|
columns: new[] { "MemberId", "GivingDate" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Givings_OfferingSessionId",
|
||||||
|
table: "Givings",
|
||||||
|
column: "OfferingSessionId",
|
||||||
|
filter: "\"OfferingSessionId\" IS NOT NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_OfferingSessions_SessionDate",
|
||||||
|
table: "OfferingSessions",
|
||||||
|
column: "SessionDate",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Givings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "GivingCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "OfferingSessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,234 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ROLAC.API.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExpenseModule : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseCategoryGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
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_ExpenseCategoryGroups", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Ministries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
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),
|
||||||
|
Description_en = table.Column<string>(type: "text", nullable: true),
|
||||||
|
Description_zh = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "boolean", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Ministries", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "MonthlyStatements",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
Year = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Month = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
OpeningBalance = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
TotalGiving = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
TotalOtherIncome = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
TotalExpenses = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
CalculatedClosingBalance = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
BankStatementBalance = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Difference = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
IsFinalized = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
FinalizedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
FinalizedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_MonthlyStatements", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExpenseSubCategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
GroupId = table.Column<int>(type: "integer", 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_ExpenseSubCategories", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExpenseSubCategories_ExpenseCategoryGroups_GroupId",
|
||||||
|
column: x => x.GroupId,
|
||||||
|
principalTable: "ExpenseCategoryGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Expenses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
MinistryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CategoryGroupId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SubCategoryId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false, defaultValue: "Draft"),
|
||||||
|
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||||
|
VendorName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||||
|
MemberId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
||||||
|
ExpenseDate = table.Column<DateOnly>(type: "date", nullable: false),
|
||||||
|
ReceiptBlobPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SubmittedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
|
||||||
|
SubmittedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ReviewedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
|
||||||
|
ReviewedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
ReviewNotes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||||
|
PaidAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
PaidBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
|
||||||
|
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Expenses", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_ExpenseCategoryGroups_CategoryGroupId",
|
||||||
|
column: x => x.CategoryGroupId,
|
||||||
|
principalTable: "ExpenseCategoryGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_ExpenseSubCategories_SubCategoryId",
|
||||||
|
column: x => x.SubCategoryId,
|
||||||
|
principalTable: "ExpenseSubCategories",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_Members_MemberId",
|
||||||
|
column: x => x.MemberId,
|
||||||
|
principalTable: "Members",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Expenses_Ministries_MinistryId",
|
||||||
|
column: x => x.MinistryId,
|
||||||
|
principalTable: "Ministries",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_CategoryGroupId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "CategoryGroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_ExpenseDate",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "ExpenseDate");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_MemberId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "MemberId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_MinistryId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "MinistryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_Status",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "Status",
|
||||||
|
filter: "\"IsDeleted\" = false");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Expenses_SubCategoryId",
|
||||||
|
table: "Expenses",
|
||||||
|
column: "SubCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExpenseSubCategories_GroupId",
|
||||||
|
table: "ExpenseSubCategories",
|
||||||
|
column: "GroupId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_MonthlyStatements_Year_Month",
|
||||||
|
table: "MonthlyStatements",
|
||||||
|
columns: new[] { "Year", "Month" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Expenses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "MonthlyStatements");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseSubCategories");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Ministries");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExpenseCategoryGroups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+94
-13
@@ -1,10 +1,15 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.Data.Interceptors;
|
||||||
using ROLAC.API.Entities;
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Json;
|
||||||
using ROLAC.API.Services;
|
using ROLAC.API.Services;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -13,14 +18,17 @@ var config = builder.Configuration;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Database
|
// Database
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services.AddDbContext<AppDbContext>(opt =>
|
builder.Services.AddHttpContextAccessor();
|
||||||
opt.UseNpgsql(config.GetConnectionString("DefaultConnection")));
|
builder.Services.AddScoped<AuditSaveChangesInterceptor>();
|
||||||
|
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
|
||||||
|
opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
|
||||||
|
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Identity
|
// Identity (API-only — no cookie auth; JWT is the default scheme)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddIdentity<AppUser, AppRole>(opt =>
|
.AddIdentityCore<AppUser>(opt =>
|
||||||
{
|
{
|
||||||
opt.Password.RequiredLength = 8;
|
opt.Password.RequiredLength = 8;
|
||||||
opt.Password.RequireDigit = true;
|
opt.Password.RequireDigit = true;
|
||||||
@@ -28,8 +36,8 @@ builder.Services
|
|||||||
opt.Password.RequireLowercase = true;
|
opt.Password.RequireLowercase = true;
|
||||||
opt.Password.RequireNonAlphanumeric = true;
|
opt.Password.RequireNonAlphanumeric = true;
|
||||||
opt.User.RequireUniqueEmail = true;
|
opt.User.RequireUniqueEmail = true;
|
||||||
opt.SignIn.RequireConfirmedAccount = false;
|
|
||||||
})
|
})
|
||||||
|
.AddRoles<AppRole>()
|
||||||
.AddEntityFrameworkStores<AppDbContext>()
|
.AddEntityFrameworkStores<AppDbContext>()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
@@ -40,13 +48,14 @@ var jwtKey = config["Jwt:SecretKey"]
|
|||||||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddAuthentication(opt =>
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
{
|
|
||||||
opt.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
||||||
opt.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
||||||
})
|
|
||||||
.AddJwtBearer(opt =>
|
.AddJwtBearer(opt =>
|
||||||
{
|
{
|
||||||
|
// Keep JWT claim names exactly as written ("role", "sub", "email").
|
||||||
|
// Without this, .NET 8's JsonWebTokenHandler may remap "role" to the
|
||||||
|
// long ClaimTypes.Role URI, which conflicts with RoleClaimType = "role".
|
||||||
|
opt.MapInboundClaims = false;
|
||||||
|
|
||||||
opt.TokenValidationParameters = new TokenValidationParameters
|
opt.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
@@ -56,9 +65,37 @@ builder.Services
|
|||||||
ValidIssuer = config["Jwt:Issuer"],
|
ValidIssuer = config["Jwt:Issuer"],
|
||||||
ValidAudience = config["Jwt:Audience"],
|
ValidAudience = config["Jwt:Audience"],
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
// Roles were written as JWT short name "role"; map to ClaimTypes.Role for [Authorize].
|
NameClaimType = "sub",
|
||||||
RoleClaimType = "role",
|
RoleClaimType = "role",
|
||||||
ClockSkew = TimeSpan.Zero,
|
ClockSkew = TimeSpan.FromMinutes(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Diagnostic events — visible in the API console while debugging 401s.
|
||||||
|
opt.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnAuthenticationFailed = ctx =>
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[JWT] Auth failed: {ctx.Exception.GetType().Name} — {ctx.Exception.Message}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnChallenge = ctx =>
|
||||||
|
{
|
||||||
|
// Fires when a 401 challenge is about to be sent.
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[JWT] Challenge: error={ctx.Error}, description={ctx.ErrorDescription}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnForbidden = ctx =>
|
||||||
|
{
|
||||||
|
// Fires when user IS authenticated but lacks the required role (403).
|
||||||
|
Console.WriteLine(
|
||||||
|
$"[JWT] Forbidden: user={ctx.HttpContext.User.Identity?.Name}, " +
|
||||||
|
$"roles=[{string.Join(',', ctx.HttpContext.User.Claims
|
||||||
|
.Where(c => c.Type == "role")
|
||||||
|
.Select(c => c.Value))}]");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,12 +117,42 @@ builder.Services.AddCors(opt =>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IMemberService, MemberService>();
|
||||||
|
builder.Services.AddScoped<IUserManagementService, UserManagementService>();
|
||||||
|
builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
|
||||||
|
builder.Services.AddScoped<IGivingService, GivingService>();
|
||||||
|
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
|
||||||
|
builder.Services.AddScoped<IMinistryService, MinistryService>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
|
||||||
|
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
|
||||||
|
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
|
||||||
|
builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
||||||
|
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||||
|
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||||
|
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||||
|
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||||
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
|
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||||
|
|
||||||
|
// Real-time hub for the live Sunday attendance counter.
|
||||||
|
builder.Services.AddSignalR();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Swagger / MVC
|
// Swagger / MVC
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services.AddControllers();
|
builder.Services
|
||||||
|
.AddControllers()
|
||||||
|
.AddJsonOptions(opt =>
|
||||||
|
{
|
||||||
|
// camelCase in/out + tolerant DateOnly (accepts "yyyy-MM-dd" or full ISO datetime).
|
||||||
|
opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
opt.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
|
||||||
|
opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
|
opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter());
|
||||||
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddHealthChecks();
|
||||||
builder.Services.AddSwaggerGen(opt =>
|
builder.Services.AddSwaggerGen(opt =>
|
||||||
{
|
{
|
||||||
opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" });
|
opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" });
|
||||||
@@ -114,6 +181,12 @@ builder.Services.AddSwaggerGen(opt =>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Behind a TLS-terminating reverse proxy (nginx), honour the original scheme/client IP.
|
||||||
|
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||||
|
{
|
||||||
|
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
||||||
|
});
|
||||||
|
|
||||||
// Apply migrations + seed on startup
|
// Apply migrations + seed on startup
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
@@ -128,10 +201,18 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLS is terminated by nginx in production; only redirect in local dev.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
|
|
||||||
app.UseCors("Angular");
|
app.UseCors("Angular");
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
app.MapHub<ROLAC.API.Hubs.AttendanceHub>("/hubs/attendance");
|
||||||
|
app.MapHub<ROLAC.API.Hubs.OfferingEntryHub>("/hubs/offering-entry");
|
||||||
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user