# 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→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. 導覽整合 + 端到端手動驗證
