Compare commits

..

98 Commits

Author SHA1 Message Date
Chris Chen ddced87dc6 Update
ci-cd-nas / build-push (push) Failing after 27s
ci-cd-nas / deploy (push) Has been skipped
2026-06-20 22:26:52 -07:00
Chris Chen 7ab8e9703b WIP 2026-06-20 21:06:24 -07:00
Chris Chen aaaae09bd2 Add Line notifications module design spec
Phase 1: Line Messaging API channel with webhook binding (individual +
group), manual send-now, history, and binding/group admin UI. Scheduled
sends and event triggers deferred to phases 2-3; IMessageChannel seam
left for future PWA/WeChat channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:49:00 -07:00
Chris Chen 8061a60fe5 add quick add entry. 2026-06-20 20:42:06 -07:00
Chris Chen 87425b3276 add attendance 2026-06-20 19:43:15 -07:00
Chris Chen 2af169fa60 Fix null payee. 2026-06-20 18:05:22 -07:00
Chris Chen 3558c67fd7 WIP 2026-06-20 17:51:33 -07:00
Chris Chen f55807fa7d wip 2026-06-20 15:13:23 -07:00
Chris Chen b6c50a38aa Enhance 2026-05-30 09:57:43 -07:00
Chris Chen caed5091f0 Enhance offering session 2026-05-30 00:15:10 -07:00
Chris Chen 769597d769 refactor finance. 2026-05-29 23:56:29 -07:00
Chris Chen 241870fe48 docs: align plan test command with project (ng test headless)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:29:19 -07:00
Chris Chen e817801e14 docs: record dropdown bilingual display convention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:27:06 -07:00
Chris Chen aef5454202 feat(i18n): bilingual language dropdown on member form dialog
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:27:03 -07:00
Chris Chen 9a94e3b09e feat(i18n): bilingual ministry/category/status dropdowns on expense pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 22:27:00 -07:00
Chris Chen fba0b63214 feat(i18n): bilingual language + roles selectors in user dialogs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:13:41 -07:00
Chris Chen e5296e79dc feat(i18n): bilingual gender/status dropdowns on member form + members page 2026-05-29 22:11:39 -07:00
Chris Chen 61e34d343a feat(i18n): bilingual category + method dropdowns on givings page 2026-05-29 22:09:19 -07:00
Chris Chen 126e640731 feat(i18n): bilingual Type/Method dropdowns + line echo on offering session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:07:57 -07:00
Chris Chen 4e15e9f630 feat(i18n): compute bilingual label on giving/ministry/expense-category lookups 2026-05-29 22:06:09 -07:00
Chris Chen 4bee06addb feat(i18n): add central bilingual option lists for enum dropdowns 2026-05-29 22:04:18 -07:00
Chris Chen a99755a5db feat(i18n): add bilingual() display helper 2026-05-29 22:02:50 -07:00
Chris Chen fef3b76a31 docs: implementation plan for bilingual dropdown options
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:56:22 -07:00
Chris Chen e37aade69f docs: spec for bilingual (English/Chinese) dropdown options
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 21:50:31 -07:00
Chris Chen fe50ea3d30 WIP 2026-05-29 20:54:08 -07:00
Chris Chen 95fa37ebdf fix(expense): open category read to all authed users; statement lookups via FirstOrDefaultAsync
Final-review findings:
- ExpenseCategoriesController was finance-only at the class level, but the member
  self-service reimbursement form reads the category list to populate its dropdown,
  so members got 403 and could not submit. Open GET to any authenticated user;
  keep group/subcategory writes finance-only (mirrors MinistriesController).
  Verified live with a member-role account: reads 200, writes 403, self-submit 200.
- MonthlyStatementService Update/Finalize now use FirstOrDefaultAsync for
  convention consistency with the rest of the service layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:14:18 -07:00
