price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
+316
View File
@@ -0,0 +1,316 @@
# Bank Deposit Module Plan
> **Status:** implemented 2026-10-08 with the §12 defaults. Deviations: one `NAV:accounts.bank-deposits` permission
> with `Create`/`Delete` action suffixes instead of three codes (§6); the nav item sorts last under Accounts; the
> day-wise PDF gets a separate cash table rather than two more columns (§9). Details in `Backend/PROGRESS.md` and
> `Frontend/PROGRESS.md`.
> **Lives under:** the existing **Accounts** nav item (`NavItemId = 12`, `/dashboard/accounts`), next to
> Cash / Bank Accounts, Cheque Books, Received Cheques and Expenses.
> **GL transport:** `docs/12-GENERAL-LEDGER-INTEGRATION.md` (typed calls via `IGeneralLedgerService`).
> **Reference implementation to mirror:** `ExpensePaymentService`. It calls GL before commit, so a failed post
> rolls back the whole operation.
> **Consumers:** the Day-End Sales Report (`docs/DAY_END_SALES_REPORT_PLAN.md`) reads deposit figures from this
> module (§9). The report itself does not own any deposit logic.
---
## 1. Goal
At the end of a business day, the cash collected in the till is taken to the bank. Users can:
- see the **cash in hand** for a branch and day: what came in as cash, what went out as cash, what was already deposited, and what is left,
- record a **deposit** of that cash into a chosen **bank account**,
- **cancel** a wrong deposit, which reverses its GL entry,
- list deposits by date, branch and bank account.
**Only cash is deposited.** Card, bank-transfer and cheque receipts never sit in the till, so this module ignores them.
Cheques have their own Received Cheques flow.
Every deposit posts a real GL journal entry that moves the money from the cash account to the bank account.
---
## 2. Concepts
```
Cash in (per branch, per local day)
├── Sales slips / bundle sales → CashAmount
└── Sales invoice payments → PaymentMethod = Cash
Cash out
└── Expense payments → PaymentMethod = Cash
│
▼
Cash in hand (running balance, carried day to day)
│
▼
BankDeposit (Cash account ──► Bank account, posts GL, reduces cash in hand)
```
For branch *B* and local day *D*:
```
NetCash(D) = CashIn(D) − CashOut(D)
Deposited(D) = Σ Posted BankDeposit.Amount where BusinessDate = D
Opening(D) = Σ NetCash(d) − Σ Deposited(d) for CashTrackingStartDate ≤ d < D
Closing(D) = Opening(D) + NetCash(D) − Deposited(D) ← "remaining cash in hand"
```
`NetCash(D)` is the same figure the Day-End Sales Report already shows. This module adds the carry-forward and the deposits on top of it.
---
## 3. Current state
| Area | What exists | Gap |
|---|---|---|
| Cash/bank accounts | Kept in GL. `ListBankAccountsAsync` returns `GlBankAccount` (`AccountType` = `Cash`/`Bank`, `AccountName`, `BankName`, `AccountNumber`, `GlAccountCode`). The UI is at `/dashboard/accounts/bank-accounts`. | — |
| Cash received | Slips and bundles have `CashAmount`. `SalesInvoicePayment.PaymentMethod` exists. | — |
| Cash paid | `ExpensePayment.PaymentMethod = Cash` | — |
| Net cash | Computed inline in `DayEndReportService.LoadAsync` | Not reusable. Not carried forward between days. |
| Deposits | — | No entity, service, API or screen. |
---
## 4. Data model (backend)
### 4.1 `Domain/Entities/BankDeposit.cs`
| Field | Type | Notes |
|---|---|---|
| `BankDepositId` | int | PK |
| `DocNo` | string | `DocumentTypes.BankDeposit` (prefix `BD`) |
| `BranchId` / `Branch` | int | The branch whose till the cash came from |
| `BusinessDate` | DateOnly | The day whose cash is deposited. Cash in hand and the day-end report group by this date. |
| `DepositDate` | DateOnly | The day the bank received the money. Used as the GL entry date. Defaults to `BusinessDate`. |
| `Amount` | decimal(18,2) | > 0 |
| `FromGlCashAccountId` / `FromCashAccountCode` / `FromCashAccountName` | long / string | A GL account with `AccountType = Cash`. Snapshots, because GL accounts have no local FK. |
| `ToGlBankAccountId` / `ToBankAccountCode` / `ToBankAccountName` / `ToBankName` / `ToAccountNumber` | long / string | A GL account with `AccountType = Bank`. Snapshots. |
| `DepositSlipNo` | string? | The reference on the bank's deposit slip |
| `DepositedBy` | string? | The person who took the cash to the bank |
| `Remarks` | string? | |
| `Status` | `BankDepositStatus` | `Posted`, `Cancelled` (new enum in `Domain/Enums`) |
| `GlJournalNo`, `GlPostedAt` | | The posting entry |
| `GlReversalJournalNo`, `CancelReason`, `CancelledBy`, `CancelledAt` | | Cancellation |
| `CreatedBy`, `CreatedAt`, `RowVersion` | | Same as `Expense` |
There is no Draft state. A deposit is entered after the cash has actually been handed over, so saving it
posts it. A mistake is fixed by cancelling and re-entering.
### 4.2 Configuration & migration
- `Infra/Persistence/Configurations/BankDepositConfiguration.cs`:
- Index `(BranchId, BusinessDate, Status)`.
- Unique `DocNo`.
- `CHECK (Amount > 0)`, quoted the MariaDB-safe way (see commit `c97a7e9`).
- Add `DbSet<BankDeposit>` to `ErpDbContext`.
- Migration `AddBankDepositModule`:
- Creates the table.
- Seeds the `BD` document-number row.
- Seeds the permissions and nav item (§6).
- Grants them to the roles that already reach Accounts (same pattern as `GrantExpensePermissions`).
---
## 5. Settings (`appsettings.json`)
```json
"BankDeposit": {
"DefaultCashAccountCode": "1000-01",
"CashTrackingStartDate": "2026-10-01"
}
```
- **`DefaultCashAccountCode`** preselects the "from" account on the form. It is the same cash account that `SalesDayEnd` debits.
- **`CashTrackingStartDate`** is the day the running cash-in-hand balance starts. Earlier sales had no reliable cash/card split.
- The local day comes from the existing `DayEndReport:TimeZone` setting, so both features agree on what "today" means.
---
## 6. Permissions & navigation
| Code | Purpose |
|---|---|
| `accounts.bank-deposits` | View the list, the details and the cash in hand. Also the nav entry. |
| `accounts.bank-deposits.create` | Record a deposit |
| `accounts.bank-deposits.cancel` | Cancel a deposit |
- Add the nav entry to `SubNavItemConfiguration` under Accounts (`NavItemId = 12`), href `/dashboard/accounts/bank-deposits`, placed after Cash / Bank Accounts.
- Add the matching permission rows to `PermissionConfiguration`.
---
## 7. Backend services & API
### 7.1 `CashInHandService` / `ICashInHandService` (owned by this module)
This service is the single source for cash in hand. Both the deposit form and the Day-End Sales Report call it.
```csharp
Task<CashPositionDto> GetPositionAsync(DateOnly day, int? branchId, CancellationToken ct);
Task<IReadOnlyList<CashPositionDto>> GetRangeAsync(DateOnly from, DateOnly to, int? branchId, CancellationToken ct);
// CashPositionDto(Date, Opening, CashIn, CashOut, NetCash, Deposited, Closing)
```
- **Moving the logic:** move the cash queries out of `DayEndReportService.LoadAsync` into this service: slip/bundle `CashAmount`, cash invoice payments, and cash expense payments.
- **Opening balance:** `Opening(from)` is computed with a few `SumAsync` queries over `[CashTrackingStartDate, from)`. Those queries cover cash in, cash out and posted deposits. Days inside the range are then rolled forward in memory.
- **Shared date helpers:** move the local-day to UTC-window helpers (`ToUtc`, `LocalDay`, `ResolveZone`) into a small shared `BusinessDayClock` so the two services cannot drift apart.
- **Branch scope:** branch-scoped users always get their own branch (`ICurrentBranchContext`).
### 7.2 `BankDepositService` / `IBankDepositService`
**`CreateAsync(CreateBankDepositRequest)`**
Request: `BranchId, BusinessDate, DepositDate?, Amount, FromGlCashAccountId, ToGlBankAccountId, DepositSlipNo?, DepositedBy?, Remarks?`
1. Resolve the effective branch.
2. Check the accounts with `_gl.ListBankAccountsAsync`:
- The "from" account must be `Cash`. Otherwise fail with `BankDepositCashAccountInvalid`.
- The "to" account must be `Bank`. Otherwise fail with `BankDepositBankAccountInvalid`.
3. Check the dates:
- `CashTrackingStartDate ≤ BusinessDate ≤ today`.
- `BusinessDate ≤ DepositDate ≤ tomorrow`.
- Otherwise fail with `BankDepositDateInvalid`.
4. Check the balance:
- `Amount ≤ Closing(BusinessDate)`. Otherwise fail with `BankDepositExceedsCashInHand` (400), and include the available amount in the message.
- A back-dated deposit must also not push any later day's `Closing` below zero.
5. In `_uow.ExecuteInTransactionAsync`, re-check the balance inside the transaction (two cashiers may deposit at the same time), then post to GL before saving:
```
SourceModule = "BANK_DEPOSIT", Reference = DocNo, EntryDate = DepositDate
Dr ToBankAccountCode Amount "Cash deposit {DocNo} — {BusinessDate:yyyy-MM-dd}"
Cr FromCashAccountCode Amount
```
**`CancelAsync(id, reason)`:** the deposit must be Posted. Post the reversing journal (Dr cash / Cr bank) and set `Cancelled`.
Cancelling only puts cash back in hand, so it needs no balance check.
**Read methods**
- **`ListAsync(filter)`:** filter by date range on `BusinessDate`, branch, bank account and status. Paged, with the filtered total.
- **`GetAsync(id)`**
### 7.3 `Controllers/BankDepositsController.cs`, route `api/v1/bank-deposits`
| Method | Route | Permission |
|---|---|---|
| GET | `/` | `accounts.bank-deposits` |
| GET | `/{id}` | `accounts.bank-deposits` |
| GET | `/cash-in-hand?date=&branchId=` | `accounts.bank-deposits` |
| GET | `/cash-in-hand/daily?from=&to=&branchId=` | `accounts.bank-deposits` (≤ 92 days) |
| POST | `/` | `accounts.bank-deposits.create` |
| POST | `/{id}/cancel` | `accounts.bank-deposits.cancel` |
### 7.4 Wiring
- Add `DocumentTypes.BankDeposit = "BD"`.
- Add to `ErrorCodes`:
- `BankDepositCashAccountInvalid`
- `BankDepositBankAccountInvalid`
- `BankDepositExceedsCashInHand`
- `BankDepositDateInvalid`
- `BankDepositNotCancellable`
- Add DTOs in `Dtos/Accounts/BankDepositDtos.cs`.
- Register `ICashInHandService` and `IBankDepositService` in `Program.cs`.
---
## 8. Frontend (`/dashboard/accounts/bank-deposits`)
- **API and types:** `lib/api/bank-deposits.ts`, `types/bank-deposits.ts`.
- **Accounts hub:** add a **Bank Deposits** card to `app/dashboard/accounts/page.tsx`, and add the entry to `AppSidebar.tsx`, gated by `accounts.bank-deposits`.
**List page**
- Filters: date range, branch, bank account, status.
- Table columns: Doc No · Business Date · Deposit Date · Branch · Bank Account (name, bank, masked number) · Slip No · Amount · Status.
- Footer total.
- **New Deposit** button.
**New page (`/new`)**
- **Branch** and **business date**. Changing either refetches `/cash-in-hand`.
- **Cash position panel:** Opening · Cash in · Cash out · Already deposited · **Available to deposit**.
- **From cash account:** `Cash` accounts only. Defaults to `DefaultCashAccountCode`.
- **To bank account:** `Bank` accounts only, shown as name, bank and account number.
- **Amount:** defaults to the full available amount. Values above it are blocked on the client.
- Deposit date, deposit slip no, deposited by, remarks.
**Detail page (`/[id]`)**
- All fields plus the GL journal number.
- **Cancel** button (gated by `.cancel`) that asks for a reason.
**Cash in hand view (optional tab on the list page)**
- A daily table: Date · Opening · Cash in · Cash out · Deposited · Closing.
- Uses `/cash-in-hand/daily`.
---
## 9. Integration with the Day-End Sales Report
The report only consumes this module's figures and owns no deposit logic.
**Backend:** `DayEndReportService` calls `ICashInHandService.GetRangeAsync` instead of computing `NetCash` inline.
- `DayEndReportDayDto` gets three new fields: `OpeningCashInHand`, `CashDeposited` and `ClosingCashInHand`. `NetCash` stays.
- In the totals row, `Opening` comes from the first day and `Closing` from the last day. `CashDeposited` is summed.
- `DayEndReportDto` (single day) gets `Deposits[]`: `BankDepositId, DocNo, BankAccountName, BankName, AccountNumber, DepositSlipNo, DepositDate, Amount`.
**Page (`/dashboard/sales/day-end-report`)**
- Replace the one-line "Net cash in hand" text with a **Cash** card group:
- Opening cash in hand
- Net cash today
- Deposited to bank
- **Remaining cash in hand** (amber when greater than 0)
- Add a **Bank deposits** table (bank account · slip no · amount), linking to each deposit's detail page.
- Daily breakdown table: add **Deposited** and **Cash in hand** columns.
**PDF (`lib/pdf/day-end-report-pdf.ts`)**
- **Single day:** add the cash block and the deposits list.
- **Day-wise:** add the same two columns.
---
## 10. Phases
1. **Cash in hand:** add `CashInHandService` and `BusinessDayClock`, then refactor `DayEndReportService` onto them. There is no behaviour change, so the existing `DayEndReportServiceTests` must stay green.
2. **Deposits backend:** entity, migration, `BankDepositService`, controller, permissions and nav.
3. **Deposits frontend:** list, new and detail pages, plus the Accounts hub card.
4. **Day-end report integration:** §9.
5. **Verification and docs:** §11, then update `Backend/PROGRESS.md` and `Frontend/PROGRESS.md`.
---
## 11. Verification
**Backend tests (`CashInHandServiceTests`, `BankDepositServiceTests`):**
- Cash slip + card slip + cash invoice payment + card invoice payment + cash expense payment → only the cash items are counted.
- The opening balance carries across days. A deposit on day 1 lowers day 2's opening.
- A deposit above the closing balance is rejected.
- A back-dated deposit that would make a later day negative is rejected.
- Cancelling restores the cash in hand. Cancelled deposits are ignored everywhere.
- A Bank account as "from", or a Cash account as "to", is rejected.
- A branch user cannot see or deposit another branch's cash.
- A late-evening sale at the timezone boundary lands on the right business day.
- A GL failure leaves no deposit row behind.
**Manual checks:**
- The deposit form's available amount equals the day-end report's remaining cash for the same day and branch.
- In GL, the cash account goes down and the bank account goes up by the deposit amount.
---
## 12. Open decisions
| # | Question | Recommended default |
|---|---|---|
| 1 | **Which day a deposit counts against.** Monday's cash is deposited on Tuesday morning: should it reduce Monday's or Tuesday's cash in hand? | Monday's (`BusinessDate`). `DepositDate` is kept for GL and bank reconciliation. |
| 2 | **Over-deposit.** Block deposits above cash in hand, or only warn? | Block. Till surplus/shortage should be a separate "cash over/short" entry later. |
| 3 | **Starting point / float.** From when is cash counted, and does each till keep a fixed float? | `CashTrackingStartDate`. Add an optional per-branch opening float later if tills keep one. |
| 4 | **Source of truth.** Cash in hand is derived from sales and expense documents (per branch), not from the GL cash account balance. GL has no branch dimension. | Use the document-derived figure. The GL balance can be shown later as a cross-check. |
| 5 | **Several deposits per day.** Allow mid-day pickups? | Yes, any number, as long as the total stays within cash in hand. |
+191
View File
@@ -0,0 +1,191 @@
# Day-End Sales Report — Implementation Plan
A per-day view of sales, collections (cash vs card), expenses and resulting income, with a
day-wise income chart and a PDF export.
> **Naming:** `SalesDayEnd` already exists — it is the cashier close-out (shown in the sidebar
> as "Approved Invoices", permission code `sales.day-end`). This module is a separate,
> read-only report: **Day-End Sales Report**, code `sales.day-end-report`.
---
## 1. Requirements
For a selected day (and branch):
| Group | Figures |
|---|---|
| Sales | Total sales, Paid amount, Due amount |
| Payment split | Paid by cash, Paid by card |
| Counts | Sales count, Sales count by cash, Sales count by card |
| Expenses | Expenses of the day (total + list) |
| Result | Income of the day |
Plus:
- Day-wise income chart over a date range.
- Day-wise income exported as a PDF.
---
## 2. Current state
| Area | What exists | Gap |
|---|---|---|
| Sales documents | `SalesSlip`, `BundleSale`, `SalesInvoice` — slips and invoices carry `GrandTotal` / `PaidAmount` / `BalanceAmount` | — |
| Payment method | `SalesSlip` has only `PaidAmount`; `SalesInvoicePayment` stores only the GL bank/cash account | **No cash/card information is recorded on any sale** |
| Expenses | `Expense` (`ExpenseDate`, `Amount`, `Status`) and `ExpensePayment` (`PaymentMethod`) | — |
| Charts | `chart.js` + `react-chartjs-2` (`components/ui/line-chart.tsx`, `bar-chart.tsx`, `pie-chart.tsx`) | — |
| PDF | `jspdf` (used by `components/invoice-template/InvoicePrintKit.tsx`), `lib/simple-pdf.ts` | No PDF library on the backend → generate in the browser |
| Dates | Slip/invoice dates are stored in UTC; existing day-end groups by UTC date | Late-evening local sales fall on the wrong day |
---
## 3. Phase 1 — Capture payment method on sales (prerequisite)
1. Add enum `Domain/Enums/SalesPaymentMethod.cs`: `Cash`, `Card`, `BankTransfer`, `Cheque`
(mirrors `ExpensePaymentMethod`).
2. **`SalesSlip` and `BundleSale`:** add `CashAmount` and `CardAmount`
(invariant: `CashAmount + CardAmount = PaidAmount`). The sale's method is derived:
- **Cash** — `CardAmount = 0`
- **Card** — `CashAmount = 0`
- **Mixed** — both > 0
3. **`SalesInvoicePayment`:** add `PaymentMethod` (also on the pay request DTO).
4. **Services:** validate the split in `SalesSlipService`, `BundleSaleService`,
`SalesInvoicePaymentService`.
5. **UI:**
- POS slip form and bundle sale form: Cash / Card amount inputs.
- Invoice payment dialog: method dropdown.
6. **Migration:**
- Add the columns.
- Backfill slips and bundles with `CashAmount = PaidAmount`, `CardAmount = 0` (the code
comments note that every sale was treated as cash before partial payments).
- Backfill invoice payments as `Cash`.
---
## 4. Phase 2 — Backend report
**New files**
- `Dtos/Reports/DayEndReportDtos.cs`
- `Services/DayEndReportService.cs`, `Services/Interfaces/IDayEndReportService.cs`
- `Controllers/DayEndReportController.cs`
### 4.1 `GET /day-end-report?date=YYYY-MM-DD&branchId=`
Returns one day:
| Section | Fields |
|---|---|
| Sales | `TotalSales`, `PaidAmount`, `DueAmount` |
| Payment split | `PaidByCash`, `PaidByCard`, `PaidByOther` |
| Counts | `SalesCount`, `CashSalesCount`, `CardSalesCount`, `MixedSalesCount` |
| Returns | `SalesReturns` |
| Expenses | `ExpenseTotal`, `Expenses[]` (doc no, category, title, payee, amount) |
| Income | `Income = TotalSales − SalesReturns − ExpenseTotal` |
| Optional | `NetCash = cash collected − expense payments made in cash` (till check) |
Sources:
- **Sales:** Posted slips, bundles and invoices dated that day. Use `GroupBy` with `Sum`/`Count`
in the database, not in memory.
- **Returns:** `SalesReturn` dated that day.
- **Expenses:** Posted `Expense` rows with `ExpenseDate` = that day. Cancelled ones are excluded.
### 4.2 `GET /day-end-report/daily?from=&to=&branchId=`
One row per day: `Date, TotalSales, Paid, Due, Cash, Card, Expenses, Income`, plus a totals row.
- Days with no activity are filled in as zeros.
- The range is capped at about 92 days.
- This endpoint feeds both the chart and the PDF.
### 4.3 Cross-cutting
- **Branch scope:** invoices use `BranchId`. Slips and bundles are filtered through their
warehouse. Reuse `BranchAccessService`.
- **Business timezone:** add a setting in `appsettings.json` (next to `SalesDayEnd`). Convert
the local day to a UTC `[start, end)` range before querying.
- **Permissions and nav:**
- Seed a `sales.day-end-report` view permission and grant it through a migration (same
pattern as `GrantExpensePermissions`).
- Add a nav entry in `SubNavItemConfiguration`.
- **Wiring:**
- Register the service in `Program.cs`.
- Add new error codes (e.g. invalid range) to `System/Errors/ErrorCodes.cs`.
---
## 5. Phase 3 — Frontend page `/dashboard/sales/day-end-report`
- **API and types:** `lib/api/day-end-report.ts`, with types added to `types/sales.ts`.
- **Toolbar:** date picker (defaults to today), branch selector, **Download PDF** button.
- **Summary cards:**
- Total Sales · Paid · Due
- Paid by Cash · Paid by Card
- Sales count · Cash count · Card count
- Expenses
- **Income**, emphasised, green or red by sign
- **Expenses table** for the selected day.
- **Day-wise income chart:**
- From/to picker, defaulting to the last 30 days.
- Income line (`line-chart.tsx`), with Sales and Expenses alongside it.
- A daily breakdown table below the chart.
- **Sidebar:** add the entry to `components/Layouts/AppSidebar.tsx`, gated by the
`sales.day-end-report` permission.
---
## 6. Phase 4 — PDF export (day-wise income)
- **Where:** new `lib/pdf/day-end-report-pdf.ts`, using `jspdf` and `jspdf-autotable` (new,
small dependency).
- **Page 1:**
- Company and branch header, date range, when it was generated.
- Totals for the period.
- The chart image (`chart.toBase64Image()` from the Chart.js instance).
- **Following pages:** daily table (Date · Sales · Paid · Due · Cash · Card · Expenses · Income)
with a totals row.
- **Single-day variant:** the same module also exports one day (summary cards + expense list).
---
## 7. Phase 5 — Verification
- **Backend tests** for the aggregates:
- A mix of slips, bundles and invoices.
- Partial payments.
- Split cash/card payments.
- A cancelled expense, which must be excluded.
- A late-evening sale at the timezone boundary.
- **Frontend checks:**
- Card totals match the daily table.
- Days with no activity show as zero.
- The PDF paginates correctly over long ranges.
- Update `Backend/PROGRESS.md` and `Frontend/PROGRESS.md`.
---
## 8. Open decisions
| # | Question | Recommended default |
|---|---|---|
| 1 | **Income basis.** Accrual (sales − returns − expenses dated that day) or cash basis (money collected that day − expenses paid that day, including installments on older invoices)? | Accrual. `NetCash` is shown as a secondary figure. |
| 2 | **Split tender.** Can one sale be paid partly by cash and partly by card? | Yes (`CashAmount`/`CardAmount`). If not, use a single `PaymentMethod` and drop the Mixed count. |
| 3 | **Scope.** Include all of slips, bundles and invoices, or only POS (slips and bundles)? | All three. |
Phase 1 must ship first. Without it there is no reliable cash/card data to report on.
**Implemented 2026-10-08 with the recommended defaults.** Changes from the plan made during the build
(details in `Backend/PROGRESS.md` and `Frontend/PROGRESS.md`):
- Sales returns are **not** subtracted from income: `SalesReturnLine` has no price, so a return can't be valued.
- The UI only creates invoices, so the cash/card split comes from a **payment method on each invoice payment**
(it defaults from the account: a Cash account means Cash, a Bank account means Card). Slip/bundle `CashAmount`/`CardAmount` exist for API callers.
- Sale counts also include **Other** (bank transfer/cheque) and **Unpaid**, so the counts add up to the total.
- Rows are grouped by local day in memory over slim database projections, not with SQL `GroupBy`.
- PDF tables are drawn with plain `jspdf`. `jspdf-autotable` was not added.
**Bank deposits (2026-10-08):** the report's cash figures (net cash, opening/closing cash in hand, deposits) now come
from the Bank Deposit module's `ICashInHandService` — see `docs/BANK_DEPOSIT_MODULE_PLAN.md` §9.
+297
View File
@@ -0,0 +1,297 @@
# Dual Approval Plan — Stock Adjustments & Price Adjustments
> **Status:** implemented 2026-10-12 with the §8 recommended defaults (counts go through approval; initial prices on
> a new item need none; no Admin bypass; thresholds configurable, default "always two"; selling prices only; no future
> effective dates; no auto-expiry). Deviations: the approval doc type for price adjustments is `PADJ` (not `PRICE`);
> count-variance serial moves are recorded against the adjustment rather than the count; the Approvals inbox is open
> to every signed-in user (it lists only what they may approve and their own requests). Details in
> `Backend/PROGRESS.md` and `Frontend/PROGRESS.md`.
> **Goal:** a stock adjustment or a selling-price change only takes effect after **two different people**
> (neither of them the person who requested it) have approved it.
> **Reference to mirror:** `PurchaseOrderService` — `PendingApproval` → `Approved`/`Rejected`, the
> `Approve` action permission, and the "you cannot approve your own document" rule. This plan extends that
> one-step approval to two steps and puts it in one shared place so both documents use the same rules.
---
## 1. Requirements
| # | Requirement |
|---|---|
| 1 | A stock adjustment (including wastage) changes stock and posts to GL **only after the second approval**. |
| 2 | A selling-price change (min / max / flat price) is applied to the item **only after the second approval**. |
| 3 | Maker–checker: the requester can't approve. Approver 1 and approver 2 must be **different people**. |
| 4 | Either approver can **reject** with a reason. A rejected request never takes effect. |
| 5 | Everyone involved is notified: approvers when something is waiting for them, the requester when it is approved or rejected. |
| 6 | A full audit trail: who requested, who approved at each level, when, and any comments. |
| 7 | An **Approvals** inbox listing everything waiting for the current user. |
---
## 2. Current state
| Area | What exists | Gap |
|---|---|---|
| Stock adjustments | `AdjustmentService.CreateAsync` **posts immediately**: it moves stock, posts GL and writes the ledger in one transaction. `AdjustmentStatus` already has `Draft`, `PendingApproval` and `Posted`, but only `Posted` is ever used. | No approval step at all. |
| Wastage | `/stock/wastage` records adjustments with wastage reason codes through the same path. | Same: posts immediately. |
| Stock counts | `CountService.PostAsync` creates a variance `StockAdjustment` and posts it immediately. | A count is effectively an adjustment, so it bypasses any approval added to adjustments (see §8, decision 3). |
| Selling prices | `Item.SellingMinPrice / SellingMaxPrice / SellingFlatPrice` are edited directly in the item Edit dialog and saved instantly. `SalesPricingService` reads them. | No price-change document, no history, no approval. |
| Approval pattern | Purchase orders: one approver, Admin only, not your own PO. `RolePermissionAction.Approve` exists and `NavPolicies.ApproveAction` is used on PO endpoints. | Only one level, and it's built into the PO service. |
| Notifications | `INotificationService.RaiseAsync` broadcasts to all active users (in-app only). | No way to notify only approvers or only the requester. |
---
## 3. Design overview
```
Requester ──create──► PendingApproval (level 1)
│ approve (user A ≠ requester)
▼
PendingSecondApproval (level 2)
│ approve (user B ≠ requester, ≠ A)
▼
Approved → effect applied in the same transaction
(stock posted + GL / prices written + history)
Reject at either level ──► Rejected (terminal; nothing applied)
Requester may Withdraw while still pending ──► Cancelled
```
- **One shared approval engine** (`ApprovalService`) holds the rules: who may approve, the two-person rule,
and the audit rows. The two document services call it, and only apply their effect when it says
"fully approved".
- **Nothing changes stock or prices until the final approval.** Pending documents are only requests.
- **Re-check at final approval.** Stock may have moved since the request. A decrease that would now make
stock negative fails at approval time with a clear message, and the document stays pending.
---
## 4. Shared approval engine
### 4.1 Entity `DocumentApproval` (table `document_approvals`)
One row per decision, so the full trail is kept.
| Field | Notes |
|---|---|
| `DocumentApprovalId` | PK |
| `DocType` | `ADJ` (stock adjustment) or `PRICE` (price adjustment) |
| `DocId` | the document's id |
| `Level` | 1 or 2 |
| `Decision` | `Approved` / `Rejected` |
| `DecidedBy`, `DecidedAt` | the user and time |
| `Comment` | optional; required when rejecting |
### 4.2 `IApprovalService`
```csharp
// Validates the caller may decide at the document's current level, records the row,
// and returns where the document stands now.
Task<ApprovalOutcome> DecideAsync(string docType, int docId, int requestedBy, int currentLevel,
bool approve, string? comment, CancellationToken ct);
// ApprovalOutcome: NextLevel(2) | FullyApproved | Rejected
Task<IReadOnlyList<ApprovalStepDto>> HistoryAsync(string docType, int docId, CancellationToken ct);
```
**Rules enforced in one place:**
1. The caller has the `Approve` action on the document's nav code (`stock.adjustments` or `products.price-adjustments`).
2. The caller is not the requester (`ApprovalForbidden`, 403).
3. At level 2, the caller is not the level-1 approver (`ApprovalSamePerson`, 403).
4. The document is at the expected level (`ApprovalNotPending`, 409). This also stops two people approving the same level at once (row-version check).
5. **Optional, per settings (§7):** level 2 is limited to particular roles (for example, Manager or Admin).
**Admin bypass:** none. Even an Admin can't approve their own request or both levels (see §8, decision 5).
---
## 5. Stock adjustments
### 5.1 Status flow
`AdjustmentStatus` becomes: `Draft`, `PendingApproval`, `PendingSecondApproval`, `Posted`, `Rejected`, `Cancelled`.
Existing rows stay `Posted`.
### 5.2 Changes to `StockAdjustment`
- Add `RequestedBy` (the existing `CreatedBy`), `ApprovedLevel1By/At`, `ApprovedLevel2By/At`, `RejectedBy/At`, `RejectReason`.
- Add `PostedAt`. Today the ledger and GL use the creation time; they will use the **posting time**.
- **Inbound serials:** today, new serials are created at once from `SerialNos`. While a request is pending, the
typed serial numbers are stored on the line (new `StockAdjustmentLineSerialNo` rows or a JSON column) and the
serial records are created only when it posts.
- **Outbound serials** are validated when the request is made and **re-validated at posting**. A serial sold in the
meantime fails the approval with a message naming that serial.
### 5.3 `AdjustmentService`
| Method | Behaviour |
|---|---|
| `CreateAsync` | Validates as today (reason code, items, serials), saves the header and lines as `PendingApproval`. **No** stock movement and no GL. Notifies approvers. |
| `ApproveAsync(id, comment)` | `ApprovalService.DecideAsync`. On level 1: status becomes `PendingSecondApproval` and level-2 approvers are notified. On full approval: in one transaction, run today's posting code (`IStockMutator.ApplyAsync`, serial moves, `PostAdjustmentJournalAsync`), then set `Posted` and notify the requester. If posting fails (for example, negative stock), nothing is saved and the status stays `PendingSecondApproval`. |
| `RejectAsync(id, reason)` | Records the rejection, sets `Rejected` and notifies the requester. |
| `CancelAsync(id)` | Requester only, while pending: sets `Cancelled`. |
The posting code moves into a private `PostAsync(StockAdjustment)` so that create, approve and stock counts
(§8, decision 3) all share it.
### 5.4 API (`/api/v1/stock-adjustments`)
| Method | Route | Permission |
|---|---|---|
| POST | `/` | `stock.adjustments` with `Create` (now creates a **request**) |
| POST | `/{id}/approve` | `stock.adjustments` with `Approve` |
| POST | `/{id}/reject` | `stock.adjustments` with `Approve` |
| POST | `/{id}/cancel` | `stock.adjustments` (requester only, checked in the service) |
| GET | `/{id}` | now also returns the approval history and pending level |
| GET | `/` | new `status` filter |
---
## 6. Price adjustments (new document)
### 6.1 Entities
**`PriceAdjustment`** (`price_adjustments`): `PriceAdjustmentId`, `DocNo` (`PADJ-…`), `Reason`, `EffectiveDate`
(defaults to the approval date), `Status` (same flow as §5.1), the requested/approved/rejected stamps, `RowVersion`.
**`PriceAdjustmentLine`**: `ItemId`, plus the **old** values (snapshot when the request is made) and the **new** values for
`SellingMinPrice`, `SellingMaxPrice` and `SellingFlatPrice`.
**`ItemPriceHistory`** (`item_price_history`): one row per applied change. It holds the item, old and new values,
the `PriceAdjustmentId`, the date applied, and both approvers. This answers "what was the price on day X, and who
approved it?"
### 6.2 Rules
- **Validation when the request is made:** each item exists and is active; min ≤ flat ≤ max where given; at least one value differs from the current price.
- **One pending request per item:** a second pending request for the same item is rejected (`PriceAdjustmentItemPending`), so two requests can't overwrite each other.
- **At final approval, in one transaction:**
- If an item's current price no longer matches the request's "old" snapshot (someone changed it in between), that approval fails with `PriceAdjustmentStale`, and the requester raises a new request.
- Otherwise, write the new prices to `Item` and add an `ItemPriceHistory` row.
- **Effective date in the future (optional, §8 decision 6):** the request is approved but applied by a daily job on that date.
### 6.3 Closing the direct-edit route
Selling prices in the item **Edit dialog** become read-only, with a "Request price change" button that opens the
price-adjustment form pre-filled for that item. On the backend, `ItemService.UpdateAsync` stops changing price
fields: it rejects a change with `PriceChangeRequiresApproval`, or ignores the price fields (see §8, decision 4).
Prices set when an item is **first created** do not need approval (§8, decision 4).
### 6.4 API (`/api/v1/price-adjustments`)
List, get (with history), create, approve, reject and cancel, using the same permission pattern as §5.4 on a
new nav code `products.price-adjustments`.
There is also `GET /api/v1/items/{id}/price-history`.
---
## 7. Approval settings
A singleton `ApprovalConfig`, edited under **Settings → Approvals**:
| Setting | Default | Purpose |
|---|---|---|
| `StockAdjustment.Levels` | 2 | 0 = post immediately (today's behaviour), 1 = single approval, 2 = dual approval |
| `StockAdjustment.Level2ThresholdValue` | 0 | Adjustments whose absolute stock value is below this need only one approval (0 = always two) |
| `PriceAdjustment.Levels` | 2 | as above |
| `PriceAdjustment.Level2ThresholdPercent` | 0 | Changes smaller than this % need only one approval (0 = always two) |
| `Level2Roles` | empty | When set, only these roles can give the second approval |
---
## 8. Open decisions
| # | Question | Recommended default |
|---|---|---|
| 1 | **Who can approve:** anyone with the `Approve` action on the screen, or fixed roles? | Anyone with `Approve`, with `Level2Roles` (§7) available to restrict level 2 to managers. |
| 2 | **Thresholds:** dual approval for every adjustment, or only above a value? | Always two by default; thresholds configurable (§7). |
| 3 | **Stock counts:** should posting a count also go through dual approval? It creates an adjustment. | Yes. A count post creates its variance adjustment as `PendingApproval`, otherwise counts become a way around approval. |
| 4 | **Item edit and item creation:** block price edits in the Edit dialog? Do initial prices on a new item need approval? | Block in the Edit dialog (use "Request price change"). Initial prices on creation don't need approval. |
| 5 | **Admin bypass:** can an Admin approve their own request, or both levels? | No. The two-person rule applies to everyone. |
| 6 | **Future effective dates** for price changes? | Not in v1. A change applies when it is approved. |
| 7 | **Price adjustment scope:** selling prices only, or also stock cost (inventory revaluation)? | Selling prices only. Cost revaluation touches FIFO layers and GL and should be its own document. |
| 8 | **Expiry:** should a request still pending after N days be cancelled automatically? | No auto-expiry. The Approvals inbox shows its age. |
---
## 9. Notifications
`INotificationService` gets a targeted method next to the broadcast one:
```csharp
Task NotifyUsersAsync(IEnumerable<int> userIds, string moduleKey, string title, string message, string? entityType, int? entityId, CancellationToken ct);
```
New modules (in-app on by default, through a seed migration like `EnableLowStockAlerts`):
| Key | Sent to |
|---|---|
| `approval.pending` | users who can approve at the document's current level (excluding the requester and the level-1 approver) |
| `approval.approved` | the requester, when fully approved |
| `approval.rejected` | the requester, with the reason |
---
## 10. Frontend
- **Approvals inbox** (`/dashboard/approvals`, a sidebar badge with the count):
- Lists pending stock and price adjustments waiting for **me**, with their age, level (1/2) and requester.
- Approve / Reject (reject asks for a reason).
- **Stock → Adjustments:** status badges (Pending 1/2, Posted, Rejected, Cancelled); the detail page shows the approval timeline and Approve / Reject / Cancel buttons by role. The New form's button reads "Submit for approval".
- **Wastage:** the same status badges; recorded wastage shows "Pending approval" until posted.
- **Products → Price Adjustments** (new):
- The list.
- The **new** form: pick items, see current prices, enter new prices, reason.
- The detail page with the timeline.
- **Item page:** the prices in the Edit dialog are read-only, with "Request price change"; a new "Price history" panel.
- **Settings → Approvals:** the §7 settings.
---
## 11. Migration & rollout
- **Migration `AddDualApproval`:**
- Creates the `document_approvals`, `price_adjustments`, `price_adjustment_lines`, `item_price_history` and `approval_config` tables.
- Adds the new columns on `stock_adjustments`.
- Seeds the nav item and permission for `products.price-adjustments` and the notification modules.
- **Existing adjustments** stay `Posted`, with no approval rows.
- **Roll-out safety:** ship with `Levels` defaulting to 2, but tell admins to grant the `Approve` action to at least
**two** users per branch before deploying. Otherwise nothing can ever be approved.
---
## 12. Phases
1. **Approval engine:** `DocumentApproval`, `ApprovalService`, targeted notifications, `ApprovalConfig`.
2. **Stock adjustments:** status flow, deferred posting (including inbound serials), approve/reject/cancel endpoints; wastage and counts on the same path.
3. **Price adjustments:** entities, service, endpoints, price history, item-edit lock.
4. **Frontend:** Approvals inbox, adjustment/wastage updates, Price Adjustments pages, Settings → Approvals.
5. **Tests and docs** (§13), plus `PROGRESS.md` updates.
---
## 13. Verification
**Backend tests:**
- **Approval engine:**
- The requester can't approve.
- The same person can't approve both levels.
- A user without `Approve` is refused.
- Approving the wrong level is refused.
- A rejection needs a reason.
- **Stock adjustments:**
- Nothing moves until the second approval.
- After full approval, the ledger, GL and serials match today's immediate posting.
- An approval that would make stock negative fails and the adjustment stays pending.
- A rejected adjustment never posts.
- The value threshold drops it to one level.
- **Price adjustments:**
- Prices change only after the second approval, and a history row is written.
- The stale-price check works.
- A second pending request for the same item is refused.
- The item edit can no longer change prices.
- **Counts:** posting a count creates a pending adjustment, not a posted one.
**Manual checks:** a full two-user approval in the UI for each document, the notification for each step, and the Approvals inbox count.
+299
View File
@@ -0,0 +1,299 @@
# 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.