# 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`（39 子項）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 大類 + 39 子項**：

```
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<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default);
    Task<Stream?> 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<IFileStorage, LocalDiskFileStorage>()`。
- `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. 導覽整合 + 端到端手動驗證
