From fdd0d7c8e11a6fe4efb8680dc8e8901a5f10172c Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Fri, 29 May 2026 17:23:21 -0700 Subject: [PATCH] docs(expense): add expense tracking & reimbursement design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all five PLANNING §3.6d items: category seed (11 groups/38 subs), vendor direct payment, staff reimbursement with receipt upload + self-service submission, finance approval workflow (Draft→PendingApproval→Approved→Paid), and monthly reconciliation statement. Per DB_SCHEMA §8. Key decisions: IFileStorage abstraction + local-disk impl for receipts (Azure Blob deferred), member self-submission alongside finance entry, soft-delete Expense, cash-basis (Paid-only) monthly expense totals. Co-Authored-By: Claude Opus 4.8 --- .../2026-05-29-expense-tracking-design.md | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-expense-tracking-design.md diff --git a/docs/superpowers/specs/2026-05-29-expense-tracking-design.md b/docs/superpowers/specs/2026-05-29-expense-tracking-design.md new file mode 100644 index 0000000..1dc653b --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-expense-tracking-design.md @@ -0,0 +1,392 @@ +# Design Spec: 支出追蹤 & 報銷 Expense Tracking & Reimbursement + +**日期:** 2026-05-29 +**作者:** Chris Chen +**狀態:** 待核准 +**對應規劃:** `docs/PLANNING.md §3.6d`、`docs/DB_SCHEMA.md §8` +**前置模組:** 奉獻追蹤(`docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md`,已合併) + +--- + +## 1. 範疇 + +本 spec 規劃**支出追蹤 & 報銷**模組,涵蓋 PLANNING 勾選清單全部五項: + +1. **支出類別設定** — `ExpenseCategoryGroup`(11 大類)+ `ExpenseSubCategory`(~38 子項)CRUD + 種子 +2. **廠商直接付款記錄** — `Expense`(`Type=VendorPayment`),含支票號碼,建立即 `Paid` +3. **同工代墊報銷申請** — `Expense`(`Type=StaffReimbursement`),含收據照片上傳 + **同工自助提交** +4. **財務審核流程** — 狀態機 `Draft → PendingApproval → Approved → Paid`(或 `Rejected`) +5. **月結對帳表** — `MonthlyStatement`(期初 + 奉獻 − 支出 = 帳面期末,對比銀行對帳單) + +**明確不在本次範圍:** +- 事工預算 `MinistryBudget`(Phase 3,schema §15 已預留,本次不建) +- Azure Blob 實際接線(本次用本機磁碟 `IFileStorage` 實作,介面預留 Blob 替換) +- Capacitor Camera 原生拍照(本次用網頁 file input,桌機 + 行動網頁皆可) +- 報表 / 圖表(PLANNING §3.9,另模組) + +--- + +## 2. 關鍵決策摘要 + +| # | 決策 | 選擇 | +|---|------|------| +| D1 | 收據儲存 | **`IFileStorage` 抽象 + 本機磁碟實作**。商業邏輯依賴介面;未來換 Azure Blob 只需新增一個實作,不動 service。下載由 API 驗權後串流(取代 dev 尚無的 SAS URL)。 | +| D2 | 報銷提交範圍 | **同工自助 + 財務代建並行**。任何登入者可建立/提交/查詢自己的報銷;finance/super_admin 可代建並全覽審核。 | +| D3 | 月結納入本次 | **是**,五項一次交付。`MonthlyStatement` 彙總已完成的 `Giving` + 本模組 `Expense`。 | +| D4 | Expense 刪除策略 | **軟刪除**(`SoftDeleteEntity` + 全域 query filter)。依 DB_SCHEMA §8 `Expense` 含 `IsDeleted`;與 `Giving` 的實刪不同。 | +| D5 | 月結支出認列基礎 | **僅 `Status=Paid` 計入當月 `TotalExpenses`**(現金基礎;`Approved` 未付款者不計)。依 DB_SCHEMA §8 註解。**已經 Chris 確認。** | +| D6 | 類別刪除 | **軟停用** `IsActive=false`(保留歷史 Expense 的 FK),沿用 `GivingCategory` 作法。 | +| D7 | 授權 | 自助寫入限「提交者本人 + Draft 狀態」;審核/廠商付款/類別/月結限 `finance,super_admin`;pastor 全支出唯讀。對應 PLANNING §3.6d 權限表。 | + +--- + +## 3. 資料模型 + +依 `DB_SCHEMA.md §8`,新增 4 個 entity。 + +> **基底類別慣例:** 既有 `GivingCategory` 雖 schema §7 未列審計欄,程式實作仍繼承 `AuditableEntity`(審計欄由 `AuditSaveChangesInterceptor` 自動戳記)。本模組 category 表沿用同一慣例以保持一致。 + +### 3.1 ExpenseCategoryGroup(支出大類,Level 1)— `AuditableEntity` + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| Name_en | varchar(200) NOT NULL | | +| Name_zh | varchar(200)? | | +| SortOrder | int NOT NULL DEFAULT 0 | | +| IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用 | +| + AuditableEntity | | CreatedAt/By, UpdatedAt/By | + +### 3.2 ExpenseSubCategory(支出子項目,Level 2)— `AuditableEntity` + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| GroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id(Restrict)| +| Name_en | varchar(200) NOT NULL | | +| Name_zh | varchar(200)? | | +| SortOrder | int NOT NULL DEFAULT 0 | | +| IsActive | bool NOT NULL DEFAULT true | DELETE = 軟停用 | +| + AuditableEntity | | | + +### 3.3 Expense(支出記錄)— `SoftDeleteEntity` + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| MinistryId | int NOT NULL | FK → Ministries.Id(Restrict)| +| CategoryGroupId | int NOT NULL | FK → ExpenseCategoryGroups.Id(Restrict)| +| SubCategoryId | int NOT NULL | FK → ExpenseSubCategories.Id(Restrict)| +| Type | varchar(30) NOT NULL | 'VendorPayment' \| 'StaffReimbursement' | +| Status | varchar(30) NOT NULL DEFAULT 'Draft' | 見 §5 狀態機 | +| Amount | decimal(18,2) NOT NULL | | +| Description | varchar(500) NOT NULL | | +| VendorName | varchar(200)? | VendorPayment 用 | +| MemberId | int? | FK → Members.Id;StaffReimbursement 指向代墊同工(SetNull)| +| CheckNumber | varchar(50)? | 付款支票號碼 | +| ExpenseDate | date NOT NULL | | +| ReceiptBlobPath | varchar(500)? | 收據路徑(透過 `IFileStorage`)| +| Notes | text? | | +| SubmittedBy | varchar(450)? | FK → AspNetUsers.Id(提交報銷者)| +| SubmittedAt | timestamp? | | +| ReviewedBy | varchar(450)? | FK → AspNetUsers.Id(審核人)| +| ReviewedAt | timestamp? | | +| ReviewNotes | varchar(500)? | 審核/退回備注 | +| PaidAt | timestamp? | | +| PaidBy | varchar(450)? | FK → AspNetUsers.Id | +| + SoftDeleteEntity | | IsDeleted/DeletedAt/DeletedBy + AuditableEntity | + +> **欄位對齊:** schema §8 `PayeeType`/`PayeeName` 在本實作以 `Type` + `VendorName`(廠商)/ `MemberId`(同工)表達——`StaffReimbursement` 的收款人即 `MemberId` 指向的同工,不另存自由文字姓名,避免與 Member 資料重複(沿用 Giving 模組 D3「giver 永遠是 Member」的一致原則)。 + +### 3.4 MonthlyStatement(月底對帳表)— `AuditableEntity` + +| 欄位 | 型別 | 說明 | +|------|------|------| +| Id | int PK | | +| Year | int NOT NULL | | +| Month | int NOT NULL | 1–12 | +| OpeningBalance | decimal(18,2) NOT NULL | 期初餘額(手輸)| +| TotalGiving | decimal(18,2) NOT NULL | 伺服器算:該月 Giving 合計 | +| TotalOtherIncome | decimal(18,2) NOT NULL DEFAULT 0 | 其他收入(手輸)| +| TotalExpenses | decimal(18,2) NOT NULL | 伺服器算:該月 `Status=Paid` 且未刪支出合計 | +| CalculatedClosingBalance | decimal(18,2) NOT NULL | = Opening + Giving + OtherIncome − Expenses | +| BankStatementBalance | decimal(18,2) NOT NULL | 銀行對帳單期末(手輸)| +| Difference | decimal(18,2) NOT NULL | = Calculated − Bank(目標 0)| +| Notes | text? | | +| IsFinalized | bool NOT NULL DEFAULT false | 定稿後鎖定不可修改 | +| FinalizedAt | timestamp? | | +| FinalizedBy | varchar(450)? | FK → AspNetUsers.Id | +| + AuditableEntity | | | +| **UNIQUE** | (Year, Month) | 每月一份 | + +### 3.5 EF Core 設定(`AppDbContext.OnModelCreating`) + +- 4 個 `DbSet<>`;`Name_*` maxlength;decimal 沿用全域 `decimal(18,2)` 慣例。 +- `Expense` 全域 query filter `e => !e.IsDeleted`(沿用既有 Member/PrayerRequest 慣例)。 +- `MonthlyStatement.HasIndex(Year, Month).IsUnique()`。 +- 索引(對應 DB_SCHEMA §17): + - `idx_expenses_ministry` → `(MinistryId)` + - `idx_expenses_status` → `(Status)` filter `IsDeleted=false` + - `idx_expenses_date` → `(ExpenseDate)` +- FK 行為:`MinistryId`/`CategoryGroupId`/`SubCategoryId` = **Restrict**(有支出時類別/事工不可實刪,只能軟停用);`MemberId` = **SetNull**;`SubCategory → Group` = **Restrict**。 + +### 3.6 Seed(`DbSeeder.SeedExpenseCategoriesAsync`) + +沿用既有 code-based 風格(非 `HasData`),依 DB_SCHEMA §8 完整清單植入 **11 大類 + 38 子項**: + +``` +1 Equipment 設備: Purchase 購置 · Rental 租借 · Maintenance & Repair 維修 +2 Consumables 消耗品: Batteries 電池 · Accessories 配件 · Cleaning Supplies 清潔用品 · Office Supplies 文具 +3 Food & Beverage 餐飲: Catering 出餐費用 · Food Ingredients 食材採購 · Utensils 器具 · Consumables 消耗品 +4 Training 培訓: Course Fees 課程費用 · Books 書籍 · Conference 研討會 · Travel 差旅 +5 Materials 教材: Printing 印刷費用 · Craft Supplies 手工材料 · Copyright & Licensing 版權購買 +6 Facility 場地: Rent 場地租金 · Utilities 水電 · Property Insurance 財產保險 · Decoration 裝飾 +7 Printing 印刷: Bulletins 週報 · Order of Service 程序單 · Posters 海報 +8 Missions 宣教: Offering Transfer 奉獻轉帳 · Missionary Support 宣教士支援 · Travel 差旅 +9 Benevolence 關懷救助: Emergency Aid 急難救助 · Condolence Gifts 慰問禮品 · Visit Expenses 探訪費用 +10 Other 其他: Miscellaneous 雜支 +11 Personnel 人事: Salary & Wages 薪資 · Payroll Taxes 薪資稅費 · Employee Benefits 員工福利 · Workers Compensation 勞工保險 · Honorarium 酬庸 · Staff Training 同工進修 · Contract Labor 外包勞務 +``` + +冪等:以 `Name_en` 為鍵 `AnyAsync` 檢查後才插入(沿用 `SeedGivingCategoriesAsync` 模式)。 + +### 3.7 Migration + +一支 EF migration `AddExpenseModule`,涵蓋 4 表 + 索引 + FK。 + +--- + +## 4. 收據檔案儲存(`IFileStorage`) + +### 4.1 介面(新增 `Services/Storage/IFileStorage.cs`) + +```csharp +public interface IFileStorage +{ + Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default); + Task OpenReadAsync(string relativePath, CancellationToken ct = default); + Task DeleteAsync(string relativePath, CancellationToken ct = default); +} +``` + +### 4.2 本機磁碟實作(`LocalDiskFileStorage`) + +- 根目錄來自 config `Storage:LocalRoot`(預設 `App_Data/storage`,相對於 ContentRoot)。 +- 防目錄穿越:正規化 `relativePath`,拒絕 `..`/絕對路徑。 +- `Program.cs` 註冊 `services.AddScoped()`。 +- `appsettings.json` 加 `"Storage": { "LocalRoot": "App_Data/storage" }`;`App_Data/storage` 加進 `.gitignore`。 + +### 4.3 路徑慣例 + +``` +finance/receipts/{year}/{month}/{expenseId}-{sanitizedFilename} +``` + +### 4.4 上傳 / 下載端點(在 `ExpensesController`) + +- `POST /api/expenses/{id}/receipt`(multipart/form-data,`IFormFile file`): + - 驗 MIME:`image/jpeg`、`image/png`、`image/webp`、`application/pdf`;大小上限(如 10 MB)。 + - 權限:提交者本人(自己的報銷)或 finance/super_admin。 + - 存檔 → 回填 `Expense.ReceiptBlobPath` → 回 200。若已有舊檔則覆蓋並刪舊。 +- `GET /api/expenses/{id}/receipt`:驗權後 `IFileStorage.OpenReadAsync` 串流回傳(含 Content-Type)。權限同上 + pastor 唯讀。 + +--- + +## 5. 狀態機 & 授權 + +### 5.1 狀態流轉(伺服器端守門) + +``` +VendorPayment: + finance 建立 ──► Paid(無審核;需 CheckNumber 或付款資訊) + +StaffReimbursement: + Draft ─submit─► PendingApproval ─approve─► Approved ─pay─► Paid + └──────────── reject ───────────► Rejected +``` + +- 合法轉換對照表(service 強制;非法轉換丟 `InvalidOperationException` → controller 409): + +| 動作 | 允許的起始狀態 | 結果狀態 | 角色 | +|------|---------------|---------|------| +| submit | Draft | PendingApproval | 提交者本人 | +| approve | PendingApproval | Approved | finance, super_admin | +| reject | PendingApproval | Rejected(記 `ReviewNotes`)| finance, super_admin | +| pay | Approved | Paid(記 `CheckNumber`/`PaidAt`/`PaidBy`)| finance, super_admin | + +- `Rejected` 後是否可重新提交:本次**不允許**(終態);同工需另建新報銷。可列為日後增強。 + +### 5.2 授權矩陣(對應 PLANNING §3.6d) + +| 操作 | 角色 | +|------|------| +| 提交報銷申請 / 編輯自己的 Draft / 上傳自己的收據 | 所有登入用戶(含 member)| +| 查看自己的申請(`/api/expenses/mine`)| 提交者本人 | +| 審核 approve / reject / pay | finance, super_admin | +| 建立廠商直接付款 | finance, super_admin | +| 查看所有支出(`/api/expenses`)| finance, super_admin(pastor 唯讀)| +| 類別管理 | finance, super_admin | +| 月底對帳 | finance, super_admin | + +- 自助寫入守門:service 比對 `Expense.SubmittedBy == CurrentUserId` 且 `Status=Draft`,否則 403/409。 +- `CurrentUserId` 透過 `IHttpContextAccessor`(沿用既有 service 慣例)。 +- 同工自助建立時 `MemberId` = 當前使用者 `AppUser.MemberId`(若有連結);`Type=StaffReimbursement`、`Status=Draft`、`SubmittedBy=自己`。 + +--- + +## 6. API 介面 + +全部依角色授權。Controller thin → 委派 service,沿用 `PagedResult<>` 與錯誤轉換(`KeyNotFoundException`→404,狀態/鎖定衝突→409,跨人存取→403)。 + +### 6.1 類別 `ExpenseCategoriesController` — `/api/expense-categories`(finance, super_admin) +``` +GET /api/expense-categories?includeInactive=false 大類含巢狀子項 +POST /api/expense-categories/groups 新增大類 +PUT /api/expense-categories/groups/{id} +DELETE /api/expense-categories/groups/{id} 軟停用 +POST /api/expense-categories/subcategories 新增子項 +PUT /api/expense-categories/subcategories/{id} +DELETE /api/expense-categories/subcategories/{id} 軟停用 +``` + +### 6.2 支出 `ExpensesController` — `/api/expenses` +``` +GET /api/expenses?page&pageSize&search&ministryId&categoryGroupId&status&from&to + finance 全覽分頁(含 pastor 唯讀) +GET /api/expenses/mine?status&page&pageSize 同工查自己的報銷 +GET /api/expenses/{id} 本人或 finance/pastor +POST /api/expenses 建立(vendor 付款 或 報銷草稿) +PUT /api/expenses/{id} 編輯(Draft 提交者 或 finance) +DELETE /api/expenses/{id} 軟刪除(finance;或本人的 Draft) +POST /api/expenses/{id}/submit Draft→PendingApproval(提交者) +POST /api/expenses/{id}/approve finance +POST /api/expenses/{id}/reject finance(body: reviewNotes) +POST /api/expenses/{id}/pay finance(body: checkNumber, paidAt?) +POST /api/expenses/{id}/receipt 上傳收據(multipart) +GET /api/expenses/{id}/receipt 串流下載 +``` +- `search` 比對 `Description` / `VendorName` / Member 姓名(中英)。 +- `POST` 依 `Type` 分流:`VendorPayment` 由 finance 建立、直接 `Paid`、需 `VendorName`;`StaffReimbursement` 任何登入者建立、`Draft`。 + +#### POST /api/expenses 請求形狀 +```jsonc +// 廠商付款(finance) +{ "type": "VendorPayment", "ministryId": 10, "categoryGroupId": 3, "subCategoryId": 8, + "amount": 320.00, "description": "每週愛宴外燴", "vendorName": "ABC Catering", + "checkNumber": "2051", "expenseDate": "2026-05-31", "notes": null } + +// 同工報銷草稿(任何登入者;伺服器自帶 SubmittedBy / MemberId) +{ "type": "StaffReimbursement", "ministryId": 4, "categoryGroupId": 2, "subCategoryId": 6, + "amount": 45.50, "description": "敬拜團電池", "expenseDate": "2026-05-28", "notes": null } +``` + +### 6.3 月結 `MonthlyStatementsController` — `/api/monthly-statements`(finance, super_admin) +``` +GET /api/monthly-statements?year=2026 列表 +GET /api/monthly-statements/{id} +POST /api/monthly-statements 建立(伺服器算 Giving/Expense 合計) +PUT /api/monthly-statements/{id} 更新手輸欄位 + 重算(未 finalize 才可) +POST /api/monthly-statements/{id}/finalize 鎖定 +``` +- `POST` body:`year`、`month`、`openingBalance`、`totalOtherIncome`、`bankStatementBalance`、`notes`。撞 `UNIQUE(Year,Month)` → 409。 +- 建立與每次 `PUT` 時,伺服器**重算** `TotalGiving`、`TotalExpenses`、`CalculatedClosingBalance`、`Difference`(不信任前端)。 +- finalize 後再 `PUT` → 409。 + +--- + +## 7. 後端 Service 層 + +3 個 service(interface + impl,沿用 `GivingService` 風格:注入 `AppDbContext`、必要時 `IHttpContextAccessor` 取 `CurrentUserId`、手動 DTO mapping)。 + +- **`IExpenseCategoryService`** — 大類 + 子項 CRUD;DELETE = `IsActive=false`;`GET` 回巢狀結構(大類含子項,依 `includeInactive` 過濾)。 +- **`IExpenseService`** — 核心: + - `CreatePaged/GetMine/GetById`:投影 Ministry/Category/SubCategory/Member 名稱(沿用 `GivingService` 的字典批次查名)。 + - `CreateAsync`:依 `Type` 設初始 `Status`(Vendor→Paid、Staff→Draft)與審計欄位。 + - `UpdateAsync`:Draft(本人)或 finance 可改;其餘 409。 + - 狀態動作 `SubmitAsync/ApproveAsync/RejectAsync/PayAsync`:各自驗起始狀態 + 角色 + 寫對應時間戳/操作人。 + - `DeleteAsync`:軟刪除(攔截器或手動設 `IsDeleted`)。 + - 收據 `SaveReceiptAsync/OpenReceiptAsync`:委派 `IFileStorage`,驗權。 +- **`IMonthlyStatementService`** — 建立/更新時重算合計;`FinalizeAsync` 設 `IsFinalized`;finalize 後寫入守門。 + - `TotalGiving = Σ Givings WHERE GivingDate 落在該月`。 + - `TotalExpenses = Σ Expenses WHERE ExpenseDate 落在該月 AND Status='Paid' AND !IsDeleted`。 + +--- + +## 8. 前端 + +依既有 feature 結構 `features/expense/{pages,components,services,models}`,standalone component + Kendo Grid/Inputs/Buttons/Dropdowns + RxJS + `ApiConfigService`。 + +### 8.1 類別管理頁 `expense-categories-page`(finance) +左大類 / 右子項的雙層管理;新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 `giving-categories-page` 模式。 + +### 8.2 支出總覽頁 `expenses-page`(finance) +Kendo Grid(搜尋 / ministry / 大類 / 狀態 / 日期區間 / 分頁)。工具列: +- 「+ 廠商付款」dialog:Ministry→大類→子項串聯下拉(選大類後子項自動篩選)、金額、廠商名、支票號、日期 → 建立即 Paid。 +- 「+ 代建報銷」dialog:同上 + 選同工(復用 `GET /api/members?search=`)。 +- 列動作(依狀態):approve / reject(填原因)/ pay(填支票號+日期)/ 看收據 / 軟刪除。 + +### 8.3 我的報銷頁 `my-reimbursements-page`(所有登入者) +列出自己的報銷(狀態 badge)。「+ 新報銷」dialog:Ministry→大類→子項、金額、說明、日期、上傳收據(file input)。Draft 可編輯/刪除/提交;提交後唯讀看狀態與審核備注。 + +### 8.4 月結對帳頁 `monthly-statement-page`(finance) +列表(年份篩選)+ 「+ 新月結」:選年月 → 伺服器回填 `TotalGiving`/`TotalExpenses` → 手輸 `OpeningBalance`/`TotalOtherIncome`/`BankStatementBalance` → 即時顯示 `CalculatedClosing` 與 `Difference`(目標 0 高亮)→ 儲存 / finalize 鎖定。 + +### 8.5 API service / model +每頁一支 `*-api.service.ts` + `models/expense.model.ts`(DTO interface,對齊後端 DTO 命名與 giving model 風格)。 + +--- + +## 9. 授權 & 導覽 + +- 後端:如 §5.2 / §6 各 controller `[Authorize]` 角色設定;自助端點 `[Authorize]`(僅需登入)+ service 內本人守門。 +- 前端導覽(`UserPortalComponent` 內嵌側欄,沿用既有 role-gated section 機制): + - **Finance 區塊**(finance/super_admin)新增:`Expenses`、`Expense Categories`、`Monthly Statement`。 + - **新增所有登入者可見項**:`My Reimbursements`(放 Main 或新「Finance/個人」分組,僅需登入)。 +- Routes 加入 `user-portal` children: + - `finance/expenses`、`finance/expense-categories`、`finance/monthly-statement` → `RoleGuard data.roles=['finance','super_admin']`。 + - `my-reimbursements` → 僅 `AuthGuard`。 +- `getPageTitle` / `updatePageTitle` 對應補上新頁標題。 + +--- + +## 10. 測試(`ROLAC.API.Tests`,InMemory provider) + +- **`ExpenseCategoryService`**:大類/子項 CRUD、DELETE 軟停用、有支出時類別不可實刪、巢狀查詢 + `includeInactive`。 +- **`ExpenseService`**(重點): + - Vendor 建立 → 直接 `Paid`。 + - Staff 建立 → `Draft`、`SubmittedBy` 正確帶入。 + - 狀態機合法路徑:submit→approve→pay;submit→reject。 + - 非法轉換被拒(如 Draft 直接 approve、已 Paid 再 submit)→ 409。 + - 自助守門:非本人改他人 Draft → 拒;改非 Draft → 拒。 + - 軟刪除後不出現在查詢、不計入月結。 + - 收據上傳/下載(以 fake/in-memory `IFileStorage` 驗)。 +- **`MonthlyStatementService`**: + - `TotalGiving`/`TotalExpenses` 伺服器重算正確(只算該月 + `Paid` + 未刪)。 + - `CalculatedClosingBalance` / `Difference` 計算正確。 + - `UNIQUE(Year,Month)` 撞 → 409。 + - finalize 後修改被拒。 +- **授權**:非 finance/super_admin 呼叫審核/廠商付款/月結 → 403;自助端點未登入 → 401。 + +--- + +## 11. 風險 / 待確認 + +| # | 項目 | 說明 | +|---|------|------| +| R1 | Ministry 列表端點 | 前端串聯下拉需 ministries 清單。實作時確認既有 `GET /api/ministries`(或等價)是否存在;若無則一併補一個唯讀端點。 | +| R2 | `AppUser.MemberId` 連結 | 同工自助 `MemberId` 取自登入帳號的 `MemberId`;若帳號未連結 Member,報銷仍可建立(`MemberId=null`),但個人對應較弱。需確認登入帳號普遍已連結 Member。 | +| R3 | 本機磁碟在多實例/部署 | `LocalDiskFileStorage` 僅適合單機 dev。正式部署換 Azure Blob 實作(介面已預留)。`App_Data/storage` 需排除版控。 | +| R4 | Personnel 類別可見性 | PLANNING §3.6d 提到事工領袖不可見人事類支出;本次支出總覽僅開放 finance/pastor/super_admin,故暫無洩漏面,但日後若開放事工領袖檢視需加類別過濾。 | +| R5 | 月結與後補支出 | 月結合計在建立/PUT 當下快照;若事後再補當月支出,需手動 PUT 重算。本次以「PUT 重算」滿足,不做自動同步。 | + +--- + +## 12. 交付順序建議 + +1. Entities + EF 設定 + Seed + migration(`AddExpenseModule`) +2. `IFileStorage` + `LocalDiskFileStorage` + DI/config +3. Service 層(3 個)+ 單元測試 +4. Controllers + 授權 + 收據上傳/下載 +5. 前端 models + api services +6. 類別管理頁 → 支出總覽頁 → 我的報銷頁 → 月結對帳頁 +7. 導覽整合 + 端到端手動驗證