Add store management workflows and KYC order gating
This commit is contained in:
@@ -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<decimal>("StoreManagement:UnverifiedKycOrderThreshold");
|
||||
if (request.TotalAmount > threshold) return null;
|
||||
}
|
||||
|
||||
var order = new Models.Order
|
||||
{
|
||||
FK_StoreId = request.StoreId,
|
||||
|
||||
@@ -7,4 +7,5 @@ public interface IStoreRepository
|
||||
Task AddAsync(Models.Store entity);
|
||||
void Update(Models.Store entity);
|
||||
void Remove(Models.Store entity);
|
||||
Task<List<Models.Order>> GetOrdersForDashboardAsync(Guid storeId, DateTime periodStart, DateTime periodEndExclusive);
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,4 +22,9 @@ public class StoreRepository(AppDBContext context) : IStoreRepository
|
||||
entity.IsActive = false;
|
||||
context.Stores.Update(entity);
|
||||
}
|
||||
|
||||
public async Task<List<Models.Order>> 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();
|
||||
}
|
||||
|
||||
@@ -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<List<StoreResponse>> 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<StoreResponse?> 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<StoreResponse> 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<StoreResponse?> 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<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
@@ -106,4 +112,131 @@ public class StoreService(IStoreRepository repo, IUnitOfWork uow, ICurrentUserSe
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<StoreResponse?> 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<StoreResponse?> 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<OutstandingBalanceResponse?> 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<StoreResponse?> 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<StoreResponse?> 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<List<StoreVisitHistoryItem>?> 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<SchemeEligibilityResponse?> 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<StoreDashboardResponse?> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
"Issuer": "Distribution.Api",
|
||||
"Audience": "Distribution.Api",
|
||||
"ExpiryMinutes": 120
|
||||
},
|
||||
"StoreManagement": {
|
||||
"UnverifiedKycOrderThreshold": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user