Files
ROLAC/docs/superpowers/specs/2026-05-29-expense-tracking-design.md
T
Chris Chen fdd0d7c8e1 docs(expense): add expense tracking & reimbursement design spec
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 <noreply@anthropic.com>
2026-05-29 17:23:21 -07:00

22 KiB
Raw Blame History

Design Spec: 支出追蹤 & 報銷 Expense Tracking & Reimbursement

日期: 2026-05-29 作者: Chris Chen 狀態: 待核准 對應規劃: docs/PLANNING.md §3.6ddocs/DB_SCHEMA.md §8 前置模組: 奉獻追蹤(docs/superpowers/specs/2026-05-28-giving-donation-tracking-design.md,已合併)


1. 範疇

本 spec 規劃支出追蹤 & 報銷模組,涵蓋 PLANNING 勾選清單全部五項:

  1. 支出類別設定ExpenseCategoryGroup11 大類)+ ExpenseSubCategory~38 子項)CRUD + 種子
  2. 廠商直接付款記錄ExpenseType=VendorPayment),含支票號碼,建立即 Paid
  3. 同工代墊報銷申請ExpenseType=StaffReimbursement),含收據照片上傳 + 同工自助提交
  4. 財務審核流程 — 狀態機 Draft → PendingApproval → Approved → Paid(或 Rejected
  5. 月結對帳表MonthlyStatement(期初 + 奉獻 − 支出 = 帳面期末,對比銀行對帳單)

明確不在本次範圍:

  • 事工預算 MinistryBudgetPhase 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 ExpenseIsDeleted;與 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 = SetNullSubCategory → Group = Restrict

3.6 SeedDbSeeder.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

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}/receiptmultipart/form-dataIFormFile file):
    • 驗 MIMEimage/jpegimage/pngimage/webpapplication/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 == CurrentUserIdStatus=Draft,否則 403/409。
  • CurrentUserId 透過 IHttpContextAccessor(沿用既有 service 慣例)。
  • 同工自助建立時 MemberId = 當前使用者 AppUser.MemberId(若有連結);Type=StaffReimbursementStatus=DraftSubmittedBy=自己

6. API 介面

全部依角色授權。Controller thin → 委派 service,沿用 PagedResult<> 與錯誤轉換(KeyNotFoundException→404,狀態/鎖定衝突→409,跨人存取→403)。

6.1 類別 ExpenseCategoriesController/api/expense-categoriesfinance, 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 姓名(中英)。
  • POSTType 分流:VendorPayment 由 finance 建立、直接 Paid、需 VendorNameStaffReimbursement 任何登入者建立、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-statementsfinance, 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 bodyyearmonthopeningBalancetotalOtherIncomebankStatementBalancenotes。撞 UNIQUE(Year,Month) → 409。
  • 建立與每次 PUT 時,伺服器重算 TotalGivingTotalExpensesCalculatedClosingBalanceDifference(不信任前端)。
  • finalize 後再 PUT → 409。

7. 後端 Service 層

3 個 serviceinterface + impl,沿用 GivingService 風格:注入 AppDbContext、必要時 IHttpContextAccessorCurrentUserId、手動 DTO mapping)。

  • IExpenseCategoryService — 大類 + 子項 CRUDDELETE = IsActive=falseGET 回巢狀結構(大類含子項,依 includeInactive 過濾)。
  • IExpenseService — 核心:
    • CreatePaged/GetMine/GetById:投影 Ministry/Category/SubCategory/Member 名稱(沿用 GivingService 的字典批次查名)。
    • CreateAsync:依 Type 設初始 StatusVendor→Paid、Staff→Draft)與審計欄位。
    • UpdateAsyncDraft(本人)或 finance 可改;其餘 409。
    • 狀態動作 SubmitAsync/ApproveAsync/RejectAsync/PayAsync:各自驗起始狀態 + 角色 + 寫對應時間戳/操作人。
    • DeleteAsync:軟刪除(攔截器或手動設 IsDeleted)。
    • 收據 SaveReceiptAsync/OpenReceiptAsync:委派 IFileStorage,驗權。
  • IMonthlyStatementService — 建立/更新時重算合計;FinalizeAsyncIsFinalizedfinalize 後寫入守門。
    • 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-pagefinance

左大類 / 右子項的雙層管理;新增/編輯 dialog(雙欄 EN/中名稱、排序、啟用)。沿用 giving-categories-page 模式。

8.2 支出總覽頁 expenses-pagefinance

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-pagefinance

列表(年份篩選)+ 「+ 新月結」:選年月 → 伺服器回填 TotalGiving/TotalExpenses → 手輸 OpeningBalance/TotalOtherIncome/BankStatementBalance → 即時顯示 CalculatedClosingDifference(目標 0 高亮)→ 儲存 / finalize 鎖定。

8.5 API service / model

每頁一支 *-api.service.ts + models/expense.model.tsDTO interface,對齊後端 DTO 命名與 giving model 風格)。


9. 授權 & 導覽

  • 後端:如 §5.2 / §6 各 controller [Authorize] 角色設定;自助端點 [Authorize](僅需登入)+ service 內本人守門。
  • 前端導覽(UserPortalComponent 內嵌側欄,沿用既有 role-gated section 機制):
    • Finance 區塊finance/super_admin)新增:ExpensesExpense CategoriesMonthly Statement
    • 新增所有登入者可見項My Reimbursements(放 Main 或新「Finance/個人」分組,僅需登入)。
  • Routes 加入 user-portal children
    • finance/expensesfinance/expense-categoriesfinance/monthly-statementRoleGuard data.roles=['finance','super_admin']
    • my-reimbursements → 僅 AuthGuard
  • getPageTitle / updatePageTitle 對應補上新頁標題。

10. 測試(ROLAC.API.TestsInMemory provider

  • ExpenseCategoryService:大類/子項 CRUD、DELETE 軟停用、有支出時類別不可實刪、巢狀查詢 + includeInactive
  • ExpenseService(重點):
    • Vendor 建立 → 直接 Paid
    • Staff 建立 → DraftSubmittedBy 正確帶入。
    • 狀態機合法路徑: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 + migrationAddExpenseModule
  2. IFileStorage + LocalDiskFileStorage + DI/config
  3. Service 層(3 個)+ 單元測試
  4. Controllers + 授權 + 收據上傳/下載
  5. 前端 models + api services
  6. 類別管理頁 → 支出總覽頁 → 我的報銷頁 → 月結對帳頁
  7. 導覽整合 + 端到端手動驗證