Compare commits

103 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
187 changed files with 28598 additions and 485 deletions
@@ -22,6 +22,7 @@
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Services.Ai;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchAiConfigProviderTests
{
private static AppDbContext NewDb() =>
new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);
[Fact]
public async Task GetAsync_returns_defaults_when_no_profile_row()
{
using var db = NewDb(); // empty DB, no ChurchProfile
var cfg = await new ChurchAiConfigProvider(db).GetAsync();
Assert.Equal("Claude", cfg.Provider);
Assert.Equal("claude-haiku-4-5-20251001", cfg.ClaudeModel);
Assert.Equal("gemini-2.5-flash-lite", cfg.GeminiModel);
Assert.Null(cfg.ClaudeApiKey);
Assert.Null(cfg.GeminiApiKey);
}
}
@@ -0,0 +1,103 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ChurchProfileServiceTests
{
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
// interceptor the app uses so seeded entities save cleanly.
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
.Options);
}
private static UpdateChurchProfileRequest Req(
string provider = "Claude", string? claudeKey = null, string? geminiKey = null,
string? claudeModel = "m", string? geminiModel = "m") =>
new()
{
Name = "C", NextCheckNumber = 1001, AiProvider = provider,
ClaudeModel = claudeModel, GeminiModel = geminiModel,
ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey,
};
[Fact]
public async Task GetAsync_masks_stored_api_keys()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile
{
Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876",
});
await db.SaveChangesAsync();
var dto = await new ChurchProfileService(db).GetAsync();
Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked);
Assert.Equal("••••••9876", dto.GeminiApiKeyMasked);
}
[Fact]
public async Task UpdateAsync_blank_key_keeps_existing()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-keep-0001", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_nonblank_key_replaces()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("sk-new-9999", p.ClaudeApiKey);
}
[Fact]
public async Task UpdateAsync_sets_provider_and_models()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C" });
await db.SaveChangesAsync();
await new ChurchProfileService(db).UpdateAsync(
Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y"));
var p = await db.ChurchProfiles.FirstAsync();
Assert.Equal("Gemini", p.AiProvider);
Assert.Equal("claude-x", p.ClaudeModel);
Assert.Equal("gemini-y", p.GeminiModel);
}
}
@@ -0,0 +1,142 @@
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class DbSeederForm990Tests
{
private static AppDbContext BuildDb()
{
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "seed") })) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SeedExpenseCategories_AddsNewGroups_RenamesDuplicates_AndIsIdempotent()
{
using var db = BuildDb();
var fnb = new ExpenseCategoryGroup { Name_en = "Food & Beverage", Name_zh = "餐飲", SortOrder = 3 };
db.ExpenseCategoryGroups.Add(fnb);
await db.SaveChangesAsync();
db.ExpenseSubCategories.Add(new ExpenseSubCategory { GroupId = fnb.Id, Name_en = "Consumables", Name_zh = "消耗品" });
await db.SaveChangesAsync();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedExpenseCategoriesAsync(db); // idempotent second run
var groups = await db.ExpenseCategoryGroups.ToListAsync();
Assert.Contains(groups, g => g.Name_en == "Professional Services");
Assert.Contains(groups, g => g.Name_en == "Information Technology");
Assert.Contains(groups, g => g.Name_en == "Finance & Banking");
var fnbSubs = await db.ExpenseSubCategories.Where(s => s.GroupId == fnb.Id).ToListAsync();
Assert.DoesNotContain(fnbSubs, s => s.Name_en == "Consumables");
Assert.Contains(fnbSubs, s => s.Name_en == "Disposable Tableware");
Assert.Single(groups, g => g.Name_en == "Professional Services");
}
[Fact]
public async Task SeedMinistries_SetsAdministrationToManagementGeneral_OthersProgram()
{
using var db = BuildDb();
await DbSeeder.SeedMinistriesAsync(db);
var admin = await db.Ministries.FirstAsync(m => m.Name_en == "Administration");
var worship = await db.Ministries.FirstAsync(m => m.Name_en == "Worship");
Assert.Equal("ManagementGeneral", admin.DefaultFunctionalClass);
Assert.Equal("Program", worship.DefaultFunctionalClass);
// Activity/shepherding ministries are an attribution axis only; they default to Program
// so adding them never distorts the 990 functional columns.
var cellGroups = await db.Ministries.FirstAsync(m => m.Name_en == "Cell Groups");
var specialEvents = await db.Ministries.FirstAsync(m => m.Name_en == "Special Events");
Assert.Equal("Program", cellGroups.DefaultFunctionalClass);
Assert.Equal("Program", specialEvents.DefaultFunctionalClass);
}
[Fact]
public async Task SeedForm990Lines_CreatesCatalog_AndMapsKnownSubcategories()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db); // idempotent
Assert.Equal(1, await db.Form990ExpenseLines.CountAsync(l => l.LineCode == "7"));
Assert.True(await db.Form990ExpenseLines.AnyAsync(l => l.LineCode == "24"));
var salary = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Salary & Wages");
Assert.Equal("7", salary.Form990Line!.LineCode);
var audit = await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == "Accounting & Audit");
Assert.Equal("11c", audit.Form990Line!.LineCode);
}
[Fact]
public async Task SeedForm990Lines_MapsAuditCorrectedSubcategories_OffTheLine24CatchAll()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
async Task<string> CodeOf(string subEn) =>
(await db.ExpenseSubCategories.Include(s => s.Form990Line)
.FirstAsync(s => s.Name_en == subEn)).Form990Line!.LineCode;
// Newly mapped subcategories that previously fell through to line 24.
Assert.Equal("13", await CodeOf("Bank & Processing Fees"));
Assert.Equal("13", await CodeOf("Rental"));
Assert.Equal("13", await CodeOf("Maintenance & Repair"));
Assert.Equal("13", await CodeOf("Cleaning Supplies"));
Assert.Equal("13", await CodeOf("Craft Supplies"));
// Building repairs & maintenance are part of Occupancy (line 16), not equipment (line 13).
Assert.Equal("16", await CodeOf("Repairs & Maintenance"));
// Appreciation/outreach gifts are deliberately mapped to Other (line 24), not left unmapped.
Assert.Equal("24", await CodeOf("Gifts"));
// Visitation is a travel/program cost, not a grant to an individual.
Assert.Equal("17", await CodeOf("Visit Expenses"));
// Missions support paid to individual missionaries → line 2, not line 1 (organizations).
Assert.Equal("2", await CodeOf("Missionary Support"));
}
[Fact]
public async Task SeedForm990Lines_RemapsExistingBadMapping_ButNotAdminOverride()
{
using var db = BuildDb();
await DbSeeder.SeedExpenseCategoriesAsync(db);
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
// Simulate a database seeded by the OLD code: Visit Expenses on line 2, Missionary
// Support on line 1. Also simulate an admin who deliberately moved one elsewhere.
var lineByCode = await db.Form990ExpenseLines.ToDictionaryAsync(l => l.LineCode, l => l.Id);
var visit = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Visit Expenses");
var missionary = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Missionary Support");
var transfer = await db.ExpenseSubCategories.FirstAsync(s => s.Name_en == "Offering Transfer");
visit.Form990LineId = lineByCode["2"]; // old (wrong) value → should be corrected
missionary.Form990LineId = lineByCode["1"]; // old (wrong) value → should be corrected
transfer.Form990LineId = lineByCode["24"]; // admin override → must be left alone
await db.SaveChangesAsync();
await DbSeeder.SeedForm990ExpenseLinesAsync(db);
await db.Entry(visit).ReloadAsync();
await db.Entry(missionary).ReloadAsync();
await db.Entry(transfer).ReloadAsync();
Assert.Equal(lineByCode["17"], visit.Form990LineId); // corrected 2 → 17
Assert.Equal(lineByCode["2"], missionary.Form990LineId); // corrected 1 → 2
Assert.Equal(lineByCode["24"], transfer.Form990LineId); // admin edit preserved
}
}
@@ -65,6 +65,8 @@ public class DisbursementServiceTests
var db = BuildDb(userId); var db = BuildDb(userId);
db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 }); db.ChurchProfiles.Add(new ChurchProfile { Id = 1, Name = "ROLAC", NextCheckNumber = 1001 });
db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" }); db.Members.Add(new Member { Id = 1, FirstName_en = "John", LastName_en = "Doe", Address = "1 Main St", City = "Arcadia", State = "CA", ZipCode = "91006" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 1, Name_en = "Equipment" });
db.ExpenseCategoryGroups.Add(new ExpenseCategoryGroup { Id = 2, Name_en = "Food & Beverage" });
db.SaveChanges(); db.SaveChanges();
var fs = new FakeStorage(); var fs = new FakeStorage();
return (SvcAs(db, fs, userId), db, fs); return (SvcAs(db, fs, userId), db, fs);
@@ -73,8 +75,9 @@ public class DisbursementServiceTests
private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new() private static Expense Approved(string type, decimal amount, int? memberId = null, string? vendor = null) => new()
{ {
Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}", Type = type, Status = "Approved", Amount = amount, Description = $"{type} {amount}",
MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1),
MemberId = memberId, VendorName = vendor, MemberId = memberId, VendorName = vendor,
Lines = { new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = amount } },
}; };
[Fact] [Fact]
@@ -97,6 +100,28 @@ public class DisbursementServiceTests
Assert.Equal("1 Main St", member.Address); Assert.Equal("1 Main St", member.Address);
} }
[Fact]
public async Task GroupedWorklist_MultiCategoryExpense_ShowsMultipleLabel()
{
var (svc, db, _) = Build();
db.Expenses.Add(new Expense
{
Type = "VendorPayment", Status = "Approved", Amount = 50m, Description = "mixed invoice",
MinistryId = 1, ExpenseDate = new DateOnly(2026, 6, 1), VendorName = "Costco",
Lines =
{
new ExpenseLine { CategoryGroupId = 1, SubCategoryId = 1, Amount = 30m },
new ExpenseLine { CategoryGroupId = 2, SubCategoryId = 2, Amount = 20m },
},
});
await db.SaveChangesAsync();
var groups = await svc.GetApprovedUnpaidGroupedAsync();
var line = groups.Single(g => g.PayeeType == "Vendor").Lines.Single();
Assert.Equal("Multiple / 多類別", line.CategoryName);
}
[Fact] [Fact]
public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers() public async Task Issue_CreatesOneCheckPerPayee_MarksPaid_SequentialNumbers()
{ {
@@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Ai;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseAiServiceFactoryTests
{
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
// interceptor the app uses so seeded entities save cleanly.
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
.Options);
}
private static ExpenseAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseAiService>.Instance);
var gemini = new GeminiExpenseAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.Instance);
return new ExpenseAiServiceFactory(cfg, claude, gemini);
}
[Fact]
public async Task Resolves_Claude_by_default()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<ClaudeExpenseAiService>(svc);
}
[Fact]
public async Task Resolves_Gemini_when_selected()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<GeminiExpenseAiService>(svc);
}
}
@@ -0,0 +1,69 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Ai;
using ROLAC.API.Services.Logging;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class ExpenseCategoryAiServiceFactoryTests
{
// ChurchProfile is auditable, so the InMemory store rejects saves unless the
// required CreatedBy/UpdatedBy fields are populated. Wire the same audit
// interceptor the app uses so seeded entities save cleanly.
private static AppDbContext NewDb()
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })),
};
var httpContextAccessor = new Mock<IHttpContextAccessor>();
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object)))
.Options);
}
private static ExpenseCategoryAiServiceFactory Build(AppDbContext db)
{
var cfg = new ChurchAiConfigProvider(db);
var claude = new ClaudeExpenseCategoryAiService(
new HttpClient(), cfg, db, NullLogger<ClaudeExpenseCategoryAiService>.Instance);
var gemini = new GeminiExpenseCategoryAiService(
new HttpClient(), cfg, db, NullLogger<GeminiExpenseCategoryAiService>.Instance);
return new ExpenseCategoryAiServiceFactory(cfg, claude, gemini);
}
[Fact]
public async Task Resolves_Claude_by_default()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<ClaudeExpenseCategoryAiService>(svc);
}
[Fact]
public async Task Resolves_Gemini_when_selected()
{
using var db = NewDb();
db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" });
await db.SaveChangesAsync();
var svc = await Build(db).ResolveAsync();
Assert.IsType<GeminiExpenseCategoryAiService>(svc);
}
}
@@ -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;
@@ -55,6 +58,14 @@ public class ExpenseServiceTests
return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static ExpenseService SvcAs(AppDbContext db, FakeStorage fs, string userId, IAuditLogger audit)
{
var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs, audit);
}
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier), // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
// mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false). // mirroring the real JWT (NameClaimType="sub", MapInboundClaims=false).
private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId) private static ExpenseService SvcWithSubClaim(AppDbContext db, FakeStorage fs, string userId)
@@ -67,14 +78,20 @@ public class ExpenseServiceTests
private static CreateExpenseRequest Reimb() => new() private static CreateExpenseRequest Reimb() => new()
{ {
Type = "StaffReimbursement", MinistryId = 1, CategoryGroupId = 1, SubCategoryId = 1, Type = "StaffReimbursement", MinistryId = 1,
Amount = 45.50m, Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28), Lines = { new ExpenseLineInput { CategoryGroupId = 1, SubCategoryId = 1, Amount = 45.50m } },
Description = "Batteries", ExpenseDate = new DateOnly(2026, 5, 28),
}; };
private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new() private static UpdateExpenseRequest CloneToUpdate(CreateExpenseRequest r) => new()
{ {
Type = r.Type, MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, Type = r.Type, MinistryId = r.MinistryId,
SubCategoryId = r.SubCategoryId, Amount = r.Amount, Description = r.Description, Lines = r.Lines.Select(l => new ExpenseLineInput
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
Amount = l.Amount, FunctionalClass = l.FunctionalClass, Description = l.Description,
}).ToList(),
Description = r.Description,
VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber, VendorName = r.VendorName, MemberId = r.MemberId, CheckNumber = r.CheckNumber,
ExpenseDate = r.ExpenseDate, Notes = r.Notes, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
}; };
@@ -207,7 +224,7 @@ public class ExpenseServiceTests
Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status); Assert.Equal("PendingApproval", (await db.Expenses.FindAsync(id))!.Status);
var edit = CloneToUpdate(Reimb()); var edit = CloneToUpdate(Reimb());
edit.Amount = 99.99m; edit.Lines[0].Amount = 99.99m;
await svc.UpdateAsync(id, edit, isFinance: false); await svc.UpdateAsync(id, edit, isFinance: false);
var e = await db.Expenses.FindAsync(id); var e = await db.Expenses.FindAsync(id);
@@ -248,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()
{ {
@@ -258,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
}
}
@@ -0,0 +1,60 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class MealAttendanceServiceTests
{
// MealAttendance is auditable, so the InMemory provider requires CreatedBy/UpdatedBy
// to be set before insert. Wire in the AuditSaveChangesInterceptor (as the other
// service tests do) so those columns are stamped automatically on SaveChanges.
private static AppDbContext BuildDb()
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(
new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
}
[Fact]
public async Task SetCountsAsync_CreatesRowWhenMissing_AndReturnsTotals()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
var result = await svc.SetCountsAsync(date, adult: 40, youth: 12, kid: 8);
Assert.Equal("2026-05-31", result.Date);
Assert.Equal(40, result.Adult);
Assert.Equal(12, result.Youth);
Assert.Equal(8, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date));
}
[Fact]
public async Task SetCountsAsync_OverwritesExistingRow_AndClampsNegativesToZero()
{
using var db = BuildDb();
var svc = new MealAttendanceService(db);
var date = new DateOnly(2026, 5, 31);
await svc.SetCountsAsync(date, 40, 12, 8);
var result = await svc.SetCountsAsync(date, adult: 50, youth: -3, kid: 0);
Assert.Equal(50, result.Adult);
Assert.Equal(0, result.Youth); // negative clamped to zero
Assert.Equal(0, result.Kid);
Assert.Single(db.MealAttendances.Where(a => a.AttendanceDate == date)); // still one row
}
}
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Http;
using Moq; using 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;
@@ -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);
}
} }
@@ -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);
@@ -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,30 @@
using Microsoft.AspNetCore.DataProtection;
using ROLAC.API.Services.Security;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class TinProtectorTests
{
private static TinProtector Build() =>
new TinProtector(DataProtectionProvider.Create("ROLAC.Tests"));
[Fact]
public void Protect_then_Unprotect_round_trips()
{
var p = Build();
var cipher = p.Protect("123-45-6789");
Assert.NotEqual("123-45-6789", cipher);
Assert.Equal("123-45-6789", p.Unprotect(cipher));
}
[Theory]
[InlineData("123-45-6789", "6789")]
[InlineData("12-3456789", "6789")]
[InlineData("7", "7")]
public void Last4_keeps_only_trailing_digits(string raw, string expected)
=> Assert.Equal(expected, TinProtector.Last4(raw));
[Fact]
public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null));
}
@@ -0,0 +1,21 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>Records every audit Write so tests can assert on the emitted actions/summaries.</summary>
public sealed class CapturingAuditLogger : IAuditLogger
{
public readonly record struct Entry(string Action, string Category, string? EntityName, string? EntityId, string? Summary);
public readonly List<Entry> Entries = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
Entries.Add(new Entry(action, category, entityName, entityId, summary));
}
}
+4
View File
@@ -16,6 +16,8 @@ public static class Modules
public const string OfferingSessions = "OfferingSessions"; public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries"; public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard"; public const string FinanceDashboard = "FinanceDashboard";
public const string Form990Report = "Form990Report";
public const string Form1099 = "Form1099";
public const string MonthlyStatements = "MonthlyStatements"; public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile"; public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements"; public const string Disbursements = "Disbursements";
@@ -37,6 +39,8 @@ public static class Modules
OfferingSessions, OfferingSessions,
Ministries, Ministries,
FinanceDashboard, FinanceDashboard,
Form990Report,
Form1099,
MonthlyStatements, MonthlyStatements,
ChurchProfile, ChurchProfile,
Disbursements, Disbursements,
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/expense-ai")]
[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
public class ExpenseAiController : ControllerBase
{
private readonly IExpenseAiServiceFactory _factory;
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
[HttpPost("assist")]
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Text))
return BadRequest("Text is required.");
var svc = await _factory.ResolveAsync(ct);
var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct);
return Ok(suggestion);
}
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization; using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers;
public class ExpenseCategoriesController : ControllerBase public class ExpenseCategoriesController : ControllerBase
{ {
private readonly IExpenseCategoryService _svc; private readonly IExpenseCategoryService _svc;
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc; private readonly IExpenseCategoryAiServiceFactory _aiFactory;
public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory)
{
_svc = svc;
_aiFactory = aiFactory;
}
[HttpGet] [HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false) public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
// Suggest an English name + Form 990 line for a category being defined. Write-gated: category
// editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint.
[HttpPost("ai-suggest")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en))
return BadRequest("A name is required.");
var svc = await _aiFactory.ResolveAsync(ct);
return Ok(await svc.SuggestAsync(r, ct));
}
[HttpPost("groups")] [HttpPost("groups")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r) public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
// Snapshots are reusable vendor-payment templates — a finance tool. Every action requires
// Expenses:Write (super_admin bypasses), matching who can create vendor payments.
[ApiController]
[Route("api/expense-snapshots")]
[Authorize]
public class ExpenseSnapshotsController : ControllerBase
{
private readonly IExpenseSnapshotService _svc;
private readonly IPermissionService _perms;
public ExpenseSnapshotsController(IExpenseSnapshotService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
[HttpGet]
public async Task<IActionResult> GetAll()
{
if (!await CanManageAsync()) return Forbid();
return Ok(await _svc.GetAllAsync());
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
{
if (!await CanManageAsync()) return Forbid();
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseSnapshotRequest r)
{
if (!await CanManageAsync()) return Forbid();
try { return Ok(new { id = await _svc.CreateAsync(r) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseSnapshotRequest r)
{
if (!await CanManageAsync()) return Forbid();
try { await _svc.UpdateAsync(id, r); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
if (!await CanManageAsync()) return Forbid();
try { await _svc.DeleteAsync(id); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); }
}
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/form1099-report")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Form1099ReportController : ControllerBase
{
private readonly IForm1099ReportService _svc;
private readonly I1099FormService _form;
public Form1099ReportController(IForm1099ReportService svc, I1099FormService form)
{
_svc = svc;
_form = form;
}
[HttpGet("boxes")]
public async Task<IActionResult> Boxes() => Ok(await _svc.GetBoxesAsync());
[HttpGet("summary")]
public async Task<IActionResult> Summary([FromQuery] int taxYear)
=> Ok(await _svc.GetAnnualSummaryAsync(taxYear));
[HttpGet("recipient/{payeeId:int}")]
public async Task<IActionResult> Recipient(int payeeId, [FromQuery] int taxYear)
=> await _svc.GetRecipientDetailAsync(payeeId, taxYear) is { } d ? Ok(d) : NotFound();
[HttpGet("recipient/{payeeId:int}/copy-b")]
public async Task<IActionResult> CopyB(int payeeId, [FromQuery] int taxYear)
{
var (stream, contentType, fileName) = await _form.RenderCopyBAsync(payeeId, taxYear);
return File(stream, contentType, fileName);
}
[HttpGet("export-csv")]
public async Task<IActionResult> ExportCsv([FromQuery] int taxYear)
{
var (stream, contentType, fileName) = await _form.ExportFilingCsvAsync(taxYear);
return File(stream, contentType, fileName);
}
}
@@ -0,0 +1,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.DTOs.MealAttendance;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -23,4 +24,10 @@ public class MealAttendanceController : ControllerBase
[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,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(); }
}
} }
@@ -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,
}); });
} }
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/payee-1099")]
[HasPermission(Modules.Form1099, PermissionActions.Read)]
public class Payee1099Controller : ControllerBase
{
private readonly IPayee1099Service _svc;
public Payee1099Controller(IPayee1099Service svc) => _svc = svc;
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id)
=> await _svc.GetByIdAsync(id) is { } dto ? Ok(dto) : NotFound();
[HttpPost]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] SavePayee1099Request r)
=> Ok(new { id = await _svc.CreateAsync(r) });
[HttpPut("{id:int}")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] SavePayee1099Request r)
{ await _svc.UpdateAsync(id, r); return NoContent(); }
[HttpDelete("{id:int}")]
[HasPermission(Modules.Form1099, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id)
{ await _svc.DeleteAsync(id); return NoContent(); }
// Full TIN reveal is gated on Write (a stronger right than Read).
[HttpGet("{id:int}/tin")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
public async Task<IActionResult> RevealTin(int id)
=> Ok(new { tin = await _svc.RevealTinAsync(id) });
// Mirrors the expense-receipt upload: multipart form file, size-limited, type-checked.
[HttpPost("{id:int}/w9")]
[HasPermission(Modules.Form1099, PermissionActions.Write)]
[RequestSizeLimit(10_485_760)]
public async Task<IActionResult> UploadW9(int id, IFormFile file)
{
if (file is null || file.Length == 0) return BadRequest(new { message = "No file." });
var allowed = new[] { "image/jpeg", "image/png", "image/webp", "application/pdf" };
if (!allowed.Contains(file.ContentType)) return BadRequest(new { message = "Unsupported file type." });
try
{
await using var stream = file.OpenReadStream();
await _svc.SaveW9Async(id, stream, file.FileName);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
}
// Class-level Read gate covers viewing the stored W-9 (mirrors the receipt GET).
[HttpGet("{id:int}/w9")]
public async Task<IActionResult> GetW9(int id)
{
var result = await _svc.OpenW9Async(id);
if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType);
}
}
@@ -16,7 +16,13 @@ public class ChurchProfileDto
public string? BankName { get; set; } public string? BankName { get; set; }
public string? BankAccountNumber { get; set; } public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; } public string? BankRoutingNumber { get; set; }
public string? PayerEin { get; set; }
public int NextCheckNumber { get; set; } public int NextCheckNumber { get; set; }
public string AiProvider { get; set; } = "Claude";
public string? ClaudeModel { get; set; }
public string? ClaudeApiKeyMasked { get; set; }
public string? GeminiModel { get; set; }
public string? GeminiApiKeyMasked { get; set; }
} }
public class UpdateChurchProfileRequest public class UpdateChurchProfileRequest
@@ -33,5 +39,11 @@ public class UpdateChurchProfileRequest
[MaxLength(200)] public string? BankName { get; set; } [MaxLength(200)] public string? BankName { get; set; }
[MaxLength(50)] public string? BankAccountNumber { get; set; } [MaxLength(50)] public string? BankAccountNumber { get; set; }
[MaxLength(50)] public string? BankRoutingNumber { get; set; } [MaxLength(50)] public string? BankRoutingNumber { get; set; }
[MaxLength(20)] public string? PayerEin { get; set; }
[Range(1, int.MaxValue)] public int NextCheckNumber { get; set; } [Range(1, int.MaxValue)] public int NextCheckNumber { get; set; }
[MaxLength(20)] public string AiProvider { get; set; } = "Claude";
[MaxLength(100)] public string? ClaudeModel { get; set; }
[MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged
[MaxLength(100)] public string? GeminiModel { get; set; }
[MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged
} }
@@ -0,0 +1,65 @@
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Expense;
/// <summary>Request body for the expense AI assist endpoint.</summary>
public class ExpenseAiAssistRequest
{
/// <summary>The user's free-text expense description (typically Chinese).</summary>
[Required] public string Text { get; set; } = "";
/// <summary>The expense amount, used as a hint when classifying the category.</summary>
public decimal Amount { get; set; }
}
/// <summary>
/// AI suggestion for an expense: an English translation of the description plus a proposed
/// major category (大項) and sub-category (系項). Category ids are null when the model could
/// not confidently classify or returned an id outside the live catalog.
/// </summary>
public class ExpenseAiSuggestion
{
public string? EnglishDescription { get; set; }
/// <summary>Typo-corrected, refined Traditional Chinese description.</summary>
public string? ChineseDescription { get; set; }
public int? GroupId { get; set; }
public int? SubCategoryId { get; set; }
/// <summary>Bilingual label of the suggested group, e.g. "Consumables / 消耗品".</summary>
public string? GroupLabel { get; set; }
/// <summary>Bilingual label of the suggested sub-category, e.g. "Batteries / 電池".</summary>
public string? SubLabel { get; set; }
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
public double Confidence { get; set; }
}
/// <summary>
/// Request body for the expense-category AI assist endpoint: refine the name, translate to English,
/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited.
/// </summary>
public class ExpenseCategoryAiRequest
{
/// <summary>The user-typed Chinese name (the primary input).</summary>
public string Name_zh { get; set; } = "";
/// <summary>The English name, if already typed (extra context for the model).</summary>
public string? Name_en { get; set; }
/// <summary>"group" (大項) or "sub" (小項); selects the prompt framing.</summary>
public string Level { get; set; } = "group";
/// <summary>For a sub-category: the parent group's bilingual name, used for context.</summary>
public string? ParentGroupName { get; set; }
/// <summary>For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice.</summary>
public int? ParentForm990LineId { get; set; }
}
/// <summary>
/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a
/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog.
/// </summary>
public class CategoryAiSuggestion
{
/// <summary>Typo-corrected, refined Traditional Chinese name.</summary>
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
/// <summary>Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地".</summary>
public string? Form990LineLabel { get; set; }
/// <summary>Model self-reported confidence in the mapping, 0..1.</summary>
public double Confidence { get; set; }
}
@@ -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,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; }
}
+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; }
}
+135 -2
View File
@@ -20,7 +20,13 @@ 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>();
@@ -118,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);
@@ -199,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 ─────────────────────────────────────────────
@@ -208,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 ───────────────────────────────────────────────
@@ -219,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 ──────────────────────────────────────────────────────────
@@ -247,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) ───────────────────────────────
@@ -266,12 +393,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.BankName).HasMaxLength(200); entity.Property(e => e.BankName).HasMaxLength(200);
entity.Property(e => e.BankAccountNumber).HasMaxLength(50); entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50); entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
entity.Property(e => e.PayerEin).HasMaxLength(20);
entity.Property(e => e.NameZh).HasMaxLength(200); entity.Property(e => e.NameZh).HasMaxLength(200);
entity.Property(e => e.Phone).HasMaxLength(50); entity.Property(e => e.Phone).HasMaxLength(50);
entity.Property(e => e.Email).HasMaxLength(200); entity.Property(e => e.Email).HasMaxLength(200);
entity.Property(e => e.Website).HasMaxLength(300); entity.Property(e => e.Website).HasMaxLength(300);
entity.Property(e => e.CreatedBy).HasMaxLength(450); entity.Property(e => e.CreatedBy).HasMaxLength(450);
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.Property(e => e.AiProvider).HasMaxLength(20).HasDefaultValue("Claude");
entity.Property(e => e.ClaudeModel).HasMaxLength(100).HasDefaultValue("claude-haiku-4-5-20251001");
entity.Property(e => e.ClaudeApiKey).HasMaxLength(500);
entity.Property(e => e.GeminiModel).HasMaxLength(100).HasDefaultValue("gemini-2.5-flash-lite");
entity.Property(e => e.GeminiApiKey).HasMaxLength(500);
// Optimistic-concurrency token for safe check-number allocation. // Optimistic-concurrency token for safe check-number allocation.
entity.Property(e => e.xmin).IsRowVersion(); entity.Property(e => e.xmin).IsRowVersion();
}); });
+234 -8
View File
@@ -28,6 +28,8 @@ public static class DbSeeder
("Hospitality", "招待", 8), ("Hospitality", "招待", 8),
("Children", "兒牧", 9), ("Children", "兒牧", 9),
("Catering", "餐飲", 10), ("Catering", "餐飲", 10),
("Cell Groups", "小組牧養", 11),
("Special Events", "特別活動", 12),
]; ];
// (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)]) // (GroupEn, GroupZh, Sort, SubItems[(SubEn, SubZh)])
@@ -35,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 =
@@ -87,6 +206,12 @@ public static class DbSeeder
("finance", Modules.MonthlyStatements, true, true, false, true), ("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false), ("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true), ("finance", Modules.Disbursements, true, true, true, true),
("finance", Modules.Form990Report, true, false, false, false),
// Form1099 — finance manages recipients and tracks filings; pastor and board_member
// get read-only oversight (same pattern as Form990Report). No Approve semantics.
("finance", Modules.Form1099, true, true, true, false),
("pastor", Modules.Form1099, true, false, false, false),
("board_member", Modules.Form1099, true, false, false, false),
// Logs — read-only. System logs are technical (pastor only); audit logs have // Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too. // governance value, so finance and board members can read them too.
@@ -94,6 +219,24 @@ public static class DbSeeder
("pastor", Modules.AuditLogs, true, false, false, false), ("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false), ("finance", Modules.AuditLogs, true, false, false, false),
("board_member", 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) public static async Task SeedRolePermissionsAsync(AppDbContext db)
@@ -163,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);
@@ -192,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).
@@ -270,6 +494,8 @@ public static class DbSeeder
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 SeedSiteSettingAsync(db);
await SeedNotificationSettingAsync(db, config); await SeedNotificationSettingAsync(db, config);
+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;
+10
View File
@@ -21,6 +21,16 @@ public class ChurchProfile : AuditableEntity, IAuditable
public string? BankAccountNumber { get; set; } public string? BankAccountNumber { get; set; }
public string? BankRoutingNumber { get; set; } public string? BankRoutingNumber { get; set; }
/// <summary>Payer EIN printed on Form 1099-NEC Copy B; the church's own public business identifier.</summary>
public string? PayerEin { get; set; }
// ── AI assist provider settings (editable via Church Profile → AI 設定 tab) ──
public string AiProvider { get; set; } = "Claude"; // "Claude" | "Gemini"
public string? ClaudeModel { get; set; } = "claude-haiku-4-5-20251001";
public string? ClaudeApiKey { get; set; } // secret, stored plaintext
public string? GeminiModel { get; set; } = "gemini-2.5-flash-lite";
public string? GeminiApiKey { get; set; } // secret, stored plaintext
/// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary> /// <summary>Next check number to allocate; consumed (++) when a check is issued.</summary>
public int NextCheckNumber { get; set; } = 1001; public int NextCheckNumber { get; set; } = 1001;
+4 -5
View File
@@ -5,14 +5,13 @@ public class Expense : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int MinistryId { get; set; } public int MinistryId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement public string Type { get; set; } = "StaffReimbursement"; // VendorPayment | StaffReimbursement
public string Status { get; set; } = "Draft"; // see state machine public string Status { get; set; } = "Draft"; // see state machine
public decimal Amount { get; set; } public decimal Amount { get; set; } // denormalized total = SUM(Lines.Amount), recomputed server-side
public string Description { get; set; } = null!; public string Description { get; set; } = null!;
public string? VendorName { get; set; } public string? VendorName { get; set; }
public int? MemberId { get; set; } public int? MemberId { get; set; }
public int? PayeeId { get; set; } // 1099 recipient attribution (header-level)
public string? CheckNumber { get; set; } public string? CheckNumber { get; set; }
public DateOnly ExpenseDate { get; set; } public DateOnly ExpenseDate { get; set; }
public string? ReceiptBlobPath { get; set; } public string? ReceiptBlobPath { get; set; }
@@ -26,7 +25,7 @@ public class Expense : SoftDeleteEntity, IAuditable
public string? PaidBy { get; set; } public string? PaidBy { get; set; }
public Ministry? Ministry { get; set; } public Ministry? Ministry { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { get; set; }
public Member? Member { get; set; } public Member? Member { get; set; }
public Payee1099? Payee { get; set; }
public List<ExpenseLine> Lines { get; set; } = new();
} }
@@ -9,5 +9,11 @@ public class ExpenseCategoryGroup : AuditableEntity, IAuditable
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
public Form1099Box? Form1099Box { get; set; }
public List<ExpenseSubCategory> SubCategories { get; set; } = []; public List<ExpenseSubCategory> SubCategories { get; set; } = [];
} }
+23
View File
@@ -0,0 +1,23 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// One category line of an <see cref="Expense"/>. A single invoice/payment can span
/// multiple expense categories, so the category / amount / functional-class axis lives
/// here per line; the Expense header keeps payment-level info and a denormalized total.
/// Lines are wholly owned by the header (replaced as a set on update, like CheckLine).
/// </summary>
public class ExpenseLine : AuditableEntity
{
public int Id { get; set; }
public int ExpenseId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; }
public string? Description { get; set; } // optional per-line note (header description is authoritative for check printing)
public Expense? Expense { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { get; set; }
}
+22
View File
@@ -0,0 +1,22 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// A reusable template of a vendor payment. Lets finance save a recurring fixed expense
/// (rent, internet, a fixed catered-meal cost) and re-apply it later, pre-filling everything
/// except the ExpenseDate. Shared church-wide; the creator is the auditable CreatedBy.
/// Lines are wholly owned by the header (replaced as a set on update, like ExpenseLine).
/// </summary>
public class ExpenseSnapshot : SoftDeleteEntity
{
public int Id { get; set; }
public string Name { get; set; } = null!; // user label, e.g. "Monthly Rent — Landlord X"
public int MinistryId { get; set; }
public string Description { get; set; } = null!;
public string? VendorName { get; set; }
public string? CheckNumber { get; set; }
public string? Notes { get; set; }
public Ministry? Ministry { get; set; }
public List<ExpenseSnapshotLine> Lines { get; set; } = new();
}
@@ -0,0 +1,18 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>One category line of an <see cref="ExpenseSnapshot"/>, mirroring <see cref="ExpenseLine"/>.</summary>
public class ExpenseSnapshotLine : AuditableEntity
{
public int Id { get; set; }
public int SnapshotId { get; set; }
public int CategoryGroupId { get; set; }
public int SubCategoryId { get; set; }
public string? FunctionalClass { get; set; } // null = inherit Ministry.DefaultFunctionalClass
public decimal Amount { get; set; }
public string? Description { get; set; }
public ExpenseSnapshot? Snapshot { get; set; }
public ExpenseCategoryGroup? CategoryGroup { get; set; }
public ExpenseSubCategory? SubCategory { get; set; }
}
@@ -10,5 +10,11 @@ public class ExpenseSubCategory : AuditableEntity, IAuditable
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int? Form990LineId { get; set; }
public Form990ExpenseLine? Form990Line { get; set; }
public int? Form1099BoxId { get; set; } // null = not 1099-reportable
public Form1099Box? Form1099Box { get; set; }
public ExpenseCategoryGroup? Group { get; set; } public ExpenseCategoryGroup? Group { get; set; }
} }
+20
View File
@@ -0,0 +1,20 @@
namespace ROLAC.API.Entities;
/// <summary>Shared 1099 constants. Box codes match Form1099Box.BoxCode seed values.</summary>
public static class Form1099
{
/// <summary>IRS reporting threshold (USD) per box, per recipient, per calendar year.</summary>
public const decimal ReportingThreshold = 600m;
public const string BoxNec1 = "NEC-1"; // Nonemployee compensation
public const string BoxMisc1 = "MISC-1"; // Rents
public static class W9Status
{
public const string Missing = "Missing";
public const string Requested = "Requested";
public const string OnFile = "OnFile";
public const string Expired = "Expired";
public static readonly IReadOnlyList<string> All = [Missing, Requested, OnFile, Expired];
}
}
+14
View File
@@ -0,0 +1,14 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>A 1099 reporting box, e.g. "NEC-1 — Nonemployee compensation".</summary>
public class Form1099Box : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string BoxCode { get; set; } = null!; // "NEC-1", "MISC-1"
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public string FormType { get; set; } = "1099-NEC"; // "1099-NEC" | "1099-MISC"
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,13 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>A row of IRS Form 990 Part IX (natural expense line), e.g. "7 — Other salaries and wages".</summary>
public class Form990ExpenseLine : AuditableEntity, IAuditable
{
public int Id { get; set; }
public string LineCode { get; set; } = null!; // "7", "11b", "16", "24"
public string Name_en { get; set; } = null!;
public string? Name_zh { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,18 @@
namespace ROLAC.API.Entities;
/// <summary>
/// The three IRS Form 990 Part IX functional-expense columns. Stored verbatim in
/// Ministry.DefaultFunctionalClass and ExpenseLine.FunctionalClass.
/// </summary>
public static class FunctionalClasses
{
public const string Program = "Program";
public const string ManagementGeneral = "ManagementGeneral";
public const string Fundraising = "Fundraising";
public static readonly IReadOnlyList<string> All = [Program, ManagementGeneral, Fundraising];
/// <summary>Returns the value if valid, otherwise Program (the safe default).</summary>
public static string Normalize(string? value) =>
value is not null && All.Contains(value) ? value : Program;
}
+2 -1
View File
@@ -53,6 +53,7 @@ public static class AuditActions
public const string CheckIssued = "CheckIssued"; public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided"; public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved"; public const string ExpenseApproved = "ExpenseApproved";
public const string ExpenseRejected = "ExpenseRejected";
public const string StatementFinalized = "StatementFinalized"; public const string StatementFinalized = "StatementFinalized";
public static readonly IReadOnlyList<string> All = public static readonly IReadOnlyList<string> All =
@@ -60,7 +61,7 @@ public static class AuditActions
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged, Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, PasswordChanged, UserDeactivated, PermissionChanged,
InvitationCreated, InvitationAccepted, CheckIssued, InvitationCreated, InvitationAccepted, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized, CheckVoided, ExpenseApproved, ExpenseRejected, StatementFinalized,
]; ];
} }
+1
View File
@@ -10,6 +10,7 @@ public class Member : SoftDeleteEntity, IAuditable
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; } // company / business name (公司行號) — used for company-check offerings
public string? Gender { get; set; } // 'M' | 'F' | 'Other' public string? Gender { get; set; } // 'M' | 'F' | 'Other'
public DateOnly? DateOfBirth { get; set; } public DateOnly? DateOfBirth { get; set; }
public DateOnly? BaptismDate { get; set; } public DateOnly? BaptismDate { get; set; }
+1
View File
@@ -11,4 +11,5 @@ public class Ministry : IAuditable
public string? Description_zh { get; set; } public string? Description_zh { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public string DefaultFunctionalClass { get; set; } = "Program";
} }
+32
View File
@@ -0,0 +1,32 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities;
/// <summary>
/// A 1099 recipient (independent contractor / vendor). Holds W-9 data and an encrypted TIN.
/// Optionally linked to a Member (e.g. a part-time co-worker paid as a contractor).
/// </summary>
public class Payee1099 : SoftDeleteEntity, IAuditable
{
public int Id { get; set; }
public string LegalName { get; set; } = null!; // name on the W-9
public string? DisplayName { get; set; } // friendly / DBA
public int? MemberId { get; set; }
public Member? Member { get; set; }
public string TaxClassification { get; set; } = "Individual"; // drives Is1099Tracked default
public bool Is1099Tracked { get; set; } = true;
public string? TinType { get; set; } // "SSN" | "EIN"
public string? TinEncrypted { get; set; } // Data-Protection ciphertext
public string? TinLast4 { get; set; }
public string? AddressLine1 { get; set; }
public string? AddressLine2 { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string W9Status { get; set; } = Form1099.W9Status.Missing;
public DateOnly? W9ReceivedDate { get; set; }
public string? W9BlobPath { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddForm990FunctionalExpenses : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultFunctionalClass",
table: "Ministries",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Program");
migrationBuilder.AddColumn<int>(
name: "Form990LineId",
table: "ExpenseSubCategories",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FunctionalClass",
table: "Expenses",
type: "character varying(20)",
maxLength: 20,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Form990LineId",
table: "ExpenseCategoryGroups",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "Form990ExpenseLines",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LineCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Form990ExpenseLines", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ExpenseSubCategories_Form990LineId",
table: "ExpenseSubCategories",
column: "Form990LineId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseCategoryGroups_Form990LineId",
table: "ExpenseCategoryGroups",
column: "Form990LineId");
migrationBuilder.CreateIndex(
name: "IX_Form990ExpenseLines_LineCode",
table: "Form990ExpenseLines",
column: "LineCode",
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
table: "ExpenseCategoryGroups",
column: "Form990LineId",
principalTable: "Form990ExpenseLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
table: "ExpenseSubCategories",
column: "Form990LineId",
principalTable: "Form990ExpenseLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExpenseCategoryGroups_Form990ExpenseLines_Form990LineId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropForeignKey(
name: "FK_ExpenseSubCategories_Form990ExpenseLines_Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropTable(
name: "Form990ExpenseLines");
migrationBuilder.DropIndex(
name: "IX_ExpenseSubCategories_Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropIndex(
name: "IX_ExpenseCategoryGroups_Form990LineId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropColumn(
name: "DefaultFunctionalClass",
table: "Ministries");
migrationBuilder.DropColumn(
name: "Form990LineId",
table: "ExpenseSubCategories");
migrationBuilder.DropColumn(
name: "FunctionalClass",
table: "Expenses");
migrationBuilder.DropColumn(
name: "Form990LineId",
table: "ExpenseCategoryGroups");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddChurchAiSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AiProvider",
table: "ChurchProfiles",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Claude");
migrationBuilder.AddColumn<string>(
name: "ClaudeApiKey",
table: "ChurchProfiles",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClaudeModel",
table: "ChurchProfiles",
type: "character varying(100)",
maxLength: 100,
nullable: true,
defaultValue: "claude-haiku-4-5-20251001");
migrationBuilder.AddColumn<string>(
name: "GeminiApiKey",
table: "ChurchProfiles",
type: "character varying(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "GeminiModel",
table: "ChurchProfiles",
type: "character varying(100)",
maxLength: 100,
nullable: true,
defaultValue: "gemini-2.5-flash-lite");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiProvider",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "ClaudeApiKey",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "ClaudeModel",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "GeminiApiKey",
table: "ChurchProfiles");
migrationBuilder.DropColumn(
name: "GeminiModel",
table: "ChurchProfiles");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,122 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddExpenseSnapshots : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ExpenseSnapshots",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
MinistryId = table.Column<int>(type: "integer", nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
VendorName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
CheckNumber = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
Notes = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExpenseSnapshots", x => x.Id);
table.ForeignKey(
name: "FK_ExpenseSnapshots_Ministries_MinistryId",
column: x => x.MinistryId,
principalTable: "Ministries",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ExpenseSnapshotLines",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
SnapshotId = table.Column<int>(type: "integer", nullable: false),
CategoryGroupId = table.Column<int>(type: "integer", nullable: false),
SubCategoryId = table.Column<int>(type: "integer", nullable: false),
FunctionalClass = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: true),
Amount = table.Column<decimal>(type: "numeric(18,2)", nullable: false),
Description = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ExpenseSnapshotLines", x => x.Id);
table.ForeignKey(
name: "FK_ExpenseSnapshotLines_ExpenseCategoryGroups_CategoryGroupId",
column: x => x.CategoryGroupId,
principalTable: "ExpenseCategoryGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ExpenseSnapshotLines_ExpenseSnapshots_SnapshotId",
column: x => x.SnapshotId,
principalTable: "ExpenseSnapshots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ExpenseSnapshotLines_ExpenseSubCategories_SubCategoryId",
column: x => x.SubCategoryId,
principalTable: "ExpenseSubCategories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_ExpenseSnapshotLines_CategoryGroupId",
table: "ExpenseSnapshotLines",
column: "CategoryGroupId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseSnapshotLines_SnapshotId",
table: "ExpenseSnapshotLines",
column: "SnapshotId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseSnapshotLines_SubCategoryId",
table: "ExpenseSnapshotLines",
column: "SubCategoryId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseSnapshots_CreatedAt",
table: "ExpenseSnapshots",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_ExpenseSnapshots_MinistryId",
table: "ExpenseSnapshots",
column: "MinistryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ExpenseSnapshotLines");
migrationBuilder.DropTable(
name: "ExpenseSnapshots");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddForm1099RecipientTracking : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Form1099BoxId",
table: "ExpenseSubCategories",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PayeeId",
table: "Expenses",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "Form1099BoxId",
table: "ExpenseCategoryGroups",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "Form1099Boxes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BoxCode = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Name_en = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Name_zh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
FormType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Form1099Boxes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Payee1099s",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
LegalName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
MemberId = table.Column<int>(type: "integer", nullable: true),
TaxClassification = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
Is1099Tracked = table.Column<bool>(type: "boolean", nullable: false),
TinType = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
TinEncrypted = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
TinLast4 = table.Column<string>(type: "character varying(4)", maxLength: 4, nullable: true),
AddressLine1 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
AddressLine2 = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
State = table.Column<string>(type: "character varying(2)", maxLength: 2, nullable: true),
Zip = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: true),
Email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
W9Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false, defaultValue: "Missing"),
W9ReceivedDate = table.Column<DateOnly>(type: "date", nullable: true),
W9BlobPath = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
Notes = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
CreatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
DeletedBy = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Payee1099s", x => x.Id);
table.ForeignKey(
name: "FK_Payee1099s_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_ExpenseSubCategories_Form1099BoxId",
table: "ExpenseSubCategories",
column: "Form1099BoxId");
migrationBuilder.CreateIndex(
name: "IX_Expenses_PayeeId",
table: "Expenses",
column: "PayeeId");
migrationBuilder.CreateIndex(
name: "IX_ExpenseCategoryGroups_Form1099BoxId",
table: "ExpenseCategoryGroups",
column: "Form1099BoxId");
migrationBuilder.CreateIndex(
name: "IX_Form1099Boxes_BoxCode",
table: "Form1099Boxes",
column: "BoxCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Payee1099s_MemberId",
table: "Payee1099s",
column: "MemberId");
migrationBuilder.AddForeignKey(
name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId",
table: "ExpenseCategoryGroups",
column: "Form1099BoxId",
principalTable: "Form1099Boxes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_Expenses_Payee1099s_PayeeId",
table: "Expenses",
column: "PayeeId",
principalTable: "Payee1099s",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId",
table: "ExpenseSubCategories",
column: "Form1099BoxId",
principalTable: "Form1099Boxes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ExpenseCategoryGroups_Form1099Boxes_Form1099BoxId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropForeignKey(
name: "FK_Expenses_Payee1099s_PayeeId",
table: "Expenses");
migrationBuilder.DropForeignKey(
name: "FK_ExpenseSubCategories_Form1099Boxes_Form1099BoxId",
table: "ExpenseSubCategories");
migrationBuilder.DropTable(
name: "Form1099Boxes");
migrationBuilder.DropTable(
name: "Payee1099s");
migrationBuilder.DropIndex(
name: "IX_ExpenseSubCategories_Form1099BoxId",
table: "ExpenseSubCategories");
migrationBuilder.DropIndex(
name: "IX_Expenses_PayeeId",
table: "Expenses");
migrationBuilder.DropIndex(
name: "IX_ExpenseCategoryGroups_Form1099BoxId",
table: "ExpenseCategoryGroups");
migrationBuilder.DropColumn(
name: "Form1099BoxId",
table: "ExpenseSubCategories");
migrationBuilder.DropColumn(
name: "PayeeId",
table: "Expenses");
migrationBuilder.DropColumn(
name: "Form1099BoxId",
table: "ExpenseCategoryGroups");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddPayerEinToChurchProfile : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PayerEin",
table: "ChurchProfiles",
type: "character varying(20)",
maxLength: 20,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PayerEin",
table: "ChurchProfiles");
}
}
}
@@ -439,6 +439,13 @@ namespace ROLAC.API.Migrations
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
b.Property<string>("AiProvider")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Claude");
b.Property<string>("BankAccountNumber") b.Property<string>("BankAccountNumber")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -455,6 +462,16 @@ namespace ROLAC.API.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<string>("ClaudeApiKey")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("ClaudeModel")
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasDefaultValue("claude-haiku-4-5-20251001");
b.Property<DateTimeOffset>("CreatedAt") b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -467,6 +484,16 @@ namespace ROLAC.API.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("GeminiApiKey")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("GeminiModel")
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasDefaultValue("gemini-2.5-flash-lite");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(200) .HasMaxLength(200)
@@ -479,6 +506,10 @@ namespace ROLAC.API.Migrations
b.Property<int>("NextCheckNumber") b.Property<int>("NextCheckNumber")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("PayerEin")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Phone") b.Property<string>("Phone")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -525,9 +556,6 @@ namespace ROLAC.API.Migrations
b.Property<decimal>("Amount") b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<string>("CheckNumber") b.Property<string>("CheckNumber")
.HasMaxLength(50) .HasMaxLength(50)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(50)");
@@ -574,6 +602,9 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("PayeeId")
.HasColumnType("integer");
b.Property<string>("ReceiptBlobPath") b.Property<string>("ReceiptBlobPath")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@@ -596,9 +627,6 @@ namespace ROLAC.API.Migrations
.HasColumnType("character varying(30)") .HasColumnType("character varying(30)")
.HasDefaultValue("Draft"); .HasDefaultValue("Draft");
b.Property<int>("SubCategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("SubmittedAt") b.Property<DateTimeOffset?>("SubmittedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -625,19 +653,17 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CategoryGroupId");
b.HasIndex("ExpenseDate"); b.HasIndex("ExpenseDate");
b.HasIndex("MemberId"); b.HasIndex("MemberId");
b.HasIndex("MinistryId"); b.HasIndex("MinistryId");
b.HasIndex("PayeeId");
b.HasIndex("Status") b.HasIndex("Status")
.HasFilter("\"IsDeleted\" = false"); .HasFilter("\"IsDeleted\" = false");
b.HasIndex("SubCategoryId");
b.ToTable("Expenses"); b.ToTable("Expenses");
}); });
@@ -657,6 +683,12 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("Form1099BoxId")
.HasColumnType("integer");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<bool>("IsActive") b.Property<bool>("IsActive")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -682,9 +714,190 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Form1099BoxId");
b.HasIndex("Form990LineId");
b.ToTable("ExpenseCategoryGroups"); b.ToTable("ExpenseCategoryGroups");
}); });
modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("ExpenseId")
.HasColumnType("integer");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("SubCategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("CategoryGroupId");
b.HasIndex("ExpenseId");
b.HasIndex("SubCategoryId");
b.ToTable("ExpenseLines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CheckNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeletedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<int>("MinistryId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("VendorName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("MinistryId");
b.ToTable("ExpenseSnapshots");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int>("CategoryGroupId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("FunctionalClass")
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("SnapshotId")
.HasColumnType("integer");
b.Property<int>("SubCategoryId")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("CategoryGroupId");
b.HasIndex("SnapshotId");
b.HasIndex("SubCategoryId");
b.ToTable("ExpenseSnapshotLines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -701,6 +914,12 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450) .HasMaxLength(450)
.HasColumnType("character varying(450)"); .HasColumnType("character varying(450)");
b.Property<int?>("Form1099BoxId")
.HasColumnType("integer");
b.Property<int?>("Form990LineId")
.HasColumnType("integer");
b.Property<int>("GroupId") b.Property<int>("GroupId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -729,6 +948,10 @@ namespace ROLAC.API.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Form1099BoxId");
b.HasIndex("Form990LineId");
b.HasIndex("GroupId"); b.HasIndex("GroupId");
b.ToTable("ExpenseSubCategories"); b.ToTable("ExpenseSubCategories");
@@ -772,6 +995,115 @@ namespace ROLAC.API.Migrations
b.ToTable("FamilyUnits"); b.ToTable("FamilyUnits");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Form1099Box", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("BoxCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("FormType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("BoxCode")
.IsUnique();
b.ToTable("Form1099Boxes");
});
modelBuilder.Entity("ROLAC.API.Entities.Form990ExpenseLine", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LineCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Name_en")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name_zh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("LineCode")
.IsUnique();
b.ToTable("Form990ExpenseLines");
});
modelBuilder.Entity("ROLAC.API.Entities.Giving", b => modelBuilder.Entity("ROLAC.API.Entities.Giving", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1140,6 +1472,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("Entity")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("FamilyUnitId") b.Property<int?>("FamilyUnitId")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -1241,6 +1577,13 @@ namespace ROLAC.API.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DefaultFunctionalClass")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Program");
b.Property<string>("Description_en") b.Property<string>("Description_en")
.HasColumnType("text"); .HasColumnType("text");
@@ -1658,6 +2001,128 @@ namespace ROLAC.API.Migrations
b.ToTable("OfferingSessions"); b.ToTable("OfferingSessions");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AddressLine1")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("AddressLine2")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("City")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DeletedBy")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<bool>("Is1099Tracked")
.HasColumnType("boolean");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<string>("LegalName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("State")
.HasMaxLength(2)
.HasColumnType("character varying(2)");
b.Property<string>("TaxClassification")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("TinEncrypted")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("TinLast4")
.HasMaxLength(4)
.HasColumnType("character varying(4)");
b.Property<string>("TinType")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedBy")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("W9BlobPath")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateOnly?>("W9ReceivedDate")
.HasColumnType("date");
b.Property<string>("W9Status")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasDefaultValue("Missing");
b.Property<string>("Zip")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.HasKey("Id");
b.HasIndex("MemberId");
b.ToTable("Payee1099s");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1930,12 +2395,6 @@ namespace ROLAC.API.Migrations
modelBuilder.Entity("ROLAC.API.Entities.Expense", b => modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{ {
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
.WithMany()
.HasForeignKey("CategoryGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Member", "Member") b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany() .WithMany()
.HasForeignKey("MemberId") .HasForeignKey("MemberId")
@@ -1947,6 +2406,49 @@ namespace ROLAC.API.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("ROLAC.API.Entities.Payee1099", "Payee")
.WithMany()
.HasForeignKey("PayeeId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
b.Navigation("Ministry");
b.Navigation("Payee");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
.WithMany()
.HasForeignKey("Form1099BoxId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Form1099Box");
b.Navigation("Form990Line");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseLine", b =>
{
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
.WithMany()
.HasForeignKey("CategoryGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.Expense", "Expense")
.WithMany("Lines")
.HasForeignKey("ExpenseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory") b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
.WithMany() .WithMany()
.HasForeignKey("SubCategoryId") .HasForeignKey("SubCategoryId")
@@ -1955,21 +2457,71 @@ namespace ROLAC.API.Migrations
b.Navigation("CategoryGroup"); b.Navigation("CategoryGroup");
b.Navigation("Member"); b.Navigation("Expense");
b.Navigation("SubCategory");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
{
b.HasOne("ROLAC.API.Entities.Ministry", "Ministry")
.WithMany()
.HasForeignKey("MinistryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Ministry"); b.Navigation("Ministry");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshotLine", b =>
{
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup")
.WithMany()
.HasForeignKey("CategoryGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ROLAC.API.Entities.ExpenseSnapshot", "Snapshot")
.WithMany("Lines")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory")
.WithMany()
.HasForeignKey("SubCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("CategoryGroup");
b.Navigation("Snapshot");
b.Navigation("SubCategory"); b.Navigation("SubCategory");
}); });
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b =>
{ {
b.HasOne("ROLAC.API.Entities.Form1099Box", "Form1099Box")
.WithMany()
.HasForeignKey("Form1099BoxId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.Form990ExpenseLine", "Form990Line")
.WithMany()
.HasForeignKey("Form990LineId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group") b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group")
.WithMany("SubCategories") .WithMany("SubCategories")
.HasForeignKey("GroupId") .HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.Navigation("Form1099Box");
b.Navigation("Form990Line");
b.Navigation("Group"); b.Navigation("Group");
}); });
@@ -2047,6 +2599,16 @@ namespace ROLAC.API.Migrations
b.Navigation("MessagingGroup"); b.Navigation("MessagingGroup");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{ {
b.HasOne("ROLAC.API.Entities.AppUser", "User") b.HasOne("ROLAC.API.Entities.AppUser", "User")
@@ -2090,11 +2652,21 @@ namespace ROLAC.API.Migrations
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Expense", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b =>
{ {
b.Navigation("SubCategories"); b.Navigation("SubCategories");
}); });
modelBuilder.Entity("ROLAC.API.Entities.ExpenseSnapshot", b =>
{
b.Navigation("Lines");
});
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{ {
b.Navigation("Givings"); b.Navigation("Givings");
+28
View File
@@ -15,6 +15,7 @@ using ROLAC.API.Json;
using ROLAC.API.Middleware; using ROLAC.API.Middleware;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Logging; using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Security;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration; var config = builder.Configuration;
@@ -153,13 +154,23 @@ builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
ROLAC.API.Services.Storage.LocalDiskFileStorage>(); ROLAC.API.Services.Storage.LocalDiskFileStorage>();
builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>(); builder.Services.AddScoped<IExpenseCategoryService, ExpenseCategoryService>();
builder.Services.AddScoped<IExpenseService, ExpenseService>(); builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IExpenseSnapshotService, ExpenseSnapshotService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>(); builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>(); builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IForm990ReportService, Form990ReportService>();
builder.Services.AddScoped<IForm1099ReportService, Form1099ReportService>();
builder.Services.AddScoped<IPayee1099Service, Payee1099Service>();
builder.Services.AddScoped<I1099FormService, Form1099FormService>();
builder.Services.AddDataProtection();
builder.Services.AddScoped<ITinProtector, TinProtector>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>(); builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>(); builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>(); builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService, builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>(); ROLAC.API.Services.Disbursement.CheckPrintService>();
// Pre-printed check-stock field coordinates; tune in appsettings.json without recompiling.
builder.Services.Configure<ROLAC.API.Services.Disbursement.CheckPrintLayoutOptions>(
config.GetSection("CheckPrint:Layout"));
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>(); builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── Notifications (email via SMTP + Line) ────────────────────────────────── // ── Notifications (email via SMTP + Line) ──────────────────────────────────
@@ -178,6 +189,23 @@ builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationSer
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel, builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
ROLAC.API.Services.Notifications.LineMessageChannel>(); ROLAC.API.Services.Notifications.LineMessageChannel>();
// ── AI assist (expense translation + category suggestion) ──────────────────
// Backend proxy so the API key stays server-side. Provider + model + key come from the
// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude
// or Gemini per request based on ChurchProfile.AiProvider.
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
// Category-mapping AI (define a 大項/小項: refine name + translate + suggest Form 990 line).
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseCategoryAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseCategoryAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseCategoryAiServiceFactory,
ROLAC.API.Services.Ai.ExpenseCategoryAiServiceFactory>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix) // Configurable role-based permissions (RBAC matrix)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -0,0 +1,13 @@
namespace ROLAC.API.Services.Ai;
/// <summary>Active AI configuration resolved from the ChurchProfile singleton (blanks filled with defaults).</summary>
public sealed record ChurchAiConfig(
string Provider,
string ClaudeModel, string? ClaudeApiKey,
string GeminiModel, string? GeminiApiKey);
/// <summary>Reads the church's AI settings from the database for the current request.</summary>
public interface IChurchAiConfigProvider
{
Task<ChurchAiConfig> GetAsync(CancellationToken ct = default);
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Loads AI settings from the singleton <c>ChurchProfile</c> row, substituting default model names
/// for any blank field so a freshly migrated install still names a valid model. The API keys are
/// passed through as-is (null when unset → the calling service treats AI as disabled).
/// </summary>
public sealed class ChurchAiConfigProvider : IChurchAiConfigProvider
{
private const string DefaultClaudeModel = "claude-haiku-4-5-20251001";
private const string DefaultGeminiModel = "gemini-2.5-flash-lite";
private readonly AppDbContext _db;
public ChurchAiConfigProvider(AppDbContext db) => _db = db;
public async Task<ChurchAiConfig> GetAsync(CancellationToken ct = default)
{
var p = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync(ct);
var provider = string.IsNullOrWhiteSpace(p?.AiProvider) ? "Claude" : p.AiProvider;
var claudeModel = string.IsNullOrWhiteSpace(p?.ClaudeModel) ? DefaultClaudeModel : p!.ClaudeModel!;
var geminiModel = string.IsNullOrWhiteSpace(p?.GeminiModel) ? DefaultGeminiModel : p!.GeminiModel!;
return new ChurchAiConfig(provider, claudeModel, p?.ClaudeApiKey, geminiModel, p?.GeminiApiKey);
}
}
@@ -0,0 +1,127 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Translates and classifies an expense via the Anthropic Claude Messages API. It forces a single
/// tool call (<c>tool_choice</c> → <c>classify_expense</c>) whose <c>input_schema</c> matches our
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
/// model can be swapped (e.g. to a cheaper model) without code changes.
/// </summary>
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
{
private const string BaseUrl = "https://api.anthropic.com/v1";
private const string AnthropicVersion = "2023-06-01";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<ClaudeExpenseAiService> _logger;
public ClaudeExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
{
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
return null;
}
try
{
var payload = new
{
model = cfg.ClaudeModel,
max_tokens = 1024,
tools = new[]
{
new
{
name = "classify_expense",
description = "Record the English translation and the chosen expense category ids for the expense.",
input_schema = new
{
type = "object",
properties = new
{
chineseDescription = new { type = "string" },
englishDescription = new { type = "string" },
groupId = new { type = "integer" },
subCategoryId = new { type = "integer" },
confidence = new { type = "number" },
},
required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" },
},
},
},
tool_choice = new { type = "tool", name = "classify_expense" },
messages = new[]
{
new { role = "user", content = prompt },
},
};
var url = $"{BaseUrl}/messages";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body);
return null;
}
// The forced tool call lands in content[] as a tool_use block; its `input` is our object.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray())
{
if (block.GetProperty("type").GetString() != "tool_use") continue;
var parsed = block.GetProperty("input").Deserialize<ClaudeAnswer>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (parsed is null) return null;
return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
}
_logger.LogWarning("Claude response contained no tool_use block.");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Claude expense AI assist failed.");
return null;
}
}
/// <summary>Shape of the classify_expense tool input the model fills in.</summary>
private sealed class ClaudeAnswer
{
public string? EnglishDescription { get; set; }
public string? ChineseDescription { get; set; }
public int GroupId { get; set; }
public int SubCategoryId { get; set; }
public double Confidence { get; set; }
}
}
@@ -0,0 +1,124 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Refines, translates, and maps an expense category to a Form 990 line via the Anthropic Claude
/// Messages API. It forces a single tool call (<c>tool_choice</c> → <c>map_category</c>) whose
/// <c>input_schema</c> matches our answer shape, so the model returns structured JSON in a
/// <c>tool_use</c> block. The catalog, prompt, and id validation come from
/// <see cref="ExpenseCategoryAiServiceBase"/>; this class only owns the Claude HTTP call + parse.
/// </summary>
public sealed class ClaudeExpenseCategoryAiService : ExpenseCategoryAiServiceBase
{
private const string BaseUrl = "https://api.anthropic.com/v1";
private const string AnthropicVersion = "2023-06-01";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<ClaudeExpenseCategoryAiService> _logger;
public ClaudeExpenseCategoryAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseCategoryAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
{
_logger.LogWarning("Claude API key is not configured; category AI assist is disabled.");
return null;
}
try
{
var payload = new
{
model = cfg.ClaudeModel,
max_tokens = 1024,
tools = new[]
{
new
{
name = "map_category",
description = "Record the refined Chinese name, English translation, and chosen Form 990 line id for the expense category.",
input_schema = new
{
type = "object",
properties = new
{
chineseName = new { type = "string" },
englishName = new { type = "string" },
form990LineId = new { type = "integer" },
confidence = new { type = "number" },
},
required = new[] { "chineseName", "englishName", "form990LineId", "confidence" },
},
},
},
tool_choice = new { type = "tool", name = "map_category" },
messages = new[]
{
new { role = "user", content = prompt },
},
};
var url = $"{BaseUrl}/messages";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body);
return null;
}
// The forced tool call lands in content[] as a tool_use block; its `input` is our object.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray())
{
if (block.GetProperty("type").GetString() != "tool_use") continue;
var parsed = block.GetProperty("input").Deserialize<ClaudeAnswer>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (parsed is null) return null;
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
}
_logger.LogWarning("Claude response contained no tool_use block.");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Claude category AI assist failed.");
return null;
}
}
/// <summary>Shape of the map_category tool input the model fills in.</summary>
private sealed class ClaudeAnswer
{
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
public double Confidence { get; set; }
}
}
@@ -0,0 +1,119 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Provider-independent expense-AI logic: loads the active category catalog, builds the
/// classification prompt, and validates the model's chosen ids against that catalog. Concrete
/// providers (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus
/// response parsing — so the catalog/prompt/validation code lives in exactly one place.
/// </summary>
public abstract class ExpenseAiServiceBase : IExpenseAiService
{
private readonly AppDbContext _db;
protected ExpenseAiServiceBase(AppDbContext db) => _db = db;
/// <summary>One sub-category in the catalog passed to the model.</summary>
protected sealed record CatalogSub(int Id, string NameEn, string? NameZh);
/// <summary>One major category (with its sub-categories) in the catalog passed to the model.</summary>
protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList<CatalogSub> Subs);
/// <summary>The model's raw answer, before its ids are validated against the catalog.</summary>
protected sealed record ModelAnswer(
string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence);
public async Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
{
var catalog = await LoadCatalogAsync(ct);
var prompt = BuildPrompt(chineseText, amount, catalog);
var answer = await CallModelAsync(prompt, ct);
if (answer is null) return new ExpenseAiSuggestion();
return BuildSuggestion(answer, catalog);
}
/// <summary>
/// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
/// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
/// </summary>
protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);
private async Task<List<CatalogGroup>> LoadCatalogAsync(CancellationToken ct)
{
return await _db.ExpenseCategoryGroups
.AsNoTracking()
.Where(group => group.IsActive)
.OrderBy(group => group.SortOrder)
.Select(group => new CatalogGroup(
group.Id,
group.Name_en,
group.Name_zh,
group.SubCategories
.Where(sub => sub.IsActive)
.OrderBy(sub => sub.SortOrder)
.Select(sub => new CatalogSub(sub.Id, sub.Name_en, sub.Name_zh))
.ToList()))
.ToListAsync(ct);
}
private static string BuildPrompt(string chineseText, decimal amount, List<CatalogGroup> catalog)
{
var catalogJson = JsonSerializer.Serialize(catalog);
return
"You are a bookkeeping assistant for a church. Given an expense description (often in " +
"Traditional Chinese) and its amount, do three things:\n" +
"1. Correct any typos in the description and refine it into natural Traditional Chinese — " +
"return it as chineseDescription.\n" +
"2. Translate that into concise, natural accounting English (a short noun phrase, not a " +
"full sentence) — return it as englishDescription.\n" +
"3. Choose the single best matching major category (group) and sub-category from the catalog " +
"below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " +
"subCategoryId must belong to that groupId. If nothing fits well, choose the closest " +
"\"Other / 其他\" option and lower your confidence.\n\n" +
$"Expense description: {chineseText}\n" +
$"Amount: {amount}\n\n" +
$"Category catalog (JSON; each group has an Id, English/Chinese names, and its Subs):\n{catalogJson}";
}
private static ExpenseAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogGroup> catalog)
{
var suggestion = new ExpenseAiSuggestion
{
EnglishDescription = string.IsNullOrWhiteSpace(answer.EnglishDescription)
? null
: answer.EnglishDescription.Trim(),
ChineseDescription = string.IsNullOrWhiteSpace(answer.ChineseDescription)
? null
: answer.ChineseDescription.Trim(),
Confidence = answer.Confidence,
};
// Re-validate the returned ids against the catalog; drop anything that doesn't line up
// (defends against a hallucinated id, or a sub-category that doesn't belong to the group).
var group = catalog.FirstOrDefault(candidate => candidate.Id == answer.GroupId);
if (group is not null)
{
suggestion.GroupId = group.Id;
suggestion.GroupLabel = Label(group.NameEn, group.NameZh);
var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == answer.SubCategoryId);
if (sub is not null)
{
suggestion.SubCategoryId = sub.Id;
suggestion.SubLabel = Label(sub.NameEn, sub.NameZh);
}
}
return suggestion;
}
/// <summary>Mirror the frontend's bilingual() convention: "English / 中文" (or just English).</summary>
private static string Label(string nameEn, string? nameZh)
=> string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
}
@@ -0,0 +1,30 @@
namespace ROLAC.API.Services.Ai;
/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseAiServiceFactory
{
Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default);
}
public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory
{
private readonly IChurchAiConfigProvider _config;
private readonly ClaudeExpenseAiService _claude;
private readonly GeminiExpenseAiService _gemini;
public ExpenseAiServiceFactory(
IChurchAiConfigProvider config,
ClaudeExpenseAiService claude,
GeminiExpenseAiService gemini)
{
_config = config;
_claude = claude;
_gemini = gemini;
}
public async Task<IExpenseAiService> ResolveAsync(CancellationToken ct = default)
{
var cfg = await _config.GetAsync(ct);
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
}
}
@@ -0,0 +1,116 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Provider-independent category-AI logic: loads the active Form 990 line catalog, builds the
/// mapping prompt, and validates the model's chosen line id against that catalog. Concrete providers
/// (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus response parsing —
/// so the catalog/prompt/validation code lives in exactly one place. Mirrors
/// <see cref="ExpenseAiServiceBase"/>, which does the same for the expense-entry classification task.
/// </summary>
public abstract class ExpenseCategoryAiServiceBase : IExpenseCategoryAiService
{
private readonly AppDbContext _db;
protected ExpenseCategoryAiServiceBase(AppDbContext db) => _db = db;
/// <summary>One Form 990 line in the catalog passed to the model.</summary>
protected sealed record CatalogLine(int Id, string LineCode, string NameEn, string? NameZh);
/// <summary>The model's raw answer, before its line id is validated against the catalog.</summary>
protected sealed record ModelAnswer(string? ChineseName, string? EnglishName, int? Form990LineId, double Confidence);
public async Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default)
{
var catalog = await LoadCatalogAsync(ct);
var prompt = BuildPrompt(request, catalog);
var answer = await CallModelAsync(prompt, ct);
if (answer is null) return new CategoryAiSuggestion();
return BuildSuggestion(answer, catalog);
}
/// <summary>
/// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
/// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
/// </summary>
protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);
private async Task<List<CatalogLine>> LoadCatalogAsync(CancellationToken ct)
{
return await _db.Form990ExpenseLines
.AsNoTracking()
.Where(line => line.IsActive)
.OrderBy(line => line.SortOrder)
.Select(line => new CatalogLine(line.Id, line.LineCode, line.Name_en, line.Name_zh))
.ToListAsync(ct);
}
private static string BuildPrompt(ExpenseCategoryAiRequest request, List<CatalogLine> catalog)
{
var catalogJson = JsonSerializer.Serialize(catalog);
var levelLabel = request.Level.Equals("sub", StringComparison.OrdinalIgnoreCase)
? "sub-category (小項)"
: "major category (大項)";
var context = new StringBuilder();
context.Append($"This is an expense {levelLabel} in a church's bookkeeping chart of accounts.\n");
if (!string.IsNullOrWhiteSpace(request.Name_zh))
context.Append($"Chinese name entered: {request.Name_zh}\n");
if (!string.IsNullOrWhiteSpace(request.Name_en))
context.Append($"English name entered: {request.Name_en}\n");
if (!string.IsNullOrWhiteSpace(request.ParentGroupName))
context.Append($"It belongs under the parent major category: {request.ParentGroupName}\n");
if (request.ParentForm990LineId is int parentLineId)
context.Append(
$"The parent major category is mapped to Form 990 line id {parentLineId}; prefer a consistent " +
"choice unless a more specific line clearly fits this sub-category.\n");
return
"You are a bookkeeping assistant for a church mapping its expense categories to the IRS Form 990 " +
"Part IX (Statement of Functional Expenses) lines. Given an expense category name (often in " +
"Traditional Chinese), do three things:\n" +
"1. Correct any typos in the name and refine it into natural Traditional Chinese — return it as " +
"chineseName.\n" +
"2. Translate that into a concise, natural accounting English noun phrase (not a full sentence) — " +
"return it as englishName.\n" +
"3. Choose the single best matching Form 990 line from the catalog below. You MUST pick a " +
"form990LineId that appears in the catalog. If nothing fits well, choose the closest general line " +
"(e.g. an \"Other expenses\" line) and lower your confidence.\n\n" +
context +
"\n" +
$"Form 990 line catalog (JSON; each line has an Id, LineCode, and English/Chinese names):\n{catalogJson}";
}
private static CategoryAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogLine> catalog)
{
var suggestion = new CategoryAiSuggestion
{
ChineseName = string.IsNullOrWhiteSpace(answer.ChineseName) ? null : answer.ChineseName.Trim(),
EnglishName = string.IsNullOrWhiteSpace(answer.EnglishName) ? null : answer.EnglishName.Trim(),
Confidence = answer.Confidence,
};
// Re-validate the returned id against the catalog; drop a hallucinated id rather than returning it.
var line = catalog.FirstOrDefault(candidate => candidate.Id == answer.Form990LineId);
if (line is not null)
{
suggestion.Form990LineId = line.Id;
suggestion.Form990LineLabel = Label(line);
}
return suggestion;
}
/// <summary>Mirror the frontend dropdown label: "code — English / 中文" (or just "code — English").</summary>
private static string Label(CatalogLine line)
=> string.IsNullOrWhiteSpace(line.NameZh)
? $"{line.LineCode} — {line.NameEn}"
: $"{line.LineCode} — {line.NameEn} / {line.NameZh}";
}
@@ -0,0 +1,30 @@
namespace ROLAC.API.Services.Ai;
/// <summary>Selects the active category-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseCategoryAiServiceFactory
{
Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default);
}
public sealed class ExpenseCategoryAiServiceFactory : IExpenseCategoryAiServiceFactory
{
private readonly IChurchAiConfigProvider _config;
private readonly ClaudeExpenseCategoryAiService _claude;
private readonly GeminiExpenseCategoryAiService _gemini;
public ExpenseCategoryAiServiceFactory(
IChurchAiConfigProvider config,
ClaudeExpenseCategoryAiService claude,
GeminiExpenseCategoryAiService gemini)
{
_config = config;
_claude = claude;
_gemini = gemini;
}
public async Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default)
{
var cfg = await _config.GetAsync(ct);
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
}
}
@@ -0,0 +1,120 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Translates and classifies an expense via the Google Gemini <c>generateContent</c> API, using
/// Gemini's structured-output mode (<c>responseSchema</c>). The catalog, prompt, and id validation
/// come from <see cref="ExpenseAiServiceBase"/>; this class only owns the Gemini HTTP call + parse.
/// </summary>
public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
{
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<GeminiExpenseAiService> _logger;
public GeminiExpenseAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<GeminiExpenseAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
{
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
return null;
}
try
{
var payload = new
{
contents = new[]
{
new { parts = new[] { new { text = prompt } } },
},
generationConfig = new
{
responseMimeType = "application/json",
responseSchema = new
{
type = "object",
properties = new
{
chineseDescription = new { type = "string" },
englishDescription = new { type = "string" },
groupId = new { type = "integer" },
subCategoryId = new { type = "integer" },
confidence = new { type = "number" },
},
required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" },
},
},
};
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
return null;
}
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
var text = doc.RootElement
.GetProperty("candidates")[0]
.GetProperty("content")
.GetProperty("parts")[0]
.GetProperty("text")
.GetString();
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("Gemini response contained no text part.");
return null;
}
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (parsed is null) return null;
return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
}
catch (Exception ex)
{
_logger.LogError(ex, "Gemini expense AI assist failed.");
return null;
}
}
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
private sealed class GeminiAnswer
{
public string? EnglishDescription { get; set; }
public string? ChineseDescription { get; set; }
public int GroupId { get; set; }
public int SubCategoryId { get; set; }
public double Confidence { get; set; }
}
}
@@ -0,0 +1,119 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Refines, translates, and maps an expense category to a Form 990 line via the Google Gemini
/// <c>generateContent</c> API, using Gemini's structured-output mode (<c>responseSchema</c>). The
/// catalog, prompt, and id validation come from <see cref="ExpenseCategoryAiServiceBase"/>; this class
/// only owns the Gemini HTTP call + parse.
/// </summary>
public sealed class GeminiExpenseCategoryAiService : ExpenseCategoryAiServiceBase
{
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<GeminiExpenseCategoryAiService> _logger;
public GeminiExpenseCategoryAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<GeminiExpenseCategoryAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
{
_logger.LogWarning("Gemini API key is not configured; category AI assist is disabled.");
return null;
}
try
{
var payload = new
{
contents = new[]
{
new { parts = new[] { new { text = prompt } } },
},
generationConfig = new
{
responseMimeType = "application/json",
responseSchema = new
{
type = "object",
properties = new
{
chineseName = new { type = "string" },
englishName = new { type = "string" },
form990LineId = new { type = "integer" },
confidence = new { type = "number" },
},
required = new[] { "chineseName", "englishName", "form990LineId", "confidence" },
},
},
};
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
return null;
}
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
var text = doc.RootElement
.GetProperty("candidates")[0]
.GetProperty("content")
.GetProperty("parts")[0]
.GetProperty("text")
.GetString();
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("Gemini response contained no text part.");
return null;
}
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (parsed is null) return null;
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
}
catch (Exception ex)
{
_logger.LogError(ex, "Gemini category AI assist failed.");
return null;
}
}
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
private sealed class GeminiAnswer
{
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
public double Confidence { get; set; }
}
}
@@ -0,0 +1,14 @@
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>AI assistance for expense entry: translate a description and suggest a category.</summary>
public interface IExpenseAiService
{
/// <summary>
/// Translate <paramref name="chineseText"/> to concise accounting English and suggest the best
/// major/sub category from the live catalog, using <paramref name="amount"/> as a hint.
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
/// </summary>
Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// AI assistance for defining an expense category (大項/小項): refine the Chinese name, translate it
/// to English, and suggest the matching IRS Form 990 Part IX line.
/// </summary>
public interface IExpenseCategoryAiService
{
/// <summary>
/// Refine the entered name, translate it to concise accounting English, and choose the best Form 990
/// line from the live catalog (biased by the group/sub context in <paramref name="request"/>).
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
/// </summary>
Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default);
}
+21 -2
View File
@@ -18,7 +18,12 @@ public class ChurchProfileService : IChurchProfileService
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email, Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
Website = p.Website, Address = p.Address, City = p.City, State = p.State, Website = p.Website, Address = p.Address, City = p.City, State = p.State,
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber, ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber, BankRoutingNumber = p.BankRoutingNumber, PayerEin = p.PayerEin, NextCheckNumber = p.NextCheckNumber,
AiProvider = p.AiProvider,
ClaudeModel = p.ClaudeModel,
ClaudeApiKeyMasked = Mask(p.ClaudeApiKey),
GeminiModel = p.GeminiModel,
GeminiApiKeyMasked = Mask(p.GeminiApiKey),
}; };
} }
@@ -28,7 +33,13 @@ public class ChurchProfileService : IChurchProfileService
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email; p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State; p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber; p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber; p.BankRoutingNumber = r.BankRoutingNumber; p.PayerEin = r.PayerEin; p.NextCheckNumber = r.NextCheckNumber;
p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider;
p.ClaudeModel = r.ClaudeModel;
p.GeminiModel = r.GeminiModel;
// Leave-unchanged semantics: only overwrite a stored key when a new value is supplied.
if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey;
if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
@@ -43,4 +54,12 @@ public class ChurchProfileService : IChurchProfileService
} }
return p; return p;
} }
/// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
private static string Mask(string? key)
{
if (string.IsNullOrEmpty(key)) return "";
if (key.Length <= 4) return new string('•', key.Length);
return new string('•', 6) + key[^4..];
}
} }
@@ -0,0 +1,86 @@
namespace ROLAC.API.Services.Disbursement;
/// <summary>
/// Field coordinates (in inches) for printing onto pre-printed three-stub check stock.
/// Every position is bound from the "CheckPrint:Layout" section of appsettings.json so alignment
/// can be tuned and reloaded without recompiling. Positions are expressed as an X (absolute from the
/// page's left edge) plus a Y offset within the field's stub; the per-stub origins below place the
/// three stacked regions (check + two identical receipt copies) down the 8.5"x11" page.
/// </summary>
public sealed class CheckPrintLayoutOptions
{
// Calibration nudge (inches): a TextBox renders its text inset down-and-right from the box
// origin by a fixed internal margin + line leading, so configured X/Y don't match the ink.
// These values are subtracted from every position so configured X/Y == actual printed position.
// To recalibrate: set both to 0, print, measure how far the ink sits past a known X/Y, and put
// those differences here (defaults are the measured inset for this stock).
public float TextInsetX { get; set; } = 0.13f;
public float TextInsetY { get; set; } = 0.15f;
// Stub origins — top edge of each region, inches from the top of the page.
public float CheckOriginY { get; set; } = 0f;
public float Receipt1OriginY { get; set; } = 3.67f;
public float Receipt2OriginY { get; set; } = 7.33f;
// Check stub fields (offset within the check stub).
public FieldPos Payee { get; set; } = new() { X = 1.25f, OffsetY = 1.75f, FontSize = 11, Bold = true };
public FieldPos AmountNumeric { get; set; } = new() { X = 6.50f, OffsetY = 1.75f, FontSize = 11, Bold = true };
public FieldPos AmountWords { get; set; } = new() { X = 0.60f, OffsetY = 2.20f, FontSize = 10 };
public FieldPos Memo { get; set; } = new() { X = 0.60f, OffsetY = 2.90f, FontSize = 9 };
public FieldPos CheckDate { get; set; } = new() { X = 6.50f, OffsetY = 1.25f, FontSize = 10 };
// Receipt stub fields (offset within a receipt stub — shared by both identical copies).
public FieldPos ReceiptPayee { get; set; } = new() { X = 1.00f, OffsetY = 0.30f, FontSize = 10, Bold = true };
public FieldPos ReceiptAmount { get; set; } = new() { X = 6.50f, OffsetY = 0.30f, FontSize = 10, Bold = true };
public FieldPos ReceiptMemo { get; set; } = new() { X = 1.00f, OffsetY = 0.60f, FontSize = 9 };
public FieldPos ReceiptDate { get; set; } = new() { X = 6.50f, OffsetY = 0.60f, FontSize = 9 };
// Voucher detail grid (offsets within a receipt stub).
public VoucherGridOptions Grid { get; set; } = new();
}
/// <summary>One printable field: where it sits and how it renders.</summary>
public sealed class FieldPos
{
/// <summary>Absolute X from the page's left edge, in inches.</summary>
public float X { get; set; }
/// <summary>Y offset within the field's stub, in inches (added to the stub origin).</summary>
public float OffsetY { get; set; }
public float FontSize { get; set; } = 10;
public bool Bold { get; set; }
}
/// <summary>
/// Two-column voucher detail grid on each receipt stub: 6 rows per column, 12 expense lines max,
/// filled column-major (lines 1-6 left, 7-12 right). All offsets are within the receipt stub.
/// </summary>
public sealed class VoucherGridOptions
{
/// <summary>Left edge of the left column block, in inches from the page's left edge.</summary>
public float OriginX { get; set; } = 0.60f;
/// <summary>Y offset (within the stub) of the first data row.</summary>
public float OffsetY { get; set; } = 1.10f;
public float RowHeight { get; set; } = 0.22f;
/// <summary>Horizontal gap between the left and right column blocks, in inches.</summary>
public float ColumnGap { get; set; } = 0.30f;
public float DateWidth { get; set; } = 0.85f;
public float DescWidth { get; set; } = 2.10f;
public float AmountWidth { get; set; } = 0.80f;
/// <summary>Draw our own Date/Description/Amount headers. Set false if the stock pre-prints them.</summary>
public bool ShowGridHeaders { get; set; } = true;
/// <summary>Y offset (within the stub) of the header row.</summary>
public float HeaderOffsetY { get; set; } = 0.88f;
/// <summary>Y offset (within the stub) of the "...and N more lines" overflow hint.</summary>
public float OverflowOffsetY { get; set; } = 2.55f;
public float FontSize { get; set; } = 8.5f;
}
@@ -1,17 +1,28 @@
using System.Drawing;
using System.Globalization; using System.Globalization;
using DevExpress.Office; using DevExpress.Office;
using DevExpress.XtraRichEdit; using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native; using DevExpress.XtraRichEdit.API.Native;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Disbursement; namespace ROLAC.API.Services.Disbursement;
/// <summary> /// <summary>
/// Renders a check on 8.5"x11" stock using the DevExpress Office (RichEdit) API: /// Renders a check onto pre-printed 8.5"x11" three-stub stock using the DevExpress Office
/// a check block on top followed by two identical ledger detail stubs. The layout is /// (RichEdit) API. Fields are placed as absolutely-positioned floating TextBoxes so they align to
/// built programmatically (no external .docx template) and exported to PDF. /// the boxes printed on the stock; every coordinate comes from <see cref="CheckPrintLayoutOptions"/>
/// (bound from "CheckPrint:Layout" in appsettings.json) so alignment can be tuned without recompiling.
/// The page is one check stub on top followed by two identical receipt copies.
/// </summary> /// </summary>
public class CheckPrintService : ICheckPrintService public class CheckPrintService : ICheckPrintService
{ {
private readonly CheckPrintLayoutOptions _layout;
public CheckPrintService(IOptions<CheckPrintLayoutOptions> layout)
{
_layout = layout.Value;
}
public Task<Stream> RenderPdfAsync(CheckPrintModel model) public Task<Stream> RenderPdfAsync(CheckPrintModel model)
{ {
using var server = new RichEditDocumentServer(); using var server = new RichEditDocumentServer();
@@ -26,9 +37,13 @@ public class CheckPrintService : ICheckPrintService
section.Margins.Left = section.Margins.Right = 0.6f; section.Margins.Left = section.Margins.Right = 0.6f;
section.Margins.Top = section.Margins.Bottom = 0.5f; section.Margins.Top = section.Margins.Bottom = 0.5f;
BuildCheckBlock(doc, model); // Floating TextBoxes must anchor to a paragraph; everything is positioned relative to
BuildStub(doc, model, "PAYMENT ADVICE — DETAIL"); // the page, so a single empty anchor paragraph at the document start is enough.
BuildStub(doc, model, "PAYMENT ADVICE — RECORD COPY"); var anchor = doc.Paragraphs[0];
BuildCheckStub(doc, anchor, model);
BuildReceiptStub(doc, anchor, model, _layout.Receipt1OriginY);
BuildReceiptStub(doc, anchor, model, _layout.Receipt2OriginY);
} }
finally finally
{ {
@@ -41,6 +56,125 @@ public class CheckPrintService : ICheckPrintService
return Task.FromResult<Stream>(ms); return Task.FromResult<Stream>(ms);
} }
// ── check page builders (absolute positioning) ─────────────────────────────
private void BuildCheckStub(Document doc, Paragraph anchor, CheckPrintModel model)
{
var check = model.Check;
var originY = _layout.CheckOriginY;
PlaceField(doc, anchor, _layout.Payee, originY, 4.5f, check.PayeeName);
PlaceField(doc, anchor, _layout.AmountNumeric, originY, 1.6f, FormatCurrency(check.Amount,13), rightAlign: false);
PlaceField(doc, anchor, _layout.AmountWords, originY, 6.0f, model.AmountInWords);
PlaceField(doc, anchor, _layout.CheckDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
if (!string.IsNullOrWhiteSpace(check.Memo))
PlaceField(doc, anchor, _layout.Memo, originY, 4.5f, check.Memo);
}
private void BuildReceiptStub(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
{
var check = model.Check;
PlaceField(doc, anchor, _layout.ReceiptPayee, originY, 4.5f, "Pay To The Order Of: " + check.PayeeName);
PlaceField(doc, anchor, _layout.ReceiptAmount, originY, 1.6f, "Amount: " + FormatCurrency(check.Amount), rightAlign: true);
PlaceField(doc, anchor, _layout.ReceiptDate, originY, 1.6f, check.CheckDate.ToString("MM/dd/yyyy"));
if (!string.IsNullOrWhiteSpace(check.Memo))
PlaceField(doc, anchor, _layout.ReceiptMemo, originY, 4.5f, check.Memo);
BuildVoucherGrid(doc, anchor, model, originY);
}
/// <summary>
/// Two-column voucher detail grid: up to 12 expense lines (6 per column), filled column-major.
/// Beyond 12 lines, prints the first 12 plus an overflow hint so the receipt total stays honest.
/// </summary>
private void BuildVoucherGrid(Document doc, Paragraph anchor, CheckPrintModel model, float originY)
{
var grid = _layout.Grid;
var blockWidth = grid.DateWidth + grid.DescWidth + grid.AmountWidth;
const int rowsPerColumn = 6;
const int maxLines = rowsPerColumn * 2;
float ColumnX(int column) => grid.OriginX + column * (blockWidth + grid.ColumnGap);
void PlaceRow(float x, float y, string date, string description, string amount, bool bold)
{
PlaceText(doc, anchor, x, y, grid.DateWidth, date, grid.FontSize, bold, rightAlign: false);
PlaceText(doc, anchor, x + grid.DateWidth, y, grid.DescWidth, description, grid.FontSize, bold, rightAlign: false);
PlaceText(doc, anchor, x + grid.DateWidth + grid.DescWidth, y, grid.AmountWidth, amount, grid.FontSize, bold, rightAlign: true);
}
if (grid.ShowGridHeaders)
{
var headerY = originY + grid.HeaderOffsetY;
for (var column = 0; column < 2; column++)
PlaceRow(ColumnX(column), headerY, "Date", "Description", "Amount", bold: true);
}
var printed = Math.Min(model.Lines.Count, maxLines);
for (var i = 0; i < printed; i++)
{
var line = model.Lines[i];
var column = i < rowsPerColumn ? 0 : 1;
var row = i % rowsPerColumn;
var y = originY + grid.OffsetY + row * grid.RowHeight;
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
PlaceRow(ColumnX(column), y, date, line.Description, FormatCurrency(line.Amount), bold: false);
}
if (model.Lines.Count > maxLines)
{
var remaining = model.Lines.Count - maxLines;
var remainingTotal = model.Lines.Skip(maxLines).Sum(line => line.Amount);
var hint = $"…and {remaining} more line(s) ({FormatCurrency(remainingTotal)})";
PlaceText(doc, anchor, grid.OriginX, originY + grid.OverflowOffsetY,
blockWidth * 2 + grid.ColumnGap, hint, grid.FontSize, bold: false, rightAlign: false);
}
}
/// <summary>Places one configured field's value at its stub-relative position.</summary>
private void PlaceField(Document doc, Paragraph anchor, FieldPos field, float stubOriginY,
float width, string text, bool rightAlign = false)
{
PlaceText(doc, anchor, field.X, stubOriginY + field.OffsetY, width, text,
field.FontSize, field.Bold, rightAlign);
}
/// <summary>
/// Inserts a borderless, fill-less floating TextBox positioned absolutely relative to the page
/// (X from the left edge, Y from the top edge) and writes <paramref name="text"/> into it.
/// </summary>
private void PlaceText(Document doc, Paragraph anchor, float x, float y, float width,
string text, float fontSize, bool bold, bool rightAlign)
{
var shape = doc.Shapes.InsertTextBox(anchor.Range.Start);
shape.HorizontalAlignment = ShapeHorizontalAlignment.None;
shape.VerticalAlignment = ShapeVerticalAlignment.None;
shape.RelativeHorizontalPosition = ShapeRelativeHorizontalPosition.Page;
shape.RelativeVerticalPosition = ShapeRelativeVerticalPosition.Page;
// Pull the box up-and-left by the calibration inset so the text inside lands exactly on the
// configured (x, y) rather than down-and-right of it.
shape.Offset = new PointF(x - _layout.TextInsetX, y - _layout.TextInsetY);
shape.Size = new SizeF(width, Math.Max(0.2f, fontSize / 72f + 0.08f));
shape.Fill.SetNoFill();
shape.Line.Fill.SetNoFill();
var body = shape.ShapeFormat.TextBox.Document;
var range = body.InsertText(body.Range.Start, text);
var characters = body.BeginUpdateCharacters(range);
characters.FontSize = fontSize;
characters.Bold = bold;
body.EndUpdateCharacters(characters);
if (rightAlign)
{
var paragraphs = body.BeginUpdateParagraphs(range);
paragraphs.Alignment = ParagraphAlignment.Right;
body.EndUpdateParagraphs(paragraphs);
}
}
public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model) public Task<Stream> RenderReceiptPdfAsync(CheckPrintModel model)
{ {
using var server = new RichEditDocumentServer(); using var server = new RichEditDocumentServer();
@@ -131,110 +265,15 @@ public class CheckPrintService : ICheckPrintService
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? ""); private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
private static void BuildCheckBlock(Document doc, CheckPrintModel m)
{
var issuer = m.Issuer;
var check = m.Check;
AppendLine(doc, issuer.Name, bold: true, size: 13);
var issuerAddr = JoinAddress(issuer.Address, issuer.City, issuer.State, issuer.ZipCode);
if (!string.IsNullOrWhiteSpace(issuerAddr)) AppendLine(doc, issuerAddr, size: 9);
if (!string.IsNullOrWhiteSpace(issuer.BankName)) AppendLine(doc, issuer.BankName, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy}", bold: true, size: 10);
AppendLine(doc, "");
AppendLine(doc, $"PAY TO THE ORDER OF: {check.PayeeName}", bold: true, size: 11);
var payeeAddr = JoinAddress(check.PayeeAddress, check.PayeeCity, check.PayeeState, check.PayeeZip);
if (!string.IsNullOrWhiteSpace(payeeAddr)) AppendLine(doc, payeeAddr, size: 9);
AppendLine(doc, "");
AppendLine(doc, $"AMOUNT: {FormatCurrency(check.Amount)}", bold: true, size: 12);
AppendLine(doc, m.AmountInWords, size: 10);
if (!string.IsNullOrWhiteSpace(check.Memo)) { AppendLine(doc, ""); AppendLine(doc, $"Memo: {check.Memo}", size: 9); }
AppendLine(doc, "");
AppendLine(doc, "____________________________________", size: 10);
AppendLine(doc, "Authorized Signature", size: 8);
AppendSeparator(doc);
}
private static void BuildStub(Document doc, CheckPrintModel m, string title)
{
var check = m.Check;
AppendLine(doc, title, bold: true, size: 10);
AppendLine(doc, $"Check No: {check.CheckNumber} Date: {check.CheckDate:MM/dd/yyyy} Payee: {check.PayeeName}", size: 9);
AppendLine(doc, "");
var rows = m.Lines.Count + 2; // header + lines + total
var table = doc.Tables.Create(doc.Range.End, rows, 3, AutoFitBehaviorType.AutoFitToWindow);
table.Borders.InsideHorizontalBorder.LineStyle = TableBorderLineStyle.Single;
table.Borders.Top.LineStyle = table.Borders.Bottom.LineStyle = TableBorderLineStyle.Single;
SetCell(doc, table[0, 0], "Date", bold: true);
SetCell(doc, table[0, 1], "Description", bold: true);
SetCell(doc, table[0, 2], "Amount", bold: true, right: true);
for (var i = 0; i < m.Lines.Count; i++)
{
var line = m.Lines[i];
var r = i + 1;
// CheckLine snapshots description; date comes from the source expense if loaded.
var date = line.Expense?.ExpenseDate.ToString("MM/dd/yyyy") ?? "";
SetCell(doc, table[r, 0], date);
SetCell(doc, table[r, 1], line.Description);
SetCell(doc, table[r, 2], FormatCurrency(line.Amount), right: true);
}
var totalRow = rows - 1;
SetCell(doc, table[totalRow, 0], "");
SetCell(doc, table[totalRow, 1], "TOTAL", bold: true, right: true);
SetCell(doc, table[totalRow, 2], FormatCurrency(check.Amount), bold: true, right: true);
AppendLine(doc, "");
if (check.ReceiptSignedAt is { } signedAt)
AppendLine(doc, $"Received by: {check.ReceiptSignedName} on {signedAt:MM/dd/yyyy HH:mm}", size: 9);
AppendSeparator(doc);
}
// ── low-level helpers ────────────────────────────────────────────────────── // ── low-level helpers ──────────────────────────────────────────────────────
private static string FormatCurrency(decimal amount, int paddingAstric = 0)
private static void AppendLine(Document doc, string text, bool bold = false, float size = 10)
{ {
var range = doc.AppendText(text + "\r\n"); var c = (CultureInfo)CultureInfo.GetCultureInfo("en-US").Clone();
var cp = doc.BeginUpdateCharacters(range); c.NumberFormat.CurrencySymbol = "";
cp.Bold = bold; string formatedAmount = amount.ToString("#,##0.00", CultureInfo.GetCultureInfo("en-US"));
cp.FontSize = size; return paddingAstric > 0 ? formatedAmount.PadLeft(paddingAstric, '*') : formatedAmount;
doc.EndUpdateCharacters(cp);
} }
private static void AppendSeparator(Document doc)
{
AppendLine(doc, "");
AppendLine(doc, "------------------------------------------------------------------------------------------", size: 8);
AppendLine(doc, "");
}
private static void SetCell(Document doc, TableCell cell, string text, bool bold = false, bool right = false)
{
var range = doc.InsertText(cell.ContentRange.Start, text);
var cp = doc.BeginUpdateCharacters(range);
cp.Bold = bold;
cp.FontSize = 9;
doc.EndUpdateCharacters(cp);
if (right)
{
var pp = doc.BeginUpdateParagraphs(range);
pp.Alignment = ParagraphAlignment.Right;
doc.EndUpdateParagraphs(pp);
}
}
private static string FormatCurrency(decimal amount) =>
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
private static string JoinAddress(string? addr, string? city, string? state, string? zip) private static string JoinAddress(string? addr, string? city, string? state, string? zip)
{ {
var cityLine = string.Join(", ", var cityLine = string.Join(", ",
+14 -1
View File
@@ -40,6 +40,19 @@ public class DisbursementService : IDisbursementService
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id); var members = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)).ToDictionaryAsync(m => m.Id);
// Category label per expense: the single line's category, or "Multiple" when it spans several.
var expenseIds = rows.Select(r => r.Id).ToList();
var lineGroups = await _db.ExpenseLines.AsNoTracking()
.Where(l => expenseIds.Contains(l.ExpenseId))
.OrderBy(l => l.Id)
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
.ToListAsync();
var categoryByExpense = lineGroups.GroupBy(l => l.ExpenseId).ToDictionary(
g => g.Key,
g => g.Select(l => l.CategoryGroupId).Distinct().Count() > 1
? "Multiple / 多類別"
: grpNames.GetValueOrDefault(g.First().CategoryGroupId, ""));
var groups = new Dictionary<string, PayeeGroupDto>(); var groups = new Dictionary<string, PayeeGroupDto>();
foreach (var e in rows) foreach (var e in rows)
{ {
@@ -77,7 +90,7 @@ public class DisbursementService : IDisbursementService
ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), ExpenseId = e.Id, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
Description = e.Description, Amount = e.Amount, Description = e.Description, Amount = e.Amount,
MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), CategoryName = categoryByExpense.GetValueOrDefault(e.Id, ""),
}); });
g.TotalAmount += e.Amount; g.TotalAmount += e.Amount;
} }
@@ -22,21 +22,35 @@ public class ExpenseCategoryService : IExpenseCategoryService
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en) .OrderBy(s => s.SortOrder).ThenBy(s => s.Name_en)
.ToListAsync(); .ToListAsync();
var lineCodes = await _db.Form990ExpenseLines.AsNoTracking()
.ToDictionaryAsync(l => l.Id, l => l.LineCode);
var boxCodes = await _db.Form1099Boxes.AsNoTracking()
.ToDictionaryAsync(b => b.Id, b => b.BoxCode);
return groups.Select(g => new ExpenseCategoryGroupDto return groups.Select(g => new ExpenseCategoryGroupDto
{ {
Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh, Id = g.Id, Name_en = g.Name_en, Name_zh = g.Name_zh,
SortOrder = g.SortOrder, IsActive = g.IsActive, SortOrder = g.SortOrder, IsActive = g.IsActive,
Form990LineId = g.Form990LineId,
Form990LineCode = g.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(g.Form990LineId.Value) : null,
Form1099BoxId = g.Form1099BoxId,
Form1099BoxCode = g.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(g.Form1099BoxId.Value) : null,
SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto SubCategories = subs.Where(s => s.GroupId == g.Id).Select(s => new ExpenseSubCategoryDto
{ {
Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh, Id = s.Id, GroupId = s.GroupId, Name_en = s.Name_en, Name_zh = s.Name_zh,
SortOrder = s.SortOrder, IsActive = s.IsActive, SortOrder = s.SortOrder, IsActive = s.IsActive,
Form990LineId = s.Form990LineId,
Form990LineCode = s.Form990LineId.HasValue ? lineCodes.GetValueOrDefault(s.Form990LineId.Value) : null,
Form1099BoxId = s.Form1099BoxId,
Form1099BoxCode = s.Form1099BoxId.HasValue ? boxCodes.GetValueOrDefault(s.Form1099BoxId.Value) : null,
}).ToList(), }).ToList(),
}).ToList(); }).ToList();
} }
public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r) public async Task<int> CreateGroupAsync(CreateExpenseGroupRequest r)
{ {
var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; var g = new ExpenseCategoryGroup { Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
_db.ExpenseCategoryGroups.Add(g); _db.ExpenseCategoryGroups.Add(g);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return g.Id; return g.Id;
@@ -46,7 +60,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var g = await _db.ExpenseCategoryGroups.FindAsync(id) var g = await _db.ExpenseCategoryGroups.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found."); ?? throw new KeyNotFoundException($"ExpenseCategoryGroup {id} not found.");
g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Name_en = r.Name_en; g.Name_zh = r.Name_zh; g.SortOrder = r.SortOrder; g.IsActive = r.IsActive; g.Form990LineId = r.Form990LineId; g.Form1099BoxId = r.Form1099BoxId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
@@ -62,7 +76,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId); var exists = await _db.ExpenseCategoryGroups.AnyAsync(g => g.Id == r.GroupId);
if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found."); if (!exists) throw new KeyNotFoundException($"ExpenseCategoryGroup {r.GroupId} not found.");
var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true }; var s = new ExpenseSubCategory { GroupId = r.GroupId, Name_en = r.Name_en, Name_zh = r.Name_zh, SortOrder = r.SortOrder, IsActive = true, Form990LineId = r.Form990LineId, Form1099BoxId = r.Form1099BoxId };
_db.ExpenseSubCategories.Add(s); _db.ExpenseSubCategories.Add(s);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return s.Id; return s.Id;
@@ -72,7 +86,7 @@ public class ExpenseCategoryService : IExpenseCategoryService
{ {
var s = await _db.ExpenseSubCategories.FindAsync(id) var s = await _db.ExpenseSubCategories.FindAsync(id)
?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found."); ?? throw new KeyNotFoundException($"ExpenseSubCategory {id} not found.");
s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.GroupId = r.GroupId; s.Name_en = r.Name_en; s.Name_zh = r.Name_zh; s.SortOrder = r.SortOrder; s.IsActive = r.IsActive; s.Form990LineId = r.Form990LineId; s.Form1099BoxId = r.Form1099BoxId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
+159 -28
View File
@@ -35,8 +35,9 @@ public class ExpenseService : IExpenseService
{ {
var query = _db.Expenses.AsNoTracking().AsQueryable(); var query = _db.Expenses.AsNoTracking().AsQueryable();
if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value); if (ministryId.HasValue) query = query.Where(e => e.MinistryId == ministryId.Value);
if (categoryGroupId.HasValue) query = query.Where(e => e.CategoryGroupId == categoryGroupId.Value); // Category filters now match against any line of the expense.
if (subCategoryId.HasValue) query = query.Where(e => e.SubCategoryId == subCategoryId.Value); if (categoryGroupId.HasValue) query = query.Where(e => e.Lines.Any(l => l.CategoryGroupId == categoryGroupId.Value));
if (subCategoryId.HasValue) query = query.Where(e => e.Lines.Any(l => l.SubCategoryId == subCategoryId.Value));
// `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard // `statuses` (comma-separated) takes precedence over single `status`; lets the dashboard
// request the Paid+Approved set in one call. // request the Paid+Approved set in one call.
if (!string.IsNullOrWhiteSpace(statuses)) if (!string.IsNullOrWhiteSpace(statuses))
@@ -81,57 +82,173 @@ public class ExpenseService : IExpenseService
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}"); var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}"); var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => $"{g.Name_en} / {g.Name_zh}");
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => $"{s.Name_en} / {s.Name_zh}");
var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet(); var memberIds = rows.Where(r => r.MemberId != null).Select(r => r.MemberId!.Value).ToHashSet();
var memNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id)) var memberNames = await _db.Members.AsNoTracking().Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}"); .Select(m => new { m.Id, m.FirstName_en, m.LastName_en, m.NickName })
.ToDictionaryAsync(
m => m.Id,
m => new MemberPayeeName($"{m.FirstName_en} {m.LastName_en}",
BuildNickPayeeName(m.NickName, m.FirstName_en, m.LastName_en)));
var reviewerNames = await ResolveUserNamesAsync(rows.Select(r => r.ReviewedBy));
var items = rows.Select(e => new ExpenseListItemDto // Line count + first line's category, per expense on this page.
var expenseIds = rows.Select(r => r.Id).ToList();
var lineRows = await _db.ExpenseLines.AsNoTracking()
.Where(l => expenseIds.Contains(l.ExpenseId))
.OrderBy(l => l.Id)
.Select(l => new { l.ExpenseId, l.CategoryGroupId })
.ToListAsync();
var linesByExpense = lineRows.GroupBy(l => l.ExpenseId)
.ToDictionary(g => g.Key, g => g.ToList());
var items = rows.Select(e =>
{
linesByExpense.TryGetValue(e.Id, out var ls);
var firstGroupId = ls is { Count: > 0 } ? ls[0].CategoryGroupId : 0;
return new ExpenseListItemDto
{ {
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""), MinistryId = e.MinistryId, MinistryName = minNames.GetValueOrDefault(e.MinistryId, ""),
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(e.CategoryGroupId, ""), LineCount = ls?.Count ?? 0,
SubCategoryId = e.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(e.SubCategoryId, ""), PrimaryCategoryName = grpNames.GetValueOrDefault(firstGroupId, ""),
VendorName = e.VendorName, MemberId = e.MemberId, VendorName = e.VendorName, MemberId = e.MemberId,
MemberName = e.MemberId != null ? memNames.GetValueOrDefault(e.MemberId.Value) : null, MemberName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Legal : null,
MemberNickName = e.MemberId != null ? memberNames.GetValueOrDefault(e.MemberId.Value)?.Nick : null,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"),
HasReceipt = e.ReceiptBlobPath != null, HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, CheckNumber = e.CheckNumber,
ReviewedByName = e.ReviewedBy != null ? reviewerNames.GetValueOrDefault(e.ReviewedBy) : null,
ReviewedAt = e.ReviewedAt,
ReviewNotes = e.ReviewNotes,
PayeeId = e.PayeeId,
};
}).ToList(); }).ToList();
return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize }; return new PagedResult<ExpenseListItemDto> { Items = items, TotalCount = total, Page = page, PageSize = pageSize };
} }
// Resolve actor user ids (AppUser.Id, stored in ReviewedBy/SubmittedBy/PaidBy) to a display name:
// the linked Member's full name when present, otherwise the account email.
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
{
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
if (ids.Count == 0) return new Dictionary<string, string>();
var users = await _db.Users.AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.Email, u.MemberId })
.ToListAsync();
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
var memberNames = await _db.Members.AsNoTracking()
.Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
return users.ToDictionary(
u => u.Id,
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
? name
: (u.Email ?? u.Id));
}
// Member payee names carried to the frontend: the legal name (printed on the check) and an
// optional friendly "NickName LastName" line shown above it.
private sealed record MemberPayeeName(string Legal, string? Nick);
// Build the friendly "NickName LastName" payee line, or null when the member has no distinct
// nickname (mirrors the frontend memberDisplayName rule: a nickname equal to the first name is not shown).
private static string? BuildNickPayeeName(string? nickName, string firstNameEn, string lastNameEn)
{
bool hasDistinctNickName = !string.IsNullOrWhiteSpace(nickName) && nickName != firstNameEn;
if (!hasDistinctNickName)
{
return null;
}
return $"{nickName} {lastNameEn}";
}
public async Task<ExpenseDto?> GetByIdAsync(int id) public async Task<ExpenseDto?> GetByIdAsync(int id)
{ {
var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id); var e = await _db.Expenses.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (e is null) return null; if (e is null) return null;
var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? ""; var minName = await _db.Ministries.Where(m => m.Id == e.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
var grpName = await _db.ExpenseCategoryGroups.Where(g => g.Id == e.CategoryGroupId).Select(g => g.Name_en).FirstOrDefaultAsync() ?? ""; string? memberName = null;
var subName = await _db.ExpenseSubCategories.Where(s => s.Id == e.SubCategoryId).Select(s => s.Name_en).FirstOrDefaultAsync() ?? ""; string? memberNickName = null;
string? memName = e.MemberId != null if (e.MemberId != null)
? await _db.Members.Where(m => m.Id == e.MemberId).Select(m => m.FirstName_en + " " + m.LastName_en).FirstOrDefaultAsync() {
var member = await _db.Members.AsNoTracking()
.Where(m => m.Id == e.MemberId)
.Select(m => new { m.FirstName_en, m.LastName_en, m.NickName })
.FirstOrDefaultAsync();
if (member != null)
{
memberName = $"{member.FirstName_en} {member.LastName_en}";
memberNickName = BuildNickPayeeName(member.NickName, member.FirstName_en, member.LastName_en);
}
}
var reviewerName = e.ReviewedBy != null
? (await ResolveUserNamesAsync(new[] { e.ReviewedBy })).GetValueOrDefault(e.ReviewedBy)
: null; : null;
var lines = await _db.ExpenseLines.AsNoTracking().Where(l => l.ExpenseId == id).OrderBy(l => l.Id).ToListAsync();
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(s => s.Id, s => s.Name_en);
var lineDtos = lines.Select(l => new ExpenseLineItemDto
{
Id = l.Id, CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
return new ExpenseDto return new ExpenseDto
{ {
Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description, Id = e.Id, Type = e.Type, Status = e.Status, Amount = e.Amount, Description = e.Description,
MinistryId = e.MinistryId, MinistryName = minName, MinistryId = e.MinistryId, MinistryName = minName,
CategoryGroupId = e.CategoryGroupId, CategoryGroupName = grpName, LineCount = lineDtos.Count,
SubCategoryId = e.SubCategoryId, SubCategoryName = subName, PrimaryCategoryName = lineDtos.Count > 0 ? lineDtos[0].CategoryGroupName : "",
VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memName, VendorName = e.VendorName, MemberId = e.MemberId, MemberName = memberName, MemberNickName = memberNickName,
ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null, ExpenseDate = e.ExpenseDate.ToString("yyyy-MM-dd"), HasReceipt = e.ReceiptBlobPath != null,
CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes, CheckNumber = e.CheckNumber, Notes = e.Notes, ReviewNotes = e.ReviewNotes,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, ReviewedAt = e.ReviewedAt, PaidAt = e.PaidAt, ReviewedByName = reviewerName, ReviewedAt = e.ReviewedAt,
SubmittedBy = e.SubmittedBy, SubmittedAt = e.SubmittedAt, PaidAt = e.PaidAt,
PayeeId = e.PayeeId,
Lines = lineDtos,
}; };
} }
// Lines are the source of truth: ≥1 line, each with a category/subcategory and a positive amount.
private static void ValidateLines(List<ExpenseLineInput> lines)
{
if (lines is null || lines.Count == 0)
throw new InvalidOperationException("An expense must have at least one line.");
foreach (var l in lines)
{
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
throw new InvalidOperationException("Each expense line needs a category group and subcategory.");
if (l.Amount <= 0)
throw new InvalidOperationException("Each expense line amount must be greater than zero.");
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
}
}
private static List<ExpenseLine> BuildLines(List<ExpenseLineInput> inputs) =>
inputs.Select(l => new ExpenseLine
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance) public async Task<int> CreateAsync(CreateExpenseRequest r, bool isFinance)
{ {
ValidateLines(r.Lines);
var e = new Expense var e = new Expense
{ {
MinistryId = r.MinistryId, CategoryGroupId = r.CategoryGroupId, SubCategoryId = r.SubCategoryId, MinistryId = r.MinistryId,
Type = r.Type, Amount = r.Amount, Description = r.Description, VendorName = r.VendorName, Type = r.Type, Amount = r.Lines.Sum(l => l.Amount), Description = r.Description, VendorName = r.VendorName,
CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes, CheckNumber = r.CheckNumber, ExpenseDate = r.ExpenseDate, Notes = r.Notes,
Lines = BuildLines(r.Lines),
}; };
if (r.Type == "VendorPayment") if (r.Type == "VendorPayment")
@@ -158,6 +275,7 @@ public class ExpenseService : IExpenseService
e.VendorName = null; e.VendorName = null;
} }
e.PayeeId = r.PayeeId;
_db.Expenses.Add(e); _db.Expenses.Add(e);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return e.Id; return e.Id;
@@ -171,16 +289,21 @@ public class ExpenseService : IExpenseService
public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance) public async Task UpdateAsync(int id, UpdateExpenseRequest r, bool isFinance)
{ {
ValidateLines(r.Lines);
// FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies. // FirstOrDefaultAsync (not FindAsync) so the soft-delete query filter applies.
var e = await _db.Expenses.FirstOrDefaultAsync(x => x.Id == id) var e = await _db.Expenses.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Expense {id} not found."); ?? throw new KeyNotFoundException($"Expense {id} not found.");
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval"))) if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only edit your own draft or pending reimbursements."); throw new InvalidOperationException("You can only edit your own draft, pending, or rejected reimbursements.");
e.MinistryId = r.MinistryId; e.CategoryGroupId = r.CategoryGroupId; e.SubCategoryId = r.SubCategoryId; e.MinistryId = r.MinistryId; e.Description = r.Description; e.CheckNumber = r.CheckNumber;
e.Amount = r.Amount; e.Description = r.Description; e.CheckNumber = r.CheckNumber; e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes; e.PayeeId = r.PayeeId;
e.ExpenseDate = r.ExpenseDate; e.Notes = r.Notes;
if (e.Type == "VendorPayment") e.VendorName = r.VendorName; if (e.Type == "VendorPayment") e.VendorName = r.VendorName;
// Replace the line set wholesale (lines are owned by the header), recompute the total.
_db.ExpenseLines.RemoveRange(e.Lines);
e.Lines = BuildLines(r.Lines);
e.Amount = r.Lines.Sum(l => l.Amount);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
@@ -203,8 +326,11 @@ public class ExpenseService : IExpenseService
{ {
var e = await RequireAsync(id); var e = await RequireAsync(id);
if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement."); if (e.SubmittedBy != CurrentUserId) throw new InvalidOperationException("Only the submitter can submit this reimbursement.");
if (e.Status != "Draft") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'."); // Draft (first submit) or Rejected (re-submit after fixing the flagged issue, e.g. a clearer receipt).
if (e.Status != "Draft" && e.Status != "Rejected") throw new InvalidOperationException($"Cannot submit from status '{e.Status}'.");
e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow; e.Status = "PendingApproval"; e.SubmittedAt = DateTimeOffset.UtcNow;
// Clear the prior review so the expense returns to a clean pending state.
e.ReviewedBy = null; e.ReviewedAt = null; e.ReviewNotes = null;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
@@ -227,6 +353,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'."); if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot reject from status '{e.Status}'.");
e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes; e.Status = "Rejected"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.ReviewNotes = reviewNotes;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.ExpenseRejected, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} rejected: {e.Description} — {reviewNotes}");
} }
public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt) public async Task PayAsync(int id, string? checkNumber, DateOnly? paidAt)
@@ -245,8 +376,8 @@ public class ExpenseService : IExpenseService
public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance) public async Task SaveReceiptAsync(int id, Stream content, string fileName, bool isFinance)
{ {
var e = await RequireAsync(id); var e = await RequireAsync(id);
if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval"))) if (!isFinance && !(e.SubmittedBy == CurrentUserId && (e.Status == "Draft" || e.Status == "PendingApproval" || e.Status == "Rejected")))
throw new InvalidOperationException("You can only attach receipts to your own draft or pending reimbursements."); throw new InvalidOperationException("You can only attach receipts to your own draft, pending, or rejected reimbursements.");
var safe = Path.GetFileName(fileName).Replace(' ', '_'); var safe = Path.GetFileName(fileName).Replace(' ', '_');
var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}"; var path = $"finance/receipts/{e.ExpenseDate.Year}/{e.ExpenseDate.Month}/{e.Id}-{safe}";
@@ -0,0 +1,165 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
public class ExpenseSnapshotService : IExpenseSnapshotService
{
private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http;
public ExpenseSnapshotService(AppDbContext db, IHttpContextAccessor http)
{ _db = db; _http = http; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
// then fall back to "sub" (real tokens). Required for the self-ownership guard to work in production.
private string CurrentUserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _http.HttpContext?.User.FindFirstValue("sub")
?? "system";
public async Task<List<ExpenseSnapshotDto>> GetAllAsync()
{
var snaps = await _db.ExpenseSnapshots.AsNoTracking()
.OrderByDescending(s => s.CreatedAt).ThenByDescending(s => s.Id)
.ToListAsync();
if (snaps.Count == 0) return new();
var ids = snaps.Select(s => s.Id).ToList();
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
.Where(l => ids.Contains(l.SnapshotId)).ToListAsync();
var linesBySnapshot = lines.GroupBy(l => l.SnapshotId).ToDictionary(g => g.Key, g => g.ToList());
var minNames = await _db.Ministries.AsNoTracking().ToDictionaryAsync(m => m.Id, m => $"{m.Name_en} / {m.Name_zh}");
var creatorNames = await ResolveUserNamesAsync(snaps.Select(s => s.CreatedBy));
return snaps.Select(s =>
{
linesBySnapshot.TryGetValue(s.Id, out var ls);
return new ExpenseSnapshotDto
{
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId,
MinistryName = minNames.GetValueOrDefault(s.MinistryId, ""),
Description = s.Description, VendorName = s.VendorName,
CheckNumber = s.CheckNumber, Notes = s.Notes,
TotalAmount = ls?.Sum(l => l.Amount) ?? 0,
LineCount = ls?.Count ?? 0,
CreatedByName = creatorNames.GetValueOrDefault(s.CreatedBy),
CreatedAt = s.CreatedAt,
};
}).ToList();
}
public async Task<ExpenseSnapshotDto?> GetByIdAsync(int id)
{
var s = await _db.ExpenseSnapshots.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return null;
var lines = await _db.ExpenseSnapshotLines.AsNoTracking()
.Where(l => l.SnapshotId == id).OrderBy(l => l.Id).ToListAsync();
var minName = await _db.Ministries.Where(m => m.Id == s.MinistryId).Select(m => m.Name_en).FirstOrDefaultAsync() ?? "";
var grpNames = await _db.ExpenseCategoryGroups.AsNoTracking().ToDictionaryAsync(g => g.Id, g => g.Name_en);
var subNames = await _db.ExpenseSubCategories.AsNoTracking().ToDictionaryAsync(x => x.Id, x => x.Name_en);
var creatorName = (await ResolveUserNamesAsync(new[] { s.CreatedBy })).GetValueOrDefault(s.CreatedBy);
return new ExpenseSnapshotDto
{
Id = s.Id, Name = s.Name, MinistryId = s.MinistryId, MinistryName = minName,
Description = s.Description, VendorName = s.VendorName, CheckNumber = s.CheckNumber, Notes = s.Notes,
TotalAmount = lines.Sum(l => l.Amount), LineCount = lines.Count,
CreatedByName = creatorName, CreatedAt = s.CreatedAt,
Lines = lines.Select(l => new ExpenseSnapshotLineDto
{
CategoryGroupId = l.CategoryGroupId, CategoryGroupName = grpNames.GetValueOrDefault(l.CategoryGroupId, ""),
SubCategoryId = l.SubCategoryId, SubCategoryName = subNames.GetValueOrDefault(l.SubCategoryId, ""),
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList(),
};
}
public async Task<int> CreateAsync(CreateExpenseSnapshotRequest r)
{
ValidateLines(r.Lines);
var s = new ExpenseSnapshot
{
Name = r.Name.Trim(), MinistryId = r.MinistryId, Description = r.Description,
VendorName = r.VendorName, CheckNumber = r.CheckNumber, Notes = r.Notes,
Lines = BuildLines(r.Lines),
};
_db.ExpenseSnapshots.Add(s);
await _db.SaveChangesAsync();
return s.Id;
}
public async Task UpdateAsync(int id, UpdateExpenseSnapshotRequest r)
{
ValidateLines(r.Lines);
var s = await _db.ExpenseSnapshots.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
s.Name = r.Name.Trim(); s.MinistryId = r.MinistryId; s.Description = r.Description;
s.VendorName = r.VendorName; s.CheckNumber = r.CheckNumber; s.Notes = r.Notes;
_db.ExpenseSnapshotLines.RemoveRange(s.Lines);
s.Lines = BuildLines(r.Lines);
await _db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var s = await _db.ExpenseSnapshots.FirstOrDefaultAsync(x => x.Id == id)
?? throw new KeyNotFoundException($"Snapshot {id} not found.");
s.IsDeleted = true; s.DeletedAt = DateTimeOffset.UtcNow; s.DeletedBy = CurrentUserId;
await _db.SaveChangesAsync();
}
private static void ValidateLines(List<ExpenseLineInput> lines)
{
if (lines is null || lines.Count == 0)
throw new InvalidOperationException("A snapshot must have at least one line.");
foreach (var l in lines)
{
if (l.CategoryGroupId <= 0 || l.SubCategoryId <= 0)
throw new InvalidOperationException("Each snapshot line needs a category group and subcategory.");
if (l.Amount <= 0)
throw new InvalidOperationException("Each snapshot line amount must be greater than zero.");
if (l.FunctionalClass is not null && !FunctionalClasses.All.Contains(l.FunctionalClass))
throw new InvalidOperationException($"Invalid functional class '{l.FunctionalClass}'.");
}
}
private static List<ExpenseSnapshotLine> BuildLines(List<ExpenseLineInput> inputs) =>
inputs.Select(l => new ExpenseSnapshotLine
{
CategoryGroupId = l.CategoryGroupId, SubCategoryId = l.SubCategoryId,
FunctionalClass = l.FunctionalClass, Amount = l.Amount, Description = l.Description,
}).ToList();
// Resolve actor user ids (AppUser.Id, stored in CreatedBy) to a display name: the linked
// Member's full name when present, otherwise the account email. Mirrors ExpenseService.
private async Task<Dictionary<string, string>> ResolveUserNamesAsync(IEnumerable<string?> userIds)
{
var ids = userIds.Where(id => !string.IsNullOrEmpty(id)).Select(id => id!).Distinct().ToList();
if (ids.Count == 0) return new();
var users = await _db.Users.AsNoTracking()
.Where(u => ids.Contains(u.Id))
.Select(u => new { u.Id, u.Email, u.MemberId })
.ToListAsync();
var memberIds = users.Where(u => u.MemberId != null).Select(u => u.MemberId!.Value).ToHashSet();
var memberNames = await _db.Members.AsNoTracking()
.Where(m => memberIds.Contains(m.Id))
.ToDictionaryAsync(m => m.Id, m => $"{m.FirstName_en} {m.LastName_en}".Trim());
return users.ToDictionary(
u => u.Id,
u => u.MemberId != null && memberNames.TryGetValue(u.MemberId.Value, out var name) && name.Length > 0
? name
: (u.Email ?? u.Id));
}
}
@@ -54,16 +54,23 @@ public class FinanceDashboardService : IFinanceDashboardService
{ {
var q = PaidApproved(from, to); var q = PaidApproved(from, to);
if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value); if (ministryId.HasValue) q = q.Where(e => e.MinistryId == ministryId.Value);
if (categoryGroupId.HasValue) q = q.Where(e => e.CategoryGroupId == categoryGroupId.Value);
// Group by the deepest level whose parent id is supplied. // Lines belonging to the scoped (Paid+Approved, optionally ministry-filtered) expenses.
var scopedLines = from l in _db.ExpenseLines
join e in q on l.ExpenseId equals e.Id
select l;
// Group by the deepest level whose parent id is supplied. Category levels aggregate
// over LINES (line amounts); the ministry level uses the header total to avoid
// double-counting a multi-line expense across its lines.
List<(int Id, decimal Amount)> grouped; List<(int Id, decimal Amount)> grouped;
if (categoryGroupId.HasValue) if (categoryGroupId.HasValue)
grouped = (await q.GroupBy(e => e.SubCategoryId) grouped = (await scopedLines.Where(l => l.CategoryGroupId == categoryGroupId.Value)
.GroupBy(l => l.SubCategoryId)
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync()) .Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
.Select(x => (x.Id, x.Amount)).ToList(); .Select(x => (x.Id, x.Amount)).ToList();
else if (ministryId.HasValue) else if (ministryId.HasValue)
grouped = (await q.GroupBy(e => e.CategoryGroupId) grouped = (await scopedLines.GroupBy(l => l.CategoryGroupId)
.Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync()) .Select(g => new { Id = g.Key, Amount = g.Sum(x => x.Amount) }).ToListAsync())
.Select(x => (x.Id, x.Amount)).ToList(); .Select(x => (x.Id, x.Amount)).ToList();
else else
@@ -0,0 +1,160 @@
using System.Globalization;
using System.Text;
using DevExpress.Office;
using DevExpress.XtraRichEdit;
using DevExpress.XtraRichEdit.API.Native;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Payee;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Produces recipient-facing 1099 outputs: a plain-paper Copy B 1099-NEC PDF (rendered with the
/// DevExpress RichEdit/Office API, mirroring <c>CheckPrintService</c>) and a filing-data CSV.
/// </summary>
public class Form1099FormService : I1099FormService
{
private readonly IForm1099ReportService _report;
private readonly IPayee1099Service _payees;
private readonly AppDbContext _db;
public Form1099FormService(IForm1099ReportService report, IPayee1099Service payees, AppDbContext db)
{
_report = report;
_payees = payees;
_db = db;
}
public async Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear)
{
var payee = await _payees.GetByIdAsync(payeeId)
?? throw new InvalidOperationException($"Payee {payeeId} not found.");
var church = await _db.ChurchProfiles.AsNoTracking().OrderBy(x => x.Id).FirstOrDefaultAsync()
?? new ChurchProfile { Name = "Church" };
// Box 1 (Nonemployee compensation) = sum of this payee's NEC-1 payments for the year.
var detail = await _report.GetRecipientDetailAsync(payeeId, taxYear);
var box1Nec = detail?.Payments
.Where(payment => payment.BoxCode == Entities.Form1099.BoxNec1)
.Sum(payment => payment.Amount) ?? 0m;
using var server = new RichEditDocumentServer();
var document = server.Document;
document.BeginUpdate();
try
{
document.Unit = DocumentUnit.Inch;
var section = document.Sections[0];
section.Page.Width = 8.5f;
section.Page.Height = 11f;
section.Margins.Left = section.Margins.Right = 0.8f;
section.Margins.Top = section.Margins.Bottom = 0.8f;
document.AppendHtmlText(BuildCopyBHtml(church, payee, taxYear, box1Nec));
}
finally
{
document.EndUpdate();
}
var stream = new MemoryStream();
server.ExportToPdf(stream);
stream.Position = 0;
return (stream, "application/pdf", $"1099-NEC-{payeeId}-{taxYear}.pdf");
}
public async Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear)
{
var summary = await _report.GetAnnualSummaryAsync(taxYear);
var builder = new StringBuilder();
builder.AppendLine("LegalName,TinLast4,W9Status,Box1_NEC,Box1_Rents,Total,MeetsThreshold");
foreach (var row in summary.Rows)
{
builder.AppendLine(string.Join(",",
Csv(row.LegalName), Csv(row.TinLast4 ?? ""), Csv(row.W9Status),
row.NecTotal.ToString(CultureInfo.InvariantCulture),
row.RentsTotal.ToString(CultureInfo.InvariantCulture),
row.GrandTotal.ToString(CultureInfo.InvariantCulture),
row.MeetsThreshold ? "Y" : "N"));
}
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
return (new MemoryStream(bytes), "text/csv", $"1099-filing-{taxYear}.csv");
static string Csv(string value) => value.Contains(',') || value.Contains('"')
? "\"" + value.Replace("\"", "\"\"") + "\"" : value;
}
private static string BuildCopyBHtml(ChurchProfile church, Payee1099Dto payee, int taxYear, decimal box1Nec)
{
var payerAddress = JoinAddress(church.Address, church.City, church.State, church.ZipCode);
var recipientAddress = JoinAddress(
JoinLines(payee.AddressLine1, payee.AddressLine2), payee.City, payee.State, payee.Zip);
var payerEin = string.IsNullOrWhiteSpace(church.PayerEin) ? "" : church.PayerEin;
var maskedTin = string.IsNullOrWhiteSpace(payee.TinLast4) ? "" : $"***-**-{payee.TinLast4}";
return
"<div style=\"font-family:Arial;font-size:11pt;color:#111;\">" +
$"<h2 style=\"text-align:center;margin:0;\">Form 1099-NEC &mdash; Copy B (For Recipient)</h2>" +
$"<p style=\"text-align:center;margin:4px 0 16px 0;\"><b>Tax Year {taxYear}</b><br/>Nonemployee Compensation</p>" +
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"6\" width=\"100%\" style=\"border-collapse:collapse;\">" +
"<tr><td width=\"50%\" valign=\"top\">" +
"<b>PAYER&rsquo;s name, address</b><br/>" +
$"{Encode(church.Name)}<br/>{payerAddress}" +
"</td>" +
"<td width=\"50%\" valign=\"top\">" +
$"<b>PAYER&rsquo;s TIN (EIN)</b><br/>{Encode(payerEin)}" +
"</td></tr>" +
"<tr><td valign=\"top\">" +
"<b>RECIPIENT&rsquo;s name, address</b><br/>" +
$"{Encode(payee.LegalName)}<br/>{recipientAddress}" +
"</td>" +
"<td valign=\"top\">" +
$"<b>RECIPIENT&rsquo;s TIN</b><br/>{Encode(maskedTin)}" +
"</td></tr>" +
"<tr><td colspan=\"2\">" +
"<b>Box 1 &mdash; Nonemployee compensation</b><br/>" +
$"<span style=\"font-size:14pt;\"><b>{Encode(FormatCurrency(box1Nec))}</b></span>" +
"</td></tr>" +
"</table>" +
"<p style=\"font-size:8pt;color:#555;margin-top:12px;\">" +
"This is important tax information and is being furnished to the recipient. " +
"Recipient&rsquo;s taxpayer identification number is shown masked for security." +
"</p>" +
"</div>";
}
private static string Encode(string? text) => System.Net.WebUtility.HtmlEncode(text ?? "");
private static string FormatCurrency(decimal amount) =>
amount.ToString("C2", CultureInfo.GetCultureInfo("en-US"));
private static string? JoinLines(string? line1, string? line2)
{
var parts = new[] { line1, line2 }.Where(part => !string.IsNullOrWhiteSpace(part));
var joined = string.Join(", ", parts);
return string.IsNullOrWhiteSpace(joined) ? null : joined;
}
// Builds an HTML address block; each text part is HTML-encoded and the line break (<br/>) is literal.
private static string JoinAddress(string? address, string? city, string? state, string? zip)
{
var cityLine = string.Join(", ",
new[] { city, string.Join(" ", new[] { state, zip }.Where(part => !string.IsNullOrWhiteSpace(part))) }
.Where(part => !string.IsNullOrWhiteSpace(part)));
var lines = new[] { address, cityLine }
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(Encode);
return string.Join("<br/>", lines);
}
}
@@ -0,0 +1,10 @@
namespace ROLAC.API.Services;
public interface I1099FormService
{
/// <summary>Recipient Copy B 1099-NEC PDF for one payee/year (plain paper).</summary>
Task<(Stream stream, string contentType, string fileName)> RenderCopyBAsync(int payeeId, int taxYear);
/// <summary>Filing-data CSV (one row per reportable recipient) for IRIS/accountant.</summary>
Task<(Stream stream, string contentType, string fileName)> ExportFilingCsvAsync(int taxYear);
}
@@ -0,0 +1,134 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Finance;
using ROLAC.API.Entities;
namespace ROLAC.API.Services;
/// <summary>
/// Read-only aggregation producing the year-end 1099 recipient summary. CASH BASIS:
/// only Paid expenses whose PaidAt falls in the tax year, attributed to a tracked payee,
/// on a line whose category maps to a 1099 box (sub ?? group). Unmapped lines are excluded.
/// </summary>
public class Form1099ReportService : IForm1099ReportService
{
private readonly AppDbContext _db;
public Form1099ReportService(AppDbContext db) => _db = db;
public async Task<List<Form1099BoxDto>> GetBoxesAsync() =>
await _db.Form1099Boxes.AsNoTracking().Where(b => b.IsActive)
.OrderBy(b => b.SortOrder)
.Select(b => new Form1099BoxDto
{
Id = b.Id, BoxCode = b.BoxCode, Name_en = b.Name_en,
Name_zh = b.Name_zh, FormType = b.FormType, SortOrder = b.SortOrder,
}).ToListAsync();
/// <summary>
/// Pulls the reportable expense lines for the tax year and materializes them (anonymous
/// projection -&gt; ToListAsync -&gt; in-memory map), mirroring Form990ReportService so the SQL
/// translation stays simple on Npgsql. The tax year is a half-open UTC range
/// [Jan 1 taxYear, Jan 1 taxYear+1), deterministic regardless of server timezone and matching
/// how Expense.PaidAt is written (midnight UTC). Unmapped lines (no 1099 box) are dropped here
/// so callers always receive reportable lines.
/// </summary>
private async Task<List<PaidLine>> LoadReportableLinesAsync(int taxYear)
{
var start = new DateTimeOffset(new DateTime(taxYear, 1, 1), TimeSpan.Zero);
var end = start.AddYears(1);
var raw = await (
from e in _db.Expenses.Where(e => e.Status == "Paid" && e.PaidAt != null
&& e.PaidAt >= start && e.PaidAt < end && e.PayeeId != null)
join p in _db.Payee1099s.Where(p => p.Is1099Tracked) on e.PayeeId equals p.Id
join l in _db.ExpenseLines on e.Id equals l.ExpenseId
join sub in _db.ExpenseSubCategories on l.SubCategoryId equals sub.Id
join grp in _db.ExpenseCategoryGroups on l.CategoryGroupId equals grp.Id
select new
{
PayeeId = p.Id,
p.LegalName,
p.TinLast4,
p.W9Status,
PaidAt = e.PaidAt!.Value,
e.Description,
GroupName = grp.Name_en,
SubName = sub.Name_en,
l.Amount,
BoxId = sub.Form1099BoxId ?? grp.Form1099BoxId,
}).ToListAsync();
return raw.Where(x => x.BoxId != null)
.Select(x => new PaidLine
{
PayeeId = x.PayeeId,
LegalName = x.LegalName,
TinLast4 = x.TinLast4,
W9Status = x.W9Status,
PaidAt = x.PaidAt,
Description = x.Description,
CategoryName = x.GroupName + " / " + x.SubName,
Amount = x.Amount,
BoxId = x.BoxId,
}).ToList();
}
public async Task<Form1099SummaryDto> GetAnnualSummaryAsync(int taxYear)
{
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
var lines = await LoadReportableLinesAsync(taxYear);
var dto = new Form1099SummaryDto { TaxYear = taxYear };
foreach (var g in lines.GroupBy(x => x.PayeeId))
{
var first = g.First();
var nec = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxNec1).Sum(x => x.Amount);
var rents = g.Where(x => boxes.GetValueOrDefault(x.BoxId!.Value) == Form1099.BoxMisc1).Sum(x => x.Amount);
var w9Missing = first.W9Status != Form1099.W9Status.OnFile;
var meets = nec >= Form1099.ReportingThreshold || rents >= Form1099.ReportingThreshold;
dto.Rows.Add(new Form1099RecipientRowDto
{
PayeeId = first.PayeeId, LegalName = first.LegalName, TinLast4 = first.TinLast4,
W9Status = first.W9Status, NecTotal = nec, RentsTotal = rents,
GrandTotal = nec + rents, MeetsThreshold = meets, W9Missing = w9Missing,
});
}
dto.Rows = dto.Rows.OrderByDescending(r => r.GrandTotal).ThenBy(r => r.LegalName).ToList();
dto.TotalReportable = dto.Rows.Sum(r => r.GrandTotal);
dto.RecipientsAtThreshold = dto.Rows.Count(r => r.MeetsThreshold);
dto.RecipientsMissingW9 = dto.Rows.Count(r => r.W9Missing);
return dto;
}
public async Task<Form1099RecipientDetailDto?> GetRecipientDetailAsync(int payeeId, int taxYear)
{
var payee = await _db.Payee1099s.AsNoTracking().FirstOrDefaultAsync(p => p.Id == payeeId);
if (payee is null) return null;
var boxes = await _db.Form1099Boxes.AsNoTracking().ToDictionaryAsync(b => b.Id, b => b.BoxCode);
var lines = (await LoadReportableLinesAsync(taxYear)).Where(x => x.PayeeId == payeeId).ToList();
return new Form1099RecipientDetailDto
{
PayeeId = payee.Id, LegalName = payee.LegalName, TinLast4 = payee.TinLast4,
W9Status = payee.W9Status, TaxYear = taxYear,
Payments = lines.OrderBy(x => x.PaidAt).Select(x => new Form1099PaymentDto
{
PaidDate = DateOnly.FromDateTime(x.PaidAt.Date).ToString("yyyy-MM-dd"),
Description = x.Description, CategoryName = x.CategoryName,
BoxCode = boxes.GetValueOrDefault(x.BoxId!.Value) ?? "", Amount = x.Amount,
}).ToList(),
};
}
private sealed class PaidLine
{
public int PayeeId { get; set; }
public string LegalName { get; set; } = "";
public string? TinLast4 { get; set; }
public string W9Status { get; set; } = "";
public DateTimeOffset PaidAt { get; set; }
public string Description { get; set; } = "";
public string CategoryName { get; set; } = "";
public decimal Amount { get; set; }
public int? BoxId { get; set; }
}
}

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