Compare commits

153 Commits

Author SHA1 Message Date
Chris Chen 4b949dff9b 更新支票列印
ci-cd-vm / ci-cd (push) Successful in 1m56s
2026-06-27 21:37:40 -07:00
Chris Chen 773d38d838 update view.
ci-cd-vm / ci-cd (push) Successful in 1m59s
2026-06-25 21:55:16 -07:00
Chris Chen d987ddea0e Merge: 1099 Recipient Tracking feature (sub-project B)
ci-cd-vm / ci-cd (push) Successful in 2m57s
Adds a Payee1099 recipient master (encrypted TIN, W-9, optional Member
link), Form1099Box catalog + category->box mapping, a cash-basis year-end
1099 report (per-recipient x box, $600 + missing-W9 flags), recipient
Copy B 1099-NEC PDF + filing CSV, W-9 upload, write-gated TIN reveal, and
a ChurchProfile payer EIN. New Form1099 permission module + admin pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:29:29 -07:00
Chris Chen a4ded78442 feat(1099): show payer EIN on church profile page and 1099-NEC PDF
Populate the PAYER's TIN (EIN) box in Form1099FormService.BuildCopyBHtml
from ChurchProfile.PayerEin (blank when null, matching prior behaviour).
Add payerEin field to ChurchProfileDto TS model (flows into
UpdateChurchProfileRequest via the existing Omit type) and a text input on
the Church Info tab of the church-profile settings page, mirroring the
Routing # field pattern. CSV left unchanged — adding a payer context line
would break the existing ExportFilingCsvAsync assertion (3 lines expected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:23:49 -07:00
Chris Chen 831b868d9d feat(1099): add payer EIN to ChurchProfile (entity, DTO, migration)
Add PayerEin (nullable string, max 20) to ChurchProfile entity, AppDbContext
config, ChurchProfileDto response, UpdateChurchProfileRequest, and
ChurchProfileService round-trip — mirroring the Phone/BankRoutingNumber
nullable-string pattern. Migration AddPayerEinToChurchProfile adds only the
one nullable column to ChurchProfiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:23:40 -07:00
Chris Chen 771889a99a feat(1099): default Is1099Tracked from tax classification
In the recipient dialog, changing tax classification on a NEW record sets the
1099-tracked default: CCorp/SCorp default to NOT tracked (spec §2.1/§2.3),
others to tracked. Only applies until the user manually toggles it; never
overrides an explicit choice or an existing saved value on edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:44 -07:00
Chris Chen 4d396601f7 feat(1099): add reveal-full-TIN button (write-gated) to recipient dialog
Wires the existing GET payee-1099/{id}/tin endpoint into the edit dialog:
a "Reveal full TIN" button guarded by *appHasPermission Form1099:write that
fetches and displays the decrypted TIN read-only. Satisfies acceptance
criterion #11.4. The value is never logged or persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:32 -07:00
Chris Chen d29de83116 feat(1099): wire W-9 document upload/view for recipients
Adds POST/GET payee-1099/{id}/w9, mirroring the expense-receipt upload:
IFileStorage saves to finance/w9/{id}{ext}, content-type derived from the
blob extension. Frontend dialog (edit mode) gains a W-9 file input and an
auth-correct blob "View W-9" link. Payee1099Service ctor now takes
IFileStorage; tests updated with an in-memory FakeStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:11:11 -07:00
Chris Chen ad276c01f3 docs(1099): document Payee1099s/Form1099Boxes schema and seed Form1099 permissions
- DB_SCHEMA.md §8: add Form1099Box catalog table, Payee1099 recipient master
  (with TIN at-rest encryption note), and new FK columns on Expenses /
  ExpenseSubCategories / ExpenseCategoryGroups; update TOC and Seed Data section
- DbSeeder.cs: grant Modules.Form1099 to finance (R/W/D), pastor (R), and
  board_member (R), mirroring the Form990Report + Disbursements pattern;
  idempotent (only inserts if row absent, never clobbers admin edits)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:56:09 -07:00
Chris Chen fb95bf0048 feat(1099): add 1099 recipient picker to expense form
Add optional payeeId to CreateExpenseRequest + ExpenseListItemDto
frontend models. In the expense form dialog: inject Payee1099ApiService,
load active payees on init, add payeeId to the form state, pre-populate
it from expense.payeeId in edit mode, and include it in the emitSave
payload. Render a "1099 Recipient / 1099 收款人" Kendo DropdownList
(textField=legalName, valueField=id, [valuePrimitive]="true",
md:col-span-2) inside the vendor-mode ng-container below Vendor Name
and Check #.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:50:25 -07:00
Chris Chen d8e6f3ed61 feat(1099): add 1099 box dropdowns to category admin page
Mirror the 990-line dropdown in both the group and subcategory edit
dialogs: add form1099BoxId to the frontend group/subcategory DTOs and
request interfaces, load boxes via a new getForm1099Boxes() method on
ExpenseCategoryApiService (same label pattern as getForm990Lines:
"boxCode — name_en / name_zh"), wire form1099BoxId into all
open/edit/save paths, and render a side-by-side "1099 Box / 1099 框"
Kendo DropdownList with [valuePrimitive]="true" and "— none —" default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:50:12 -07:00
Chris Chen 402826ee3d feat(1099): round-trip Form1099BoxId through expense category DTOs/service
Mirror Form990LineId: add Form1099BoxId + Form1099BoxCode to all four
category DTOs (response + request, group + sub); load a boxCodes lookup
dictionary in GetAllAsync and project it; set/copy the field in
CreateGroupAsync, UpdateGroupAsync, CreateSubCategoryAsync, and
UpdateSubCategoryAsync. All 4 category-service unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:46:31 -07:00
Chris Chen 82096e7e6f feat(1099): 1099 year-end report page with drill-in, CSV, Copy B
Add Form1099ReportPageComponent (year selector, summary chips with a
prominent missing-W-9 flag, desktop grid + mobile cards, recipient detail
dialog). Per-row Copy B PDF via right-click context menu and a header
Export filing CSV action, both downloaded as auth-correct blobs. Wire the
eager route + sidebar nav item, gated on Form1099:read. Also convert the
neighboring finance/payee-1099 route from lazy loadComponent to an eager
component import so both 1099 routes match the surrounding convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:39:20 -07:00
Chris Chen 6ffaaf37ac feat(1099): add authenticated blob downloads to report API service
Add downloadCsv/downloadCopyB returning Blob via HttpClient so the auth
interceptor attaches the bearer token (raw window.open would 401). Remove
the now-unused copyBUrl/exportCsvUrl raw-URL builders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:38:47 -07:00
Chris Chen d1747b510e feat(1099): 1099 recipients master page with nav + route
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:31:48 -07:00
Chris Chen bf247726e1 feat(1099): frontend models, API services, and permission module entry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:26:57 -07:00
Chris Chen 8cb6245560 feat(1099): add 1099 Copy B + filing CSV download endpoints
Injects I1099FormService into Form1099ReportController and adds two
Read-gated GET endpoints: recipient/{payeeId}/copy-b (Copy B PDF) and
export-csv (filing-data CSV). Registers Form1099FormService in DI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:21:14 -07:00
Chris Chen b7eb95056d feat(1099): add I1099FormService with filing CSV export + Copy B PDF
Adds I1099FormService and Form1099FormService: an IRIS/accountant filing-data
CSV (one row per reportable recipient) and a plain-paper recipient Copy B
1099-NEC PDF rendered via the DevExpress RichEdit/Office API (mirroring
CheckPrintService). Includes a CSV-export unit test over a stub report service.

Service lives in namespace ROLAC.API.Services (not ...Services.Form1099) to
avoid shadowing the ROLAC.API.Entities.Form1099 constants class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:21:04 -07:00
Chris Chen 556abba687 feat(1099): EF migration for Payee1099, Form1099Box, mapping columns
Creates Form1099Boxes and Payee1099s tables; adds Form1099BoxId to
ExpenseSubCategories and ExpenseCategoryGroups; adds PayeeId to
Expenses. All new columns nullable, all FKs with SetNull, unique index
on Form1099Boxes.BoxCode. No data backfill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:14:57 -07:00
Chris Chen 1a8002015a feat(1099): seed Form1099Box catalog and default subcategory mappings
Adds Form1099BoxSeed (NEC-1, MISC-1) and Form1099SubMappingSeed
(6 service/rent subcategories), SeedForm1099BoxesAsync method with
null-fill idempotency (never clobbers admin edits), and wires it into
SeedAsync after SeedForm990ExpenseLinesAsync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:13:44 -07:00
Chris Chen 7c63f6c9ba feat(1099): carry PayeeId through expense create/update/read
Add int? PayeeId to CreateExpenseRequest (UpdateExpenseRequest inherits)
and to ExpenseListItemDto (so it round-trips to the form). Set e.PayeeId
unconditionally in CreateAsync and UpdateAsync so 1099 attribution is
independent of VendorPayment vs StaffReimbursement type. Map PayeeId in
both DTO projections: the paged-list lambda and GetByIdAsync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:08:01 -07:00
Chris Chen 7c5348969b feat(1099): add recipient and report controllers
Payee1099Controller (api/payee-1099): CRUD + TIN reveal, class-level
Read gate, method-level Write/Delete overrides — mirrors the
HasPermission class+method stacking pattern from ExpensesController.
Form1099ReportController (api/form1099-report): boxes, annual summary,
and per-recipient detail; read-only, no method-level overrides needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:06:49 -07:00
Chris Chen 0a9b82544d feat(1099): register Form1099 permission module and services
Add Form1099 const to Modules.cs (after Form990Report) and insert it
into the All display-order list. Register IForm1099ReportService and
IPayee1099Service in Program.cs beside the existing Form990Report entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:06:03 -07:00
Chris Chen 6080946e74 feat(1099): add Payee1099Service recipient CRUD with TIN protection
Implement IPayee1099Service and Payee1099Service: list/get/create/update/
soft-delete and RevealTin. TIN is encrypted via ITinProtector on write;
TinLast4 is the only clear-text fragment stored. Null Tin on update
preserves the existing ciphertext. Four xUnit tests cover encrypt-on-create,
null-tin-keeps-ciphertext, list-masks-to-last4, and soft-delete hides from list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:02:45 -07:00
Chris Chen 560fb79bf0 feat(1099): add recipient DTOs
Add Payee1099ListItemDto, Payee1099Dto, and SavePayee1099Request in
DTOs/Payee for the 1099 recipient CRUD surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 17:02:33 -07:00
Chris Chen 0767a3fe94 refactor(1099): materialize report query for Npgsql safety; deterministic year + ordering
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:58:28 -07:00
Chris Chen 0754ed8d69 feat(1099): add Form1099ReportService cash-basis annual aggregation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:53:28 -07:00
Chris Chen 9aa64b5f4c feat(1099): add report and recipient DTOs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:53:15 -07:00
Chris Chen 5e2fbe800c feat(1099): add ITinProtector with Data Protection encryption + last-4 helper
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:47:49 -07:00
Chris Chen 89238bba99 fix(1099): pin max-lengths on Payee1099/Form1099Box columns to match codebase
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:45:02 -07:00
Chris Chen 225e64b992 feat(1099): configure Payee1099, Form1099Box, and mapping FKs in DbContext
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:40:49 -07:00
Chris Chen 7809ba9741 feat(1099): add Form1099BoxId mapping FKs and Expense.PayeeId
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:40:40 -07:00
Chris Chen 48ae014def feat(1099): add Payee1099 recipient master entity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:40:34 -07:00
Chris Chen 89f02d020b feat(1099): add Form1099Box catalog entity
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:40:30 -07:00
Chris Chen 3b76ff43fc feat(1099): add Form1099 constants (threshold, box codes, W9 statuses)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:40:22 -07:00
Chris Chen a0b96b056a docs(1099): implementation plan for sub-project B
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:21:17 -07:00
Chris Chen 93374c3c0a docs(1099): design spec for sub-project B — 1099 recipient tracking
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 16:16:04 -07:00
Chris Chen 55543af5e1 checks
ci-cd-vm / ci-cd (push) Successful in 2m17s
2026-06-25 15:51:52 -07:00
Chris Chen d32eea3523 Merge: Vendor Payment Snapshot feature
ci-cd-vm / ci-cd (push) Successful in 2m21s
Save a vendor payment as a reusable named snapshot and re-apply it later,
pre-filling every field except the Expense Date. Shared church-wide with a
creator tag; quick picker in the vendor form + a management page (rename/delete).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:26:59 -07:00
Chris Chen 099303995b fix(expense-snapshot): gate page on Expenses:write to match the write-only API
The snapshot management page backs an API that gates every action on
Expenses:Write, so a read-only user reaching it via a read-gated nav/route
would hit a silent 403 and a blank page. Require write for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:21:11 -07:00
Chris Chen 44a7dcf089 refactor(expense-form): remove dead empty per-line AI assist scaffold
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:18:28 -07:00
Chris Chen a8f5547c3c feat(expense-snapshot): route + sidebar nav for snapshot management
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:15:20 -07:00
Chris Chen 41dce076d6 feat(expense-snapshot): snapshot management page (rename/delete)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:14:16 -07:00
Chris Chen 315d85ddcc feat(expense-snapshot): load/save snapshot in vendor payment form
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:13:09 -07:00
Chris Chen bc827e8b60 feat(expense-snapshot): frontend model + api service with tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:11:32 -07:00
Chris Chen 8922bb69de fix(expense-snapshot): validate functional class + stamp DeletedBy on soft delete
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:07:55 -07:00
Chris Chen 4877fec1da feat(expense-snapshot): REST controller + DI registration 2026-06-25 15:01:38 -07:00
Chris Chen 73c52ded88 feat(expense-snapshot): snapshot service with creator-name resolution + tests 2026-06-25 15:00:36 -07:00
Chris Chen f1de8d7ab7 feat(expense-snapshot): add snapshot DTOs 2026-06-25 14:58:53 -07:00
Chris Chen 5957d0f45e feat(expense-snapshot): register snapshot tables + EF migration 2026-06-25 14:58:18 -07:00
Chris Chen c5405a95c3 feat(expense-snapshot): add ExpenseSnapshot + ExpenseSnapshotLine entities 2026-06-25 14:55:50 -07:00
Chris Chen 5d03e42302 docs(expense-snapshot): implementation plan + spec read-time creator-name refinement
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:45:08 -07:00
Chris Chen d4c20df34f docs(expense-snapshot): design spec for vendor payment snapshot
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:37:00 -07:00
Chris Chen 73077295a4 feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s
Add an AI assist button to the Edit/New Group (大項) and Subcategory
(小項) dialogs: the user enters a Chinese name, and the model refines
the Chinese, translates it to English, and suggests the matching IRS
Form 990 Part IX line. Suggestions surface in a confirm card; Apply
fills the Chinese name, English name, and 990 line fields.

Backend mirrors the existing expense-classification AI family but over
the Form 990 line catalog: IExpenseCategoryAiService + base (catalog
load, prompt, id validation) + Claude/Gemini providers + factory that
picks the provider from ChurchProfile.AiProvider. New write-gated
POST api/expense-categories/ai-suggest endpoint; sub-category requests
pass the parent group + its 990 line to bias the choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:18:34 -07:00
Chris Chen c5b1a9372a test(ai): cover config-provider default fallback when no profile row 2026-06-25 13:34:20 -07:00
Chris Chen ece2676e38 style(church-profile): lead AI tab title with English for consistency
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:32:53 -07:00
Chris Chen 26259c252d feat(church-profile): AI 設定 tab (provider/model/key) with masked keys 2026-06-25 13:29:52 -07:00
Chris Chen 120240ad0c feat(ai): DB-only config + runtime provider selection via factory 2026-06-25 13:23:13 -07:00
Chris Chen ece9938bfb feat(ai): add DB-backed church AI config provider 2026-06-25 13:18:04 -07:00
Chris Chen a16e21dbfd feat(church-profile): masked-read + leave-unchanged write for AI keys 2026-06-25 13:13:42 -07:00
Chris Chen 75905e7036 feat(church-profile): add AI provider/model/key columns + migration 2026-06-25 13:07:30 -07:00
Chris Chen bcaa3e2f25 Add implementation plan: Church Profile AI Settings tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:03:53 -07:00
Chris Chen 5448a9ff85 Add design spec: Church Profile AI Settings tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 12:55:39 -07:00
Chris Chen bdccb79029 WIP 2026-06-25 12:47:14 -07:00
Chris Chen a89e936f4d Implement AI 2026-06-25 11:11:26 -07:00
Chris Chen fa3e75a333 add approve.
ci-cd-vm / ci-cd (push) Successful in 2m24s
2026-06-25 10:22:01 -07:00
Chris Chen 8bdb942a49 update detail.
ci-cd-vm / ci-cd (push) Successful in 4m27s
2026-06-25 09:33:49 -07:00
Chris Chen 609ce6a439 WIP
ci-cd-vm / ci-cd (push) Successful in 1m49s
2026-06-24 21:47:22 -07:00
Chris Chen 46a4298a71 WIP 2026-06-24 21:37:41 -07:00
Chris Chen 9f91683633 docs: sync DB_SCHEMA with Form 990 functional-expense schema
ci-cd-vm / ci-cd (push) Successful in 2m41s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:54:00 -07:00
Chris Chen 5aaac3246d feat(web): Form 990 functional-expenses report page, route, and nav
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:49:02 -07:00
Chris Chen 677cb8f054 feat(web): default functional class on the ministry form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:45:40 -07:00
Chris Chen f79dab163d feat(web): functional-class override on the expense form 2026-06-24 19:43:03 -07:00
Chris Chen 4438c351e2 feat(web): map expense categories to Form 990 lines in the category admin page
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:38:17 -07:00
Chris Chen 1a03a1cbba feat(finance): expose Form 990 line catalog endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:36:01 -07:00
Chris Chen 3f61e9ceaf feat(web): add Form990Report permission and expense functional-class/line fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:33:37 -07:00
Chris Chen b41297f972 feat(finance): Form 990 functional-expenses report endpoint 2026-06-24 19:30:33 -07:00
Chris Chen a5de2dbbb1 feat(finance): Form 990 Part IX functional-expense aggregation service
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:26:59 -07:00
Chris Chen 1fa36ae62f fix(finance): make Form990 row DTO use properties (System.Text.Json skips fields) 2026-06-24 19:23:54 -07:00
Chris Chen 1353b5571f feat(finance): add Form 990 report DTOs and permission module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:23:05 -07:00
Chris Chen 4e83f27703 feat(seed): default Administration ministry to Management & General
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:21:04 -07:00
Chris Chen d5e1732505 feat(seed): seed Form 990 line catalog and default subcategory mappings 2026-06-24 19:17:51 -07:00
Chris Chen ae757bee3d test(seed): use Assert.Single predicate overload (xUnit2031) 2026-06-24 19:15:10 -07:00
Chris Chen 6e04b64466 feat(seed): add IT/Professional/Finance categories and rename overlapping subcategories
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:12:57 -07:00
Chris Chen f70a7b5a58 feat(db): migration for Form 990 lines, category mapping, functional class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 19:09:00 -07:00
Chris Chen b6b110254a feat(expense): add per-expense FunctionalClass override 2026-06-24 19:05:07 -07:00
Chris Chen d3e6b5aed5 feat(ministry): add DefaultFunctionalClass for Form 990 functional split 2026-06-24 19:00:36 -07:00
Chris Chen ac84097254 test(expense): assert Form990LineCode projection resolves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:57:14 -07:00
Chris Chen 971bf165cc feat(expense): map category group/subcategory to Form 990 lines 2026-06-24 18:53:13 -07:00
Chris Chen f1faa0d435 feat(expense): add Form990ExpenseLine catalog entity and functional-class constants 2026-06-24 18:47:42 -07:00
Chris Chen 9dbb1d38d8 WIP 2026-06-24 18:45:22 -07:00
Chris Chen e908e35530 docs: implementation plan for sub-project A (Form 990 functional expenses)
17 TDD tasks: Form990ExpenseLine catalog + category mapping, Ministry
DefaultFunctionalClass, Expense FunctionalClass override, EF migration,
seed (new categories/renames/line catalog/mappings/ministry defaults),
Form990ReportService Part IX aggregation + controller, and the frontend
(category line mapping, expense + ministry functional-class controls,
report page/route/nav). DB_SCHEMA sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:41:35 -07:00
Chris Chen b51f22cfba docs: expand 990 expense-line catalog and add categories to cover gaps
Add 990 lines 5/8/11b/11c/20 to the catalog and new natural categories
(Personnel officer comp + pension, Missions foreign support, Printing
advertising, plus Professional Services / Information Technology /
Finance & Banking groups) so the category tree covers the common Part IX
lines instead of dumping uncovered lines into 24.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:30:49 -07:00
Chris Chen 764464e785 docs: spec for sub-project A — expense 990 functional expenses (Part IX)
Audit-readiness at Form 990 Part IX level: functional class (Program/M&G/
Fundraising) via Ministry default + per-expense override, Form990ExpenseLine
catalog + subcategory/group mapping, duplicate-category cleanup, and the
Part IX functional-expense matrix report. 1099 (B) and revenue Part VIII (C)
are separate specs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:24:17 -07:00
Chris Chen cfd344f48c Update dashboard.component.html
ci-cd-vm / ci-cd (push) Successful in 1m45s
2026-06-24 12:34:56 -07:00
Chris Chen 4dc7ff7df7 Update member.model.ts
ci-cd-vm / ci-cd (push) Successful in 1m38s
2026-06-24 12:07:02 -07:00
Chris Chen e9aad74df6 update quick add.
ci-cd-vm / ci-cd (push) Successful in 1m40s
2026-06-24 12:01:55 -07:00
Chris Chen e768f53ccc feat(giving): show Sunday attendance per session and add edit action 2026-06-24 11:40:44 -07:00
Chris Chen b0e2e112fc feat(giving): add sundayAttendanceCount model field and attendance setCounts API
ci-cd-vm / ci-cd (push) Successful in 2m21s
2026-06-24 11:35:34 -07:00
Chris Chen 28eba8a3ea feat(giving): include Sunday attendance total in offering session list 2026-06-24 11:24:31 -07:00
Chris Chen 7eb6a4db78 feat(attendance): add PUT /api/meal-attendance/{date} to overwrite a Sunday's counts 2026-06-24 11:18:27 -07:00
Chris Chen 7dc03f3bc0 docs(attendance): explain SetCountsAsync divergence from ExecuteUpdate path 2026-06-24 11:17:19 -07:00
Chris Chen 8d91bbeb31 feat(attendance): add SetCountsAsync to set all three age groups for a date 2026-06-24 11:14:09 -07:00
Chris Chen 182f8bf74c Merge branch 'feature/member-invitation-links'
ci-cd-vm / ci-cd (push) Successful in 2m31s
Add member invitation links: passwordless first login with forced password
set. Admins generate a single-use, 7-day link (copy or email); the member
opens it to set their own password and is logged straight in. Auto-creates a
passwordless account for members without one; re-issuing revokes prior links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:54:55 -07:00
Chris Chen a88567fea6 Track AddUserInvitations migration files
Force-add the EF migration excluded by the Migrations/ gitignore rule, so
the UserInvitations table migration is versioned alongside the feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:54:45 -07:00
Chris Chen e53cea7a82 Add init link. 2026-06-24 10:53:13 -07:00
Chris Chen e88ea7917f add church profile.
ci-cd-vm / ci-cd (push) Successful in 2m31s
2026-06-24 08:21:31 -07:00
Chris Chen 99585a1c0e Update dashboard.component.ts
ci-cd-vm / ci-cd (push) Successful in 3m0s
2026-06-23 20:38:11 -07:00
Chris Chen d327a5146c Merge branch 'feature/change-password' 2026-06-23 20:36:26 -07:00
Chris Chen 4276ca890b WIP 2026-06-23 20:36:18 -07:00
Chris Chen 3a121f6085 fix(account): add Account Settings to real sidebar nav
The Settings item wired in Task 7 lived in UserHeaderComponent, which is
unused dead code (its selector is rendered nowhere). Add a real "Account
Settings" entry to the Personal nav section of UserPortalComponent (the
actual shell) pointing at /user-portal/account, and revert the ineffective
user-header edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:26:17 -07:00
Chris Chen 5a25b33258 fix(account): show new!=current error under New Password field
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:21:52 -07:00
Chris Chen b0deb62c82 update sunday 2026-06-23 20:20:12 -07:00
Chris Chen a2ecc895de feat(account): add Account Settings page, route, and wire Settings menu item 2026-06-23 20:15:56 -07:00
Chris Chen 1e6ddddf1f feat(account): add ChangePasswordFormComponent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:10:58 -07:00
Chris Chen c54adf1eda feat(auth): add changePassword() to frontend AuthService 2026-06-23 20:04:18 -07:00
Chris Chen 5e0348de1d feat(account): add password strength and match validators 2026-06-23 20:01:16 -07:00
Chris Chen 8f18166dbf feat(auth): add POST /api/auth/change-password endpoint 2026-06-23 19:54:20 -07:00
Chris Chen 8f1af536ed fix(auth): make change-password session revocation null-safe for Npgsql 2026-06-23 19:52:21 -07:00
Chris Chen 180dea60c1 feat(auth): add ChangePasswordAsync with other-session revocation and audit 2026-06-23 19:47:43 -07:00
Chris Chen 9df391b42c feat(auth): add PasswordChanged audit action and ChangePasswordRequest DTO 2026-06-23 19:44:23 -07:00
Chris Chen 4225b49e58 Merge feature/notification-service: Email + Line notification service (API)
ci-cd-vm / ci-cd (push) Successful in 2m29s
2026-06-23 19:37:35 -07:00
Chris Chen 5a915ebdd1 Harden notifications: bump MailKit, bound webhook body, share truncation, skip soft-deleted members 2026-06-23 19:29:23 -07:00
Chris Chen fd71f5a107 Cross-link implemented notification design in NOTIFICATIONS.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:23:22 -07:00
Chris Chen 9405914d88 Register notification services and add SMTP/Line config sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:21:47 -07:00
Chris Chen 39432ac588 Add admin NotificationsController for binding, groups, history, and send 2026-06-23 19:20:28 -07:00
Chris Chen 4c22cfaf19 Add Line webhook controller with signature verification and dispatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:18:50 -07:00
Chris Chen c8bc7103ba Add LineNotificationService with send, binding, and group ops
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:17:10 -07:00
Chris Chen 3eeb314dc2 Add IMessageChannel and Line REST implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:13:42 -07:00
Chris Chen 0ddb34dd20 Add EmailService with recipient resolution and logging
TDD: IEmailService interface, EmailService resolves member emails + raw addresses (case-insensitive dedup), sends via ISmtpDispatcher, writes a NotificationLog per recipient (sent/failed), and never aborts the batch on a single failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:11:13 -07:00
Chris Chen 444cc70b56 Add SMTP dispatcher seam and MailKit implementation 2026-06-23 19:08:30 -07:00
Chris Chen 85bf329d93 Add Line webhook signature verification helper
Implements LineSignature.IsValid() using HMAC-SHA256 + FixedTimeEquals to prevent timing attacks; includes xUnit tests for valid, tampered, and null/empty header cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:07:01 -07:00
Chris Chen 3544b6ee78 Add change-password implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:04:09 -07:00
Chris Chen 0e90f19377 Add notification entities, DbContext config, and migration
Creates MemberChannelBinding, LineBindingCode, MessagingGroup, and NotificationLog
entities under ROLAC.API.Entities.Notifications; wires DbSets and fluent config into
AppDbContext; generates EF migration AddNotifications creating the four tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:03:35 -07:00
Chris Chen f9c4d7edb2 Add shared notification models, records, and constants 2026-06-23 19:00:24 -07:00
Chris Chen b7372dec1f Add MailKit package and notification option classes 2026-06-23 18:58:41 -07:00
Chris Chen 21e9823008 Add self-service change-password design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:58:03 -07:00
Chris Chen 583408032d Add implementation plan for Email + Line notification service
12 TDD tasks: MailKit package, entities + migration, email service (SMTP seam),
Line message channel + signature verify, Line notification service (send/binding/
groups), webhook + admin controllers, DI + config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:56:04 -07:00
Chris Chen ea0ea233a8 Add Email + Line notification service design spec
Phase 1 (API-only): IEmailService (MailKit/SMTP) + ILineNotificationService
(full approved Line module) as two peer services sharing NotificationLog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:46:44 -07:00
Chris Chen 7356d0e810 Update attendance-counter-page.component.scss
ci-cd-vm / ci-cd (push) Failing after 1m25s
2026-06-23 17:47:16 -07:00
Chris Chen b1e3e23325 Sunday Worship Count
ci-cd-vm / ci-cd (push) Failing after 1m28s
2026-06-23 17:24:12 -07:00
Chris Chen a298d0ee1c update for signalR
ci-cd-vm / ci-cd (push) Successful in 44s
2026-06-23 17:12:17 -07:00
Chris Chen 249ae1164d Update rolac.conf
ci-cd-vm / ci-cd (push) Successful in 44s
2026-06-23 17:03:20 -07:00
Chris Chen c6e3f1db64 Revert "Update rolac.conf"
ci-cd-vm / ci-cd (push) Successful in 39s
This reverts commit bd722933dc.
2026-06-23 16:51:43 -07:00
Chris Chen bd722933dc Update rolac.conf
ci-cd-vm / ci-cd (push) Successful in 46s
2026-06-23 16:10:14 -07:00
Chris Chen f6277aa339 Update angular.json
ci-cd-vm / ci-cd (push) Successful in 1m45s
2026-06-23 15:16:19 -07:00
Chris Chen 2e226e60f5 update mobile view.
ci-cd-vm / ci-cd (push) Failing after 1m13s
2026-06-23 14:18:55 -07:00
Chris Chen 68649223d9 update mobile view. 2026-06-23 14:15:20 -07:00
Chris Chen 9d7c224ad2 Update user-portal.component.scss 2026-06-23 13:53:57 -07:00
Chris Chen 47aec287aa update mobile view for expense. 2026-06-23 13:49:38 -07:00
Chris Chen 5dfca873dd update for shrink image size.
ci-cd-vm / ci-cd (push) Successful in 1m23s
2026-06-23 13:30:20 -07:00
Chris Chen 62592c29ae Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s
2026-06-23 12:13:47 -07:00
Chris Chen 870eeec82a Add role control 2026-06-23 07:19:08 -07:00
372 changed files with 45642 additions and 1104 deletions
+33
View File
@@ -50,3 +50,36 @@ jobs:
docker compose up -d docker compose up -d
sleep 5 sleep 5
curl -fsS http://localhost:8080/api/health curl -fsS http://localhost:8080/api/health
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
- name: Notify Rocket.Chat
if: always()
env:
JOB_STATUS: ${{ job.status }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
run: |
if [ "$JOB_STATUS" = "success" ]; then
STATUS_TEXT="✅ Build succeeded"
COLOR="#2ecc71"
else
STATUS_TEXT="❌ Build failed"
COLOR="#e74c3c"
fi
SHORT_SHA="${SHA:0:7}"
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
{
"attachments": [
{
"title": "$REPO — $STATUS_TEXT",
"title_link": "$COMMIT_URL",
"color": "$COLOR",
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
}
]
}
JSON
+38
View File
@@ -52,3 +52,41 @@ jobs:
docker compose pull docker compose pull
docker compose up -d docker compose up -d
curl -fsS https://manage.rolac.org/api/health curl -fsS https://manage.rolac.org/api/health
# Always runs (success or failure) so the team gets a build result in Rocket.Chat.
# A failed or skipped upstream job (skipped means an earlier job failed) reports as failed.
notify:
needs: [test, build-push, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Notify Rocket.Chat
env:
BUILD_FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
SHA: ${{ github.sha }}
ACTOR: ${{ github.actor }}
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}
WEBHOOK: ${{ secrets.ROCKETCHAT_WEBHOOK }}
run: |
if [ "$BUILD_FAILED" = "true" ]; then
STATUS_TEXT="❌ Build failed"
COLOR="#e74c3c"
else
STATUS_TEXT="✅ Build succeeded"
COLOR="#2ecc71"
fi
SHORT_SHA="${SHA:0:7}"
curl -fsS -X POST -H 'Content-Type: application/json' --data @- "$WEBHOOK" <<JSON
{
"attachments": [
{
"title": "$REPO — $STATUS_TEXT",
"title_link": "$COMMIT_URL",
"color": "$COLOR",
"text": "Branch *$REF* · commit $SHORT_SHA · by $ACTOR"
}
]
}
JSON
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Moq;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Authorization;
public class PermissionAuthorizationHandlerTests
{
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = roles.Select(role => new Claim("role", role));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static async Task<bool> EvaluateAsync(
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
{
var handler = new PermissionAuthorizationHandler(permissions);
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
await handler.HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
{
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
Assert.True(succeeded);
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RoleWithPermission_Succeeds()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
.ReturnsAsync(true);
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
Assert.True(succeeded);
}
[Fact]
public async Task RoleWithoutPermission_Fails()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
Assert.False(succeeded);
}
}
@@ -26,7 +26,7 @@ public class AuditInterceptorTests
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>(); var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AuditSaveChangesInterceptor(mock.Object); return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
} }
[Fact] [Fact]
@@ -22,6 +22,7 @@
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Moq; using Moq;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
using Xunit; using Xunit;
@@ -33,7 +34,8 @@ public class AuthServiceTests
private static Mock<UserManager<AppUser>> BuildUserManager( private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null, AppUser? findResult = null,
bool passwordOk = true, bool passwordOk = true,
IList<string>? roles = null) IList<string>? roles = null,
IdentityResult? changePasswordResult = null)
{ {
var store = new Mock<IUserStore<AppUser>>(); var store = new Mock<IUserStore<AppUser>>();
// Remaining ctor params are all optional; Moq passes them via reflection. // Remaining ctor params are all optional; Moq passes them via reflection.
@@ -52,6 +54,9 @@ public class AuthServiceTests
.ReturnsAsync(roles ?? new List<string> { "member" }); .ReturnsAsync(roles ?? new List<string> { "member" });
mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>())) mgr.Setup(m => m.UpdateAsync(It.IsAny<AppUser>()))
.ReturnsAsync(IdentityResult.Success); .ReturnsAsync(IdentityResult.Success);
mgr.Setup(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
return mgr; return mgr;
} }
@@ -72,11 +77,21 @@ public class AuthServiceTests
return svc; return svc;
} }
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
private static Mock<IPermissionService> BuildPermissionService()
{
var svc = new Mock<IPermissionService>();
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, ModuleActions>());
return svc;
}
private static AuthService BuildSut( private static AuthService BuildSut(
Mock<UserManager<AppUser>> umMock, Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock, Mock<ITokenService> tsMock,
AppDbContext db) AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildConfig()); => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Login tests // Login tests
@@ -154,6 +169,48 @@ public class AuthServiceTests
um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once); um.Verify(m => m.UpdateAsync(It.Is<AppUser>(u => u.LastLoginAt != null)), Times.Once);
} }
[Fact]
public async Task Login_LinkedMember_ReturnsMemberInfo()
{
var db = BuildDb();
db.Members.Add(new Member
{
Id = 7,
NickName = "Johnny",
FirstName_en = "John",
LastName_en = "Chen",
LastName_zh = "陳",
CreatedBy = "seed",
UpdatedBy = "seed",
});
await db.SaveChangesAsync();
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = 7 };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, db);
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.NotNull(response.User.MemberInfo);
Assert.Equal(7, response.User.MemberInfo!.Id);
Assert.Equal("Johnny", response.User.MemberInfo.NickName);
Assert.Equal("Chen", response.User.MemberInfo.LastName_en);
}
[Fact]
public async Task Login_AdminOnlyAccount_ReturnsNullMemberInfo()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true, MemberId = null };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var (response, _) = await sut.LoginAsync(new LoginRequest { Email = "a@b.com", Password = "P@ssw0rd!" });
Assert.Null(response.User.MemberInfo);
}
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Refresh tests // Refresh tests
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -255,4 +312,85 @@ public class AuthServiceTests
var token = db.RefreshTokens.Single(); var token = db.RefreshTokens.Single();
Assert.NotNull(token.RevokedAt); Assert.NotNull(token.RevokedAt);
} }
// -----------------------------------------------------------------------
// Change password tests
// -----------------------------------------------------------------------
[Fact]
public async Task ChangePassword_ValidRequest_Succeeds()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
Assert.True(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
}
[Fact]
public async Task ChangePassword_UnknownUser_Fails()
{
var um = BuildUserManager(findResult: null);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
Assert.False(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
Assert.False(result.Succeeded);
}
[Fact]
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
var db = BuildDb();
// Current session token (raw "current-raw" => "hash:current-raw")
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:current-raw",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
// Another active session on a different device
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:other-device",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-2),
});
await db.SaveChangesAsync();
var sut = BuildSut(um, ts, db);
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
Assert.Null(current.RevokedAt); // current session preserved
Assert.NotNull(other.RevokedAt); // other session revoked
}
} }
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Services.Ai;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchAiConfigProviderTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
[Fact]
public async Task GetAsync_returns_defaults_when_no_profile_row()
{
using var db = NewDb(); // empty DB, no ChurchProfile
var cfg = await new ChurchAiConfigProvider(db).GetAsync();
Assert.Equal("Claude", cfg.Provider);
Assert.Equal("claude-haiku-4-5-20251001", cfg.ClaudeModel);
Assert.Equal("gemini-2.5-flash-lite", cfg.GeminiModel);
Assert.Null(cfg.ClaudeApiKey);
Assert.Null(cfg.GeminiApiKey);
}
}
@@ -0,0 +1,103 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchProfileServiceTests
{
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
// interceptor the app uses so seeded entities save cleanly.
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
.Options);
}
private static UpdateChurchProfileRequest Req(
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
string? claudeModel = "m", string? geminiModel = "m") =>
new()
{
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
ClaudeModel = claudeModel, GeminiModel = geminiModel,
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
};
[Fact]
public async Task GetAsync_masks_stored_api_keys()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
});
await db.SaveChangesAsync();
var dto = await new ChurchProfileService(db).GetAsync();
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
}
[Fact]
public async Task UpdateAsync_blank_key_keeps_existing()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_nonblank_key_replaces()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_sets_provider_and_models()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("Gemini", p.AiProvider);
Assert.Equal("claude-x", p.ClaudeModel);
Assert.Equal("gemini-y", p.GeminiModel);
}
}
@@ -0,0 +1,142 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DbSeederForm990Tests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
{
using var db = BuildDb();
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
db.ExpenseCategoryGroups.Add(fnb);
await db.SaveChangesAsync();
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
await db.SaveChangesAsync();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
var groups = await db.ExpenseCategoryGroups.ToListAsync();
Assert.Contains(groups, g => g.Name_en == "Professional Services");
Assert.Contains(groups, g => g.Name_en == "Information Technology");
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
Assert.Single(groups, g => g.Name_en == "Professional Services");
}
[Fact]
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
{
using var db = BuildDb();
await DbSeeder.SeedMinistriesAsync(db);
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
Assert.Equal("Program", worship.DefaultFunctionalClass);
// Activity/shepherding ministries are an attribution axis only; they default to Program
// so adding them never distorts the 990 functional columns.
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
Assert.Equal("Program", specialEvents.DefaultFunctionalClass);
}
[Fact]
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Salary & Wages");
Assert.Equal("7", salary.Form990Line!.LineCode);
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Accounting & Audit");
Assert.Equal("11c", audit.Form990Line!.LineCode);
}
[Fact]
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
async Task<string> CodeOf(string subEn) =>
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
// Newly mapped subcategories that previously fell through to line 24.
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
Assert.Equal("13", await CodeOf("Rental"));
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
Assert.Equal("13", await CodeOf("Craft Supplies"));
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
Assert.Equal("24", await CodeOf("Gifts"));
// Visitation is a travel/program cost, not a grant to an individual.
Assert.Equal("17", await CodeOf("Visit Expenses"));
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
Assert.Equal("2", await CodeOf("Missionary Support"));
}
[Fact]
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
await db.SaveChangesAsync();
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await db.Entry(visit).ReloadAsync();
await db.Entry(missionary).ReloadAsync();
await db.Entry(transfer).ReloadAsync();
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
}
}
@@ -49,7 +49,7 @@ public class DisbursementServiceTests
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId) private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
@@ -57,7 +57,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new DisbursementService(db, http.Object, fs, new FakePrint()); return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin") private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
var db = BuildDb(userId); var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 }); db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" }); db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.SaveChanges(); db.SaveChanges();
var fs = new FakeStorage(); var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs); return (SvcAs(db, fs, userId), db, fs);
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new() private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{ {
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}", Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor, MemberId = memberId, VendorName = vendor,
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
}; };
[Fact] [Fact]
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
Assert.Equal("1 Main St", member.Address); Assert.Equal("1 Main St", member.Address);
} }
[Fact]
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
{
var (svc, db, _) = Build();
db.Expenses.Add(new Expense
{
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
},
});
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
Assert.Equal("Multiple / 多類別", line.CategoryName);
}
[Fact] [Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers() public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{ {
@@ -203,7 +228,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return (new DisbursementService(db, http.Object, fs, print), db, fs, print); return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
} }
[Fact] [Fact]
@@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Ai;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseAiServiceFactoryTests
{
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
// interceptor the app uses so seeded entities save cleanly.
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
.Options);
}
private static ExpenseAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
var gemini = new GeminiExpenseAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
return new ExpenseAiServiceFactory(cfg, claude, gemini);
}
[Fact]
public async Task Resolves_Claude_by_default()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<ClaudeExpenseAiService>(svc);
}
[Fact]
public async Task Resolves_Gemini_when_selected()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<GeminiExpenseAiService>(svc);
}
}
@@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Ai;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseCategoryAiServiceFactoryTests
{
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
// interceptor the app uses so seeded entities save cleanly.
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
.Options);
}
private static ExpenseCategoryAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseCategoryAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseCategoryAiService>.Instance);
var gemini = new GeminiExpenseCategoryAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseCategoryAiService>.Instance);
return new ExpenseCategoryAiServiceFactory(cfg, claude, gemini);
}
[Fact]
public async Task Resolves_Claude_by_default()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<ClaudeExpenseCategoryAiService>(svc);
}
[Fact]
public async Task Resolves_Gemini_when_selected()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<GeminiExpenseCategoryAiService>(svc);
}
}
@@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
[Fact] [Fact]
@@ -58,4 +58,23 @@ public class ExpenseCategoryServiceTests
await Assert.ThrowsAsync<KeyNotFoundException>(() => await Assert.ThrowsAsync<KeyNotFoundException>(() =>
svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" })); svc.UpdateGroupAsync(999, new UpdateExpenseGroupRequest { Name_en = "X" }));
} }
[Fact]
public async Task CreateAndGet_RoundTrips_Form990LineId()
{
using var db = BuildDb();
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 1, LineCode = "24", Name_en = "Other" });
db.Form990ExpenseLines.Add(new ROLAC.API.Entities.Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries" });
await db.SaveChangesAsync();
var svc = new ExpenseCategoryService(db);
var gid = await svc.CreateGroupAsync(new CreateExpenseGroupRequest { Name_en = "Personnel", Form990LineId = 1 });
var sid = await svc.CreateSubCategoryAsync(new CreateExpenseSubCategoryRequest { GroupId = gid, Name_en = "Salary & Wages", Form990LineId = 7 });
var all = await svc.GetAllAsync(includeInactive: true);
var sub = all.Single(g => g.Id == gid).SubCategories.Single(s => s.Id == sid);
Assert.Equal(7, sub.Form990LineId);
Assert.Equal("7", sub.Form990LineCode);
Assert.Equal(1, all.Single(g => g.Id == gid).Form990LineId);
Assert.Equal("24", all.Single(g => g.Id == gid).Form990LineCode);
}
} }
@@ -7,8 +7,11 @@ using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage; using ROLAC.API.Services.Storage;
using ROLAC.API.Tests.TestSupport;
using Xunit; using Xunit;
namespace ROLAC.API.Tests.Services; namespace ROLAC.API.Tests.Services;
@@ -33,7 +36,7 @@ public class ExpenseServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1") private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
@@ -52,7 +55,15 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
}
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs, audit);
} }
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier), // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
@@ -62,19 +73,25 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static CreateExpenseRequest Reimb() => new() private static CreateExpenseRequest Reimb() => new()
{ {
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", MinistryId = 1,
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28), Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
}; };
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new() private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
{ {
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, Type = r.Type, MinistryId = r.MinistryId,
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description, Lines = r.Lines.Select(l => new ExpenseLineInput
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
}).ToList(),
Description = r.Description,
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber, VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
ExpenseDate = r.ExpenseDate, Notes = r.Notes, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
}; };
@@ -197,6 +214,48 @@ public class ExpenseServiceTests
bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false)); bobSvc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
} }
[Fact]
public async Task Update_OwnPendingApproval_AsNonFinance_Succeeds()
{
// After Submit a reimbursement sits in PendingApproval; the owner may still correct it.
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb());
edit.Lines[0].Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal(99.99m, e!.Amount);
Assert.Equal("PendingApproval", e.Status);
}
[Fact]
public async Task Update_OwnApproved_AsNonFinance_Throws()
{
// Once approved, the owner can no longer edit.
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").ApproveAsync(id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.UpdateAsync(id, CloneToUpdate(Reimb()), isFinance: false));
}
[Fact]
public async Task SaveReceipt_OwnPendingApproval_AsNonFinance_Succeeds()
{
var (svc, db, _) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "r.jpg", isFinance: false);
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
}
[Fact] [Fact]
public async Task SoftDelete_HidesFromQueries() public async Task SoftDelete_HidesFromQueries()
{ {
@@ -206,6 +265,84 @@ public class ExpenseServiceTests
Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id)); Assert.Null(await db.Expenses.FirstOrDefaultAsync(e => e.Id == id));
} }
[Fact]
public async Task Create_PersistsFunctionalClass_AndGetReturnsIt()
{
var db = BuildDb("u1");
db.Ministries.Add(new ROLAC.API.Entities.Ministry { Id = 1, Name_en = "Admin" });
db.ExpenseCategoryGroups.Add(new ROLAC.API.Entities.ExpenseCategoryGroup { Id = 1, Name_en = "Other" });
db.ExpenseSubCategories.Add(new ROLAC.API.Entities.ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
await db.SaveChangesAsync();
var svc = SvcAs(db, new FakeStorage(), "u1");
var id = await svc.CreateAsync(new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1,
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m, FunctionalClass = "ManagementGeneral" } },
Description = "x", ExpenseDate = new DateOnly(2026, 5, 1),
}, isFinance: true);
var dto = await svc.GetByIdAsync(id);
Assert.Equal("ManagementGeneral", dto!.Lines.Single().FunctionalClass);
}
[Fact]
public async Task Create_MultiLine_SetsHeaderTotal_AndRoundTripsLines()
{
var (svc, db, _) = Build("u1");
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
await db.SaveChangesAsync();
var r = new CreateExpenseRequest
{
Type = "VendorPayment", MinistryId = 1, VendorName = "Costco",
Description = "Mixed invoice", ExpenseDate = new DateOnly(2026, 5, 1),
Lines =
{
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 12.50m },
},
};
var id = await svc.CreateAsync(r, isFinance: true);
Assert.Equal(42.50m, (await db.Expenses.FindAsync(id))!.Amount);
var dto = await svc.GetByIdAsync(id);
Assert.Equal(2, dto!.Lines.Count);
Assert.Equal(42.50m, dto.Amount);
Assert.Equal(2, dto.LineCount);
}
[Fact]
public async Task Create_WithNoLines_Throws()
{
var (svc, _, _) = Build("u1");
var r = Reimb(); r.Lines.Clear();
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r, isFinance: false));
}
[Fact]
public async Task Update_ReplacesLines_AndRecomputesTotal()
{
var (svc, db, _) = Build("alice");
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Snacks" });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Reimb(), isFinance: false);
var edit = CloneToUpdate(Reimb());
edit.Lines = new()
{
new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 10m },
new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 5m },
};
await svc.UpdateAsync(id, edit, isFinance: false);
Assert.Equal(15m, (await db.Expenses.FindAsync(id))!.Amount);
Assert.Equal(2, await db.ExpenseLines.CountAsync(l => l.ExpenseId == id));
}
[Fact] [Fact]
public async Task Receipt_SaveThenOpen_RoundTrips() public async Task Receipt_SaveThenOpen_RoundTrips()
{ {
@@ -216,4 +353,93 @@ public class ExpenseServiceTests
var got = await svc.OpenReceiptAsync(id, isFinance: true); var got = await svc.OpenReceiptAsync(id, isFinance: true);
Assert.NotNull(got); Assert.NotNull(got);
} }
[Fact]
public async Task Reject_WritesAuditEntry_WithReason()
{
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
var audit = new CapturingAuditLogger();
await SvcAs(db, fs, "finance", audit).RejectAsync(id, "Receipt unclear, please retake");
var entry = Assert.Single(audit.Entries);
Assert.Equal(AuditActions.ExpenseRejected, entry.Action);
Assert.Equal(AuditCategories.Business, entry.Category);
Assert.Equal(nameof(ROLAC.API.Entities.Expense), entry.EntityName);
Assert.Equal(id.ToString(), entry.EntityId);
Assert.Contains("Receipt unclear", entry.Summary);
}
[Fact]
public async Task Resubmit_FromRejected_ReturnsToPending_AndClearsReview()
{
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt missing");
// Owner fixes the issue and re-submits.
await svc.SubmitAsync(id);
var e = await db.Expenses.FindAsync(id);
Assert.Equal("PendingApproval", e!.Status);
Assert.Null(e.ReviewedBy);
Assert.Null(e.ReviewedAt);
Assert.Null(e.ReviewNotes);
}
[Fact]
public async Task Update_OwnRejected_AsNonFinance_Succeeds()
{
// A rejected reimbursement can be corrected by its owner before re-submitting.
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").RejectAsync(id, "Amount does not match receipt");
var edit = CloneToUpdate(Reimb());
edit.Lines[0].Amount = 77.77m;
await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id);
Assert.Equal(77.77m, e!.Amount);
Assert.Equal("Rejected", e.Status);
}
[Fact]
public async Task SaveReceipt_OwnRejected_AsNonFinance_Succeeds()
{
var (svc, db, fs) = Build("alice");
var id = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(id);
await SvcAs(db, fs, "finance").RejectAsync(id, "Receipt unclear, please retake");
using var input = new MemoryStream(Encoding.UTF8.GetBytes("img"));
await svc.SaveReceiptAsync(id, input, "retake.jpg", isFinance: false);
Assert.NotNull(await svc.OpenReceiptAsync(id, isFinance: true));
}
[Fact]
public async Task GetById_ResolvesReviewerName_MemberFullName_EmailFallback()
{
var (svc, db, fs) = Build("alice");
// Reviewer linked to a member → shows the member's full name.
db.Members.Add(new Member { Id = 5, FirstName_en = "Sam", LastName_en = "Approver" });
db.Users.Add(new AppUser { Id = "reviewer-with-member", MemberId = 5 });
// Reviewer with no member → falls back to email.
db.Users.Add(new AppUser { Id = "reviewer-no-member", Email = "nomember@church.org" });
await db.SaveChangesAsync();
var withMember = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(withMember);
await SvcAs(db, fs, "reviewer-with-member").ApproveAsync(withMember);
Assert.Equal("Sam Approver", (await svc.GetByIdAsync(withMember))!.ReviewedByName);
var noMember = await svc.CreateAsync(Reimb(), isFinance: false);
await svc.SubmitAsync(noMember);
await SvcAs(db, fs, "reviewer-no-member").RejectAsync(noMember, "Duplicate submission");
Assert.Equal("nomember@church.org", (await svc.GetByIdAsync(noMember))!.ReviewedByName);
}
} }
@@ -0,0 +1,165 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseSnapshotServiceTests
{
private static AppDbContext BuildDb(string userId)
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(mock.Object))).Options);
}
private static (ExpenseSnapshotService svc, AppDbContext db) Build(string userId = "u1")
{
var db = BuildDb(userId);
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Worship", Name_zh = "敬拜" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Facilities" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Rent" });
db.SaveChanges();
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var http = new Mock<IHttpContextAccessor>();
http.Setup(x => x.HttpContext).Returns(ctx);
return (new ExpenseSnapshotService(db, http.Object), db);
}
private static CreateExpenseSnapshotRequest Rent() => new()
{
Name = "Monthly Rent", MinistryId = 1, Description = "Office rent", VendorName = "Landlord X",
CheckNumber = "1001",
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1200m } },
};
[Fact]
public async Task Create_PersistsHeaderAndLines_StampsCreator()
{
var (svc, db) = Build("creator-1");
var id = await svc.CreateAsync(Rent());
var saved = await db.ExpenseSnapshots.FindAsync(id);
Assert.Equal("Monthly Rent", saved!.Name);
Assert.Equal("creator-1", saved.CreatedBy);
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
}
[Fact]
public async Task Create_WithNoLines_Throws()
{
var (svc, _) = Build();
var r = Rent(); r.Lines.Clear();
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
}
[Fact]
public async Task Create_WithInvalidFunctionalClass_Throws()
{
var (svc, _) = Build();
var r = Rent();
r.Lines[0].FunctionalClass = "NotAClass";
await Assert.ThrowsAsync<InvalidOperationException>(() => svc.CreateAsync(r));
}
[Fact]
public async Task GetById_ReturnsLines_TotalsAndCreatorName()
{
var (svc, db) = Build("creator-1");
db.Members.Add(new Member { Id = 5, FirstName_en = "Joy", LastName_en = "Wong" });
db.Users.Add(new AppUser { Id = "creator-1", MemberId = 5 });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Rent());
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
Assert.Equal(1200m, dto!.TotalAmount);
Assert.Equal(1, dto.LineCount);
Assert.Equal("Rent", dto.Lines.Single().SubCategoryName);
Assert.Equal("Joy Wong", dto.CreatedByName);
}
[Fact]
public async Task GetAll_ReturnsNewestFirst()
{
var (svc, _) = Build();
var first = await svc.CreateAsync(Rent());
var second = await svc.CreateAsync(Rent());
var all = await svc.GetAllAsync();
Assert.Equal(2, all.Count);
Assert.Equal(second, all[0].Id);
Assert.Equal(first, all[1].Id);
}
[Fact]
public async Task Update_RenamesAndReplacesLines()
{
var (svc, db) = Build();
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Utilities" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Internet" });
await db.SaveChangesAsync();
var id = await svc.CreateAsync(Rent());
await svc.UpdateAsync(id, new UpdateExpenseSnapshotRequest
{
Name = "Monthly Internet", MinistryId = 1, Description = "ISP",
Lines = { new ExpenseLineInput { CategoryGroupId = 2, SubCategoryId = 2, Amount = 80m } },
});
var dto = await svc.GetByIdAsync(id);
Assert.Equal("Monthly Internet", dto!.Name);
Assert.Equal(80m, dto.TotalAmount);
Assert.Equal("Internet", dto.Lines.Single().SubCategoryName);
Assert.Equal(1, await db.ExpenseSnapshotLines.CountAsync(l => l.SnapshotId == id));
}
[Fact]
public async Task Update_MissingId_Throws()
{
var (svc, _) = Build();
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.UpdateAsync(999, new UpdateExpenseSnapshotRequest
{
Name = "x", MinistryId = 1, Description = "x",
Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 1m } },
}));
}
[Fact]
public async Task Delete_SoftDeletes_HidesFromQueries()
{
var (svc, db) = Build();
var id = await svc.CreateAsync(Rent());
await svc.DeleteAsync(id);
Assert.Empty(await svc.GetAllAsync());
Assert.Null(await db.ExpenseSnapshots.FirstOrDefaultAsync(s => s.Id == id));
}
[Fact]
public async Task Delete_StampsDeletedBy()
{
var (svc, db) = Build("deleter-1");
var id = await svc.CreateAsync(Rent());
await svc.DeleteAsync(id);
var row = await db.ExpenseSnapshots.IgnoreQueryFilters().FirstAsync(s => s.Id == id);
Assert.Equal("deleter-1", row.DeletedBy);
}
}
@@ -0,0 +1,73 @@
using System.Globalization;
using System.Text;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form1099FormServiceTests
{
/// <summary>Stub report service: only GetAnnualSummaryAsync is exercised by the CSV export.</summary>
private sealed class StubReportService : IForm1099ReportService
{
private readonly Form1099SummaryDto _summary;
public StubReportService(Form1099SummaryDto summary) => _summary = summary;
public Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear) => Task.FromResult(_summary);
public Task<List<Form1099BoxDto>> GetBoxesAsync() => throw new NotImplementedException();
public Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
=> throw new NotImplementedException();
}
private static Form1099FormService BuildService(Form1099SummaryDto summary) =>
// IPayee1099Service and AppDbContext are only used by RenderCopyBAsync, not by the CSV path.
new Form1099FormService(new StubReportService(summary), payees: null!, db: null!);
[Fact]
public async Task ExportFilingCsvAsync_WritesHeaderRowPerRecipientAndInvariantNumbers()
{
var summary = new Form1099SummaryDto
{
TaxYear = 2026,
Rows =
{
new Form1099RecipientRowDto
{
PayeeId = 1, LegalName = "Acme, LLC", TinLast4 = "1234", W9Status = "OnFile",
NecTotal = 1234.50m, RentsTotal = 0m, GrandTotal = 1234.50m, MeetsThreshold = true
},
new Form1099RecipientRowDto
{
PayeeId = 2, LegalName = "Bob Smith", TinLast4 = "9876", W9Status = "Missing",
NecTotal = 100m, RentsTotal = 50m, GrandTotal = 150m, MeetsThreshold = false
},
}
};
var service = BuildService(summary);
var (stream, contentType, fileName) = await service.ExportFilingCsvAsync(2026);
Assert.Equal("text/csv", contentType);
Assert.Equal("1099-filing-2026.csv", fileName);
using var reader = new StreamReader(stream, Encoding.UTF8);
var text = await reader.ReadToEndAsync();
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries);
// Header + one data line per row.
Assert.Equal(3, lines.Length);
Assert.Equal("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold", lines[0]);
// A value containing a comma is quoted.
Assert.StartsWith("\"Acme, LLC\",1234,OnFile,", lines[1]);
// Invariant numeric formatting (period decimal separator) and Y/N threshold flag.
Assert.Contains("1234.50", lines[1]);
Assert.EndsWith(",Y", lines[1]);
Assert.EndsWith(",N", lines[2]);
// Sanity: the period really is the invariant separator regardless of current culture.
Assert.Equal("1234.50", 1234.50m.ToString(CultureInfo.InvariantCulture));
}
}
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using System.Security.Claims;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form1099ReportServiceTests
{
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
var accessorMock = new Mock<IHttpContextAccessor>();
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessorMock.Object))).Options);
}
private static AppDbContext Seeded(out int necSubId, out int rentSubId, out int salarySubId)
{
var db = NewDb();
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "Program" });
var nec = new Form1099Box { Id = 1, BoxCode = Form1099.BoxNec1, Name_en = "NEC", FormType = "1099-NEC", SortOrder = 1 };
var rent = new Form1099Box { Id = 2, BoxCode = Form1099.BoxMisc1, Name_en = "Rent", FormType = "1099-MISC", SortOrder = 2 };
db.Form1099Boxes.AddRange(nec, rent);
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Facility" });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Contract Labor", Form1099BoxId = 1 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 2, Name_en = "Rent", Form1099BoxId = 2 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 3, GroupId = 1, Name_en = "Salary & Wages", Form1099BoxId = null });
db.SaveChanges();
necSubId = 1; rentSubId = 2; salarySubId = 3;
return db;
}
private static void AddPaidExpense(AppDbContext db, int payeeId, int subId, int groupId, decimal amount, DateOnly paidOn)
{
var e = new Expense
{
MinistryId = 1, Type = "VendorPayment", Status = "Paid", PayeeId = payeeId,
Amount = amount, Description = "x", ExpenseDate = paidOn,
PaidAt = new DateTimeOffset(paidOn.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero),
Lines = [ new ExpenseLine { CategoryGroupId = groupId, SubCategoryId = subId, Amount = amount } ],
};
db.Expenses.Add(e);
db.SaveChanges();
}
[Fact]
public async Task Sums_tracked_recipient_by_box_and_flags_threshold_and_w9()
{
var db = Seeded(out var necSub, out var rentSub, out _);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Pat Player", Is1099Tracked = true, W9Status = "Missing" });
db.SaveChanges();
AddPaidExpense(db, 10, necSub, 1, 700m, new DateOnly(2026, 3, 1));
AddPaidExpense(db, 10, rentSub, 2, 500m, new DateOnly(2026, 4, 1));
var svc = new Form1099ReportService(db);
var sum = await svc.GetAnnualSummaryAsync(2026);
var row = Assert.Single(sum.Rows);
Assert.Equal(700m, row.NecTotal);
Assert.Equal(500m, row.RentsTotal);
Assert.Equal(1200m, row.GrandTotal);
Assert.True(row.MeetsThreshold);
Assert.True(row.W9Missing);
Assert.Equal(1, sum.RecipientsAtThreshold);
Assert.Equal(1, sum.RecipientsMissingW9);
}
[Fact]
public async Task Excludes_untracked_recipients_and_unmapped_and_wrong_year()
{
var db = Seeded(out var necSub, out _, out var salarySub);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Tracked Tim", Is1099Tracked = true, W9Status = "OnFile" });
db.Payee1099s.Add(new Payee1099 { Id = 11, LegalName = "Corp Inc", Is1099Tracked = false, W9Status = "OnFile" });
db.SaveChanges();
AddPaidExpense(db, 11, necSub, 1, 5000m, new DateOnly(2026, 5, 1)); // untracked
AddPaidExpense(db, 10, salarySub, 1, 5000m, new DateOnly(2026, 6, 1)); // unmapped box
AddPaidExpense(db, 10, necSub, 1, 5000m, new DateOnly(2025, 6, 1)); // wrong year
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
Assert.Empty(sum.Rows);
}
[Fact]
public async Task Threshold_flag_is_false_below_600()
{
var db = Seeded(out var necSub, out _, out _);
db.Payee1099s.Add(new Payee1099 { Id = 10, LegalName = "Small Sam", Is1099Tracked = true, W9Status = "OnFile" });
db.SaveChanges();
AddPaidExpense(db, 10, necSub, 1, 599.99m, new DateOnly(2026, 7, 1));
var sum = await new Form1099ReportService(db).GetAnnualSummaryAsync(2026);
var row = Assert.Single(sum.Rows);
Assert.False(row.MeetsThreshold);
Assert.False(row.W9Missing);
Assert.Equal(0, sum.RecipientsAtThreshold);
}
}
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Form990ReportServiceTests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "t") })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
private static async Task SeedAsync(AppDbContext db)
{
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 7, LineCode = "7", Name_en = "Salaries", SortOrder = 5 });
db.Form990ExpenseLines.Add(new Form990ExpenseLine { Id = 24, LineCode = "24", Name_en = "Other", SortOrder = 21 });
db.Ministries.Add(new Ministry { Id = 1, Name_en = "Admin", DefaultFunctionalClass = "ManagementGeneral" });
db.Ministries.Add(new Ministry { Id = 2, Name_en = "Worship", DefaultFunctionalClass = "Program" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Personnel", Form990LineId = 24 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Salary", Form990LineId = 7 });
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 2, GroupId = 1, Name_en = "Misc", Form990LineId = null });
await db.SaveChangesAsync();
}
private static Expense Exp(int min, int sub, decimal amt, string status, string? fc = null) => new()
{
MinistryId = min, Type = "VendorPayment",
Status = status, Amount = amt, Description = "x", ExpenseDate = new DateOnly(2026, 5, 10),
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = sub, Amount = amt, FunctionalClass = fc } },
};
[Fact]
public async Task Statement_AggregatesByLineAndFunction_WithFallbackAndUnmappedCount()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
db.Expenses.Add(Exp(1, 1, 40m, "Approved"));
db.Expenses.Add(Exp(2, 2, 25m, "Paid"));
db.Expenses.Add(Exp(2, 1, 999m, "Draft"));
db.Expenses.Add(Exp(1, 1, 10m, "Paid", fc: "Program"));
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
var line7 = stmt.Rows.Single(r => r.LineCode == "7");
Assert.Equal(110m, line7.Program);
Assert.Equal(40m, line7.ManagementGeneral);
Assert.Equal(150m, line7.Total);
var line24 = stmt.Rows.Single(r => r.LineCode == "24");
Assert.Equal(25m, line24.Program);
Assert.Equal(1, stmt.UnmappedExpenseCount);
Assert.Equal(175m, stmt.GrandTotal);
Assert.Equal(135m, stmt.ProgramTotal);
Assert.Equal(40m, stmt.ManagementGeneralTotal);
}
[Fact]
public async Task Statement_RespectsDateRange()
{
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(Exp(2, 1, 100m, "Paid"));
var older = Exp(2, 1, 500m, "Paid"); older.ExpenseDate = new DateOnly(2026, 1, 1);
db.Expenses.Add(older);
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(new DateOnly(2026, 5, 1), new DateOnly(2026, 5, 31));
Assert.Equal(100m, stmt.GrandTotal);
}
[Fact]
public async Task Statement_SplitsOneExpenseAcrossLines()
{
// One invoice with two lines of different categories must land on two different 990 lines.
using var db = BuildDb();
await SeedAsync(db);
db.Expenses.Add(new Expense
{
MinistryId = 2, Type = "VendorPayment", Status = "Paid", Amount = 70m,
Description = "mixed", ExpenseDate = new DateOnly(2026, 5, 10),
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 50m }, // sub→line 7
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 2, Amount = 20m }, // sub unmapped→group fallback line 24
},
});
await db.SaveChangesAsync();
var svc = new Form990ReportService(db);
var stmt = await svc.GetFunctionalExpenseStatementAsync(null, null);
Assert.Equal(50m, stmt.Rows.Single(r => r.LineCode == "7").Program); // ministry 2 default = Program
Assert.Equal(20m, stmt.Rows.Single(r => r.LineCode == "24").Program);
Assert.Equal(70m, stmt.GrandTotal);
Assert.Equal(1, stmt.UnmappedExpenseCount); // one unmapped line
}
}
@@ -23,7 +23,7 @@ public class GivingCategoryServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -24,7 +24,7 @@ public class GivingServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -0,0 +1,60 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
// service tests do) so those columns are stamped automatically on SaveChanges.
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(
new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
@@ -29,7 +29,7 @@ public class MemberServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var accessor = BuildAccessor(userId); var accessor = BuildAccessor(userId);
var interceptor = new AuditSaveChangesInterceptor(accessor); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Moq; using Moq;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
using Xunit; using Xunit;
@@ -20,7 +21,7 @@ public class MinistryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
[Fact] [Fact]
@@ -41,4 +42,19 @@ public class MinistryServiceTests
Assert.Equal("A", active[0].Name_en); Assert.Equal("A", active[0].Name_en);
Assert.Equal(3, all.Count); Assert.Equal(3, all.Count);
} }
[Fact]
public async Task Create_DefaultsFunctionalClassToProgram_AndUpdateChangesIt()
{
using var db = BuildDb();
var svc = new MinistryService(db);
var id = await svc.CreateAsync(new CreateMinistryRequest { Name_en = "Worship" });
var afterCreate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
Assert.Equal("Program", afterCreate.DefaultFunctionalClass);
await svc.UpdateAsync(id, new UpdateMinistryRequest { Name_en = "Worship", DefaultFunctionalClass = "ManagementGeneral" });
var afterUpdate = (await svc.GetAllAsync(true)).Single(m => m.Id == id);
Assert.Equal("ManagementGeneral", afterUpdate.DefaultFunctionalClass);
}
} }
@@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static MonthlyStatementService Build(AppDbContext db) private static MonthlyStatementService Build(AppDbContext db)
@@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests
var mock = new Mock<IHttpContextAccessor>(); var mock = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new MonthlyStatementService(db, mock.Object); return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
[Fact] [Fact]
@@ -42,8 +42,8 @@ public class MonthlyStatementServiceTests
db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" }); db.ExpenseSubCategories.Add(new ExpenseSubCategory { Id = 1, GroupId = 1, Name_en = "Misc" });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) }); db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 1000m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 5, 10) });
db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) }); db.Givings.Add(new Giving { GivingCategoryId = 1, Amount = 500m, PaymentMethod = "Cash", GivingDate = new DateOnly(2026, 6, 1) });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20) }); db.Expenses.Add(new Expense { MinistryId = 1, Type = "VendorPayment", Status = "Paid", Amount = 300m, Description = "x", ExpenseDate = new DateOnly(2026, 5, 20), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 300m } } });
db.Expenses.Add(new Expense { MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21) }); db.Expenses.Add(new Expense { MinistryId = 1, Type = "StaffReimbursement", Status = "Approved", Amount = 999m, Description = "not paid", ExpenseDate = new DateOnly(2026, 5, 21), Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 999m } } });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var svc = Build(db); var svc = Build(db);
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class EmailServiceTests
{
// Records every email it is asked to send; can be told to throw for a given address.
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
{
public List<OutboundEmail> Sent { get; } = new();
public string? FailForAddress { get; set; }
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
if (email.ToAddress == FailForAddress)
throw new InvalidOperationException("smtp rejected");
Sent.Add(email);
return Task.CompletedTask;
}
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, "member@example.com");
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var message = new EmailMessage(
MemberIds: new[] { memberId },
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
Subject: "Hi", HtmlBody: "<p>Body</p>");
var result = await service.SendAsync(message);
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
Assert.Equal(0, result.FailedCount);
Assert.Equal(2, dispatcher.Sent.Count);
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendAsync_SkipsMembersWithNoEmail()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, null);
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
Assert.Equal(0, result.SentCount);
Assert.Empty(dispatcher.Sent);
}
[Fact]
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
{
using var db = BuildDb();
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
Array.Empty<int>(),
new[] { "bad@example.com", "good@example.com" },
"Hi", "<p>Body</p>"));
Assert.Equal(1, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Single(result.Failures);
Assert.Equal("bad@example.com", result.Failures[0].Target);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
@@ -0,0 +1,83 @@
using System.Net;
using System.Text.Json;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests
{
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
private sealed class StubSettings : INotificationSettingsService
{
public SmtpOptions GetSmtp() => new();
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
public void Reload() { }
}
// Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public string ResponseBody { get; set; } = "{}";
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
private static LineMessageChannel BuildChannel(CapturingHandler handler)
{
var http = new HttpClient(handler);
return new LineMessageChannel(http, new StubSettings());
}
[Fact]
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.True(result.Success);
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
}
[Fact]
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
await channel.ReplyAsync("RTOKEN", "hi back");
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
}
[Fact]
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
{
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.False(result.Success);
Assert.Contains("429", result.Error);
}
}
@@ -0,0 +1,211 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineNotificationServiceTests
{
// Records pushes; can be told to fail every call.
private sealed class FakeMessageChannel : IMessageChannel
{
public List<(string Target, string Text)> UserPushes { get; } = new();
public List<(string Target, string Text)> GroupPushes { get; } = new();
public bool Fail { get; set; }
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
{
UserPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
{
GroupPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> Task.FromResult(new MessageSendResult(true, null));
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var stored = await db.LineBindingCodes.SingleAsync();
Assert.Equal(code, stored.Code);
Assert.Null(stored.ConsumedAt);
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
}
[Fact]
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var result = await service.TryBindMemberAsync("U999", code);
Assert.True(result.Success);
Assert.Equal(memberId, result.MemberId);
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U999", binding.ExternalId);
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
}
[Fact]
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.LineBindingCodes.Add(new LineBindingCode
{
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel());
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
}
[Fact]
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U-NEW", binding.ExternalId);
}
[Fact]
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
{
using var db = BuildDb();
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.RegisterGroupAsync("G1");
await service.RegisterGroupAsync("G1"); // second call must not duplicate
Assert.Equal(1, await db.MessagingGroups.CountAsync());
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
await service.DeactivateGroupAsync("G1");
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
}
[Fact]
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
db.MessagingGroups.AddRange(activeGroup, deadGroup);
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId },
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
Assert.Equal(2, result.SentCount); // member + active group only
Assert.Single(channel.UserPushes);
Assert.Single(channel.GroupPushes); // inactive group skipped
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
[Fact]
public async Task SendLineAsync_SkipsSoftDeletedMembers()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
// Soft-delete the member.
var member = await db.Members.FirstAsync(m => m.Id == memberId);
member.IsDeleted = true;
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Empty(channel.UserPushes);
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineSignatureTests
{
private const string Secret = "test-channel-secret";
private static string Sign(string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
}
[Fact]
public void IsValid_ReturnsTrue_ForMatchingSignature()
{
var body = """{"events":[]}""";
var signature = Sign(body);
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
Assert.True(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForTamperedBody()
{
var signature = Sign("""{"events":[]}""");
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
Assert.False(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
{
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
Assert.False(LineSignature.IsValid(Secret, body, null));
Assert.False(LineSignature.IsValid(Secret, body, ""));
}
}
@@ -36,7 +36,7 @@ public class OfferingSessionServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -164,4 +164,27 @@ public class OfferingSessionServiceTests
Assert.Equal("PP-456", line.PayPalTransactionId); Assert.Equal("PP-456", line.PayPalTransactionId);
Assert.Equal("C-789", line.CheckNumber); Assert.Equal("C-789", line.CheckNumber);
} }
[Fact]
public async Task GetPagedAsync_IncludesSundayAttendanceTotal_WhenRowExists()
{
using var db = BuildDb();
var catId = await SeedCategoryAsync(db);
var svc = new OfferingSessionService(db, BuildAccessor(), new NoOpFileStorage());
var withDate = new DateOnly(2026, 5, 31);
var withoutDate = new DateOnly(2026, 5, 24);
await svc.CreateAsync(BuildRequest(catId, withDate));
await svc.CreateAsync(BuildRequest(catId, withoutDate));
db.MealAttendances.Add(new MealAttendance
{ AttendanceDate = withDate, AdultCount = 40, YouthCount = 12, KidCount = 8 });
await db.SaveChangesAsync();
var page = await svc.GetPagedAsync(1, 20, null, null);
var withItem = page.Items.Single(i => i.SessionDate == "2026-05-31");
var withoutItem = page.Items.Single(i => i.SessionDate == "2026-05-24");
Assert.Equal(60, withItem.SundayAttendanceCount); // 40 + 12 + 8
Assert.Null(withoutItem.SundayAttendanceCount); // no attendance row -> null
}
} }
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using System.Security.Claims;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Security;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class Payee1099ServiceTests
{
// Minimal in-memory IFileStorage (mirrors the ExpenseServiceTests fake).
private sealed class FakeStorage : IFileStorage
{
public Dictionary<string, byte[]> Files = new();
public Task<string> SaveAsync(Stream c, string p, CancellationToken ct = default)
{ using var ms = new MemoryStream(); c.CopyTo(ms); Files[p] = ms.ToArray(); return Task.FromResult(p); }
public Task<Stream?> OpenReadAsync(string p, CancellationToken ct = default)
=> Task.FromResult<Stream?>(Files.TryGetValue(p, out var b) ? new MemoryStream(b) : null);
public Task DeleteAsync(string p, CancellationToken ct = default) { Files.Remove(p); return Task.CompletedTask; }
}
private static (Payee1099Service svc, AppDbContext db) Build()
{
var httpContext = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
var accessorMock = new Mock<IHttpContextAccessor>();
accessorMock.Setup(x => x.HttpContext).Returns(httpContext);
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(accessorMock.Object))).Options);
var tin = new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
return (new Payee1099Service(db, tin, new FakeStorage()), db);
}
[Fact]
public async Task Create_encrypts_tin_and_stores_last4_only_in_clear()
{
var (svc, db) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request
{ LegalName = "Pat Player", TinType = "SSN", Tin = "123-45-6789", W9Status = "OnFile" });
var saved = await db.Payee1099s.FindAsync(id);
Assert.NotNull(saved);
Assert.Equal("6789", saved!.TinLast4);
Assert.NotNull(saved.TinEncrypted);
Assert.DoesNotContain("123-45-6789", saved.TinEncrypted!);
Assert.Equal("123-45-6789", await svc.RevealTinAsync(id));
}
[Fact]
public async Task Update_with_null_tin_keeps_existing_ciphertext()
{
var (svc, db) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "X", Tin = "11-2223333" });
var before = (await db.Payee1099s.FindAsync(id))!.TinEncrypted;
await svc.UpdateAsync(id, new SavePayee1099Request { LegalName = "X renamed", Tin = null });
var after = await db.Payee1099s.FindAsync(id);
Assert.Equal("X renamed", after!.LegalName);
Assert.Equal(before, after.TinEncrypted);
Assert.Equal("3333", after.TinLast4);
}
[Fact]
public async Task List_dto_masks_tin_to_last4()
{
var (svc, _) = Build();
await svc.CreateAsync(new SavePayee1099Request { LegalName = "Y", Tin = "999-88-7777" });
var list = await svc.GetAllAsync(includeInactive: true);
var item = Assert.Single(list);
Assert.Equal("7777", item.TinLast4);
}
[Fact]
public async Task Delete_is_soft_and_hides_from_list()
{
var (svc, _) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "Z" });
await svc.DeleteAsync(id);
Assert.Empty(await svc.GetAllAsync(includeInactive: true));
}
[Fact]
public async Task SaveW9_records_document_and_round_trips_bytes()
{
var (svc, _) = Build();
var id = await svc.CreateAsync(new SavePayee1099Request { LegalName = "W9 Payee" });
var bytes = new byte[] { 1, 2, 3, 4, 5 };
await svc.SaveW9Async(id, new MemoryStream(bytes), "w9.pdf");
var dto = await svc.GetByIdAsync(id);
Assert.NotNull(dto);
Assert.True(dto!.HasW9Document);
var opened = await svc.OpenW9Async(id);
Assert.NotNull(opened);
Assert.Equal("application/pdf", opened!.Value.contentType);
using var ms = new MemoryStream();
await opened.Value.stream.CopyToAsync(ms);
Assert.Equal(bytes, ms.ToArray());
}
}
@@ -0,0 +1,185 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class PermissionServiceTests
{
// -----------------------------------------------------------------------
// Harness: a real PermissionService backed by an in-memory EF database.
// -----------------------------------------------------------------------
private sealed class Harness
{
public required ServiceProvider Provider { get; init; }
public required PermissionService Service { get; init; }
public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions)
{
using var scope = Provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() };
db.Roles.Add(role);
foreach (var permission in permissions)
{
permission.RoleId = role.Id;
db.RolePermissions.Add(permission);
}
await db.SaveChangesAsync();
}
}
private static Harness BuildHarness()
{
var dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
return new Harness
{
Provider = provider,
Service = new PermissionService(scopeFactory, cache,
new ROLAC.API.Services.Logging.SystemLogQueue(),
new Microsoft.AspNetCore.Http.HttpContextAccessor()),
};
}
private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false)
=> new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a };
// -----------------------------------------------------------------------
// HasPermissionAsync
// -----------------------------------------------------------------------
[Fact]
public async Task HasPermission_RoleGrantsAction_ReturnsTrue()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task HasPermission_RoleLacksAction_ReturnsFalse()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete));
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read));
}
[Fact]
public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants()
{
var h = BuildHarness();
await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true));
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
// User holds both roles — should get the union.
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write));
Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete));
}
// -----------------------------------------------------------------------
// GetEffectivePermissionsAsync
// -----------------------------------------------------------------------
[Fact]
public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull()
{
var h = BuildHarness(); // no rows seeded at all
var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]);
Assert.Equal(Modules.All.Count, effective.Count);
foreach (var module in Modules.All)
{
Assert.True(effective[module].Read);
Assert.True(effective[module].Write);
Assert.True(effective[module].Delete);
Assert.True(effective[module].Approve);
}
}
[Fact]
public async Task GetEffectivePermissions_MergesFlagsAcrossRoles()
{
var h = BuildHarness();
await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true));
await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true));
var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]);
Assert.True(effective[Modules.Expenses].Read);
Assert.True(effective[Modules.Expenses].Write);
Assert.True(effective[Modules.Expenses].Approve);
Assert.False(effective[Modules.Expenses].Delete);
}
[Fact]
public async Task GetEffectivePermissions_OmitsModulesWithNoGrant()
{
var h = BuildHarness();
await h.SeedRoleAsync("member"); // role exists but no grants
var effective = await h.Service.GetEffectivePermissionsAsync(["member"]);
Assert.Empty(effective);
}
// -----------------------------------------------------------------------
// Caching / invalidation via UpsertRoleAsync
// -----------------------------------------------------------------------
[Fact]
public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
// Prime the cache with the original snapshot.
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
// Grant write; UpsertRoleAsync must invalidate the cache.
await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto
{
Module = Modules.Givings, CanRead = true, CanWrite = true,
}]);
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task UpsertRole_SuperAdmin_Throws()
{
var h = BuildHarness();
await h.SeedRoleAsync("super_admin");
await Assert.ThrowsAsync<InvalidOperationException>(
() => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
[Fact]
public async Task UpsertRole_UnknownRole_Throws()
{
var h = BuildHarness();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
}
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.DataProtection;
using ROLAC.API.Services.Security;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class TinProtectorTests
{
private static TinProtector Build() =>
new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
[Fact]
public void Protect_then_Unprotect_round_trips()
{
var p = Build();
var cipher = p.Protect("123-45-6789");
Assert.NotEqual("123-45-6789", cipher);
Assert.Equal("123-45-6789", p.Unprotect(cipher));
}
[Theory]
[InlineData("123-45-6789", "6789")]
[InlineData("12-3456789", "6789")]
[InlineData("7", "7")]
public void Last4_keeps_only_trailing_digits(string raw, string expected)
=> Assert.Equal(expected, TinProtector.Last4(raw));
[Fact]
public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
}
@@ -76,7 +76,7 @@ public class UserManagementServiceTests
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable()); .Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var result = await svc.CreateAsync(new CreateUserRequest var result = await svc.CreateAsync(new CreateUserRequest
{ {
MemberId = member.Id, MemberId = member.Id,
@@ -97,7 +97,7 @@ public class UserManagementServiceTests
var mgr = BuildUserManager(); var mgr = BuildUserManager();
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable()); .Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest svc.CreateAsync(new CreateUserRequest
@@ -131,7 +131,7 @@ public class UserManagementServiceTests
// The service checks _userManager.Users — we need to return the existing user // The service checks _userManager.Users — we need to return the existing user
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser> { existingUser }.AsQueryable()); .Returns(new List<AppUser> { existingUser }.AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest svc.CreateAsync(new CreateUserRequest
@@ -147,7 +147,7 @@ public class UserManagementServiceTests
var user = new AppUser var user = new AppUser
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true }; { Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
var mgr = BuildUserManager(findResult: user); var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await svc.DeactivateAsync("u1"); await svc.DeactivateAsync("u1");
@@ -160,7 +160,7 @@ public class UserManagementServiceTests
{ {
using var db = BuildDb(); using var db = BuildDb();
var mgr = BuildUserManager(findResult: null); var mgr = BuildUserManager(findResult: null);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing")); await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
} }
@@ -173,7 +173,7 @@ public class UserManagementServiceTests
using var db = BuildDb(); using var db = BuildDb();
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" }; var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
var mgr = BuildUserManager(findResult: user); var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var pwd = await svc.ResetPasswordAsync("u1"); var pwd = await svc.ResetPasswordAsync("u1");
@@ -0,0 +1,21 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
public sealed class CapturingAuditLogger : IAuditLogger
{
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
public readonly List<Entry> Entries = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
Entries.Add(new Entry(action, category, entityName, entityId, summary));
}
}
@@ -0,0 +1,19 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>No-op <see cref="IAuditLogger"/> for unit tests that don't assert on audit output.</summary>
public sealed class NullAuditLogger : IAuditLogger
{
public static readonly NullAuditLogger Instance = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
// intentionally empty
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Gates an action/controller on a configurable permission. Usage:
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
/// Encodes the policy name <c>PERM:&lt;module&gt;:&lt;action&gt;</c>, which
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class HasPermissionAttribute : AuthorizeAttribute
{
public const string PolicyPrefix = "PERM:";
public HasPermissionAttribute(string module, string action)
=> Policy = $"{PolicyPrefix}{module}:{action}";
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
public static (string Module, string Action)? Parse(string policyName)
{
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
return null;
var body = policyName[PolicyPrefix.Length..];
var split = body.IndexOf(':');
if (split <= 0 || split == body.Length - 1)
return null;
return (body[..split], body[(split + 1)..]);
}
}
+72
View File
@@ -0,0 +1,72 @@
namespace ROLAC.API.Authorization;
/// <summary>
/// Canonical list of permission-controlled modules. The names are stored verbatim
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
/// attributes, so changing a string here is a breaking change requiring a data update.
/// </summary>
public static class Modules
{
public const string Members = "Members";
public const string Users = "Users";
public const string Givings = "Givings";
public const string GivingCategories = "GivingCategories";
public const string Expenses = "Expenses";
public const string ExpenseCategories = "ExpenseCategories";
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report";
public const string Form1099 = "Form1099";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
public const string MealAttendance = "MealAttendance";
public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs";
public const string Settings = "Settings";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
[
Members,
Users,
Givings,
GivingCategories,
Expenses,
ExpenseCategories,
OfferingSessions,
Ministries,
FinanceDashboard,
Form990Report,
Form1099,
MonthlyStatements,
ChurchProfile,
Disbursements,
MealAttendance,
Permissions,
SystemLogs,
AuditLogs,
Settings,
];
public static bool IsValid(string module) => All.Contains(module);
}
/// <summary>
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
/// </summary>
public static class PermissionActions
{
public const string Read = "Read";
public const string Write = "Write";
public const string Delete = "Delete";
public const string Approve = "Approve";
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
public static bool IsValid(string action) => All.Contains(action);
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Authorization;
/// <summary>
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
/// ANY of the user's roles grants the requested module/action (union across roles).
/// </summary>
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
public const string SuperAdminRole = "super_admin";
private readonly IPermissionService _permissions;
public PermissionAuthorizationHandler(IPermissionService permissions)
=> _permissions = permissions;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
if (roles.Contains(SuperAdminRole))
{
context.Succeed(requirement);
return;
}
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
context.Succeed(requirement);
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Authorization;
/// <summary>
/// Materializes <c>PERM:&lt;module&gt;:&lt;action&gt;</c> policies on demand so we never
/// have to register every module/action combination at startup. Any other policy name
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
/// </summary>
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var parsed = HasPermissionAttribute.Parse(policyName);
if (parsed is null)
return _fallback.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Authorization requirement carrying the module + action a request needs.
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
/// name of the form <c>PERM:&lt;module&gt;:&lt;action&gt;</c>.
/// </summary>
public class PermissionRequirement : IAuthorizationRequirement
{
public string Module { get; }
public string Action { get; }
public PermissionRequirement(string module, string action)
{
Module = module;
Action = action;
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/audit-logs")]
[Authorize]
public class AuditLogsController : ControllerBase
{
private readonly IAuditLogQueryService _svc;
public AuditLogsController(IAuditLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] AuditLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Category / action / level option lists for the filter UI.</summary>
[HttpGet("catalog")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public IActionResult GetCatalog() => Ok(_svc.GetCatalog());
}
+114 -7
View File
@@ -1,6 +1,10 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -13,11 +17,17 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly IInvitationService _invitations;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env; private readonly IWebHostEnvironment _env;
public AuthController(IAuthService authService, IWebHostEnvironment env) public AuthController(
IAuthService authService, IInvitationService invitations,
UserManager<AppUser> userManager, IWebHostEnvironment env)
{ {
_authService = authService; _authService = authService;
_invitations = invitations;
_userManager = userManager;
_env = env; _env = env;
} }
@@ -79,17 +89,43 @@ public class AuthController : ControllerBase
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// GET /api/auth/me (dev-only diagnostic — remove before production) // GET /api/auth/me
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// <summary> /// <summary>
/// Returns the claims ASP.NET Core parsed from the Bearer token. /// Returns the current user's identity, roles, and effective permissions.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates /// The SPA calls this on startup and after an admin edits the permission matrix
/// fine; if you then get 403 on /api/users the role claim isn't matching. /// to refresh what the UI shows — without forcing a re-login.
/// </summary> /// </summary>
[HttpGet("me")] [HttpGet("me")]
[Authorize] // no role restriction — just needs a valid JWT [Authorize]
public IActionResult GetMe() [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMe()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var user = await _userManager.FindByIdAsync(userId);
if (user is null || !user.IsActive)
return Unauthorized();
var roles = await _userManager.GetRolesAsync(user);
return Ok(await _authService.BuildUserInfoAsync(user, roles));
}
// -------------------------------------------------------------------------
// GET /api/auth/claims (dev-only diagnostic)
// -------------------------------------------------------------------------
/// <summary>
/// Returns the raw 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 a protected endpoint the role/permission isn't matching.
/// </summary>
[HttpGet("claims")]
[Authorize]
public IActionResult GetClaims()
{ {
var claims = User.Claims var claims = User.Claims
.Select(c => new { c.Type, c.Value }) .Select(c => new { c.Type, c.Value })
@@ -122,6 +158,77 @@ public class AuthController : ControllerBase
return NoContent(); return NoContent();
} }
// -------------------------------------------------------------------------
// POST /api/auth/change-password
// -------------------------------------------------------------------------
/// <summary>
/// Changes the current user's password. Requires the correct current password and a
/// new password meeting the configured policy. On success the user's *other* sessions
/// are revoked while the current session stays active.
/// </summary>
[HttpPost("change-password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var currentRefresh = Request.Cookies[CookieName];
var result = await _authService.ChangePasswordAsync(
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
if (!result.Succeeded)
return BadRequest(new
{
message = string.Join(" ", result.Errors.Select(error => error.Description)),
});
return NoContent();
}
// -------------------------------------------------------------------------
// GET /api/auth/invitation/validate?token=...
// -------------------------------------------------------------------------
/// <summary>
/// Checks whether an invitation token can still be used. Anonymous so the public
/// "set your password" page can decide what to show before the member types anything.
/// </summary>
[HttpGet("invitation/validate")]
[AllowAnonymous]
[ProducesResponseType(typeof(ValidateInvitationResult), StatusCodes.Status200OK)]
public async Task<IActionResult> ValidateInvitation([FromQuery] string token)
=> Ok(await _invitations.ValidateAsync(token));
// -------------------------------------------------------------------------
// POST /api/auth/accept-invitation
// -------------------------------------------------------------------------
/// <summary>
/// Consumes an invitation: sets the account password and, on success, logs the member in
/// (issues the access token + refresh cookie) so first login lands straight on the portal.
/// </summary>
[HttpPost("accept-invitation")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> AcceptInvitation([FromBody] AcceptInvitationRequest request)
{
var (user, error) = await _invitations.AcceptAsync(request.Token, request.NewPassword);
if (user is null)
return BadRequest(new { message = error });
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var device = Request.Headers.UserAgent.FirstOrDefault();
var (response, raw) = await _authService.IssueSessionAsync(user, ip, device);
SetRefreshCookie(raw);
return Ok(response);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Private helpers // Private helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement; using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/church-profile")] [Route("api/church-profile")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class ChurchProfileController : ControllerBase public class ChurchProfileController : ControllerBase
{ {
private readonly IChurchProfileService _svc; private readonly IChurchProfileService _svc;
public ChurchProfileController(IChurchProfileService svc) => _svc = svc; public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync()); public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
[HttpPut] [HttpPut]
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r) public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
{ {
await _svc.UpdateAsync(r); await _svc.UpdateAsync(r);
@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement; using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/disbursements")] [Route("api/disbursements")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class DisbursementsController : ControllerBase public class DisbursementsController : ControllerBase
{ {
private readonly IDisbursementService _svc; private readonly IDisbursementService _svc;
public DisbursementsController(IDisbursementService svc) => _svc = svc; public DisbursementsController(IDisbursementService svc) => _svc = svc;
[HttpGet("approved-unpaid")] [HttpGet("approved-unpaid")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetApprovedUnpaid() public async Task<IActionResult> GetApprovedUnpaid()
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync()); => Ok(await _svc.GetApprovedUnpaidGroupedAsync());
[HttpPost("issue")] [HttpPost("issue")]
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r) public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
{ {
try { return Ok(await _svc.IssueChecksAsync(r)); } try { return Ok(await _svc.IssueChecksAsync(r)); }
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks")] [HttpGet("checks")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetRegister( public async Task<IActionResult> GetRegister(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) [FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to)); => Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
[HttpGet("checks/{id:int}")] [HttpGet("checks/{id:int}")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpPost("checks/{id:int}/void")] [HttpPost("checks/{id:int}/void")]
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r) public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
{ {
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); } try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks/{id:int}/pdf")] [HttpGet("checks/{id:int}/pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetPdf(int id) public async Task<IActionResult> GetPdf(int id)
{ {
var result = await _svc.RenderPdfAsync(id); var result = await _svc.RenderPdfAsync(id);
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks/{id:int}/receipt-pdf")] [HttpGet("checks/{id:int}/receipt-pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetReceiptPdf(int id) public async Task<IActionResult> GetReceiptPdf(int id)
{ {
var result = await _svc.RenderReceiptPdfAsync(id); var result = await _svc.RenderReceiptPdfAsync(id);
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpPost("checks/{id:int}/acknowledge")] [HttpPost("checks/{id:int}/acknowledge")]
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
[RequestSizeLimit(5_242_880)] [RequestSizeLimit(5_242_880)]
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName) public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
{ {
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks/{id:int}/signature")] [HttpGet("checks/{id:int}/signature")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetSignature(int id) public async Task<IActionResult> GetSignature(int id)
{ {
try try
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/expense-ai")]
[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
public class ExpenseAiController : ControllerBase
{
private readonly IExpenseAiServiceFactory _factory;
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
[HttpPost("assist")]
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Text))
return BadRequest("Text is required.");
var svc = await _factory.ResolveAsync(ct);
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
return Ok(suggestion);
}
}
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -12,39 +14,57 @@ namespace ROLAC.API.Controllers;
public class ExpenseCategoriesController : ControllerBase public class ExpenseCategoriesController : ControllerBase
{ {
private readonly IExpenseCategoryService _svc; private readonly IExpenseCategoryService _svc;
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc; private readonly IExpenseCategoryAiServiceFactory _aiFactory;
public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory)
{
_svc = svc;
_aiFactory = aiFactory;
}
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false) public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
// Suggest an English name + Form 990 line for a category being defined. Write-gated: category
// editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint.
[HttpPost("ai-suggest")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en))
return BadRequest("A name is required.");
var svc = await _aiFactory.ResolveAsync(ct);
return Ok(await svc.SuggestAsync(r, ct));
}
[HttpPost("groups")] [HttpPost("groups")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r) public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
=> Ok(new { id = await _svc.CreateGroupAsync(r) }); => Ok(new { id = await _svc.CreateGroupAsync(r) });
[HttpPut("groups/{id:int}")] [HttpPut("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r) public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("groups/{id:int}")] [HttpDelete("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateGroup(int id) public async Task<IActionResult> DeactivateGroup(int id)
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPost("subcategories")] [HttpPost("subcategories")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r) public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } } { try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPut("subcategories/{id:int}")] [HttpPut("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r) public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("subcategories/{id:int}")] [HttpDelete("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateSub(int id) public async Task<IActionResult> DeactivateSub(int id)
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
} }
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires
// Expenses:Write (super_admin bypasses), matching who can create vendor payments.
[ApiController]
[Route("api/expense-snapshots")]
[Authorize]
public class ExpenseSnapshotsController : ControllerBase
{
private readonly IExpenseSnapshotService _svc;
private readonly IPermissionService _perms;
public ExpenseSnapshotsController(IExpenseSnapshotService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
[HttpGet]
public async Task<IActionResult> GetAll()
{
if (!await CanManageAsync()) return Forbid();
return Ok(await _svc.GetAllAsync());
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
if (!await CanManageAsync()) return Forbid();
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseSnapshotRequest r)
{
if (!await CanManageAsync()) return Forbid();
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseSnapshotRequest r)
{
if (!await CanManageAsync()) return Forbid();
try { await _svc.UpdateAsync(id, r); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
if (!await CanManageAsync()) return Forbid();
try { await _svc.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
+30 -13
View File
@@ -1,21 +1,38 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
// Class is [Authorize] only — any authenticated member may submit/view their OWN
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
// against the configurable permission matrix on the "Expenses" module.
[ApiController] [ApiController]
[Route("api/expenses")] [Route("api/expenses")]
[Authorize] [Authorize]
public class ExpensesController : ControllerBase public class ExpensesController : ControllerBase
{ {
private readonly IExpenseService _svc; private readonly IExpenseService _svc;
public ExpensesController(IExpenseService svc) => _svc = svc; private readonly IPermissionService _perms;
public ExpensesController(IExpenseService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin"); private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor"); private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
private async Task<bool> CanViewAllAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime. // User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
private string CurrentUserId() => private string CurrentUserId() =>
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null, [FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null) [FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
{ {
if (!CanViewAll()) return Forbid(); if (!await CanViewAllAsync()) return Forbid();
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses)); return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
} }
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
if (dto is null) return NotFound(); if (dto is null) return NotFound();
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid(); if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
return Ok(dto); return Ok(dto);
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r) public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
{ {
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); } try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r) public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
{ {
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); } try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
} }
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); } try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
} }
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
} }
[HttpPost("{id:int}/approve")] [HttpPost("{id:int}/approve")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Approve(int id) public async Task<IActionResult> Approve(int id)
{ {
try { await _svc.ApproveAsync(id); return NoContent(); } try { await _svc.ApproveAsync(id); return NoContent(); }
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
} }
[HttpPost("{id:int}/reject")] [HttpPost("{id:int}/reject")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r) public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
{ {
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); } try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
} }
[HttpPost("{id:int}/pay")] [HttpPost("{id:int}/pay")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r) public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
{ {
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); } try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
try try
{ {
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance()); await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
return NoContent(); return NoContent();
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
{ {
try try
{ {
var result = await _svc.OpenReceiptAsync(id, IsFinance()); var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
if (result is null) return NotFound(); if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType); return File(result.Value.stream, result.Value.contentType);
} }
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/finance-dashboard")] [Route("api/finance-dashboard")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
public class FinanceDashboardController : ControllerBase public class FinanceDashboardController : ControllerBase
{ {
private readonly IFinanceDashboardService _svc; private readonly IFinanceDashboardService _svc;
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/form1099-report")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Form1099ReportController : ControllerBase
{
private readonly IForm1099ReportService _svc;
private readonly I1099FormService _form;
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
{
_svc = svc;
_form = form;
}
[HttpGet("boxes")]
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
[HttpGet("summary")]
public async Task<IActionResult> Summary([FromQuery] int taxYear)
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
[HttpGet("recipient/{payeeId:int}")]
public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
=> await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
[HttpGet("recipient/{payeeId:int}/copy-b")]
public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
{
var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear);
return File(stream, contentType, fileName);
}
[HttpGet("export-csv")]
public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
{
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
return File(stream, contentType, fileName);
}
}
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/form990-report")]
[HasPermission(Modules.Form990Report, PermissionActions.Read)]
public class Form990ReportController : ControllerBase
{
private readonly IForm990ReportService _svc;
public Form990ReportController(IForm990ReportService svc) => _svc = svc;
[HttpGet("lines")]
public async Task<IActionResult> Lines() => Ok(await _svc.GetLinesAsync());
[HttpGet("functional-expenses")]
public async Task<IActionResult> FunctionalExpenses([FromQuery] DateOnly? from, [FromQuery] DateOnly? to)
=> Ok(await _svc.GetFunctionalExpenseStatementAsync(from, to));
}
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/giving-categories")] [Route("api/giving-categories")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class GivingCategoriesController : ControllerBase public class GivingCategoriesController : ControllerBase
{ {
private readonly IGivingCategoryService _svc; private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc; public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false) public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost] [HttpPost]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request) public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{ {
var id = await _svc.CreateAsync(request); var id = await _svc.CreateAsync(request);
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request) public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{ {
try { await _svc.UpdateAsync(id, request); return NoContent(); } try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id) public async Task<IActionResult> Deactivate(int id)
{ {
try { await _svc.DeactivateAsync(id); return NoContent(); } try { await _svc.DeactivateAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/givings")] [Route("api/givings")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class GivingsController : ControllerBase public class GivingsController : ControllerBase
{ {
private readonly IGivingService _svc; private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc; public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null, [FromQuery] string? search = null, [FromQuery] int? categoryId = null,
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to)); => Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
} }
[HttpPost] [HttpPost]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request) public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{ {
var id = await _svc.CreateAsync(request); var id = await _svc.CreateAsync(request);
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request) public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{ {
try { await _svc.UpdateAsync(id, request); return NoContent(); } try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
try { await _svc.DeleteAsync(id); return NoContent(); } try { await _svc.DeleteAsync(id); return NoContent(); }
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Invitations;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for generating and e-mailing first-login invitation links.
/// The public consume/validate endpoints live on <see cref="AuthController"/> so they can set the
/// refresh-token cookie and stay anonymous.
/// </summary>
[ApiController]
[Route("api/invitations")]
[Authorize]
public class InvitationsController : ControllerBase
{
private readonly IInvitationService _invitations;
public InvitationsController(IInvitationService invitations) => _invitations = invitations;
/// <summary>POST /api/invitations — generate a link for a member; returns { token, expiresAt }.</summary>
[HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateInvitationRequest request)
{
try { return Ok(await _invitations.CreateAsync(request)); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
/// <summary>POST /api/invitations/send — e-mail an already-generated link to the member.</summary>
[HttpPost("send")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Send([FromBody] SendInvitationRequest request)
{
try { await _invitations.SendEmailAsync(request.MemberId, request.Link); return NoContent(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -0,0 +1,88 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
/// returns 400 only on signature failure.
/// </summary>
[ApiController]
[Route("api/line")]
[AllowAnonymous]
public sealed class LineWebhookController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly INotificationSettingsService _settings;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
{
_line = line;
_channel = channel;
_settings = settings;
}
[HttpPost("webhook")]
[RequestSizeLimit(262_144)]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
if (payload?.Events is not null)
foreach (var evt in payload.Events)
await DispatchAsync(evt, ct);
return Ok();
}
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
{
switch (evt.Type)
{
case "follow":
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
break;
case "message":
if (evt.Message?.Type == "text"
&& evt.Source?.UserId is { } userId
&& evt.Message.Text is { } text)
{
var result = await _line.TryBindMemberAsync(userId, text, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
}
break;
case "join":
if (evt.Source?.GroupId is { } joinGroupId)
{
await _line.RegisterGroupAsync(joinGroupId, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
}
break;
case "leave":
if (evt.Source?.GroupId is { } leaveGroupId)
await _line.DeactivateGroupAsync(leaveGroupId, ct);
break;
}
}
}
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.MealAttendance;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -16,11 +17,17 @@ public class MealAttendanceController : ControllerBase
[HttpGet("today")] [HttpGet("today")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetToday() public async Task<IActionResult> GetToday()
=> Ok(await _svc.GetOrCreateAsync(_svc.Today)); => Ok(await _svc.GetOrCreateAsync(_svc.ServiceDay));
/// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary> /// <summary>Daily counts within a date range, for the back-office dashboard chart.</summary>
[HttpGet] [HttpGet]
[Authorize] [Authorize]
public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to) public async Task<IActionResult> GetRange([FromQuery] DateOnly from, [FromQuery] DateOnly to)
=> Ok(await _svc.GetRangeAsync(from, to)); => Ok(await _svc.GetRangeAsync(from, to));
/// <summary>Overwrite a specific Sunday's counts (back-office editor). Authenticated only.</summary>
[HttpPut("{date}")]
[Authorize]
public async Task<IActionResult> SetCounts(DateOnly date, [FromBody] SetAttendanceRequest body)
=> Ok(await _svc.SetCountsAsync(date, body.Adult, body.Youth, body.Kid));
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Members; using ROLAC.API.DTOs.Members;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary> /// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
[HttpGet] [HttpGet]
[Authorize(Roles = "super_admin,secretary,pastor")] [HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members/{id}</summary> /// <summary>GET /api/members/{id}</summary>
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[Authorize(Roles = "super_admin,secretary,pastor")] [HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _members.GetByIdAsync(id); var dto = await _members.GetByIdAsync(id);
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
/// <summary>POST /api/members</summary> /// <summary>POST /api/members</summary>
[HttpPost] [HttpPost]
[Authorize(Roles = "super_admin,secretary")] [HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request) public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
{ {
var id = await _members.CreateAsync(request); var id = await _members.CreateAsync(request);
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
/// <summary>PUT /api/members/{id}</summary> /// <summary>PUT /api/members/{id}</summary>
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[Authorize(Roles = "super_admin,secretary")] [HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request) public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
{ {
try { await _members.UpdateAsync(id, request); return NoContent(); } try { await _members.UpdateAsync(id, request); return NoContent(); }
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
/// <summary>DELETE /api/members/{id} — soft delete</summary> /// <summary>DELETE /api/members/{id} — soft delete</summary>
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[Authorize(Roles = "super_admin,secretary")] [HasPermission(Modules.Members, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
try { await _members.DeleteAsync(id); return NoContent(); } try { await _members.DeleteAsync(id); return NoContent(); }
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Ministry;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -13,6 +15,31 @@ public class MinistriesController : ControllerBase
public MinistriesController(IMinistryService svc) => _svc = svc; public MinistriesController(IMinistryService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.Ministries, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false) public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMinistryRequest request)
{
var id = await _svc.CreateAsync(request);
return CreatedAtAction(nameof(GetAll), new { id }, new { id });
}
[HttpPut("{id:int}")]
[HasPermission(Modules.Ministries, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMinistryRequest request)
{
try { await _svc.UpdateAsync(id, request); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
[HttpDelete("{id:int}")]
[HasPermission(Modules.Ministries, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id)
{
try { await _svc.DeactivateAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/monthly-statements")] [Route("api/monthly-statements")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class MonthlyStatementsController : ControllerBase public class MonthlyStatementsController : ControllerBase
{ {
private readonly IMonthlyStatementService _svc; private readonly IMonthlyStatementService _svc;
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc; public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] int? year = null) public async Task<IActionResult> GetAll([FromQuery] int? year = null)
=> Ok(await _svc.GetAllAsync(year)); => Ok(await _svc.GetAllAsync(year));
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
} }
[HttpPost] [HttpPost]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r) public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
{ {
try { return Ok(new { id = await _svc.CreateAsync(r) }); } try { return Ok(new { id = await _svc.CreateAsync(r) }); }
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r) public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
{ {
try { await _svc.UpdateAsync(id, r); return NoContent(); } try { await _svc.UpdateAsync(id, r); return NoContent(); }
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
} }
[HttpPost("{id:int}/finalize")] [HttpPost("{id:int}/finalize")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
public async Task<IActionResult> Finalize(int id) public async Task<IActionResult> Finalize(int id)
{ {
try { await _svc.FinalizeAsync(id); return NoContent(); } try { await _svc.FinalizeAsync(id); return NoContent(); }
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
/// management, send history, and manual send — the manual send endpoints are the only way to fire
/// a message before a UI exists; programmatic callers use the services directly.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public sealed class NotificationsController : ControllerBase
{
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly AppDbContext _db;
private readonly CurrentUserAccessor _currentUser;
public NotificationsController(
IEmailService email, ILineNotificationService line,
AppDbContext db, CurrentUserAccessor currentUser)
{
_email = email;
_line = line;
_db = db;
_currentUser = currentUser;
}
[HttpPost("members/{id:int}/line-binding-code")]
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
=> Ok(await _db.MessagingGroups
.OrderBy(g => g.Id)
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
.ToListAsync(ct));
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
{
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
if (group is null) return NotFound();
group.Name = request.Name;
group.IsActive = request.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var size = Math.Clamp(pageSize, 1, 200);
var skip = (Math.Max(page, 1) - 1) * size;
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
var total = await query.CountAsync(ct);
var items = await query
.Skip(skip).Take(size)
.Select(l => new
{
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
l.Status, l.Error, l.SentByUserId, l.SentAt,
})
.ToListAsync(ct);
return Ok(new { total, items });
}
[HttpPost("send-line")]
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
=> Ok(await _line.SendLineAsync(
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
_currentUser.UserIdOrSystem, ct));
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
=> Ok(await _email.SendAsync(new EmailMessage(
MemberIds: request.MemberIds ?? [],
Addresses: request.Addresses ?? [],
Subject: request.Subject,
HtmlBody: request.HtmlBody,
Attachments: null,
SentByUserId: _currentUser.UserIdOrSystem), ct));
}
@@ -64,6 +64,7 @@ public class OfferingEntryController : ControllerBase
NickName = request.NickName, NickName = request.NickName,
FirstName_zh = request.FirstName_zh, FirstName_zh = request.FirstName_zh,
LastName_zh = request.LastName_zh, LastName_zh = request.LastName_zh,
Entity = request.Entity,
PhoneCell = request.PhoneCell, PhoneCell = request.PhoneCell,
Status = "Visitor", Status = "Visitor",
Country = "USA", Country = "USA",
@@ -73,6 +74,7 @@ public class OfferingEntryController : ControllerBase
{ {
Id = id, NickName = request.NickName, Id = id, NickName = request.NickName,
FirstName_en = request.FirstName_en, LastName_en = request.LastName_en, FirstName_en = request.FirstName_en, LastName_en = request.LastName_en,
Entity = request.Entity,
}); });
} }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/offering-sessions")] [Route("api/offering-sessions")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class OfferingSessionsController : ControllerBase public class OfferingSessionsController : ControllerBase
{ {
private readonly IOfferingSessionService _svc; private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc; public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to)); => Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")] [HttpGet("check-date")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date) public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) }); => Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpPost] [HttpPost]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request) public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{ {
try try
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpPost("{id:int}/reopen")] [HttpPost("{id:int}/reopen")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
public async Task<IActionResult> Reopen(int id) public async Task<IActionResult> Reopen(int id)
{ {
try { await _svc.ReopenAsync(id); return NoContent(); } try { await _svc.ReopenAsync(id); return NoContent(); }
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request) public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{ {
try { await _svc.ReplaceAsync(id, request); return NoContent(); } try { await _svc.ReplaceAsync(id, request); return NoContent(); }
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
// ── Paper-proof PDF (merged client-side, one file per session) ─────────── // ── Paper-proof PDF (merged client-side, one file per session) ───────────
[HttpPost("{id:int}/proof")] [HttpPost("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt [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) public async Task<IActionResult> UploadProof(int id, IFormFile file)
{ {
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpGet("{id:int}/proof")] [HttpGet("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetProof(int id) public async Task<IActionResult> GetProof(int id)
{ {
try try
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpDelete("{id:int}/proof")] [HttpDelete("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
public async Task<IActionResult> DeleteProof(int id) public async Task<IActionResult> DeleteProof(int id)
{ {
try { await _svc.DeleteProofAsync(id); return NoContent(); } try { await _svc.DeleteProofAsync(id); return NoContent(); }
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/payee-1099")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Payee1099Controller : ControllerBase
{
private readonly IPayee1099Service _svc;
public Payee1099Controller(IPayee1099Service svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
[HttpPost]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
=> Ok(new { id = await _svc.CreateAsync(r) });
[HttpPut("{id:int}")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
{ await _svc.UpdateAsync(id, r); return NoContent(); }
[HttpDelete("{id:int}")]
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{ await _svc.DeleteAsync(id); return NoContent(); }
// Full TIN reveal is gated on Write (a stronger right than Read).
[HttpGet("{id:int}/tin")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> RevealTin(int id)
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
[HttpPost("{id:int}/w9")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
[RequestSizeLimit(10_485_760)]
public async Task<IActionResult> UploadW9(int id, IFormFile file)
{
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveW9Async(id, stream, file.FileName);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
}
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
[HttpGet("{id:int}/w9")]
public async Task<IActionResult> GetW9(int id)
{
var result = await _svc.OpenW9Async(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
/// the role that governs who governs everyone else.
/// </summary>
[ApiController]
[Route("api/permissions")]
[Authorize(Roles = "super_admin")]
public class PermissionsController : ControllerBase
{
private readonly IPermissionService _permissions;
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
[HttpGet]
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
[HttpGet("catalog")]
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
{
Modules = Modules.All,
Actions = PermissionActions.All,
});
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
[HttpPut("{roleName}")]
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
{
try
{
await _permissions.UpsertRoleAsync(roleName, request.Modules);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Settings;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
/// </summary>
[ApiController]
[Route("api/settings")]
[Authorize]
public class SettingsController : ControllerBase
{
private readonly ISettingsService _settings;
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly CurrentUserAccessor _currentUser;
public SettingsController(
ISettingsService settings,
IEmailService email,
ILineNotificationService line,
CurrentUserAccessor currentUser)
{
_settings = settings;
_email = email;
_line = line;
_currentUser = currentUser;
}
// ── Site settings ────────────────────────────────────────────────────────
[HttpGet("site")]
[HasPermission(Modules.Settings, PermissionActions.Read)]
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
[HttpPut("site")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
{
await _settings.UpdateSiteAsync(request);
return NoContent();
}
// ── Notification settings ──────────────────────────────────────────────────
[HttpGet("notification")]
[HasPermission(Modules.Settings, PermissionActions.Read)]
public async Task<IActionResult> GetNotification()
{
var dto = await _settings.GetNotificationAsync();
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
return Ok(dto);
}
[HttpPut("notification")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
{
await _settings.UpdateNotificationAsync(request);
return NoContent();
}
[HttpPost("notification/test-email")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
{
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
if (string.IsNullOrWhiteSpace(to))
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
var result = await _email.SendAsync(new EmailMessage(
MemberIds: Array.Empty<int>(),
Addresses: new[] { to },
Subject: "ROLAC test email / 測試郵件",
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
SentByUserId: _currentUser.UserIdOrSystem), ct);
return Ok(result);
}
[HttpPost("notification/test-line")]
[HasPermission(Modules.Settings, PermissionActions.Write)]
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
{
if (request.MemberId is null && request.GroupId is null)
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
var result = await _line.SendLineAsync(
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
sentByUserId: _currentUser.UserIdOrSystem,
ct);
return Ok(result);
}
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/system-logs")]
[Authorize]
public class SystemLogsController : ControllerBase
{
private readonly ISystemLogQueryService _svc;
public SystemLogsController(ISystemLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] SystemLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>All six severities, so the UI can offer every filter option regardless of data.</summary>
[HttpGet("levels")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public IActionResult GetLevels() => Ok(Enum.GetNames<LogLevelEnum>());
}
+8 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Users; using ROLAC.API.DTOs.Users;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/users")] [Route("api/users")]
[Authorize(Roles = "super_admin")] [Authorize]
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private readonly IUserManagementService _users; private readonly IUserManagementService _users;
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary> /// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
[HttpGet] [HttpGet]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users/{id}</summary> /// <summary>GET /api/users/{id}</summary>
[HttpGet("{id}")] [HttpGet("{id}")]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetById(string id) public async Task<IActionResult> GetById(string id)
{ {
var dto = await _users.GetByIdAsync(id); var dto = await _users.GetByIdAsync(id);
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
/// TempPassword is returned ONCE — show it to the admin and never log it. /// TempPassword is returned ONCE — show it to the admin and never log it.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request) public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{ {
try try
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary> /// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
[HttpPut("{id}")] [HttpPut("{id}")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request) public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
{ {
try { await _users.UpdateAsync(id, request); return NoContent(); } try { await _users.UpdateAsync(id, request); return NoContent(); }
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary> /// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HasPermission(Modules.Users, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(string id) public async Task<IActionResult> Deactivate(string id)
{ {
try { await _users.DeactivateAsync(id); return NoContent(); } try { await _users.DeactivateAsync(id); return NoContent(); }
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary> /// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
[HttpPost("{id}/reset-password")] [HttpPost("{id}/reset-password")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> ResetPassword(string id) public async Task<IActionResult> ResetPassword(string id)
{ {
try try
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Auth;
public class ChangePasswordRequest
{
[Required]
[MaxLength(128)]
public string CurrentPassword { get; set; } = null!;
[Required]
[MinLength(8)]
[MaxLength(128)]
public string NewPassword { get; set; } = null!;
}
+26
View File
@@ -1,3 +1,5 @@
using ROLAC.API.DTOs.Permissions;
namespace ROLAC.API.DTOs.Auth; namespace ROLAC.API.DTOs.Auth;
public class LoginResponse public class LoginResponse
@@ -17,4 +19,28 @@ public class UserInfo
public string Email { get; set; } = null!; public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = []; public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en"; public string LanguagePreference { get; set; } = "en";
/// <summary>
/// Effective permissions (union across the user's roles), keyed by module name.
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
/// <summary>
/// The church member linked to this login account, or null for admin-only
/// accounts (no MemberId) and accounts whose member record was deleted.
/// Lets the SPA greet the user by their real name.
/// </summary>
public MemberInfo? MemberInfo { get; set; }
}
/// <summary>Minimal member identity for greeting the signed-in user.</summary>
public class MemberInfo
{
public int Id { get; set; }
public string? NickName { get; set; }
public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = "";
public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; }
} }
@@ -5,6 +5,10 @@ public class ChurchProfileDto
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = ""; public string Name { get; set; } = "";
public string? NameZh { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? Website { get; set; }
public string? Address { get; set; } public string? Address { get; set; }
public string? City { get; set; } public string? City { get; set; }
public string? State { get; set; } public string? State { get; set; }
@@ -12,12 +16,22 @@ public class ChurchProfileDto
public string? BankName { get; set; } public string? BankName { get; set; }
public string? BankAccountNumber { get; set; } public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; } public string? BankRoutingNumber { get; set; }
public string? PayerEin { get; set; }
public int NextCheckNumber { get; set; } public int NextCheckNumber { get; set; }
public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; }
public string? ClaudeApiKeyMasked { get; set; }
public string? GeminiModel { get; set; }
public string? GeminiApiKeyMasked { get; set; }
} }
public class UpdateChurchProfileRequest public class UpdateChurchProfileRequest
{ {
[Required, MaxLength(200)] public string Name { get; set; } = ""; [Required, MaxLength(200)] public string Name { get; set; } = "";
[MaxLength(200)] public string? NameZh { get; set; }
[MaxLength(50)] public string? Phone { get; set; }
[MaxLength(200), EmailAddress] public string? Email { get; set; }
[MaxLength(300)] public string? Website { get; set; }
[MaxLength(500)] public string? Address { get; set; } [MaxLength(500)] public string? Address { get; set; }
[MaxLength(100)] public string? City { get; set; } [MaxLength(100)] public string? City { get; set; }
[MaxLength(50)] public string? State { get; set; } [MaxLength(50)] public string? State { get; set; }
@@ -25,5 +39,11 @@ public class UpdateChurchProfileRequest
[MaxLength(200)] public string? BankName { get; set; } [MaxLength(200)] public string? BankName { get; set; }
[MaxLength(50)] public string? BankAccountNumber { get; set; } [MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { get; set; } [MaxLength(50)] public string? BankRoutingNumber { get; set; }
[MaxLength(20)] public string? PayerEin { get; set; }
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; } [Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; }
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
[MaxLength(100)] public string? GeminiModel { get; set; }
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
} }
@@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
/// <summary>Request body for the expense AI assist endpoint.</summary>
public class ExpenseAiAssistRequest
{
/// <summary>The user's free-text expense description (typically Chinese).</summary>
[Required] public string Text { get; set; } = "";
/// <summary>The expense amount, used as a hint when classifying the category.</summary>
public decimal Amount { get; set; }
}
/// <summary>
/// AI suggestion for an expense: an English translation of the description plus a proposed
/// major category (大項) and sub-category (系項). Category ids are null when the model could
/// not confidently classify or returned an id outside the live catalog.
/// </summary>
public class ExpenseAiSuggestion
{
public string? EnglishDescription { get; set; }
/// <summary>Typo-corrected, refined Traditional Chinese description.</summary>
public string? ChineseDescription { get; set; }
public int? GroupId { get; set; }
public int? SubCategoryId { get; set; }
/// <summary>Bilingual label of the suggested group, e.g. "Consumables / 消耗品".</summary>
public string? GroupLabel { get; set; }
/// <summary>Bilingual label of the suggested sub-category, e.g. "Batteries / 電池".</summary>
public string? SubLabel { get; set; }
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
public double Confidence { get; set; }
}
/// <summary>
/// Request body for the expense-category AI assist endpoint: refine the name, translate to English,
/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited.
/// </summary>
public class ExpenseCategoryAiRequest
{
/// <summary>The user-typed Chinese name (the primary input).</summary>
public string Name_zh { get; set; } = "";
/// <summary>The English name, if already typed (extra context for the model).</summary>
public string? Name_en { get; set; }
/// <summary>"group" (大項) or "sub" (小項); selects the prompt framing.</summary>
public string Level { get; set; } = "group";
/// <summary>For a sub-category: the parent group's bilingual name, used for context.</summary>
public string? ParentGroupName { get; set; }
/// <summary>For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice.</summary>
public int? ParentForm990LineId { get; set; }
}
/// <summary>
/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a
/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog.
/// </summary>
public class CategoryAiSuggestion
{
/// <summary>Typo-corrected, refined Traditional Chinese name.</summary>
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
/// <summary>Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地".</summary>
public string? Form990LineLabel { get; set; }
/// <summary>Model self-reported confidence in the mapping, 0..1.</summary>
public double Confidence { get; set; }
}
@@ -9,6 +9,10 @@ public class ExpenseSubCategoryDto
public string? Name_zh { get; set; } public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
public int? Form1099BoxId { get; set; }
public string? Form1099BoxCode { get; set; }
} }
public class ExpenseCategoryGroupDto public class ExpenseCategoryGroupDto
@@ -18,6 +22,10 @@ public class ExpenseCategoryGroupDto
public string? Name_zh { get; set; } public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int? Form990LineId { get; set; }
public string? Form990LineCode { get; set; }
public int? Form1099BoxId { get; set; }
public string? Form1099BoxCode { get; set; }
public List<ExpenseSubCategoryDto> SubCategories { get; set; } = []; public List<ExpenseSubCategoryDto> SubCategories { get; set; } = [];
} }
@@ -26,6 +34,8 @@ public class CreateExpenseGroupRequest
[Required, MaxLength(200)] public string Name_en { get; set; } = ""; [Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; } [MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
public int? Form1099BoxId { get; set; }
} }
public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest public class UpdateExpenseGroupRequest : CreateExpenseGroupRequest
{ {
@@ -38,6 +48,8 @@ public class CreateExpenseSubCategoryRequest
[Required, MaxLength(200)] public string Name_en { get; set; } = ""; [Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; } [MaxLength(200)] public string? Name_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public int? Form990LineId { get; set; }
public int? Form1099BoxId { get; set; }
} }
public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest public class UpdateExpenseSubCategoryRequest : CreateExpenseSubCategoryRequest
{ {
+34 -11
View File
@@ -1,50 +1,73 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense; namespace ROLAC.API.DTOs.Expense;
public class ExpenseLineItemDto
{
public int Id { get; set; }
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public string? FunctionalClass { get; set; }
public decimal Amount { get; set; }
public string? Description { get; set; }
}
public class ExpenseListItemDto public class ExpenseListItemDto
{ {
public int Id { get; set; } public int Id { get; set; }
public string Type { get; set; } = ""; public string Type { get; set; } = "";
public string Status { get; set; } = ""; public string Status { get; set; } = "";
public decimal Amount { get; set; } public decimal Amount { get; set; } // header total = sum of line amounts
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public int MinistryId { get; set; } public int MinistryId { get; set; }
public string MinistryName { get; set; } = ""; public string MinistryName { get; set; } = "";
public int CategoryGroupId { get; set; } public int LineCount { get; set; }
public string CategoryGroupName { get; set; } = ""; public string PrimaryCategoryName { get; set; } = ""; // first line's category (list hint; full breakdown via detail)
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public string? VendorName { get; set; } public string? VendorName { get; set; }
public int? MemberId { get; set; } public int? MemberId { get; set; }
public string? MemberName { get; set; } public string? MemberName { get; set; } // legal name "FirstName_en LastName_en" (used on the printed check)
public string? MemberNickName { get; set; } // "NickName LastName_en"; null when the member has no distinct nickname
public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd public string ExpenseDate { get; set; } = ""; // yyyy-MM-dd
public bool HasReceipt { get; set; } public bool HasReceipt { get; set; }
public string? CheckNumber { get; set; } public string? CheckNumber { get; set; }
// Review outcome — surfaced on the list so the Status column can show "Approved/Rejected by X · date".
public string? ReviewedByName { get; set; } // resolved Member full name, email fallback
public DateTimeOffset? ReviewedAt { get; set; }
public string? ReviewNotes { get; set; } // reject reason (or approval note)
public int? PayeeId { get; set; }
} }
public class ExpenseDto : ExpenseListItemDto public class ExpenseDto : ExpenseListItemDto
{ {
public string? Notes { get; set; } public string? Notes { get; set; }
public string? ReviewNotes { get; set; }
public string? SubmittedBy { get; set; } public string? SubmittedBy { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public DateTimeOffset? PaidAt { get; set; } public DateTimeOffset? PaidAt { get; set; }
public List<ExpenseLineItemDto> Lines { get; set; } = new();
}
public class ExpenseLineInput
{
[Required] public int CategoryGroupId { get; set; }
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[MaxLength(20)] public string? FunctionalClass { get; set; }
[MaxLength(500)] public string? Description { get; set; }
} }
public class CreateExpenseRequest public class CreateExpenseRequest
{ {
[Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement [Required] public string Type { get; set; } = "StaffReimbursement"; // VendorPayment|StaffReimbursement
[Required] public int MinistryId { get; set; } [Required] public int MinistryId { get; set; }
[Required] public int CategoryGroupId { get; set; } [Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
[Required] public int SubCategoryId { get; set; }
[Range(0.01, 9_999_999)] public decimal Amount { get; set; }
[Required, MaxLength(500)] public string Description { get; set; } = ""; [Required, MaxLength(500)] public string Description { get; set; } = "";
[MaxLength(200)] public string? VendorName { get; set; } [MaxLength(200)] public string? VendorName { get; set; }
public int? MemberId { get; set; } // ignored for self-service (server uses caller) public int? MemberId { get; set; } // ignored for self-service (server uses caller)
[MaxLength(50)] public string? CheckNumber { get; set; } [MaxLength(50)] public string? CheckNumber { get; set; }
[Required] public DateOnly ExpenseDate { get; set; } [Required] public DateOnly ExpenseDate { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int? PayeeId { get; set; }
} }
public class UpdateExpenseRequest : CreateExpenseRequest { } public class UpdateExpenseRequest : CreateExpenseRequest { }
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
public class ExpenseSnapshotLineDto
{
public int CategoryGroupId { get; set; }
public string CategoryGroupName { get; set; } = "";
public int SubCategoryId { get; set; }
public string SubCategoryName { get; set; } = "";
public string? FunctionalClass { get; set; }
public decimal Amount { get; set; }
public string? Description { get; set; }
}
public class ExpenseSnapshotDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public int MinistryId { get; set; }
public string MinistryName { get; set; } = "";
public string Description { get; set; } = "";
public string? VendorName { get; set; }
public string? CheckNumber { get; set; }
public string? Notes { get; set; }
public decimal TotalAmount { get; set; } // sum of line amounts (list hint)
public int LineCount { get; set; }
public string? CreatedByName { get; set; } // resolved Member full name, email fallback
public DateTimeOffset CreatedAt { get; set; }
public List<ExpenseSnapshotLineDto> Lines { get; set; } = new();
}
public class CreateExpenseSnapshotRequest
{
[Required, MaxLength(150)] public string Name { get; set; } = "";
[Required] public int MinistryId { get; set; }
[Required, MinLength(1)] public List<ExpenseLineInput> Lines { get; set; } = new();
[Required, MaxLength(500)] public string Description { get; set; } = "";
[MaxLength(200)] public string? VendorName { get; set; }
[MaxLength(50)] public string? CheckNumber { get; set; }
public string? Notes { get; set; }
}
public class UpdateExpenseSnapshotRequest : CreateExpenseSnapshotRequest { }
@@ -0,0 +1,52 @@
namespace ROLAC.API.DTOs.Finance;
public class Form1099BoxDto
{
public int Id { get; set; }
public string BoxCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public string FormType { get; set; } = "";
public int SortOrder { get; set; }
}
public class Form1099RecipientRowDto
{
public int PayeeId { get; set; }
public string LegalName { get; set; } = "";
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public decimal NecTotal { get; set; }
public decimal RentsTotal { get; set; }
public decimal GrandTotal { get; set; }
public bool MeetsThreshold { get; set; }
public bool W9Missing { get; set; }
}
public class Form1099SummaryDto
{
public int TaxYear { get; set; }
public List<Form1099RecipientRowDto> Rows { get; set; } = [];
public decimal TotalReportable { get; set; }
public int RecipientsAtThreshold { get; set; }
public int RecipientsMissingW9 { get; set; }
}
public class Form1099PaymentDto
{
public string PaidDate { get; set; } = "";
public string Description { get; set; } = "";
public string CategoryName { get; set; } = "";
public string BoxCode { get; set; } = "";
public decimal Amount { get; set; }
}
public class Form1099RecipientDetailDto
{
public int PayeeId { get; set; }
public string LegalName { get; set; } = "";
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public int TaxYear { get; set; }
public List<Form1099PaymentDto> Payments { get; set; } = [];
}
@@ -0,0 +1,35 @@
namespace ROLAC.API.DTOs.Finance;
/// <summary>One Part IX row: a 990 line split across the three functional columns.</summary>
public class FunctionalExpenseRowDto
{
public string LineCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public decimal Program { get; set; }
public decimal ManagementGeneral { get; set; }
public decimal Fundraising { get; set; }
public decimal Total { get; set; }
}
/// <summary>The full Part IX Statement of Functional Expenses for a date range.</summary>
public class FunctionalExpenseStatementDto
{
public List<FunctionalExpenseRowDto> Rows { get; set; } = [];
public decimal ProgramTotal { get; set; }
public decimal ManagementGeneralTotal { get; set; }
public decimal FundraisingTotal { get; set; }
public decimal GrandTotal { get; set; }
/// <summary>Expenses with no explicit 990 mapping (counted under line 24). Prompts mapping cleanup.</summary>
public int UnmappedExpenseCount { get; set; }
}
/// <summary>A single IRS Form 990 expense line from the catalog (used to populate mapping dropdowns).</summary>
public class Form990ExpenseLineDto
{
public int Id { get; set; }
public string LineCode { get; set; } = "";
public string Name_en { get; set; } = "";
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
}
@@ -9,4 +9,5 @@ public class MemberTypeaheadDto
public string? NickName { get; set; } public string? NickName { get; set; }
public string FirstName_en { get; set; } = ""; public string FirstName_en { get; set; } = "";
public string LastName_en { get; set; } = ""; public string LastName_en { get; set; } = "";
public string? Entity { get; set; } // company / business name (公司行號), if any
} }
@@ -11,4 +11,5 @@ public class OfferingSessionListItemDto
public decimal Difference { get; set; } public decimal Difference { get; set; }
public int LineCount { get; set; } public int LineCount { get; set; }
public bool HasProof { get; set; } public bool HasProof { get; set; }
public int? SundayAttendanceCount { get; set; } // null = no attendance recorded for the date
} }
@@ -11,5 +11,6 @@ public class QuickAddMemberRequest
[MaxLength(100)] public string? NickName { get; set; } [MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; } [MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; } [MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(30)] public string? PhoneCell { get; set; } [MaxLength(30)] public string? PhoneCell { get; set; }
} }
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Invitations;
/// <summary>
/// Admin request to generate a first-login invitation link for a member. If the member has no
/// account yet, one is auto-created (no password) using <see cref="Email"/> or the member's email.
/// </summary>
public class CreateInvitationRequest
{
[Required]
public int MemberId { get; set; }
/// <summary>Optional override for the login email when the member has none on file.</summary>
public string? Email { get; set; }
/// <summary>Roles to assign when an account is created. Defaults to ["member"].</summary>
public List<string>? Roles { get; set; }
}
/// <summary>Result of generating an invitation — the raw token is returned ONCE.</summary>
public class CreateInvitationResult
{
public string Token { get; set; } = null!;
public DateTime ExpiresAt { get; set; }
}
/// <summary>Admin request to e-mail an already-generated invitation link to the member.</summary>
public class SendInvitationRequest
{
[Required]
public int MemberId { get; set; }
[Required]
public string Link { get; set; } = null!;
}
/// <summary>Public result describing whether an invitation token can still be used.</summary>
public class ValidateInvitationResult
{
public bool Valid { get; set; }
public bool Expired { get; set; }
public string? MemberName { get; set; }
public string? Email { get; set; }
}
/// <summary>Public request to consume an invitation and set the account password.</summary>
public class AcceptInvitationRequest
{
[Required]
public string Token { get; set; } = null!;
[Required]
[StringLength(128, MinimumLength = 8)]
public string NewPassword { get; set; } = null!;
}
@@ -0,0 +1,50 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the Audit Logs grid (no heavy Changes JSON).</summary>
public class AuditLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Action { get; set; } = null!;
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? Summary { get; set; }
public string? UserId { get; set; }
public string? UserEmail { get; set; }
}
/// <summary>Full detail for the Audit Log dialog, including the before→after JSON.</summary>
public class AuditLogDetailDto : AuditLogListItemDto
{
public string? Changes { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Filters for the paged Audit Logs query.</summary>
public class AuditLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
public string? Category { get; set; }
public string? Action { get; set; }
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? UserId { get; set; }
public LogLevelEnum? MinLevel { get; set; }
public string? Search { get; set; }
}
/// <summary>Option lists for the Audit Logs filter UI.</summary>
public class AuditCatalogDto
{
public IReadOnlyList<string> Categories { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
public IReadOnlyList<string> Levels { get; set; } = [];
}
@@ -0,0 +1,43 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the System Logs grid (no heavy exception text).</summary>
public class SystemLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Category { get; set; } = null!;
public string Message { get; set; } = null!;
public bool HasException { get; set; }
public int? StatusCode { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Full detail for the System Log dialog, including the stack trace.</summary>
public class SystemLogDetailDto : SystemLogListItemDto
{
public int? EventId { get; set; }
public string? Exception { get; set; }
public string? IpAddress { get; set; }
}
/// <summary>Filters for the paged System Logs query.</summary>
public class SystemLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
/// <summary>Lower bound on severity (inclusive).</summary>
public LogLevelEnum? MinLevel { get; set; }
/// <summary>Exact severity match (takes precedence over MinLevel when set).</summary>
public LogLevelEnum? Level { get; set; }
public string? Search { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
@@ -0,0 +1,9 @@
namespace ROLAC.API.DTOs.MealAttendance;
/// <summary>Absolute head-counts to write for one Sunday, from the back-office editor.</summary>
public class SetAttendanceRequest
{
public int Adult { get; set; }
public int Youth { get; set; }
public int Kid { get; set; }
}
@@ -8,6 +8,7 @@ public class CreateMemberRequest
[MaxLength(100)] public string? NickName { get; set; } [MaxLength(100)] public string? NickName { get; set; }
[MaxLength(100)] public string? FirstName_zh { get; set; } [MaxLength(100)] public string? FirstName_zh { get; set; }
[MaxLength(100)] public string? LastName_zh { get; set; } [MaxLength(100)] public string? LastName_zh { get; set; }
[MaxLength(200)] public string? Entity { get; set; }
[MaxLength(10)] public string? Gender { get; set; } [MaxLength(10)] public string? Gender { get; set; }
public DateOnly? DateOfBirth { get; set; } public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; } public DateOnly? BaptismDate { get; set; }
@@ -8,6 +8,7 @@ public class MemberListItemDto
public string? NickName { get; set; } public string? NickName { get; set; }
public string? FirstName_zh { get; set; } public string? FirstName_zh { get; set; }
public string? LastName_zh { get; set; } public string? LastName_zh { get; set; }
public string? Entity { get; set; }
public string Status { get; set; } = ""; public string Status { get; set; } = "";
public string? Email { get; set; } public string? Email { get; set; }
public string? PhoneCell { get; set; } public string? PhoneCell { get; set; }
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Ministry;
public class CreateMinistryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public int SortOrder { get; set; }
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
}
@@ -5,6 +5,9 @@ public class MinistryDto
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = ""; public string Name_en { get; set; } = "";
public string? Name_zh { get; set; } public string? Name_zh { get; set; }
public string? Description_en { get; set; }
public string? Description_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public string DefaultFunctionalClass { get; set; } = "Program";
} }
@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Ministry;
public class UpdateMinistryRequest
{
[Required, MaxLength(200)] public string Name_en { get; set; } = "";
[MaxLength(200)] public string? Name_zh { get; set; }
[MaxLength(500)] public string? Description_en { get; set; }
[MaxLength(500)] public string? Description_zh { get; set; }
public bool IsActive { get; set; } = true;
public int SortOrder { get; set; }
[MaxLength(20)] public string? DefaultFunctionalClass { get; set; }
}
@@ -0,0 +1,28 @@
namespace ROLAC.API.DTOs.Notifications;
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
public sealed class LineWebhookPayload
{
public List<LineWebhookEvent>? Events { get; set; }
}
public sealed class LineWebhookEvent
{
public string? Type { get; set; } // follow | message | join | leave | ...
public string? ReplyToken { get; set; }
public LineWebhookSource? Source { get; set; }
public LineWebhookMessage? Message { get; set; }
}
public sealed class LineWebhookSource
{
public string? Type { get; set; } // user | group | room
public string? UserId { get; set; }
public string? GroupId { get; set; }
}
public sealed class LineWebhookMessage
{
public string? Type { get; set; } // text | image | ...
public string? Text { get; set; }
}
@@ -0,0 +1,7 @@
namespace ROLAC.API.DTOs.Notifications;
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
+54
View File
@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Payee;
public class Payee1099ListItemDto
{
public int Id { get; set; }
public string LegalName { get; set; } = "";
public string? DisplayName { get; set; }
public int? MemberId { get; set; }
public string? MemberName { get; set; }
public string TaxClassification { get; set; } = "";
public bool Is1099Tracked { get; set; }
public string? TinType { get; set; }
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public bool IsActive { get; set; }
}
public class Payee1099Dto : Payee1099ListItemDto
{
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? W9ReceivedDate { get; set; }
public bool HasW9Document { get; set; }
public string? Notes { get; set; }
}
public class SavePayee1099Request
{
[Required, MaxLength(200)] public string LegalName { get; set; } = "";
[MaxLength(200)] public string? DisplayName { get; set; }
public int? MemberId { get; set; }
[Required, MaxLength(40)] public string TaxClassification { get; set; } = "Individual";
public bool Is1099Tracked { get; set; } = true;
[MaxLength(10)] public string? TinType { get; set; }
/// <summary>Plain TIN; null = leave unchanged on update. Encrypted server-side.</summary>
public string? Tin { get; set; }
[MaxLength(100)] public string? AddressLine1 { get; set; }
[MaxLength(100)] public string? AddressLine2 { get; set; }
[MaxLength(60)] public string? City { get; set; }
[MaxLength(2)] public string? State { get; set; }
[MaxLength(10)] public string? Zip { get; set; }
[MaxLength(120)] public string? Email { get; set; }
[MaxLength(40)] public string? Phone { get; set; }
[MaxLength(20)] public string W9Status { get; set; } = "Missing";
public DateOnly? W9ReceivedDate { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
}
@@ -0,0 +1,53 @@
namespace ROLAC.API.DTOs.Permissions;
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
public class ModuleActions
{
public bool Read { get; set; }
public bool Write { get; set; }
public bool Delete { get; set; }
public bool Approve { get; set; }
public bool Any => Read || Write || Delete || Approve;
}
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
public class ModulePermissionDto
{
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
}
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
public class RolePermissionRow
{
public string RoleName { get; set; } = null!;
public string? Description { get; set; }
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
public bool IsSuperAdmin { get; set; }
public List<ModulePermissionDto> Modules { get; set; } = [];
}
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
public class PermissionMatrixDto
{
public IReadOnlyList<string> AllModules { get; set; } = [];
public IReadOnlyList<string> AllActions { get; set; } = [];
public List<RolePermissionRow> Roles { get; set; } = [];
}
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
public class PermissionCatalogDto
{
public IReadOnlyList<string> Modules { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
}
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
public class UpdateRolePermissionsRequest
{
public List<ModulePermissionDto> Modules { get; set; } = [];
}
@@ -0,0 +1,80 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Settings;
// ── Site settings ──────────────────────────────────────────────────────────
public class SiteSettingDto
{
public string SiteTitle { get; set; } = "";
public string? SiteTitleZh { get; set; }
public string DefaultLanguage { get; set; } = "en";
public string TimeZone { get; set; } = "";
public string DateFormat { get; set; } = "";
public string Currency { get; set; } = "";
}
public class UpdateSiteSettingRequest
{
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
[MaxLength(200)] public string? SiteTitleZh { get; set; }
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
[Required, MaxLength(10)] public string Currency { get; set; } = "";
}
// ── Notification settings ──────────────────────────────────────────────────
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
// shows a write-only field where a blank value on update means "keep the stored secret".
public class NotificationSettingDto
{
public bool EnableEmail { get; set; }
public string SmtpHost { get; set; } = "";
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public string SmtpUser { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
public bool HasSmtpPassword { get; set; }
public bool EnableLine { get; set; }
public bool HasLineChannelAccessToken { get; set; }
public bool HasLineChannelSecret { get; set; }
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
public string WebhookUrl { get; set; } = "";
}
public class UpdateNotificationSettingRequest
{
public bool EnableEmail { get; set; }
[MaxLength(200)] public string SmtpHost { get; set; } = "";
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
public bool SmtpUseSsl { get; set; } = true;
[MaxLength(200)] public string SmtpUser { get; set; } = "";
[MaxLength(200)] public string? FromAddress { get; set; }
[MaxLength(200)] public string? FromName { get; set; }
/// <summary>Blank = keep the stored password unchanged.</summary>
[MaxLength(500)] public string? SmtpPassword { get; set; }
public bool EnableLine { get; set; }
/// <summary>Blank = keep the stored token unchanged.</summary>
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
/// <summary>Blank = keep the stored secret unchanged.</summary>
[MaxLength(200)] public string? LineChannelSecret { get; set; }
}
// ── Test-send requests ─────────────────────────────────────────────────────
public class TestEmailRequest
{
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
}
public class TestLineRequest
{
public int? MemberId { get; set; }
public int? GroupId { get; set; }
}
+257 -2
View File
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
namespace ROLAC.API.Data; namespace ROLAC.API.Data;
@@ -9,6 +11,7 @@ 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<UserInvitation> UserInvitations => Set<UserInvitation>();
public DbSet<Member> Members => Set<Member>(); public DbSet<Member> Members => Set<Member>();
public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>(); public DbSet<FamilyUnit> FamilyUnits => Set<FamilyUnit>();
public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>(); public DbSet<GivingCategory> GivingCategories => Set<GivingCategory>();
@@ -17,12 +20,27 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<Ministry> Ministries => Set<Ministry>(); public DbSet<Ministry> Ministries => Set<Ministry>();
public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>(); public DbSet<ExpenseCategoryGroup> ExpenseCategoryGroups => Set<ExpenseCategoryGroup>();
public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>(); public DbSet<ExpenseSubCategory> ExpenseSubCategories => Set<ExpenseSubCategory>();
public DbSet<Form990ExpenseLine> Form990ExpenseLines => Set<Form990ExpenseLine>();
public DbSet<Payee1099> Payee1099s => Set<Payee1099>();
public DbSet<Form1099Box> Form1099Boxes => Set<Form1099Box>();
public DbSet<Expense> Expenses => Set<Expense>(); public DbSet<Expense> Expenses => Set<Expense>();
public DbSet<ExpenseLine> ExpenseLines => Set<ExpenseLine>();
public DbSet<ExpenseSnapshot> ExpenseSnapshots => Set<ExpenseSnapshot>();
public DbSet<ExpenseSnapshotLine> ExpenseSnapshotLines => Set<ExpenseSnapshotLine>();
public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>(); public DbSet<MonthlyStatement> MonthlyStatements => Set<MonthlyStatement>();
public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>(); public DbSet<ChurchProfile> ChurchProfiles => Set<ChurchProfile>();
public DbSet<Check> Checks => Set<Check>(); public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>(); public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>(); public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@@ -45,6 +63,23 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Ignore(e => e.IsActive); entity.Ignore(e => e.IsActive);
}); });
// ── UserInvitation (single-use, expiring first-login links) ─────────
builder.Entity<UserInvitation>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.TokenHash).IsUnique();
entity.Property(e => e.TokenHash).HasMaxLength(64).IsRequired();
entity.Property(e => e.UserId).HasMaxLength(450).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.UserId);
entity.HasOne(e => e.User).WithMany()
.HasForeignKey(e => e.UserId).OnDelete(DeleteBehavior.Cascade);
entity.Ignore(e => e.IsExpired);
entity.Ignore(e => e.IsUsed);
entity.Ignore(e => e.IsRevoked);
entity.Ignore(e => e.IsActive);
});
// ── AppUser (unchanged + new unique index on MemberId) ────────────── // ── AppUser (unchanged + new unique index on MemberId) ──────────────
builder.Entity<AppUser>(entity => builder.Entity<AppUser>(entity =>
{ {
@@ -60,6 +95,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Description).HasMaxLength(500); entity.Property(e => e.Description).HasMaxLength(500);
}); });
// ── RolePermission (configurable RBAC matrix) ───────────────────────
builder.Entity<RolePermission>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
// One row per (role, module).
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
entity.HasOne(e => e.Role).WithMany()
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
});
// ── FamilyUnit ────────────────────────────────────────────────────── // ── FamilyUnit ──────────────────────────────────────────────────────
builder.Entity<FamilyUnit>(entity => builder.Entity<FamilyUnit>(entity =>
{ {
@@ -77,6 +124,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.NickName).HasMaxLength(100); entity.Property(e => e.NickName).HasMaxLength(100);
entity.Property(e => e.FirstName_zh).HasMaxLength(100); entity.Property(e => e.FirstName_zh).HasMaxLength(100);
entity.Property(e => e.LastName_zh).HasMaxLength(100); entity.Property(e => e.LastName_zh).HasMaxLength(100);
entity.Property(e => e.Entity).HasMaxLength(200);
entity.Property(e => e.Gender).HasMaxLength(10); entity.Property(e => e.Gender).HasMaxLength(10);
entity.Property(e => e.BaptismChurch).HasMaxLength(200); entity.Property(e => e.BaptismChurch).HasMaxLength(200);
entity.Property(e => e.Email).HasMaxLength(200); entity.Property(e => e.Email).HasMaxLength(200);
@@ -158,6 +206,57 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
{ {
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired(); entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.DefaultFunctionalClass).HasMaxLength(20).HasDefaultValue("Program");
});
// ── Form990ExpenseLine (Part IX natural-expense line catalog) ─────────
builder.Entity<Form990ExpenseLine>(entity =>
{
entity.Property(e => e.LineCode).HasMaxLength(10).IsRequired();
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.LineCode).IsUnique();
});
// ── Form1099Box (1099 reporting box catalog) ──────────────────────────
builder.Entity<Form1099Box>(entity =>
{
entity.Property(e => e.BoxCode).HasMaxLength(10).IsRequired();
entity.Property(e => e.Name_en).HasMaxLength(200).IsRequired();
entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.FormType).HasMaxLength(20).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.BoxCode).IsUnique();
});
// ── Payee1099 (1099 recipient master) ────────────────────────────────
builder.Entity<Payee1099>(entity =>
{
entity.HasQueryFilter(p => !p.IsDeleted);
entity.Property(e => e.LegalName).HasMaxLength(200).IsRequired();
entity.Property(e => e.DisplayName).HasMaxLength(200);
entity.Property(e => e.TaxClassification).HasMaxLength(40).IsRequired();
entity.Property(e => e.TinType).HasMaxLength(10);
entity.Property(e => e.TinLast4).HasMaxLength(4);
entity.Property(e => e.State).HasMaxLength(2);
entity.Property(e => e.Zip).HasMaxLength(10);
entity.Property(e => e.W9Status).HasMaxLength(20).HasDefaultValue(Form1099.W9Status.Missing);
entity.Property(e => e.AddressLine1).HasMaxLength(200);
entity.Property(e => e.AddressLine2).HasMaxLength(200);
entity.Property(e => e.City).HasMaxLength(100);
entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(30);
entity.Property(e => e.Notes).HasMaxLength(500);
entity.Property(e => e.W9BlobPath).HasMaxLength(500);
entity.Property(e => e.TinEncrypted).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── ExpenseCategoryGroup ───────────────────────────────────────────── // ── ExpenseCategoryGroup ─────────────────────────────────────────────
@@ -167,6 +266,10 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Name_zh).HasMaxLength(200); entity.Property(e => e.Name_zh).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Form1099Box).WithMany()
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── ExpenseSubCategory ─────────────────────────────────────────────── // ── ExpenseSubCategory ───────────────────────────────────────────────
@@ -178,6 +281,10 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasOne(e => e.Group).WithMany(g => g.SubCategories) entity.HasOne(e => e.Group).WithMany(g => g.SubCategories)
.HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.GroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Form990Line).WithMany()
.HasForeignKey(e => e.Form990LineId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Form1099Box).WithMany()
.HasForeignKey(e => e.Form1099BoxId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── Expense ────────────────────────────────────────────────────────── // ── Expense ──────────────────────────────────────────────────────────
@@ -206,12 +313,73 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasOne(e => e.Ministry).WithMany() entity.HasOne(e => e.Ministry).WithMany()
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Payee).WithMany()
.HasForeignKey(e => e.PayeeId).OnDelete(DeleteBehavior.SetNull);
});
// ── ExpenseLine (category breakdown of one Expense) ──────────────────
builder.Entity<ExpenseLine>(entity =>
{
// Mirror the parent Expense's soft-delete filter (required relationship).
entity.HasQueryFilter(l => !l.Expense!.IsDeleted);
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.ExpenseId);
entity.HasOne(e => e.Expense).WithMany(x => x.Lines)
.HasForeignKey(e => e.ExpenseId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CategoryGroup).WithMany()
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.SubCategory).WithMany()
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
});
// ── ExpenseSnapshot (reusable vendor-payment template) ───────────────
builder.Entity<ExpenseSnapshot>(entity =>
{
entity.HasQueryFilter(s => !s.IsDeleted);
entity.Property(e => e.Name).HasMaxLength(150).IsRequired();
entity.Property(e => e.Description).HasMaxLength(500).IsRequired();
entity.Property(e => e.VendorName).HasMaxLength(200);
entity.Property(e => e.CheckNumber).HasMaxLength(50);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.DeletedBy).HasMaxLength(450);
entity.HasIndex(e => e.CreatedAt);
entity.HasOne(e => e.Ministry).WithMany()
.HasForeignKey(e => e.MinistryId).OnDelete(DeleteBehavior.Restrict);
});
// ── ExpenseSnapshotLine (category breakdown of one snapshot) ─────────
builder.Entity<ExpenseSnapshotLine>(entity =>
{
// Mirror the parent snapshot's soft-delete filter (required relationship).
entity.HasQueryFilter(l => !l.Snapshot!.IsDeleted);
entity.Property(e => e.FunctionalClass).HasMaxLength(20);
entity.Property(e => e.Amount).HasColumnType("decimal(18,2)");
entity.Property(e => e.Description).HasMaxLength(500);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => e.SnapshotId);
entity.HasOne(e => e.Snapshot).WithMany(x => x.Lines)
.HasForeignKey(e => e.SnapshotId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.CategoryGroup).WithMany() entity.HasOne(e => e.CategoryGroup).WithMany()
.HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.CategoryGroupId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.SubCategory).WithMany() entity.HasOne(e => e.SubCategory).WithMany()
.HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(e => e.SubCategoryId).OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
}); });
// ── ChurchProfile (singleton settings) ─────────────────────────────── // ── ChurchProfile (singleton settings) ───────────────────────────────
@@ -225,12 +393,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.BankName).HasMaxLength(200); entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50); entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50); entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.PayerEin).HasMaxLength(20);
entity.Property(e => e.NameZh).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(50);
entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.Website).HasMaxLength(300);
entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
// Optimistic-concurrency token for safe check-number allocation. // Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion(); entity.Property(e => e.xmin).IsRowVersion();
}); });
// ── SiteSetting (singleton presentation/locale settings) ─────────────
builder.Entity<SiteSetting>(entity =>
{
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
builder.Entity<NotificationSetting>(entity =>
{
entity.Property(e => e.SmtpHost).HasMaxLength(200);
entity.Property(e => e.SmtpUser).HasMaxLength(200);
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
entity.Property(e => e.FromAddress).HasMaxLength(200);
entity.Property(e => e.FromName).HasMaxLength(200);
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
});
// ── Check (disbursement) ───────────────────────────────────────────── // ── Check (disbursement) ─────────────────────────────────────────────
builder.Entity<Check>(entity => builder.Entity<Check>(entity =>
{ {
@@ -311,5 +516,55 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique(); entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
}); });
// ── Notifications (email + Line) ─────────────────────────────────────
builder.Entity<MemberChannelBinding>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<LineBindingCode>(entity =>
{
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
entity.HasIndex(e => e.Code);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessagingGroup>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(200);
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
});
builder.Entity<NotificationLog>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
entity.Property(e => e.Subject).HasMaxLength(300);
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.SentAt);
entity.HasIndex(e => e.Channel);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.MessagingGroup).WithMany()
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
});
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
// context, so business code can't write logs through the audited context.
// Runtime reads/writes go through the dedicated LogDbContext. Including
// them in the model lets the single startup migration create the tables.
LogModelConfiguration.Configure(builder);
} }
} }
+344 -8
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ROLAC.API.Authorization;
using ROLAC.API.Entities; using ROLAC.API.Entities;
namespace ROLAC.API.Data; namespace ROLAC.API.Data;
@@ -27,6 +28,8 @@ public static class DbSeeder
("Hospitality", "招待", 8), ("Hospitality", "招待", 8),
("Children", "兒牧", 9), ("Children", "兒牧", 9),
("Catering", "餐飲", 10), ("Catering", "餐飲", 10),
("Cell Groups", "小組牧養", 11),
("Special Events", "特別活動", 12),
]; ];
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)]) // (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
@@ -34,15 +37,132 @@ public static class DbSeeder
[ [
("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]), ("Equipment", "設備", 1, [("Purchase","購置"),("Rental","租借"),("Maintenance & Repair","維修")]),
("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]), ("Consumables", "消耗品", 2, [("Batteries","電池"),("Accessories","配件"),("Cleaning Supplies","清潔用品"),("Office Supplies","文具")]),
("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Consumables","消耗品")]), ("Food & Beverage", "餐飲", 3, [("Catering","出餐費用"),("Food Ingredients","食材採購"),("Utensils","器具"),("Disposable Tableware","一次性餐具")]),
("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]), ("Training", "培訓", 4, [("Course Fees","課程費用"),("Books","書籍"),("Conference","研討會"),("Travel","差旅")]),
("Materials", "教材", 5, [("Printing","印刷費用"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]), ("Materials", "教材", 5, [("Curriculum Printing","教材印刷"),("Craft Supplies","手工材料"),("Copyright & Licensing","版權購買")]),
("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾")]), ("Facility", "場地", 6, [("Rent","場地租金"),("Utilities","水電"),("Property Insurance","財產保險"),("Decoration","裝飾"),("Repairs & Maintenance","修繕維護")]),
("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報")]), ("Printing", "印刷", 7, [("Bulletins","週報"),("Order of Service","程序單"),("Posters","海報"),("Advertising & Promotion","廣告推廣")]),
("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Travel","差旅")]), ("Missions", "宣教", 8, [("Offering Transfer","奉獻轉帳"),("Missionary Support","宣教士支援"),("Foreign Missions Support","國外宣教支援"),("Travel","差旅")]),
("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]), ("Benevolence", "關懷救助", 9, [("Emergency Aid","急難救助"),("Condolence Gifts","慰問禮品"),("Visit Expenses","探訪費用")]),
("Other", "其他", 10, [("Miscellaneous","雜支")]), ("Other", "其他", 10, [("Miscellaneous","雜支"),("Gifts","禮品")]),
("Personnel", "人事", 11, [("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]), ("Personnel", "人事", 11, [("Officer / Key Employee Compensation","主要職員薪酬"),("Salary & Wages","薪資"),("Payroll Taxes","薪資稅費"),("Employee Benefits","員工福利"),("Retirement / Pension","退休金"),("Workers Compensation","勞工保險"),("Honorarium","酬庸"),("Staff Training","同工進修"),("Contract Labor","外包勞務")]),
("Professional Services", "專業服務", 12, [("Legal","法律服務"),("Accounting & Audit","會計與審計"),("Other Professional","其他專業服務")]),
("Information Technology", "資訊科技", 13, [("Software & Subscriptions","軟體與訂閱"),("Website & Hosting","網站與主機"),("Internet & Telecom","網路與電信")]),
("Finance & Banking", "財務與銀行", 14, [("Interest","利息支出"),("Bank & Processing Fees","銀行/金流手續費")]),
];
// (LineCode, Name_en, Name_zh, Sort)
private static readonly (string Code, string En, string Zh, int Sort)[] Form990LineSeed =
[
("1", "Grants to domestic organizations", "對國內機構之捐贈", 1),
("2", "Grants to domestic individuals", "對國內個人之捐贈", 2),
("3", "Grants to foreign organizations/individuals", "對國外之捐贈", 3),
("5", "Compensation of current officers / key employees", "主要職員/負責人薪酬", 4),
("7", "Other salaries and wages", "薪資", 5),
("8", "Pension plan accruals and contributions", "退休金提撥", 6),
("9", "Other employee benefits", "員工福利", 7),
("10", "Payroll taxes", "薪資稅", 8),
("11b", "Legal fees", "法律服務費", 9),
("11c", "Accounting fees", "會計與審計費", 10),
("11g", "Other fees for services (non-employee)", "其他勞務報酬(非員工)", 11),
("12", "Advertising and promotion", "廣告與推廣", 12),
("13", "Office expenses", "辦公費用", 13),
("14", "Information technology", "資訊科技", 14),
("16", "Occupancy", "場地佔用", 15),
("17", "Travel", "差旅", 16),
("19", "Conferences, conventions, and meetings", "會議與研習", 17),
("20", "Interest", "利息", 18),
("22", "Depreciation", "折舊", 19),
("23", "Insurance", "保險", 20),
("24", "Other expenses", "其他費用", 21),
];
// (GroupEn, SubEn, LineCode) — default natural-category → 990 line mapping.
private static readonly (string GroupEn, string SubEn, string Code)[] Form990SubMappingSeed =
[
("Personnel", "Officer / Key Employee Compensation", "5"),
("Personnel", "Salary & Wages", "7"),
("Personnel", "Payroll Taxes", "10"),
("Personnel", "Employee Benefits", "9"),
("Personnel", "Retirement / Pension","8"),
("Personnel", "Workers Compensation","9"),
("Personnel", "Honorarium", "11g"),
("Personnel", "Contract Labor", "11g"),
("Personnel", "Staff Training", "19"),
("Facility", "Rent", "16"),
("Facility", "Utilities", "16"),
("Facility", "Property Insurance", "23"),
("Facility", "Decoration", "24"),
// Building repairs & maintenance (plumbing, electrical, painting) are part of Occupancy.
("Facility", "Repairs & Maintenance", "16"),
("Training", "Course Fees", "19"),
("Training", "Conference", "19"),
("Training", "Books", "24"),
("Training", "Travel", "17"),
("Missions", "Travel", "17"),
// Domestic missions support is paid to individual missionaries/families → line 2 (grants to individuals).
("Missions", "Offering Transfer", "2"),
("Missions", "Missionary Support", "2"),
("Missions", "Foreign Missions Support", "3"),
("Benevolence", "Emergency Aid", "2"),
("Benevolence", "Condolence Gifts", "2"),
// Visitation is the church's own travel/program cost, not a grant to an individual.
("Benevolence", "Visit Expenses", "17"),
("Consumables", "Office Supplies", "13"),
// General supplies belong with office expenses (line 13), not the "Other" catch-all.
("Consumables", "Batteries", "13"),
("Consumables", "Accessories", "13"),
("Consumables", "Cleaning Supplies", "13"),
// IRS line 13 covers equipment rental and maintenance.
("Equipment", "Rental", "13"),
("Equipment", "Maintenance & Repair", "13"),
("Printing", "Bulletins", "13"),
("Printing", "Order of Service", "13"),
("Printing", "Posters", "12"),
("Printing", "Advertising & Promotion", "12"),
("Materials", "Curriculum Printing", "13"),
// Classroom/craft supplies fall under IRS line 13 office expenses ("supplies… classroom…").
("Materials", "Craft Supplies", "13"),
("Professional Services", "Legal", "11b"),
("Professional Services", "Accounting & Audit", "11c"),
("Professional Services", "Other Professional", "11g"),
("Information Technology", "Software & Subscriptions", "14"),
("Information Technology", "Website & Hosting", "14"),
("Information Technology", "Internet & Telecom", "14"),
("Finance & Banking", "Interest", "20"),
// Bank/processing fees are office expenses per IRS line 13 (consistent with Interest → 20).
("Finance & Banking", "Bank & Processing Fees", "13"),
// Appreciation/outreach gifts have no natural 990 line; mapped to 24 explicitly so this
// deliberate "Other" choice doesn't inflate UnmappedExpenseCount. (Benevolence gifts → line 2.)
("Other", "Gifts", "24"),
];
private static readonly (string Code, string En, string Zh, string FormType, int Sort)[] Form1099BoxSeed =
[
(Form1099.BoxNec1, "Nonemployee compensation", "非員工報酬", "1099-NEC", 1),
(Form1099.BoxMisc1, "Rents", "租金", "1099-MISC", 2),
];
// Only service/rent subcategories get a box. Everything else stays unmapped (not reportable).
private static readonly (string GroupEn, string SubEn, string Code)[] Form1099SubMappingSeed =
[
("Personnel", "Honorarium", Form1099.BoxNec1),
("Personnel", "Contract Labor", Form1099.BoxNec1),
("Professional Services", "Legal", Form1099.BoxNec1),
("Professional Services", "Accounting & Audit", Form1099.BoxNec1),
("Professional Services", "Other Professional", Form1099.BoxNec1),
("Facility", "Rent", Form1099.BoxMisc1),
];
// One-time corrections for subcategories that were mapped to the WRONG line in an earlier
// seed. The normal mapping loop below only fills NULLs, so it cannot fix an existing bad
// value — this block does. Idempotent: each row fires only while the subcategory still holds
// the OLD line, so it never clobbers a deliberate admin re-mapping. (GroupEn, SubEn, Old, New)
private static readonly (string GroupEn, string SubEn, string OldCode, string NewCode)[] Form990RemapSeed =
[
("Benevolence", "Visit Expenses", "2", "17"),
("Missions", "Missionary Support", "1", "2"),
("Missions", "Offering Transfer", "1", "2"),
]; ];
private static readonly (string Name, string Description)[] Roles = private static readonly (string Name, string Description)[] Roles =
@@ -62,6 +182,91 @@ public static class DbSeeder
("visitor", "Visitor — public pages only"), ("visitor", "Visitor — public pages only"),
]; ];
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
// existed before the configurable RBAC system, so day-one behavior is unchanged.
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
// later edits via the Permissions UI are never clobbered on restart.
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
[
// Secretary — manages member data.
("secretary", Modules.Members, true, true, true, false),
// Pastor — read-only overview of members and all expenses.
("pastor", Modules.Members, true, false, false, false),
("pastor", Modules.Expenses, true, false, false, false),
// Finance — full control over the finance modules.
("finance", Modules.Givings, true, true, true, false),
("finance", Modules.GivingCategories, true, true, true, false),
("finance", Modules.Expenses, true, true, true, true),
("finance", Modules.ExpenseCategories, true, true, true, false),
("finance", Modules.OfferingSessions, true, true, true, true),
("finance", Modules.FinanceDashboard, true, false, false, false),
("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false),
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
("finance", Modules.Form1099, true, true, true, false),
("pastor", Modules.Form1099, true, false, false, false),
("board_member", Modules.Form1099, true, false, false, false),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
("pastor", Modules.Form990Report, true, false, false, false),
("board_member", Modules.Form990Report, true, false, false, false),
// Ministries — secretary maintains the list; coworker_chair edits; ministry
// leaders and pastor read.
("secretary", Modules.Ministries, true, true, true, false),
("coworker_chair", Modules.Ministries, true, true, false, false),
("ministry_leader", Modules.Ministries, true, false, false, false),
("pastor", Modules.Ministries, true, false, false, false),
// Meal attendance — secretary and coworkers record; finance and pastor read.
("secretary", Modules.MealAttendance, true, true, false, false),
("coworker", Modules.MealAttendance, true, true, false, false),
("finance", Modules.MealAttendance, true, false, false, false),
("pastor", Modules.MealAttendance, true, false, false, false),
// Users, Permissions, and Settings are intentionally super_admin-only:
// super_admin bypasses all checks, so no seed rows are needed here.
];
public static async Task SeedRolePermissionsAsync(AppDbContext db)
{
var rolesByName = await db.Roles
.Where(r => r.Name != null)
.ToDictionaryAsync(r => r.Name!, r => r.Id);
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
{
if (!rolesByName.TryGetValue(role, out var roleId))
continue;
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
if (exists)
continue; // never clobber an admin's edit
db.RolePermissions.Add(new RolePermission
{
RoleId = roleId,
Module = module,
CanRead = read,
CanWrite = write,
CanDelete = delete,
CanApprove = approve,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager) public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
{ {
foreach (var (name, description) in Roles) foreach (var (name, description) in Roles)
@@ -101,13 +306,35 @@ public static class DbSeeder
foreach (var (en, zh, sort) in MinistrySeed) foreach (var (en, zh, sort) in MinistrySeed)
{ {
if (!await db.Ministries.AnyAsync(m => m.Name_en == en)) if (!await db.Ministries.AnyAsync(m => m.Name_en == en))
db.Ministries.Add(new Ministry { Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true }); db.Ministries.Add(new Ministry
{
Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true,
DefaultFunctionalClass = en == "Administration"
? FunctionalClasses.ManagementGeneral
: FunctionalClasses.Program,
});
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public static async Task SeedExpenseCategoriesAsync(AppDbContext db) public static async Task SeedExpenseCategoriesAsync(AppDbContext db)
{ {
// One-time renames to remove same-name-different-parent ambiguity. Idempotent:
// only fires while the old name still exists. (New installs never hit this.)
var renames = new (string GroupEn, string OldSub, string NewEn, string NewZh)[]
{
("Food & Beverage", "Consumables", "Disposable Tableware", "一次性餐具"),
("Materials", "Printing", "Curriculum Printing", "教材印刷"),
};
foreach (var (groupEn, oldSub, newEn, newZh) in renames)
{
var grp = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == groupEn);
if (grp is null) continue;
var sub = await db.ExpenseSubCategories.FirstOrDefaultAsync(s => s.GroupId == grp.Id && s.Name_en == oldSub);
if (sub is not null) { sub.Name_en = newEn; sub.Name_zh = newZh; }
}
await db.SaveChangesAsync();
foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed) foreach (var (gEn, gZh, gSort, subs) in ExpenseCategorySeed)
{ {
var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn); var group = await db.ExpenseCategoryGroups.FirstOrDefaultAsync(g => g.Name_en == gEn);
@@ -130,6 +357,65 @@ public static class DbSeeder
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public static async Task SeedForm990ExpenseLinesAsync(AppDbContext db)
{
foreach (var (code, en, zh, sort) in Form990LineSeed)
{
if (!await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == code))
db.Form990ExpenseLines.Add(new Form990ExpenseLine
{ LineCode = code, Name_en = en, Name_zh = zh, SortOrder = sort, IsActive = true });
}
await db.SaveChangesAsync();
var linesByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var fallbackId = linesByCode["24"];
// Every group defaults to line 24 (safety net); precise mapping lives on subcategories.
foreach (var group in await db.ExpenseCategoryGroups.ToListAsync())
group.Form990LineId ??= fallbackId;
// Subcategory default mappings — only set when not already mapped (never clobber an admin edit).
var subsByKey = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form990SubMappingSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form990LineId is null && linesByCode.TryGetValue(code, out var lineId))
sub.Form990LineId = lineId;
}
// Correct earlier mis-mappings on existing databases (see Form990RemapSeed). Only fires
// while the subcategory still holds the OLD line, so a later admin edit is never clobbered.
foreach (var (groupEn, subEn, oldCode, newCode) in Form990RemapSeed)
{
var sub = subsByKey.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is null) continue;
if (linesByCode.TryGetValue(oldCode, out var oldId)
&& linesByCode.TryGetValue(newCode, out var newId)
&& sub.Form990LineId == oldId)
sub.Form990LineId = newId;
}
await db.SaveChangesAsync();
}
public static async Task SeedForm1099BoxesAsync(AppDbContext db)
{
foreach (var (code, en, zh, formType, sort) in Form1099BoxSeed)
if (!await db.Form1099Boxes.AnyAsync(b => b.BoxCode == code))
db.Form1099Boxes.Add(new Form1099Box
{ BoxCode = code, Name_en = en, Name_zh = zh, FormType = formType, SortOrder = sort, IsActive = true });
await db.SaveChangesAsync();
var boxesByCode = await db.Form1099Boxes.ToDictionaryAsync(b => b.BoxCode, b => b.Id);
var subs = await db.ExpenseSubCategories.Include(s => s.Group).ToListAsync();
foreach (var (groupEn, subEn, code) in Form1099SubMappingSeed)
{
var sub = subs.FirstOrDefault(s => s.Group!.Name_en == groupEn && s.Name_en == subEn);
if (sub is not null && sub.Form1099BoxId is null && boxesByCode.TryGetValue(code, out var boxId))
sub.Form1099BoxId = boxId;
}
await db.SaveChangesAsync();
}
public static async Task SeedChurchProfileAsync(AppDbContext db) public static async Task SeedChurchProfileAsync(AppDbContext db)
{ {
// Singleton row used by the disbursement module (issuer info + check counter). // Singleton row used by the disbursement module (issuer info + check counter).
@@ -146,6 +432,50 @@ public static class DbSeeder
} }
} }
public static async Task SeedSiteSettingAsync(AppDbContext db)
{
// Singleton row holding site-wide presentation/locale settings.
if (!await db.SiteSettings.AnyAsync())
{
db.SiteSettings.Add(new SiteSetting
{
SiteTitle = "River Of Life Christian Church",
SiteTitleZh = "生命河靈糧堂",
DefaultLanguage = "en",
TimeZone = "America/Los_Angeles",
DateFormat = "yyyy-MM-dd",
Currency = "USD",
});
await db.SaveChangesAsync();
}
}
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
{
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
if (!await db.NotificationSettings.AnyAsync())
{
var smtp = config.GetSection("Smtp");
var line = config.GetSection("Line");
db.NotificationSettings.Add(new NotificationSetting
{
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
SmtpHost = smtp["Host"] ?? "",
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
SmtpUser = smtp["User"] ?? "",
SmtpPassword = smtp["Password"] ?? "",
FromAddress = smtp["FromAddress"] ?? "",
FromName = smtp["FromName"] ?? "",
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
LineChannelSecret = line["ChannelSecret"] ?? "",
});
await db.SaveChangesAsync();
}
}
/// <summary> /// <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.
@@ -155,14 +485,20 @@ public static class DbSeeder
var roleManager = services.GetRequiredService<RoleManager<AppRole>>(); var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
var userManager = services.GetRequiredService<UserManager<AppUser>>(); var userManager = services.GetRequiredService<UserManager<AppUser>>();
var env = services.GetRequiredService<IWebHostEnvironment>(); var env = services.GetRequiredService<IWebHostEnvironment>();
var config = services.GetRequiredService<IConfiguration>();
await SeedRolesAsync(roleManager); await SeedRolesAsync(roleManager);
var db = services.GetRequiredService<AppDbContext>(); var db = services.GetRequiredService<AppDbContext>();
await SeedRolePermissionsAsync(db);
await SeedGivingCategoriesAsync(db); await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db); await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db); await SeedExpenseCategoriesAsync(db);
await SeedForm990ExpenseLinesAsync(db);
await SeedForm1099BoxesAsync(db);
await SeedChurchProfileAsync(db); await SeedChurchProfileAsync(db);
await SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config);
if (env.IsDevelopment()) if (env.IsDevelopment())
await SeedAdminUserAsync(userManager); await SeedAdminUserAsync(userManager);
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
/// <summary>
/// Writes a before→after <see cref="AuditLog"/> row for every Create/Update/Delete of an
/// <see cref="IAuditable"/> entity. Two-phase: snapshot changed values BEFORE save (while
/// original values are still available), then — AFTER save succeeds — read DB-generated keys and
/// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't
/// fail the user's transaction, and never recurses through AppDbContext.
/// </summary>
public sealed class AuditLogInterceptor : SaveChangesInterceptor
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
private readonly List<PendingAudit> _pending = [];
public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
Capture(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Capture(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
Flush();
return base.SavedChanges(eventData, result);
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
Flush();
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear();
public override Task SaveChangesFailedAsync(
DbContextErrorEventData eventData, CancellationToken cancellationToken = default)
{
_pending.Clear();
return Task.CompletedTask;
}
// ── Phase 1: snapshot before save ─────────────────────────────────────────
private void Capture(DbContext? db)
{
if (db is null)
return;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is not IAuditable)
continue;
switch (entry.State)
{
case EntityState.Added:
_pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true)));
break;
case EntityState.Deleted:
_pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null));
break;
case EntityState.Modified:
var before = new Dictionary<string, object?>();
var after = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (!property.IsModified)
continue;
var name = property.Metadata.Name;
before[name] = Read(name, property.OriginalValue);
after[name] = Read(name, property.CurrentValue);
}
if (after.Count == 0)
break; // no real change (e.g. only audit timestamps touched on a no-op)
// A soft-delete (IsDeleted false→true) reads more naturally as a Delete.
var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update;
_pending.Add(new PendingAudit(entry, action, before, after));
break;
}
}
}
// ── Phase 2: keys exist, enqueue ──────────────────────────────────────────
private void Flush()
{
if (_pending.Count == 0)
return;
var userId = _currentUser.UserId;
var userEmail = _currentUser.Email;
var ip = _currentUser.IpAddress;
var corr = _currentUser.CorrelationId;
foreach (var item in _pending)
{
_queue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Information,
Action = item.Action,
Category = AuditCategories.DataChange,
EntityName = item.Entry.Metadata.ClrType.Name,
EntityId = ReadKey(item.Entry),
Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After),
UserId = userId,
UserEmail = userEmail,
IpAddress = ip,
CorrelationId = corr,
});
}
_pending.Clear();
}
private static Dictionary<string, object?> BuildValues(EntityEntry entry, bool current)
{
var values = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (property.Metadata.IsPrimaryKey())
continue;
var name = property.Metadata.Name;
values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue);
}
return values;
}
private static object? Read(string propertyName, object? value) =>
AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value;
private static bool IsSoftDelete(Dictionary<string, object?> after) =>
after.TryGetValue("IsDeleted", out var value) && value is true;
private static string? ReadKey(EntityEntry entry)
{
var key = entry.Metadata.FindPrimaryKey();
if (key is null)
return null;
var parts = key.Properties
.Select(p => entry.Property(p.Name).CurrentValue?.ToString())
.Where(v => v is not null);
return string.Join(",", parts);
}
private sealed record PendingAudit(
EntityEntry Entry,
string Action,
Dictionary<string, object?>? Before,
Dictionary<string, object?>? After);
}
@@ -1,15 +1,15 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors; namespace ROLAC.API.Data.Interceptors;
public class AuditSaveChangesInterceptor : SaveChangesInterceptor public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{ {
private readonly IHttpContextAccessor _http; private readonly CurrentUserAccessor _currentUser;
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
public override InterceptionResult<int> SavingChanges( public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result) DbContextEventData eventData, InterceptionResult<int> result)
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{ {
if (db is null) return; if (db is null) return;
var userId = _http.HttpContext?.User var userId = _currentUser.UserIdOrSystem;
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
foreach (var entry in db.ChangeTracker.Entries()) foreach (var entry in db.ChangeTracker.Entries())
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// A minimal, write-mostly context dedicated to the SystemLog / AuditLog tables. It is the
/// structural break that prevents log-storms: it is registered WITHOUT the audit interceptors
/// and with a silent logger factory (see Program.cs), so persisting a log row produces no log
/// events that the DB sink would pick up. It shares the same physical database/connection as
/// AppDbContext, but the tables themselves are created by AppDbContext's migration — they are
/// only mapped here so this context can read/write them.
/// </summary>
public class LogDbContext : DbContext
{
public LogDbContext(DbContextOptions<LogDbContext> options) : base(options) { }
public DbSet<SystemLog> SystemLogs => Set<SystemLog>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
LogModelConfiguration.Configure(builder);
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// Single source of truth for the SystemLog / AuditLog table schema. Applied by
/// <see cref="AppDbContext"/> (so the startup migration creates the tables) AND by
/// <see cref="LogDbContext"/> (so runtime reads/writes map to the same shape).
/// </summary>
public static class LogModelConfiguration
{
public static void Configure(ModelBuilder builder)
{
builder.Entity<SystemLog>(entity =>
{
entity.ToTable("SystemLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Category).HasMaxLength(256).IsRequired();
entity.Property(e => e.Message).IsRequired(); // text
entity.Property(e => e.RequestPath).HasMaxLength(2048);
entity.Property(e => e.HttpMethod).HasMaxLength(10);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.Level);
entity.HasIndex(e => new { e.Timestamp, e.Level });
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
builder.Entity<AuditLog>(entity =>
{
entity.ToTable("AuditLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Action).HasMaxLength(40).IsRequired();
entity.Property(e => e.Category).HasMaxLength(40).IsRequired();
entity.Property(e => e.EntityName).HasMaxLength(128);
entity.Property(e => e.EntityId).HasMaxLength(64);
entity.Property(e => e.Changes).HasColumnType("jsonb");
entity.Property(e => e.Summary).HasMaxLength(512);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.UserEmail).HasMaxLength(256);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => new { e.Category, e.Timestamp });
entity.HasIndex(e => new { e.EntityName, e.EntityId });
entity.HasIndex(e => e.Action);
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
}
}
+14 -2
View File
@@ -157,6 +157,8 @@ rows AS (
mi."Id" AS ministry_id, mi."Id" AS ministry_id,
gp."Id" AS group_id, gp."Id" AS group_id,
sc."Id" AS sub_id, sc."Id" AS sub_id,
-- pre-allocate the expense id so the matching ExpenseLine can reference it
nextval(pg_get_serial_sequence('"Expenses"','Id')) AS new_id,
sp.is_reimb, sp.is_reimb,
sp.vendor, sp.vendor,
sp.descr, sp.descr,
@@ -172,13 +174,14 @@ rows AS (
JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp JOIN "ExpenseCategoryGroups" gp ON gp."Name_en" = sp.grp
JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id" JOIN "ExpenseSubCategories" sc ON sc."Name_en" = sp.sub AND sc."GroupId" = gp."Id"
) )
, ins_exp AS (
INSERT INTO "Expenses" INSERT INTO "Expenses"
("MinistryId","CategoryGroupId","SubCategoryId","Type","Status","Amount", ("Id","MinistryId","Type","Status","Amount",
"Description","VendorName","MemberId","CheckNumber","ExpenseDate", "Description","VendorName","MemberId","CheckNumber","ExpenseDate",
"Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt", "Notes","SubmittedBy","SubmittedAt","ReviewedBy","ReviewedAt","PaidBy","PaidAt",
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted") "CreatedAt","CreatedBy","UpdatedAt","UpdatedBy","IsDeleted")
SELECT SELECT
r.ministry_id, r.group_id, r.sub_id, r.new_id, r.ministry_id,
CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END, CASE WHEN r.is_reimb THEN 'StaffReimbursement' ELSE 'VendorPayment' END,
r.status, r.status,
r.amount, r.amount,
@@ -196,6 +199,15 @@ SELECT
CASE WHEN r.status = 'Paid' THEN 'mockdata' END, CASE WHEN r.status = 'Paid' THEN 'mockdata' END,
CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END, CASE WHEN r.status = 'Paid' THEN r.expense_date::timestamptz END,
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata', false
FROM rows r
)
-- one line per mock expense (single-category), mirroring the migrated production shape
INSERT INTO "ExpenseLines"
("ExpenseId","CategoryGroupId","SubCategoryId","FunctionalClass","Amount","Description",
"CreatedAt","CreatedBy","UpdatedAt","UpdatedBy")
SELECT
r.new_id, r.group_id, r.sub_id, NULL, r.amount, NULL,
r.expense_date::timestamptz, 'mockdata', r.expense_date::timestamptz, 'mockdata'
FROM rows r; FROM rows r;
COMMIT; COMMIT;

Some files were not shown because too many files have changed in this diff Show More