Add store management workflows and KYC order gating

This commit is contained in:
2026-08-20 13:08:00 +05:30
parent c1961ebea2
commit e88668beca
8 changed files with 465 additions and 37 deletions
+215 -32
View File
@@ -1,22 +1,32 @@
# 12 — Store Management (Beyond CRUD)
**Status**: Not Started
**Depends On**: 00 — Existing Gap Fixes
**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 covers everything in
[mainPlan.md §4](./mainPlan.md#4-store-outlet-management) that isn't
plain CRUD: KYC, credit, segmentation, and dashboarding.
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).
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` are read-only integration points into
modules that don'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). Implement them now
with the query shape defined below; wire the actual join once those modules land — return an empty
list / `eligible: true` (no active schemes to check) until then, guarded by a `NotImplementedYet`
style short-circuit, not a broken query.
---
@@ -24,51 +34,224 @@ gap #8 in [00_existing_gaps.md](./00_existing_gaps.md).
### Store — New Fields
`CreditLimit` (decimal), `OutstandingBalance` (decimal, derived/cached from Finance ledger once
[19_finance_accounting.md](./19_finance_accounting.md) exists), `Segment` (string), `KycStatus`
(`Pending`, `Verified`, `Rejected`).
```
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".
├── KycStatus (enum) — Pending | Verified | Rejected. Default Pending.
```
### KYC Document
### New Enum — KycStatus
`StoreId`, `DocumentType`, `FileReference`, `UploadedAt` — file storage mechanism TBD (out of scope
for this step's data model, just the record).
```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"
├── 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
| Function reference | Endpoint intent |
|---|---|
| `createStore(storeData, kycDocs)` | Extends existing `POST /api/stores` with doc upload |
| `verifyStoreKyc(storeId)` | `PUT /api/stores/{id}/kyc/verify` |
| `updateStoreCreditLimit(storeId, newLimit, approverId)` | `PUT /api/stores/{id}/credit-limit` |
| `getStoreOutstandingBalance(storeId)` | `GET /api/stores/{id}/outstanding-balance` |
| `classifyStore(storeId, segment)` | `PUT /api/stores/{id}/segment` |
| `deactivateStore(storeId, reason)` | Extends existing `DELETE /api/stores/{id}` with a reason field |
| `getStoreVisitHistory(storeId)` | `GET /api/stores/{id}/visit-history` — reads from [11_route_sales_management.md](./11_route_sales_management.md) |
| `checkStoreSchemeEligibility(storeId, schemeId)` | `GET /api/stores/{id}/schemes/{schemeId}/eligibility` — depends on [17_promotions_schemes.md](./17_promotions_schemes.md) |
| `getStoreSalesDashboard(storeId, period)` | `GET /api/stores/{id}/dashboard?period=...` |
### 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/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document |
| GET | /api/stores/{id}/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 | Returns `{ eligible: true, reason: null }` until Step 17 wiring lands |
| 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/{id}/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
- `updateStoreCreditLimit` requires an approver at Supervisor level or above.
- A store with `KycStatus != Verified` should not be able to place orders above a configurable
threshold (or at all) — confirm exact rule with the user before implementing.
- `deactivateStore` blocks new orders against it, matching the existing `IsActive` soft-delete
filter already used in `OrderService`.
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
- Is `OutstandingBalance` a cached/denormalized field updated on invoice/payment events, or always
computed live from Finance records?
- What document types are required for KYC, and is file storage local, blob, or third-party?
- 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
> Fill this section in during planning discussion before writing any code.
### 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)
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/{id}/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)
```
</content>