diff --git a/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md b/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md new file mode 100644 index 0000000..6bf0a65 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md @@ -0,0 +1,298 @@ +# 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` / `` / ``。 +- 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→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 請求形狀 +```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 個 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. 交付順序建議 + +1. Entities + EF 設定 + Seed + migration +2. Service 層(3 個)+ 單元測試 +3. Controllers + 授權 +4. 前端 models + api services +5. 奉獻類型管理頁 → 單筆奉獻頁 → 批次錄入頁 +6. 導覽整合 + 端到端手動驗證