Files
ERP-core/docs/EXPENSE_MODULE_PLAN.md
T

15 KiB
Raw Blame History

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)

"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

  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.