Files
ROLAC/docs/superpowers/specs/2026-05-29-expense-tracking-design.md

393 lines
22 KiB
Markdown
Raw Permalink 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: 支出追蹤 & 報銷 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 3schema §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.IdRestrict|
| 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.IdRestrict|
| CategoryGroupId | int NOT NULL | FK → ExpenseCategoryGroups.IdRestrict|
| SubCategoryId | int NOT NULL | FK → ExpenseSubCategories.IdRestrict|
| 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.IdStaffReimbursement 指向代墊同工(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 | 112 |
| 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_*` maxlengthdecimal 沿用全域 `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_adminpastor 唯讀)|
| 類別管理 | 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 financebody: reviewNotes
POST /api/expenses/{id}/pay financebody: 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 個 serviceinterface + impl,沿用 `GivingService` 風格:注入 `AppDbContext`、必要時 `IHttpContextAccessor``CurrentUserId`、手動 DTO mapping)。
- **`IExpenseCategoryService`** — 大類 + 子項 CRUDDELETE = `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)。「+ 新報銷」dialogMinistry→大類→子項、金額、說明、日期、上傳收據(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→paysubmit→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. 導覽整合 + 端到端手動驗證