15 KiB
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 viaIGeneralLedgerService). 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-setuppermission 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)
"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 DbSets 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
- Setup data: entities, config, seed, migration, setup API + setup screen.
- Expense CRUD: draft create/edit/delete, list/detail screens, DocNo sequence.
- GL posting: Post, Cancel, Post & Pay, config codes.
- Payments: installments, payment status, cheque method.
- Reporting: summary endpoint, list cards, export to CSV.
- Docs: add
docs/16-BACKEND-EXPENSES.md(or extend00-CORE.mdnav), updateBackend/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:GlAccruedExpensesCodeis inappsettings.json. The per-category default codes are seeded directly (ExpenseConfiguration.cs), becauseHasDatacannot read configuration. Change them per title on the Expense Setup screen. - GL accounts (2026-10-08): GL generates account codes itself (its
POST /accountstakes no code), so the planned 6100–6900 codes could never exist. The dev GL now has2300Accrued Expenses (liability) and a non-postable5200Operating Expenses control account with children5200-01Utilities,5200-02Rent,5200-03Salaries & Wages and5200-04Other Expenses. The seed and migrationUpdateExpenseGlCodespoint 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 anIsActivebool. - Payments: one submission can have several lines (cash + bank). It posts one GL journal, and each line becomes
an
ExpensePaymentrow that shares that journal number.PaymentMethod,ChequeNoandReferenceapply 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 inCancelReason. - No FluentValidation: request validation uses DataAnnotations plus service checks, matching the rest of the API.
- Audit: no explicit
IAuditServicecalls. The DbContext's ChangeTracker audit captures every insert and update.