11 KiB
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 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. 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
for visits, 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 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
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:
- AddStoreCreditAndKyc — adds
CreditLimit,OutstandingBalance,Segment,KycStatuscolumns toStores; createsStoreKycDocumentstable.
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 | Wired to Step 11 — returns a plain array (not paginated) read from RouteVisitLog |
| 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:
{ "id": "...", "storeId": "...", "documentType": "GST Certificate", "fileReference": "uploads/kyc/{storeId}/{guid}.pdf", "uploadedAt": "2026-08-20T10:00:00" }
PUT /api/stores/{id}/kyc/verify request
{ "status": "Verified", "notes": "GST + PAN checked against ERP records" }
PUT /api/stores/{id}/credit-limit request
{ "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
{ "storeId": "...", "creditLimit": 50000, "outstandingBalance": 32000, "availableCredit": 18000 }
GET /api/stores/{id}/dashboard response
{
"storeId": "...",
"period": "2026-08",
"orderCount": 12,
"totalOrderValue": 145000,
"lastOrderDate": "2026-08-18T09:00:00"
}
Business Rules
updateStoreCreditLimitrequires the authenticated caller to haveRoleLevel >= 20(Supervisor or above) — enforced by thestores.credit.updatepolicy plus a role-level check in the service, same pattern asStoreService.HasGlobalScope.- A store with
KycStatus != Verifiedmay only place orders up toStoreManagement:UnverifiedKycOrderThreshold.OrderServicemust 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. deactivateStore(existingDELETE /api/stores/{id}) blocks new orders against the store, matching the existingIsActivesoft-delete filter already used inOrderService. No separate reason field is added — soft delete already covers it via the existingTemplate.IsActiveflag.OutstandingBalanceis never edited directly by client-supplied arbitrary values outside the dedicated endpoint — always throughUpdateOutstandingBalanceAsync, so Finance (step 19) has a single integration point to call into later.- 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
IsActiveif ever needed). - Scope rules for all new endpoints follow the existing
StoreServicepattern: global-scope roles (RoleLevel >= 50) see everything, others are checked viaIScopeAccessService.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)