22 KiB
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 勾選清單全部五項:
- 支出類別設定 —
ExpenseCategoryGroup(11 大類)+ExpenseSubCategory(39 子項)CRUD + 種子 - 廠商直接付款記錄 —
Expense(Type=VendorPayment),含支票號碼,建立即Paid - 同工代墊報銷申請 —
Expense(Type=StaffReimbursement),含收據照片上傳 + 同工自助提交 - 財務審核流程 — 狀態機
Draft → PendingApproval → Approved → Paid(或Rejected) - 月結對帳表 —
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 filtere => !e.IsDeleted(沿用既有 Member/PrayerRequest 慣例)。MonthlyStatement.HasIndex(Year, Month).IsUnique()。- 索引(對應 DB_SCHEMA §17):
idx_expenses_ministry→(MinistryId)idx_expenses_status→(Status)filterIsDeleted=falseidx_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)
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。若已有舊檔則覆蓋並刪舊。
- 驗 MIME:
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 請求形狀
// 廠商付款(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 鎖定
POSTbody: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/個人」分組,僅需登入)。
- Finance 區塊(finance/super_admin)新增:
- Routes 加入
user-portalchildren: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驗)。
- Vendor 建立 → 直接
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. 交付順序建議
- Entities + EF 設定 + Seed + migration(
AddExpenseModule) IFileStorage+LocalDiskFileStorage+ DI/config- Service 層(3 個)+ 單元測試
- Controllers + 授權 + 收據上傳/下載
- 前端 models + api services
- 類別管理頁 → 支出總覽頁 → 我的報銷頁 → 月結對帳頁
- 導覽整合 + 端到端手動驗證