price adjustment and stock adjustments
This commit is contained in:
@@ -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. |
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user