# Expense Module Plan > **Status:** implemented 2026-10-07 (phases 1–5) using the §10 defaults. Deviations from this plan are listed in > §11 and in `Backend/PROGRESS.md` / `Frontend/PROGRESS.md`. Not yet verified against a live GL service. > **Lives under:** the existing **Accounts** nav item (`NavItemId = 12`, `/dashboard/accounts`). > **GL transport:** `docs/12-GENERAL-LEDGER-INTEGRATION.md` (typed calls via `IGeneralLedgerService`). > **Reference implementation to mirror:** `PurchaseInvoicePaymentService` (call GL before commit, so a > failed post rolls back the whole operation). --- ## 1. Goal Record day-to-day business expenses that don't come through procurement (electricity bill, rent, salaries paid outside payroll, stationery, …). Users can: - pick a **category** (Utility, Rental, Salaries, Other), - pick a **title** under it (Utility → Electricity, Water, …), - enter the **amount**, date, payee and bill reference, - **pay** it now or later (in one or more installments) from a GL cash/bank account, - see the **payment status** (Unpaid / Partially Paid / Paid). Every posted expense and every payment creates a real GL journal entry, so the P&L shows it. --- ## 2. Concepts ``` ExpenseCategory (Utility, Rental, Salaries, Other) ← seeded, admins can add more └── ExpenseTitle (Electricity, Water, …) ← seeded, admins can add more └── default GL expense account code Expense (one bill: category + title + amount + status) └── ExpensePayment (0..n installments, each posts a GL entry) ``` ### 2.1 Seeded categories and titles | Category | Seeded titles | |---|---| | **Utility** | Electricity, Water, Telephone, Internet, Gas | | **Rental** | Building Rent, Vehicle Rent, Equipment Rent | | **Salaries** | Staff Salaries, Wages, Overtime, Bonus, EPF / ETF Contribution | | **Other** | Stationery, Transport, Repairs & Maintenance, Cleaning, Miscellaneous | - Seeded rows have `IsSystem = true`. They **cannot be deleted or renamed**, only deactivated. - Users with the `accounts.expense-setup` permission can add **custom titles** under any category, and **custom categories**. - Every title has a **GL expense account code**. Seeded titles default to the codes in `appsettings.json` (§5). Users can override the code per title. --- ## 3. Data model (backend) All new entities go in `Domain/Entities/`, with configurations in `Infra/Persistence/Configurations/` and one EF migration. ### 3.1 `ExpenseCategory` | Field | Type | Notes | |---|---|---| | ExpenseCategoryId | int PK | | | Code | string(30), unique | `UTILITY`, `RENTAL`, `SALARIES`, `OTHER`, or user-defined | | Name | string(100), unique | | | IsSystem | bool | seeded = true | | IsActive | bool | | | SortOrder | int | | | CreatedBy / CreatedAt / UpdatedBy / UpdatedAt | | standard audit columns | ### 3.2 `ExpenseTitle` | Field | Type | Notes | |---|---|---| | ExpenseTitleId | int PK | | | ExpenseCategoryId | int FK | | | Name | string(100) | unique **within** the category | | GlExpenseAccountCode | string(20) | the account debited when an expense with this title is posted | | IsSystem | bool | | | IsActive | bool | | | SortOrder | int | | | audit columns | | | ### 3.3 `Expense` (header) | Field | Type | Notes | |---|---|---| | ExpenseId | int PK | | | DocNo | string | `EXP-{branch}-{yyyy}-{seq}` via `NumberSequenceService` (DocType `EXPENSE`) | | BranchId | int FK | branch-scoped like other documents (`BRANCH_ISOLATION_PLAN.md`) | | ExpenseDate | DateOnly | the bill date, which is also the GL entry date on posting | | DueDate | DateOnly? | optional, used for "overdue" in the list | | ExpenseCategoryId | int FK | | | ExpenseTitleId | int FK | must belong to the chosen category | | Payee | string(150)? | free text (e.g. "CEB", "Landlord – Mr. Silva") | | VendorId | int? FK | optional link to an existing vendor | | BillReference | string(50)? | the supplier's bill/account number | | Description | string(500)? | | | Amount | decimal(18,2) | > 0 | | PaidAmount | decimal(18,2) | maintained by payments | | BalanceAmount | decimal(18,2) | `Amount - PaidAmount` | | Status | enum `ExpenseStatus` | `Draft`, `Posted`, `Cancelled` | | PaymentStatus | enum `ExpensePaymentStatus` | `Unpaid`, `PartiallyPaid`, `Paid`, derived from Paid/Balance | | GlExpenseAccountCode | string(20) | **snapshot** of the title's code at posting time | | GlJournalNo | string? | the accrual journal entry | | PeriodMonth | string(7)? | optional "covers" period, e.g. `2026-09` for September's electricity | | RowVersion | uint | ETag concurrency, same as `PurchaseInvoice` | | audit columns | | | ### 3.4 `ExpensePayment` Mirrors `PurchaseInvoicePayment` field for field: | Field | Type | Notes | |---|---|---| | ExpensePaymentId | int PK | | | ExpenseId | int FK | | | Amount | decimal(18,2) | ≤ Expense.BalanceAmount | | PaymentDate | DateTime | same `ResolvePaymentDate` rule (no future dates, one day of slack) | | PaymentMethod | enum | `Cash`, `BankTransfer`, `Cheque`, `Card` | | GlBankAccountId | long | account from `IGeneralLedgerService.ListBankAccountsAsync` | | BankAccountName | string | snapshot | | ChequeNo | string? | required when method = Cheque | | Reference | string? | | | GlJournalNo | string? | | | CreatedBy / CreatedAt | | | ### 3.5 Enums (`Domain/Enums/`) `ExpenseStatus { Draft, Posted, Cancelled }` `ExpensePaymentStatus { Unpaid, PartiallyPaid, Paid }` `ExpensePaymentMethod { Cash, BankTransfer, Cheque, Card }` --- ## 4. Workflow and GL postings ``` Draft ──Post──► Posted (Unpaid) ──Pay──► Partially Paid ──Pay──► Paid │ │ └──Delete └──Cancel (only if PaidAmount = 0) ──► Cancelled ``` | Action | Rule | GL entry | |---|---|---| | **Create (Draft)** | Editable, no GL impact | — | | **Post** | Draft only. Snapshots the title's GL code. | **Dr** title's expense account / **Cr** Accrued Expenses (`Expense:GlAccruedExpensesCode`) | | **Pay** | Posted and Balance > 0, installments allowed | **Dr** Accrued Expenses / **Cr** selected cash/bank account | | **Post & Pay** (one click, the common "paid in cash right now" case) | Draft. Runs Post and a full Pay in **one transaction**. | Both entries above | | **Cancel** | Posted with no payments | Reversing entry: **Dr** Accrued / **Cr** expense account | | **Delete** | Draft only | — | All GL calls happen inside `_uow.ExecuteInTransactionAsync` **before** the local rows are saved, the same pattern as `PurchaseInvoicePaymentService`. If GL is down or rejects the entry, nothing is saved. `SourceModule = "EXPENSE"` / `"EXPENSE_PAYMENT"`, `Reference = DocNo`. **Why accrue then pay** (rather than Dr Expense / Cr Bank on payment): unpaid bills still show in the P&L for the month they belong to, and the Accrued Expenses balance equals the total unpaid bills. "Post & Pay" keeps the one-step case fast. --- ## 5. Configuration (`appsettings.json`) ```json "Expense": { "GlAccruedExpensesCode": "2300", "DefaultGlAccountCodes": { "UTILITY": "6100", "RENTAL": "6200", "SALARIES": "6300", "OTHER": "6900" } } ``` Seeded titles take their category's default code. The actual codes must match the GL service's chart of accounts. **Confirm them with the GL owner before seeding** (open question Q3). --- ## 6. API (`Controllers/ExpensesController.cs`, `ExpenseSetupController.cs`) Policy: `NavPolicies.Prefix + "accounts"`, same door as the cheque/bank screens. ### Setup (categories and titles) | Method | Route | Purpose | |---|---|---| | GET | `/api/v1/expense-categories?includeInactive=` | list categories, each with its titles | | POST | `/api/v1/expense-categories` | add a custom category | | PUT | `/api/v1/expense-categories/{id}` | rename (non-system only) / toggle active | | POST | `/api/v1/expense-categories/{id}/titles` | add a custom title | | PUT | `/api/v1/expense-titles/{id}` | rename (non-system only) / change GL code / toggle active | ### Expenses | Method | Route | Purpose | |---|---|---| | GET | `/api/v1/expenses` | paged list. Filters: branch, category, title, status, paymentStatus, date range, search (DocNo/payee/reference) | | GET | `/api/v1/expenses/{id}` | detail + payments (ETag header) | | POST | `/api/v1/expenses` | create draft | | PUT | `/api/v1/expenses/{id}` | update draft (`If-Match`) | | DELETE | `/api/v1/expenses/{id}` | delete draft | | POST | `/api/v1/expenses/{id}/post` | post (accrue) | | POST | `/api/v1/expenses/{id}/post-and-pay` | post + full payment, body = payment request | | POST | `/api/v1/expenses/{id}/cancel` | cancel (reason required) | | GET | `/api/v1/expenses/{id}/payments` | payment history | | POST | `/api/v1/expenses/{id}/payments` | add a payment / installment | | GET | `/api/v1/expenses/summary?from=&to=&branchId=` | totals by category → title, plus paid/unpaid totals (for the dashboard cards) | ### New error codes (`System/Errors/ErrorCodes`) `EXPENSE_NOT_DRAFT`, `EXPENSE_NOT_PAYABLE`, `EXPENSE_PAYMENT_EXCEEDS_BALANCE`, `EXPENSE_HAS_PAYMENTS`, `EXPENSE_TITLE_CATEGORY_MISMATCH`, `EXPENSE_TITLE_INACTIVE`, `EXPENSE_SYSTEM_ROW_READONLY`, `EXPENSE_BANK_ACCOUNT_NOT_FOUND`, `EXPENSE_CHEQUE_NO_REQUIRED`. --- ## 7. Backend file checklist | Layer | Files | |---|---| | Entities | `ExpenseCategory.cs`, `ExpenseTitle.cs`, `Expense.cs`, `ExpensePayment.cs` | | Enums | `ExpenseStatus.cs`, `ExpensePaymentStatus.cs`, `ExpensePaymentMethod.cs` | | EF config | `ExpenseConfiguration.cs` (all four tables, seed data, indexes, check constraint `Amount > 0`, **MariaDB-safe quoting**, see commit c97a7e9) | | DbContext | 4 `DbSet`s in `ErpDbContext.cs` | | DTOs | `Dtos/Expense/ExpenseDtos.cs`, `ExpenseSetupDtos.cs` + FluentValidation validators | | Services | `ExpenseSetupService`, `ExpenseService`, `ExpensePaymentService` (+ interfaces) | | Controllers | `ExpenseSetupController`, `ExpensesController` | | Nav / perms | `SubNavItemConfiguration`: `accounts.expenses` (id 23, sort 4) and `accounts.expense-setup` (id 24, sort 5); `PermissionConfiguration` entries | | Number seq | DocType `EXPENSE` | | Audit | `IAuditService` entries on create/post/pay/cancel | | Program.cs | DI registrations | | Migration | `AddExpenseModule` | --- ## 8. Frontend (`Frontend/erp-system/app/dashboard/accounts/`) | Route | Screen | |---|---| | `expenses/page.tsx` | List: summary cards (This month total · Unpaid · Overdue), filter bar (category, title, status, payment status, dates), table with **payment status badge** (Unpaid red / Partial amber / Paid green) | | `expenses/new/page.tsx` | Form: Branch · Date · **Category** dropdown → **Title** dropdown (filtered by category, with an inline "+ Add title" if permitted) · Payee/Vendor · Bill ref · Period · Amount · Description. Buttons: **Save Draft**, **Post**, **Post & Pay** (opens the payment dialog) | | `expenses/[id]/page.tsx` | Detail, GL journal no, payment history, **Add Payment** dialog (amount defaults to balance · date · method · cash/bank account from `bankAccountsApi` · cheque no · reference), Cancel | | `expense-setup/page.tsx` | Categories as tabs or accordion → titles table (name, GL code, active, system lock icon); add/edit dialogs | API client: `lib/api/expenses.ts`. Types: `types/expense.ts`. Reuse the payment dialog pattern from the purchase-invoice payment screen. --- ## 9. Implementation phases 1. **Setup data**: entities, config, seed, migration, setup API + setup screen. 2. **Expense CRUD**: draft create/edit/delete, list/detail screens, DocNo sequence. 3. **GL posting**: Post, Cancel, Post & Pay, config codes. 4. **Payments**: installments, payment status, cheque method. 5. **Reporting**: summary endpoint, list cards, export to CSV. 6. Docs: add `docs/16-BACKEND-EXPENSES.md` (or extend `00-CORE.md` nav), update `Backend/PROGRESS.md` / `Frontend/PROGRESS.md`. --- ## 10. Open questions | # | Question | Default if not answered | |---|---|---| | Q1 | One title per expense, or **multiple lines** per expense (e.g. one bill covering electricity + water)? | One title per expense. Lines can be added later without breaking the API. | | Q2 | Can users add **new categories**, or only new titles under the four fixed ones? | **Decided 2026-10-08: titles only.** The four categories are fixed; the create-category endpoint and UI were removed. | | Q3 | Real GL account codes for Accrued Expenses and each category? | Placeholders in §5 | | Q4 | **Salaries vs. HRM Payroll:** payroll runs don't post to GL today. If payroll GL posting is added later, the "Salaries" category could double-count. Should Salaries expenses be blocked or warned once payroll posts? | Allow for now, add a warning note on the Salaries category | | Q5 | Tax (VAT) on expenses: capture input tax separately? | Out of scope for phase 1, amount is gross | | Q6 | Attachments (scan of the bill)? | Out of scope for phase 1 | | Q7 | Approval step before posting (maker/checker)? | No. Post permission is enough. | | Q8 | Recurring expenses (monthly rent auto-draft)? | Phase 2 | --- ## 11. As built: deviations from this plan - **Nav/permission ids:** sub-items 44 (`accounts.expenses`) and 45 (`accounts.expense-setup`), permissions 54/55. Ids 23/24 in §7 were already taken. - **Config (§5):** only `Expense:GlAccruedExpensesCode` is in `appsettings.json`. The per-category default codes are seeded directly (`ExpenseConfiguration.cs`), because `HasData` cannot read configuration. Change them per title on the Expense Setup screen. - **GL accounts (2026-10-08):** GL generates account codes itself (its `POST /accounts` takes no code), so the planned 6100–6900 codes could never exist. The dev GL now has `2300` Accrued Expenses (liability) and a non-postable `5200` Operating Expenses control account with children `5200-01` Utilities, `5200-02` Rent, `5200-03` Salaries & Wages and `5200-04` Other Expenses. The seed and migration `UpdateExpenseGlCodes` point the seeded titles at those. Another GL instance will generate its own codes, so check them on Expense Setup. - **Status columns:** categories and titles use the codebase's `EntityStatus` (Active/Inactive) instead of an `IsActive` bool. - **Payments:** one submission can have several lines (cash + bank). It posts **one** GL journal, and each line becomes an `ExpensePayment` row that shares that journal number. `PaymentMethod`, `ChequeNo` and `Reference` apply to the whole submission. - **Cancel:** also allowed on a Draft (no GL entry). A Posted expense with no payments gets a reversing entry dated today (`GlReversalJournalNo`). The reason is stored in `CancelReason`. - **No FluentValidation:** request validation uses DataAnnotations plus service checks, matching the rest of the API. - **Audit:** no explicit `IAuditService` calls. The DbContext's ChangeTracker audit captures every insert and update.