Files
ROLAC/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md
T
Chris Chen 82b9744024 docs: add design spec for giving/donation tracking module
Manual giving module (Phase 1): giving category config, single-entry
giving, and keyboard-first Sunday offering batch entry (OfferingSession)
with finance-gated reconciliation. Client-buffered bulk submit (decision
B), lock-after-submit, Member-or-anonymous givers with inline quick-add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-28 15:47:09 -07:00

299 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design Spec: 奉獻追蹤(手動)Giving / Donation Tracking
**日期:** 2026-05-28
**作者:** Chris Chen
**狀態:** 待核准
**對應規劃:** `docs/PLANNING.md §3.6 / §3.6c``docs/DB_SCHEMA.md §7`
---
## 1. 範疇
本 spec 只規劃**奉獻追蹤(手動)**模組,三項核心功能:
1. **奉獻類型設定**`GivingCategory` CRUD(什一 / 一般 / 特別 / 建堂 / 宣教…)
2. **單筆奉獻記錄** — 單筆 `Giving`(現金 / 支票 / Zelle / PayPal 手動)
3. **主日奉獻袋批次輸入**`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 null
- `idx_givings_date``(GivingDate)`
- FK 行為:
- `Giving.GivingCategoryId``Restrict`(類型有奉獻時不可實刪,只能軟停用)
- `Giving.MemberId``SetNull`
- `Giving.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)—— 必須走 Session `reopen`
### 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→Draftfinance,進 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 請求形狀
```jsonc
{
"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 個 serviceinterface + 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. 交付順序建議
1. Entities + EF 設定 + Seed + migration
2. Service 層(3 個)+ 單元測試
3. Controllers + 授權
4. 前端 models + api services
5. 奉獻類型管理頁 → 單筆奉獻頁 → 批次錄入頁
6. 導覽整合 + 端到端手動驗證