Manual giving module (Phase 1): giving category config, single-entry giving, and keyboard-first Sunday offering batch entry (OfferingSession) with finance-gated reconciliation. Client-buffered bulk submit (decision B), lock-after-submit, Member-or-anonymous givers with inline quick-add. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
15 KiB
Design Spec: 奉獻追蹤(手動)Giving / Donation Tracking
日期: 2026-05-28
作者: Chris Chen
狀態: 待核准
對應規劃: docs/PLANNING.md §3.6 / §3.6c、docs/DB_SCHEMA.md §7
1. 範疇
本 spec 只規劃**奉獻追蹤(手動)**模組,三項核心功能:
- 奉獻類型設定 —
GivingCategoryCRUD(什一 / 一般 / 特別 / 建堂 / 宣教…) - 單筆奉獻記錄 — 單筆
Giving(現金 / 支票 / Zelle / PayPal 手動) - 主日奉獻袋批次輸入 —
OfferingSession鍵盤優先快速錄入 + 對帳
明確不在本次範圍(之後另開 spec):
- 支出追蹤 & 報銷、月結對帳表(屬另一模組)
- 年度 IRS 收據(
GivingReceipt,PLANNING §3.7) - 個人奉獻歷史查詢、奉獻摘要報表(PLANNING §3.9)
- 線上金流 Stripe / PayPal Checkout、定期奉獻(
GivingRecurringSchedule,Phase 4) - 收據照片上傳(奉獻無此需求;blob 儲存尚未建置)
2. 關鍵決策摘要
| # | 決策 | 選擇 |
|---|---|---|
| D1 | 批次錄入儲存策略 | B — 純前端緩衝、最後一次 bulk submit。前端記憶體累積整批,提交時一次 POST 建立 Session + 所有 Giving(單一 transaction)。 |
| D2 | Session 鎖定 | Submitted 後鎖定。修改需 finance 角色 reopen(回 Draft、整批載回前端)→ 編輯 → 重新提交整批替換。所有變動進 Audit Log。 |
| D3 | 奇款人識別 | giver 永遠是 Member 或匿名(維持 DB_SCHEMA 不變,不加 GiverName 自由欄)。非匿名又查無此人時,批次介面可快速新增 Member。 |
| D4 | 快速新增教友欄位 | 快速表單要求英文姓名(FirstName_en / LastName_en),與現有 Member schema 必填一致;Status 預設 Visitor。 |
| D5 | 授權 | 奉獻所有寫入操作限 finance + super_admin(對應 PLANNING §4 權限矩陣)。 |
3. 資料模型
照 DB_SCHEMA.md §7,新增 3 個 entity,全部繼承 AuditableEntity(非軟刪除 —— 奉獻記錄為金錢帳目,刪除走實刪 + Audit Log;與 Member 的軟刪除不同)。
討論點(待確認): DB_SCHEMA §7 的 Giving / OfferingSession 未列
IsDeleted。本 spec 採實刪 + Audit。若日後需保留刪除痕跡,可改繼承SoftDeleteEntity,但會影響月結加總邏輯(需排除IsDeleted)。本次維持實刪。
3.1 GivingCategory(奉獻類型)
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int PK | |
| Name_en | varchar(200) NOT NULL | e.g. 'Tithe' |
| Name_zh | varchar(200)? | e.g. '什一奉獻' |
| Description_en | varchar(500)? | |
| Description_zh | varchar(500)? | |
| IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用(IsActive=false),不實刪,以保留歷史 Giving 的 FK |
| SortOrder | int NOT NULL DEFAULT 0 | |
| + AuditableEntity 欄位 | CreatedAt/By, UpdatedAt/By |
3.2 OfferingSession(主日奉獻袋批次)
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int PK | |
| SessionDate | date NOT NULL UNIQUE | 一個主日只有一個 Session |
| Status | varchar(20) NOT NULL DEFAULT 'Draft' | 'Draft' | 'Submitted' | 'Reconciled' |
| CashTotal | decimal(18,2) NOT NULL DEFAULT 0 | 財務人工清點現金總額 |
| CheckTotal | decimal(18,2) NOT NULL DEFAULT 0 | 支票清點總額 |
| SystemTotal | decimal(18,2) NOT NULL DEFAULT 0 | 伺服器計算的明細加總 |
| Difference | decimal(18,2) NOT NULL DEFAULT 0 | = (CashTotal + CheckTotal) − SystemTotal,目標 0 |
| Notes | text? | |
| SubmittedAt / SubmittedBy | timestamp? / varchar(450)? | |
| ReconciledAt / ReconciledBy | timestamp? / varchar(450)? | |
| + AuditableEntity 欄位 |
Reconciled 狀態用途: 本次仍實作
submit(Draft→Submitted)。Reconciled作為日後月結對帳模組對接的終態保留欄位,本次 UI 不主動設定(只在 schema 預留)。
3.3 Giving(奉獻記錄)
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int PK | |
| MemberId | int? | FK → Members.Id;null = 匿名 |
| GivingCategoryId | int NOT NULL | FK → GivingCategories.Id |
| OfferingSessionId | int? | FK → OfferingSessions.Id;null = 非批次單筆 |
| Amount | decimal(18,2) NOT NULL | IRS 收據用金額 |
| PaymentMethod | varchar(20) NOT NULL | 'Cash' | 'Check' | 'Zelle' | 'PayPal' | 'Other' |
| CheckNumber | varchar(50)? | PaymentMethod=Check 時必填 |
| ZelleReferenceCode | varchar(100)? | PaymentMethod=Zelle |
| PayPalTransactionId | varchar(100)? | PaymentMethod=PayPal |
| GivingDate | date NOT NULL | 批次時 = SessionDate |
| IsAnonymous | bool NOT NULL DEFAULT false | |
| Notes | varchar(500)? | |
| + AuditableEntity 欄位 |
Phase 4 欄位(
GrossAmount/FeeAmount/StripePaymentIntentId/GivingRecurringScheduleId)本次不建,待線上金流模組再加,屆時為純新增欄位,不影響本次結構。
3.4 EF Core 設定(AppDbContext.OnModelCreating)
- 新增 3 個
DbSet<GivingCategory>/<OfferingSession>/<Giving>。 - fluent 設定:各
Name_*/Description_*maxlength;decimal 欄位decimal(18,2)(沿用既有全域 decimal 慣例,或逐欄設定)。 OfferingSession:HasIndex(SessionDate).IsUnique()。Giving索引(對應 DB_SCHEMA §17):idx_givings_member_date→(MemberId, GivingDate)idx_givings_session→(OfferingSessionId)filter not nullidx_givings_date→(GivingDate)
- FK 行為:
Giving.GivingCategoryId→Restrict(類型有奉獻時不可實刪,只能軟停用)Giving.MemberId→SetNullGiving.OfferingSessionId→Cascade(刪 Session 連帶刪明細;一般不會發生,僅資料一致性)
3.5 Seed(DbSeeder)
新增 SeedGivingCategoriesAsync(沿用既有 code-based 風格,非 HasData):
1. Tithe / 什一奉獻
2. General Offering / 一般奉獻
3. Special Offering / 特別奉獻
4. Building Fund / 建堂基金
5. Mission / 宣教奉獻
3.6 Migration
一支 EF migration(如 AddGivingModule),涵蓋 3 表 + 索引 + FK。
4. API 介面
全部 [Authorize(Roles = "finance,super_admin")]。Controller 維持 thin → 委派 service,沿用既有 PagedResult<> 與錯誤轉換慣例(KeyNotFoundException→404,鎖定衝突→409)。
4.1 奉獻類型 GivingCategoriesController — /api/giving-categories
GET /api/giving-categories?includeInactive=false 全部(預設僅 active)
POST /api/giving-categories
PUT /api/giving-categories/{id}
DELETE /api/giving-categories/{id} 軟停用 IsActive=false
4.2 單筆奉獻 GivingsController — /api/givings
GET /api/givings?page&pageSize&search&categoryId&from&to 分頁列表
GET /api/givings/{id}
POST /api/givings 單筆,OfferingSessionId=null
PUT /api/givings/{id}
DELETE /api/givings/{id}
search比對 Member 姓名(中英)/ CheckNumber / Notes。- 屬於某 Session(
OfferingSessionId != null)的 Giving,若該 Session 已Submitted,單筆 PUT/DELETE 一律拒絕(409)—— 必須走 Sessionreopen。
4.3 批次 OfferingSessionsController — /api/offering-sessions
GET /api/offering-sessions?page&pageSize&from&to 歷次 Session 列表
GET /api/offering-sessions/{id} 含完整明細 + 小計(reopen/檢視用)
POST /api/offering-sessions 一次建立整批(見 §6)
POST /api/offering-sessions/{id}/reopen Submitted→Draft(finance,進 Audit)
PUT /api/offering-sessions/{id} 整批替換明細 + 總額,重新 Submitted
GET /api/offering-sessions/check-date?date=... 檢查該日期是否已有 Session
- 批次教友搜尋:復用
GET /api/members?search=(已支援中英文,見MemberService)。 - 批次內快速新增教友:復用
POST /api/members(現有CreateMemberRequest)。
POST /api/offering-sessions 請求形狀
{
"sessionDate": "2026-05-31",
"cashTotal": 1250.00,
"checkTotal": 800.00,
"notes": "...",
"givings": [
{ "memberId": 12, "givingCategoryId": 1, "amount": 100.00,
"paymentMethod": "Cash", "isAnonymous": false, "notes": null },
{ "memberId": null, "givingCategoryId": 2, "amount": 50.00,
"paymentMethod": "Cash", "isAnonymous": true },
{ "memberId": 8, "givingCategoryId": 1, "amount": 300.00,
"paymentMethod": "Check", "checkNumber": "1043" }
]
}
回傳建立後的 Session(含 id、伺服器算的 systemTotal、difference、status="Submitted")。
5. 後端 Service 層
3 個 service(interface + impl,沿用 MemberService 風格:建構式注入 AppDbContext + IHttpContextAccessor、CurrentUserId、手動 DTO mapping)。
IGivingCategoryService— CRUD;DELETE = 設IsActive=false。IGivingService— 單筆 CRUD + 分頁查詢;寫入前檢查所屬 Session 鎖定狀態。IOfferingSessionService— 核心:CreateAsync(request):單一 transaction 內建立OfferingSession(Status=Submitted)+ 所有Giving(OfferingSessionId回填、GivingDate=SessionDate);伺服器重算SystemTotal = Σ amount、Difference;SubmittedAt/By設值。SessionDate撞 unique → 409。GetByIdAsync(id):含明細投影。ReopenAsync(id):僅Submitted可 reopen →Draft,清SubmittedAt/By。ReplaceAsync(id, request):僅Draft可改;刪舊明細、插新明細、重算、回Submitted。- 鎖定守門:對已
Submitted的修改丟InvalidOperationException→ controller 轉 409。 - 金額一律以伺服器加總為準(不信任前端
systemTotal)。
6. 批次錄入流程(前端,核心體驗)
頁面:features/giving/pages/offering-session-page。採決策 D1(純前端緩衝)。
1. 選日期(預設今天)→ 呼叫 check-date 確認當日尚無 Session
2. 逐筆快速錄入(全部存在前端記憶體 buffer)
┌─────────────────────────────────────────────┐
│ 搜尋教友:[即時下拉] ← 復用 GET /api/members │
│ 類型:[GivingCategory ▼] │
│ 付款:(●現金 ○支票 ○Zelle ○PayPal) │
│ 現金→信封號(選填) / 支票→支票號(必填) │
│ 金額:[$____] 備註:[____] │
│ [匿名] [快速新增教友] [+ 新增(Enter)] │
│ ───────────────────────────────────────── │
│ 已錄入 N 筆 | 前端小計:$X,XXX.XX │
└─────────────────────────────────────────────┘
- Tab 跳欄、Enter 新增;新增後焦點回搜尋框
- 樂觀 UI:新增即在右側清單顯示、即時更新前端小計
- 清單每列可即時改 / 刪(純前端操作)
3. 對帳步驟:輸入實點 CashTotal / CheckTotal
→ 前端顯示 Difference = (Cash+Check) − 前端小計(目標 0)
4. 「提交」→ 一次 POST /api/offering-sessions(整批)
→ 伺服器 transaction 建立 + 重算 + 回傳 → 鎖定
5. 編輯已提交:finance 在歷次列表點 reopen
→ GET 載回整批進 buffer → 編輯 → PUT 整批替換 → 重新 Submitted
鍵盤優先要點: 復用既有 focus-navigator / init-focus directives 與 currency-input / drop-down-list 共用元件。
7. 快速新增教友(批次介面內)
- 搜尋無結果時出現「快速新增教友」→ inline 小 dialog。
- 收最少欄位:
FirstName_en、LastName_en(必填,決策 D4)、FirstName_zh/LastName_zh(選填)、PhoneCell(選填)。 Status預設Visitor。- 呼叫現有
POST /api/members(CreateMemberRequest)→ 取回id→ 自動帶回當前錄入列、焦點移至金額。
8. 前端 — 其餘頁面
依既有 feature 結構 features/giving/{pages,components,services,models},標準元件:standalone component + Kendo Grid/Inputs/Buttons/Dropdowns + RxJS + ApiConfigService。
8.1 奉獻類型管理頁 giving-categories-page
Kendo Grid 列表 + 新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 users/members 頁模式。
8.2 單筆奉獻頁 givings-page
Kendo Grid 列表(搜尋 / 日期區間 / 類型篩選 / 分頁)+ 單筆新增/編輯 dialog。付款方式切換時動態顯示支票號 / Zelle ref / PayPal txn 欄位。教友選擇復用 GET /api/members?search=。
8.3 API service / model
每頁一支 *-api.service.ts + models/*.model.ts(DTO interface,對齊後端 DTO 命名)。
9. 授權 & 導覽
- 後端:所有奉獻 controller
[Authorize(Roles="finance,super_admin")]。 - 前端:沿用既有 role-gated sidebar nav(commit
bc67146的 Administration 機制),在 Finance/Administration 區塊新增「奉獻」分組,內含三頁,僅finance/super_admin可見。 - route guard 沿用
core/guards既有角色守衛。
10. 測試
後端 ROLAC.API.Tests(沿用既有測試風格,InMemory provider):
GivingCategoryService:CRUD、DELETE 軟停用、有奉獻時類型不可實刪。GivingService:單筆 CRUD、分頁/篩選、屬已 Submitted Session 的單筆寫入被拒(409)。OfferingSessionService(重點):- 整批建立 →
SystemTotal/Difference伺服器重算正確(不信前端值)。 SessionDate撞 unique → 409。- 已
Submitted修改被拒(鎖定守門)。 reopen→replace→ 重新Submitted流程,明細正確替換。- 匿名筆(
MemberId=null/IsAnonymous=true)正確記錄。
- 整批建立 →
- 授權:非 finance/super_admin 角色被 403。
11. 風險 / 待確認
| # | 項目 | 說明 |
|---|---|---|
| R1 | 實刪 vs 軟刪除(§3 討論點) | 本次 Giving/Session 採實刪 + Audit。若財務合規要求保留刪除痕跡,需改軟刪除並調整加總邏輯。待 Chris 確認。 |
| R2 | 前端緩衝資料遺失(決策 B 的取捨) | 提交前瀏覽器崩潰 → 整批未存的資料遺失。可選緩解:localStorage 暫存草稿(本次列為可選,不強制)。 |
| R3 | reopen 整批替換的 Audit 粒度 | 整批替換會產生「刪舊 N 筆 + 插新 M 筆」的 Audit 記錄,而非逐筆 diff。對帳追溯時需理解此模型。 |
| R4 | Audit Log 基礎設施 | 現有 AuditSaveChangesInterceptor 是否已涵蓋這些新 entity 的 Create/Update/Delete,需在實作時確認;AuditLog 表本身屬另一模組,若尚未建置需補。 |
12. 交付順序建議
- Entities + EF 設定 + Seed + migration
- Service 層(3 個)+ 單元測試
- Controllers + 授權
- 前端 models + api services
- 奉獻類型管理頁 → 單筆奉獻頁 → 批次錄入頁
- 導覽整合 + 端到端手動驗證