# 12 — Store Management (Beyond CRUD) **Status**: Done — migration applied to the database **Depends On**: 00 — Existing Gap Fixes, 10 — Region Management, 11 — Route & Sales Management --- ## Goal Store CRUD already exists (`Features/Store`). This step adds everything in [mainPlan.md §4](./mainPlan.md#4-store-outlet-management) that isn't plain CRUD: credit control, KYC verification, segmentation, outstanding balance, visit history, scheme eligibility, and a sales dashboard. --- ## Background / Context `Store` currently has no credit limit, outstanding balance, segment, or KYC fields — flagged as gap #8 in [00_existing_gaps.md](./00_existing_gaps.md). Credit fields exist informally today as `OneTimeAmount` / `CreditAmount`, which stay as-is; this step adds the real credit-limit + outstanding-balance + KYC-gated-ordering flow on top. `getStoreVisitHistory` and `checkStoreSchemeEligibility` were originally read-only integration points into modules that didn't exist yet ([11_route_sales_management.md](./11_route_sales_management.md) for visits, [17_promotions_schemes.md](./17_promotions_schemes.md) for schemes) — both shipped as placeholders (empty list / `eligible: true`) guarded by a `NotImplementedYet`-style short-circuit. **Update (2026-08-21, after 17_promotions_schemes.md landed):** `checkStoreSchemeEligibility` is now wired for real. `StoreService.CheckSchemeEligibilityAsync` calls `SchemeService.CheckStoreScopeEligibilityAsync(storeId, schemeId)`, which checks the scheme's region/segment/date-range scope against the store (no order context, since this endpoint takes no `orderId` — that's a *scope* eligibility check, not the full order-line rule evaluation that `GET /api/schemes/eligible?storeId=...&orderId=...` does). `getStoreVisitHistory` is still an empty list — [11_route_sales_management.md](./11_route_sales_management.md) remains Not Started. --- ## Data Model Changes ### Store — New Fields ``` Store (existing, add columns) ├── CreditLimit (decimal) — default 0. Max outstanding allowed before new orders are blocked. ├── OutstandingBalance (decimal) — default 0. Cached/denormalized. Updated by Finance module │ (step 19) on invoice/payment events via UpdateOutstandingBalanceAsync. │ Until Finance exists, only moved by manual adjustment endpoint. ├── Segment (string?) — free text, e.g. "Gold", "Silver", "Key Account", "High Risk". ├── KycStatus (enum) — Pending | Verified | Rejected. Default Pending. ``` ### New Enum — KycStatus ```csharp public enum KycStatus { Pending, Verified, Rejected } ``` ### New Entity — StoreKycDocument ``` StoreKycDocument ├── Id (Guid) — PK, from Template ├── FK_StoreId (Guid) — required ├── DocumentType (string) — free text, e.g. "GST Certificate", "PAN Card", "Shop License,NIC" ├── FileReference (string) — relative path under wwwroot/uploads/kyc/{storeId}/ ├── UploadedAt (DateTime) ├── IsActive, CreatedAt — from Template ``` ### AppSettings — New Config Key ``` StoreManagement:UnverifiedKycOrderThreshold (decimal) — default 0. Max single-order value a store with KycStatus != Verified may place. 0 disables ordering entirely for unverified stores; set higher to allow small orders through. ``` --- ## Migrations One migration needed: 1. **AddStoreCreditAndKyc** — adds `CreditLimit`, `OutstandingBalance`, `Segment`, `KycStatus` columns to `Stores`; creates `StoreKycDocuments` table. --- ## API Endpoints ### Existing (unchanged) | Method | Route | Permission | Notes | |---|---|---|---| | GET | /api/stores | stores.view | | | GET | /api/stores/{id} | stores.view | | | POST | /api/stores | stores.create | | | PUT | /api/stores/{id} | stores.update | | | DELETE | /api/stores/{id} | stores.delete | Soft delete | ### New | Method | Route | Permission | Notes | |---|---|---|---| | POST | /api/stores/{storeId:guid}/kyc-documents | stores.update | Multipart upload, appends a KYC document | | GET | /api/stores/{storeId:guid}/kyc-documents | stores.view | Lists documents for a store | | PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` | | PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above | | PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit | | GET | /api/stores/{id}/outstanding-balance | stores.view | Returns cached value + CreditLimit + available credit | | PUT | /api/stores/{id}/segment | stores.update | Body: `{ "segment": "Gold" }` | | GET | /api/stores/{id}/visit-history | stores.view | Paginated. Empty list until Step 11 wiring lands | | GET | /api/stores/{id}/schemes/{schemeId}/eligibility | stores.view | Wired to Step 17 — returns real region/segment/date-range eligibility for the store against that scheme | | GET | /api/stores/{id}/dashboard?period=... | stores.view | Aggregates order count/value for the period from existing Order tables | --- ## Request / Response Shapes ### POST `/api/stores/{storeId:guid}/kyc-documents` (multipart/form-data) Fields: `documentType` (string), `file` (binary). Response: ```json { "id": "...", "storeId": "...", "documentType": "GST Certificate", "fileReference": "uploads/kyc/{storeId}/{guid}.pdf", "uploadedAt": "2026-08-20T10:00:00" } ``` ### PUT `/api/stores/{id}/kyc/verify` request ```json { "status": "Verified", "notes": "GST + PAN checked against ERP records" } ``` ### PUT `/api/stores/{id}/credit-limit` request ```json { "newLimit": 50000, "approverId": "..." } ``` `approverId` must resolve to a user with `RoleLevel >= 20` (Supervisor or above); enforced server-side from the authenticated caller, not trusted from the body — the field is echoed back for audit display only. ### GET `/api/stores/{id}/outstanding-balance` response ```json { "storeId": "...", "creditLimit": 50000, "outstandingBalance": 32000, "availableCredit": 18000 } ``` ### GET `/api/stores/{id}/dashboard` response ```json { "storeId": "...", "period": "2026-08", "orderCount": 12, "totalOrderValue": 145000, "lastOrderDate": "2026-08-18T09:00:00" } ``` --- ## Business Rules 1. `updateStoreCreditLimit` requires the authenticated caller to have `RoleLevel >= 20` (Supervisor or above) — enforced by the `stores.credit.update` policy plus a role-level check in the service, same pattern as `StoreService.HasGlobalScope`. 2. A store with `KycStatus != Verified` may only place orders up to `StoreManagement:UnverifiedKycOrderThreshold`. `OrderService` must check this at order creation time; orders exceeding the threshold are rejected with a 400. This mirrors the stock-ledger pattern of rejecting at the point of the write, not after. 3. `deactivateStore` (existing `DELETE /api/stores/{id}`) blocks new orders against the store, matching the existing `IsActive` soft-delete filter already used in `OrderService`. No separate reason field is added — soft delete already covers it via the existing `Template.IsActive` flag. 4. `OutstandingBalance` is never edited directly by client-supplied arbitrary values outside the dedicated endpoint — always through `UpdateOutstandingBalanceAsync`, so Finance (step 19) has a single integration point to call into later. 5. KYC documents are append-only from the API's perspective — no update endpoint. To replace a document, upload a new one; old ones stay for audit trail (soft-deletable via `IsActive` if ever needed). 6. Scope rules for all new endpoints follow the existing `StoreService` pattern: global-scope roles (`RoleLevel >= 50`) see everything, others are checked via `IScopeAccessService.HasStoreScopeAsync`. --- ## Permissions to Add to Seed New permission codes to add to the existing seed: ``` stores.kyc.verify, stores.credit.update ``` (`stores.view`, `stores.create`, `stores.update`, `stores.delete` already exist.) Role assignments: - **SalesRep (10)**: stores.view (unchanged) - **Supervisor (20)**: stores.view, stores.update, stores.credit.update - **AreaManager (30)**: stores.view, stores.update, stores.credit.update, stores.kyc.verify - **AreaSupervisor (40)**: all store permissions - **RegionalManager (50)**: all store permissions - **RegionalAgency (60)**: all store permissions --- ## Open Questions - None. Resolved in planning discussion (2026-08-20): OutstandingBalance is cached/denormalized; KYC document type is free text with local disk storage; unverified stores are gated by a configurable order-value threshold rather than a hard block. --- ## Implementation Notes ### File structure created / touched ``` Features/ Store/ StoreController.cs (extended — credit-limit, balance, kyc/verify, segment, visit-history, scheme-eligibility, dashboard endpoints) StoreDTOs.cs (extended — new request/response records) StoreService.cs (extended — new methods, RoleLevel >= 20 credit-approver check; CheckSchemeEligibilityAsync now depends on Features/Scheme's SchemeService for the real region/segment/date-range check) IStoreRepository.cs (extended — GetOrdersForDashboardAsync) StoreRepository.cs (extended) Kyc/ StoreKycDocumentController.cs StoreKycDocumentDTOs.cs StoreKycDocumentService.cs IStoreKycDocumentRepository.cs StoreKycDocumentRepository.cs Order/ OrderService.cs (extended — KycStatus + UnverifiedKycOrderThreshold gate in CreateAsync) Models/ Store.cs (add CreditLimit, OutstandingBalance, Segment, KycStatus columns) StoreKycDocument.cs (new) Enums.cs (add KycStatus enum) Migrations/ AddStoreCreditAndKyc appsettings.json add "StoreManagement": { "UnverifiedKycOrderThreshold": 0 } Program.cs register IStoreKycDocumentRepository, StoreKycDocumentRepository, StoreKycDocumentService ``` KYC documents live as a sub-namespace `Features/Store/Kyc/` rather than a top-level feature, matching the one-feature-per-folder convention (`Features/Store`, `Features/Stock`) — they only ever belong to a Store and have no independent lifecycle. ### KYC upload logic (pseudo) ``` POST /api/stores/{storeId:guid}/kyc-documents — multipart (documentType, file): 1. Load Store by id, reject if missing/inactive or caller lacks store scope 2. Ensure wwwroot/uploads/kyc/{storeId}/ exists 3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}" 4. Insert StoreKycDocument row, commit in the same UoW transaction ``` ### Order-creation KYC gate (pseudo) ``` OrderService.CreateAsync — before inserting the order: 1. Load Store by request.StoreId 2. If Store.KycStatus != Verified: threshold = config["StoreManagement:UnverifiedKycOrderThreshold"] if request.TotalAmount > threshold: reject (return null → 403 at controller) ```