# 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 DecideAsync(string docType, int docId, int requestedBy, int currentLevel, bool approve, string? comment, CancellationToken ct); // ApprovalOutcome: NextLevel(2) | FullyApproved | Rejected Task> 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 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.