Chris Chen e1f99158aa fix(expense): resolve current user id from 'sub' JWT claim
Live verification revealed the JWT carries the user id in the 'sub' claim
(NameClaimType=sub, MapInboundClaims=false), so ClaimTypes.NameIdentifier is
null at runtime. This caused ExpensesController.GetMine/GetById to throw
NullReferenceException (500) on the '!.Value', and made the services fall back
to 'system' — silently defeating the self-ownership guard. Resolve via
NameIdentifier (unit tests) then 'sub' (real tokens). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 19:08:21 -07:00
Chris Chen 95008788f3 feat(expense): wire routes + sidebar nav for expense pages
Also fix kendo-grid [total] binding in expenses-page template by
switching to GridDataResult object form ({ data, total }) on [data].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:59:25 -07:00
Chris Chen f5ff03260b feat(expense): add monthly reconciliation statement page
Implements Task 16 — MonthlyStatementPageComponent with Kendo Grid list
(year filter), create/edit dialog (server-computed totals preview), and
finalize action that locks the statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:56:14 -07:00
Chris Chen aa77f2051a feat(expense): add finance expenses overview + review page 2026-05-29 18:53:39 -07:00
Chris Chen 4704d33b4a fix(expense): authenticated receipt download + correct delete confirm
The receipt <a href target=_blank> was an unauthenticated browser navigation
that the API's [Authorize] rejects with 401. Replace with a HttpClient blob
download (downloadReceipt) so the auth interceptor attaches the JWT, opened
via an object URL. Also fix the delete button: confirm() must run inside the
component method (matching givings-page), not as a template expression where
confirm is not a component member.
2026-05-29 18:51:04 -07:00
Chris Chen 18b9707e44 feat(expense): add member self-service My Reimbursements page
Standalone Angular component (Kendo Grid + ExpenseFormDialog) that lets
any logged-in user list, create, submit, and delete their own draft
reimbursements, with optional receipt upload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:48:42 -07:00
Chris Chen 1bb9da16d4 feat(expense): add reusable expense form dialog with category cascade
Standalone ExpenseFormDialogComponent with Ministry → Category Group → SubCategory
cascade, vendor/reimbursement modes, optional member picker (MemberApiService
search-as-you-type), and receipt file input. Emits CreateExpenseRequest + File.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:46:34 -07:00
Chris Chen 3188064335 feat(expense): add expense categories management page 2026-05-29 18:43:19 -07:00
Chris Chen 04b05617b8 feat(expense): add frontend models + API services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:40:22 -07:00
Chris Chen 9933c180b7 feat(expense): add controllers + register services
Adds ExpenseCategoriesController, ExpensesController, MonthlyStatementsController
and registers IExpenseCategoryService, IExpenseService, IMonthlyStatementService in DI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:37:25 -07:00
Chris Chen 86d9879a6d feat(expense): add MonthlyStatementService with server-side recompute + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:34:39 -07:00
Chris Chen d9289008f6 feat(expense): add ExpenseService with state machine + receipt storage + tests
TDD: wrote 8 tests first (red), then implemented IExpenseService + ExpenseService
covering CRUD, Draft→PendingApproval→Approved→Paid state machine, soft-delete,
per-owner access guards, and receipt blob round-trip via IFileStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:28:38 -07:00
Chris Chen 015f689d9b feat(expense): add ExpenseCategoryService + tests
TDD cycle: wrote 3 xUnit tests first (red), then implemented
IExpenseCategoryService + ExpenseCategoryService (green).
2026-05-29 18:24:07 -07:00
Chris Chen 15cdfe6f92 feat(expense): add expense, category, and monthly-statement DTOs 2026-05-29 18:21:52 -07:00
Chris Chen e7bf07c2ad feat(storage): add IFileStorage + local-disk implementation
Adds IFileStorage abstraction and LocalDiskFileStorage for receipt file storage with path-traversal protection, and registers it in DI. Includes 3 TDD-verified xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:28 -07:00
Chris Chen ac65c68e18 feat(expense): add AddExpenseModule EF migration
Creates Ministries, ExpenseCategoryGroups, ExpenseSubCategories,
Expenses (with filtered Status index, MinistryId/ExpenseDate indexes,
Restrict FKs + SetNull on Member), and MonthlyStatements (unique
Year+Month index) tables. No existing tables modified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:15:16 -07:00
Chris Chen cf929557fe feat(expense): add Expense + MonthlyStatement entities and EF config 2026-05-29 18:11:56 -07:00
Chris Chen cc58d06723 docs(expense): correct subcategory seed count to 39 (matches DB_SCHEMA §8) 2026-05-29 18:10:42 -07:00
Chris Chen b3eb9d297a feat(expense): add expense category entities + seed (11 groups / 38 subs)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:08:12 -07:00
Chris Chen f6f06d841c feat(ministry): add Ministry entity, seed (10), and read endpoint 2026-05-29 18:03:28 -07:00
Chris Chen 50e518095e docs(expense): add expense tracking implementation plan
18 TDD tasks: Ministry prerequisite (entity/seed/endpoint), expense category
entities + seed, Expense + MonthlyStatement entities, EF migration,
IFileStorage + local-disk impl, DTOs, three services with tests, controllers
with auth split, and four Angular pages + nav/routes wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:54:13 -07:00
Chris Chen fdd0d7c8e1 docs(expense): add expense tracking & reimbursement design spec
Covers all five PLANNING §3.6d items: category seed (11 groups/38 subs),
vendor direct payment, staff reimbursement with receipt upload + self-service
submission, finance approval workflow (Draft→PendingApproval→Approved→Paid),
and monthly reconciliation statement. Per DB_SCHEMA §8.

