diff --git a/Distribution/Features/Order/OrderService.cs b/Distribution/Features/Order/OrderService.cs index 3ebb0db..d7d6fb4 100644 --- a/Distribution/Features/Order/OrderService.cs +++ b/Distribution/Features/Order/OrderService.cs @@ -1,9 +1,18 @@ +using Distribution.Features.Store; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; +using Distribution.Models; +using Microsoft.Extensions.Configuration; namespace Distribution.Features.Order; -public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess) +public class OrderService( + IOrderRepository repo, + IStoreRepository storeRepo, + IUnitOfWork uow, + ICurrentUserService currentUser, + IScopeAccessService scopeAccess, + IConfiguration configuration) { private const int GlobalScopeRoleLevel = 50; @@ -63,6 +72,14 @@ public class OrderService(IOrderRepository repo, IUnitOfWork uow, ICurrentUserSe { if (!await HasStoreScopeAsync(request.StoreId)) return null; + var store = await storeRepo.GetByIdAsync(request.StoreId); + if (store is null || !store.IsActive) return null; + if (store.KycStatus != KycStatus.Verified) + { + var threshold = configuration.GetValue("StoreManagement:UnverifiedKycOrderThreshold"); + if (request.TotalAmount > threshold) return null; + } + var order = new Models.Order { FK_StoreId = request.StoreId, diff --git a/Distribution/Features/Store/IStoreRepository.cs b/Distribution/Features/Store/IStoreRepository.cs index a9667d9..45416a8 100644 --- a/Distribution/Features/Store/IStoreRepository.cs +++ b/Distribution/Features/Store/IStoreRepository.cs @@ -7,4 +7,5 @@ public interface IStoreRepository Task AddAsync(Models.Store entity); void Update(Models.Store entity); void Remove(Models.Store entity); + Task> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive); } diff --git a/Distribution/Features/Store/StoreController.cs b/Distribution/Features/Store/StoreController.cs index 9cfb5a9..78da8a7 100644 --- a/Distribution/Features/Store/StoreController.cs +++ b/Distribution/Features/Store/StoreController.cs @@ -44,4 +44,68 @@ public class StoreController(StoreService service) : ControllerBase var deleted = await service.DeleteAsync(id, ct); return deleted ? NoContent() : NotFound(); } + + [HttpPut("{id:guid}/credit-limit")] + [Authorize(Policy = "stores.credit.update")] + public async Task UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct) + { + var result = await service.UpdateCreditLimitAsync(id, request, ct); + return result is null ? Forbid() : Ok(result); + } + + [HttpPut("{id:guid}/outstanding-balance")] + [Authorize(Policy = "stores.credit.update")] + public async Task UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct) + { + var result = await service.UpdateOutstandingBalanceAsync(id, request, ct); + return result is null ? Forbid() : Ok(result); + } + + [HttpGet("{id:guid}/outstanding-balance")] + [Authorize(Policy = "stores.view")] + public async Task GetOutstandingBalance(Guid id) + { + var result = await service.GetOutstandingBalanceAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("{id:guid}/kyc/verify")] + [Authorize(Policy = "stores.kyc.verify")] + public async Task VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct) + { + var result = await service.VerifyKycAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPut("{id:guid}/segment")] + [Authorize(Policy = "stores.update")] + public async Task ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct) + { + var result = await service.ClassifySegmentAsync(id, request, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/visit-history")] + [Authorize(Policy = "stores.view")] + public async Task GetVisitHistory(Guid id) + { + var result = await service.GetVisitHistoryAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")] + [Authorize(Policy = "stores.view")] + public async Task CheckSchemeEligibility(Guid id, Guid schemeId) + { + var result = await service.CheckSchemeEligibilityAsync(id, schemeId); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id:guid}/dashboard")] + [Authorize(Policy = "stores.view")] + public async Task GetDashboard(Guid id, [FromQuery] string period) + { + var result = await service.GetDashboardAsync(id, period); + return result is null ? NotFound() : Ok(result); + } } diff --git a/Distribution/Features/Store/StoreDTOs.cs b/Distribution/Features/Store/StoreDTOs.cs index d502aad..4e59e66 100644 --- a/Distribution/Features/Store/StoreDTOs.cs +++ b/Distribution/Features/Store/StoreDTOs.cs @@ -1,3 +1,5 @@ +using Distribution.Models; + namespace Distribution.Features.Store; public record CreateStoreRequest( @@ -30,5 +32,25 @@ public record StoreResponse( decimal Longitude, decimal OneTimeAmount, decimal CreditAmount, + decimal CreditLimit, + decimal OutstandingBalance, + string? Segment, + KycStatus KycStatus, bool IsActive, DateTime CreatedAt); + +public record UpdateCreditLimitRequest(decimal NewLimit, Guid ApproverId); + +public record UpdateOutstandingBalanceRequest(decimal NewBalance); + +public record VerifyKycRequest(KycStatus Status, string? Notes); + +public record ClassifySegmentRequest(string Segment); + +public record OutstandingBalanceResponse(Guid StoreId, decimal CreditLimit, decimal OutstandingBalance, decimal AvailableCredit); + +public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUserId, string Outcome); + +public record SchemeEligibilityResponse(bool Eligible, string? Reason); + +public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate); diff --git a/Distribution/Features/Store/StoreRepository.cs b/Distribution/Features/Store/StoreRepository.cs index 9db5742..30ff1aa 100644 --- a/Distribution/Features/Store/StoreRepository.cs +++ b/Distribution/Features/Store/StoreRepository.cs @@ -22,4 +22,9 @@ public class StoreRepository(AppDBContext context) : IStoreRepository entity.IsActive = false; context.Stores.Update(entity); } + + public async Task> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive) => + await context.Orders + .Where(o => o.FK_StoreId == storeId && o.IsActive && o.OrderDate >= periodStart && o.OrderDate < periodEndExclusive) + .ToListAsync(); } diff --git a/Distribution/Features/Store/StoreService.cs b/Distribution/Features/Store/StoreService.cs index 2db19eb..0849fb9 100644 --- a/Distribution/Features/Store/StoreService.cs +++ b/Distribution/Features/Store/StoreService.cs @@ -1,11 +1,13 @@ using Distribution.Infra.Authorization; using Distribution.Infra.UoW; +using Distribution.Models; namespace Distribution.Features.Store; public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserService currentUser, IScopeAccessService scopeAccess) { private const int GlobalScopeRoleLevel = 50; + private const int CreditApproverRoleLevel = 20; private bool HasGlobalScope => currentUser.RoleLevel >= GlobalScopeRoleLevel; @@ -15,6 +17,10 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe return await scopeAccess.HasStoreScopeAsync(currentUser.UserId.Value, storeId); } + private static StoreResponse ToResponse(Models.Store s) => + new(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount, + s.CreditLimit, s.OutstandingBalance, s.Segment, s.KycStatus, s.IsActive, s.CreatedAt); + public async Task> GetAllAsync() { var stores = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList(); @@ -23,7 +29,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet(); stores = stores.Where(s => accessibleStoreIds.Contains(s.Id)).ToList(); } - return stores.Select(s => new StoreResponse(s.Id, s.FK_RouteId, s.OwnerName, s.StoreName, s.StoreAddress, s.Latitude, s.Longitude, s.OneTimeAmount, s.CreditAmount, s.IsActive, s.CreatedAt)).ToList(); + return stores.Select(ToResponse).ToList(); } public async Task GetByIdAsync(Guid id) @@ -31,7 +37,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe var store = await repo.GetByIdAsync(id); if (store is null || !store.IsActive) return null; if (!await HasStoreScopeAsync(id)) return null; - return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt); + return ToResponse(store); } public async Task CreateAsync(CreateStoreRequest request, CancellationToken ct = default) @@ -58,7 +64,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe await uow.RollbackAsync(ct); throw; } - return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt); + return ToResponse(store); } public async Task UpdateAsync(Guid id, UpdateStoreRequest request, CancellationToken ct = default) @@ -85,7 +91,7 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe await uow.RollbackAsync(ct); throw; } - return new StoreResponse(store.Id, store.FK_RouteId, store.OwnerName, store.StoreName, store.StoreAddress, store.Latitude, store.Longitude, store.OneTimeAmount, store.CreditAmount, store.IsActive, store.CreatedAt); + return ToResponse(store); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) @@ -106,4 +112,131 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe } return true; } + + public async Task UpdateCreditLimitAsync(Guid id, UpdateCreditLimitRequest request, CancellationToken ct = default) + { + if (currentUser.RoleLevel < CreditApproverRoleLevel) return null; + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + store.CreditLimit = request.NewLimit; + await uow.BeginAsync(ct); + try + { + repo.Update(store); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return ToResponse(store); + } + + public async Task UpdateOutstandingBalanceAsync(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct = default) + { + if (currentUser.RoleLevel < CreditApproverRoleLevel) return null; + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + store.OutstandingBalance = request.NewBalance; + await uow.BeginAsync(ct); + try + { + repo.Update(store); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return ToResponse(store); + } + + public async Task GetOutstandingBalanceAsync(Guid id) + { + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + return new OutstandingBalanceResponse(store.Id, store.CreditLimit, store.OutstandingBalance, store.CreditLimit - store.OutstandingBalance); + } + + public async Task VerifyKycAsync(Guid id, VerifyKycRequest request, CancellationToken ct = default) + { + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + store.KycStatus = request.Status; + await uow.BeginAsync(ct); + try + { + repo.Update(store); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return ToResponse(store); + } + + public async Task ClassifySegmentAsync(Guid id, ClassifySegmentRequest request, CancellationToken ct = default) + { + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + store.Segment = request.Segment; + await uow.BeginAsync(ct); + try + { + repo.Update(store); + await uow.CommitAsync(ct); + } + catch + { + await uow.RollbackAsync(ct); + throw; + } + return ToResponse(store); + } + + public async Task?> GetVisitHistoryAsync(Guid id) + { + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + // Wired to Step 11 (Route & Sales Management) once ride/visit tracking exists. + return []; + } + + public async Task CheckSchemeEligibilityAsync(Guid id, Guid schemeId) + { + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + // Wired to Step 17 (Promotions & Schemes) once scheme rules exist. + return new SchemeEligibilityResponse(true, null); + } + + public async Task GetDashboardAsync(Guid id, string period) + { + var store = await repo.GetByIdAsync(id); + if (store is null || !store.IsActive) return null; + if (!await HasStoreScopeAsync(id)) return null; + + if (!DateTime.TryParseExact(period, "yyyy-MM", null, System.Globalization.DateTimeStyles.None, out var periodStart)) + periodStart = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1); + var periodEndExclusive = periodStart.AddMonths(1); + + var orders = await repo.GetOrdersForDashboardAsync(id, periodStart, periodEndExclusive); + return new StoreDashboardResponse( + id, + periodStart.ToString("yyyy-MM"), + orders.Count, + orders.Sum(o => o.TotalAmount), + orders.Count == 0 ? null : orders.Max(o => o.OrderDate)); + } } diff --git a/Distribution/appsettings.json b/Distribution/appsettings.json index 685c27c..e217f92 100644 --- a/Distribution/appsettings.json +++ b/Distribution/appsettings.json @@ -14,5 +14,8 @@ "Issuer": "Distribution.Api", "Audience": "Distribution.Api", "ExpiryMinutes": 120 + }, + "StoreManagement": { + "UnverifiedKycOrderThreshold": 0 } } diff --git a/Distribution/docs/12_store_management.md b/Distribution/docs/12_store_management.md index 42753ec..0a13b92 100644 --- a/Distribution/docs/12_store_management.md +++ b/Distribution/docs/12_store_management.md @@ -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) +``` +