Key decisions: IFileStorage abstraction + local-disk impl for receipts
(Azure Blob deferred), member self-submission alongside finance entry,
soft-delete Expense, cash-basis (Paid-only) monthly expense totals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:23:21 -07:00
Chris Chen 0639d1fe83 WIP 2026-05-28 22:29:13 -07:00
Chris Chen a2d394029a fix(giving): render Finance nav section in UserPortal sidebar
Task 11 added the finance nav to user-navbar.component, but the active
sidebar is rendered inline by UserPortalComponent (app-user-navbar is
not mounted). Added the role-gated Finance section (Offering Entry /
Givings / Giving Types) to UserPortalComponent, matching its
Administration pattern. Verified at runtime: section renders for
super_admin and links load the giving pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 18:22:50 -07:00
Chris Chen 5f8676f962 Merge feature/giving-module: manual giving/donation tracking module
Giving-category config, single giving entry, and keyboard-first Sunday
offering batch entry (OfferingSession) with server-side reconciliation,
lock-after-submit, and finance reopen/replace. 53 backend tests; runtime
E2E verified against dev DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 18:10:40 -07:00
Chris Chen 48885dba83 fix(giving): reset in-buffer line-edit index on reopen/cancel/submit 2026-05-28 17:44:43 -07:00
Chris Chen af21e50d9f feat(giving): add reopen-and-edit flow + recent sessions list to offering page 2026-05-28 17:41:19 -07:00
Chris Chen a573179714 feat(giving): match giver member name in single-giving search (spec §4.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:24:47 -07:00
Chris Chen 66640d1fd0 fix(giving): keep giver name visible when editing a buffered offering line 2026-05-28 17:18:40 -07:00
Chris Chen 001db35cef feat(giving): keyboard-first Sunday offering batch entry page + routes
- Add MemberQuickAddDialogComponent for fast in-session member creation
- Add OfferingSessionPageComponent: client-side buffer, reconcile totals, date-conflict check, submit to API
- Wire finance/giving-categories, finance/givings, finance/offering-session routes (RoleGuard: finance + super_admin)
- Fix givings-page: replace [total] + data[] with GridDataResult for Kendo v20 server-side paging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:14:56 -07:00
Chris Chen 81a0b5a038 feat(giving): single giving entry page 2026-05-28 17:10:07 -07:00
Chris Chen 7260e5c115 feat(giving): giving categories management page
Add GivingCategoriesPageComponent — standalone Angular 18 component with Kendo Grid (list/deactivate) and Kendo Dialog (add/edit form) for managing giving types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:04:55 -07:00
Chris Chen 91247a7c69 feat(giving): add role-gated finance nav section 2026-05-28 17:00:49 -07:00
Chris Chen 4a2b142061 feat(giving): frontend models + API services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:57:15 -07:00
Chris Chen b5a15dd9f2 feat(giving): add offering-sessions controller 2026-05-28 16:54:24 -07:00
Chris Chen 86041c0d05 fix(giving): map duplicate-date race to 409 + return zelle/paypal refs in session detail 2026-05-28 16:53:24 -07:00
Chris Chen e04776460d feat(giving): offering-session batch service with server-side totals + locking
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:47:19 -07:00
Chris Chen 586551aec0 feat(giving): add givings controller 2026-05-28 16:43:06 -07:00
Chris Chen 8ff93e3698 test(giving): cover anonymous member-id stripping and delete-lock guard 2026-05-28 16:42:18 -07:00
Chris Chen 2b6f29e775 feat(giving): single-entry giving service with paging + lock guard
Adds GivingListItemDto, GivingDto, CreateGivingRequest, UpdateGivingRequest DTOs;
IGivingService interface; GivingService implementation with category/date filtering,
OfferingSession lock guard (Submitted/Reconciled), and DI registration in Program.cs.
Covered by 4 xUnit tests (TDD: red → green).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:38:32 -07:00
Chris Chen 81efaedbc2 feat(giving): add giving-categories controller 2026-05-28 16:34:18 -07:00
Chris Chen cb15d30980 refactor(giving): drop unused accessor from category service + add deactivate-missing test 2026-05-28 16:33:27 -07:00
Chris Chen 798dfa3fe0 feat(giving): giving-category service with CRUD + soft-disable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:29:31 -07:00
Chris Chen 8b52572fad feat(giving): add EF migration for giving module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:25:11 -07:00
Chris Chen 577ae1aabe refactor(giving): use AnyAsync in category seed (code-review minor) 2026-05-28 16:21:32 -07:00
Chris Chen e20964ae0d feat(giving): seed default giving categories 2026-05-28 16:19:44 -07:00
Chris Chen 999f8a80f9 feat(giving): add GivingCategory, OfferingSession, Giving entities + EF config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 16:16:24 -07:00
Chris Chen 3974cec967 docs: add implementation plan for giving/donation tracking module
15 bite-sized TDD tasks: entities+EF+seed+migration, three services
(giving-category, single giving, offering-session) with server-side
totals and lock-after-submit, controllers, Angular models/services and
three pages (categories, single entry, keyboard-first batch entry +
quick-add member), role-gated finance nav, and E2E verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:57:47 -07:00
Chris Chen 82b9744024 docs: add design spec for giving/donation tracking module
Manual giving module (Phase 1): giving category config, single-entry
giving, and keyboard-first Sunday offering batch entry (OfferingSession)
with finance-gated reconciliation. Client-buffered bulk submit (decision
B), lock-after-submit, Member-or-anonymous givers with inline quick-add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:47:09 -07:00
Chris Chen a525c71baa WIP 2026-05-28 15:25:31 -07:00
Chris Chen d79b1faa8f fix 401 loop hell 2026-05-27 15:09:05 -07:00
Chris Chen e83fa4c2e9 fix: use RandomNumberGenerator for cryptographic temp password generation
Replaced `new Random()` with `RandomNumberGenerator.GetInt32()` and a
Fisher-Yates shuffle to ensure temp passwords are cryptographically secure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:29:26 -07:00
Chris Chen bc67146d86 feat: add Administration section to sidebar with role-gated Member/User nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:26:07 -07:00
Chris Chen a18d44bd0a feat: add UsersPageComponent with Kendo Grid + edit/deactivate/reset-password
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:24:00 -07:00
Chris Chen 6c3292861a feat: add EditUserDialogComponent 2026-05-27 14:22:13 -07:00
Chris Chen 3a5b5721e4 feat: add MembersPageComponent with Kendo Grid and routing
Also adds stub UsersPageComponent for route compilation, and fixes
pre-existing kendo-textbox type="email" build errors in dialog templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:20:52 -07:00
Chris Chen 07e0270599 feat: add CreateUserAccountDialogComponent with temp-password reveal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:17:15 -07:00
Chris Chen 32e47e4566 feat: add MemberFormDialogComponent (3-tab form)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:15:26 -07:00
Chris Chen d2eac52a47 feat: add Angular member and user models + API services
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:13:35 -07:00
Chris Chen 8249b3fe3e feat: add UsersController and register all services
Adds UsersController with CRUD endpoints (list, get, create, update,
deactivate, reset-password) restricted to super_admin role. Registers
IUserManagementService in Program.cs alongside existing services.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:10:46 -07:00
Chris Chen 3ab0998793 feat: add UserManagementService with temp-password creation and deactivation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:08:50 -07:00
Chris Chen 0986233d9b feat: add MembersController (CRUD + paged list)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:03:23 -07:00
Chris Chen bfffdee2a8 feat: add MemberService with soft-delete and paged search
Implements IMemberService with Create/Read/Update/soft-Delete operations,
NickName/zh-name search, status and hasUser filtering, and full xUnit coverage
(11 tests). Uses separate user-lookup query for InMemory DB compatibility; detaches
entity after soft-delete so query-filter assertions work correctly in tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:00:59 -07:00
Chris Chen 97743f6974 feat: add PagedResult, Member DTOs, and User DTOs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:55:21 -07:00
Chris Chen 34344cbf83 feat: add Member/FamilyUnit DbSets, audit interceptor registration, EF migration
Registers AuditSaveChangesInterceptor in DI and wires it into AppDbContext.
Adds Members and FamilyUnits DbSets with full column/index configuration and
applies the AddMemberAndFamilyUnit migration to the ChurchCRM database.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:52:58 -07:00
Chris Chen cd5413125d feat: add Member and FamilyUnit entities 2026-05-27 13:49:50 -07:00
Chris Chen 820ca6981c feat: add AuditSaveChangesInterceptor and failing interceptor tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:48:03 -07:00
Chris Chen f703519838 fix: use DateTimeOffset instead of DateTime in audit base classes 2026-05-27 13:46:31 -07:00
Chris Chen 5041873c2b feat: add AuditableEntity and SoftDeleteEntity base classes 2026-05-27 13:44:10 -07:00
Chris Chen 61c6697c87 docs: add 3-part implementation plan for Member and User Management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:18:27 -07:00
Chris Chen 5d556b882d docs: add NickName field to Member spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:14:07 -07:00
Chris Chen adad5cb7e9 docs: add AspNetUsers CRUD and Member Management design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:12:07 -07:00
279 changed files with 33605 additions and 475 deletions
+72
View File
@@ -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
+54
View File
@@ -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
+2
View File
@@ -92,3 +92,5 @@ logs/
*.tmp *.tmp
*.temp *.temp
/.claude /.claude
/API/ROLAC.API/bin-verify
API/ROLAC.API/App_Data/
+7
View File
@@ -0,0 +1,7 @@
**/bin
**/obj
**/appsettings.Development.json
**/appsettings.*.local.json
ROLAC.API.Tests/
.git
*.user
+25
View File
@@ -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;
}
+59
View File
@@ -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; }
}
+18
View File
@@ -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; }
}
+21
View File
@@ -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; }
}
+10
View File
@@ -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";
}
+2
View File
@@ -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; }
}
+274 -10
View File
@@ -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();
});
} }
} }
+115
View File
@@ -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;
}
}
}
}
+217
View File
@@ -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
}
+43
View File
@@ -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; } = [];
}
+18
View File
@@ -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; }
}
+26
View File
@@ -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; }
}
+32
View File
@@ -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; }
}
+11
View File
@@ -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; }
}
+23
View File
@@ -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; }
}
+14
View File
@@ -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; }
}
+17
View File
@@ -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; }
}
+32
View File
@@ -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; }
}
+12
View File
@@ -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; }
}
+22
View File
@@ -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; } = [];
}
+39
View File
@@ -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);
}
}
+21
View File
@@ -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
+97 -16
View File
@@ -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;
},
}; };
}); });
@@ -78,14 +115,44 @@ builder.Services.AddCors(opt =>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Application services // Application services
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
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();
} }
app.UseHttpsRedirection(); // TLS is terminated by nginx in production; only redirect in local dev.
if (app.Environment.IsDevelopment())
{
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